# Warlock AI — full skills

> Package: `@warlock.js/ai`

> Generated artifact. Concatenates every SKILL.md and reference file under `@warlock.js/ai/skills/`. Re-run `node scripts/generate-llms.mjs` after any change.

## ai-basics  `@warlock.js/ai/ai-basics/SKILL.md`

---
name: ai-basics
description: 'Start with @warlock.js/ai — provider-agnostic core for agents / tools / workflows / supervisors. 4-primitive ladder (agent → workflow → supervisor → orchestrator v2). Every primitive returns {data, error, usage, report}. Triggers: `ai.agent`, `ai.tool`, `ai.workflow`, `ai.supervisor`, `ai.systemPrompt`, `ExecuteResult`, `BaseReport`, `AIError`; ''which AI primitive do I use'', ''what is warlock ai'', ''pick an AI skill''; typical import `import { ai } from "@warlock.js/ai"`. Skip: agent details — `@warlock.js/ai/run-ai-agent/SKILL.md`; competing libs `langchain`, `llamaindex`, `ai` (Vercel SDK); raw `openai` / `@anthropic-ai/sdk`.'
---

# AI foundations

Provider-agnostic core for building AI primitives in TypeScript. Adapters live in sibling packages — all five first-party adapters ship today: `@warlock.js/ai-openai`, `-anthropic`, `-bedrock`, `-google`, `-ollama`.

> This skill is the AI **map** — read it first, then load the specific skill for the task.

## The 4-primitive ladder

```
ai.agent()        →  single task, stateless                    [shipped]
ai.workflow()     →  static predefined steps, resumable        [shipped]
ai.supervisor()   →  multi-agent dynamic routing, resumable    [shipped]
ai.orchestrator() →  stateful — owns session/history/ctx       (v2)
```

Each primitive is an escape hatch to the next level of complexity. Users start low, graduate upward only when needed. Every primitive returns the same result envelope — canonical destructure `{ data, error, usage, report }` (the shared `BaseResult` guarantees `usage` + optional `error`; each primitive adds `data` + `report`). Workflows and supervisors expose `.asTool()` so an agent can call them inside its tool loop; compose freely.

## Foundations

1. **Public API is functional factories.** Use `ai.agent({...})`, `ai.tool({...})`, `ai.workflow({...})`, `ai.step({...})`, `ai.supervisor({...})`, `ai.systemPrompt()`, `ai.persona()`, `ai.instruction()`. Never `new Agent()`.
2. **Adapter entry points are classes.** `new OpenAISDK({ apiKey })` from [`@warlock.js/ai-openai/setup-openai/SKILL.md`](@warlock.js/ai-openai/setup-openai/SKILL.md).
3. **Schemas everywhere are `StandardSchemaV1<T>`.** Recommended: [`@warlock.js/seal`](@warlock.js/seal/seal-basics/SKILL.md) — `v.object({...})`. Zod, Valibot, hand-rolled all interop.
4. **`execute()` never throws.** Errors funnel into `result.error` as a typed `AIError` subclass. Same for `stream.result`, `workflow.execute()` / `resume()`, `supervisor.execute()` / `resume()`. See [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md).
5. **Each `execute()` call is isolated.** Fresh internal execution instance per call.
6. **Every error is an `AIError`.** Plain `Error` never leaks. Branch on `error.code` (stable string), `error.category` (coarse), or `instanceof`.
7. **Result shape is uniform.** `{ data, error, usage, report }` across every primitive. `report` is a recursive `BaseReport` tree.
8. **Persistence is delegated** to `@warlock.js/cache`. See [`@warlock.js/ai/persist-ai-data/SKILL.md`](@warlock.js/ai/persist-ai-data/SKILL.md).
9. **Logging is delegated** to `@warlock.js/logger`. See [`@warlock.js/ai/log-ai-calls/SKILL.md`](@warlock.js/ai/log-ai-calls/SKILL.md).
10. **`name` on agents is optional.** Anonymous agents get a deterministic `anon_<provider>_<model>` fingerprint.
11. **Every report carries lineage** — `rootRunId` + `parentRunId` + `reportSchemaVersion: 1`.
12. **`version` is dev-curated, `sessionId` is caller-supplied** — both propagate through nested reports.
13. **Cost is computed at emit time as a per-channel breakdown.** Set `pricing` on the model adapter; `Usage.cost` carries `{ input, output, cachedInput?, cachedOutput? }` per trip, rolled up bottom-up.
14. **Every `AIError` carries a coarse `category`** for retry-policy dispatch (`rate-limit`, `auth`, `content-filter`, `schema`, etc.).

## 30-second example

```ts
import { ai } from "@warlock.js/ai";
import { OpenAISDK } from "@warlock.js/ai-openai";

const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! });
const myAgent = ai.agent({ model: openai.model({ name: "gpt-4o-mini" }) });

const { data, text, report, usage, error } = await myAgent.execute("Hello");

if (error) /* typed AIError */ ;
console.log(text, usage.total, report.duration);
```

## Pick a skill

| If the task is about… | Load |
| --- | --- |
| `ai.agent({...})` — single-LLM-turn primitive, structured output, streaming, attachments | [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) |
| `ai.tool({...})` — typed validated functions the model can call | [`@warlock.js/ai/define-ai-tool/SKILL.md`](@warlock.js/ai/define-ai-tool/SKILL.md) |
| `ai.systemPrompt()` / `ai.persona()` / `ai.instruction()` — composable prompts with placeholders | [`@warlock.js/ai/write-system-prompt/SKILL.md`](@warlock.js/ai/write-system-prompt/SKILL.md) |
| `ai.workflow({...})` — durable resumable pipelines with steps, routing, retry | [`@warlock.js/ai/run-ai-workflow/SKILL.md`](@warlock.js/ai/run-ai-workflow/SKILL.md) |
| `ai.supervisor({...})` — multi-intent routing, fan-out, evaluate loops | [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) |
| `sdk.embedder({...})` — text-to-vector for RAG tools, vector ingest | [`@warlock.js/ai/embed-text/SKILL.md`](@warlock.js/ai/embed-text/SKILL.md) |
| Agent middleware — `budget` / `guardrail` / `semanticCache` + custom hooks | [`@warlock.js/ai/attach-ai-middleware/SKILL.md`](@warlock.js/ai/attach-ai-middleware/SKILL.md) |
| Snapshot resume + semantic cache via `@warlock.js/cache` | [`@warlock.js/ai/persist-ai-data/SKILL.md`](@warlock.js/ai/persist-ai-data/SKILL.md) |
| Configuring framework logging | [`@warlock.js/ai/log-ai-calls/SKILL.md`](@warlock.js/ai/log-ai-calls/SKILL.md) |
| `AIError` hierarchy, `error.code` / `error.category`, retry patterns | [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md) |
| Choosing a provider adapter (OpenAI / OpenRouter / Anthropic / Bedrock / Ollama) | [`@warlock.js/ai/pick-ai-provider/SKILL.md`](@warlock.js/ai/pick-ai-provider/SKILL.md) |

## Package layout

```
@warlock.js/ai               — agent, tool, workflow, supervisor, system-prompt, errors, middleware
@warlock.js/ai-openai        — OpenAI SDK adapter (model + embedder); also OpenRouter / Azure via baseURL
@warlock.js/ai-anthropic     — Anthropic / Claude adapter (Messages API)
@warlock.js/ai-bedrock       — AWS Bedrock adapter (Converse API + Titan embeddings)
@warlock.js/ai-google        — Google / Gemini adapter (@google/genai + batch embeddings)
@warlock.js/ai-ollama        — Ollama adapter for local models
```

Runtime deps: `@warlock.js/cache` (persistence), `@warlock.js/logger` (logging), `@warlock.js/seal` (recommended schema lib).

## When NOT to use this skill

- Code importing `openai` / `@anthropic-ai/sdk` directly without going through `@warlock.js/ai` — those are raw provider SDKs.
- Generic JS/TS questions unrelated to agent / tool / workflow / supervisor wiring.

## Design references

- `domains/ai/design/decisions.md` — locked architectural decisions with rationale
- `domains/ai/design/workflow.md` — workflow spec
- `domains/ai/design/supervisor.md` — supervisor spec
- `domains/ai/design/execution-result.md` — unified `ExecuteResult` + recursive `BaseReport` tree
- `domains/ai/conventions/errors.md` — framework-vs-consumer-app error split


## attach-ai-middleware  `@warlock.js/ai/attach-ai-middleware/SKILL.md`

---
name: attach-ai-middleware
description: 'Wire agent middleware — ai.middleware.budget (token / USD caps), ai.middleware.guardrail (pre / post content checks), ai.middleware.semanticCache (exact + vector cache), plus authoring custom hooks (execute / trip / tool). Triggers: `ai.middleware.budget`, `ai.middleware.guardrail`, `ai.middleware.semanticCache`, `ai.middleware.compose`, `ai.middleware.forTool`, `AgentMiddleware`, `BudgetExceededError`, `GuardrailViolationError`; ''cap token cost'', ''block pii in prompts'', ''semantic cache before LLM'', ''write custom hook''; typical import `import { ai } from "@warlock.js/ai"`. Skip: agent lifecycle — `@warlock.js/ai/run-ai-agent/SKILL.md`; cache drivers — `@warlock.js/ai/persist-ai-data/SKILL.md`; competing libs `langchain` callbacks.'
---

# Middleware — agent-level pipeline

Cross-cutting concerns wrapped around an agent run at three granularities: `execute`, `trip`, `tool`. One middleware = one object. Ships with `budget`, `guardrail`, and `semanticCache` built-ins.

## Install order at a glance

```ts
import { ai } from "@warlock.js/ai";
import { OpenAISDK } from "@warlock.js/ai-openai";
import { cache } from "@warlock.js/cache";

const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! });

ai.config({ defaultStore: cache.driver("redis", { client: redisClient }) });

const myAgent = ai.agent({
  model: openai.model({ name: "gpt-4o" }),
  middleware: [
    ai.middleware.semanticCache({
      embedder: openai.embedder({ name: "text-embedding-3-small" }),
      threshold: 0.95,
    }),
    ai.middleware.budget({ maxTokens: 50_000 }),
    ai.middleware.guardrail({
      inputCheck: async (text) =>
        text.match(/\bSSN\b/) ? { ok: false, reason: "pii" } : { ok: true },
    }),
  ],
});
```

**Canonical order: `[cache, budget, guardrail, observability]`** — see "Ordering invariants" below.

## `ai.middleware.budget(options)`

Cumulative token / USD cap across all trips of one execution.

```ts
ai.middleware.budget({
  maxTokens: 50_000,
  maxCostUSD: 0.5,
  pricing: { "gpt-4o": { inputPer1K: 0.005, outputPer1K: 0.015 } },
  onExceeded: "abort", // or "warn"
});
```

Breach → `BudgetExceededError` on `result.error`. Inspect `error.unit` (`"tokens" | "usd"`), `error.limit`, `error.actual`. Warn mode logs and continues — useful for measuring before enforcing.

USD only fires when both `maxCostUSD` AND a matching `pricing[modelName]` entry exist.

## `ai.middleware.guardrail(options)`

Pre / post content checks.

```ts
ai.middleware.guardrail({
  inputCheck: async (text, ctx) =>
    text.includes("forbidden") ? { ok: false, reason: "policy-1" } : { ok: true },
  outputCheck: async (text) =>
    text.length > 10_000 ? { ok: false, reason: "too-long" } : { ok: true },
  name: "pii-guardrail",
});
```

Rejection → `GuardrailViolationError` with `phase: "input" | "output"` and the configured `reason`. Output checks fire BEFORE tool dispatch — a rejected response means the tools it requested are never invoked.

Checks run on every trip (including tool follow-ups and repair attempts). Gate only the first trip via `ctx.tripIndex === 0`.

## `ai.middleware.semanticCache(options)`

Two-tier cache — exact-match key first, vector similarity second. Delegates to any vector-capable `CacheDriver`.

```ts
ai.middleware.semanticCache({
  embedder: openai.embedder({ name: "text-embedding-3-small" }),
  // store optional — falls back to ai.config({ defaultStore })
  store: cache.driver("pg", {
    client: pgPool,
    vector: { dimensions: 1536, index: "hnsw" },
  }),
  threshold: 0.95,
  ttlMs: 60 * 60 * 1000,
  namespace: "support-faq",
});
```

**Driver requirements.** Must support `similar()` — `pg` (with `vector` config), `redis` (with RediSearch), or memory drivers for dev. Without similarity → `CacheUnsupportedError` at first vector op.

**How it works.**
- **Exact-match** — FNV hash over the message list. `store.get(hash)` returns an instant hit.
- **Vector-match** — embeds the prompt, calls `store.similar(vector, { topK: 1, threshold })`. Driver uses its native ANN index.
- **Hits** return a synthetic `ModelResponse` with `usage: { input: 0, output: 0, total: 0 }`.
- **Writes** happen at `trip.after` on miss.
- **Trip-zero only** — only first-trip responses are cached. Tool-using loops never serve cached tool-call responses (would infinite-loop).
- **Never use memory drivers in production** — linear scan per query.

## Writing your own middleware

One object. Any subset of three hook maps.

```ts
import type { AgentMiddleware } from "@warlock.js/ai";

const latencyLogger: AgentMiddleware = {
  name: "latency-logger",
  execute: {
    before(ctx) {
      ctx.state.set("latency.start", performance.now());
    },
    after(ctx, result) {
      const start = ctx.state.get("latency.start") as number;
      console.log(`agent ${ctx.agent.name} finished in ${performance.now() - start}ms`);
    },
  },
  trip: {
    before(ctx) {
      ctx.state.set(`latency.trip.${ctx.tripIndex}.start`, performance.now());
    },
    after(ctx) {
      const start = ctx.state.get(`latency.trip.${ctx.tripIndex}.start`) as number;
      console.log(`  trip ${ctx.tripIndex}: ${performance.now() - start}ms`);
    },
  },
};
```

### Rules

- **Never close over mutable state.** Use `ctx.state` — fresh per `execute()` call.
- **Abort with a typed `AIError` subclass.** Never `throw new Error(...)`.
- **Short-circuit by returning from `before`.** Return the level's result type — the pipeline skips the real work and outer `after` hooks still run on your synthetic value.
- **`onError` is opt-in recovery.** Return a value to recover; return `void` to let the error propagate.
- **`log: false`** suppresses framework debug emission for that middleware (the middleware itself still runs).

## Ordering invariants — read before shipping

1. **Cache MUST be outermost when guardrails are present.** Guardrail `trip.after` throws to reject bad output — but `after` hooks run bottom-up. If guardrail is outside the cache, rejection fires AFTER the cache has written the bad response. Canonical order `[cache, budget, guardrail]` keeps rejected outputs out of the cache.
2. **Budget before guardrails.** Guardrails may call classifiers with their own token costs.
3. **Observability last.** It should see the final decision every other middleware made.

## Helpers

### `ai.middleware.compose(...sources)`

Flatten multiple sources into one ordered array. No sorting, no dedup.

```ts
ai.agent({
  model,
  middleware: ai.middleware.compose(standardStack, toolRules, auditMiddleware),
});
```

### `ai.middleware.forTool(name | names, middleware)`

Scope `tool.*` hooks to specific tool names. `execute` and `trip` hooks pass through.

```ts
const scoped = ai.middleware.forTool(["paid_api", "expensive_db"], toolRateLimit({ maxCalls: 5 }));
```

## Caveats

- **`tool.onError` is almost-never-useful.** `ToolContract.invoke()` never throws — errors are captured into `result.error`. `tool.onError` only fires when another middleware's `tool.before`/`tool.after` itself throws. For "the tool itself failed," branch on `result.error` in a `tool.after`.
- **Middleware does NOT observe unregistered tool calls.** When the model asks for a tool the agent wasn't configured with, the pipeline is bypassed and a failed `ToolCall` is recorded directly.
- **`name` must be unique** across an agent's middleware array.
- **Middleware state does NOT cross `agent.execute()` boundaries.** One execute → one fresh `ctx.state`.

## Workflow + middleware — what works today (v1)

- Inside a workflow step with `agent: myAgent` — the agent's own middleware fires normally.
- `workflow.asTool()` called from an agent — the calling agent's `tool`-level middleware wraps the workflow.
- Step-level / workflow-level / supervisor-level middleware does NOT exist yet.

## See also

- [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — agent lifecycle the middleware wraps
- [`@warlock.js/ai/persist-ai-data/SKILL.md`](@warlock.js/ai/persist-ai-data/SKILL.md) — `defaultStore` for semantic cache
- [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md) — `BudgetExceededError` / `GuardrailViolationError`


## define-ai-tool  `@warlock.js/ai/define-ai-tool/SKILL.md`

---
name: define-ai-tool
description: 'Define tools with ai.tool({...}) — typed validated async functions the model can call. Covers name / description / action / mode (feedback / silent) / input / execute, `ctx.artifacts` side-channel, `ToolExecutionError`. Triggers: `ai.tool`, `ToolContract`, `ToolContext`, `ToolCall`, `ToolExecutionError`, `artifactsSchema`, `mode: "silent"`, `workflow.asTool`; ''define a tool'', ''wire tool into agent'', ''tool input validation'', ''side-channel artifacts''; typical import `import { ai } from "@warlock.js/ai"`. Skip: agent loop — `@warlock.js/ai/run-ai-agent/SKILL.md`; supervisor artifacts — `@warlock.js/ai/run-supervisor/SKILL.md`; competing libs `langchain` tools, raw `openai` function-calling.'
---

# `ai.tool()` — typed tool factory

Tools are async functions the model can call by name during a trip loop. Define one with `ai.tool()`, pass it in `agent({ tools: [...] })`, and the agent handles dispatch, input validation, and error surfacing automatically.

## Factory shape

```ts
ai.tool({
  name: string,                                 // stable identifier
  description: string,                          // sent to the model
  version?: string,                             // mirrored onto tool reports
  action?: string | ((input: TInput) => string), // UI label for streaming UX
  mode?: "feedback" | "silent",                 // result feedback control
  input: StandardSchemaV1<TInput>,              // validated before execute
  execute: (input: TInput, ctx?: ToolContext) => Promise<unknown>,
});
```

Returns a `ToolContract<TInput, TOutput>`. One tool can be attached to many agents.

## `description` vs `action`

Two roles, two fields:

- **`description`** — what the LLM reads when deciding whether to call this tool.
- **`action`** — present-progressive UI string surfaced to humans on `agent.tool.calling` / `agent.tool.called` events.

```ts
ai.tool({
  name: "search_catalog",
  description: "Search the product catalog. Returns matching products with SKU, name, price.",
  action: ({ query }) => `Searching the catalog for "${query}"`,
  input: v.object({ query: v.string() }),
  execute: async ({ query }) => searchProducts(query),
});
```

Two forms supported: static string or function. Function form runs after input validation; throws are swallowed (UI strings aren't worth aborting LLM dispatch over).

## Schema via Standard Schema V1

Input is typed as `StandardSchemaV1<T>`. Recommended: `@warlock.js/seal`. Zod / Valibot / hand-rolled all interop.

```ts
import { v } from "@warlock.js/seal";

const searchTool = ai.tool({
  name: "search",
  description: "Search the docs index",
  input: v.object({
    query: v.string(),
    limit: v.number().optional(),
  }),
  execute: async ({ query, limit }) => fetchDocs(query, limit ?? 10),
});
```

## Input validation is automatic

The agent calls `input["~standard"].validate(rawArgs)` before invoking `execute`. Validation failures **do not throw** — the failure is recorded on the trip's `ToolCall.error` and fed back to the model on the next trip as a tool error message. The model gets a chance to correct and retry within the bounded `maxTrips` loop.

## What gets returned to the model

Whatever your `execute` resolves with is `JSON.stringify`'d and sent back as the next trip's `tool` message. Strings pass through unchanged. Throw (or return a rejected promise) to signal failure — the agent records the error on `ToolCall.error` and tells the model.

## `mode` — feedback vs silent

Default `"feedback"`.

- **`mode: "feedback"`** (default) — standard round-trip. Result feeds back into next trip; the model reads it and replies. Use for tools whose output the model needs to narrate: `search_catalog`, `search_knowledge_base`, `ask_questions`.
- **`mode: "silent"`** — fire-and-forget. Result NOT fed back to the model. When EVERY tool call in a single generation is silent, the agent loop terminates after dispatch. Use for pure side-effect tools: `update_state`, `set_locale`, telemetry pings.

```ts
ai.tool({
  name: "update_state",
  description: "Persist customer slot-fill across turns.",
  mode: "silent",
  input: v.object({ preferences: v.array(v.string()).optional() }),
  execute: async (patch, ctx) => {
    ctx.artifacts.stateUpdate = patch;
    return { ok: true };  // model never sees this
  },
});
```

**All-silent rule.** The loop terminates only when EVERY tool call this trip is silent. Silent + feedback in the same generation → loop continues (the feedback tool still round-trips, the silent one piggybacks).

**Constraints for silent tools.** MUST be cheap + fast (HTTP request still open until dispatch resolves), should be idempotent (no surface to communicate failure to the model), side-effect-only.

## Tool context — `ctx.artifacts` side-channel

`execute` accepts an optional **second argument** — a `ToolContext` with a mutable `artifacts` bag and the dispatch's `signal`. Use it to capture system-only data (renderable blocks, citations, files, telemetry, soft signals) that the LLM should NOT see.

```ts
ai.tool({
  name: "search_catalog",
  input: v.object({ query: v.string() }),
  execute: async (input, ctx) => {
    const items = await searchItems(input.query);

    // Side-channel — never reaches the LLM.
    ctx.artifacts.blocks ??= [];
    ctx.artifacts.blocks.push({ type: "items", itemIds: items.map(i => i.id) });

    // LLM-visible — what the agent reasons over.
    return { total: items.length };
  },
});
```

Under a supervisor: bag starts empty per iteration, accumulates writes from all tool calls, merges into state at iteration end (auto-spread by default; `finalizeArtifacts` for concat / dedupe). See [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md).

Standalone (no supervisor): framework supplies `{ artifacts: {} }`. Mutations are harmless no-ops.

## Type contract for artifacts

The supervisor declares an `artifactsSchema`; tools registered to it inherit typed `ctx.artifacts.*`. Standalone tools fall back to `Record<string, unknown>`.

```ts
ai.supervisor({
  artifactsSchema: v.object({
    blocks: v.array(blockSchema).optional(),
    citations: v.array(citationSchema).optional(),
  }),
  // tools see ctx.artifacts typed as { blocks?, citations? }
});
```

## Error categorization

`invoke()` never throws — failures surface on the returned `error` field, and the agent records them on the dispatch's `ToolCall.error`. The error class depends on what failed:

- **Input schema rejected the model's args** → `SchemaValidationError` (`code: "SCHEMA_VALIDATION_FAILED"`), `issues` preserved. NOT wrapped in `ToolExecutionError`.
- **Schema's own `validate()` threw** → `SchemaValidationError` wrapping the cause.
- **Your `execute()` threw** → `ToolExecutionError` (`code: "TOOL_EXEC_FAILED"`, category `tool`) with `toolName`, and the thrown value on `error.cause`.

`ToolExecutionError` carries `toolName` always; `tripIndex` is stamped by the agent that dispatched it. The validation failure is fed back to the model on the next trip so it can correct within the `maxTrips` loop.

See [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md).

## Inspecting tool calls

```ts
const result = await myAgent.execute("Pick a city and tell me the weather.");

const toolCalls = result.report.children.filter((c) => c.type === "tool");

for (const call of toolCalls) {
  console.log(call.tripIndex, call.name, call.input, call.output, call.duration);
}
```

Tool dispatches are child `BaseReport` nodes on `report.children` (not a separate `report.toolCalls` field) — filter by `c.type === "tool"`. Each `ToolCall` is a `BaseReport & { type: "tool", tripIndex, input, output?, error? }`, so it carries `name` / `startedAt` / `endedAt` / `duration` from the report base.

## Events

- `agent.tool.calling` — `{ tool, input, tripIndex }`
- `agent.tool.called` — `ToolCall & { tool }` (full record)
- `agent.tool.failed` — `{ tool, input, error, tripIndex }`

Subscribe at factory / instance / per-call.

## Pattern — workflow as a tool

```ts
const wrapped = myWorkflow.asTool({
  description: "Run the catalog ingestion workflow",
  inputSchema: v.object({ url: v.string() }),
});

const agent = ai.agent({ model, tools: [wrapped] });
```

Workflow errors surface as `ToolExecutionError` with `cause` pointing at the original `WorkflowError` subclass.

## See also

- [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — how tools plug into the trip loop
- [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md) — error hierarchy
- [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) — artifacts under a supervisor
- [`@warlock.js/ai/run-ai-workflow/SKILL.md`](@warlock.js/ai/run-ai-workflow/SKILL.md) — `workflow.asTool()` composition


## embed-text  `@warlock.js/ai/embed-text/SKILL.md`

---
name: embed-text
description: 'Text-to-vector via sdk.embedder({...}) — embed(string) for single, embedMany(string[]) for batch. Peer primitive on the SDK adapter, not wired into agents. Compose into RAG tools, workflow run steps, or ai.middleware.semanticCache. Triggers: `sdk.embedder`, `EmbedderContract`, `embedder.embed`, `embedder.embedMany`, `EmbeddingResult`, `EmbeddingBatchResult`, `dimensions`; ''embed text'', ''build RAG tool'', ''populate vector store'', ''embedding batch''; typical import `import { OpenAISDK } from "@warlock.js/ai-openai"`. Skip: cache similarity — `@warlock.js/cache/use-cache-similarity/SKILL.md`; pgvector queries — `@warlock.js/cascade/search-by-vector/SKILL.md`; competing libs `langchain` embeddings, raw `openai.embeddings.create`.'
---

# Embeddings — peer primitive on the SDK adapter

`EmbedderContract` is a sibling of `ModelContract` on `SDKAdapterContract`, not part of the agent loop. Text-in / vector-out. No streaming, no tools, no relationship to chat completions.

## Contract

```ts
interface EmbedderContract {
  readonly name: string;
  readonly provider: string;
  readonly dimensions: number;       // 0 until first call when no override given

  embed(input: string): Promise<EmbeddingResult>;
  embedMany(inputs: string[]): Promise<EmbeddingBatchResult>;
}
```

Single and batch are deliberately split — different cost profiles, different per-request token caps, different failure modes.

The `embedder()` method is **optional** on `SDKAdapterContract` — not every provider supports embeddings:

```ts
if (typeof sdk.embedder === "function") {
  const embedder = sdk.embedder({ name: "text-embedding-3-small" });
}
```

## OpenAI adapter — first implementation

```ts
import { OpenAISDK } from "@warlock.js/ai-openai";

const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! });
const embedder = openai.embedder({ name: "text-embedding-3-small" });

const one = await embedder.embed("Hello, world.");
// { vector: number[], dimensions: number, usage: { promptTokens, totalTokens } }

const many = await embedder.embedMany(["foo", "bar", "baz"]);
// { vectors: number[][], dimensions: number, usage: { promptTokens, totalTokens } }
```

## Not wired into the agent loop

Embeddings are deliberately not automatic. Consumers obtain an embedder from the adapter and call it directly. Composes into:

- **Retrieval tools** the agent can call (RAG pattern).
- **`run` steps** in a workflow (vector ingest, catalog item embedding).
- **Query vectors** for `ai.middleware.semanticCache` — see [`@warlock.js/ai/attach-ai-middleware/SKILL.md`](@warlock.js/ai/attach-ai-middleware/SKILL.md).
- **Cascade vector columns** for native pgvector search — see [`@warlock.js/cascade/search-by-vector/SKILL.md`](@warlock.js/cascade/search-by-vector/SKILL.md).
- **Cache similarity retrieval** via `cache.set({ vector })` + `cache.similar(...)` — see [`@warlock.js/cache/use-cache-similarity/SKILL.md`](@warlock.js/cache/use-cache-similarity/SKILL.md).

## Usage example — workflow `run` step

```ts
ai.step({
  name: "embed",
  run: async (ctx) => {
    const text = `${ctx.steps.extract.output.name} ${ctx.steps.extract.output.description}`;
    const { vector } = await embedder.embed(text);
    ctx.state.embedding = vector;
  },
  output: { extract: (ctx) => ({ dims: (ctx.state.embedding as number[]).length }) },
});
```

## Pattern — RAG tool

```ts
import { v } from "@warlock.js/seal";

const searchKb = ai.tool({
  name: "searchKb",
  description: "Search the knowledge base for relevant passages.",
  input: v.object({ query: v.string(), k: v.number().optional() }),
  execute: async ({ query, k }) => {
    const { vector } = await embedder.embed(query);
    const hits = await vectorStore.query(vector, { topK: k ?? 5 });
    return hits.map((h) => ({ text: h.text, score: h.score, source: h.source }));
  },
});

ai.agent({ model, tools: [searchKb] });
```

## Dimensions

`embedder.dimensions` is `0` on a fresh embedder when no override is given — populated from the first embed call's response. Pre-seed via the adapter's `dimensions` config option when you need the value before the first call (e.g. to size a vector column in a migration schema).

## Retrieval is app-level

No built-in vector store. Bring your own (pgvector / Qdrant / Pinecone / Chroma / cache's `similar()`) and wrap it in an `ai.tool({...})`.

## See also

- [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — composing embedders into tools
- [`@warlock.js/ai/run-ai-workflow/SKILL.md`](@warlock.js/ai/run-ai-workflow/SKILL.md) — embeddings inside `run` steps
- [`@warlock.js/ai/persist-ai-data/SKILL.md`](@warlock.js/ai/persist-ai-data/SKILL.md) — performance guidance on vector storage
- [`@warlock.js/cache/use-cache-similarity/SKILL.md`](@warlock.js/cache/use-cache-similarity/SKILL.md) — cache as a vector store
- [`@warlock.js/cascade/search-by-vector/SKILL.md`](@warlock.js/cascade/search-by-vector/SKILL.md) — cascade `similarTo` query method


## handle-ai-errors  `@warlock.js/ai/handle-ai-errors/SKILL.md`

---
name: handle-ai-errors
description: 'Typed AIError hierarchy with stable code strings + coarse category for retry-policy dispatch. execute() never throws — errors surface via result.error. Triggers: `AIError`, `ProviderRateLimitError`, `ProviderAuthError`, `ContextLengthExceededError`, `ContentFilterError`, `SchemaValidationError`, `ToolExecutionError`, `WorkflowDriftError`, `BudgetExceededError`, `GuardrailViolationError`, `error.code`, `error.category`; ''handle ai error'', ''retry on rate limit'', ''branch on error code'', ''build fallback ladder''; typical import `import { AIError } from "@warlock.js/ai"`. Skip: log surfacing — `@warlock.js/ai/log-ai-calls/SKILL.md`; native `try / catch` on raw `openai`.'
---

# Typed errors — `AIError` hierarchy

Every error surfaced by `@warlock.js/ai` and every adapter package is an `AIError` subclass with a stable `code`. The base extends platform `Error`; it does NOT extend `HttpError`. Plain `Error` never leaks.

## Two invariants

1. **`execute()` never throws.** Every `agent.execute()` / `workflow.execute()` resolves with a well-formed result. Failures funnel into `result.error`. Same for `stream.result`.
2. **Every error is an `AIError`.** Both core and adapter packages funnel everything through `AIError` subclasses. Branch on `error.code` (stable string) or `instanceof`.

## Dispatch pattern

```ts
import {
  AIError,
  ProviderRateLimitError,
  ProviderAuthError,
  ContextLengthExceededError,
  ContentFilterError,
  SchemaValidationError,
  ToolExecutionError,
  WorkflowDriftError,
  // ...
} from "@warlock.js/ai";

const result = await agent.execute(input);

if (!result.error) return result.data;

if (result.error instanceof ProviderRateLimitError) {
  await sleep(result.error.retryAfter ?? 1000);
  return retry();
}

if (result.error instanceof ContextLengthExceededError) {
  return truncateAndRetry(result.error);
}

// Or branch on stable code string (good for persisted logs / metrics)
switch (result.error.code) {
  case "PROVIDER_RATE_LIMIT": /* ... */ break;
  case "CONTENT_FILTER":      /* ... */ break;
  case "WORKFLOW_DRIFT":      /* ... */ break;
}
```

Codes are the public contract — class names may evolve; codes stay.

## Coarse dispatch via `error.category`

Too granular to dashboard on `code` — every `AIError` carries a coarser `category`:

```ts
type ErrorCategory =
  | "auth" | "rate-limit" | "timeout" | "validation" | "content-filter"
  | "provider" | "tool" | "cancelled" | "max-trips" | "max-iterations"
  | "max-steps" | "schema" | "drift" | "routing" | "guardrail"
  | "budget" | "quota" | "context-length" | "unknown";

switch (result.error.category) {
  case "rate-limit":     return retryWithBackoff();
  case "timeout":        return retryOnce();
  case "auth":           return escalate();              // not retryable
  case "content-filter": return policyMessage();          // not retryable
  case "schema":         return repair();                 // use agent `repair`
}

metrics.increment("ai.error", { category: result.error.category });
```

Each typed subclass declares its `static defaultCategory`. The 4th-arg category override exists only on the base `AIError` for direct `new AIError(...)` usage.

## Hierarchy

```
AIError  (base — code, category, message, cause?, context?)
├── AgentExecutionError          AGENT_EXEC_FAILED
│   ├── AgentCancelledError      AGENT_CANCELLED            { cancelledAt?, reason? } — caller pulled the plug
│   └── AgentMaxTripsError       AGENT_MAX_TRIPS            { maxTrips } — runaway tool loop hit the cap
├── SchemaValidationError        SCHEMA_VALIDATION_FAILED   { issues? }
├── ToolExecutionError           TOOL_EXEC_FAILED           { toolName, tripIndex? }
├── WorkflowError                WORKFLOW_ERROR  (base)
│   ├── StepFailedError          STEP_FAILED                { stepName, attempts }
│   ├── WorkflowDriftError       WORKFLOW_DRIFT             { savedSignature, currentSignature, runId }
│   ├── WorkflowCancelledError   WORKFLOW_CANCELLED         { cancelledAt, reason }
│   ├── MaxStepsExceededError    WORKFLOW_MAX_STEPS         { maxSteps }
│   └── RoutingError             WORKFLOW_INVALID_GOTO      { stepName, targetName }
├── SupervisorFailedError        SUPERVISOR_FAILED  (base + authoring/runtime)
│   ├── MaxIterationsError       SUPERVISOR_MAX_ITERATIONS  { maxIterations }
│   ├── SupervisorRoutingError   SUPERVISOR_INVALID_ROUTE
│   ├── SupervisorCancelledError SUPERVISOR_CANCELLED       { cancelledAt, reason }
│   └── SupervisorDriftError     SUPERVISOR_DRIFT           { savedSignature, currentSignature, runId }
├── ProviderError                PROVIDER_ERROR  (base + catch-all)
│   ├── ProviderRateLimitError   PROVIDER_RATE_LIMIT        { retryAfter? } — transient
│   ├── QuotaExceededError       PROVIDER_QUOTA_EXCEEDED    — NOT retryable (billing cap)
│   ├── ProviderTimeoutError     PROVIDER_TIMEOUT
│   ├── ContextLengthExceededError CONTEXT_LENGTH_EXCEEDED  { limit?, actual?, modelName? }
│   ├── ContentFilterError       CONTENT_FILTER             { reason?, categories? }
│   ├── InvalidRequestError      PROVIDER_INVALID_REQUEST
│   └── ProviderAuthError        PROVIDER_AUTH
├── BudgetExceededError          BUDGET_EXCEEDED            { limit, actual, unit } — from ai.middleware.budget
└── GuardrailViolationError      GUARDRAIL_VIOLATION        { phase, reason } — from ai.middleware.guardrail
```

> `SupervisorFailedError` doubles as the base for the supervisor family **and** the authoring-time error for bad config (e.g. `route` + `router` both set). It carries extra `SUPERVISOR_INTENT_*` / `SUPERVISOR_DISPATCH_CYCLE` codes for specific intent-validation failures.

## Error fields

- `code` — stable `AIErrorCode` string.
- `category` — coarse `ErrorCategory`.
- `message` — human-readable.
- `cause?` — root error (often a provider SDK error).
- `context?` — `Record<string, unknown>` for provider-raw diagnostics (`status`, `requestId`, `headers`).

Typed fields (`retryAfter`, `toolName`, `issues`, `stepName`, …) are first-class consumer surface.

## Retry strategy

| Error family | Retryable? |
| --- | --- |
| `ProviderRateLimitError` | Yes — back off by `retryAfter` ms |
| `ProviderTimeoutError` | Yes — short delay |
| `ProviderError` (generic) | Maybe — depends on cause |
| `QuotaExceededError` | **No** — needs human intervention |
| `ProviderAuthError` | **No** — fix config / rotate key |
| `ContextLengthExceededError` | Only after truncating input |
| `ContentFilterError` | Usually **no** — the prompt itself is the issue |
| `SchemaValidationError` | Use agent `repair: { maxAttempts }` instead |
| `ToolExecutionError` | Depends on `cause` |
| `WorkflowDriftError` | **No** — manual migration or `force: true` |
| `WorkflowCancelledError` | **No** — caller-driven cancel |
| `MaxStepsExceededError` / `RoutingError` | **No** — programmer error |
| `BudgetExceededError` | **No** — raise the cap, split the workload |
| `GuardrailViolationError` (`phase: "input"`) | **No** — block / sanitize at product layer |
| `GuardrailViolationError` (`phase: "output"`) | Sometimes — re-prompt with adjusted system message |

## Why extend `Error`, not `HttpError`

- `@warlock.js/ai` is a standalone product — used from CLIs / workers / scripts as often as HTTP handlers.
- Coupling to a web framework pulls HTTP into every consumer.
- AI errors aren't HTTP errors anyway — "rate limit" is a 429 the *upstream provider* returned, not one the server returns.

The **consumer app** layer (`src/app/ai/`) wraps framework errors with its own `AIError` subclass that extends `HttpError`. See `domains/ai/conventions/errors.md`.

## OpenAI adapter — status + code dispatch

The OpenAI wrapper categorizes via `APIError.status + code` combined:

- `APIError.code` is semantically stable (`context_length_exceeded`, `content_filter`, `invalid_api_key`, etc.) across SDK versions; message strings are not.
- Status alone collapses three distinct failure modes into one bucket (`400` = context-length OR content-filter OR bad-model-name).
- When `code` is missing (proxied deployments), fall back to `status`.
- Name-based detection catches `APIConnectionTimeoutError` and Node-level `ETIMEDOUT` / `ECONNABORTED`.

## Pattern — full fallback ladder

```ts
async function runWithFallbacks(input: string) {
  for (let attempt = 0; attempt < 3; attempt++) {
    const { data, error } = await myAgent.execute(input);

    if (!error) return data;

    if (error instanceof ProviderRateLimitError) {
      await sleep(error.retryAfter ?? 2000);
      continue;
    }

    if (error instanceof ContextLengthExceededError) {
      input = truncate(input, error.limit ?? 4000);
      continue;
    }

    if (error instanceof QuotaExceededError || error instanceof ProviderAuthError) {
      throw error;   // not retryable
    }

    throw error;     // unknown — give up
  }

  throw new Error("exhausted retries");
}
```

## See also

- [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — `AgentResult.error`
- [`@warlock.js/ai/run-ai-workflow/SKILL.md`](@warlock.js/ai/run-ai-workflow/SKILL.md) — `WorkflowError` subclasses
- [`@warlock.js/ai/define-ai-tool/SKILL.md`](@warlock.js/ai/define-ai-tool/SKILL.md) — `ToolExecutionError` wrapping
- [`@warlock.js/ai/log-ai-calls/SKILL.md`](@warlock.js/ai/log-ai-calls/SKILL.md) — error logging
- `domains/ai/conventions/errors.md` — framework vs app error convention


## log-ai-calls  `@warlock.js/ai/log-ai-calls/SKILL.md`

---
name: log-ai-calls
description: 'Framework logging delegated to @warlock.js/logger — every primitive emits via the log singleton, configure channels / levels / redaction once at boot. Four-arg call convention (module, action, message, context). Triggers: `log.configure`, `log.setMinLevel`, `log.setChannels`, `ConsoleLog`, `FileLog`, `LogChannel`, `redact.paths`, `ai.agent.<name>` / `ai.workflow.<name>` / `ai.supervisor.<name>` modules; ''configure ai logging'', ''mask prompts in logs'', ''silence logs in tests'', ''capture log entries''; typical import `import { log } from "@warlock.js/logger"`. Skip: error hierarchy — `@warlock.js/ai/handle-ai-errors/SKILL.md`; competing libs `pino`, `winston`, `console.log`.'
---

# Logging — `log` from `@warlock.js/logger`

`@warlock.js/ai` does not own a logger contract. Every primitive imports the `log` singleton from [`@warlock.js/logger`](@warlock.js/logger/logger-basics/SKILL.md) directly and emits structured entries through it. Configuration — channels, levels, redaction — lives entirely on the logger.

**No `ai.config({ logger })`. No per-primitive `logger:` override.** Configure once at app boot; the framework picks it up.

## Installation — configure at boot

```ts
import { log, ConsoleLog, FileLog } from "@warlock.js/logger";

log.configure({
  channels: [
    new ConsoleLog(),
    new FileLog({ chunk: "daily" }),
  ],
  autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
});

log.setMinLevel("info");
```

That's it. Every agent / workflow / supervisor running in the process emits to the configured channels.

## Call convention — four positional args

Every framework log call uses the 4-arg positional form:

```ts
log.info("ai.agent", "trip.started", "agent starting trip", { tripIndex, model });
```

- **`module`** — emitting primitive, name-suffixed (`"ai.agent.<name>"`, `"ai.workflow.<name>"`, `"ai.supervisor.<name>"`); provider adapters use `"ai.openai"` etc.
- **`action`** — mirrors event names without the primitive prefix (`"trip.started"`, `"tool.called"`).
- **`message`** — human-readable summary.
- **`context`** — structured bag of diagnostic fields.

`action` strips the prefix of the corresponding event (`agent.trip.started` → `trip.started`) so grep filters and event handlers share vocabulary.

## Level mapping

| Level | Framework usage |
| --- | --- |
| `debug` | Internals (request/response bodies, token counts per trip) |
| `info` | Milestones (agent starting, agent completed) |
| `warn` | Retries, repair attempts, recoverable tool failures |
| `error` | Terminal failures surfaced via `result.error` |
| `success` | Tool-call success |

Streaming deltas are intentionally **not** logged at token granularity — trip boundaries carry the same information at readable volume.

## What gets logged

### Agent

| Action | Level | Context |
|---|---|---|
| `agent.starting` | `info` | inputLength, model, maxTrips |
| `trip.started` | `debug` | tripIndex |
| `tool.calling` | `debug` | tool name, action, tripIndex |
| `tool.called` | `success` | tool name, duration, tripIndex |
| `tool.failed` | `warn` | tool name, error code, tripIndex |
| `repair.attempting` | `warn` | tripIndex, validation issues |
| `agent.completed` | `info` | totalUsage, totalDuration, trip count |
| `agent.error` | `error` | error code, message, stack |

### Workflow

`workflow.starting` / `step.starting` / `step.completed` / `step.failed` / `workflow.completed` / `workflow.error`. Module is `ai.workflow.<name>`.

### Supervisor

`supervisor.starting` / `iteration.starting` / `router.deciding` / `router.decided` / `agent.starting` (per dispatched agent) / `iteration.completed` / `evaluate.verdict` / `supervisor.completed`. Module is `ai.supervisor.<name>`.

### Provider adapter

`ai.openai` (and future adapters) emit `request` (debug) and `response` (debug) per call, plus `error` on the wrapped `AIError`.

## Redaction

Redaction is a `@warlock.js/logger` feature — configure once on the logger, applies to every framework log automatically.

```ts
log.configure({
  redact: {
    paths: [
      "context.messages",          // prompts
      "context.input",              // user input
      "context.apiKey",             // never log this anyway, but defense-in-depth
    ],
  },
});
```

See [`@warlock.js/logger/redact-sensitive-log-fields/SKILL.md`](@warlock.js/logger/redact-sensitive-log-fields/SKILL.md) for the full redaction surface.

## Events vs. logs — two channels, one source

- **Events** are push-model (subscribers), typed payloads, per-execution lifetime — ideal for UI streaming, SSE, metrics.
- **Logs** are pull-model (written to channels), structured-string + context, persistent — ideal for grep, post-mortem.

Both fire from the same internal emit so every event produces both.

## Patterns

### Silence everything in tests

```ts
import { log } from "@warlock.js/logger";

beforeAll(() => log.setChannels([]));
```

### Capture all framework log entries in a test

```ts
import { log, LogChannel } from "@warlock.js/logger";

class Capture extends LogChannel {
  public name = "capture";
  public entries: any[] = [];
  public log(data) { this.entries.push(data); }
}

const capture = new Capture();
log.setChannels([capture]);
```

See [`@warlock.js/logger/test-logging-code/SKILL.md`](@warlock.js/logger/test-logging-code/SKILL.md) for the test patterns.

## See also

- [`@warlock.js/logger/logger-basics/SKILL.md`](@warlock.js/logger/logger-basics/SKILL.md) — logger foundations
- [`@warlock.js/logger/configure-logger/SKILL.md`](@warlock.js/logger/configure-logger/SKILL.md) — startup setup
- [`@warlock.js/logger/redact-sensitive-log-fields/SKILL.md`](@warlock.js/logger/redact-sensitive-log-fields/SKILL.md) — redaction
- [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md) — what lands on the `error` channel


## persist-ai-data  `@warlock.js/ai/persist-ai-data/SKILL.md`

---
name: persist-ai-data
description: 'Persistence delegated to @warlock.js/cache — workflow + supervisor snapshot resume via snapshotStore, semantic cache via vector-capable CacheDriver, global default via ai.config({defaultStore}). Covers drift detection + three recovery paths. Triggers: `ai.config`, `defaultStore`, `snapshotStore`, `wf.resume`, `supervisor.resume`, `WorkflowSnapshot`, `SupervisorSnapshot`, `WorkflowDriftError`, `SupervisorDriftError`, `force: true`; ''resume a workflow run'', ''configure snapshot store'', ''handle signature drift'', ''wire pg vector cache''; typical import `import { ai } from "@warlock.js/ai"`. Skip: cache driver catalog — `@warlock.js/cache/cache-basics/SKILL.md`; competing libs `temporal`, `inngest`.'
---

# Persistence — `@warlock.js/cache` everywhere

`@warlock.js/ai` owns no persistence primitives. Anything that needs durable state — supervisor / workflow snapshot resume, semantic cache, future memory — accepts a `CacheDriver` from `@warlock.js/cache`. The cache package ships memory / lru-memory / file / redis / pg drivers; pg adds optional `pgvector` for similarity retrieval.

## The big picture

```
┌──────────────┐        ┌─────────────────────────┐
│  ai.config   │  ───▶  │  @warlock.js/cache      │
│ defaultStore │        │  CacheDriver            │
└──────────────┘        │  (memory|redis|pg|...)  │
                        └────────────▲────────────┘
                                     │
       ┌─────────────────────────────┼─────────────────────────────┐
┌──────────────┐         ┌────────────────────┐         ┌──────────────────┐
│ supervisor   │         │   workflow         │         │  semanticCache   │
│ snapshotStore│         │  snapshotStore     │         │  store (vector)  │
└──────────────┘         └────────────────────┘         └──────────────────┘
```

## Resolution order

```
options.store ?? ai.config({ defaultStore }) ?? undefined
```

When neither is set:
- **Snapshot consumers** silently skip writes and throw on `resume()`.
- **Semantic cache** throws at construction.

## `ai.config({ defaultStore })` — set once at boot

```ts
import { ai } from "@warlock.js/ai";
import { cache } from "@warlock.js/cache";

ai.config({
  defaultStore: cache.driver("redis", { client: redisClient }),
});
```

Every consumer that doesn't supply its own `store` / `snapshotStore` picks this up. Per-declaration overrides win.

## Picking a driver

| Driver | KV | TTL | Tags | `similar()` | Fits |
|---|---|---|---|---|---|
| `memory` / `lru-memory` | ✅ | ✅ | ✅ | ✅ (brute force) | Dev / tests |
| `file` | ✅ | ✅ | ✅ | ❌ | Single-process persistence |
| `null` | no-op | no-op | no-op | `[]` | Test isolation |
| `redis` | ✅ | ✅ | ✅ | (RediSearch, separate phase) | Production KV + future similarity |
| `pg` | ✅ | ✅ | ✅ | ✅ (pgvector) | Production semantic cache |

Brute-force memory drivers carry an `O(N)` similarity scan — fine up to a few thousand entries.

## Snapshot resume — workflow + supervisor

### Wiring

```ts
ai.config({ defaultStore: cache.driver("redis", { client }) });

const wf = ai.workflow({
  name: "ticket-processor",
  steps: [...],
  // snapshotStore optional — falls back to defaultStore
});

const sup = ai.supervisor({
  name: "support-team",
  router: routerAgent,
  intents: { triage, billing, resolver },
  // explicit override when this primitive needs a different driver
  snapshotStore: cache.driver("pg", { client: pgPool, table: "support_runs" }),
});
```

### Snapshot shapes

```ts
type WorkflowSnapshot = {
  runId: string;
  workflowName: string;
  signature: string;             // structural fingerprint
  version?: string;
  input: unknown;
  state: Record<string, unknown>;
  steps: Record<string, StepSnapshot>;
  next: string | null;
  status: "running" | "completed" | "failed" | "cancelled";
  startedAt: string;
  savedAt: string;
};

type SupervisorSnapshot = {
  runId: string;
  supervisorName: string;
  signature: string;
  input: string | Record<string, unknown>;   // SupervisorInput
  iteration: number;                          // last *completed* iteration; -1 before any settle
  snapshots: IterationSnapshot[];
  status: "running" | "completed" | "failed" | "cancelled";
  startedAt: string;
  savedAt: string;
};
```

### Checkpoint rules

- Workflow: snapshot after every step settles. Parallel groups checkpoint atomically.
- Supervisor: snapshot after every iteration. Plus once on final completion / cancel / fail.
- Mid-step / mid-iteration crash resumes from the last completed checkpoint — partial work is **not** persisted.
- **Idempotency is the user's responsibility.** Steps and agents may re-run on resume.

## Fresh run vs. resume

```ts
const result = await wf.execute({ input, runId: "ticket-123" });
const result = await wf.resume("ticket-123");

await sup.execute("urgent", { runId: "support-7" });
await sup.resume("support-7");
```

Resume reads the snapshot, rehydrates state, continues from the snapshot's `next`.

## Signature drift detection

`signature` is a structural fingerprint computed at construction. On `resume()`, current signature is compared to the snapshot's. Mismatch throws `WorkflowDriftError` / `SupervisorDriftError` **without executing**:

```ts
{
  code: "WORKFLOW_DRIFT",
  savedSignature: "abc123…",
  currentSignature: "def456…",
  runId: "ticket-123",
  completedSteps: ["fetch", "extract"],
  pendingStep: "classify",
}
```

## Recovery paths

Three choices when drift is detected:

1. **Discard** — safest when the shape genuinely changed:

   ```ts
   await store.remove("ticket-123");
   await wf.execute({ input, runId: "ticket-123" });
   ```

2. **Force resume** — escape hatch for trivial edits you know are safe:

   ```ts
   await wf.resume("ticket-123", { force: true });
   ```

3. **Manual migration** — for changes you can mechanically translate:

   ```ts
   const snapshot = await store.get<WorkflowSnapshot>("ticket-123");
   if (snapshot) {
     snapshot.steps.newName = snapshot.steps.oldName;
     delete snapshot.steps.oldName;
     snapshot.signature = wf.signature;
     await store.set("ticket-123", snapshot);
     await wf.resume("ticket-123");
   }
   ```

## Semantic cache

```ts
ai.config({
  defaultStore: cache.driver("pg", {
    client: pgPool,
    vector: { dimensions: 1536, index: "hnsw" },
  }),
});

const myAgent = ai.agent({
  model,
  middleware: [
    ai.middleware.semanticCache({
      embedder: openai.embedder({ name: "text-embedding-3-small" }),
      threshold: 0.95,
      ttlMs: 60 * 60 * 1000,
    }),
  ],
});
```

The driver must support `similar()`. Without similarity → `CacheUnsupportedError`. See [`@warlock.js/ai/attach-ai-middleware/SKILL.md`](@warlock.js/ai/attach-ai-middleware/SKILL.md).

## See also

- [`@warlock.js/ai/run-ai-workflow/SKILL.md`](@warlock.js/ai/run-ai-workflow/SKILL.md) — `snapshotStore` + `resume()`
- [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) — same on supervisor
- [`@warlock.js/ai/attach-ai-middleware/SKILL.md`](@warlock.js/ai/attach-ai-middleware/SKILL.md) — `semanticCache` middleware
- [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md) — drift errors
- [`@warlock.js/cache/cache-basics/SKILL.md`](@warlock.js/cache/cache-basics/SKILL.md) — driver catalog


## pick-ai-provider  `@warlock.js/ai/pick-ai-provider/SKILL.md`

---
name: pick-ai-provider
description: 'Choose an AI provider adapter — @warlock.js/ai-openai (shipped, also handles OpenRouter / Azure via baseURL), @warlock.js/ai-anthropic, @warlock.js/ai-bedrock, @warlock.js/ai-google, @warlock.js/ai-ollama. Triggers: `OpenAISDK`, `SDKAdapterContract`, `ModelContract`, `sdk.model`, `sdk.embedder`, `capabilities.vision`, `capabilities.structuredOutput`, `pricing`, `baseURL`, `provider: "openrouter"`; ''pick a provider'', ''openai vs openrouter'', ''does this model support vision'', ''configure pricing''; typical import `import { OpenAISDK } from "@warlock.js/ai-openai"`. Skip: agent factory — `@warlock.js/ai/run-ai-agent/SKILL.md`; competing libs raw `openai`, `@anthropic-ai/sdk`, `@aws-sdk/client-bedrock-runtime`.'
---

# Pick an AI provider adapter

`@warlock.js/ai` is provider-agnostic. Concrete adapters live in sibling packages and follow the same `SDKAdapterContract`. Pick by which provider(s) your app talks to and which capabilities the model needs.

## Available adapters

| Package | Status | Notes |
| --- | --- | --- |
| `@warlock.js/ai-openai` | ✅ Shipped | OpenAI + any OpenAI-compatible gateway (OpenRouter, Together.ai, etc.) |
| `@warlock.js/ai-anthropic` | ✅ Shipped | Native Claude API (Opus / Sonnet / Haiku) |
| `@warlock.js/ai-bedrock` | ✅ Shipped | AWS Bedrock — Converse API + Titan embeddings |
| `@warlock.js/ai-google` | ✅ Shipped | Gemini direct via `@google/genai`, native batch embeddings |
| `@warlock.js/ai-ollama` | ✅ Shipped | Local models via the official `ollama` client |

All five first-party adapters share the same `SDKAdapterContract`, so switching providers is a one-line change at the model construction site. `ai-openrouter` is intentionally deferred — use `ai-openai` with a `baseURL` pointed at OpenRouter.

## Decision tree

- **Default first choice:** `@warlock.js/ai-openai` direct to OpenAI. Best support, predictable behavior, native structured-output, native vision on `gpt-4o*`, embeddings, streaming.
- **Need many models / cost arbitrage:** `@warlock.js/ai-openai` against OpenRouter. Same code, different `baseURL` + `provider: "openrouter"` on the SDK.
- **Need native Claude features:** `@warlock.js/ai-anthropic` — Opus / Sonnet / Haiku via the native Messages API.
- **Need local / self-hosted models:** `@warlock.js/ai-ollama`, or a local OpenAI-compatible gateway via `ai-openai`.
- **Need AWS Bedrock pricing / compliance:** `@warlock.js/ai-bedrock` — Converse API + Titan embeddings.
- **Need Gemini:** `@warlock.js/ai-google` — Gemini direct via `@google/genai`.

## The adapter contract

```ts
interface SDKAdapterContract {
  model(config): ModelContract;            // chat completions / tool calls / structured output
  count(text, model?): Promise<number>;    // token counting
  embedder?(config): EmbedderContract;     // optional — not every provider supports embeddings
}
```

Adapters are classes — `new OpenAISDK({ apiKey })`, `new AnthropicSDK({ apiKey })`. They expose:

- `model({ name, ...options })` — returns a `ModelContract`. The provider label lives on the returned `ModelContract.provider` (`"openai"`, `"openrouter"`, …), not on the SDK.
- `count(text, model?)` — provider-appropriate token count.
- `embedder({ name })` — text-to-vector. Optional; check `typeof sdk.embedder === "function"` before calling.

The `ModelContract.capabilities` field declares what the model supports — two flags today, both optional (absent = treat as `false`):

```ts
type ModelCapabilities = {
  structuredOutput?: boolean;   // native response_format: json_schema support?
  vision?: boolean;             // can accept image attachments?
};
```

The framework reads `capabilities` to fail loud upfront — e.g. passing `attachments: [...]` to a non-vision model throws at the boundary instead of failing mid-trip.

## OpenAI adapter — usage

```ts
import { OpenAISDK } from "@warlock.js/ai-openai";

// Direct OpenAI
const openai = new OpenAISDK({
  apiKey: process.env.OPENAI_API_KEY!,
  pricing: {
    "gpt-4o-mini": { input: 0.15, output: 0.6, cachedInput: 0.075 },
    "gpt-4o":      { input: 5.0,  output: 15.0 },
  },
});

const agent = ai.agent({ model: openai.model({ name: "gpt-4o-mini" }) });
```

### Via OpenRouter (cost arbitrage, many providers)

```ts
const openrouter = new OpenAISDK({
  apiKey: process.env.OPENROUTER_API_KEY!,
  baseURL: "https://openrouter.ai/api/v1",
  provider: "openrouter",   // labels reports correctly
});

const agent = ai.agent({ model: openrouter.model({ name: "anthropic/claude-3.5-sonnet" }) });
```

Same `OpenAISDK` class, different `baseURL`. Reports label the provider via the `provider` field for downstream metrics.

### Per-model overrides

```ts
const openai = new OpenAISDK({ apiKey });

// Override capabilities for a custom or fine-tuned model
const customModel = openai.model({
  name: "my-org/custom-gpt-4-finetuned",
  vision: true,                    // override capabilities.vision
  structuredOutput: true,
  pricing: { input: 1.0, output: 3.0 },   // per-model pricing (wins over SDK registry)
});
```

## Pricing — per-channel cost breakdown

Configure `pricing` on the model (or via the SDK registry) and every report carries `Usage.cost`:

```ts
const openai = new OpenAISDK({
  apiKey,
  pricing: { "gpt-4o-mini": { input: 0.15, output: 0.6, cachedInput: 0.075 } },
});

const { usage } = await ai.agent({ model: openai.model({ name: "gpt-4o-mini" }) }).execute("hi");

usage.cost;
// { input: 0.0000045, output: 0.000192, cachedInput: 0.000009 }  — USD breakdown
```

Shape mirrors `ModelPricing` — `{ input, output, cachedInput?, cachedOutput? }`. Per-model `pricing` wins over the SDK-level registry. `undefined` when no pricing configured — honest absence over false zero.

## Embeddings

OpenAI ships the first embedder:

```ts
const embedder = openai.embedder({ name: "text-embedding-3-small" });
const { vector } = await embedder.embed("Hello, world.");
```

See [`@warlock.js/ai/embed-text/SKILL.md`](@warlock.js/ai/embed-text/SKILL.md).

## Multi-provider apps

Pattern: one SDK instance per provider, mix at the call site:

```ts
const openai     = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! });
const openrouter = new OpenAISDK({
  apiKey: process.env.OPENROUTER_API_KEY!,
  baseURL: "https://openrouter.ai/api/v1",
  provider: "openrouter",
});

const fastAgent  = ai.agent({ model: openai.model({ name: "gpt-4o-mini" }) });
const claudeAgent = ai.agent({ model: openrouter.model({ name: "anthropic/claude-3.5-sonnet" }) });
```

Reports label per-agent provider correctly. Pricing applies per SDK instance.

## When the adapter changes

If you switch providers mid-project (e.g. OpenAI → Anthropic):

1. The agent factory call signature stays the same — `ai.agent({ model: <newSdk>.model({...}) })`.
2. Capabilities matter — if the new model doesn't support `structuredOutput` natively, fall back to the soft "respond in JSON only" instruction (framework handles it).
3. Errors stay typed — `ProviderAuthError`, `ContextLengthExceededError`, etc. are adapter-agnostic.
4. Pricing matrix needs updating per the new provider's rates.

## See also

- [`@warlock.js/ai-openai/setup-openai/SKILL.md`](@warlock.js/ai-openai/setup-openai/SKILL.md) — full OpenAI adapter docs
- [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — model passed into `ai.agent({...})`
- [`@warlock.js/ai/embed-text/SKILL.md`](@warlock.js/ai/embed-text/SKILL.md) — embedder primitive on the SDK
- [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md) — adapter error categorization


## run-ai-agent  `@warlock.js/ai/run-ai-agent/SKILL.md`

---
name: run-ai-agent
description: 'Build agents with ai.agent({...}) — the single-LLM-turn primitive. Covers execute / stream, attachments, structured output, placeholders, events. Triggers: `ai.agent`, `agent.execute`, `agent.stream`, `AgentResult`, `AgentReport`, `streamingToolGuard`, `attachments`, `repair`, `maxTrips`, `sessionId`; ''run an agent'', ''stream an agent response'', ''structured output schema'', ''pass image to agent'', ''cancel an agent run''; typical import `import { ai } from "@warlock.js/ai"`. Skip: tool definition — `@warlock.js/ai/define-ai-tool/SKILL.md`; workflows — `@warlock.js/ai/run-ai-workflow/SKILL.md`; competing libs `langchain`, `ai` (Vercel), raw `openai`.'
---

# `ai.agent()` — single-turn primitive

The lowest rung of the 4-primitive ladder. One LLM call, optional tool loop, optional structured output. Stateless across calls.

## Factory shape

```ts
import { ai } from "@warlock.js/ai";
import { OpenAISDK } from "@warlock.js/ai-openai";

const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! });

ai.agent({
  name?: string,                              // optional — anonymous gets a fingerprint
  model: openai.model({ name: "gpt-4o-mini" }),
  systemPrompt?: string | SystemPromptContract,
  tools?: ToolContract<any, any>[],
  placeholders?: Record<string, unknown>,
  maxTrips?: number,                           // default 10
  modelOptions?: ModelCallOptions,
  output?: StandardSchemaV1<T>,                // default structured-output schema
  middleware?: AgentMiddleware[],
  streamingToolGuard?: StreamingToolGuardConfig, // opt-in tool-call recovery from text leaks
  on?: AgentEventHandlers,
  version?: string,                            // mirrored onto reports for trip archives
});
```

The factory returns an `AgentContract<TOutput>`. Every execution spawns a fresh internal `Execution` — the factory holds no per-call state.

## Anonymous agents

`name` is optional. Anonymous agents receive a deterministic fingerprint:

```
anon_<provider>_<model>[_<tool1>+<tool2>+...]
```

Same config across process restarts → same synthetic name. Keeps workflow signature drift detection honest when you compose anonymous agents into a workflow.

## Execute surface

```ts
agent.execute(input: string, options?: AgentExecuteOptions): Promise<AgentResult<T>>;
agent.stream(input: string, options?: AgentExecuteOptions): StreamContract<AgentResult<T>>;
```

`AgentExecuteOptions` — every field optional:

```ts
{
  history?: Message[];
  attachments?: Attachment[];                    // images today; PDFs later
  placeholders?: Record<string, unknown>;
  output?: StandardSchemaV1<T>;                  // typed structured output → result.data
  responseSchema?: Record<string, unknown>;      // hand-crafted JSON Schema escape hatch
  systemPrompt?: SystemPromptContract;           // per-call override
  repair?: { maxAttempts?: number };             // opt-in re-ask on validation failure
  signal?: AbortSignal;                          // cancellation
  sessionId?: string;                            // stitch many runs into one session
  streamingToolGuard?: StreamingToolGuardConfig;
  on?: AgentEventHandlers;
}
```

## `streamingToolGuard` — recover tool calls leaked as text

Cheap and fast models occasionally emit a registered tool's structured input as **literal text in the content stream** instead of as a real `tool_call`. Without intervention, customers watch raw JSON build character-by-character.

```ts
ai.agent({
  model: someFastModel,
  tools: [suggestFollowupsTool, searchCatalogTool],
  streamingToolGuard: {},  // empty object = on with defaults
});
```

Recovery conditions: the buffered JSON must (a) parse cleanly, (b) carry a `name` or `tool` key resolving to a registered tool, AND (c) carry an `arguments` or `input` key whose value validates against that tool's input schema. Anything else flushes back as text — the guard never invents calls.

**Off by default.** Set this explicitly on agents whose registered tools have been observed to leak.

## `sessionId` — stitch many runs into one user session

```ts
const sessionId = "sess_user_42_2026-05-12";
await agent.execute("what's my order?", { sessionId });
await agent.execute("cancel it", { sessionId });   // 30 seconds later, same session
```

The framework stamps it onto every report node this run produces. Cost dashboards can group by `sessionId` without joining the report tree.

## Result shape — `AgentResult<T>`

```ts
type AgentResult<T> = {
  type: "agent";
  data?: T;             // structured output when `output` schema was supplied
  text?: string;        // raw final LLM text
  report: AgentReport;  // trips, toolCalls, status, timing
  usage: Usage;         // aggregated token usage + cost breakdown
  error?: AIError;
};

type AgentReport = {
  runId: string;
  rootRunId: string;
  name: string;
  status: "completed" | "failed" | "cancelled";
  startedAt: string;
  endedAt: string;
  duration: number;
  model: { name: string; provider: string };
  trips: LLMTrip[];
  children: ToolCall[];   // tool dispatches — filter by `c.type === "tool"`
};
```

Tool calls are NOT a separate `report.toolCalls` field — every tool dispatch is a child `BaseReport` node (`type: "tool"`) on `report.children`. Filter the tree to isolate them:

```ts
const toolCalls = report.children.filter((c) => c.type === "tool");
const nestedAgents = report.children.filter((c) => c.type === "agent");
```

Canonical destructuring:

```ts
const { data, text, report, usage, error } = await agent.execute(input);

if (error) {
  logger.warn(error.code, { duration: report.duration, trips: report.trips.length });
  return;
}
```

## Pattern — structured output

```ts
import { v, type Infer } from "@warlock.js/seal";

const summarySchema = v.object({
  summary: v.string(),
  keyPoints: v.array(v.string()).min(1),
});

const result = await myAgent.execute(input, { output: summarySchema });

if (result.data) {
  // typed as Infer<typeof summarySchema>
}
```

Adapters with `capabilities.structuredOutput: true` forward the schema natively. Adapters without it get a soft "respond in JSON only" instruction. Client-side validation always runs.

## Pattern — output baked into the agent

```ts
const titleAgent = ai.agent({
  model: openai.model({ name: "gpt-4o-mini" }),
  output: titleSchema, // typed end-to-end via AgentContract<Infer<typeof titleSchema>>
  systemPrompt: "...",
});

const result = await titleAgent.execute(currentMessage, { history });
//    ^? AgentResult<{ title?: string }>
```

Call-site `options.output` fully **replaces** `config.output` for that run — no merging.

## Pattern — repair on validation failure

```ts
await myAgent.execute(input, {
  output: schema,
  repair: { maxAttempts: 1 }, // re-ask once on parse/validation failure
});
```

Disabled by default. Each repair attempt counts against `maxTrips`.

## Pattern — image attachments

```ts
await myAgent.execute("What's in this?", {
  attachments: ["./photo.png", "https://cdn.example.com/cat.jpg"],
});
```

Shorthand strings infer the image kind from extension. Tagged form for explicit control:

```ts
attachments: [
  { type: "image", source: "./photo" },
  { type: "image", source: { base64: "...", mediaType: "image/png" } },
];
```

Model must declare `capabilities.vision`. OpenAI adapter auto-infers from name; override with `openai.model({ name, vision: true })`.

## Pattern — streaming

```ts
const stream = myAgent.stream(input);

for await (const event of stream) {
  if (event.type === "agent.trip.streaming") {
    process.stdout.write(event.delta);
  }
}

const result = await stream.result;
```

Or use `.on({ "agent.trip.streaming": ..., "agent.completed": ..., "agent.error": ... })` alongside iteration.

## Pattern — cancellation

```ts
const ctrl = new AbortController();
const resultPromise = myAgent.execute(input, { signal: ctrl.signal });

setTimeout(() => ctrl.abort("too slow"), 30_000);

const { error, report } = await resultPromise;
if (report.status === "cancelled") {
  // error is an AgentCancelledError (code "AGENT_CANCELLED",
  // category "cancelled") carrying `cancelledAt` + `reason`
}
```

Between-trip abort is guaranteed. Mid-trip best-effort.

## Events — dot-notation + 3-tier subscription

- `agent.starting`, `agent.trip.started`, `agent.trip.streaming`, `agent.trip.completed`
- `agent.tool.calling`, `agent.tool.called`, `agent.tool.failed`
- `agent.completed`, `agent.error`

Three subscription tiers — fire in order **factory → instance → per-call**:

```ts
ai.agent({ model, on: { "agent.starting": () => metrics.inc("agent.runs") } });

const unsubscribe = myAgent.on("agent.error", ({ error }) => logger.error(error));

await myAgent.execute("go", {
  on: { "agent.trip.completed": ({ trip }) => console.log(trip.duration) },
});
```

Every event payload carries `runId` and `rootRunId`. Same identity fields ride on stream events.

## When NOT to use this primitive

- Multi-step pipeline with a fixed shape → [`@warlock.js/ai/run-ai-workflow/SKILL.md`](@warlock.js/ai/run-ai-workflow/SKILL.md)
- Multi-agent routing with iteration → [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md)

## See also

- [`@warlock.js/ai/define-ai-tool/SKILL.md`](@warlock.js/ai/define-ai-tool/SKILL.md) — tool wiring + schema validation
- [`@warlock.js/ai/write-system-prompt/SKILL.md`](@warlock.js/ai/write-system-prompt/SKILL.md) — persona / instruction builders
- [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md) — `AIError` hierarchy


## run-ai-workflow  `@warlock.js/ai/run-ai-workflow/SKILL.md`

---
name: run-ai-workflow
description: 'Build durable resumable pipelines with ai.workflow({...}) + ai.step({...}) — lifecycle (skip / before / run|agent|parallel / output / after / nextStep), retry, parallel groups, snapshot resume. Triggers: `ai.workflow`, `ai.step`, `wf.execute`, `wf.resume`, `WorkflowContext`, `WorkflowResult`, `StepSnapshot`, `nextStep`, `onFailure`, `WorkflowDriftError`; ''build a workflow'', ''define a step'', ''resume after crash'', ''parallel steps'', ''retry with backoff''; typical import `import { ai } from "@warlock.js/ai"`. Skip: agent — `@warlock.js/ai/run-ai-agent/SKILL.md`; supervisor — `@warlock.js/ai/run-supervisor/SKILL.md`; competing libs `temporal`, `inngest`, `bullmq`.'
---

# `ai.workflow()` — static, deterministic pipelines

Second rung of the 4-primitive ladder. A named, ordered set of steps with a stable signature. Each step is exactly one of: an agent call (`agent`), a `run` function, or a parallel group (`parallel`). Compose another workflow in by wrapping it with `workflow.asTool()` and calling it from a `run` step. Durable (resumable via any `CacheDriver` from `@warlock.js/cache`), observable, cancellable.

## When NOT to use a workflow

- Unknown shape at author time → wait for `ai.planner()` (v3)
- Quality-loop until goal met → [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md)
- Multi-turn conversation with persistent session → orchestrator (v2)
- Iterate a runtime list of items → `ai.batch()` utility wrapping a workflow

## Minimal shape

```ts
import { ai } from "@warlock.js/ai";
import { MemoryCacheDriver } from "@warlock.js/cache";
import { v } from "@warlock.js/seal";

ai.config({ defaultStore: new MemoryCacheDriver() });

type CatalogInput = { url: string };
type CatalogOutput = { id: string };
type CatalogState = { html?: string; catalogId?: string };

const wf = ai.workflow<CatalogInput, CatalogOutput, CatalogState>({
  name: "catalog-item",
  output: {
    extract: (ctx) => ({ id: ctx.state.catalogId ?? "" }),
    schema: v.object({ id: v.string() }),
  },
  steps: [
    ai.step<CatalogInput, CatalogState>({
      name: "fetch",
      run: async (ctx) => {
        ctx.state.html = await fetch(ctx.input.url).then(r => r.text());
      },
    }),
    ai.step<CatalogInput, CatalogState>({
      name: "extract",
      agent: extractorAgent,
      input: (ctx) => ({ prompt: `Extract from: ${ctx.state.html}` }),
      output: {
        extract: (ctx) => ctx.agentResult?.data,
        schema: itemSchema,
      },
      retry: { attempts: 3, backoff: "exponential" },
    }),
  ],
});
```

## Generics

```ts
ai.workflow<TInput, TOutput, TState, TContext>(...)
ai.step<TInput, TState, TContext>(...)
```

Order: Input/Output describe the public contract, State before Context because step bodies touch state more often. Defaults (`unknown`, `Record<string, unknown>`) let partial typing work.

## Execute — two interchangeable shapes

```ts
// canonical — mirrors agent.execute
const result = await wf.execute(
  { url: "https://..." },
  { runId: "catalog-123", signal: AbortSignal.timeout(60_000) },
);

// single-object — ergonomic alt
const result = await wf.execute({
  input: { url: "https://..." },
  runId: "catalog-123",
});
```

`WorkflowRunOptions` carries `runId`, `signal`, `on`, `context`, `sessionId`. `WorkflowDefinition.version` mirrors onto every produced report.

## `execute()` never throws

All failures funnel into `result.error`:

- `StepFailedError` / `STEP_FAILED`
- `RoutingError` / `WORKFLOW_INVALID_GOTO`
- `WorkflowDriftError` / `WORKFLOW_DRIFT`
- `WorkflowCancelledError` / `WORKFLOW_CANCELLED`
- `MaxStepsExceededError` / `WORKFLOW_MAX_STEPS`

See [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md).

## Result shape

```ts
const { data, report, usage, error } = await wf.execute(input);
```

```ts
type WorkflowResult<TOutput> = {
  type: "workflow";
  data?: TOutput;            // from workflow.output.extract
  report: WorkflowReport;    // runId, signature, status, timings, per-step snapshots
  usage: Usage;              // aggregated across all agent calls
  error?: AIError;
};
```

`report.steps[name]` holds a frozen `StepSnapshot` with `output`, `status`, `attempts`, `attemptHistory`, timings, nested children for parallel groups.

## Step lifecycle

```
skip? → before? → (run | agent | parallel) → output.extract (+ schema) → after? → nextStep?
```

Exactly one of `run` / `agent` / `parallel` per step (enforced at `ai.step()` author time).

| Phase | Purpose |
| --- | --- |
| `skip` | Return `true` to skip the step. Output becomes `undefined`. `nextStep` still fires. |
| `before` | Pre-work — fetch, set state, validate. |
| `run` | Core non-agent work. |
| `agent` | Agent to execute. Takes `input(ctx)` as prompt builder. |
| `input` | Required when `agent` is set. |
| `output` | `{ extract, schema? }` — extracts the step's output. |
| `after` | Post-work — save, notify. |
| `nextStep` | Step-level routing on `completed` / `skipped`. |
| `onFailure` | Step-level recovery routing after retries exhaust. |
| `onCancel` | Cleanup if cancelled in-flight. |

Errors in `before`/`run`/`agent`/`after`/`output` are retryable. Errors in `nextStep` and `onFailure` terminate the workflow with `RoutingError`.

## Context (`ctx`)

```ts
type WorkflowContext<TInput, TState, TContext> = {
  readonly input: TInput;                         // frozen — durable cause
  readonly context: TContext;                     // frozen — per-execution
  readonly steps: Record<string, StepSnapshot>;   // frozen snapshots of COMPLETED steps
  state: TState;                                  // mutable current shared state
  readonly agentResult?: AgentResult<unknown>;    // set when current step has an agent
  readonly runId: string;
  readonly signal?: AbortSignal;
  readonly startedAt: Date;
};
```

`input`, `context`, `steps` are deep-frozen. `state` is mutable during a step and frozen into `steps[name].state` on completion.

### `input` vs `context`

- `input` answers *what* to process — persisted in the snapshot, replayed verbatim on `resume()`.
- `context` answers *who's running it* — tenancy, user, locale, traceId. **Never persisted.** Callers pass fresh on every `execute()` and `resume()`.

**Resume rule.** No fingerprinting on context. Persistence-scoping fields (e.g. `organizationId`) MUST match across resume — silent data corruption otherwise.

## State vs `steps[x].output` — performance

- **Small control data** (flags, counters) → `ctx.state`. Cheap.
- **Large artifacts** (HTML blobs, embedding vectors) → producer's `output.extract`, read via `ctx.steps[prev].output`. `ctx.state` clones on every retry attempt; `ctx.steps` clones once on step commit.

## Parallel children

```ts
ai.step({
  name: "generate",
  parallel: [
    ai.step({ name: "draft", agent: writerAgent, input, output }),
    ai.step({ name: "suggest-articles", agent: kbAgent, input, output }),
  ],
});
```

- Children share `ctx.state` — last-write-wins.
- Addressable by flat (`ctx.steps.draft`) AND nested (`ctx.steps.generate.steps.draft`) path.
- Any child fails → all siblings still complete (atomic settlement); parent's `error` becomes the first child's error.
- Checkpoint atomically after all children settle.

## Routing — `nextStep` (success) + `onFailure` (failure)

```ts
ai.step({
  name: "qa",
  agent: qaReviewerAgent,
  input,
  output,
  nextStep: (ctx) => {
    if (!ctx.agentResult?.data.approved) {
      ctx.state.qaFeedback = ctx.agentResult?.data.feedback;
      return { goto: "draft" };       // success-path route
    }
  },
  onFailure: (ctx, error) => {
    if (error.code === "PROVIDER_RATE_LIMIT") {
      return { goto: "fallbackQa" };
    }
    // void → halt with the original StepFailedError
  },
});
```

Returns: `{ goto: "stepName" }`, `{ end: true }`, or `void` (fall through / halt).

**Guards:** `maxSteps` (default 100) hard-fails with `MaxStepsExceededError`. `loopWarnAfter` (default 5) emits `workflow.loop.warning`.

## Retry

```ts
retry: {
  attempts: 3,                    // default 1 = no retry
  backoff: "exponential",         // "none" | "linear" | "exponential" | (attempt) => ms
  retryOn: (error, attempt) => true,
  onRetry: (attempt, error) => {},
}
```

Exponential defaults: 500 ms → 1 s → 2 s → 4 s → 8 s, capped at 30 s. `AbortError` short-circuits retry.

## Cancellation

```ts
const ctrl = new AbortController();
const result = wf.execute({ input, signal: ctrl.signal });
ctrl.abort("user cancelled");
```

Between-step cancellation is guaranteed. Mid-step is best-effort. `status: "cancelled"` on return with partial `report.steps`; checkpoint written before returning (resume works).

## Persistence & resume

See [`@warlock.js/ai/persist-ai-data/SKILL.md`](@warlock.js/ai/persist-ai-data/SKILL.md).

```ts
await wf.execute({ input, runId: "ticket-123" });  // fresh run
await wf.resume("ticket-123");                      // after crash
```

## Events — three-tier subscription

`workflow.starting`, `workflow.step.{starting|streaming|completed|skipped|retrying|failed}`, `workflow.loop.warning`, `workflow.cancelled`, `workflow.completed`, `workflow.error`.

Subscription order: **definition → instance → per-call** (all matching handlers fire).

Every payload carries `runId` and `rootRunId`.

## Design reference

`domains/ai/design/workflow.md` — locked spec, §1–§16 covers every rule with five PoC examples.

## See also

- [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — agents inside steps
- [`@warlock.js/ai/persist-ai-data/SKILL.md`](@warlock.js/ai/persist-ai-data/SKILL.md) — snapshot resume + drift
- [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md) — `WorkflowError` subclasses


## run-supervisor  `@warlock.js/ai/run-supervisor/SKILL.md`

---
name: run-supervisor
description: 'Multi-intent routing with ai.supervisor({...}) — classifier (iter-0 dispatch), router agent OR route callback, intents as agents / workflows / callbacks, fan-out, evaluate quality loop, ack receptionist. Triggers: `ai.supervisor`, `supervisor.execute`, `supervisor.resume`, `intents`, `router`, `route`, `classifier`, `evaluate`, `ack`, `artifactsSchema`, `END`, `ctx.intents.X.execute`; ''route one input across specialists'', ''multi-intent dispatch'', ''fan-out then evaluate'', ''classifier then router''; typical import `import { ai } from "@warlock.js/ai"`. Skip: fixed pipelines — `@warlock.js/ai/run-ai-workflow/SKILL.md`; single agent — `@warlock.js/ai/run-ai-agent/SKILL.md`; competing libs `langgraph`, `crewai`.'
---

# `ai.supervisor()` — multi-intent routing

A supervisor takes one input, picks which intent(s) handle it, runs them, optionally evaluates the result, and either terminates or iterates. Stateless between runs unless you wire `snapshotStore` for resume.

## When to reach for it

- **`agent`** — one model + tools, single task. Doesn't fit when the right specialist depends on the input.
- **`workflow`** — fixed step order. Doesn't fit when routing decisions need an LLM or vary per request.
- **`supervisor`** — when the right specialist is decided per-call and you may iterate to a goal.
- **`orchestrator` (v2)** — when the *session* matters: long-running conversations.

## Three dispatch surfaces

| | When it fires | Iterations |
| --- | --- | --- |
| `classifier` | iter-0 prelude — picks the FIRST intent | 1 |
| `router` | iter 0+ (no classifier); iter 1+ (with classifier) | 1..maxIterations |
| `route` | iter 0+ (no classifier); iter 1+ (with classifier) | 1..maxIterations |

`router` and `route` are mutually exclusive. `classifier` composes with either. Classifier alone (no router/route) → terminates after iter 0.

`classifier` is mutually exclusive with `initialAgent`.

Quick decision tree:
- Pure classification → `classifier` alone.
- Multi-step reasoning → `router` + `intents` with rich descriptions.
- Deterministic routing → `route` callback.
- Classify-then-iterate → `classifier` + `router`/`route`.

## Two routing modes — `route` XOR `router`

### Deterministic — `route(ctx)`

```ts
const triageBot = ai.supervisor({
  name: "triage",
  intents: { billing, shipping, returns },
  route: (ctx) => {
    const text = typeof ctx.input === "string" ? ctx.input.toLowerCase() : "";
    if (text.includes("refund")) return "billing";
    if (text.includes("ship")) return "shipping";
    return "returns";
  },
});
```

`route` returns `string | string[] | typeof END`. Array → fan-out.

### LLM-driven — `router` agent

```ts
const routerAgent = ai.agent({
  output: v.object({ next: v.string(), reasoning: v.string() }),
  // ...
});

const supportBot = ai.supervisor({
  router: routerAgent,
  intents: { billing, shipping, returns, escalate },
  evaluate: (ctx) =>
    Object.values(ctx.result).some((b: any) => b.data?.resolved)
      ? { satisfied: true }
      : undefined,
});
```

The router agent's output MUST include `next: string | string[] | typeof END`; `reasoning: string` is optional but recommended.

`evaluate` pairs with both `router` AND `route` — state-driven termination is useful in either dispatch mode.

## The `intents` map — five accepted shapes

```ts
intents: {
  billing:  billingAgent,                                       // (a) AgentContract
  escalate: escalationWorkflow,                                 // (b) WorkflowInstance
  refund:   async (ctx) => ({ refundId: await callRefundAPI(ctx.input) }), // (c) callback
  triage: {                                                     // (d) agent entry
    agent: triageAgent,
    description: "First-pass classifier",
    placeholders: (ctx) => ({ ticket: ctx.input }),
    output: v.object({ category: v.string() }),
  },
  cancel: {                                                     // (e) callback entry
    run: async (ctx) => ({ cancelledId: await cancelOrder(ctx.input) }),
    description: "Cancel on customer request",
    output: v.object({ cancelledId: v.string() }),
  },
}
```

Runtime detects shape in order: `function → "run" in value → "agent" in value → instanceof`. Mixed dispatch fields (`{ agent, run }` together) throw at construction.

**Under a router**, every intent MUST have a non-empty `description` so the LLM has signal. Bare callback shorthand has no description — upgrade to `{ run, description }` under a router.

## State model

A supervisor builds up typed `state` across iterations. Each intent contributes a slice; final state validates against the supervisor's `output` schema.

```ts
type RefundOutput = { category: string; order?: { id: string }; reply: string };

const refundSupervisor = ai.supervisor<RefundOutput>({
  name: "refund-support",
  output: outputSchema,
  intents: {
    classify: { agent: classifierAgent, output: v.object({ category: v.string() }) },
    lookupOrder: {
      run: async (ctx) => ({ order: await ordersRepo.find(extractId(ctx.input)) }),
    },
    compose: { agent: replyAgent, output: v.object({ reply: v.string() }) },
  },
  router: routerAgent,
  evaluate: (ctx) => (ctx.state.reply ? { satisfied: true } : undefined),
});
```

Each branch's output strip-merges into state per its declared `output` schema. Last-write-wins on fan-out conflict (warning logged).

## Per-intent `next` — skip the router

```ts
intents: {
  classify: {
    agent: classifierAgent,
    next: (ctx) => ctx.state.category === "refund" ? "lookupOrder" : "escalate",
  },
  lookupOrder: {
    run: async (ctx) => ({ order: await ordersRepo.find(extractId(ctx.input)) }),
    next: (ctx) => ctx.state.order ? "compose" : "escalate",
  },
  compose: { agent: replyAgent, next: () => END },
}
```

Returns: `string` (intent name), `string[]` (fan-out), `END` (terminate), `undefined` (fall back to router). Order of authority: `evaluate` → `intent.next` → `router/route`.

## Stream-mode intents

For chat-style prose replies, opt out of structured-output coercion:

```ts
intents: {
  smalltalk: {
    agent: smalltalkAgent,
    mode: "stream",
    streamTo: "reply",   // raw text → state.reply
  },
}
```

Token deltas surface as `supervisor.agent.streaming`. `mode: "stream"` + `output` together throws — they're mutually exclusive. Stream mode is agent-only (workflows can't stream this way).

## `ack` — fast preamble

When the router agent / first specialist takes 5+ seconds and users feel it:

```ts
ack: (ctx) => ({ ack: "Got it, one moment..." })  // bare callback
ack: { run: (ctx) => ({ ack: pickHedge(ctx.input) }), output: v.object({ ack: v.string() }) }
ack: { agent: tinyAckAgent, placeholders: (ctx) => ({ tier: ctx.context.customerTier as string }) }
```

Fires on iter-0 only, in parallel with the routing decision. **Same-model trap:** if ack uses the same model+provider as the router, ack often takes longer than the router. The callback forms (1+2) are right for the common case.

## Classifier — `classifier`

Iter-0 prelude. Output locked to `{ intent, reasoning?, confidence? }`.

```ts
classifier: classifyAgent
// or with refine:
classifier: {
  agent: classifyAgent,
  refine: (ctx) => {
    const { confidence } = ctx.result.data;
    if ((confidence ?? 1) < 0.7) return { intent: "fallback" };
    return undefined;
  },
}
```

`refine` shapes: `undefined` (keep), `END` (halt), `{ intent: "x", ...slice }` (override + merge), `{ ...slice }` (keep intent, merge).

LLM-reported `confidence` is poorly calibrated — use it as a soft signal alongside heuristics.

## Tool artifacts — `ctx.artifacts`

Tools mutate `ctx.artifacts`; supervisor merges into `state` at iteration end.

```ts
ai.supervisor({
  artifactsSchema: v.object({ blocks: v.array(blockSchema).optional() }),
  finalizeArtifacts: (state, artifacts) => ({
    ...state,
    blocks: [...(state.blocks ?? []), ...(artifacts.blocks ?? [])],
  }),
});
```

Default merger — auto-spread (`{...state, ...artifacts}`). `finalizeArtifacts` for concat / dedupe across iterations. Bag resets every iteration.

## Callback intents — `ctx.intents.X.execute()` + `ctx.run` / `ctx.stream`

```ts
intents: {
  "special-refund": async (ctx) => {
    if ((ctx.input as { amount: number }).amount > 1_000) {
      await ctx.intents["audit-log"].execute();   // dispatch registered intent
    }
    return await callRefundAPI(ctx.input);
  },

  // Inline (non-registered) execution
  classify: async (ctx) => {
    const { data } = await ctx.run(classifierAgent, ctx.input);
    return { category: (data as { label: string }).label };
  },

  chatInline: async (ctx) => {
    const stream = ctx.stream(someAgent, enrich(ctx.input));
    const final = await stream.result;
    return { reply: final.text };
  },
}
```

Cycle protection: per-branch call stack. Re-entry on same intent → `SUPERVISOR_DISPATCH_CYCLE`.

## Per-call options

```ts
await supportBot.execute(message, {
  context: { userId, db, traceId },   // request-scoped bag, never persisted
  history: priorMessages,              // Message[] forwarded to router + agents
  sessionId: "sess_user_42",           // stamps onto every report node
  signal: AbortSignal.timeout(60_000),
  runId: "support-2026-04-26-7",       // for snapshot resume
});
```

`history` precedence: per-call → factory `config.history`. Slice with `historyWindow.{router,agents,ack}` (default ack = 0, router/agents = unbounded) or per-entry `history(ctx)` override.

## Iteration model

1. Router/route picks `next` (or `END`).
2. Picked intents dispatch (parallel for fan-out).
3. `evaluate` (if provided) inspects results.
4. If satisfied or `END` → terminate. Otherwise → loop.

Hard cap via `maxIterations` (default 10). Hitting cap surfaces `MaxIterationsError`.

## Streaming

```ts
const stream = supportBot.stream(message);

for await (const event of stream) {
  if (event.type === "supervisor.agent.streaming") {
    process.stdout.write(event.delta);
  }
}

const result = await stream.result;
```

Token-level streaming requires the dispatched agents to be streamed (supervisor calls `agent.stream()` internally). Callbacks don't stream tokens.

## Snapshot resume

```ts
import { ai } from "@warlock.js/ai";
import { cache } from "@warlock.js/cache";

ai.config({ defaultStore: cache.driver("redis", { client }) });

await supportBot.execute(message, { runId: "support-7" });   // fresh
await supportBot.resume("support-7");                          // after crash
```

Signature drift detection throws `SupervisorDriftError` on shape mismatch — `force: true` bypasses. See [`@warlock.js/ai/persist-ai-data/SKILL.md`](@warlock.js/ai/persist-ai-data/SKILL.md).

## `asTool()` — supervisor as a tool

```ts
const supportTool = supportBot.asTool({
  description: "Route a customer support request to the right specialist",
  inputSchema: v.object({ message: v.string() }),
});

const escalationAgent = ai.agent({ model, tools: [supportTool] });
```

## Design reference

`domains/ai/design/supervisor.md` — full design rationale.

## See also

- [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — dispatchable units
- [`@warlock.js/ai/run-ai-workflow/SKILL.md`](@warlock.js/ai/run-ai-workflow/SKILL.md) — when steps are known up front
- [`@warlock.js/ai/persist-ai-data/SKILL.md`](@warlock.js/ai/persist-ai-data/SKILL.md) — `snapshotStore` + resume
- [`@warlock.js/ai/attach-ai-middleware/SKILL.md`](@warlock.js/ai/attach-ai-middleware/SKILL.md) — `semanticCache` fits under each agent's middleware
- [`@warlock.js/ai/define-ai-tool/SKILL.md`](@warlock.js/ai/define-ai-tool/SKILL.md) — tool artifacts side-channel


## write-system-prompt  `@warlock.js/ai/write-system-prompt/SKILL.md`

---
name: write-system-prompt
description: 'Compose system prompts via ai.systemPrompt() / ai.persona() / ai.instruction() — immutable builders with {{placeholder}} substitution. Triggers: `ai.systemPrompt`, `ai.persona`, `ai.instruction`, `SystemPromptBlockContract`, `PersonaContract`, `InstructionContract`, `placeholders`, `{{placeholder|default}}`; ''write a system prompt'', ''compose persona + instructions'', ''per-call prompt override'', ''mustache placeholder''; typical import `import { ai } from "@warlock.js/ai"`. Skip: agent factory wiring — `@warlock.js/ai/run-ai-agent/SKILL.md`; competing libs `langchain` `PromptTemplate`, raw f-strings.'
---

# System prompts — immutable builders

Three factories — `ai.systemPrompt()`, `ai.persona()`, `ai.instruction()` — compose into the `systemPrompt` option accepted by every agent / workflow step.

## The namespace

```ts
import { ai } from "@warlock.js/ai";

ai.systemPrompt();                  // empty — chain .persona(), .instruction() onto it
ai.systemPrompt("literal text");    // one-shot string form
ai.systemPrompt([block1, block2]);  // array form — blocks render in declaration order

ai.persona(text);                   // PersonaContract block
ai.instruction(text);               // InstructionContract block
```

## Two shapes, same result

### String form — one-shot

```ts
ai.agent({
  model,
  systemPrompt: "You are a concise senior TypeScript engineer.",
});
```

### Builder form — composable

```ts
const prompt = ai.systemPrompt()
  .persona("You are Alex, a senior TypeScript engineer.")
  .instruction("Explain things assuming the reader is a Go developer.")
  .instruction("Always cite the relevant TypeScript handbook section.");

const myAgent = ai.agent({ model, systemPrompt: prompt });
```

### Array form — explicit order

```ts
ai.systemPrompt([
  ai.persona("You are Alex, a TypeScript expert."),
  ai.instruction("Respond in {{language|English}}."),
]);
```

## Block ordering

`SystemPrompt` stores `blocks: readonly SystemPromptBlockContract[]` — not separate persona + instructions fields. Rendering honors insertion order.

- **Chained `.persona(x)`** — replaces the existing persona in place, or prepends when none exists. Default persona-first layout.
- **Chained `.instruction(y)`** — appends.
- **Array form** — verbatim.

## Immutability — safe forking

Every mutation returns a **new** `SystemPrompt`. The original is never touched:

```ts
const base = ai.systemPrompt().persona(alex).instruction(cite);
const arabic = base.instruction("Prefer Arabic comments");

// base still has 2 blocks, arabic has 3. Neither affects the other.
```

`Persona` and `Instruction` follow the same rule — their `text` is `readonly`.

## Mustache placeholders

`{{key}}` and `{{key|defaultValue}}` substitute at render time:

```ts
const prompt = ai.systemPrompt()
  .persona("You are Alex, a TypeScript expert.")
  .instruction("Respond in {{language|English}}.");

await myAgent.execute("Why use generics?", {
  placeholders: { language: "Arabic" },
});
```

Or set defaults on the agent — per-call values override them:

```ts
ai.agent({ model, systemPrompt: prompt, placeholders: { language: "Arabic" } });
```

Substitution works on the **rendered** concatenation of every block, so `{{key}}` inside a persona and inside an instruction both resolve against the same placeholder bag.

## Per-call overrides

Replace the agent's system prompt for a single run:

```ts
await myAgent.execute(input, { systemPrompt: alternativePrompt });
```

Useful for A/B testing, request-scoped personalization, or turn-by-turn prompt variation.

## Tagged discriminator (not `instanceof`)

All blocks implement `SystemPromptBlockContract { readonly type: string; readonly text; resolve() }`. Runtime discrimination uses the string `type` tag (`"persona"`, `"instruction"`, future kinds) — **not** `instanceof`.

Why: `instanceof` breaks across duplicate package copies (different `node_modules` trees), realms, bundler scopes.

## Pattern — forking a base prompt

```ts
const base = ai.systemPrompt()
  .persona("You are a support agent for Acme Corp.")
  .instruction("Cite policy §{{policy}} when denying a refund.");

const enterprise = base.instruction("Escalate immediately for Enterprise customers.");
const trial = base.instruction("Offer a 14-day extension before closing the ticket.");
```

Three distinct prompts, one common foundation. Base is immutable — safe to share.

## See also

- [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — `systemPrompt` on factory + per-call override
- [`@warlock.js/ai/run-ai-workflow/SKILL.md`](@warlock.js/ai/run-ai-workflow/SKILL.md) — per-step agent references inherit their own system prompt


