# Ailu documentation (@ailu-ai/graph-sdk 1.28.0) > The full Ailu documentation as one file, for AI coding agents. The short index is /llms.txt. > Every TypeScript example below is typechecked and run by the SDK's test suite. # What is Ailu Ailu is a TypeScript SDK for building AI agent workflows as **graphs**. A graph is a set of steps (nodes) connected by edges. Some steps are your code, some are LLM agents, and some are **human approval gates**: the run stops there until a person says yes. Three things set Ailu apart: - **Governed.** An agent can be made to stop before it calls a sensitive tool (a refund, a payment, a deletion). A named human approves, and only then does the tool run. Decisions can be signed and checked later. - **Resumable.** The engine saves a checkpoint after every step. A run that stops for a human, a timer or an external event picks up exactly where it stopped. - **Replayable.** A run can record every model call. Later, anyone can replay it from the record, without calling a model, and check that it reaches the same result. The engine is written in Rust and ships as a prebuilt native addon, so you only install an npm package. These docs describe `@ailu-ai/graph-sdk` 1.28.0. ## When to use Ailu Use Ailu when an agent's actions matter: it moves money, changes customer data, sends messages, or must leave an audit trail. Ailu is also a good fit for long workflows that wait for people or external systems. For a one-off chat completion, you don't need a graph: call a model directly with [`model.invoke()`](./guides/agents.md#call-a-model-directly). ## What's stable | Area | Status | | --- | --- | | TypeScript SDK: graphs, agents, tools, human gates, streaming, sub-agents | Stable | | Governance: tool approval, attestation, replay, secret redaction | Stable | | Resume in another process (`runCatalogGraph` / `resumeCatalogGraph`) | Stable | | Python SDK | Partial: validate and compile graphs, run components and prebuilt agents. No graph runs yet. | | YAML graphs and the `ailu` CLI | Graph shape only: validate, inspect, compile | | C ABI and other language bindings | Experimental | ## Next steps 1. [Install](./install.md) the SDK. 2. Run the [quickstart](./quickstart.md): an agent, a human gate, and a resume, in one file. 3. Pick a [guide](./guides/graphs.md) for the task at hand. Building with an AI coding agent? Point it at [`/llms.txt`](pathname:///llms.txt) and [For AI agents](./reference/for-ai-agents.md). # Install ## Requirements - Node.js 22 or later. - macOS (x64 or Apple silicon), Linux with glibc (x64 or arm64), or Windows x64. The Rust engine ships prebuilt for these platforms. Alpine (musl) and Windows on ARM are not supported yet. ## Start a new project ```bash npm create @ailu-ai@latest my-app cd my-app npm install npm start ``` `npm start` runs `app.ts`: a small graph that stops at a human gate and resumes. `npm run inspect` opens the same graph in the browser inspector. ## Add Ailu to an existing project ```bash npm install @ailu-ai/graph-sdk ``` The SDK is an ES module. Run TypeScript files with [tsx](https://tsx.is): `npx tsx app.ts`. To check that the engine loaded on your machine: ```ts console.log(rustEngineAvailable()); // true ``` If it prints `false`, your platform has no prebuilt engine. `compile()` then throws `RustEngineRequiredError`; there is no slower fallback. ## Set an API key Agents call a model provider. Set the key of the provider you use as an environment variable: | Provider | `model.` | Variable | | --- | --- | --- | | Anthropic | `model.anthropic(...)` | `ANTHROPIC_API_KEY` | | OpenAI | `model.openai(...)` | `OPENAI_API_KEY` | | Google Gemini | `model.gemini(...)` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | | Mistral | `model.mistral(...)` | `MISTRAL_API_KEY` | | OpenRouter | `model.openrouter(...)` | `OPENROUTER_API_KEY` | | MiniMax | `model.minimax(...)` | `MINIMAX_API_KEY` | | Hugging Face | `model.huggingface(...)` | `HF_TOKEN` or `HUGGINGFACE_API_KEY` | | Ollama (local) | `model.ollama(...)` | `AILU_USE_OLLAMA=1` | | LM Studio (local) | `model.lmstudio(...)` | `AILU_USE_LMSTUDIO=1` | A missing key is an error that names the variable to set. See [Models and providers](./reference/models.md) for tiers and custom endpoints. ## Run without an API key Set `AILU_LLM_MOCK=1` to run offline. Every agent without a key then answers from the engine's deterministic mock: it calls each of its tools once, then answers `done`. Use it to try the examples, and in tests and CI. ```bash AILU_LLM_MOCK=1 npx tsx app.ts ``` :::caution The mock is only used when you ask for it. Without `AILU_LLM_MOCK=1`, a run with no key fails. ::: ## Other tools - **CLI**: `npm install -g @ailu-ai/cli` gives you `ailu validate`, `ailu compile` and `ailu diff` for YAML graphs. See [YAML and the CLI](./guides/yaml-and-cli.md). - **Python**: `pip install ailu`. It validates and compiles graphs and runs prebuilt agents; it does not run graphs yet. See [Python and other languages](./guides/python.md). Next: the [quickstart](./quickstart.md). # Quickstart In five minutes you will build a graph where an agent drafts a reply to a customer, the run stops for a human to review the draft, and then finishes. ## 1. Create the project ```bash mkdir refund-desk && cd refund-desk npm init -y && npm pkg set type=module npm install @ailu-ai/graph-sdk ``` ## 2. Write the graph Save this as `app.ts`: ```ts title="app.ts" import { createGraph, finalAnswer, model } from "@ailu-ai/graph-sdk"; const app = createGraph({ name: "refund-desk" }) .channel("request", { type: "string", default: "" }) .channel("reply", { type: "string", default: "" }) .agentNode("draft", { model: model.anthropic("claude-sonnet-4-6"), prompt: { system: "Draft a one-sentence reply to the customer's refund request." } }) .humanGate("review") // the run stops here until a human approves .node("send", async (_input, state) => ({ reply: finalAnswer(state.channels.agentResult) })) .edge("draft", "review") .edge("review", "send") .compile(); // 1. Run: the agent drafts, then the run suspends at the gate. const paused = await app.run({ request: "Please refund order #1024." }); console.log(paused.status); // "suspended" console.log(finalAnswer(paused.channels.agentResult)); // the draft a human reviews // 2. A human approved: resume from the checkpoint. const done = await app.resume(paused.runId); console.log(done.status); // "completed" console.log(done.channels.reply); ``` What each part does: - **`channel`** declares a piece of run state. Nodes read channels and return the ones they change. - **`agentNode`** runs an LLM agent. It writes its result to the `agentResult` channel; `finalAnswer()` reads the answer out of it. - **`humanGate`** stops the run. `run()` returns with status `"suspended"`. - **`resume`** continues from the checkpoint saved at the gate. ## 3. Run it With an Anthropic key: ```bash ANTHROPIC_API_KEY=sk-ant-... npx tsx app.ts ``` Or offline, with the deterministic mock: ```bash AILU_LLM_MOCK=1 npx tsx app.ts ``` You should see `suspended`, the draft, then `completed` and the reply. ## What you just used In a real app, the gap between `run()` and `resume()` is where a person reviews the draft in your UI. Your server keeps the `CompiledGraph` and calls `resume(runId)` when they approve. To resume in a different process, after a deploy or a restart, see [Long-running runs](./guides/long-running.md). ## Next - Give the agent a tool that needs approval: [Tools and approval](./guides/tools.md). - Understand nodes, channels and edges: [Graphs](./guides/graphs.md). - See a complete app: [Governed refund agent](./examples/refund-agent.md). # Graphs A graph has three parts: - **Channels**: the run's state. Each has a name, a type label and a default value. - **Nodes**: the steps. A node reads the state and returns the channels it changes. - **Edges**: the order of the steps. ```ts import { createGraph } from "@ailu-ai/graph-sdk"; const app = createGraph({ name: "greeter" }) // Channels are the run's state: a name, a type and a default. .channel("name", { type: "string", default: "" }) .channel("greeting", { type: "string", default: "" }) // A node reads the state and returns the channels it changes. .node("greet", async (_input, state) => ({ greeting: `Hello, ${state.channels.name}!` })) .node("shout", async (_input, state) => ({ greeting: state.channels.greeting.toUpperCase() })) // Edges set the order. The first node added is the entry. .edge("greet", "shout") .compile(); const result = await app.run({ name: "Ada" }); console.log(result.status); // "completed" console.log(result.channels.greeting); // "HELLO, ADA!" ``` `createGraph` returns a builder. `compile()` checks the graph (unknown nodes, missing handlers, dangling edges) and returns a `CompiledGraph` you can `run()` many times. Each run gets its own state and its own `runId`. The first node you add is the entry. To start elsewhere, call `.entry("nodeId")`. ## Read and write state A node handler receives `(input, state)`. Read channels from `state.channels`; they are typed from your `.channel()` declarations. Return an object with the channels to update. Return `{}` to change nothing. `run(data)` seeds channels from `data`, and every channel you don't pass starts at its default. `run()` resolves with the final state: `status`, `runId`, `currentNodeId` and `channels`. ## Route with conditions A conditional edge is taken when its predicate returns `true`. The predicate has a name, so the graph stays data you can inspect, store and render; the function stays in your code. ```ts import { createGraph } from "@ailu-ai/graph-sdk"; const app = createGraph({ name: "triage" }) .channel("amount", { type: "number", default: 0 }) .channel("route", { type: "string", default: "" }) .node("intake", async () => ({})) .node("auto", async () => ({ route: "auto-approved" })) .node("manual", async () => ({ route: "sent to a human" })) // A conditional edge is taken when its named predicate returns true. .conditionalEdge("intake", "manual", "isLarge", (state) => state.channels.amount > 1000) .conditionalEdge("intake", "auto", "isSmall", (state) => state.channels.amount <= 1000) .compile(); console.log((await app.run({ amount: 50 })).channels.route); // "auto-approved" console.log((await app.run({ amount: 5000 })).channels.route); // "sent to a human" ``` When a node finishes, the engine looks at its outgoing edges in the order you added them and follows the first one that applies: a plain edge, or a conditional edge whose predicate returns `true`. If none applies, the run ends there. A predicate that throws fails the run rather than silently taking another branch. ## Combine updates with reducers By default a node's value **replaces** the channel's value. A reducer changes that: | Reducer | Effect | | --- | --- | | `replace` (default) | The new value replaces the old one. | | `append` | The value is added to the end of the list; an array adds each of its items. Useful for logs and messages. | | `merge` | The object's keys are written over the existing object, one level deep. | ```ts import { createGraph } from "@ailu-ai/graph-sdk"; const app = createGraph({ name: "audit-log" }) // "append" adds each update to the list instead of replacing it. .channel("log", { type: "string[]", reducer: "append", default: [] as string[] }) .node("open", async () => ({ log: ["opened"] })) .node("check", async () => ({ log: ["checked"] })) .node("close", async () => ({ log: ["closed"] })) .edge("open", "check") .edge("check", "close") .compile(); console.log((await app.run()).channels.log); // ["opened", "checked", "closed"] ``` ## Retry, then branch on failure A node can retry before it fails the run. An error edge sends the run somewhere else once the retries are spent, instead of failing it. ```ts import { createGraph } from "@ailu-ai/graph-sdk"; let calls = 0; const app = createGraph({ name: "flaky-call" }) .channel("result", { type: "string", default: "" }) // Retry a failing node up to 3 times, 100 ms apart... .node("fetch", { retryPolicy: { maxAttempts: 3, backoffMs: 100 }, handler: async () => { calls += 1; throw new Error("upstream unavailable"); } }) // ...then take the error edge instead of failing the run. .node("fallback", async () => ({ result: "served from cache" })) .errorEdge("fetch", "fallback") .compile(); const out = await app.run(); console.log(out.status, out.channels.result); // "completed" "served from cache" ``` A thrown error that is not caught by an error edge fails the run: `status` is `"failed"` and a `run_failed` event says why. ## Keep a channel out of logs Mark a channel `noLog: true` and its value is masked in every run event and log. It is still saved in checkpoints, so the run can resume. See [Governance](./governance.md#keep-secrets-out-of-logs). ## Next - Add an LLM step: [Agents and models](./agents.md). - Stop for a person: [Human approval](./human-approval.md). - Run steps in parallel: [Multi-agent](./multi-agent.md). # Agents and models An agent node runs an LLM in a loop: it reads the run's state, may call tools, and ends with an answer. ```ts import { createGraph, finalAnswer, model } from "@ailu-ai/graph-sdk"; const app = createGraph({ name: "assistant" }) .channel("question", { type: "string", default: "" }) .agentNode("answer", { model: model.anthropic("claude-sonnet-4-6"), prompt: { system: "Answer in one sentence." } }) .compile(); const out = await app.run({ question: "What is a checkpoint?" }); console.log(finalAnswer(out.channels.agentResult)); ``` ## What the agent sees The agent gets its system prompt and the run's state: every channel, as JSON. Show it less with `visibleChannels`, which keeps the prompt small and keeps unrelated data away from the model: ```ts builder.agentNode("answer", { model: model.anthropic("claude-sonnet-4-6"), prompt: { system: "Answer the question." }, visibleChannels: ["question", "context"] }); ``` ## Read the answer The agent writes its result to the `agentResult` channel. Use `outputChannel` to pick another name, which you need when a graph has several agents. | Field | What it holds | | --- | --- | | `reasoning` | The agent's steps; the answer follows the last `final:` marker. Read it with `finalAnswer(result)`. | | `structuredOutput` | The parsed JSON when you asked for [structured output](#get-structured-output). | | `usage` | Tokens used across the agent's model calls. | | `approvalRequests` | Tool calls waiting for a human. See [Tools and approval](./tools.md). | | `todos` | The agent's plan, when it uses the todo tool. See [Deep agents](./deep-agents.md). | ## Choose a model Pass a model to `model:`. Everything below is exported as `model` from `@ailu-ai/graph-sdk`: ```ts import { model } from "@ailu-ai/graph-sdk"; // A provider and a model id. The key comes from the provider's variable (OPENAI_API_KEY, ...). const gpt = model.openai("gpt-4o"); const claude = model.anthropic("claude-sonnet-4-6"); // A capability tier: the provider is picked from the keys you have set. const cheap = model.fast; const careful = model.frontier; // A provider and a tier. const mistralFast = model.mistral.fast; // The same thing as a string, handy in config files. const fromConfig = model("openai:gpt-4o"); // Any OpenAI-compatible server: vLLM, LM Studio, a gateway. The key is read only from apiKeyEnv. const local = model.openaiCompatible({ baseURL: "http://localhost:8000/v1", model: "llama-3.1-8b-instruct", apiKeyEnv: "MY_GATEWAY_KEY" }); ``` With a tier (`model.fast`, `model.balanced`, `model.frontier`), the engine picks the provider from the keys you have set, in this order: Anthropic, OpenAI, Gemini, Mistral, OpenRouter, MiniMax, Hugging Face. The model each tier maps to is listed in [Models and providers](../reference/models.md). Each provider reads its key from one environment variable (see [Install](../install.md#set-an-api-key)). If the key is missing, the run fails with an error that names it. Set `AILU_LLM_MOCK=1` to run offline on the deterministic mock. ## Get structured output Give the agent a JSON Schema. The provider is asked for JSON that matches it, and the parsed value lands in `structuredOutput`: ```ts import { createGraph, model } from "@ailu-ai/graph-sdk"; const ticketSchema = { type: "object", properties: { category: { type: "string", enum: ["billing", "bug", "other"] }, urgent: { type: "boolean" } }, required: ["category", "urgent"], additionalProperties: false }; const app = createGraph({ name: "ticket-triage" }) .channel("ticket", { type: "string", default: "" }) .agentNode("classify", { model: model.openai("gpt-4o"), prompt: { system: "Classify the support ticket." }, // The agent must answer with JSON that matches the schema. middleware: [{ kind: "structuredOutput", params: { schema: ticketSchema, mode: "lenient" } }] }) .compile(); const out = await app.run({ ticket: "I was charged twice this month!" }); const ticket = out.channels.agentResult.structuredOutput as { category: string; urgent: boolean } | undefined; console.log(ticket); // { category: "billing", urgent: true } ``` With `mode: "required"` (the default), the agent retries when the answer doesn't match, then reports an error in its result. With `"lenient"`, it keeps the plain answer and leaves `structuredOutput` empty. ## Control cost and length | Option | Effect | | --- | --- | | `maxIterations` | Caps the agent's loop (model call → tool calls → model call …). | | `middleware: [{ kind: "terse" }]` | Asks for short answers. Saves output tokens on prose; don't use it for code. | | `middleware: [{ kind: "contextBudget", params: { chars: 8000 } }]` | Trims the state the agent is shown. | | `middleware: [{ kind: "compress" }]` | Compresses long prompts through an [LLMLingua](https://github.com/microsoft/LLMLingua) service at `AILU_LLMLINGUA_URL`. Does nothing when it is not set. | ## Call a model directly For a single completion you don't need a graph: ```ts import { model } from "@ailu-ai/graph-sdk"; // One call, no graph. The answer text is on `.content`. const reply = await model.anthropic("claude-sonnet-4-6").invoke("Say hello in French."); console.log(reply.content, reply.usage); ``` And for typed JSON, `.output()`: ```ts // Ask for JSON that matches a schema, and get it parsed. export const classify = async (ticket: string) => { const triage = model.openai("gpt-4o").output({ jsonSchema: { type: "object", properties: { category: { type: "string", enum: ["billing", "bug", "other"] } }, required: ["category"] }, parse: (value) => value as { category: "billing" | "bug" | "other" } }); const { parsed } = await triage.invoke(`Classify: ${ticket}`); return parsed.category; }; ``` ## Next - Let the agent act: [Tools and approval](./tools.md). - Watch tokens as they arrive: [Streaming](./streaming.md). - Several agents working together: [Multi-agent](./multi-agent.md). # Tools and approval A tool is a function an agent can call. You describe it (name, description, input schema) and register a handler. Mark it `requiresApproval: true` and the agent can't call it until a human says yes. ```ts import { createGraph, InMemoryToolRegistry, model, type ToolId } from "@ailu-ai/graph-sdk"; const refunds: string[] = []; // 1. Describe the tool. `jsonSchema` and `description` are what the model sees. const tools = new InMemoryToolRegistry(); tools.register( { id: "refund" as ToolId, name: "refund", description: "Refund an order. Use only when the customer asks for a refund.", jsonSchema: { type: "object", properties: { orderId: { type: "string" } }, required: ["orderId"] }, inputSchema: { parse: (value: unknown) => value as { orderId?: string } }, outputSchema: { parse: (value: unknown) => value as { ok: boolean } }, permissions: ["payments:write"], requiresApproval: true // a human must approve every call }, async (input) => { refunds.push(input.orderId ?? "unknown"); return { ok: true }; } ); // 2. Give the tool to an agent that stops when it wants to call a gated tool. const app = createGraph({ name: "support" }) .channel("request", { type: "string", default: "" }) .agentNode("assistant", { model: model.anthropic("claude-sonnet-4-6"), prompt: { system: "Help the customer. Use the refund tool when needed." }, tools, suspendForApproval: true }) .compile(); // 3. The run suspends before the refund runs. const paused = await app.run({ request: "Please refund order ORD-8830." }); console.log(paused.status); // "suspended" console.log(refunds.length); // 0: nothing ran yet // 4. A named human approves; the agent resumes and the refund runs. const done = await app.approveAndResume(paused.runId, { approvedTools: ["refund"], resolvedBy: "alice@example.com" }); console.log(done.status); // "completed" console.log(refunds.length); // 1 ``` ## Describe a tool `InMemoryToolRegistry.register(definition, handler)` takes: | Field | Purpose | | --- | --- | | `id`, `name` | The tool's identity. The model calls it by `name`. | | `description` | When to use it. The model reads this. | | `jsonSchema` | The JSON Schema of the input. The model reads this to build the call. | | `inputSchema`, `outputSchema` | Objects with a `parse(value)` method that validate the input and output in your code. A Zod schema fits. | | `permissions` | Labels for your own audit, such as `"payments:write"`. | | `requiresApproval` | `true` makes every call wait for a human. | The handler receives the parsed input and returns a JSON value, which the agent sees as the tool's result. Tools without `requiresApproval` run as soon as the agent calls them. ## Approve a call With `suspendForApproval: true`, an agent that wants a gated tool stops the run: 1. `run()` returns `status: "suspended"`. The agent's result lists what it wants in `approvalRequests`, for example `{ subject: "tool:refund", reason: "..." }`. `app.explain(runId).summary` says the same in one sentence. 2. Show the request to a person in your app. 3. When they approve, call `approveAndResume(runId, { approvedTools: ["refund"], resolvedBy: "" })`. The agent runs again and can now call `refund`. If they refuse, don't resume. The run stays suspended; you can discard it or keep it for the record. :::caution Who approves `resolvedBy` is required: take it from your authenticated session. It is recorded with the approval. The SDK refuses an empty value (`AILU_APPROVER_REQUIRED`), and the engine refuses a grant where the approver is the agent that asked. ::: ## Keep the handler honest The model chooses the tool's input. Treat it like user input: - validate it in `inputSchema.parse`; - check permissions in the handler (does this user own this order?); - keep handlers idempotent where you can: a resumed run may call a tool again. ## Record approvals as evidence To sign each decision and let an auditor check it later, see [Governance](./governance.md#sign-approval-decisions). To resume an approval in another process (after a deploy, or from a queue worker), see [Long-running runs](./long-running.md). ## Next - Stop the whole run, not just a tool: [Human approval](./human-approval.md). - Full app: [Governed refund agent](../examples/refund-agent.md). # Human approval A human gate is a node that stops the run. Everything before it has run and is checkpointed; nothing after it runs until you resume. ```ts import { createGraph } from "@ailu-ai/graph-sdk"; const app = createGraph({ name: "publish-flow" }) .channel("draft", { type: "string", default: "" }) .channel("published", { type: "boolean", default: false }) .node("write", async () => ({ draft: "Release notes for 2.0" })) .humanGate("review") .node("publish", async () => ({ published: true })) .edge("write", "review") .edge("review", "publish") .compile(); const paused = await app.run(); console.log(paused.status); // "suspended" console.log(paused.currentNodeId); // "review" // What is the run waiting for? Useful in a UI, a log, or for an AI agent. console.log(app.explain(paused.runId).summary); // After a human approves in your app, resume on the same CompiledGraph. const done = await app.resume(paused.runId); console.log(done.status, done.channels.published); // "completed" true ``` ## The approval flow in an app 1. **Run.** `run()` returns as soon as the run reaches the gate, with `status: "suspended"` and `currentNodeId` set to the gate. Save the `runId`. 2. **Show.** Display what needs review. The channels are on the returned state; `app.explain(runId)` describes the wait in words. 3. **Resume.** When a person approves, call `app.resume(runId)`. The run continues from the gate. `resume` works on the `CompiledGraph` instance that started the run, while your process is up. To resume after a restart or in another process, run the graph with `runCatalogGraph` and keep its state: see [Long-running runs](./long-running.md). ## Gate a tool, or gate the run? | You want to... | Use | | --- | --- | | Review a step's output before the run goes on (a draft, a plan, a price) | `humanGate` | | Approve a specific action an agent wants to take (a refund, a deletion) | a tool with `requiresApproval`. See [Tools and approval](./tools.md). | ## Several gates A graph can have as many gates as it needs. Each `resume` runs until the next gate or the end. A gate inside a [subgraph](./multi-agent.md#reuse-a-graph-as-a-node) suspends the parent run too, and `resume` on the parent continues the child. ## Next - Wait for a webhook or a date instead of a person: [Long-running runs](./long-running.md). - Sign each decision: [Governance](./governance.md). # Streaming `app.stream(input, mode)` runs the graph like `run()` and yields events as it goes. Pick the mode for what you want to show. Tokens as the model writes them: ```ts // Tokens as the model writes them. for await (const event of app.stream({ question: "What is a checkpoint?" }, "messages")) { if (event.type === "message_delta") process.stdout.write(event.delta); } ``` Progress, node by node: ```ts // One event per finished node, with the channels it changed. for await (const event of app.stream({ question: "What is a checkpoint?" }, "updates")) { if (event.type === "state_update") console.log(event.nodeId, Object.keys(event.delta)); } ``` ## Modes | Mode | Yields | Use it for | | --- | --- | --- | | `"messages"` | `message_delta`: `{ delta, nodeId, messageId }` for each token of each agent | A chat UI | | `"updates"` | `state_update`: `{ nodeId, delta }` when a node finishes | A progress list | | `"values"` | `state_value`: `{ state }`, the full state after each node | Debugging, dashboards | | `"debug"` | `debug`: every lifecycle event, wrapped | Tracing | `messageId` groups the tokens of one agent turn, so you can show several agents side by side. A stream only yields events of its own run, even when several runs of the same graph stream at once. Leaving the loop early with `break` is safe. ## Subscribe to events `app.onEvent(handler)` receives the lifecycle events of every run of the graph: `node_started`, `node_completed`, `run_suspended`, `run_completed`, `run_failed`, and more. It returns an unsubscribe function. The full list is in [Events](../reference/events.md); sending them to a tracing backend is covered in [Observability](./observability.md). ## Next - [Observability](./observability.md): traces, costs, the browser inspector. # Multi-agent Five building blocks cover most multi-agent designs. Pick the one that matches the shape of your problem. | You have... | Use | | --- | --- | | A fixed set of steps that can run at the same time | [`fanOut`](#run-steps-in-parallel) | | A list, and one agent per item | [`mapAgents`](#one-agent-per-item) | | A task to hand to an isolated sub-agent that reports back | [`taskNode`](#delegate-to-a-sub-agent) | | A graph you want to reuse as one step | [`subgraph`](#reuse-a-graph-as-a-node) | | A question worth several opinions | [`council`](#ask-a-council) | ## Run steps in parallel `fanOut(from, [branches], joinAt)` runs the branches at the same time, each from the same state, then continues at `joinAt` once all of them are done. Give each agent its own `outputChannel` so their results don't overwrite each other. ```ts import { createGraph, finalAnswer, model } from "@ailu-ai/graph-sdk"; const app = createGraph({ name: "pros-and-cons" }) .channel("decision", { type: "string", default: "" }) .channel("summary", { type: "string", default: "" }) .node("start", async () => ({})) // Two agents run in parallel on the same state. Each writes its own channel. .agentNode("pros", { model: model.fast, prompt: { system: "List the strongest argument FOR the decision." }, outputChannel: "pros" }) .agentNode("cons", { model: model.fast, prompt: { system: "List the strongest argument AGAINST the decision." }, outputChannel: "cons" }) .node("merge", async (_input, state) => ({ summary: `For: ${finalAnswer(state.channels.pros)} / Against: ${finalAnswer(state.channels.cons)}` })) // start fans out to both agents; the run continues at merge once both are done. .fanOut("start", ["pros", "cons"], "merge") .compile(); const out = await app.run({ decision: "Move the team to a four-day week" }); console.log(out.channels.summary); ``` Branch updates are applied in the order you list the branches, whichever finishes first, so the result is the same on every run. ## One agent per item `mapAgents` runs one sub-agent per item of a list channel, in parallel, and collects the results in input order. ```ts import { createGraph, finalAnswer, model, type AgentResult } from "@ailu-ai/graph-sdk"; const app = createGraph({ name: "review-files" }) .channel("files", { type: "json", default: [] as string[] }) // One sub-agent per item of `files`, run in parallel; results come back in input order. .mapAgents("review", { overChannel: "files", subAgent: { model: model.fast, prompt: { system: "Review this file. One sentence." } }, joinAt: "reviews" }) .compile(); const out = await app.run({ files: ["auth.ts", "billing.ts", "search.ts"] }); for (const review of out.channels.reviews as AgentResult[]) console.log(finalAnswer(review)); ``` With `suspendForApproval: true`, a sub-agent that wants a gated tool suspends the whole run, as a single agent would. ## Delegate to a sub-agent `taskNode` runs a sub-agent in its own child graph. It sees only the `objective` channel, and only its `report` comes back: the parent never sees its intermediate work, and it never sees the parent's other channels. ```ts import { createGraph, finalAnswer, model } from "@ailu-ai/graph-sdk"; const app = createGraph({ name: "research" }) .channel("objective", { type: "string", default: "" }) // A sub-agent in its own child graph: it sees only `objective`, // and only its report comes back (in `report`). .taskNode("dig", { subAgent: { model: model.balanced, prompt: { system: "Research the objective. Report in 3 bullets." } } }) .compile(); const out = await app.run({ objective: "Compare durable execution engines" }); console.log(finalAnswer(out.channels.report)); ``` Rename the channels with `objectiveChannel` and `reportChannel`. By default the report is kept short (`compress: true`); set `compress: false` for the full answer. ## Reuse a graph as a node `subgraph` embeds another builder as one node. The mappings list which channels cross the boundary, written `destination: source`. ```ts import { createGraph } from "@ailu-ai/graph-sdk"; // A child graph with its own channels... const normalize = createGraph({ name: "normalize" }) .channel("text", { type: "string", default: "" }) .channel("clean", { type: "string", default: "" }) .node("trim", async (_input, state) => ({ clean: state.channels.text.trim().toLowerCase() })); // ...used as one node of a parent graph. Mappings say which channels cross the boundary, // written destination: source. const app = createGraph({ name: "intake" }) .channel("raw", { type: "string", default: "" }) .channel("normalized", { type: "string", default: "" }) .subgraph("normalize", normalize, { inputMapping: { text: "raw" }, // child `text` <- parent `raw` outputMapping: { normalized: "clean" } // parent `normalized` <- child `clean` }) .compile(); console.log((await app.run({ raw: " HELLO World " })).channels.normalized); // "hello world" ``` The child shares the parent's run: a human gate inside it suspends the parent, and resuming the parent continues the child. ## Ask a council `council()` builds a complete graph: members answer in parallel, reviewers rank the answers without knowing who wrote them, and a chair writes the final answer from the ranking. It runs with `runCatalogGraph`. ```ts import { council, finalAnswer, model, runCatalogGraph, type AgentResult } from "@ailu-ai/graph-sdk"; const seat = (system: string) => ({ model: model.balanced, prompt: { system } }); const definition = council({ // Each member answers on its own... members: [seat("Answer as a lawyer."), seat("Answer as an engineer."), seat("Answer as a CFO.")], // ...reviewers rank the answers without knowing who wrote them (labels A, B, C)... reviewers: [seat("Rank the answers A, B, C from best to worst.")], // ...and a chair writes the final answer from the ranking. chair: seat("Write the final answer from the best-ranked answers."), humanGate: false // true: stop for a human before the chair decides }); const outcome = await runCatalogGraph(definition, { initialData: { query: "Should we open-source our SDK?" } }); console.log(finalAnswer(outcome.state.channels.answer as AgentResult)); ``` Each seat sees only what it needs: members see the question; reviewers see the question and the anonymized answers; the chair sees the answers and their ranking. The `fieldKey` channel maps each label back to its member for your audit trail. ## Next - An agent that plans and delegates on its own: [Deep agents](./deep-agents.md). - A complete example: [Parallel agents](../examples/parallel-agents.md). # Deep agents A deep agent works on a long task on its own: it writes a plan, reads and writes files, hands parts of the work to sub-agents, and keeps going for many turns. Ailu gives it these abilities with the same governance as any agent: file access follows a policy, and sensitive writes wait for a human. ```ts import { createGraph, InMemoryToolRegistry, model, writeTodosTool, type TodoItem } from "@ailu-ai/graph-sdk"; // The built-in planning tool: the agent keeps its plan as a todo list. const tools = new InMemoryToolRegistry(); tools.register(writeTodosTool.definition, writeTodosTool.handler); const app = createGraph({ name: "report-writer" }) .channel("brief", { type: "string", default: "" }) .channel("plan", { type: "json", default: [] as TodoItem[] }) .agentNode("writer", { model: model.anthropic("claude-sonnet-4-6"), prompt: { system: "Plan with writeTodos, then write the report to drafts/report.md." }, tools, todosChannel: "plan", // the plan is saved here after every turn enableFs: true, // read_file, write_file, ls, glob, grep, edit_file, ... scoped to this run maxIterations: 20 }) // What the agent may do with files. Paths no rule matches are read-only. .fsPolicy([ { glob: "drafts/**", verb: "write" }, { glob: "published/**", verb: "gate" }, // writing here needs a human approval { glob: "secrets/**", verb: "deny" } ]) .compile(); const out = await app.run({ brief: "Summarize Q3 incidents for the board." }); console.log(out.channels.plan); // [{ text, status }, ...] ``` ## Plan with todos `writeTodosTool` is a built-in tool: the agent calls it with its whole plan each time it changes (`{ text, status }` items, status `pending`, `in_progress` or `completed`). With `todosChannel`, the latest plan is saved in that channel after every turn, so you can show progress, and a resumed run keeps it. ## Work with files `enableFs: true` gives the agent file tools: `read_file`, `write_file`, `edit_file`, `delete_file`, `move_file`, `ls`, `glob` and `grep`. The files live in a virtual filesystem that belongs to the run: the agent can't reach your disk. `.fsPolicy([...])` sets what the agent may do, path by path. The most specific rule wins, and paths no rule matches are read-only. | Verb | Effect | | --- | --- | | `read` | Read only. | | `write` | Read and write. | | `gate` | Writes stop the run for a human approval, pinned to that exact path and content. | | `deny` | No access at all. | To keep the files outside the process, point the engine at a storage service with `AILU_FS_BACKEND_URL` (and `AILU_FS_BACKEND_TOKEN`). If that service is unreachable, file operations fail rather than silently falling back. ## Delegate Give a deep agent sub-agents with [`taskNode`](./multi-agent.md#delegate-to-a-sub-agent) (one isolated helper that reports back) or [`mapAgents`](./multi-agent.md#one-agent-per-item) (one helper per item). ## Presets `profile` sets sensible defaults in one word. Explicit options always win. | Profile | Model tier | Behaviour | | --- | --- | --- | | `"fast"` | fast | Short answers, tight context budget. For high-volume, low-stakes steps. | | `"frontier-careful"` | frontier | Roomy context, self-review, stops for approval. For high-stakes reasoning. | | `"governed-deep"` | balanced | Short answers, 12k context budget, self-review, stops for approval, files enabled. The one-line deep agent. | ## Memory across runs `memory: { namespace: "customer-42" }` lets an agent recall notes from earlier runs in the same namespace, and save new ones. :::note In the open-source SDK, memory lives in the process (it is lost on restart) and recall uses a simple built-in embedder, not a semantic model. Use it for development and demos. ::: ## Skills Skills are reusable playbooks (instructions, sometimes tools) an agent loads when relevant. You pass the skill records per run, on the catalog runner: ```ts await runCatalogGraph(app.definition, { skills: [/* SkillRecord, ... */] }); ``` and set `skills: { namespace, required: ["refund-policy@1"] }` on the agent. Skills are not loaded by `app.run()` yet. ## Next - A complete deep agent: [Deep agent example](../examples/deep-agent.md). # RAG and retrieval Retrieval-augmented generation (RAG) has two steps: find the documents that match the question, then let an agent answer from them. In Ailu each step is a node. The retriever writes its matches to a channel; the agent reads that channel with the rest of the state. ## Start with keyword search BM25 ranks documents by the words they share with the question. It needs no API key and no vector store, and it is hard to beat on short, precise corpora (FAQs, policies, product docs). ```ts import { components, createGraph, finalAnswer, model } from "@ailu-ai/graph-sdk"; const docs = [ { id: "refunds", content: "Refunds are issued within 5 business days of approval." }, { id: "shipping", content: "Orders ship within 24 hours on weekdays." }, { id: "returns", content: "Items can be returned within 30 days." } ]; const app = createGraph({ name: "support-faq" }) .channel("question", { type: "string", default: "" }) .channel("context", { type: "json", default: [] as Array<{ id: string; content: string; score: number }> }) // BM25 keyword search: no API key, no vector store. Top matches land in `context`. .component("retrieve", components.bm25Retriever({ query: "question", into: "context", k: 2, docs })) // The agent sees the question and the retrieved context. .agentNode("answer", { model: model.anthropic("claude-sonnet-4-6"), prompt: { system: "Answer from the context only. Say 'I don't know' otherwise." } }) .edge("retrieve", "answer") .compile(); const out = await app.run({ question: "How long do refunds take?" }); console.log(out.channels.context); // [{ id: "refunds", content: "...", score: ... }, ...] console.log(finalAnswer(out.channels.agentResult)); ``` Each match is `{ id, content, score }`. ## Search by meaning with embeddings When questions use different words than your documents, embed both and search by similarity. `semanticRetriever` embeds the documents once, stores the vectors, and returns the closest matches. ```ts import { createEmbeddings, createGraph, createVectorStore, model, semanticRetriever } from "@ailu-ai/graph-sdk"; export const buildHelpdesk = () => createGraph({ name: "helpdesk" }) .channel("question", { type: "string", default: "" }) .node( "retrieve", semanticRetriever({ queryFrom: "question", into: "context", k: 4, docs: [ { id: "refunds", content: "Refunds are issued within 5 business days of approval." }, { id: "shipping", content: "Orders ship within 24 hours on weekdays." } ], // Embeddings need OPENAI_API_KEY (or provider: "mistral" with MISTRAL_API_KEY). embeddings: createEmbeddings({ provider: "openai" }), // Vectors are kept in a JSON file, so documents are embedded once. store: createVectorStore({ persistPath: "./vectors.json" }) }) ) .agentNode("answer", { model: model.openai("gpt-4o"), prompt: { system: "Answer from the context only." } }) .edge("retrieve", "answer") .compile(); ``` - `createEmbeddings({ provider: "openai" | "mistral" })` reads `OPENAI_API_KEY` or `MISTRAL_API_KEY`, and throws when the key is missing. - `createVectorStore({ persistPath })` keeps the vectors in a JSON file. Omit `persistPath` for an in-memory store. For large corpora, implement the `VectorStore` interface over your database and pass it as `store`. ## Improve the ranking | Component | What it does | | --- | --- | | `components.reranker` | Re-scores the matches against the question with a cross-encoder at `AILU_RERANK_ENDPOINT`. Without it, keeps the order. | | `components.mergeRanker` | Fuses the results of several retrievers (keyword + semantic) into one ranking, with Reciprocal Rank Fusion. | | `components.documentSplitter` | Splits a long text into chunks, by characters or sentences, before you index it. | | `components.answerBuilder` | Assembles the final answer text, with numbered citations if you want them. | All components are listed with their parameters in [Components](../reference/components.md). ## Next - A complete question-answering app: [Document Q&A](../examples/document-qa.md). # Long-running runs A run can stop for a person, a date or an external event, and continue later. This page shows where the state lives while it waits, and how to pick it up again. ## Two ways to run a graph | | `app.run()` | `runCatalogGraph(app.definition)` | | --- | --- | --- | | State between steps | In the `CompiledGraph`, in memory | Returned to you as plain JSON | | Resume | `app.resume(runId)`, same instance, same process | `resumeCatalogGraph(definition, state)`, anywhere | | Nodes that run | All | Agents, components, human gates, subgraphs, `mapAgents`. Your own `.node()` functions and conditional-edge functions do not run. | | Also | Streaming, timers and signals | Cancellation, record and replay | Use `app.run()` while a run finishes within one process. Use the catalog runner when a run must survive a restart or move between machines. ## Resume in another process `runCatalogGraph` returns the state when the run stops. Store it anywhere that holds JSON, and hand it to `resumeCatalogGraph` later: ```ts import { createGraph, model, resumeCatalogGraph, runCatalogGraph, type GraphState } from "@ailu-ai/graph-sdk"; const app = createGraph({ name: "contract-review" }) .channel("contract", { type: "string", default: "" }) .agentNode("review", { model: model.balanced, prompt: { system: "List the risky clauses." } }) .humanGate("legal-sign-off") .agentNode("summarize", { model: model.fast, prompt: { system: "Summarize the review for the customer." }, outputChannel: "summary" }) .edge("review", "legal-sign-off") .edge("legal-sign-off", "summarize") .compile(); // Process A: run until the gate, then save the state (a database row, a file, a queue message). const paused = await runCatalogGraph(app.definition, { initialData: { contract: "..." } }); console.log(paused.status); // "suspended" const saved = JSON.stringify(paused.state); // Process B, hours later, maybe on another machine: load the state and resume. const done = await resumeCatalogGraph(app.definition, JSON.parse(saved) as GraphState); console.log(done.status); // "completed" ``` To resume a tool approval this way, pass the approved tools in the resume options: `resumeCatalogGraph(definition, state, { approvedTools: [{ name: "refund", requestedBy: "assistant", resolvedBy: "alice@example.com" }], tools, approvalEngine })`. Pass your tool handlers again (`tools`) on every call: they are code, not state. With `approvalEngine`, the resume first checks that the engine approved what the run waits on (see [Governance](./governance.md#sign-approval-decisions)). ## Wait for an external event A node can suspend the run until your system delivers a named signal: a payment webhook, a message from another service, a manual action. ```ts import { createGraph, readSignal, waitForSignal } from "@ailu-ai/graph-sdk"; const app = createGraph({ name: "order" }) .channel("paid", { type: "boolean", default: false }) // The run suspends here until your system delivers the "payment" signal. .node("await-payment", async () => waitForSignal("payment")) .node("ship", async (_input, state) => ({ paid: readSignal(state, "payment") !== undefined })) .edge("await-payment", "ship") .compile(); const waiting = await app.run(); console.log(waiting.status); // "suspended" // Later, a webhook arrives: deliver the signal and the run continues. const done = await app.signal(waiting.runId, "payment", { amount: 42 }); console.log(done.status, done.channels.paid); // "completed" true ``` `waitForSignal(name, { wakeAt })` also wakes the run at `wakeAt` if the signal never comes. ## Wait until a date `sleepUntil(date)` suspends the run and records when to wake it. Ailu does not keep a clock running: your scheduler (a cron job, a delayed queue message) calls `resume` at that time. ```ts import { createGraph, readSuspendMeta, sleepUntil } from "@ailu-ai/graph-sdk"; const app = createGraph({ name: "reminder" }) .channel("sent", { type: "boolean", default: false }) // Suspend until a date. The engine records when to wake up; your scheduler resumes the run. .node("wait", async () => sleepUntil("2030-01-01T09:00:00Z")) .node("remind", async () => ({ sent: true })) .edge("wait", "remind") .compile(); const sleeping = await app.run(); console.log(readSuspendMeta(sleeping)); // { reason: "timer", wakeAt: "2030-01-01T09:00:00Z" } // At wakeAt, your scheduler (cron, queue, ...) resumes the run. const done = await app.resume(sleeping.runId); console.log(done.channels.sent); // true ``` `readSuspendMeta(state)` tells you why a run is suspended: `human-gate`, `interrupt` (a tool approval), `timer` or `signal`, with `wakeAt` or `awaitingSignal`. ## Cancel a run Pass an `AbortSignal` to the catalog runner. Aborting it stops the run at the next step: the node in flight finishes, its checkpoint is saved, and the run ends with status `"cancelled"`. A cancelled run can still be resumed or replayed. ```ts import { createGraph, model, runCatalogGraph } from "@ailu-ai/graph-sdk"; const app = createGraph({ name: "long-job" }) .agentNode("step1", { model: model.fast, prompt: { system: "Plan the job." }, outputChannel: "plan" }) .agentNode("step2", { model: model.fast, prompt: { system: "Do the job." }, outputChannel: "work" }) .edge("step1", "step2") .compile(); // Abort the controller to stop the run. The engine finishes the node in flight, // saves its checkpoint, and returns with status "cancelled". const controller = new AbortController(); const outcome = await runCatalogGraph(app.definition, { signal: controller.signal, onEvent: (event) => { if (event.type === "node_completed") controller.abort(); // e.g. the user clicked Stop } }); console.log(outcome.status); // "cancelled" ``` ## Next - A complete example: [Resume across processes](../examples/resume-across-processes.md). - Record a run to replay it later: [Governance](./governance.md#replay-a-run). # Governance Governance in Ailu answers three questions about every run: **who** allowed each sensitive action, **what** the agent saw and did, and **can we prove it** later. The building blocks: | Need | Tool | | --- | --- | | A person decides before an agent acts | [Tool approval](./tools.md) and [human gates](./human-approval.md) | | A record of each decision that can't be edited unnoticed | [Signed decisions](#sign-approval-decisions) | | Proof that a run happened as recorded | [Replay](#replay-a-run) | | No secrets or personal data sent to a model or written to logs | [Redaction](#redact-secrets-and-personal-data) and [`noLog`](#keep-secrets-out-of-logs) | ## Sign approval decisions An approval engine keeps the approval requests and who resolved them. An attestor signs each decision with Ed25519 and links it to the previous one, so removing or editing a record breaks the chain. ```ts import { Ed25519Attestor, InMemoryApprovalEngine, verifyChain, type NodeId, type RunId } from "@ailu-ai/graph-sdk"; const approvals = new InMemoryApprovalEngine(); // Keep the private key in your secret store; publish the public key to your auditors. const attestor = new Ed25519Attestor(); // An agent asked to refund; a named human approved it. const request = await approvals.request({ runId: "run-42" as RunId, nodeId: "assistant" as NodeId, requestedBy: "assistant", subject: { description: "refund order ORD-8830" } }); const approved = await approvals.approve(request.id, "alice@example.com"); // Sign each decision, chained to the previous one. const first = attestor.attest(approved, null); const records = [first]; // Anyone holding the records can check them. Also check that record.publicKey is your key. console.log(verifyChain(records)); // true ``` - `approvals.approve(id, user)` refuses a user who is also the requester (`ApprovalSelfApprovalError`), and refuses to resolve a request twice. - `verifyChain(records)` checks the links and every signature. It checks each signature against the public key stored in the record, so also compare that key with the one you published. - `InMemoryApprovalEngine` is for development. In production, implement the `ApprovalEngine` interface over your database. To file approval requests automatically when a run stops, run the graph with the catalog runner and pass the engine: `runCatalogGraph(app.definition, { approvalEngine, tools })`. Each gated tool call and each human gate gets a request you can list with `approvalEngine.getPending(runId)`; their ids are also saved in the run's state. Pass the same engine to `resumeCatalogGraph`. It checks the engine before anything runs, and throws `ApprovalNotGrantedError` if a request the run waits on is still pending, a human gate was rejected, or a tool in `approvedTools` isn't approved by the person the grant names. [Resume across processes](../examples/resume-across-processes.md) shows it. :::caution Without `approvalEngine`, `resumeCatalogGraph` doesn't check anything: it continues past a human gate. ::: Audit exports from Ailu Studio can be checked by anyone, offline: `npx @ailu-ai/verify capsule.json --key `. ## Replay a run With `AILU_LLM_RECORD=1`, the catalog runner records every model call and timestamp of the run. Store the recording with the run. Later, replay it: the engine re-runs the graph from its first state, feeding it the recorded model outputs instead of calling a model, and must reach the same result. ```ts import { docQaReferenceDefinition, replayCatalogGraph, runCatalogGraph } from "@ailu-ai/graph-sdk"; const definition = docQaReferenceDefinition(); // 1. Record: with AILU_LLM_RECORD=1 the engine journals every model call and timestamp. process.env.AILU_LLM_RECORD = "1"; const recorded = await runCatalogGraph(definition, { initialData: { question: "How does Ailu resume a run?", documents: "Ailu checkpoints after every node." } }); delete process.env.AILU_LLM_RECORD; // Store these two with the run: they are the evidence. const { entryState, replayJournal } = recorded; // 2. Replay, later and elsewhere: the run is re-derived from its entry state // using the recorded model outputs. No model is called. const replayed = await replayCatalogGraph(definition, entryState!, "audit-1", replayJournal!); console.log(JSON.stringify(replayed.state.channels.answer) === JSON.stringify(recorded.state.channels.answer)); // true ``` To compare the approval decisions of a replay with the signed chain, use `verifyReplayDecisions(attested, replayed)`: it returns `ok` and the list of mismatches. ## Redact secrets and personal data Before any text reaches a model, the engine scans it for secrets (API keys, tokens, private keys) and masks them. This is always on. Set `AILU_SECRETS_POLICY=block` to fail the call instead of masking. For personal data (names, emails, account numbers), point the engine at a redaction service with `AILU_PII_REDACTOR_URL`. The service receives the outgoing texts and returns them redacted. If it is unreachable, the text is sent unredacted unless you set `AILU_PII_REDACTOR_FAIL_CLOSED=1`, which is what you want in production. ## Keep secrets out of logs A channel marked `noLog: true` is masked in every run event, so it never reaches your logs or traces. It is still checkpointed, so the run can resume. ```ts import { createGraph, type RunEvent } from "@ailu-ai/graph-sdk"; const app = createGraph({ name: "kyc" }) // noLog: the value is checkpointed as usual but masked in every run event and log. .channel("passport", { type: "string", default: "", noLog: true }) .channel("verified", { type: "boolean", default: false }) .node("check", async (_input, state) => ({ verified: state.channels.passport.length > 0 })) .compile(); const events: RunEvent[] = []; app.onEvent((event) => events.push(event)); const out = await app.run({ passport: "X1234567" }); console.log(out.channels.verified); // true console.log(JSON.stringify(events).includes("X1234567")); // false ``` ## Next - Send traces and costs to your observability stack: [Observability](./observability.md). - Production settings: [Deploy to production](./production.md). # Observability ## Run events Every run emits lifecycle events. Subscribe with `onEvent`: ```ts import { createGraph } from "@ailu-ai/graph-sdk"; const app = createGraph({ name: "pipeline" }) .channel("n", { type: "number", default: 0 }) .node("a", async () => ({ n: 1 })) .node("b", async () => ({ n: 2 })) .edge("a", "b") .compile(); // Every run emits lifecycle events: node_started, node_completed, run_suspended, run_completed, ... const unsubscribe = app.onEvent((event) => console.log(event.type)); await app.run(); unsubscribe(); ``` The events and their fields are listed in [Events](../reference/events.md). A channel marked `noLog: true` is masked in all of them. ## Traces and costs `exportTracesToOtlp` sends one trace per run, with a span per node, to any OpenTelemetry (OTLP/HTTP) collector: Jaeger, Grafana Tempo, Honeycomb, Langfuse, LangSmith and others. ```ts // Send one trace per run (a span per node, with token cost) to any OTLP/HTTP collector: // Jaeger, Tempo, Honeycomb, Langfuse, LangSmith, ... const stop = exportTracesToOtlp(app, { endpoint: "http://localhost:4318/v1/traces", // or set AILU_OTEL_EXPORTER_URL serviceName: "refund-desk", fetchImpl: collector // omit in production: the global fetch is used }); await app.run(); stop(); // stop exporting ``` It traces the runs of that `app` (`run`, `resume`, `approveAndResume`, `signal`). For the catalog runner, pass `onEvent` to `runCatalogGraph` and forward the events to your tracer. Agent spans carry token usage and an estimated cost (`ailu.cost.usd`). The estimate uses a built-in price list; pass `priceBook` to use your own prices. `computeCost(usage, model)` gives the same estimate for one agent result's `usage`. ## Watch a run in the browser `serveInspector(app, input)` runs the graph once and serves a live view of it on `http://127.0.0.1:4517`: nodes light up as they run, and a suspended run shows what it waits for. It is a development tool and listens on the local machine only. ```ts const inspector = await serveInspector(app, { question: "What is a checkpoint?" }); console.log(inspector.url); ``` ## Debug a run **Where is it stuck?** `app.explain(runId)` returns the run's status, the node it stopped at, why, and the call that continues it. For a state you saved yourself, use `explainRun(state)`. **Why did it fail?** A failed run has `status: "failed"` and emits `run_failed` with the error. A node that throws is retried when it has a `retryPolicy`; each attempt emits `node_failed`. **Common errors:** | Error | Fix | | --- | --- | | `no API key for provider '...'` | Set the variable it names, or `AILU_LLM_MOCK=1` to run offline. | | `RustEngineRequiredError` | The native engine didn't load. See [Install](../install.md#requirements). | | `ResumeStateNotFoundError` | `resume` was called on another `CompiledGraph`, or after a restart. See [Long-running runs](./long-running.md). | | `GraphCompileError` | The graph is invalid. The message lists each problem with its code. | Every SDK error has a `code`, a `hint` with the fix, and a link to its entry in [Errors](../reference/errors.md). ## Next - [Deploy to production](./production.md). # Deploy to production Ailu is a library: it runs inside your own Node.js service. There is no Ailu server to deploy. ## The usual shape 1. **Your API** starts runs with the catalog runner and stores the returned state in your database, keyed by `runId`. 2. **Your UI** shows suspended runs to the people who approve them. 3. **On approval** (or on a webhook, or at a date), your API or a queue worker loads the state and calls `resumeCatalogGraph`. Because the state is plain JSON in your database, any instance can resume any run, and a deploy or a crash loses nothing that was checkpointed. See [Long-running runs](./long-running.md). `app.run()` / `app.resume()` keep suspended runs in the memory of one `CompiledGraph`. They suit runs that finish within one request or one worker, and single-instance services. ## Platform - Node.js 22 or later. - Linux with glibc (x64 or arm64), macOS or Windows x64. In Docker, use a Debian- or Ubuntu-based image such as `node:22-slim`, not Alpine. - Call `rustEngineAvailable()` at startup and fail fast if it returns `false`. ## Checklist | Check | Why | | --- | --- | | API keys come from your secret manager, as environment variables | Keys never appear in code or in graph definitions. | | `AILU_LLM_MOCK` is **not** set | It answers with a mock instead of failing when a key is missing. | | `resolvedBy` comes from your authenticated session | It is the approver of record. See [Tools and approval](./tools.md). | | `AILU_PII_REDACTOR_URL` and `AILU_PII_REDACTOR_FAIL_CLOSED=1`, if you handle personal data | Personal data is redacted before it reaches a model, and nothing is sent if the redactor is down. See [Governance](./governance.md). | | `AILU_SECRETS_POLICY=block` for strict environments | A prompt containing a secret fails instead of being masked. | | An `ApprovalEngine` backed by your database, passed to `runCatalogGraph` and `resumeCatalogGraph` | Approval requests and decisions survive restarts, and a resume refuses to continue until they are approved. | | `AILU_LLM_RECORD=1` for runs you may have to justify | You can replay them later. | | Tracing: `exportTracesToOtlp(app)` for `app.run()`; the `onEvent` option of the catalog runner otherwise | Traces and cost for every run. See [Observability](./observability.md). | | `AILU_HTTP_READ_TIMEOUT_SECS` | Timeout for model calls (default 600 s). | Every variable is listed in [Environment variables](../reference/environment.md). ## Test before you ship Run your graphs in CI with `AILU_LLM_MOCK=1`: agents answer from the deterministic mock (each tool is called once, then the agent answers `done`), so tests are fast, free and repeatable. Test the wiring: the gates suspend, approvals resume, the right channels get written. # YAML and the CLI A YAML graph describes a graph's **shape**: its channels, nodes and edges. Keep one in your repository when the shape itself is worth reviewing and diffing, for example a process that compliance signs off. YAML holds structure only. It carries no agent configuration (model, prompt, tools), no functions, and no fan-out, error edges or subgraph mappings. To give nodes behavior, build the graph with the TypeScript builder; the builder and YAML produce the same `GraphDefinition`. ## The format ```yaml id: triage # required version: 1.0.0 # required name: Ticket triage # required entryNodeId: intake # required: where runs start recursionLimit: 25 # optional: required for graphs with cycles channels: ticket: { type: string, reducer: replace, default: "" } log: { type: "string[]", reducer: append, default: [] } nodes: - { id: intake, type: action, label: Intake } - { id: review, type: human-gate, label: Human review } edges: - { id: e1, from: intake, to: review, type: default } ``` - Node `type`: `action`, `agent`, `tool`, `human-gate` or `subgraph`. - Edge `type`: `default` or `conditional` (with `condition: `). - Channel `reducer`: `replace`, `append` or `merge`. See [Graphs](./graphs.md#combine-updates-with-reducers). ## Compile from code ```ts import { compileGraphFile, validateGraph } from "@ailu-ai/graph-sdk"; const yaml = ` id: triage version: 1.0.0 name: Ticket triage entryNodeId: intake channels: ticket: { type: string, reducer: replace, default: "" } nodes: - { id: intake, type: action, label: Intake } - { id: review, type: human-gate, label: Human review } edges: - { id: e1, from: intake, to: review, type: default } `; // YAML describes the graph's shape. Compile it to the same GraphDefinition the builder produces. const { result, diagnostics } = compileGraphFile(yaml, "triage.graph.yaml"); console.log(diagnostics); // [] when the file is valid console.log(validateGraph(result!)); // the engine's own validation ``` ## The CLI ```bash npm install -g @ailu-ai/cli ``` | Command | What it does | | --- | --- | | `ailu init graph --id triage --out triage.graph.yaml` | Writes a starter file. | | `ailu validate triage.graph.yaml` | Checks the file. Exits with 1 and lists the problems if it is invalid. | | `ailu compile triage.graph.yaml --out build/` | Writes the compiled `GraphDefinition` to `build/triage.graph.json`. | | `ailu diff old.graph.yaml new.graph.yaml` | Lists the nodes and edges added or removed between two versions. | | `ailu run triage.graph.yaml --input '{}'` | A dry run: walks the graph and prints each event. Nodes do nothing. | `ailu validate` fits well in CI, next to your tests. To really run a graph, run your TypeScript code: `npx tsx app.ts`. # Python and other languages The engine is written once, in Rust. The TypeScript SDK exposes all of it. Other languages expose part of it today. ## Python ```bash pip install ailu ``` ```python import ailu # Validate and compile graphs: the same checks as the TypeScript SDK. graph = ailu.compile_graph_yaml(open("triage.graph.yaml").read()) assert ailu.validate_graph(graph) == [] # Resolve a model tier against the keys you have set. print(ailu.available_providers()) # e.g. ["anthropic"] print(ailu.resolve_model("fast")) # {"provider": ..., "model": ..., "recommended": ...} # Run one component, or one prebuilt agent. ailu.run_component("promptBuilder", {"template": "Hello {{name}}!", "into": "prompt"}, {"name": "Ada"}) outcome = ailu.prebuilt.summarizer("A long text to summarize...") print(outcome["status"], outcome["channels"]) ``` | Function | Purpose | | --- | --- | | `validate_graph(definition)` | The list of problems in a graph definition (empty when valid). | | `compile_graph_yaml(text)` | A YAML graph compiled to a definition (a dict). | | `available_providers()`, `resolve_model(tier, ...)` | Which providers have keys, and which model a tier maps to. | | `list_components()`, `run_component(kind, params, channels)` | The component catalog, and one component run. | | `list_prebuilt()`, `run_prebuilt(name, input)`, `prebuilt.(input)` | The prebuilt agents, and one agent run. | Errors are raised as `ailu.GraphValidationError`, `ailu.GraphCompileError` or `ailu.RunError`. Prebuilt agents read API keys like the TypeScript SDK does, and `AILU_LLM_MOCK=1` runs them offline. :::note Not yet in Python Building a graph, running it, human gates, resume and streaming are TypeScript only for now. A Python service can run the graphs through a small TypeScript worker. ::: ## Other languages The `sdks/` folder of the repository has bindings for Go, Java/Kotlin (JVM), C#, C++, Swift, Objective-C, Zig, Ruby, PHP, Lua, PowerShell and Elixir, over the engine's C ABI (`include/ailu.h`). They are **experimental**: the surface may change, and they are not published to package registries. Build them from source; each folder has its own README. # Examples Each example is one file you can run. They check their own results, and the SDK's tests run all of them offline, so they work with the version these docs describe. | Example | Shows | | --- | --- | | [Governed refund agent](./refund-agent.md) | An agent with a tool that needs a human's approval. | | [Document Q&A](./document-qa.md) | Retrieval, reranking and a grounded answer with citations. | | [Streaming chat](./streaming-chat.md) | Tokens and node progress as they happen. | | [Resume across processes](./resume-across-processes.md) | A run that stops in one process and finishes in another. | | [Parallel agents](./parallel-agents.md) | One agent per item, and a council that ranks its answers. | | [Deep agent](./deep-agent.md) | Planning, a governed filesystem, and a sub-agent. | ## Run one ```bash git clone https://github.com/AI-Adriane/ailu-core && cd ailu-core pnpm install && bash scripts/build-napi.sh AILU_LLM_MOCK=1 pnpm --filter @ailu-ai/graph-sdk exec node --import tsx examples/agent.ts ``` Drop `AILU_LLM_MOCK=1` and set a provider key (`ANTHROPIC_API_KEY`, ...) to run on a real model. ## Longer examples in the repository - [`startup-e2e.ts`](https://github.com/AI-Adriane/ailu-core/blob/main/packages/graph-sdk/examples/startup-e2e.ts): a venture pipeline on the catalog runner, with a brand-review human gate and a `deploy_to_prod` tool that waits for the founder's approval. - [`finance-sage-optimization.ts`](https://github.com/AI-Adriane/ailu-core/blob/main/packages/graph-sdk/examples/finance-sage-optimization.ts): an agent analyses an accounting export; posting corrections waits for the CFO's approval. - [`product-pipeline.ts`](https://github.com/AI-Adriane/ailu-core/blob/main/packages/graph-sdk/examples/product-pipeline.ts): brief to launch, with a component, a semantic retriever and agents on three model tiers. - [`qa-rag.ts`](https://github.com/AI-Adriane/ailu-core/blob/main/packages/graph-sdk/examples/qa-rag.ts): question answering where an answer no source backs goes to a human review gate instead of being published. - [`token-economics.ts`](https://github.com/AI-Adriane/ailu-core/blob/main/packages/graph-sdk/examples/token-economics.ts): prompt tokens of one long shared context compared with governed RAG agents. # Governed refund agent A support agent can refund orders, but the refund tool is registered with `requiresApproval: true`. The run stops before the refund, a named person approves, and the refund runs exactly once. Guide: [Tools and approval](../guides/tools.md). ```ts title="agent.ts" /** * Agent + approval — the core governance loop. * * A support agent reaches for a sensitive tool (`refund`). The tool is registered with * `requiresApproval: true`, so the agent cannot run it on its own authority: with * `suspendForApproval` the whole run suspends at the agent node and nothing is refunded. * A human then grants the tool with `approveAndResume`; the run resumes and the refund * executes exactly once. The approver is recorded, and the engine refuses an approval * granted by the agent that asked for it. * * Run it offline (no API key — the engine's deterministic mock calls each declared tool once): * AILU_LLM_MOCK=1 pnpm --filter @ailu-ai/graph-sdk example:agent * With ANTHROPIC_API_KEY set, the same code runs on Claude. */ import { createGraph, InMemoryToolRegistry, model, type ToolId } from "@ailu-ai/graph-sdk"; // Self-check: fail loudly (throw) rather than print a wrong claim. const check = (condition: boolean, label: string): void => { if (!condition) throw new Error(`Check failed: ${label}`); console.log(` ✓ ${label}`); }; // ── The sensitive tool ─────────────────────────────────────────────────────── let refunds = 0; const passthrough = { parse: (value: unknown) => value }; const tools = new InMemoryToolRegistry(); tools.register( { id: "refund" as ToolId, name: "refund", description: "Refund a customer order. Sensitive: needs human approval.", inputSchema: passthrough, outputSchema: passthrough, permissions: ["payments:write"], requiresApproval: true, // the agent can request it, never run it unapproved jsonSchema: { type: "object", properties: { orderId: { type: "string" } } } // what the model sees }, async (input: unknown) => { refunds += 1; console.log(" → refund executed", JSON.stringify(input)); return { ok: true }; } ); // ── The graph: one agent node ──────────────────────────────────────────────── const app = createGraph({ name: "support-agent" }) .channel("ticket", { type: "string", default: "" }) .agentNode("assistant", { model: model.anthropic("claude-sonnet-4-6"), prompt: { system: "You are a support agent. Use your tools to resolve the ticket." }, tools, suspendForApproval: true, // suspend the run when a tool needs approval maxIterations: 4 }) .compile(); // 1) The agent reaches for `refund` → the run suspends; nothing has been refunded. const suspended = await app.run({ ticket: "Order #1042 arrived broken. Please refund it." }); console.log("status:", suspended.status); // "suspended" check(suspended.status === "suspended", "the run suspended for approval"); check(String(suspended.currentNodeId) === "assistant", "it is paused at the agent node"); check( JSON.stringify(suspended.channels.agentResult.approvalRequests).includes("tool:refund"), "the pending approval request names tool:refund" ); check(refunds === 0, "the refund did NOT run before approval"); // 2) A human (alice) grants the tool; the run resumes and the refund executes. const done = await app.approveAndResume(suspended.runId, { approvedTools: ["refund"], resolvedBy: "alice" // the approver's identity; the requesting agent can never approve itself }); console.log("resumed status:", done.status); // "completed" check(done.status === "completed", "the run completed after approval"); check(refunds === 1, "the refund ran exactly once"); ``` Run it offline: ```bash AILU_LLM_MOCK=1 pnpm --filter @ailu-ai/graph-sdk exec node --import tsx examples/agent.ts ``` # Document Q&A A complete retrieval pipeline built from components, with one agent that writes a grounded answer and a final step that adds numbered citations. The same graph is exported by the SDK as `buildDocQaReference()`. Guide: [RAG and retrieval](../guides/rag.md). ```ts title="doc-qa-reference.ts" /** * Reference pipeline — Doc-QA (retrieval-augmented question answering), end to end. * * A COMPLETE input → output pipeline composed entirely from the catalog and run on the * Rust engine: * * INPUT { question, documents } * → clean (textCleaner) normalise the raw documents text * → split (documentSplitter) chunk it into passages * → retrieve (retriever) deterministic mock-embedding top-k over the corpus * → rerank (reranker) reorder the hits against the question * → prompt (promptBuilder) build a grounded prompt from context + question * → answer (AGENT, balanced) a grounded RAG answerer writes its AgentResult * → extract (fieldExtractor) reduce AgentResult.reasoning to the final answer text * → assemble (answerBuilder) answer text + numbered citations → OUTPUT { answer } * OUTPUT { answer } * * Single input set, single output channel. * * ── OFFLINE vs LIVE ─────────────────────────────────────────────────────────── * - AILU_LLM_MOCK=1, no key → the answerer runs on the engine's deterministic offline * mock, so the whole pipeline is reproducible with no network. * - MISTRAL_API_KEY → the balanced tier resolves to a concrete Mistral model and * the answerer makes a real (short) call. * * Self-verifying: every claim below is checked, and the first failed check throws, so this * example doubles as an end-to-end smoke test. Checks are structural (status, channels * written, citations present), never the model's wording. * * Run it: * AILU_LLM_MOCK=1 pnpm --filter @ailu-ai/graph-sdk example:docqa */ import { buildDocQaReference, docQaReferenceDefinition, isCatalogGraph, runCatalogGraph, type RunId } from "@ailu-ai/graph-sdk"; // Self-check: fail loudly (throw) rather than print a wrong claim. const check = (condition: boolean, label: string): void => { if (!condition) throw new Error(`Check failed: ${label}`); console.log(` ✓ ${label}`); }; const liveKey = process.env.MISTRAL_API_KEY !== undefined && process.env.MISTRAL_API_KEY.length > 0; const QUESTION = "How does Ailu resume a run after a crash or an approval?"; const DOCUMENTS = "

Ailu is a stateful, resumable agent graph runtime.

It checkpoints after " + "every node completion. Human gates suspend the run cleanly for approval."; console.log(`\nDoc-QA reference pipeline (${liveKey ? "live Mistral" : "offline mock"})\n`); console.log(`Question: ${QUESTION}\n`); // ── Run 1: as a CompiledGraph (the runnable SDK object) ────────────────────── const app = buildDocQaReference(); const out = await app.run({ question: QUESTION, documents: DOCUMENTS }, { runId: "doc-qa-example" as RunId }); const answer = String(out.channels.answer ?? ""); check(out.status === "completed", "the pipeline ran to completion"); check(answer.trim().length > 0, "the `answer` output channel is a non-empty string"); check(answer.includes("Sources:"), "the answer is grounded with a citations block"); check( !answer.includes('{"reasoning"') && !answer.trimStart().startsWith("{"), "the answer is CLEAN text, not a raw AgentResult JSON dump" ); check(!String(out.channels.cleaned).includes("

"), "the documents were HTML-cleaned"); check(Array.isArray(out.channels.chunks), "the documents were split into chunks"); check((out.channels.ranked as unknown[]).length > 0, "the retriever + reranker ranked the corpus"); console.log(`\n Answer:\n${answer.split("\n").map((line) => ` ${line}`).join("\n")}\n`); // ── Run 2: as a carrier-only GraphDefinition through the catalog run path ───── // This is the exact seam the control plane uses: a plain GraphDefinition whose nodes // carry node.metadata.component / node.metadata.agent, executed on the Rust engine. const definition = docQaReferenceDefinition(); check(isCatalogGraph(definition), "the definition is a catalog graph (carrier present on its nodes)"); console.log("Catalog run path (runCatalogGraph on the Rust engine):"); const outcome = await runCatalogGraph(definition, { runId: "doc-qa-example-catalog" as RunId, initialData: { question: QUESTION, documents: DOCUMENTS } }); check(outcome.status === "completed", "the carrier-only definition ran to completion"); check( typeof outcome.state.channels.answer === "string" && outcome.state.channels.answer.trim().length > 0, "the catalog run populated the `answer` output channel" ); console.log("\nAll checks passed — the Doc-QA reference pipeline runs end to end."); ``` Run it offline: ```bash AILU_LLM_MOCK=1 pnpm --filter @ailu-ai/graph-sdk exec node --import tsx examples/doc-qa-reference.ts ``` # Streaming chat Two ways to watch a run: the `messages` stream for text as the model writes it, and the `updates` stream for one event per finished node. Guide: [Streaming](../guides/streaming.md). ```ts title="streaming.ts" /** * Streaming: watch a run while it executes. * * `app.stream(input, mode)` runs the graph and yields events as they happen: * - "messages": the agents' text as it is generated. With a real key you get token deltas; * the offline mock sends each answer as a single delta. `messageId` groups one model turn. * - "updates": one event per node as it completes, with the channels that node wrote. * (Two more modes exist: "values" yields the full state after each node, "debug" every * lifecycle event.) * * Run it offline: AILU_LLM_MOCK=1 pnpm --filter @ailu-ai/graph-sdk exec node --import tsx examples/streaming.ts * With ANTHROPIC_API_KEY set, watch Claude's answer arrive token by token. */ import { createGraph, model } from "@ailu-ai/graph-sdk"; const check = (condition: boolean, label: string): void => { if (!condition) throw new Error(`Check failed: ${label}`); console.log(` ✓ ${label}`); }; const sonnet = model.anthropic("claude-sonnet-4-6"); // Two agents in sequence: one answers, the next titles the answer. const app = createGraph({ name: "streaming-demo" }) .channel("question", { type: "string", default: "" }) .agentNode("answer", { model: sonnet, prompt: { system: "Answer the question in two sentences." }, outputChannel: "answerResult" }) .agentNode("title", { model: sonnet, prompt: { system: "Write a five-word title for the answer in state." }, outputChannel: "titleResult" }) .edge("answer", "title") .compile(); const input = { question: "Why do long-running agent workflows need checkpoints?" }; // ── 1. "messages": print the text as it arrives ────────────────────────────── console.log("1. messages"); let streamed = ""; let currentNode = ""; for await (const event of app.stream(input, "messages")) { if (event.type !== "message_delta") continue; if (event.nodeId !== currentNode) { currentNode = event.nodeId; process.stdout.write(`\n[${currentNode}] `); // a new node started talking } process.stdout.write(event.delta); streamed += event.delta; } process.stdout.write("\n"); check(streamed.length > 0, "text streamed while the run executed"); // ── 2. "updates": print progress, one line per completed node ──────────────── console.log("\n2. updates"); const completed: string[] = []; for await (const event of app.stream(input, "updates")) { if (event.type !== "state_update") continue; completed.push(event.nodeId); console.log(` ${event.nodeId} done → wrote ${Object.keys(event.delta).join(", ")}`); } check(completed.join(" → ") === "answer → title", "one update per node, in execution order"); ``` Run it offline: ```bash AILU_LLM_MOCK=1 pnpm --filter @ailu-ai/graph-sdk exec node --import tsx examples/streaming.ts ``` # Resume across processes Process 1 runs the graph with the catalog runner and saves the suspended state as JSON. A reviewer approves. Process 2 loads the state and resumes; given the approval engine, the resume refuses to continue until the reviewer has approved. Guide: [Long-running runs](../guides/long-running.md). ```ts title="resume-across-processes.ts" /** * Resume a run in another process. * * `app.resume(runId)` only works on the CompiledGraph instance that started the run: its * checkpoints live in memory. To suspend a run in one process and finish it in another (a web * request that stops at a human gate, a worker that resumes it hours later), run the graph's * definition on the catalog path and store the suspended state yourself. It is plain JSON. * * Process 1 drafts a reply and suspends at a human gate; its state is saved as if written to a * database. A reviewer approves. Process 2 loads it and finishes the run; the resume checks the * approval first. * * Run it offline: AILU_LLM_MOCK=1 pnpm --filter @ailu-ai/graph-sdk exec node --import tsx examples/resume-across-processes.ts * With ANTHROPIC_API_KEY set, the agent drafts a real reply. */ import { createGraph, finalAnswer, InMemoryApprovalEngine, model, resumeCatalogGraph, runCatalogGraph, type AgentResult, type GraphState } from "@ailu-ai/graph-sdk"; const check = (condition: boolean, label: string): void => { if (!condition) throw new Error(`Check failed: ${label}`); console.log(` ✓ ${label}`); }; // An agent drafts a reply; a human reviews it before the run completes. const app = createGraph({ name: "support-reply" }) .channel("ticket", { type: "string", default: "" }) .agentNode("draft", { model: model.anthropic("claude-sonnet-4-6"), prompt: { system: "Draft a short, polite reply to the support ticket." }, outputChannel: "reply" }) .humanGate("review") .edge("draft", "review") .compile(); // Shared by both processes. In production both are your database: a persistent ApprovalEngine // and a table of suspended run states. const approvals = new InMemoryApprovalEngine(); const database = new Map(); // ── Process 1: start the run; it suspends at the review gate ───────────────── const started = await runCatalogGraph(app.definition, { initialData: { ticket: "My invoice shows the wrong billing address." }, approvalEngine: approvals // files one approval request for the gate }); check(started.status === "suspended", "process 1: the run suspended at the review gate"); database.set("run-42", JSON.stringify(started.state)); // the whole state is plain JSON // ── Out of band: a reviewer approves. Their identity is recorded on the request. ─ const [request] = await approvals.getPending(started.state.runId); if (request === undefined) throw new Error("Check failed: no approval request was filed"); await approvals.approve(request.id, "alice@example.com"); // ── Process 2: load the state and resume ───────────────────────────────────── const state = JSON.parse(database.get("run-42") ?? "{}") as GraphState; // With the approval engine, the resume checks it first: it throws while a request the run waits // on is pending or was rejected. For an approval-gated TOOL (`requiresApproval: true`), also pass // the grant: approvedTools: [{ name: "refund", requestedBy: "draft", resolvedBy: "alice@example.com" }]. // It must match a request the engine recorded as approved by that same person. const finished = await resumeCatalogGraph(app.definition, state, { approvalEngine: approvals }); check(finished.status === "completed", "process 2: the run completed"); check((await approvals.getById(request.id))?.resolvedBy === "alice@example.com", "the engine records who approved"); const reply = finalAnswer(finished.state.channels.reply as AgentResult | undefined); check(reply.length > 0, "the drafted reply survived the round trip"); console.log(`\nReply: ${reply}`); ``` Run it offline: ```bash AILU_LLM_MOCK=1 pnpm --filter @ailu-ai/graph-sdk exec node --import tsx examples/resume-across-processes.ts ``` # Parallel agents `mapAgents` summarizes a list of reviews in parallel, in input order. Then a council answers a question: members answer, reviewers rank the answers without knowing who wrote them, and a chair decides. Guide: [Multi-agent](../guides/multi-agent.md). ```ts title="parallel-agents.ts" /** * Run agents in parallel, two ways. * * 1. `mapAgents`: one sub-agent per item of a list, all running concurrently. The results land * in input order, whatever order the agents finish in, so the run is deterministic and * resumable. * 2. `council`: several member agents answer the same question, reviewers rank the answers * without knowing who wrote them, and a chair writes the final answer from the ranking. * `council()` returns a graph definition, which you run with `runCatalogGraph`. * * Run it offline: AILU_LLM_MOCK=1 pnpm --filter @ailu-ai/graph-sdk exec node --import tsx examples/parallel-agents.ts * With ANTHROPIC_API_KEY set, every agent runs on Claude. */ import { council, createGraph, finalAnswer, model, runCatalogGraph, type AgentResult } from "@ailu-ai/graph-sdk"; const check = (condition: boolean, label: string): void => { if (!condition) throw new Error(`Check failed: ${label}`); console.log(` ✓ ${label}`); }; const sonnet = model.anthropic("claude-sonnet-4-6"); // ── 1. mapAgents: summarize every review concurrently ──────────────────────── const REVIEWS = [ "Setup took five minutes and the docs answered every question I had.", "Great product, but the invoice PDF is missing our VAT number.", "Support replied within an hour and fixed the sync bug the same day." ]; const app = createGraph({ name: "review-summaries" }) .channel("reviews", { type: "json", default: [] as string[] }) .mapAgents("summarize", { overChannel: "reviews", // one sub-agent per item of this list subAgent: { model: sonnet, prompt: { system: "Summarize the customer review in five words." } }, joinAt: "summaries" // AgentResult[], in the same order as `reviews` }) .compile(); console.log("\n1. mapAgents"); const out = await app.run({ reviews: REVIEWS }); check(out.status === "completed", "the fan-out completed"); check(out.channels.summaries.length === REVIEWS.length, "one result per review, in input order"); check(out.channels.summaries.every((result) => finalAnswer(result).length > 0), "every sub-agent answered"); out.channels.summaries.forEach((result, index) => console.log(` ${index + 1}. ${finalAnswer(result)}`)); // ── 2. council: three perspectives, anonymous peer review, one chair ───────── const seat = (perspective: string) => ({ model: sonnet, prompt: { system: `Answer as ${perspective}.` } }); const definition = council({ members: [seat("a pragmatic staff engineer"), seat("a security reviewer"), seat("a cost-conscious CTO")], // reviewers default to one per member; each ranks the anonymized answers A, B, C chair: { model: model.anthropic.frontier, prompt: { system: "Synthesize the best final answer." } } }); console.log("\n2. council"); const outcome = await runCatalogGraph(definition, { initialData: { query: "Postgres or SQLite for a three-person startup's first product?" } }); const ranking = outcome.state.channels.aggregate as string[]; // consensus, best first const answer = finalAnswer(outcome.state.channels.answer as AgentResult | undefined); check(outcome.status === "completed", "the council completed"); check([...ranking].sort().join() === "A,B,C", "the consensus ranks all three anonymized answers"); check(answer.length > 0, "the chair wrote the final answer"); console.log(` Ranking: ${ranking.join(" > ")}`); console.log(` Answer: ${answer}`); ``` Run it offline: ```bash AILU_LLM_MOCK=1 pnpm --filter @ailu-ai/graph-sdk exec node --import tsx examples/parallel-agents.ts ``` # Deep agent A lead agent writes its plan with `writeTodos`, keeps notes in files under a policy, and hands research to an isolated sub-agent. Guide: [Deep agents](../guides/deep-agents.md). ```ts title="deep-agent.ts" /** * A deep agent: it plans, works in files, and delegates. * * - Planning: the `writeTodos` tool. Each call replaces the agent's todo list, and the engine * saves the latest list to a channel in the same checkpoint as the agent's result, so later * nodes (and your UI) can read the plan. * - A governed filesystem: `enableFs: true` gives the agent read_file, write_file, ls, glob, * grep, edit_file, delete_file and move_file over a virtual filesystem scoped to the run. * `.fsPolicy()` decides what it may do per path; an unmatched path is read-only. * - Delegation: `taskNode` runs a sub-agent in an isolated context. Only the objective goes in * and only the sub-agent's report comes back. * * Offline, the mock calls writeTodos once with an empty plan and never touches the files (it * only calls the tools you declare). With ANTHROPIC_API_KEY set, the lead agent writes a real * plan and keeps its notes under notes/. * * Run it offline: AILU_LLM_MOCK=1 pnpm --filter @ailu-ai/graph-sdk exec node --import tsx examples/deep-agent.ts */ import { createGraph, finalAnswer, InMemoryToolRegistry, model, TODOS_CHANNEL, writeTodosTool, type TodoItem } from "@ailu-ai/graph-sdk"; const check = (condition: boolean, label: string): void => { if (!condition) throw new Error(`Check failed: ${label}`); console.log(` ✓ ${label}`); }; const sonnet = model.anthropic("claude-sonnet-4-6"); const tools = new InMemoryToolRegistry(); tools.register(writeTodosTool.definition, writeTodosTool.handler); const app = createGraph({ name: "deep-agent" }) .channel("objective", { type: "string", default: "" }) // The durable plan: null until the agent first calls writeTodos. .channel(TODOS_CHANNEL, { type: "json", default: null as TodoItem[] | null }) .fsPolicy([ { glob: "notes/**", verb: "write" }, // the agent's scratch space { glob: "secrets/**", verb: "deny" } // never readable, never writable ]) .agentNode("lead", { model: sonnet, prompt: { system: "Plan the work with writeTodos before anything else and keep it up to date. " + "Keep your working notes in files under notes/." }, tools, todosChannel: TODOS_CHANNEL, // where the engine saves the latest plan enableFs: true, maxIterations: 8, outputChannel: "leadResult" }) // Delegate: reads the `objective` channel, writes its report to the `report` channel. .taskNode("research", { subAgent: { model: sonnet, prompt: { system: "Research the objective and report back in five bullet points." } } }) .edge("lead", "research") .compile(); const out = await app.run({ objective: "Compare three open-source vector databases for a small team." }); const plan = out.channels[TODOS_CHANNEL]; check(out.status === "completed", "the run completed"); check(Array.isArray(plan), "the plan was saved to the durable todos channel"); check(finalAnswer(out.channels.leadResult).length > 0, "the lead agent answered"); check(finalAnswer(out.channels.report).length > 0, "the delegated sub-agent returned its report"); console.log("\nPlan:"); for (const todo of plan ?? []) console.log(` [${todo.status}] ${todo.text}`); if (plan?.length === 0) console.log(" (empty: the offline mock calls writeTodos with no items)"); console.log(`\nResearch report: ${finalAnswer(out.channels.report)}`); ``` Run it offline: ```bash AILU_LLM_MOCK=1 pnpm --filter @ailu-ai/graph-sdk exec node --import tsx examples/deep-agent.ts ``` # API Everything on this page is exported by `@ailu-ai/graph-sdk`. Types are in the package's `.d.ts` files; your editor shows them on hover. ## Build a graph: `createGraph` `createGraph({ name, id?, version?, recursionLimit? })` returns a builder. Every method returns the builder, so calls chain. | Method | Adds | | --- | --- | | `.channel(name, { type, default?, reducer?, noLog? })` | A channel. `reducer`: `"replace"` (default), `"append"`, `"merge"`. `noLog` masks it in events. | | `.node(id, handler)` | A step: `async (input, state) => ({ ...updates })`. Or `.node(id, { handler, retryPolicy: { maxAttempts, backoffMs }, label })`. | | `.agentNode(id, config)` | An LLM agent. See below. | | `.humanGate(id)` | A node that suspends the run until you resume it. | | `.component(id, components.({...}))` | A [built-in component](./components.md). | | `.taskNode(id, { subAgent, objectiveChannel?, reportChannel?, compress? })` | An isolated sub-agent that reads `objective` and writes `report`. | | `.mapAgents(id, { overChannel, subAgent, joinAt, suspendForApproval? })` | One sub-agent per item of `overChannel`, results in `joinAt`. | | `.subgraph(id, builder, { inputMapping?, outputMapping? })` | Another builder as one node. Mappings are `destination: source`. | | `.edge(from, to)` | An edge. | | `.conditionalEdge(from, to, name, (state) => boolean)` | An edge taken when the named predicate returns `true`. | | `.errorEdge(from, to)` | Where to go when `from` fails after its retries. | | `.fanOut(from, [branches], joinAt)` | Run `branches` in parallel after `from`, continue at `joinAt`. | | `.entry(id)` | The start node (default: the first node added). | | `.fsPolicy([{ glob, verb }])` | File rules for agents with `enableFs`. `verb`: `read`, `write`, `gate`, `deny`. | | `.compile()` | Checks the graph and returns a `CompiledGraph`. Throws `GraphCompileError`. `.safeCompile()` returns a result instead. | ### `agentNode` config | Option | Type | Default | | --- | --- | --- | | `model` | `model.*(...)`, or a `"provider:model"` string | Anthropic's default model | | `prompt` | `{ system: string }` | required | | `tools` | `InMemoryToolRegistry` | none | | `suspendForApproval` | `boolean`: stop the run when a gated tool is requested | `false` | | `outputChannel` | `string` | `"agentResult"` | | `visibleChannels` | `string[]`: the channels the agent is shown | all | | `maxIterations` | `number` | engine default | | `middleware` | `[{ kind: "structuredOutput" \| "terse" \| "contextBudget" \| "compress" \| "reflection", params? }]` | none | | `profile` | `"fast" \| "frontier-careful" \| "governed-deep"` | none | | `todosChannel` | `string`: where the `writeTodos` plan is saved | none | | `enableFs` | `boolean`: file tools, under `.fsPolicy` | `false` | | `memory` | `{ namespace, topK?, recall? }` | none | | `skills` | `{ namespace, required?, advisoryK? }` (catalog runner only) | none | | `inputBlocksChannel` | `string`: a channel of images, audio or files for the model | none | ## Run it: `CompiledGraph` | Member | Does | | --- | --- | | `run(data?, { runId? })` | Runs until the end or a suspension. Resolves with the state: `{ runId, status, currentNodeId, channels }`. | | `resume(runId)` | Continues a suspended run of this instance. | | `approveAndResume(runId, { approvedTools, resolvedBy })` | Grants gated tools, then continues. `resolvedBy`, the approver, is required. | | `signal(runId, name, payload?)` | Delivers a signal to a run waiting on `waitForSignal(name)`. | | `stream(data, mode, { runId? })` | Runs and yields events. `mode`: `"messages"`, `"updates"`, `"values"`, `"debug"`. | | `onEvent(handler)` | Subscribes to run events. Returns an unsubscribe function. | | `explain(runId)` | What a suspended run waits for, and how to continue it. | | `definition` | The graph as a plain `GraphDefinition` (JSON). | `status` is `"running"`, `"suspended"`, `"completed"`, `"failed"` or `"cancelled"`. ## Run from saved state: the catalog runner | Function | Does | | --- | --- | | `runCatalogGraph(definition, options?)` | Runs a `GraphDefinition`. Resolves with `{ state, status, pendingApprovals?, replayJournal?, entryState? }`. | | `resumeCatalogGraph(definition, state, options?)` | Continues a run from a saved state. With `approvalEngine`, first checks that the engine approved what the run waits on. | | `replayCatalogGraph(definition, entryState, id, replayJournal)` | Re-runs a recorded run without calling a model. | Options: `initialData`, `runId`, `approvalEngine`, `tools` (`[{ name, execute }]`), `approvedTools` (resume only: `[{ name, requestedBy, resolvedBy }]`), `signal` (an `AbortSignal`), `onEvent`, `providerKeys`, `fsPolicy`, `skills`, `subgraphs`, `streamTokens`. Only agents, components, human gates, subgraphs and `mapAgents` run on this path: your own `.node()` functions and conditional-edge functions do not. ## Models `model` and `model.invoke()`: see [Models and providers](./models.md) and [Agents and models](../guides/agents.md#call-a-model-directly). ## Helpers | Export | Does | | --- | --- | | `finalAnswer(result)` | The answer text of an agent result. | | `InMemoryToolRegistry` | Holds tools: `register(definition, handler)`. | | `writeTodosTool` | The built-in planning tool: `register(writeTodosTool.definition, writeTodosTool.handler)`. | | `waitForSignal(name, { wakeAt? })`, `sleepUntil(date)` | Node return values that suspend the run. | | `readSignal(state, name)`, `readSuspendMeta(state)` | Read a delivered signal, or why a run is suspended. | | `council({ members, reviewers?, chair, humanGate? })` | A council graph, for `runCatalogGraph`. | | `components` | The [built-in components](./components.md). | | `semanticRetriever`, `createEmbeddings`, `createVectorStore` | Retrieval by embeddings. See [RAG](../guides/rag.md). | | `InMemoryApprovalEngine`, `Ed25519Attestor`, `verifyChain`, `verifyReplayDecisions` | Approval records and evidence. See [Governance](../guides/governance.md). | | `exportTracesToOtlp`, `computeCost`, `serveInspector`, `explainRun` | Observability. See [Observability](../guides/observability.md). | | `compileGraphFile(yaml, fileName)`, `validateGraph(definition)` | YAML graphs. See [YAML and the CLI](../guides/yaml-and-cli.md). | | `rustEngineAvailable()` | Whether the native engine loaded. | | `componentCatalog`, `componentSchemas()`, `generateLlmsTxt()` | Machine-readable descriptions of the SDK. | # Models and providers ## Pick a model | Form | Example | Provider | Model | | --- | --- | --- | --- | | Provider and model id | `model.openai("gpt-4o")` | named | named | | Provider and tier | `model.mistral.fast` | named | from the table below | | Tier only | `model.balanced` | the first provider with a key, in the order of the table | from the table | | String | `model("anthropic:claude-sonnet-4-6")`, `model("openai:fast")` | named | named, or from the table | | Custom endpoint | `model.openaiCompatible({ baseURL, model, apiKeyEnv })` | any OpenAI-compatible server | named | The same forms work for `agentNode({ model })` and for one-off calls with `.invoke()`. A string also works directly: `agentNode({ model: "openai:gpt-4o" })`. An unknown provider is an error. ## Providers, keys and tiers | Provider | Key | `fast` | `balanced` | `frontier` | `creative` | | --- | --- | --- | --- | --- | --- | | anthropic | `ANTHROPIC_API_KEY` | `claude-haiku-4-5` | `claude-sonnet-4-6` | `claude-opus-4-8` | `claude-fable-5` | | openai | `OPENAI_API_KEY` | `gpt-4o-mini` | `gpt-4o` | `gpt-4o` | `gpt-4o` | | google | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | `gemini-2.5-flash` | `gemini-2.5-flash` | `gemini-2.5-pro` | `gemini-2.5-flash` | | mistral | `MISTRAL_API_KEY` | `mistral-small-latest` | `mistral-medium-latest` | `mistral-large-latest` | `mistral-large-latest` | | openrouter | `OPENROUTER_API_KEY` | `openai/gpt-4o-mini` | `openai/gpt-4o-mini` | `openai/gpt-4o` | `openai/gpt-4o` | | minimax | `MINIMAX_API_KEY` | `MiniMax-Text-01` | `MiniMax-Text-01` | `MiniMax-Text-01` | `MiniMax-Text-01` | | huggingface | `HF_TOKEN` or `HUGGINGFACE_API_KEY` | `meta-llama/Llama-3.3-70B-Instruct` | `meta-llama/Llama-3.3-70B-Instruct` | `meta-llama/Llama-3.3-70B-Instruct` | `meta-llama/Llama-3.3-70B-Instruct` | | ollama | `AILU_USE_OLLAMA=1` | `mistral` | `mistral` | `mistral` | `mistral` | | lmstudio | `AILU_USE_LMSTUDIO=1` | `local-model` | `local-model` | `local-model` | `local-model` | A missing key fails the call with the name of the variable to set. `AILU_LLM_MOCK=1` turns missing keys into calls to the deterministic offline mock instead. Local servers need no key but must be switched on: `AILU_USE_OLLAMA=1` (with `AILU_OLLAMA_BASE_URL` for a server not on `http://localhost:11434/v1`), `AILU_USE_LMSTUDIO=1` (with `AILU_LMSTUDIO_BASE_URL`). ## Custom endpoints `model.openaiCompatible` points an agent at any server that speaks the OpenAI chat-completions API: vLLM, LM Studio, LiteLLM, Azure OpenAI, a company gateway. ```ts model.openaiCompatible({ baseURL: "https://gateway.example.com/v1", model: "llama-3.1-70b", apiKeyEnv: "GATEWAY_API_KEY" // sent as a Bearer token; omit for a keyless server }); ``` The key is read only from `apiKeyEnv`. Your `OPENAI_API_KEY` is never sent to a custom endpoint. Anthropic and Gemini have their own APIs and can't be pointed at a custom URL. # Components A component is a ready-made node: prompt templating, validation, parsing, retrieval, ranking, text processing. Components run inside the engine, without calling back into JavaScript, and they work on every runner, including [`runCatalogGraph`](../guides/long-running.md). ```ts createGraph({ name: "greet" }) .channel("name", { type: "string", default: "" }) .component("prompt", components.promptBuilder({ template: "Hello {{name}}!", into: "prompt" })); ``` Parameters named `from`, `query` or `queryFrom` name the channel a component reads; `into` names the channel it writes. The two integration components, `httpFetch` and `webSearch`, call external services and are added with `.node()`. To list components from code, use `componentCatalog`; for a JSON Schema of each component's parameters, `componentSchemas()`. ### promptBuilder Render every {{var}} placeholder from the channels into a target channel. Category: prompt. Use: `.component(id, components.promptBuilder({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `template` | `string` | yes | Template with {{var}} placeholders filled from the channels. | | `into` | `string` | yes | Channel the rendered string is written into. | ### jsonValidator Validate a channel value's type and required keys, writing an ok flag and an errors list. Category: validation. Use: `.component(id, components.jsonValidator({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `from` | `string` | yes | Channel whose value is validated. | | `requiredKeys` | `string[]` | no | Required object keys to assert present. | | `expectType` | `"string" \| "number" \| "boolean" \| "object" \| "array" \| "null"` | no | Expected JSON type. | | `okInto` | `string` | yes | Channel receiving the boolean validity flag. | | `errorsInto` | `string` | yes | Channel receiving the string[] of validation errors. | ### outputParser Extract the first balanced JSON object or array from a text channel. Category: parsing. Use: `.component(id, components.outputParser({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `from` | `string` | yes | Text channel to extract the first JSON value from. | | `into` | `string` | yes | Channel receiving the parsed value (or null when none is found). | ### router Pick a route string from a channel value by ordered match rules (pairs with a conditional edge). Category: routing. Use: `.component(id, components.router({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `from` | `string` | yes | Channel whose value is matched against the rules. | | `rules` | `RouterRule[]` | yes | Ordered rules ({ equals?, contains?, route }); the first match wins. | | `defaultRoute` | `string` | yes | Route emitted when no rule matches. | | `into` | `string` | yes | Channel the chosen route string is written into. | ### retriever Score candidate documents against a query and keep the top-k by similarity. Category: retrieval. Use: `.component(id, components.retriever({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `query` | `string` | yes | Channel holding the query text (falls back to this literal when the channel is empty). | | `into` | `string` | yes | Channel receiving the top-k { id, content, score } array. | | `k` | `number` | no | Number of results to keep (default 4). | | `docs` | `RetrieverDoc[]` | yes | The corpus ({ id, content }[]) to score against. | ### reranker Reorder a retrieval-result array, optionally re-scoring against a query embedding. Category: retrieval. Use: `.component(id, components.reranker({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `from` | `string` | yes | Channel holding the retrieval-result array to reorder. | | `into` | `string` | yes | Channel receiving the reordered array. | | `query` | `string` | no | Optional channel holding query text for embedding-based re-scoring. | ### textCleaner Normalise a text channel: strip HTML, lowercase, collapse whitespace, trim. Category: text. Use: `.component(id, components.textCleaner({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `from` | `string` | yes | Channel whose text is normalised. | | `into` | `string` | yes | Channel receiving the cleaned text. | | `lowercase` | `boolean` | no | Lowercase the text. Defaults to false. | | `stripHtml` | `boolean` | no | Strip <...> HTML tags. Defaults to false. | | `collapseWhitespace` | `boolean` | no | Collapse runs of whitespace to a single space. Defaults to false. | | `trim` | `boolean` | no | Trim leading/trailing whitespace. Defaults to false. | ### documentSplitter Split a text channel into an array of chunk strings by chars or sentences. Category: text. Use: `.component(id, components.documentSplitter({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `from` | `string` | yes | Channel holding the text to split. | | `into` | `string` | yes | Channel receiving the string[] of chunks. | | `by` | `"chars" \| "sentences"` | yes | Split unit: sliding char windows or greedy sentence packing. | | `size` | `number` | yes | Window size in chars or sentences. Must be > 0. | | `overlap` | `number` | no | Overlap repeated at the start of each next chunk. Defaults to 0. | ### htmlToText Strip HTML tags from a text channel and decode the common named entities. Category: text. Use: `.component(id, components.htmlToText({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `from` | `string` | yes | Channel holding the HTML text. | | `into` | `string` | yes | Channel receiving the tag-stripped, entity-decoded text. | ### csvParser Parse a CSV text channel into an array of row objects (or arrays). Category: parsing. Use: `.component(id, components.csvParser({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `from` | `string` | yes | Channel holding the CSV text. | | `into` | `string` | yes | Channel receiving the parsed rows array. | | `delimiter` | `string` | no | Single-character cell delimiter. Defaults to ",". | | `header` | `boolean` | no | When true (default) the first row supplies object keys; otherwise rows are arrays. | ### documentJoiner Concatenate the array values across several channels into one merged array. Category: data. Use: `.component(id, components.documentJoiner({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `fromChannels` | `string[]` | yes | Channels whose array values are concatenated in order. | | `into` | `string` | yes | Channel receiving the merged array. | | `dedupeBy` | `string` | no | Optional object field to de-duplicate the merged items by. | ### deduplicator De-duplicate an array channel, keeping the first occurrence and preserving order. Category: data. Use: `.component(id, components.deduplicator({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `from` | `string` | yes | Channel holding the array to de-duplicate. | | `into` | `string` | yes | Channel receiving the de-duplicated array. | | `key` | `string` | no | Optional object field to compare items by (else whole-value compare). | ### truncator Truncate a text channel to at most maxChars characters with an ellipsis. Category: text. Use: `.component(id, components.truncator({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `from` | `string` | yes | Channel holding the text to truncate. | | `into` | `string` | yes | Channel receiving the (possibly truncated) text. | | `maxChars` | `number` | yes | Maximum character length (the ellipsis counts against this budget). | | `ellipsis` | `string` | no | Suffix appended when truncated. Defaults to "…". | ### regexExtractor Extract literal-pattern matches (with ^/$ anchors) from a text channel. Category: parsing. Use: `.component(id, components.regexExtractor({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `from` | `string` | yes | Channel holding the text to match against. | | `into` | `string` | yes | Channel receiving the match (or matches when all). | | `pattern` | `string` | yes | Literal-substring pattern with optional leading ^ and trailing $ anchors. | | `group` | `number` | no | Accepted for forward-compat; only 0 (the whole match) is supported. Defaults to 0. | | `all` | `boolean` | no | When true, return every non-overlapping occurrence as an array. Defaults to false. | ### answerBuilder Assemble a final answer string, optionally appending numbered citations. Category: text. Use: `.component(id, components.answerBuilder({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `from` | `string` | yes | Channel supplying the core answer text. | | `into` | `string` | yes | Channel receiving the assembled answer. | | `contextFrom` | `string` | no | Optional channel holding a retrieval-result array rendered as numbered citations. | | `template` | `string` | no | Optional {{answer}}/{{citations}} template controlling the layout. | ### fieldMapper Remap an object channel's fields (by dotted path) into a new object. Category: data. Use: `.component(id, components.fieldMapper({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `from` | `string` | yes | Channel holding the source object. | | `into` | `string` | yes | Channel receiving the remapped object. | | `mapping` | `Record` | yes | { outKey: inKeyPath } map; inKeyPath is a dotted path into the source. | ### fieldExtractor Extract a scalar from a channel: follow an optional dotted path, and (finalOnly) reduce an agent reasoning trace to the text after the last "final:" marker. Category: data. Use: `.component(id, components.fieldExtractor({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `from` | `string` | yes | Channel holding the source value. | | `into` | `string` | yes | Channel receiving the extracted scalar. | | `path` | `string` | no | Optional dotted path descended into the from value (else the whole value). | | `finalOnly` | `boolean` | no | When true, if the result is a string with a "final:" marker, keep only the text after the last marker (trimmed). Defaults to false. | ### bm25Retriever Lexical BM25 ranking of a corpus against a query; keep the top-k by score. Category: retrieval. Use: `.component(id, components.bm25Retriever({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `query` | `string` | yes | Channel holding the query text (falls back to this literal when the channel is empty). | | `into` | `string` | yes | Channel receiving the top-k { id, content, score } array. | | `k` | `number` | no | Number of results to keep (default 4). | | `docs` | `LexicalDoc[]` | yes | The corpus ({ id, content }[]) to rank. | | `k1` | `number` | no | BM25 term-frequency saturation. Defaults to 1.2. | | `b` | `number` | no | BM25 length-normalization. Defaults to 0.75. | ### keywordRetriever Lexical keyword-overlap ranking: score each doc by the fraction of distinct query terms it contains. Category: retrieval. Use: `.component(id, components.keywordRetriever({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `query` | `string` | yes | Channel holding the query text (falls back to this literal when the channel is empty). | | `into` | `string` | yes | Channel receiving the top-k { id, content, score } array. | | `k` | `number` | no | Number of results to keep (default 4). | | `docs` | `LexicalDoc[]` | yes | The corpus ({ id, content }[]) to rank. | ### sentenceWindowSplitter Split text into overlapping windows of whole sentences (a sliding window with an explicit stride). Category: splitter. Use: `.component(id, components.sentenceWindowSplitter({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `from` | `string` | yes | Channel holding the text to split. | | `into` | `string` | yes | Channel receiving the string[] of sentence windows. | | `windowSize` | `number` | no | Sentences per window. Defaults to 3. | | `stride` | `number` | no | Sentences advanced between windows (1 <= stride <= windowSize). Defaults to 1. | ### languageDetector Heuristic language detection (en/fr/es/de/it/und) by stop-word hits, with an optional confidence score. Category: text. Use: `.component(id, components.languageDetector({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `from` | `string` | yes | Channel holding the text to classify. | | `into` | `string` | yes | Channel receiving the detected language code (or "und"). | | `confidenceInto` | `string` | no | Optional channel receiving the winning language's share of hits in [0, 1]. | ### metadataFilter Keep the items of an array channel whose dotted-path field satisfies a predicate. Category: data. Use: `.component(id, components.metadataFilter({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `from` | `string` | yes | Channel holding the array to filter. | | `into` | `string` | yes | Channel receiving the filtered array. | | `field` | `string` | yes | Dotted path into each item compared by the predicate. | | `op` | `"equals" \| "notEquals" \| "contains" \| "exists" \| "absent" \| "gt" \| "gte" \| "lt" \| "lte"` | yes | The predicate operator. | | `value` | `unknown` | no | The comparison value (required except for exists/absent). | ### listJoiner Combine several array channels into one list by concat, union (dedupe) or interleave. Category: data. Use: `.component(id, components.listJoiner({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `fromChannels` | `string[]` | yes | Channels whose array values are combined. | | `into` | `string` | yes | Channel receiving the combined array. | | `mode` | `"concat" \| "union" \| "interleave"` | no | Combine mode. Defaults to "concat". | ### mergeRanker Fuse several retrieval-result streams into one ranking with Reciprocal Rank Fusion (RRF). Category: retrieval. Use: `.component(id, components.mergeRanker({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `fromChannels` | `string[]` | yes | Channels each holding a retrieval-result array to fuse. | | `into` | `string` | yes | Channel receiving the fused { id, content, score } array. | | `idKey` | `string` | no | Object field identifying items across lists. Defaults to "id". | | `k` | `number` | no | Keep only the top-k fused results (default: keep all). | | `rrfK` | `number` | no | Reciprocal Rank Fusion constant. Defaults to 60. | ### evaluator Score actual vs expected text (token-F1 / set overlap / exact match), with an optional pass flag. Category: evaluation. Use: `.component(id, components.evaluator({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `expectedFrom` | `string` | yes | Channel holding the expected/reference text. | | `actualFrom` | `string` | yes | Channel holding the actual/candidate text. | | `into` | `string` | yes | Channel receiving the numeric score in [0, 1]. | | `metric` | `"tokenF1" \| "overlap" \| "exact"` | no | Scoring metric. Defaults to "tokenF1". | | `passInto` | `string` | no | Optional channel receiving a boolean score >= threshold. | | `threshold` | `number` | no | Pass threshold for passInto. Defaults to 0.5. | ### chatMessageBuilder Assemble a role-tagged chat-message array ([{ role, content }]) an LLM generator consumes. Category: generation. Use: `.component(id, components.chatMessageBuilder({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `into` | `string` | yes | Channel receiving the [{ role, content }] array. | | `messages` | `ChatMessageSpec[]` | yes | Ordered specs ({ role, content?\|contentFrom? }); content is rendered through the {{var}} template engine. | | `systemFrom` | `string` | no | Optional channel prepended as a leading system message when non-empty. | ### conditionalRouter Multi-branch rule routing over the channels by dotted-path predicates (pairs with a conditional edge). Category: routing. Use: `.component(id, components.conditionalRouter({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `into` | `string` | yes | Channel the chosen route string is written into. | | `defaultRoute` | `string` | yes | Route emitted when no branch matches. | | `branches` | `ConditionalRouterBranch[]` | yes | Ordered branches ({ when: { field, op, value? }, route }); the first match wins. | ### documentWriter Append documents into an in-state document store array (optionally de-duplicating by a field). Category: writer. Use: `.component(id, components.documentWriter({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `from` | `string` | yes | Channel holding the incoming documents array to append. | | `into` | `string` | yes | Channel receiving the accumulated store array. | | `store` | `string` | no | Channel holding the current store. Defaults to into. | | `dedupeBy` | `string` | no | Optional object field to de-duplicate the merged store by. | ### httpFetch Integration (vendor I/O): perform a real HTTP request via global fetch, writing { status, ok, body, json }. Never throws — non-2xx is surfaced via status/ok; an error/timeout writes { ok: false, error }. Category: integration. Use: `.node(id, components.httpFetch({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `url` | `string` | no | A literal URL to fetch (mutually exclusive with urlFrom). | | `urlFrom` | `string` | no | A channel whose value supplies the URL (takes precedence when its channel is set). | | `into` | `string` | yes | Channel receiving the { status, ok, body, json } result. | | `method` | `string` | no | HTTP method. Defaults to "GET". | | `headers` | `Record` | no | Request headers sent with the call. | | `body` | `string` | no | Request body (sent verbatim) for non-GET methods. | | `timeoutMs` | `number` | no | Abort the request after this many milliseconds (drives an AbortController). | | `fetchImpl` | `HttpFetchImpl` | no | The transport to call. Defaults to the real globalThis.fetch; inject a fake to stay offline. | ### webSearch Integration (vendor I/O): run a real web search (default: Tavily connector behind TAVILY_API_KEY), writing { results, note? }. Degrades gracefully with no network call (empty results + note) when the key is absent. Category: integration. Use: `.node(id, components.webSearch({...}))`. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `query` | `string` | no | A literal query (mutually exclusive with queryFrom). | | `queryFrom` | `string` | no | A channel whose value supplies the query (takes precedence when its channel is set). | | `into` | `string` | yes | Channel receiving the { results, note? } outcome. | | `k` | `number` | no | Number of results to request. Defaults to 3. | | `searchImpl` | `WebSearchImpl` | no | The search implementation to call. Defaults to a real Tavily connector behind TAVILY_API_KEY (no network when the key is absent). | | `transport` | `WebSearchTransport` | no | HTTP transport the default Tavily connector posts through. Defaults to globalThis.fetch; inject a fake to stay offline. Ignored when searchImpl is supplied. | # Events ## Run events Subscribe with `app.onEvent(handler)`, or `onEvent` in the catalog runner's options. Every event has `type`, `runId` and `timestamp`. | `type` | Extra fields | When | | --- | --- | --- | | `node_started` | `nodeId` | A node starts. | | `node_completed` | `nodeId`, `output` | A node finishes; `output` is the update it returned. | | `node_failed` | `nodeId`, `error`, `attempt`, `category` | A node threw. With a `retryPolicy`, once per attempt. | | `node_error_routed` | `nodeId`, `toNodeId`, `errorEdgeId`, `error`, `category` | Retries are spent and the run follows an error edge. | | `run_suspended` | `nodeId`, `reason` | The run stopped: `human-gate`, `interrupt` (tool approval), `timer` or `signal`. | | `run_resumed` | `nodeId` | A suspended run continues. | | `run_completed` | `finalState` | The run finished. | | `run_failed` | `error` | The run failed. | | `run_cancelled` | `nodeId` | The run was cancelled before `nodeId`. | | `token_delta` | `nodeId`, `messageId`, `delta`, `parentRunId`, `spawnId` | A model token, when token streaming is on. `parentRunId` and `spawnId` are set only for a `mapAgents` sub-agent. Not saved in checkpoints. | `category` classifies a failure, so an error branch can treat a transient error (a timeout) and a permanent one differently. Values of channels marked `noLog: true` are masked in every event. ## Stream events `app.stream(input, mode)` yields one of these, depending on `mode`: | Mode | `type` | Fields | | --- | --- | --- | | `"messages"` | `message_delta` | `delta` (text), `nodeId`, `messageId` | | `"messages"` | `tool_call` | `toolId`, `input`, `nodeId`: a tool call the model made | | `"updates"` | `state_update` | `nodeId`, `delta` (the channels the node changed) | | `"values"` | `state_value` | `state` (the full state) | | `"debug"` | `debug` | `nodeId`, `payload` (a run event) | See [Streaming](../guides/streaming.md). # Errors SDK errors carry a stable `code`, a `hint` that says how to fix the problem, and a `docUrl` that points to its entry below. `error.format()` prints all three. ```ts try { createGraph({ name: "broken" }).edge("a", "b").compile(); } catch (error) { if (error instanceof AiluSdkError) console.error(error.format()); } ``` ## Building a graph ### AILU_GRAPH_COMPILE `GraphCompileError`. The graph is invalid: an edge to a node that doesn't exist, a missing entry node, an unknown channel, and so on. The message lists every problem with its own code. Fix each one, or use `safeCompile()` to get the problems as a value. ### AILU_DUPLICATE_NODE `DuplicateNodeError`. Two nodes have the same id. Give each node a unique id. ### AILU_MISSING_HANDLER `MissingHandlerError`. A `.node()` was added without a handler. Pass one: `.node("id", async () => ({}))`. ### AILU_UNKNOWN_NODE `UnknownNodeError`. An edge, a condition or `entry()` names a node that doesn't exist. Add the node before you reference it. ### AILU_GOVERNANCE_MIDDLEWARE_REJECTED `GovernanceMiddlewareRejectedError`. An agent's `middleware` list contains a governance kind (redaction, approval gate, file policy). Those are applied by the engine and can't be set or removed per agent. Keep only `structuredOutput`, `terse`, `contextBudget`, `compress` and `reflection`. ## Running a graph ### AILU_RUST_ENGINE_REQUIRED `RustEngineRequiredError`. The native engine didn't load, so the graph can't run. Check that your platform is supported ([Install](../install.md#requirements)). This error also appears when a graph uses a removed option: an `approvalEngine` on `agentNode`, or `AILU_SDK_ENGINE=ts`. See [Migrating](./migration.md). ### AILU_NO_SUSPENDED_STATE `ResumeStateNotFoundError`. `resume`, `approveAndResume` or `signal` was called with a run id this `CompiledGraph` doesn't hold: another instance started it, the process restarted, or the run already finished. To resume across processes, use the catalog runner ([Long-running runs](../guides/long-running.md)). ### AILU_APPROVER_REQUIRED `ApproverRequiredError`. `approveAndResume` was called without `resolvedBy`, or with an empty one. Pass the person who approved, from your authenticated session: `approveAndResume(runId, { approvedTools, resolvedBy: "alice@example.com" })`. ### AILU_APPROVAL_NOT_GRANTED `ApprovalNotGrantedError`. `resumeCatalogGraph` was given an `approvalEngine`, and the engine doesn't authorize the resume: a request the run waits on is still pending, a human gate was rejected, a tool in `approvedTools` has no request approved by the person the grant names, or the run was started without the engine. `error.problems` lists each one. Nothing ran; resolve the requests with `approve(id, approver)` or `reject(...)` and resume again. ### AILU_LEGACY_TS_AGENT_HANDLER An agent handler from the removed TypeScript engine was called. Build agents with `agentNode` and run them with `app.run()`. ## Models ### AILU_UNKNOWN_PROVIDER `UnknownProviderError`. A model or a `provider` option names a provider Ailu doesn't know, such as `"groq:llama-3"`. Use one of `openai`, `anthropic`, `google`, `mistral`, `openrouter`, `minimax`, `huggingface`, `ollama`, `lmstudio`, or `model.openaiCompatible({ baseURL })` for any OpenAI-compatible server. ### AILU_MISSING_PROVIDER_KEY `MissingProviderKeyError`. `model.(...).invoke()` found no key. Set the variable the message names, or `AILU_LLM_MOCK=1` to run offline. ### AILU_NO_PROVIDER_IN_ENV `NoProviderInEnvError`. A tier-only model (`model.fast`) found no provider key at all. Set one of the variables in [Models and providers](./models.md), or name a provider. ## Messages from the engine Some errors come from the engine as plain messages: | Message starts with | Meaning | | --- | --- | | `unknown model provider '...'` | A graph definition names a provider Ailu doesn't know. See `AILU_UNKNOWN_PROVIDER`. | | `no API key for provider '...'` | An agent's provider has no key. Set the variable it names, or `AILU_LLM_MOCK=1`. | | `no model provider API key found` | A tier-only agent found no key at all. | | `Ollama is not enabled` / `LM Studio is not enabled` | Set `AILU_USE_OLLAMA=1` / `AILU_USE_LMSTUDIO=1`. | | `condition '...' failed` | A conditional-edge predicate threw; the run fails rather than guess a branch. | # Environment variables The engine reads these at the moment it needs them, so a change applies to the next run. ## Model providers | Variable | Used for | | --- | --- | | `ANTHROPIC_API_KEY` | `model.anthropic(...)` | | `OPENAI_API_KEY` | `model.openai(...)`, and `createEmbeddings({ provider: "openai" })` | | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | `model.gemini(...)` | | `MISTRAL_API_KEY` | `model.mistral(...)`, and `createEmbeddings()` (Mistral is the default provider) | | `OPENROUTER_API_KEY` | `model.openrouter(...)` | | `MINIMAX_API_KEY` | `model.minimax(...)` | | `HF_TOKEN` or `HUGGINGFACE_API_KEY` | `model.huggingface(...)` | | `AILU_USE_OLLAMA=1`, `AILU_OLLAMA_BASE_URL` | Turn on `model.ollama(...)`; the server URL (default `http://localhost:11434/v1`). | | `AILU_USE_LMSTUDIO=1`, `AILU_LMSTUDIO_BASE_URL` | Turn on `model.lmstudio(...)`; the server URL (default `http://localhost:1234/v1`). | | The variable you name in `apiKeyEnv` | The key for a `model.openaiCompatible(...)` endpoint. | | `AILU_LLM_MOCK=1` | Offline mode: calls without a key answer from the deterministic mock instead of failing. For tests and CI. | | `AILU_HTTP_READ_TIMEOUT_SECS` | How long to wait for a model response. Default 600. | ## Governance | Variable | Used for | | --- | --- | | `AILU_SECRETS_POLICY=block` | Fail a model call that contains a secret, instead of masking the secret. | | `AILU_SECRETS_REDACTOR_URL`, `AILU_SECRETS_REDACTOR_TOKEN` | An extra secrets-detection service, after the built-in one. | | `AILU_PII_REDACTOR_URL`, `AILU_PII_REDACTOR_TOKEN` | A personal-data redaction service. | | `AILU_PII_REDACTOR_FAIL_CLOSED=1` | Don't send the text when the redaction service is unreachable. | | `AILU_LLM_RECORD=1` | Record model calls and timestamps, for [replay](../guides/governance.md#replay-a-run). | ## Agents and retrieval | Variable | Used for | | --- | --- | | `AILU_FS_BACKEND_URL`, `AILU_FS_BACKEND_TOKEN` | A storage service for the agents' virtual filesystem, instead of memory. | | `AILU_RERANK_ENDPOINT` | A cross-encoder service for `components.reranker`. | | `AILU_LLMLINGUA_URL`, `AILU_LLMLINGUA_RATE`, `AILU_LLMLINGUA_MIN_CHARS` | A prompt-compression service for the `compress` middleware; the rate (default 0.5) and the shortest prompt it compresses. | | `TAVILY_API_KEY` | `components.webSearch`. | ## Observability | Variable | Used for | | --- | --- | | `AILU_OTEL_EXPORTER_URL` | The default endpoint of `exportTracesToOtlp`. | # For AI coding agents If an AI coding agent writes your Ailu code, give it these. ## Start here - [`/llms.txt`](pathname:///llms.txt): a short index of the SDK, generated from the SDK itself: the builder, the models, every component and its parameters, the error codes. - [`/llms-full.txt`](pathname:///llms-full.txt): this whole documentation as one text file. `generateLlmsTxt()` returns the same index from the installed package, so it always matches the version you have. ## Rules that save a round trip - Configure agents with `model: model.("")` or `model: "provider:model"`. `llm:` and `DefaultLLMGateway` are deprecated and ignored. - Read an agent's answer with `finalAnswer(result)`. - Name a tool's input in `jsonSchema`; the model reads it. - Approve tools with `approveAndResume(runId, { approvedTools: ["name"], resolvedBy })`. - `resume` works only on the `CompiledGraph` that started the run. Across processes, use `runCatalogGraph` and `resumeCatalogGraph`. - Run tests with `AILU_LLM_MOCK=1`. ## Check your own work | Tool | Tells you | | --- | --- | | `app.compile()` / `safeCompile()` | Whether the graph is valid, with a code and a fix for each problem. | | `componentSchemas()` | A JSON Schema for the parameters of every component. | | `app.explain(runId)`, `explainRun(state)` | Why a run is suspended or failed, and the exact call that continues it. | | `error.code`, `error.hint`, `error.docUrl` | What went wrong, how to fix it, and where it is documented. See [Errors](./errors.md). | ```ts const explanation = app.explain(paused.runId); console.log(explanation.summary); ``` # Migrating Older code, tutorials and AI-generated snippets may use the APIs on the left. The rest of these docs only use the right-hand column. ## The project was renamed Ailu | Before | Now | | --- | --- | | `@adriane-ai/*` npm packages | `@ailu-ai/*` | | `npm create adriane` | `npm create @ailu-ai@latest` | | Python `adriane` | `pip install ailu`, `import ailu` | | `ADRIANE_*` environment variables | `AILU_*` (the old names are no longer read) | | `ADR_*` error codes | `AILU_*` | | `adriane` CLI | `ailu` | ## Models | Before | Now | | --- | --- | | `agentNode({ llm: new DefaultLLMGateway() })` | `agentNode({ model: model.anthropic("claude-sonnet-4-6") })`. `llm` is ignored. | | `MockLLMProviderAdapter`, scripted gateways in tests | `AILU_LLM_MOCK=1` | | `agentNode({ provider: "openai", model: "gpt-4o" })` | `agentNode({ model: model.openai("gpt-4o") })` or `model: "openai:gpt-4o"` | | `agentNode({ tier: "fast" })` | `agentNode({ model: model.fast })` | | `import { openai } from "@ailu-ai/model-openai"` | `import { model } from "@ailu-ai/graph-sdk"`, then `model.openai(...)` | | A run without an API key silently used a mock | It fails and names the variable to set. Use `AILU_LLM_MOCK=1` to run offline on purpose. | | An unknown `provider` (`"groq"`) ran on Anthropic | It fails with [`AILU_UNKNOWN_PROVIDER`](./errors.md#ailu_unknown_provider). | | `streamAgentTokens(...)` | `app.stream(input, "messages")` | | Reading the answer from the `messages` channel | `finalAnswer(out.channels.agentResult)` | ## Approvals and resume | Before | Now | | --- | --- | | `approveAndResume(runId, ["refund"])`, or `resolvedBy` left out | `approveAndResume(runId, { approvedTools: ["refund"], resolvedBy: "alice@example.com" })`. `resolvedBy` is required. | | Checking each request with `getById` before `resumeCatalogGraph` | `resumeCatalogGraph(definition, state, { approvalEngine })` checks it and throws `ApprovalNotGrantedError` | | `agentNode({ approvalEngine })` | `suspendForApproval: true` with `approveAndResume`, or the catalog runner: `runCatalogGraph(app.definition, { approvalEngine, tools })` | | `toolNode` with a gated tool | Give the tool to an `agentNode` with `suspendForApproval: true` | | `.checkpointer(...)`, a custom `Checkpointer` | `runCatalogGraph` / `resumeCatalogGraph` and your own storage. See [Long-running runs](../guides/long-running.md). | | `resume` in a new process | `resumeCatalogGraph(definition, savedState)` | ## Engine and routing | Before | Now | | --- | --- | | `AILU_SDK_ENGINE=ts`, the TypeScript engine | Removed. The Rust engine is the only engine. | | A handler returning `Command { goto }` | `conditionalEdge` to route, `fanOut` or `mapAgents` to run in parallel | | `ailu run` to execute a graph | `ailu run` is a dry run. Run your TypeScript code. | | `@ailu-ai/knowledge`, `@ailu-ai/okf` | Not part of the public SDK. For retrieval, see [RAG](../guides/rag.md). | # Glossary | Term | Meaning | | --- | --- | | **Agent node** | A node that runs an LLM in a loop, with tools. `agentNode`. | | **Approval request** | A gated tool call waiting for a human, listed in the agent's `approvalRequests`. | | **Attestation** | A signed record of an approval decision, chained to the previous one. | | **Catalog runner** | `runCatalogGraph` / `resumeCatalogGraph`: runs a graph from its JSON definition and returns its state, so it can resume anywhere. | | **Channel** | A named piece of run state, with a type label, a default and a reducer. | | **Checkpoint** | The saved state of a run after a node. A run resumes from its last checkpoint. | | **Component** | A ready-made node that runs inside the engine, such as a retriever or a prompt template. | | **Compiled graph** | What `compile()` returns: a checked graph you can run many times. | | **Council** | A graph where several agents answer, others rank the anonymized answers, and a chair decides. | | **Edge** | A link from one node to the next. Plain, conditional (with a named predicate) or error. | | **Engine** | The Rust runtime that executes graphs, shipped inside the npm package. | | **Fan-out** | Running several branches at the same time, then joining. | | **Graph definition** | A graph as plain JSON (`app.definition`), the same format YAML compiles to. | | **Human gate** | A node that suspends the run until you resume it. | | **Offline mode** | `AILU_LLM_MOCK=1`: agents without a key answer from a deterministic mock. | | **Reducer** | How a channel combines updates: replace, append or merge. | | **Replay** | Re-running a recorded run from its record, without calling a model. | | **Resolved by** | The person who approved a tool call, recorded with the approval. | | **Run** | One execution of a graph, with its own `runId` and state. | | **Subgraph** | A graph used as one node of another graph. | | **Suspended** | The status of a run waiting for a human, a date or a signal. | | **Tier** | A capability level (`fast`, `balanced`, `frontier`, `creative`) mapped to a model per provider. | | **Tool** | A function an agent can call. Gated tools need a human's approval. |