# Agent Client Protocol (ACP) Source: https://lousho.com/acp The [Agent Client Protocol](https://agentclientprotocol.com) lets an editor drive a coding agent that runs as a subprocess: the editor writes JSON-RPC 2.0 requests to the agent's stdin, one JSON message per line, and reads the agent's replies and streamed updates from its stdout. Zed and other editors speak it. `lousho acp` serves any Lousho agent this way, so you can chat with it, watch its tool calls and approve them from the editor's agent panel. ## The command ```bash theme={null} npx lousho acp agent.yaml # a spec file npx lousho acp ./my-agent --model openai/gpt-4o # an agent directory, on another model npx lousho acp src/agent.ts # a .ts/.js module (createAgent() options or an agent) ``` ```text theme={null} lousho acp [--model provider/model] ``` `` is loaded like [`lousho chat`](/cli#lousho-chat) loads it, and `--model` works the same way. The `vendor/` model prefix chooses the provider; with OpenRouter use `openrouter//` (e.g. `openrouter/openai/gpt-4o`). The editor starts the process; it runs until stdin closes. stdout carries nothing but protocol messages: errors, and anything the agent's code prints with `console.log`, go to stderr, which editors show in their logs. A bad path or flag exits with code 1 and the coded error (`LOUSHO_CONFIG_INVALID`, ...) on stderr. ## Zed Add the agent to Zed's `settings.json`, then pick it in the agent panel: ```json theme={null} { "agent_servers": { "My Lousho agent": { "type": "custom", "command": "npx", "args": ["lousho", "acp", "/path/to/my-agent"], "env": { "OPENAI_API_KEY": "sk-..." } } } } ``` Run it from the project that has `@lousho/build-ai-agent` installed (or use an absolute path to `node_modules/.bin/lousho` as `command`). Provider keys come from `env` or from the environment Zed was started in. ## What is supported Protocol version 1. The agent answers: | Method | What it does | | - | - | | `initialize` | Returns `protocolVersion: 1`, `loadSession: false`, text-only prompt capabilities and no auth methods. | | `session/new` | Opens an SDK session (`agent.session()`): the prompts of one ACP session share their history. Returns `{ sessionId }`. `cwd` and `mcpServers` are ignored; give the agent its MCP servers in its own config. | | `session/prompt` | Runs one turn. Text blocks are the input; a `resource_link` block becomes a Markdown link to its URI. Resolves to `{ stopReason }` when the turn ends. | | `session/cancel` (notification) | Aborts the session's running turn: its `session/prompt` resolves to `{ stopReason: 'cancelled' }`, and a permission request still open counts as rejected. | While a turn runs the agent sends `session/update` notifications: * `agent_message_chunk` with a `text` content block for each piece of text the model writes; * `agent_thought_chunk` with a `text` content block for each piece of the model's [reasoning](/reasoning); * `tool_call` (`status: 'in_progress'`, `kind: 'other'`, the tool's name as `title`, its arguments as `rawInput`) when a tool call starts; * `tool_call_update` with `status: 'completed'` (the result as text content and as `rawOutput`) or `'failed'` (the error) when it ends. Sub-agent runs started by the `task` tool are not forwarded; the `task` call itself appears as one tool call. **Permissions.** When a tool needs approval (`needsApproval`, `permissions` rules that ask), the agent sends the editor a `session/request_permission` request with the tool call and two options, `allow` (Allow, `allow_once`) and `reject` (Reject, `reject_once`). The answer decides the approval with `agent.approvals.streamResolve()` and the continued turn keeps streaming updates; a rejection marks the tool call `failed` and the model carries on. A turn may ask several times. **Questions.** An `ask_question` pause (`askQuestion: true`) is not mapped to a permission request, because the user's answer can be free text. It ends the turn (`end_turn`) with the question, and its numbered options, as the agent's message; the next prompt of the session is the answer (`agent.approvals.streamAnswer()`). **Stop reasons.** `end_turn` for a normal finish, `max_turn_requests` when `maxSteps` ran out, `max_tokens` when a `limits` budget tripped or the model stopped on its output limit, `refusal` when a guardrail blocked or the provider's content filter stopped the model, and `cancelled` after `session/cancel`. **Errors.** An unknown method answers JSON-RPC error `-32601`, a line that is not JSON `-32700`, a line that is JSON but not a request object (`null`, an array, a string, a number, or a non-string `method`) `-32600`, an unknown `sessionId` or an empty prompt `-32602`, and a second prompt while one runs in the same session `-32600`. A run that fails answers `-32603` with the error's message, and its SDK error code (for example `LOUSHO_PROVIDER_RATE_LIMITED`) in `error.data.code`. A `null` or non-object `params` reads as `{}`. A malformed line never stops the server. ## Not supported * `session/load` (`loadSession: false`) and session modes. * The client's `fs/*` and `terminal/*` methods: the agent reads files and runs commands with its own tools ([Workspace tools](/workspace-tools)), not through the editor. * Image, audio and embedded-resource prompt blocks (text prompts only). * `plan` updates, and `allow_always` / `reject_always` permission options. * Authentication (`authMethods` is empty): provider keys come from the environment. ## In code The protocol core is `serveAcp(agent, { input, write })`: it reads JSON-RPC lines from any async iterable and hands each outgoing line to `write`, so you can serve it over another transport or drive it in tests without a process. It resolves once `input` ends, after aborting any running turn. ```ts theme={null} import * as readline from 'node:readline'; import { createAgent, serveAcp } from '@lousho/build-ai-agent'; const agent = createAgent({ model: 'openai/gpt-4o-mini', instructions: 'You are a coding assistant.' }); await serveAcp(agent, { input: readline.createInterface({ input: process.stdin }), write: (line) => process.stdout.write(`${line}\n`), }); ``` Pass `store` to keep the ACP sessions' transcripts in an `AgentStore` (for example a `SqliteStore`); by default they use the agent's own `store`, or memory. # Agent directories Source: https://lousho.com/agent-directories An agent can be defined as a directory. `loadAgentDir()` reads it and calls `createAgent()` with the options the files describe, so it returns exactly what `createAgent()` returns. The typed code API stays the source of truth: a directory is a different way to write the same options, and you can switch to code at any time without a rewrite. > **Security:** loading an agent directory **executes its code** (`agent.ts`, > everything in `tools/`). Only load directories you trust. No sandboxing is > implied; the files run with your process's full permissions. ```ts theme={null} import { loadAgentDir } from '@lousho/build-ai-agent'; const agent = await loadAgentDir('./my-agent'); const { text } = await agent.send('Hello!'); ``` ## Layout ```text theme={null} my-agent/ agent.{ts,mts,js,mjs,cjs,json,yaml,yml} # optional config instructions.md # system prompt instructions/.md # appended to the prompt when the model id contains hooks.{ts,mts,cts,js,mjs,cjs} # hooks file (or any path `hooks` names), exports a hook or a list approve.{ts,mts,cts,js,mjs,cjs} # approver file (or any path `approve` names), exports a function tools/*.{ts,mts,cts,js,mjs,cjs} # defineTool() tools skills/ # same layouts as loadSkills() subagents// # nested agent directories schedules/*.{ts,mts,cts,js,mjs,cjs} # defineSchedule() cron schedules (see schedules.md) channels/*.{ts,mts,cts,js,mjs,cjs} # a channel each: defineChannel(), webhookChannel(), ... (see channels.md) memory/*.{ts,mts,cts,js,mjs,cjs} # a memory slot each: defineMemory() (see memory.md); part of the agent auth.{ts,mts,cts,js,mjs,cjs} # route auth for the deployed server: jwt(), oidc(), basic(), ... (see auth.md), # plus optional named authorizeSession / authorizeApproval exports ``` Only one config file may exist. Files inside `tools/` and the entries of `skills/` and `subagents/` are read in sorted order, so loading is deterministic. ### Config file ```ts theme={null} // agent.ts export default { model: 'openai/gpt-4o-mini', description: 'Triages bug reports', maxSteps: 8, toolConcurrency: 2, }; ``` | Key | Maps to | | - | - | | `name` | `createAgent({ name })` (default: the directory name) | | `description` | `manifest.description` — the directory loader does not forward it to `createAgent` (though `createAgent({ description })` accepts it); what a parent agent reads for a sub-agent (required there) | | `model` | `createAgent({ model })`; the `vendor/` prefix chooses the provider — with OpenRouter use `openrouter//` | | `provider` | `createAgent({ provider })` (code config files only) | | `instructions` | `createAgent({ instructions })` (use this or `instructions.md`, not both) | | `maxSteps` | `createAgent({ maxSteps })` | | `toolConcurrency` | `createAgent({ toolConcurrency })` | | `projectInstructions` | `createAgent({ projectInstructions })` | | `permissionMode` | `createAgent({ permissionMode })`: `'default'`, `'plan'`, `'acceptEdits'` or `'dontAsk'` (a function in a code config) | | `permissions` | `createAgent({ permissions })`, in the serializable rule form below | | `compaction` | `createAgent({ compaction })`: `true` or an options object | | `hooks` | `createAgent({ hooks })`: a path (relative to the directory) to a file default-exporting a hook or a list, or the hooks inline in a code config | | `approve` | `createAgent({ approve })`: a path to a file default-exporting the approver function, or the function inline in a code config | | `approvalTtlMs` | `createAgent({ approvalTtlMs })`: milliseconds before an unanswered approval expires; an `ask` rule's `ttlMs` wins over it | | `limits` | `createAgent({ limits })`, e.g. `{ "maxCostUsd": 0.05 }` | | `modelSettings` | `createAgent({ modelSettings })`, e.g. `{ "maxTokens": 1024, "temperature": 0.2 }`; see [Model settings](/configuration#model-settings) | | `retry` | `createAgent({ retry })`, e.g. `{ "maxRetries": 4, "timeoutMs": 30000, "backoff": { "initialMs": 1000 } }`; `timeoutMs` bounds each attempt, `false` sends each call once. `retryOn`, `onRetry` and `signal` are functions, so code configs only | | `output` | `createAgent({ output })`: a Standard Schema (e.g. a zod object) the final answer must match. Code config files only (JSON/YAML cannot hold a schema); from code, `loadAgentDir(dir, { output })` also types `result.object` | | `store` | `createAgent({ store })`: `{ "dir": "./.lousho" }` (resolved against the agent directory) becomes a `fileStore()` — sessions, checkpoints and paused approvals survive a restart; `historyLimit` and `tokenKey` may be set too. A code config may give an `AgentStore` instance instead. Node only | Unknown keys are an error with a suggestion (`'modle' (did you mean 'model'?)`). A `permissions` rule is `{ "tool", "when"?, "action", "reason"?, "ttlMs"? }`: `tool` is a name, a list of names (`'*'` covers every tool) or a `RegExp` in a code config; `action` is `'allow'`, `'ask'` or `'deny'`; `ttlMs` is only allowed on `'ask'` rules — how long the pause waits for a decision before it expires ('approval expired'), winning over the agent's `approvalTtlMs`. In JSON/YAML `when` is a record of argument names to matchers, all of which must hold. A bare string is a regular expression tested against `String(args[name])`; an object picks operators: `eq`/`ne` compare the argument to the operand with `===`/`!==`, `lt`/`lte`/`gt`/`gte` compare `Number(args[name])` with the operand (a non-numeric argument never matches) and `matches` is a regular expression. Several operators on one argument must all hold, so `{ "replicas": { "gte": 1, "lte": 10 } }` matches 1 through 10. A code config may give the predicate itself. The first matching rule decides. ```json theme={null} // agent.json { "permissions": [ { "tool": "shell", "when": { "command": "\\brm\\b" }, "action": "deny", "reason": "No deletes" }, { "tool": "deploy", "when": { "environment": { "eq": "staging" }, "replicas": { "lte": 10 } }, "action": "ask" }, { "tool": ["read_file", "shell"], "action": "allow" }, { "tool": ["write_file", "edit_file"], "action": "ask" } ], "hooks": "hooks.ts", "approve": "approve.ts", "limits": { "maxCostUsd": 0.05 } } ``` ```ts theme={null} // hooks.ts — the default export is a hook or a list of them import type { AgentHook } from '@lousho/build-ai-agent'; const audit: AgentHook = { name: 'audit', postToolCall(ctx) { console.log(ctx.toolName); }, }; export default audit; ``` ```ts theme={null} // approve.ts — the default export decides every `ask`ed call import type { ApproveToolCall } from '@lousho/build-ai-agent'; const approve: ApproveToolCall = ({ args }) => !String(args.path ?? '').endsWith('.test.js'); export default approve; ``` A `hooks` / `approve` path must be relative and stay inside the directory; `lousho build` bundles root `hooks.*` and `approve.*` files next to the config and resolves the compiled `.js` sibling, so `.ts` paths keep working after the build. `instructions/.md` appends to `instructions.md` when the resolved model id contains ``: `instructions/openai.md` applies to `openai/gpt-4o-mini` and to `openrouter/openai/gpt-4o-mini`. The first match in sorted order wins; an unrelated model id gets the base prompt only. ### Tools Each file in `tools/` default-exports a `defineTool()` tool, and may also export more tools by name (or an array of tools). Other exports are ignored, but a file that exports no tool at all is an error, as are two tools with the same name (both files are named in the message). ```ts theme={null} // tools/send_email.ts import { z } from 'zod'; import { defineTool } from '@lousho/build-ai-agent'; export default defineTool({ name: 'send_email', description: 'Send an email', input: z.object({ to: z.string(), body: z.string() }), execute: async ({ to, body }) => ({ sent: true, to, length: body.length }), }); ``` ### Skills `skills/` accepts what `loadSkills()` accepts: `skills//SKILL.md` and `skills/.md`, each with a `description` in its frontmatter. See [Skills](/skills). ### Sub-agents Every directory in `subagents/` is itself an agent directory (and can have its own `subagents/`). It must have a `description` in its config. Each one is a native sub-agent of the parent: it lands in the parent's `subagents` map, so the parent's model delegates to it with the `task` tool (see [Sub-agents](/sub-agents)). The parent also gets a `delegate_to_` tool, kept as a backward-compatible alias: it takes the task text and returns the sub-agent's final answer, and it runs through the same runtime as `task`. A sub-agent uses its own `model` if it sets one, otherwise it inherits the parent's; a `provider` override passed to `loadAgentDir()` reaches all of them. Either way the sub-agent runs inside the parent's run, like any `createAgent()` sub-agent: * the parent's `permissions` rules come first in the sub-agent, so a `deny('write_file')` on the parent holds for the whole tree; * a sub-agent call that needs approval pauses the parent run (`finishReason: 'awaiting-approval'`, the pending approval's `subagentPath` names the sub-agent) and `agent.approvals.resolve()` continues it. The sub-agent directory's own `approve` decides its calls first; without one, an `approve` passed to `loadAgentDir()` in code does, and otherwise the call waits for a human. The parent directory's own `approve` file does not decide its sub-agents' calls; * its token usage is added to the parent's (`result.usage.delegated`), its events reach the parent's listeners tagged with `event.subagent`, and its spans nest under the parent's trace. Nested `subagents/` directories raise the parent's `maxSubagentDepth` to the depth of the tree (unless you pass one), so every level can delegate. A sub-agent directory whose config sets `"engine": "pi"` becomes a Pi coding-agent sub-agent instead: it lands in the parent's `subagents` map (the `task` tool) without a `delegate_to_` alias, and its sessions run in the parent's directory - the workspace the parent's file tools and the Pi session share. `model` takes the usual `pi//` id, and `permissions` rules gate the Pi session's own tool calls (`read`, `bash`, `edit`, `write`, ...). Other keys in its config are ignored: Pi carries its own system prompt and coding tools. Running a `pi` sub-agent needs the optional `@earendil-works/pi-coding-agent` peer and the Node runtime - a Cloudflare Worker target rejects it. ```json theme={null} // subagents/coder/agent.json { "description": "Implements code changes in this workspace.", "engine": "pi", "model": "pi/openrouter/openai/gpt-4o-mini", "permissions": [ { "tool": "bash", "when": { "command": "\\brm\\b" }, "action": "deny" }, { "tool": "*", "action": "allow" } ] } ``` `loadAgentDir(dir, { piAgent: { ... } })` injects `piAgent()` options into every `engine: 'pi'` sub-agent (e.g. a faux `modelRuntime` in tests, a `sessionDir` for durable approvals); the directory always wins `cwd`, `name` and `description`. ### Channels Each file in `channels/` default-exports a [channel](/channels) made with `defineChannel()` or a built-in factory (`webhookChannel()`, `httpChannel()`, `slackChannel()`, `discordChannel()`, `telegramChannel()`, `githubChannel()`, `teamsChannel()`). The channel's name is the one it sets, else the file name. A file that does not export a channel fails with `LOUSHO_CHANNEL_INVALID` naming the file. `resolveAgentDir()` returns them as `channels` (and their names as `manifest.channels`); `loadAgentDir()` does not mount them. The node server that `lousho build --target=node-server` emits mounts them under `/channels` next to the chat routes (`createDeployedServer(agent, { channels })`, an internal helper of the generated bundle); with your own server, use `mountChannels()`: ```ts theme={null} import { createServer } from 'node:http'; import { createAgent, mountChannels, resolveAgentDir } from '@lousho/build-ai-agent'; // channels/support.ts: export default webhookChannel({ secret: process.env.HOOK_SECRET ?? '' }) const { config, channels } = await resolveAgentDir('./my-agent'); const handler = mountChannels(createAgent(config), channels); createServer((req, res) => void handler(req, res).then((handled) => handled || res.writeHead(404).end())).listen(3000); ``` `lousho dev` mounts them too, and `lousho build` deploys them (see below). ### Memory Each file in `memory/` default-exports a [memory slot](/memory): the result of `defineMemory({ ... })`, or the same options without a `name`, in which case the file name is the slot name. Unlike schedules and channels, slots are part of the agent: `loadAgentDir()` passes them to `createAgent({ memory })`, so the `remember_` / `recall_` tools and recall into the prompt work with no extra code, and `manifest.memory` lists their names. Each slot uses its own `provider`. A file that does not export a slot fails with `LOUSHO_MEMORY_INVALID` naming the file. A `memory` override passed to `loadAgentDir(dir, { overrides })` is merged with the directory's slots by name: the override wins a name clash. ```ts theme={null} import { defineMemory, fileMemory, resolveAgentDir } from '@lousho/build-ai-agent'; // memory/notes.ts: export default { scope: 'global', provider: fileMemory({ dir: './.lousho/memory' }) } const { manifest } = await resolveAgentDir('./my-agent', { memory: [defineMemory({ name: 'notes', scope: 'global', provider: fileMemory({ dir: './.lousho/memory' }) })], }); console.log(manifest.memory); // names found in memory/ ``` A relative `dir` in a `memory/*.ts` file resolves against the process working directory, not the agent directory (unlike `store.dir` in `agent.ts`, which is relative to the agent directory). A bare `./.lousho/memory` therefore puts memory in a different tree from the sessions, and it moves when you start the agent from another directory or from a build. Anchor it to the module instead, so it always lands next to the agent's other state: ```ts theme={null} // memory/notes.ts import { fileURLToPath } from 'node:url'; import { fileMemory } from '@lousho/build-ai-agent'; export default { scope: 'global', provider: fileMemory({ dir: fileURLToPath(new URL('../.lousho/memory', import.meta.url)) }), }; ``` The path follows the module: a copied or bundled build keeps its memory next to the build, so point it at a fixed absolute path (or a database provider) if memory must survive redeploys. ## How it maps to `createAgent()` | Directory | `createAgent()` option | | - | - | | `instructions.md` | `instructions` | | `agent.*` keys | the options in the table above | | `tools/` | `tools: [...]` | | `skills/` | `skills: await loadSkills('./skills')` | | `subagents//` | one `subagents` entry (the `task` tool), plus a `delegate_to_` alias in `tools` | `resolveAgentDir()` returns the assembled options and a manifest, which is handy for tests and tooling: ```ts theme={null} import { resolveAgentDir, createAgent } from '@lousho/build-ai-agent'; const { config, manifest } = await resolveAgentDir('./my-agent'); console.log(manifest.tools, manifest.skills, manifest.subagents); const agent = createAgent({ ...config, maxSteps: 3 }); ``` When the directory has a `lousho-registry.json` install receipt ([Registry](/registry)), `manifest.registry` reports whether each installed item's files still match it, and the tools of a receipt item run inside the permission manifest it was installed with. ## Overrides The second argument takes the same options as `createAgent()` and wins over the files. Every option is passed on to the agent, including the ones no file declares (`guardrails`, `onEvent`, `retry`, `reasoning`, `redactContent`, `output`, ...). Use it to swap the model in tests: ```ts theme={null} import { loadAgentDir } from '@lousho/build-ai-agent'; import { mockModel } from '@lousho/build-ai-agent/testing'; const agent = await loadAgentDir('./my-agent', { provider: mockModel(['Hi there.']) }); ``` `tools` and `skills` overrides replace the discovered ones (they are not merged). A `provider/model` string in a file is dropped when you override `provider`, so the instance is used as-is. `approve: null` / `hooks: null` strip the directory's wiring (the file its config names is not even imported); `undefined` keeps it. ## Moving from files to code Call `resolveAgentDir()`, print `config`, and paste what you need into a `createAgent()` call; or load the directory and override just the parts you want to own in code. Tools are plain `defineTool()` values, so a tool file can be imported from code unchanged. ## Loading TypeScript `.ts` files are loaded with a dynamic `import()`, so the process must already run under a TypeScript loader: `npx tsx your-script.ts` (or ts-node, bun, deno, or Node's built-in type stripping). Without one, loading fails with an error that says so. Compile the directory first, or use `.js`/`.mjs` tools with an `agent.json` / `agent.yaml` config, which work everywhere. A `.js` tool using `import` syntax needs `"type": "module"` in the nearest `package.json` (or the `.mjs` extension). ## Run it with `lousho dev` ```bash theme={null} npx lousho dev ./my-agent ``` Serves the chat UI and `POST /chat` for the directory and reloads it when `instructions.md`, the config file, `tools/`, `skills/` or `subagents/` change (see [`lousho dev`](/cli#lousho-dev) for the details). A tool file is imported afresh on each reload, so an edit to `tools/*.ts` takes effect on the next message. A failed reload (a syntax error, an empty `instructions.md`) is logged and shown in the chat page, and the previous agent keeps answering. The directory's `channels/` are mounted under `/channels` and its `schedules/` are started, and a reload swaps both: the old schedules are stopped before the new ones start, so no timer or route outlives its file. Pass `--no-schedules` to mount the channels but not fire the crons (see [Schedules in dev](/schedules#in-lousho-dev)). ## Deploy it with `lousho build` ```bash theme={null} npx lousho build ./my-agent --target=node-server # or docker ``` The agent directory is the unit of deployment: the built server loads it with `resolveAgentDir()` at start-up, starts its `schedules/` and mounts its `channels/` under `/channels`, guards its chat routes with `auth.ts` when there is one ([Route auth and principals](/auth)), and prints which it found. A function `auth.ts` exports as `authorizeSession` or `authorizeApproval` decides who may use a session and who may decide an approval, e.g. a manager approving a user's paused claim ([Who may decide an approval](/auth#who-may-decide-an-approval)); without them the server keeps the defaults (a session and its approvals belong to the caller that started it). The code files (the config, `auth.ts`, a root `hooks.*` / `approve.*` file, `tools/`, `schedules/`, `channels/`, `memory/`, and the same in each sub-agent) are bundled to `dist/agent/**.js`, and only `instructions.md` (with `instructions/.md`), `skills/` and a JSON/YAML config (the same in each sub-agent) are copied next to them, so the server needs no TypeScript loader, sources or `node_modules`. Nothing else is copied: `.env` files, the `.lousho/` session store (or the config's `store.dir`) and any other file stay out of the build and its Docker image. See [Deployment](/deployment#agent-directories). The Cloudflare Worker target builds a directory too, embedding every folder: `subagents/` become `delegate_to_` tools, `schedules/` become cron triggers, `channels/` are mounted under `/channels` and `memory/` slots run on `kvMemory()` ([Cloudflare Workers](/cloudflare-workers#build-and-deploy)). ## Serving over MCP `lousho mcp` takes an agent directory as well as a spec file ([Configuration](/configuration)): `lousho mcp ./my-agent` serves it as an MCP server ([MCP](/mcp-integration#serve-an-agent-over-mcp)). # Agent Forge Source: https://lousho.com/agent-forge Agent Forge (`apps/agent-forge`) is this SDK's companion visual dashboard: build an agent's graph on a canvas, run it, watch it execute in a live debug console, chat with it, and author sandboxed pre/post hooks - all reading and writing the exact same `AgentSpec` YAML that `lousho dev` and `lousho build` use. It's launched with one command, `lousho studio`, and ships as part of this SDK's npm package. This doc covers: installation, the `lousho studio` quickstart, a first-agent walkthrough, hook authoring, time travel (replaying a run from a past step), and how settings/secrets/deploy wiring works. ## Installation Agent Forge ships inside `@lousho/build-ai-agent` itself - there's no separate package to install: ```bash theme={null} npm install @lousho/build-ai-agent ``` That's it. `lousho studio` (below) runs a pre-built copy of the app; you don't need `apps/agent-forge`'s own source or its devDependencies (Vite, `tsx`, etc.) to use it. If you're working inside this SDK's own monorepo instead (contributing to Agent Forge itself), see [Dev mode](#dev-mode) below. ## Quickstart ```bash theme={null} npx lousho studio ``` This starts one local server and prints its URL (default `http://127.0.0.1:4750/?token=`). Open that exact URL in a browser - you'll see the canvas, the left rail (your saved agents + a node/hook palette), the Inspector (right), and a bottom drawer with Chat/Logs/Trace/Output/Settings tabs. Below 860px wide the rail and the Inspector become slide-over panels you open from the top bar, and Debug, Import, Export and Save move into its **More** (`⋯`) menu. Useful flags: ```bash theme={null} npx lousho studio --port 5000 # pick a different port npx lousho studio --host 0.0.0.0 # bind to all interfaces npx lousho studio --prod # force production mode (see below) npx lousho studio --dev # force dev mode (monorepo only) ``` ### Access token The studio can store provider keys, start runs and execute hook code, so its API is locked to the browser tab you opened from the printed URL: * Each launch mints a random token and prints it in the URL (`?token=...`). The page keeps it for that tab (it survives a reload) and sends it on every API call (`x-lousho-studio-token` header) and on the WebSocket (`?token=` query). A request without it gets `401`. Opening the bare `http://127.0.0.1:4750` in a new tab loads the page, but its API calls fail until you use the printed URL. * The server sends no CORS headers and refuses (`403`) a request whose `Origin` is not the studio's own, or whose `Host` is not a loopback name (`localhost`, `127.0.0.1`, `[::1]`) or the `--host` you bound to. With `--host 0.0.0.0` any `Host` is accepted and the token is the only gate. * `GET /health` and the static client need no token. From `curl`, pass the token: `curl -H "x-lousho-studio-token: $TOKEN" localhost:4750/agents`. `lousho studio` hands these to the processes it starts through environment variables; you only set them yourself when you start the studio server without the CLI: | Variable | Read by | Meaning | | - | - | - | | `LOUSHO_STUDIO_TOKEN` | the studio API server | The access token. Unset, the server mints a random one per launch. | | `LOUSHO_STUDIO_ALLOWED_ORIGINS` | the studio API server | Comma-separated extra origins to accept besides the studio's own (dev mode sets the Vite dev server's). | | `LOUSHO_STUDIO_API_URL` | the Vite dev server (dev mode) | Where to proxy API calls (default `http://127.0.0.1:4750`). | ### Where agents live Agent specs and run state are stored under `.lousho/` in the directory you ran `lousho studio` from (agent YAML files under `.lousho/agents/`, checkpoints and approvals alongside them) - the same `.lousho/` layout `lousho dev`/`lousho build` use. The left rail lists exactly the agents in that `.lousho/agents/`: a YAML file you add there shows up, and an agent you create in the studio is written there at once (autosave and **Save** both write the file), so it can be chatted with straight away. Another project, opened in its own `lousho studio`, shows only its own agents. The browser keeps nothing but a draft of an edit that could not reach the server (it is pushed on the next load), stored per workspace directory. A file in `.lousho/agents/` that no longer parses is skipped with a warning in the server log. ### Production vs. dev mode `lousho studio` has two modes, and picks the right one automatically: * **Production** (the default once Agent Forge has been built): a single Express server serves both the REST/WebSocket API *and* the pre-built client (React app) as static files, on one port. This is what runs when you `npm install` the published package - there's no separate Vite dev server and no TypeScript loader involved. * **Dev** (falls back to this if the build hasn't been run yet, i.e. inside a source checkout of this monorepo): the API server runs straight off its TypeScript source via `tsx`, alongside a real Vite dev server (with hot-module reload) that proxies API requests to it. Two processes, two ports internally, one command. `--prod`/`--dev` force one or the other; run without either flag and `lousho studio` auto-detects based on whether `apps/agent-forge/dist-server` exists. ## First-agent walkthrough 1. **Create an agent.** In the left rail's "Agents" tab, click **New**, give it a name, and pick a starting template - **Blank graph** (a single LLM node using the built-in `mock` provider) is the fastest way to try things out without any API keys. Agent Forge creates the agent under that name (`.lousho/agents/.yaml`, with `name: ` in the spec) and opens it on the canvas. The open agent's card has **Rename** and **Delete**. Rename moves the spec to the new name; earlier chats, traces and checkpoints stay under the old one. 2. **Look at the graph.** A blank agent is one `llm` node. Drag more nodes in from the left rail's palette (**Trigger**, **LLM step**, **Tool call**, **Approval gate**, **Response / output**), or click (or press Enter on) a palette item to add it at the centre of the canvas, and connect them by dragging between their handles. Click a node to edit its settings (prompt, provider/model, tools, breakpoints) in the Inspector on the right. 3. **Run it.** Click **Run** in the top bar. With the `mock` provider there's nothing to configure - it returns deterministic canned output, which is exactly the point for trying the rest of the UI without needing a real LLM API key. The status pill in the top bar tracks the run (`running` → `done`, `stopped` after you press **Stop**, `error` or `awaiting approval`). Run sends a fixed prompt, which the Chat tab shows as a **Run input** bubble instead of a message from you. If the studio server goes away, a banner says the connection was lost and the studio reconnects when it is back. 4. **Watch it in the debug console.** Open the bottom drawer's **Logs** tab for a live, filterable log feed of the run (trigger/llm/tool/ sandbox/checkpoint/approval events), or **Trace** for a span waterfall. Turn on **Debug** in the top bar (or set a breakpoint on a node in the Inspector) to pause the run at LLM/tool boundaries and step through it with the debug bar's **Step**/**Continue** controls; expand "Live message array" there to inspect the in-flight message list. **Output** shows the full `ExecutionResult` (messages, tool calls, usage, steps) as a collapsible JSON tree once the run finishes or pauses. **Trace history.** Every run is also saved as a trace file, so the **Trace** tab keeps the runs of the agent after the page is reloaded or the server restarted. A list on the left shows the agent's past runs, newest first (start time, duration, model calls, tokens in/out, cost, status), with a **Live** entry on top while a run is active; choose one to open its spans in the same waterfall, with each span's kind (`internal` or `client`) and failed spans in the error colour. The list refreshes when a run ends. The files are the ones `lousho traces` reads, in `.lousho/agents//traces//.jsonl` under the directory `lousho studio` was started in (see [Local traces](/observability#local-traces)): `npx lousho traces --dir .lousho/agents//traces` shows the same runs in the terminal, and deleting the agent's folder deletes its traces. The tab shows what the spans hold: model and tool names, token counts, cost, durations and error messages, and with the span's tooltip the attributes, which carry prompt and tool content. The server reads traces only from that folder (an id with a path separator is a 400) and never sends the file path to the browser. Keep `.lousho/` out of version control. 5. **Chat with it.** The **Chat** tab is a separate conversational transport (`POST /agents/:id/message`, streamed back over the same WebSocket as everything else) - send it a message and it replies in the thread, independent of the graph "Run" button above. Each agent keeps its own chat history (`+ New chat` starts a fresh session; old ones stay browsable). 6. **Approve a paused tool call.** Add a **Tool call** node, open it in the Inspector, and pick a tool - some tools (or an agent's own policy) can require human approval before running. When a run or chat hits one, an inline approval card appears (in the top bar for a graph run, or as a message bubble in the Chat thread) showing the tool name and its arguments. Click **Approve** to let it proceed or **Reject** to abort that call; the run resumes automatically either way. ## Hooks Hooks are small, sandboxed functions that run immediately before or after a tool call or an LLM `generate` - the same mechanism the SDK core exposes as `HookRegistry`/`AgentHook` (see [API Overview](/api-overview)), but authored visually and attached to a specific canvas node. To attach one: 1. Select an `llm` or `tool` node on the canvas (hooks only apply to these - they're the two points `AgentExecutor` actually calls out to). 2. Open the Inspector's **Hooks** section. Drag a **Pre-hook** or **Post-hook** entry from the left rail's palette onto the node (or drop it directly in the Hooks section), or click the palette entry while the node is selected, to attach a new one; you'll get a choice of starter templates (`redact-pii`, `rate-limit`, `audit-log`, `inject-context`) as an editable starting point. 3. Click the hook's chip to select it, then edit its code in the CodeMirror editor. The code is the **body** of an async function invoked as `hook(ctx)` inside a sandboxed subprocess (`server/hookSandbox.ts`) - never in the browser or the API server process itself: * For a tool-call hook, `ctx` is `{ toolName, args, result?, error? }`. A pre-hook can mutate `ctx.args`; a post-hook can inspect/mutate `ctx.result`/`ctx.error`. Return `ctx`. * For an LLM-generate hook, `ctx` is `{ messages, model }`. Mutate/push onto `ctx.messages` and return `ctx`. * **Throwing aborts the step** - a rate-limit hook, for example, throws to block the tool call outright rather than letting it run. 4. Toggle the switch on a hook's chip to enable/disable it without removing it, or use its **x** to remove it entirely. Only enabled hooks are compiled into the run. Example: a redact-PII pre-hook on a tool node (this is the `redact-pii` starter template) - ```js theme={null} const PII = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi; for (const key of Object.keys(ctx.args || {})) { if (typeof ctx.args[key] === 'string') { ctx.args[key] = ctx.args[key].replace(PII, '[REDACTED]'); } } return ctx; ``` Saving the agent persists these as `spec.policy.hooks` in the agent's YAML; the server compiles enabled hooks into a real `HookRegistry` for the run (`server/compileHooks.ts`), routed through the same `SandboxAdapter` a sandboxed tool uses. ## Time travel Agent Forge keeps every run's checkpoints, not just the latest: its file store (`server/checkpointStore.ts`) keeps the same bounded history as the SDK's `LocalStorageCheckpointStore` (the newest 50 saves per run, under `.lousho/agents//checkpoint-history/`; see [Checkpoint history](/durable-execution#checkpoint-history)). From that history you can fork a run at any step, change what happened there, and replay it next to the original, with [`AgentExecutor.fork()`](/durable-execution#fork-and-replay) and `compareTrajectories()` doing the work. ### The History tab 1. Run the agent (Topbar **Run**, or a Chat message). 2. Open the bottom drawer's **History** tab. It lists the run's steps: step number, status, the model's finish reason, the tool calls made, and the tokens and cost of the step's model call when known. 3. Click **Edit and replay from here** on a step. Choose **Append a user message** and type one, or pick one of the step's tool calls to edit its result (prefilled with the recorded result; text that parses as JSON is sent as JSON). 4. Click **Fork and replay**. The run is forked at that step with your edit and started as a new run, `.fork-`. 5. The fork opens next to the original as a side-by-side trajectory: each model turn of both runs (text, tool calls and results), the first turn where they differ highlighted as **diverged**, and the drift entries (tool order, arguments, step count, finish reason) listed below. It refreshes when the fork finishes. ### Routes A run id is the run's checkpoint session id: the agent id for the agent's own runs, `.fork-` for a fork of run ``. The runtime control server has three routes for it (the History tab uses them), and two more for an agent's saved traces (the Trace tab uses them): | Route | What it does | | - | - | | `GET /runs/:id/history` | `{ runId, steps }`: one entry per step of the run's latest execution (the newest checkpoint saved at that step), oldest first, with `status`, `savedAt`, `finishReason`, `toolCalls` (`{ id, name, args, result? }`), and `tokens` / `costUsd` of the step's model call when known. 404 when the run has no checkpoints. | | `POST /runs/:id/fork` | Body `{ fromStep, patch? }`, where `patch` is `{ toolResult?: { toolCallId, result }, appendInput?, businessState? }`. Forks the run at `fromStep`, then starts the fork through the run registry with the original run's spec. Returns 202 `{ runId, fromStep, status }`; the fork streams on `WS /agents//stream` and reports on `GET /agents//status` like any run. 400 for a bad body or an unknown `toolCallId`, 404 for an unknown run or a step with no checkpoint. | | `GET /agents/:id/traces?limit=N` | `{ traces }`: the agent's saved runs from `.lousho/agents//traces`, newest first (`limit` defaults to 20, at most 200), each with `traceId`, `name`, `startTime`, `durationMs`, `status`, `modelCalls`, `toolCalls`, `inputTokens`, `outputTokens` and `costUsd` when known. An agent with no runs gives an empty list; 404 for an unknown agent. | | `GET /agents/:id/traces/:traceId` | `{ traceId, spans }`: the spans of one saved run by start time, each with `id`, `parentId`, `name`, `kind`, `status`, `startTime`, `endTime` and `attributes`. `traceId` may be a unique prefix. 400 for an id that is not letters, digits and dashes or a prefix that matches several runs, 404 for an unknown agent or trace. | | `GET /runs/compare?a=&b=` | `compareTrajectories(a, b)` of two runs' latest checkpoints: each run's model turns, `divergedAt` (the first turn that differs) and the `drift` entries. 404 when either run has no checkpoint. | The flow, for an agent `weather` that has run once: ```bash theme={null} # $TOKEN is the ?token= value `lousho studio` printed curl -H "x-lousho-studio-token: $TOKEN" localhost:4750/runs/weather/history # {"runId":"weather","steps":[{"step":1,"status":"finished","finishReason":"stop","toolCalls":[],"tokens":...}]} curl -X POST localhost:4750/runs/weather/fork -H "x-lousho-studio-token: $TOKEN" -H 'content-type: application/json' \ -d '{"fromStep":1,"patch":{"appendInput":"And in Celsius?"}}' # {"runId":"weather.fork-1","fromStep":1,"status":{"status":"running",...}} curl -H "x-lousho-studio-token: $TOKEN" 'localhost:4750/runs/compare?a=weather&b=weather.fork-1' # {"a":[...],"b":[...],"divergedAt":2,"drift":[{"field":"steps","committed":"1","current":"2"}]} ``` The fork is a run of its own: the original run's checkpoints, status and chat are unchanged. ## Settings, secrets and deploy wiring The bottom drawer has a **Settings** tab with three parts: provider API keys (OpenAI and Anthropic, stored encrypted under `.lousho/` and never shown again), named settings profiles (provider, deploy adapter, hook timeout, OpenTelemetry toggle; kept in `.lousho/settings.json`, which holds no secrets), and a **Deploy** section that picks an adapter (`node-server`, `docker` or `cloudflare-worker`) and runs `lousho build` for the selected agent. Other providers still read their environment variables, the same way `lousho dev`/`lousho build` do (see [Configuration](/configuration)), and the `mock` provider needs no credentials. Stored keys are encrypted with AES-256-GCM in `.lousho/secrets.json`. The encryption key is generated on first use and kept next to it in `.lousho/secrets.key` (both `0600` on POSIX). This keeps keys out of anything that copies the ciphertext alone - a backup, a zip of the project, a shared agent YAML - but anyone who can read both files as your user can decrypt them. It is not an OS keychain. Keep `.lousho/` out of version control and out of shared archives, and prefer environment variables on shared machines. An agent whose provider has no key (no stored key and no environment variable) does not run. **Run** and **Chat** fail with `LOUSHO_PROVIDER_MISSING_API_KEY` and a message naming the fix: add the key in Settings, set the environment variable, or switch the agent's provider to `mock`. The studio never swaps in the mock provider on its own. The top bar's provider pill shows the `provider/model` the agent actually runs on, and a mock agent carries a **MOCK** badge. ## Dev mode If you're working inside this SDK's own monorepo (contributing to Agent Forge itself, not just using it), `lousho studio` falls back to dev mode automatically as long as `apps/agent-forge/dist-server` hasn't been built yet: ```bash theme={null} git clone https://github.com/LinuxDevil/agent-sdk.git cd agent-sdk npm install npx lousho studio --dev # or just `npx lousho studio` before building ``` This runs the API server straight from TypeScript (`tsx`) and a real Vite dev server with hot-module reload for `apps/agent-forge/src/**`, as two sibling processes. Open the printed `http://localhost:5173/?token=...` URL; Vite is pinned to port 5173 and the API server accepts that origin. To build the production bundle used by everyone else (and to verify what actually ships): ```bash theme={null} npm run build:studio # builds apps/agent-forge/dist (client) and # apps/agent-forge/dist-server (bundled server) npx lousho studio # now runs in production mode ``` `npm run build:studio` also runs automatically as part of the root `prepublishOnly` script, so a published `npm publish` can never ship a stale or unbuilt studio. ## E2E smoke test `apps/agent-forge/e2e/studio.spec.ts` is a headless Playwright test that drives the real, built `lousho studio` (the same `dist-server/index.cjs` production server, not a dev-mode Vite server) through a browser: it creates an agent from the "Support bot" template, retargets its tool node at a server-local `demo-approval` tool (always `needsApproval: true` - see `server/buildAgent.ts`'s doc comment for why this exists, since none of the SDK's spec-resolvable built-in tools require approval), sends it a chat message that triggers the mock provider's tool-call heuristic, waits for the run to pause on the approval gate, approves it via the Chat tab's inline approval card, and asserts the run completes. Run it with: ```bash theme={null} npm run test:e2e:studio # from the repo root, or apps/agent-forge/ ``` This builds Agent Forge first (`build:studio`), then runs Playwright against it. # AI SDK UI (useChat) Source: https://lousho.com/ai-sdk-ui If your front end already uses the Vercel AI SDK's UI hooks (`useChat` from `@ai-sdk/react`, and the Vue, Svelte and Angular equivalents), you can keep them and run a Lousho agent behind the route. Three functions, exported from the package root, do the translation. They import nothing from `ai`, use no `node:*` module, and work on Node, Workers and any Fetch-based runtime. | Function | Direction | What it does | | - | - | - | | `toUIMessageStream(run)` | out | the run's events as a `ReadableStream` of UI message chunks | | `toUIMessageStreamResponse(run, init?)` | out | a `Response`: SSE, `data: [DONE]` at the end, header `x-vercel-ai-ui-message-stream: v1` | | `fromUIMessages(messages, { lastUserOnly? })` | in | the `UIMessage[]` `useChat` posts as an `AgentInput` | ## Route handler The `vendor/` model prefix chooses the provider; with OpenRouter use `openrouter//` (e.g. `openrouter/openai/gpt-4o-mini`). ```ts theme={null} import { createAgent, fromUIMessages, memoryStore, toUIMessageStreamResponse, type UIMessageLike } from '@lousho/build-ai-agent'; // The store must outlive the request: without one, every `agent.session({ id })` // gets a fresh in-memory transcript and the next turn would start empty. // `memoryStore()` keeps it for the process's life; for a serverless deployment // or to survive a restart use `fileStore()`, a `SqliteStore` or `KVStore` // (see [Sessions](sessions.md#choosing-a-store)). const agent = createAgent({ model: 'openai/gpt-4o-mini', instructions: 'You are helpful.', store: memoryStore() }); // app/api/chat/route.ts export async function POST(request: Request): Promise { const { id, messages } = (await request.json()) as { id: string; messages: UIMessageLike[] }; // The session (keyed on the chat id) keeps the transcript, so only the new user message is the input. const session = agent.session({ id }); return toUIMessageStreamResponse(session.stream(fromUIMessages(messages, { lastUserOnly: true }))); } ``` Without a session, pass every message instead: `toUIMessageStreamResponse(agent.stream(fromUIMessages(messages)))`. ## Client ```tsx theme={null} import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; export function Chat() { const { messages, sendMessage } = useChat({ transport: new DefaultChatTransport({ api: '/api/chat' }), }); return ( <> {messages.map((message) => message.parts.map((part, i) => part.type === 'text' ?

{part.text}

: null ) )} ); } ``` This snippet targets `ai` 6 and 7 (`useChat` takes a `transport`, and `sendMessage` posts the message). On `ai` 4 — also a supported peer — `useChat({ api: '/api/chat' })` takes the endpoint directly and returns `input` / `handleInputChange` / `handleSubmit` instead of `sendMessage`; `ai` 5 is not in the peer range. ## Event mapping | Lousho event | UI message chunk | | - | - | | `run.start` | `start` (`messageId` is the run id) | | `step.start` / `step.done` | `start-step` / `finish-step` | | `text.delta`, `text.done` | `text-start` (once per text part), `text-delta`, `text-end` | | `reasoning.start`, `reasoning.delta`, `reasoning.done` | `reasoning-start`, `reasoning-delta`, `reasoning-end` (see [Reasoning](/reasoning)) | | `tool.start` / `tool.resume` | `tool-input-start`, then `tool-input-available` with the arguments | | `tool.partial` | `tool-output-available` with `preliminary: true` (AI SDK 5+): a snapshot of a [streaming tool](/tools#streaming-partial-results) | | `tool.done` / `tool.error` | `tool-output-available` / `tool-output-error` | | `approval.requested` | `data-lousho-approval` (below) | | `todo.updated` | `data-lousho-todos`: a data part `{ todos, counts }` with the complete todo list, under the id `todos` so each update replaces the last (see [Todo tools](/tools#todo-tools)) | | `error` | `error` | | `run.done` | `finish` | A streaming tool's snapshots arrive as preliminary outputs, which `useChat` shows as the tool part's `output` with `preliminary: true`; the `tool.done` chunk after them has no `preliminary` and is the final output. Events with no UI counterpart (retries, compaction, permission decisions, guardrails, ...) and the events of sub-agents are not forwarded. The `finish` chunk's `finishReason` is the AI SDK's (`stop`, `length`, `content-filter`, `tool-calls`, `error`; anything else, such as `awaiting-approval` or `max-steps`, is `other`). ## Usage and cost Usage goes in the `finish` chunk's `messageMetadata`, so `message.metadata` on the finished assistant message has it: ```json theme={null} { "runId": "run_1", "loushoFinishReason": "stop", "usage": { "totalTokens": 120, "costUsd": 0.0004 } } ``` `usage` is the run's `AgentEventUsage` (absent when the run failed); `loushoFinishReason` is the run's own finish reason, unmapped. ## Approvals and `ask_question` A tool that needs approval, or an `ask_question` call, pauses the run: the stream ends (`finish` with `loushoFinishReason: "awaiting-approval"`) after a custom data part, which `useChat` shows as a part of type `data-lousho-approval`: ```json theme={null} { "type": "data-lousho-approval", "id": "appr_1", "data": { "approvalId": "appr_1", "toolCallId": "call_1", "toolName": "deploy", "input": { "env": "prod" }, "kind": "question", "question": { "text": "Which environment?", "options": ["staging", "prod"] } } } ``` `kind` and `question` are present only for an `ask_question`; a tool waiting for an [OAuth sign-in](/oauth#what-the-user-sees) instead carries `kind: 'sign-in'` and `signIn: { provider, displayName?, url }` — show the link and, once the provider redirected back, approve it ("I've signed in"). Render the part with your own buttons and answer it with the session API's approvals route (`POST /chat/:sessionId/approvals/:approvalId`, see [Sessions](/sessions) and [Approvals](/approvals)): `{ "approved": true }` or `{ "approved": false, "note": "..." }` for a tool, `{ "answer": "prod" }` for a question. The response is the continuation as an event stream, which you can read with the UI bindings' parser or ignore and call `useChat`'s `sendMessage` again; or, in your own route, call `agent.approvals.streamResolve()` / `streamAnswer()` and return `toUIMessageStreamResponse()` of the result, so the continuation renders in the same chat. ## Inbound conversion `fromUIMessages()` maps text parts to text, `image/*` file parts to image parts and other file parts to file parts (the part's `url`, a `data:` or `http(s)` URL, becomes the data). Tool, reasoning, data, source and unknown parts are ignored, and messages that end up empty are dropped. The messages come from the client, so only `user` and `assistant` messages are kept: a `system` message (or any other role, or an entry that is not a message) is dropped, so a caller cannot add instructions to the agent's prompt. Set instructions on the agent (`createAgent({ instructions })`), not in the request. A dedicated `@lousho/build-ai-agent/ai-sdk-ui` subpath may follow; for now the functions come from the package root. # API Overview Source: https://lousho.com/api-overview The package root (@lousho/build-ai-agent) exports everything below. For the complete, generated API reference (every export, signature and doc comment), build the TypeDoc site: ```bash theme={null} npm run docs:build # writes docs/api/index.html ``` > Snippets on this page leave some names to you: `mcpClient` is a connected MCP `Client`; `provider` is any `LLMProvider` (offline: `createMockProvider()`). How the pieces fit: ```text theme={null} ┌─────────────────────────────────────┐ │ Your Application │ │ (React, Vue, Express, etc.) │ └──────────────┬──────────────────────┘ │ ┌──────────────▼──────────────────────┐ │ @lousho/build-ai-agent │ │ ┌────────────────────────────┐ │ │ │ createAgent / Builder / │ │ │ │ Executor (approvals, │ │ │ │ checkpoints, tracing) │ │ │ ├────────────────────────────┤ │ │ │ Tools │ Sub-agents │ MCP │ │ │ │ Flows │ Guardrails │ Evals │ │ │ ├────────────────────────────┤ │ │ │ Core Engine │ │ │ └────────────────────────────┘ │ └──────────────┬──────────────────────┘ │ ┌──────────────▼──────────────────────┐ │ Your LLM Provider & Deploy Target │ │ (OpenAI/Anthropic/Ollama/OpenRouter,│ │ Node server / Docker / Workers) │ └─────────────────────────────────────┘ ``` ## Building and running agents | Export | Description | | - | - | | `createAgent(config)` | Zero-config `{ send(message) }` agent from a `model` string or provider (+ instructions, tools). | | `AgentBuilder` (from `@lousho/build-ai-agent/executor`) | Fluent builder for an `AgentConfig` (`AgentBuilder.create().setName(...)...build()`). Advanced: see [the executor API](/executor-api). | | `AgentExecutor.execute(opts)` (from `@lousho/build-ai-agent/executor`) | Static executor: runs an agent (LLM + tool-calling loop) and resolves to an `ExecutionResult`. Advanced: see [the executor API](/executor-api). | | `resumeAfterApproval()` (from `@lousho/build-ai-agent/executor`) | Resume an execution paused for human approval. Advanced: see [the executor API](/executor-api). | | `InMemoryApprovalStore` | Process-local `ApprovalStore`; the default store of `createAgent()` agents. | | `StorageServiceApprovalStore`, `LocalStorageCheckpointStore` (from `/utils`) | File-backed approval and checkpoint stores over a `StorageService` (see [Approvals](/approvals), [Durable execution](/durable-execution)). | | `SqliteStore` (from `/sqlite`) | Sessions, checkpoints and approvals in one SQLite file (see [Sessions](/sessions#choosing-a-store)). | | `fileStore(dir)` | Sessions, checkpoints and approvals as plain JSON files under `dir` (see [Sessions](/sessions#choosing-a-store)). | | `KVStore`, `KVCheckpointStore` (from `/kv`) | Stores on a Cloudflare Workers KV binding, for a hand-written Worker (see [Deployment](/deployment)). | | `AgentStore`, `memoryStore()` | The `createAgent({ store })` option: `{ sessions?, checkpoints?, approvals?, tokens? }`, and an in-memory one (see [Sessions](/sessions#choosing-a-store); `tokens` holds OAuth tokens, see [OAuth](/oauth)). | | `SessionAwaitingApprovalError` | Thrown by `execute()` when its `sessionId` is paused on an approval (see [Durable execution](/durable-execution)). | | `SDKError`, `ERROR_CODES` | Base class of the SDK's errors: a stable `code`, a `hint` and a `docs` link (see [Errors](/errors)). | ### Dynamic config `createAgent()`'s `model`, `instructions` and `tools` each take the static value or a function of the run, `(ctx) => value | Promise` (type `PerRun`). `ctx` is `{ sessionId?, input, metadata?, principal? }` (type `RunConfigContext`): the `sessionId` of `send()` / `stream()` or the `agent.session()` id, the run's user input, the `metadata` call option of `send()`, `stream()`, `session.send()` and `session.stream()`, and the caller the run's auth accepted (`principal`, absent when the call did not pass one). The functions run once when a run starts, before the first model call, and again on every session turn. Everything else applies to what they return: `fallbackModels` and `retry`, `projectInstructions`, memory, skills, sub-agents, MCP tools, permissions, approvals and guardrails. A dynamic agent used as a sub-agent resolves with the task prompt as `input` and the lead run's `sessionId`, `metadata` and `principal`. ```ts theme={null} import { createAgent, defineTool } from '@lousho/build-ai-agent'; import { mockModel } from '@lousho/build-ai-agent/testing'; import { z } from 'zod'; const refund = defineTool({ name: 'refund', description: 'Refunds an order', input: z.object({ orderId: z.string() }), execute: async () => 'ok' }); const agent = createAgent({ provider: mockModel(['Hello!']), // A cheaper model for short inputs, tenant instructions, tools by role. model: ({ input }) => (typeof input === 'string' && input.length < 200 ? 'small-model' : 'large-model'), instructions: ({ metadata }) => `You are the support agent of ${String(metadata?.tenant ?? 'Acme')}.`, tools: ({ metadata }) => (metadata?.role === 'admin' ? [refund] : []), }); await agent.send('Hi', { metadata: { tenant: 'Globex', role: 'admin' } }); ``` A function that throws fails the run with `LOUSHO_CONFIG_RESOLVER_FAILED` (`error.field` names the option, `error.cause` is the thrown error): `send()` rejects, `stream()` ends with an `error` event and a session keeps its transcript as it was. A run paused for an approval or a question keeps the `ctx` and the model it resolved in the paused snapshot: resuming it, even from another process, uses that model (it never switches mid-turn) and calls the `tools` function again with the same `ctx`. A run resumed from a crash checkpoint (`agent.resume(id)`) resolves again with `input: []` and no `metadata`. With only static values nothing changes: the agent is built once, when it is created. ### UI bindings `@lousho/build-ai-agent/react` exports `useLoushoAgent(source, options?)`, a React hook that runs an agent in process (`{ agent, sessionId? }`) or over HTTP (`{ url }`) and returns `messages`, `status`, `pendingApproval`, `send()`, `stop()`, `approve()` and `reject()`. Its framework-neutral parts, `reduceAgentEvents()` and `parseEventStream()`, are exported too. See [React](/react). `@lousho/build-ai-agent/vue` exports the same `useLoushoAgent()` as a Vue 3 composable, with the state as refs. See [Vue](/vue). `@lousho/build-ai-agent/svelte` exports `loushoAgent()`, the same state and actions as a Svelte store (`$agent`). See [Svelte](/svelte). ### Sub-agents Pass `subagents: { researcher, writer }` (agents from `createAgent()` with a `description`, or a `{ list, resolve }` catalog) to `createAgent()` or `AgentExecutor.execute()`: the lead gets a `task` tool to delegate, the three background-task tools `agent_status`, `agent_await` and `agent_cancel`, and a prompt listing, each sub-agent runs on the task prompt alone, and it inherits the lead run's signal, hooks (`ctx.subagent`), tracing, approval store and event listeners (`event.subagent`). `maxSubagentDepth` (default 1) bounds nesting. See [Sub-agents](/sub-agents). ### Approvals A `createAgent()` agent pauses on a `needsApproval` tool instead of failing: `send()` (and `session.send()`) resolves with `finishReason: 'awaiting-approval'` and an `approvalId`. `agent.approvals.list()` returns the pending calls and `agent.approvals.resolve({ id, approved, note? })` runs or rejects the call and resolves with the continued run's result (continuing the session it paused in). Pauses are kept in a per-agent `InMemoryApprovalStore` unless you pass `approvalStore` (e.g. `SqliteStore.approvals`) or a `store`; `approve: (request: PendingApproval) => boolean | string | Promise` decides each call in code without pausing (`stream()` still ends at the pause) — `request` carries the call plus its `kind` and `principal`. With `askQuestion: true` the agent can ask the user a question (`kind: 'question'`), answered with `agent.approvals.answer({ id, answer })`. See [Approvals](/approvals). ### Structured output Pass `output: zodSchema` to `createAgent()` (or `AgentExecutor.execute()` / `stream()`): the final reply must be a JSON object matching the schema, and `send()` / `run.result` resolve with it validated as `result.object`, typed `z.output` (`result.text` keeps the raw JSON). Tools still run first. Each model call carries a `responseFormat: { type: 'json', schema }` hint, which the `ai`-SDK providers map to JSON mode. An invalid reply gets one repair step listing the issues (it counts against `maxSteps`); if that is invalid too, the run ends with `finishReason: 'output-invalid'` and `outputError: { message, issues }`. See [Structured output](/structured-output). ### Skills Pass `skills: [defineSkill({ name, description, content }), ...(await loadSkills(dir))]` to `createAgent()` or `AgentExecutor.execute()`: only names and descriptions go in the system prompt and the model loads bodies through an auto-registered `load_skill` tool. See [Skills](/skills). ### Memory Pass `memory: [defineMemory({ name, scope, provider })]` to `createAgent()` to keep items across conversations: each run recalls a slot's newest items into the system prompt on its first model call, and the model gets `remember_` / `recall_` tools. `scope` is `'global'`, `'session'` or a function of `{ sessionId, metadata }`; `inMemoryMemory()` and `fileMemory({ dir })` are the built-in providers. See [Memory](/memory). ### Durable execution `sessionId` + `checkpointStore` make a run crash-safe and a session multi-turn: the run is checkpointed after every model response, every tool result and every pause, and calling `execute()` again with the same `sessionId` resumes an unfinished run (without re-calling the model for a turn it already has), continues a finished conversation with the new input, or throws `SessionAwaitingApprovalError` while an approval is pending. Tools run at-least-once across a crash; `execute` receives the call's `toolCallId` to use as an idempotency key. See [Durable execution](/durable-execution) for the exact guarantees. How a run ends (finish reasons), how to cancel one and how tool calls run in parallel: see [Runs](/runs). ## Declarative specs | Export | Description | | - | - | | `loadSpec(path)` | Load and validate a `.yaml`/`.yml`/`.json` `AgentSpec` file. | | `agentSpecSchema` | The zod schema for `AgentSpec`. | | `specToAgent(spec)` | Turn an `AgentSpec` into a live `createAgent()` agent. | ## Providers | Export | Description | | - | - | | `resolveProvider('p/model')` | Build a real provider from env-var credentials. | | `LLMProviderRegistry` | Registry of provider factories (`create`, `register`, `has`). | | `OpenAIProvider`, `AnthropicProvider`, `OllamaProvider`, `OpenRouterProvider` | Provider classes. | | `createMockProvider()`, `MockLLMProvider` | Deterministic mock provider for tests and demos. | | `withRetry(provider, opts?)`, `withFallback(providers, opts?)` | Retry transient provider failures with backoff; fall back to the next provider. See [Configuration](/configuration#provider-retries-and-fallback). | | `textOf(message)` | The text of a message: its string `content`, or its text parts joined. | `Message.content` is a string or a list of `ContentPart`s (`text`, `image`, `file`); the built-in providers send image parts on user messages. See [Multimodal input](/providers#multimodal-input). See [Providers](/providers) for how a model string is resolved and which model runs. ## Testing Exported from `@lousho/build-ai-agent/testing` (see [Testing agents](/testing)). | Export | Description | | - | - | | `mockModel(script)` | Scripted, deterministic `LLMProvider` that records every request (`calls`, `lastCall`, `reset()`, `assertExhausted()`). | | `recordReplay(options)` | Record/replay VCR provider that serves model responses from a cassette. | ## Tools | Export | Description | | - | - | | `defineTool({ name, description, input, execute, ... })` | Define a tool; `execute`/`needsApproval` args are inferred from the zod `input`. Accepted by `createAgent({ tools: [...] })`, `ToolRegistry.register(tool)` and `AgentBuilder.addTool(tool)`. | | `ToolInput`, `ToolOutput` | Argument and result types of a defined tool. | | `ToolRegistry` (from `@lousho/build-ai-agent/tools` or `@lousho/build-ai-agent/executor`) | Holds the tools an agent config refers to (advanced: `register(tool)` or `register(name, descriptor)`). | | `httpTool`, `currentDateTool`, `dayNameTool` | Built-in tools. | | `createFsTools(fs, options?)`, `createShellTool(shell, options?)` | Workspace tools (`read_file`, `write_file`, `edit_file`, `list_dir`, `glob`, `grep`, `shell`) over an `FsProvider` / `ShellProvider`. See [Workspace tools](/workspace-tools). | | `NodeWorkspace`, `MemoryWorkspace`, `SandboxShell` | Workspace providers: a real directory (paths confined to `root`, minimal shell env), an in-memory tree with a scripted `exec` for tests, and a `ShellProvider` over a `SandboxAdapter` (Docker). | | `createTodoTools({ store?, onChange? })` | `todo_write` / `todo_read` tools (plus `getTodos()`) so agents can plan and track multi-step work; see [Todo tools](/tools#todo-tools). | | `connectMcp(servers, options?)` | Connect MCP servers from config (stdio or HTTP) and load their tools; see [Share servers with `connectMcp()`](/mcp-integration#share-servers-with-connectmcp). | | `loadMcpTools(client, connectionName)` | Load a connected MCP server's tools as `ToolDescriptor`s. Available from the package root, `@lousho/build-ai-agent/tools`, and `@lousho/build-ai-agent/mcp`. | See [Tools](/tools) for a guide to defining and registering tools. ```ts theme={null} import { defineTool } from '@lousho/build-ai-agent'; import { z } from 'zod'; const sendEmail = defineTool({ name: 'send_email', // 1-64 chars: letters, digits, _ and - description: 'Send an email', input: z.object({ to: z.string().email(), subject: z.string() }), needsApproval: ({ to }) => !to.endsWith('@mycompany.com'), // `to` is typed async execute({ to, subject }) { return { messageId: `${to}:${subject}` }; }, }); ``` Optional fields: `displayName`, `needsApproval` (boolean or predicate), `requiresSandbox` and `sandboxExecute`. Defining a tool validates its name, description and zod `input` immediately; registering two tools with the same name throws an error naming the conflict. A defined tool carries its schema as `inputSchema` (the same zod schema as `input`) and its `execute` function directly. These are the canonical fields of a `ToolDescriptor`; the `.tool` object (an `ai` v4 `{ description, parameters, execute }`) is legacy, still built for compatibility, and used only for descriptors that do not set `inputSchema` / `execute`. ```ts theme={null} import { loadMcpTools } from '@lousho/build-ai-agent/mcp'; const tools = await loadMcpTools(mcpClient, 'my-server'); ``` **Schema support.** Each tool's JSON Schema `inputSchema` is converted to a zod schema, and the model's arguments are validated against it before the server is called. The converter handles `type` (including arrays such as `["string", "null"]`), `enum` with mixed types, `const`, `anyOf` / `oneOf` (a union; `anyOf: [X, { type: "null" }]` becomes `X.nullable()`), `allOf` (merge / intersection), local `$ref` into `$defs` / `definitions`, `properties` / `required`, `additionalProperties` (boolean or schema), `items`, `default`, and the constraints `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `minLength`, `maxLength`, `pattern`, `minItems` and `maxItems`. Where JSON Schema is ambiguous the conversion is permissive: unknown keywords, empty schemas, tuple `items` and unresolvable refs become `z.any()`, a recursive `$ref` is expanded once and `z.any()` is used for the inner occurrence, an invalid `pattern` regex is skipped, and objects keep extra properties unless `additionalProperties` is `false`. The converter never throws on schema content. **Skipped tools.** If one tool still cannot be converted, only that tool is skipped; the rest of the server's tools load. Pass a `logger` to receive a warning naming the server, the tool and the reason, and `onSkip` to collect what was left out: ```ts theme={null} import { loadMcpTools, type SkippedMcpTool } from '@lousho/build-ai-agent/mcp'; const skipped: SkippedMcpTool[] = []; const tools = await loadMcpTools(mcpClient, 'my-server', { logger: console, onSkip: (tool) => skipped.push(tool), // { name, reason } }); ``` **Results.** An MCP result with `isError: true` is a normal tool failure: the model receives `{ "error": "McpToolError", "toolName": "...", "message": "..." }` where `message` is the server's text content. Successful results are JSON-serializable: if the server returns `structuredContent` it is the result object; otherwise the result is `{ text, content }`, where `text` joins all text parts and `content` keeps every part in order (`text`, `image`, `audio`, `resource`, `resource_link`; an unrecognised part type is kept as `{ type: 'unknown', raw }`). When `structuredContent` arrives together with non-text parts the result is `{ structuredContent, text, content }` with `content` holding only the non-text parts, so images, audio and resources are never dropped. Todo tools and tool errors: see [Tools](/tools#todo-tools). ## Models, tokens and cost Token estimates, the model price table, and the usage and cost of a run: see [Models, tokens and cost](/models-and-cost). Context compaction is on [its own page](/compaction). ## Flows, evals, observability and security * `FlowBuilder` / `FlowExecutor` (`@lousho/build-ai-agent/flows`) - multi-step workflow graphs; see [Flows](/flows). * `TriggerAdapter` and `TriggerRegistry` (`@lousho/build-ai-agent/triggers`) - the extension point for your own wake-up mechanism, plus `verifySlackSignature()`, `parseCronExpression()` and the `WebhookAuth` helpers; see [Triggers](/triggers). * `jwt()`, `oidc()`, `basic()`, `apiToken()`, `anonymous()` and `routeAuth()` (`@lousho/build-ai-agent/auth`) - route auth as an ordered list; the caller it accepts reaches the run as `principal`; see [Route auth and principals](/auth). * `defineEval()`, scorers such as `exactMatch`, `toolCallOrder` and `budget`, checks such as `includes` and `atLeast`, and `llmJudge()` - agent evals run under vitest or `lousho eval`; see [Evals](/evals). * `withSpan()` and `TraceExporter` - tracing for `AgentExecutor.execute()`. `TraceExporter` is a bring-your-own-exporter interface (no exporter ships by default); for real OpenTelemetry spans, import `createOtelTraceExporter()` from the `@lousho/build-ai-agent/otel` subpath (requires the optional peer dependency `@opentelemetry/api`) instead of hand-rolling the OTel bridge - see `examples/tracing/run-otel.ts`. Spans follow the OpenTelemetry GenAI semantic conventions (flows are traced too); see [observability](/observability). * `NoopSandbox` / `SubprocessSandbox` - sandboxing for tools that opt in via `requiresSandbox`; `runPatchChecks()` and patch checks such as `createCommandCheck()`, `createDiffSizeCheck()` and `secretScanCheck`. See [Guardrails and sandboxing](/guardrails). * `HookRegistry`, `AgentHook`, `HookContext`, `ToolCallHookContext`, `GenerateHookContext` - hooks that observe, deny, rewrite or redact tool calls and model calls. See [Hooks](/hooks). * `EncryptionUtils`, `sha256`, `StorageService` (`@lousho/build-ai-agent/utils`) - supporting utilities; see [Utilities](/utilities). ## Deployment * `DeploymentAdapter`, `registerAdapter()`, `getAdapter()`, `listAdapters()` - the adapter registry behind `lousho build` (see [Deployment](/deployment)). # Approvals Source: https://lousho.com/approvals Flag a tool `needsApproval` and the run pauses before calling it, until a human (or your code) approves or rejects the call. The pause is saved in an `ApprovalStore`, so the decision can come minutes or days later, from another request or another process, and the run then continues where it stopped. See also: [Pause, restart, resume](/pause-and-resume), a short end-to-end tutorial. > Snippets on this page leave some names to you: `provider` is any `LLMProvider` (offline: `createMockProvider()`); `agent` is a `createAgent()` result; `input` is the user's text, a `string`; `emailTool` is a tool built with `defineTool()`; `storage` is a `StorageService`; `toolRegistry` is a `ToolRegistry`. ```ts theme={null} import { createAgent, defineTool } from '@lousho/build-ai-agent'; import { z } from 'zod'; const sendEmail = defineTool({ name: 'send_email', description: 'Send an email', input: z.object({ to: z.string() }), needsApproval: true, execute: async ({ to }) => `sent to ${to}`, }); const agent = createAgent({ provider, instructions: 'You send emails.', tools: [sendEmail] }); const paused = await agent.send('Email the report to sam@example.com'); if (paused.finishReason === 'awaiting-approval') { console.log(await agent.approvals.list()); // [{ id, toolName: 'send_email', args: { to: '...' }, ... }] const result = await agent.approvals.resolve({ id: paused.approvalId!, approved: true }); // or approved: false, note: 'why' console.log(result.text); } ``` ## Which calls pause `needsApproval` is `true`, or a predicate that receives the validated arguments, typed from the tool's zod `input`: ```ts theme={null} import { defineTool } from '@lousho/build-ai-agent'; import { z } from 'zod'; const sendEmail = defineTool({ name: 'send_email', description: 'Send an email', input: z.object({ to: z.string().email(), subject: z.string(), body: z.string() }), needsApproval: ({ to }) => !to.endsWith('@mycompany.com'), // only external recipients pause async execute({ to, subject, body }) { return { messageId: '...' }; }, }); ``` The predicate can also deny a call or approve it only once per session; see [Approve, deny or ask](#approve-deny-or-ask). When the model asks for several tools in one turn, the calls that need no approval run, and the run pauses once on **every** call that needs approval: each gets its own approval id, and they run together once all of them are decided (see [Several calls in one step](#several-calls-in-one-step)). MCP tools ask for approval by default (`approval: 'always'`). With `approval: 'annotations'` a server's tool annotations decide instead: `readOnlyHint: true` runs, while `destructiveHint` true or absent (the MCP default) asks. Choose per server with `approval: 'always' | 'annotations' | 'never'` or a function; see [MCP tool approval](/mcp-integration#approval-for-mcp-tools). ## Permission policies `permissions` sets rules for the whole agent instead of tool by tool: a list of `{ tool, when?, action, reason? }` rules, checked in order for every tool call before the tool's own `needsApproval`. The first rule that matches decides: * `allow` runs the call, without approval even if `needsApproval` would ask. A `needsApproval` that denies the call still denies it. * `deny` does not run it. The model gets a tool error with `kind: 'denied'` and the rule's `reason` (see [Tool errors](/tools)), and streams see `tool.error`. * `ask` pauses the run for approval, exactly like `needsApproval` (or asks the `approve` callback). When no rule matches, the tool's own `needsApproval` decides, as before. `tool` is a name, a list of names, a `RegExp` tested against the name, or `'*'` for every tool. `when` narrows a rule to some calls: it gets the validated arguments (after `preToolCall` hooks) and `{ toolName, toolCallId, sessionId, principal }` (`principal`: who the run acts for, frozen; absent without one — see [Principals in tools and approvals](/auth#principals-in-tools-and-approvals)), and may be async; when it throws, the call fails with that error. `allow(tools)`, `deny(tools, reason?)` and `ask(tools)` build the common rules. ```ts theme={null} import { allow, ask, createAgent, defineTool, deny, type PermissionRule } from '@lousho/build-ai-agent'; import { z } from 'zod'; const shell = defineTool({ name: 'shell', description: 'Run a shell command', input: z.object({ command: z.string() }), execute: async ({ command }) => `ran ${command}`, }); const noDeletes: PermissionRule = { tool: 'shell', when: (args) => /\brm\b/.test(String(args.command)), action: 'deny', reason: 'Deleting files is not allowed', }; const agent = createAgent({ provider, instructions: 'You are a coding assistant.', tools: [shell], permissions: [ noDeletes, // first match wins: this beats the `ask` below allow(/^read_/), // read-only tools never pause deny('delete_file', 'Use the trash tool instead'), ask(['shell', 'write_file']), ], onPermissionDecision: (entry) => console.log(entry.at, entry.toolName, entry.decision, entry.rule?.reason), }); ``` `onPermissionDecision` is the audit log: it is called once per tool call (except calls already refused for invalid arguments) with `{ toolName, toolCallId, decision, rule?, args?, at }`. `decision` is the matching rule's action or `'default'` when none matched, `rule` is that rule's `{ index, reason? }`, `at` is an ISO timestamp, and `args` is left out when the run sets `redactContent`. Streams get the same entry as a `permission.decision` event (see [Streaming](/streaming)). Both are only produced when the agent sets `permissions` or `onPermissionDecision`, or a permission mode other than `'default'`. For a named preset over these rules - `plan` (look, do not touch), `acceptEdits` or `dontAsk`, switchable in the middle of a session - see [Permission modes](/permission-modes). Both options also exist on `AgentExecutor.execute()` / `stream()` and `resumeAfterApproval()`. Sub-agents inherit the lead agent's rules, checked before the sub-agent's own, and report their decisions to the lead's `onPermissionDecision`. ## Approve, deny or ask A `needsApproval` function may return more than a boolean. It gets the validated arguments and `{ toolName, toolCallId, sessionId, messages, principal }` (`principal`: who the run acts for, see [Principals in tools and approvals](/auth#principals-in-tools-and-approvals)), and returns (or resolves to): * `'ask'` or `true`: pause for approval, as before. * `'approve'` or `false`: run the call. * `'deny'` or `{ deny: reason }`: do not run it and do not pause. The model gets a tool error with `kind: 'denied'` and the `reason`, so it can try something else; streams see `tool.error`, and `onPermissionDecision` records `decision: 'deny'` with the `reason` (and no `rule`). Three helpers cover the common policies: `always()` (the same as `true`), `never()` (`false`) and `once()`. `once()` asks the first time the tool is called in a session; once a human approves a call, later calls of that tool in the same session run without asking. A rejection is not remembered, nor is a yes from a `createAgent({ approve })` callback (that callback is asked again for each call), and a new session asks again. `once({ per: 'args' })` remembers approvals per tool and arguments instead, so a call with different arguments asks again. The memory lives in the transcript (the approved call's `tool` message carries `metadata.approval`), so it is saved wherever the session, checkpoint or approval snapshot is, and holds after a resume in another process. Without a session, each `send()` is its own transcript. Compacting away that message makes the tool ask again. ```ts theme={null} import { createAgent, defineTool, once } from '@lousho/build-ai-agent'; import { z } from 'zod'; const askOnce = once(); const deploy = defineTool({ name: 'deploy', description: 'Deploy a service', input: z.object({ service: z.string(), env: z.enum(['staging', 'prod']) }), needsApproval: (args, ctx) => (args.env === 'prod' ? { deny: 'Production deploys go through CI' } : askOnce(args, ctx)), execute: async ({ service, env }) => `deployed ${service} to ${env}`, }); const agent = createAgent({ provider, instructions: 'You deploy services.', tools: [deploy] }); const chat = agent.session(); // the first staging deploy asks, later ones in this session run ``` With `permissions`, a `deny` rule always wins, and the tool's `needsApproval` is not called. Otherwise the tool's own `'deny'` (or `{ deny }`) always denies, whatever rule matched. An `allow` rule replaces the tool's ask (so it skips `'ask'`, `true` and `once()`), and an `ask` rule pauses even when the tool would approve. With no matching rule, the tool's `needsApproval` outcome applies. A `preToolCall` hook runs before all of these and can deny the call first (see [Hook outcomes](/hooks#hook-outcomes)). MCP tools keep their annotation-derived `needsApproval` (a boolean). Sub-agents evaluate their tools' `needsApproval` the same way, and a `once()` approval given through the lead agent is remembered for the rest of that sub-agent's task. ## `createAgent()` agents * A paused `send()` resolves (it does not throw) with `finishReason: 'awaiting-approval'`, an `approvalId` and `approvalIds`: every call the step waits on, in call order (`approvalId` is the first; one entry when one call paused). * `agent.approvals.list()` returns the pending calls, oldest first: the ones this agent paused on in this process and, with an `approvalStore` that implements `list()` (the file, SQLite, KV and in-memory stores do), the ones the store holds from another process or from before a restart. A pause that another process already decided is left out. Each entry is `{ id, toolCallId, toolName, args, createdAt }`, plus `subagentPath` when the call belongs to a sub-agent and `expiresAt` when the pause has a deadline (see [Approval expiry](#approval-expiry)). An entry past `expiresAt` stays listed until it is decided - the run is still paused - but deciding it denies the call. * `agent.approvals.get(id)` returns one pending call without deciding it. With an `approvalStore` that implements `load()` the store answers first, so a pause saved before a restart is found and a pause another process decided is not; `undefined` when the id is unknown (malformed ids included) or already resolved. * `agent.approvals.resolve({ id, approved, note? })` runs the call (approved) or gives the model a rejection with your `note` (rejected), continues the run, and resolves with the continued run's result, which may pause again. It throws when `id` is unknown or already resolved. Two more options apply to an approval (see [Don't ask again, approve with edits](#dont-ask-again-approve-with-edits)): `remember: 'session'` and `args`. * A run that paused inside `agent.session()` continues in that session: the tool call, its result and the final answer join the session's transcript. * `agent.stream()` and `session.stream()` end at the pause with an `approval.requested` event per paused call and `run.done` (`'awaiting-approval'`); resolve it the same way. Pauses are kept in a per-agent `InMemoryApprovalStore` unless you pass `approvalStore` or a `store` with `approvals`. To decide a pause after a restart, give the agent a durable store, such as the SQLite one (see [Choosing a store](/sessions#choosing-a-store)): ```ts theme={null} import { createAgent } from '@lousho/build-ai-agent'; import { SqliteStore } from '@lousho/build-ai-agent/sqlite'; const store = new SqliteStore('./.lousho/agent.db'); const agent = createAgent({ provider, tools: [emailTool], store }); // or approvalStore: store.approvals ``` With a durable store, `list()` and `get(id)` read the store, so after a restart (or from another process) they show every pause it still holds, and stop showing one as soon as any process decides it. A stored pause that no session of this process has run yet is listed without its `sessionId`. With a custom `ApprovalStore` that has no `list()`, `list()` only knows the pauses made by this agent object. ### Several calls in one step When one model step has several calls that need approval, the run pauses once on all of them. `result.approvalIds` lists them in call order, each is its own `agent.approvals.list()` entry, and a stream reports one `approval.requested` event per call. Decide them in any order: * Deciding a call while others of the step are still undecided runs nothing: `resolve()` resolves with `finishReason: 'awaiting-approval'` and the ids still to decide in `approvalIds` (and `approvalId`, the first of them). * The decision that completes the step runs it: the approved calls run in call order, each rejected one gives the model its rejection, and the run continues. No call runs before the human saw all of them. * `agent.approvals.resolveAll([{ id, approved, args?, remember?, note? }, ...])` decides several at once (one after the other, in the order given) and resolves with the last decision's result. The step's calls that need no approval do not wait: they run when the step does, before the pause. ```ts theme={null} import { createAgent } from '@lousho/build-ai-agent'; const agent = createAgent({ model: 'openai/gpt-4o-mini' /* , tools: [payInvoice, archiveThread] */ }); const paused = await agent.send('Pay the invoice and archive the thread'); // paused.approvalIds: ['…pay', '…archive'] const result = await agent.approvals.resolveAll([ { id: paused.approvalIds![0], approved: true, args: { amount: 120 } }, { id: paused.approvalIds![1], approved: false, note: 'keep it for now' }, ]); ``` Each decision is stored with the step's other pending calls, so they can be decided from different requests, processes or channels, and after a restart, with a durable store. Decide the calls of one step from one process at a time: two decisions on the same step racing in two processes can fail with `LOUSHO_APPROVAL_CONFLICT` (nothing runs; decide again). A paused session reports the ids still to decide in `session.pending()` (`approvalIds`). An `approve` callback is asked about each call of the step; a call it defers waits for a human while it decides the rest. A sub-agent whose step pauses on several calls pauses the lead on all of them (each listed with `subagentPath`), and they are decided the same way. A sub-agent started by a step that also pauses on calls of its own runs with the step; when it pauses too, the run pauses on it once the step's calls are decided. ### Don't ask again, approve with edits * `remember: 'session'` approves this call and every later call of the same tool **with the same arguments** in the same session, without pausing (the tool's `needsApproval` is not asked again; a permission rule's `ask` still pauses). Calls with other arguments ask as before. Like `once()`, the memory is the transcript, so it holds after a restart and is gone in a new session. * `args` approves with edited arguments ("approve with edits"). They are validated against the tool's input schema; invalid arguments throw `LOUSHO_TOOL_ARGS_INVALID` and leave the approval pending, so you can decide again. The model's tool call in the transcript is rewritten to the edited arguments, so the model sees what ran. `args` applies to a tool call, not to a question, a sign-in or a handoff, and is ignored with `approved: false`. ```ts theme={null} import { createAgent } from '@lousho/build-ai-agent'; const agent = createAgent({ model: 'openai/gpt-4o-mini', tools: [emailTool] }); const support = agent.session({ id: 'support-42' }); const paused = await support.send('Email the report to sam@example.com'); // Send it to the team list instead, and stop asking for that exact email in this session. await agent.approvals.resolve({ id: paused.approvalId!, approved: true, args: { to: 'team@example.com' }, remember: 'session' }); ``` `remember` and `args` are for decisions people make. An `approve` callback decides each call it is asked about, and `once()` does not count its yes. ### Ending the run after a rejection A rejection is a tool result, not a stop: the model gets the `kind: 'rejected'` error (with your `note`) and the run continues, so a model that still wants the task done may call the tool again, with new arguments, and pause on a new approval. There is no option on `resolve()` that ends the run. To stop that: * Say it in the tool's `description` or the agent's `instructions`: "If the reviewer rejects the call, do not call it again; tell the user it was not done." * Enforce it with a `preToolCall` hook that denies the tool once the transcript holds a rejection of it. A denial is not an approval pause, so the model gets the reason and answers: ```ts theme={null} import { createAgent, type AgentHook } from '@lousho/build-ai-agent'; const noRefile: AgentHook = { name: 'no-refile', preToolCall(ctx) { const rejected = ctx.messages.some((m) => m.role === 'tool' && m.toolName === ctx.toolName && String(m.content).includes('"kind":"rejected"')); return rejected ? { deny: 'A reviewer rejected this call. Do not call it again; tell the user it was not done.' } : undefined; }, }; const agent = createAgent({ provider, instructions: 'You file claims.', tools: [emailTool], hooks: [noRefile] }); ``` The rule is per run history: in a long session, a rejection earlier in the transcript also blocks later, unrelated calls of that tool, so narrow the check (for example to the messages after the latest user message) if that is too broad. ### Approval expiry `createAgent({ approvalTtlMs })` puts a deadline on every pause the agent makes: the pending approval records `expiresAt` (an ISO-8601 timestamp, like `createdAt`), and the `approval.requested` stream event carries it too, so a UI can show the deadline. Past it, the pause can no longer be approved - deciding it, whenever and wherever that happens, denies the call: the model gets a `kind: 'denied'` tool error (`Approval of 'send_email' expired before it was decided`, with no separate `reason` field) and the run goes on, like a rejection. The reason string `'approval expired'` (the `APPROVAL_EXPIRED_REASON` export) is only on the audit entry (`onPermissionDecision`). An expired pause resolved with `approved: true` does not run the tool. ```ts theme={null} import { createAgent, defineTool } from '@lousho/build-ai-agent'; import { z } from 'zod'; const sendEmail = defineTool({ name: 'send_email', description: 'Send an email', input: z.object({ to: z.string() }), needsApproval: true, execute: async ({ to }) => `sent to ${to}`, }); const agent = createAgent({ provider, tools: [sendEmail], approvalTtlMs: 5 * 60_000, // pauses die after five minutes unanswered }); ``` * Expiry is checked when the pause is decided, so it holds through a durable `approvalStore` (a run resumed in another process or after a restart sees the denial, not a stale approval) and through `agent.approvals.resolve()` and `streamResolve()` alike. * An `approve` callback that is still waiting at the deadline is cut off: the `send()` / session turn resumes with the denial. * `send(message, { approvalTtlMs })` overrides it for one run. * A deadline is a positive number of milliseconds of at most 100 years; anything else, `1e20` included, throws `LOUSHO_CONFIG_INVALID`. Leave it out for pauses that never expire. * A permission rule's `ttlMs` bounds only that rule's pauses and wins over the agent default: `{ tool: 'deploy', action: 'ask', ttlMs: 30_000 }`, or `ask('deploy', { ttlMs: 30_000 })`. * A run that keeps an audit log records the expiry as a `deny` decision with `reason: 'approval expired'` (see `onPermissionDecision` above). * Unset, pauses never expire - the same as before. ### Resuming with a changed agent An approval snapshot carries the paused agent's fingerprint, and resolving it with an agent whose model, tools or instructions differ warns (`'warn'`, the default), rejects with `LOUSHO_AGENT_DRIFT` (`onAgentDrift: 'error'`, the approval stays pending) or carries on (`'ignore'`). An approved call whose tool no longer exists always rejects with `LOUSHO_RESUME_TOOL_MISSING`. See [Resuming with a changed agent](/durable-execution#resuming-with-a-changed-agent). On a resumed approved call the pre-tool hooks run again, and the arguments they leave (whether through `{ input }` or by changing `ctx.args` in place) must equal what was approved, key order aside; otherwise the call is refused with a `kind: 'validation'` tool error. A hook that redacts or normalizes its input should run on the paused run too, so the human approves the redacted input. That re-fire sets `ctx.resumedAfterApproval: true`, so a stateful hook that should run only once per call (a loop guard, an audit counter) can skip it. ### Deciding in code Pass `approve` to decide each call as it comes up instead of pausing: `true` runs the tool, `false` sends the model a rejection, and a string approves with that string as the note (an `ask_question` answer). The literal `'defer'` does not decide: the call stays pending - listed by `agent.approvals.list()` and resolvable by `agent.approvals.resolve()` - and the run surfaces the pause (`send()` resolves with `finishReason: 'awaiting-approval'`), exactly as if the callback had never been asked. An approver can thereby decide the calls it trusts automatically and hand the rest to a human. It applies to `send()`, sessions and `agent.approvals.resolve()`; `stream()` still ends at the pause. Its argument's `principal` is whose call it is, and `agent.approvals.resolve(decision, { principal })` records who decided as the tool's `ctx.approval.by` (see [auth](/auth#who-approved)). ```ts theme={null} import { createAgent } from '@lousho/build-ai-agent'; const trusted = createAgent({ provider, tools: [emailTool], approve: ({ toolName, args }) => toolName !== 'send_email' || String(args.to).endsWith('@example.com'), }); ``` ### Streaming the continued run `agent.approvals.streamResolve(decision, { signal })` and `agent.approvals.streamAnswer({ id, answer }, { signal })` continue the run like `resolve()` and `answer()`, but return the `AgentRun` that `agent.stream()` returns (see [Streaming](/streaming#streaming-after-an-approval)): `run.start`, the decided call's `tool.resume` and `tool.done` (`tool.error` for a rejection; `tool.resume`, not a second `tool.start`, because the paused run already reported that call's start), then the continuation's events up to `run.done`. `run.result` is what `resolve()` resolves with. A continuation that pauses again ends with `approval.requested` and `run.done` (`'awaiting-approval'`), even with an `approve` callback, as `stream()` does. A pause made inside a session continues in that session, and `run.done` comes once the transcript is saved. ```ts theme={null} import { createAgent } from '@lousho/build-ai-agent'; const agent = createAgent({ provider, tools: [emailTool] }); const paused = await agent.send('Email Sam the report'); if (paused.approvalId) { const run = agent.approvals.streamResolve({ id: paused.approvalId, approved: true }); for await (const event of run) { if (event.type === 'text.delta') process.stdout.write(event.text); } } ``` ## Asking the user a question `createAgent({ askQuestion: true })` gives the agent the built-in `ask_question` tool (off by default; `askQuestionTool()` returns the same tool to pass in `tools` yourself). Its input is `{ question: string; options?: string[]; allowFreeText?: boolean }`. A call pauses the run through the approval mechanism, so it waits exactly like an approval: in sessions, in durable stores and across a restart. * The pending record has `kind: 'question'` and `question: { text, options?, allowFreeText? }`, in `agent.approvals.list()`, in the `approve` callback and on the `approval.requested` event, so a UI can render a question instead of an approve button. * `agent.approvals.answer({ id, answer })` (the same as `resolve({ id, approved: true, note: answer })`) continues the run. The model gets `{ answer, option? }`, where `option` is the index of the matching entry of `options` (case-insensitive). With `allowFreeText: false`, an answer outside the options reaches the model as a tool error. * `resolve({ id, approved: false, note? })` declines: the model gets a `kind: 'rejected'` tool error saying the user declined to answer. * An `approve` callback may return a string to answer in code, which is handy in tests and scripted agents. ```ts theme={null} import { createAgent } from '@lousho/build-ai-agent'; const agent = createAgent({ instructions: 'Plan the trip with the user.', provider, askQuestion: true }); const paused = await agent.send('Book me a weekend away'); const [pending] = await agent.approvals.list(); if (paused.approvalId && pending?.kind === 'question') { console.log(pending.question?.text, pending.question?.options); const result = await agent.approvals.answer({ id: paused.approvalId, answer: 'Lisbon' }); console.log(result.text); } // Scripted: answer every question in code. const scripted = createAgent({ provider, askQuestion: true, approve: (request) => (request.kind === 'question' ? 'Lisbon' : false), }); ``` A permission rule that `allow`s `ask_question` skips the pause, so the call fails with "No answer"; leave the tool to its default. A tool that needs the user to sign in to an OAuth provider pauses the same way, with `kind: 'sign-in'` and the link in `signIn`; see [Tools that need sign-in](/oauth#tools-that-need-sign-in). ## Advanced: the executor API `createAgent()` agents use `agent.approvals` (see [`createAgent()` agents](#createagent-agents)). With `AgentExecutor.execute()` directly (see [the executor API](/executor-api)), pass an `approvalStore`. On a gated call the executor persists an `ExecutionSnapshot` instead of invoking the tool. Resume later, after a real restart if you like, with `resumeAfterApproval()`: ```ts theme={null} import { AgentExecutor, resumeAfterApproval } from '@lousho/build-ai-agent/executor'; import { StorageServiceApprovalStore } from '@lousho/build-ai-agent/utils'; const approvalStore = new StorageServiceApprovalStore(storage); const paused = await AgentExecutor.execute({ agent, input, provider, toolRegistry, approvalStore, }); // paused.finishReason === 'awaiting-approval', paused.approvalId is set // ...later, from any process, after a human approves... const result = await resumeAfterApproval( { id: paused.approvalId!, approved: true }, approvalStore, toolRegistry, provider, ); ``` `resumeAfterApproval(decision, store, registry, provider, options?, checkpointStore?)` takes most of the execution options of `execute()` (for example `signal`, `onAgentEvent`, `exporter`). With `sessionId` + `checkpointStore`, the pause is also checkpointed, and `execute()` with that `sessionId` throws `SessionAwaitingApprovalError` until the approval is decided, so a pending approval cannot be bypassed (see [Durable execution](/durable-execution)). `streamResumeAfterApproval()` takes the same arguments and streams the continued run as an `AgentRun` (see [Streaming after an approval](/streaming#streaming-after-an-approval)). | Store | Where the pause lives | | - | - | | `InMemoryApprovalStore` | This process only; the default for `createAgent()`. | | `StorageServiceApprovalStore(storage)` | JSON files through a `StorageService`. | | `fileStore(dir)` | One JSON file per pause, `/approvals/.json`, on one machine; an approval can be resolved by only one process (see [Choosing a store](/sessions#choosing-a-store)). | | `SqliteStore.approvals` | One SQLite file, shared by several processes on one host ([details](/sessions#sharing-a-sqlite-file)); an approval can be resolved by only one of them. | An `ApprovalStore` needs `save()` and `resolve()`. `load(id)` (read a pause without claiming it) and `list()` (every pause not resolved yet, oldest first) are optional; `agent.approvals.get()` and `list()` use them to see pauses other processes saved or decided. The built-in stores implement both (`KVStore` lists only when its namespace binding has `list`, as a real one does). `StorageServiceApprovalStore` implements `load()` only. ## Elsewhere * **Sub-agents.** A gated tool inside a sub-agent pauses the lead run; the lead's `agent.approvals` resolves it. See [Approvals inside a sub-agent](/sub-agents#approvals-inside-a-sub-agent). * **Workspace tools.** The shell tool is approval-gated by default. See [Workspace tools](/workspace-tools). * **MCP.** Approval-gated tools cannot be approved over MCP; see [Serve an agent over MCP](/mcp-integration#serve-an-agent-over-mcp). * **Agent Forge** shows pending approvals as inline cards in its chat; see [Agent Forge](/agent-forge). # Route auth and principals Source: https://lousho.com/auth An agent served over HTTP needs to know two things about each request: may it call at all, and who is it. `@lousho/build-ai-agent/auth` answers both. You give a route an ordered list of auth entries (`jwt()`, `oidc()`, `basic()`, `apiToken()`, `anonymous()` or your own function); the first entry that accepts the request returns a **principal**, and that principal goes into the run, where your `model` / `instructions` / `tools` functions and memory scopes can read it. ```ts theme={null} import { createAgent, createRouteHandler } from '@lousho/build-ai-agent'; import { apiToken, jwt } from '@lousho/build-ai-agent/auth'; import { mockModel } from '@lousho/build-ai-agent/testing'; const agent = createAgent({ provider: mockModel(['Hello!']), instructions: ({ principal }) => `You are helping ${principal?.id ?? 'a guest'}.`, }); // Read secrets from the environment and fail at startup when one is missing. // Never fall back to a literal: a default in your source is a secret everyone can read. function requireEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`${name} is not set`); return value; } export const { GET, POST } = createRouteHandler(agent, { auth: [ jwt({ secret: requireEnv('JWT_SECRET'), issuer: 'https://auth.example.com', audience: 'agent-api' }), apiToken(requireEnv('CI_TOKEN'), { id: 'ci' }), ], }); ``` ## The auth list Entries run in order, and each one does one of three things: | An entry ... | Then | | - | - | | returns a `Principal` | the request is accepted; later entries are not asked | | returns `null` or `undefined` | it skips: the next entry is asked | | throws `AuthError(401)` or `AuthError(403)` | the request is refused with that status; later entries are not asked | When every entry skips, the request gets a `401`. An empty list refuses every request. An entry that throws anything else is a bug: the request gets a `500` with a generic body, and the error is logged with `console.error` (never sent). The built-in helpers never throw for a bad credential: a wrong password, an expired token or a token for another audience all skip, so the next entry gets its chance (two `jwt()` entries for two issuers work), and a request nobody accepts ends in the same `401`. A `Principal` is: ```ts theme={null} interface Principal { id: string; // JWT `sub`, Basic user name, 'api-token', ... type: 'user' | 'service'; authenticator: string; // 'jwt' | 'oidc' | 'basic' | 'api-token' | 'anonymous' | your name issuer?: string; // JWT `iss`: the same id from another issuer is another caller claims?: Readonly>; // verified claims; never the raw token or a password } ``` ## Helpers All helpers are created once, at start-up, and check their options then: a helper that cannot work throws [`LOUSHO_AUTH_CONFIG_INVALID`](/errors#lousho_auth_config_invalid) instead of refusing every request later. ### `jwt()` Accepts `Authorization: Bearer ` signed by the key you configure. Give exactly one key source: | Option | Key | Algorithms (default: all of the key's family) | | - | - | - | | `secret` | an HMAC secret of at least 32 bytes | `HS256`, `HS384`, `HS512` | | `publicKey` | a PEM `-----BEGIN PUBLIC KEY-----` or a JWK | `RS256`, `RS384`, `RS512` (RSA, 2048 bits or more), `ES256` (P-256), `ES384` (P-384) | | `jwksUrl` | a JWKS endpoint (https, or http on localhost) | `RS256`, `ES256` by default; RSA and EC keys only | ```ts theme={null} import { jwt } from '@lousho/build-ai-agent/auth'; const fromOurAuthServer = jwt({ jwksUrl: 'https://auth.example.com/.well-known/jwks.json', issuer: 'https://auth.example.com', audience: 'agent-api', // Map the verified claims to the principal; return null to skip. principal: (claims) => (typeof claims.sub === 'string' ? { id: claims.sub, type: 'user', authenticator: 'jwt', issuer: claims.iss, claims } : null), }); ``` What is checked: * The header's `alg` must be one of `algorithms`; `none` is always refused, and so is a token whose header marks an extension `crit`. * The algorithm must fit the key: a public key is never used as an HMAC secret, whatever the token's header says, and a key set never supplies an HMAC key. * `exp` is required; `nbf` and `iat` are honored. All three allow `clockToleranceSec` of clock skew (default 60, at most 300). * `iss` must equal one of `issuer` exactly (no trailing-slash tolerance), when you set `issuer`. * `aud` (a string or a list) must contain one of `audience`. `audience` is required; pass `allowAnyAudience: true` to skip that check on purpose. * A key set is fetched with a 5-second timeout, cached for 10 minutes, and refetched at most once every 30 seconds when a token names a `kid` it does not know (a failed fetch is retried on the same 30-second bound). Its URL is configuration only: the `jku`, `x5u` and `jwk` headers of a token are ignored. The default principal is `{ id: sub, type: 'user', authenticator: 'jwt', issuer: iss, claims }`; a token without `sub` skips. ### `oidc()` A `jwt()` whose keys come from an OpenID Connect provider. It fetches `/.well-known/openid-configuration` once (or `discoveryUrl`), requires the document's `issuer` to equal yours exactly, and uses its `jwks_uri` (https, or http on `localhost` / `127.0.0.1` / `[::1]` — the same rule as `jwt()`'s `jwksUrl`). Algorithms default to `RS256` and `ES256`; HMAC is not allowed. ```ts theme={null} import { oidc } from '@lousho/build-ai-agent/auth'; const google = oidc({ issuer: 'https://accounts.google.com', audience: process.env.GOOGLE_CLIENT_ID ?? 'client-id' }); ``` The principal has `authenticator: 'oidc'` and the provider's `iss` as `issuer`. ### `basic()` HTTP Basic credentials. User names and passwords are NFC-normalized and compared in constant time, and every configured user is compared on every request, so an unknown user takes as long as a wrong password. The `401` carries `WWW-Authenticate: Basic realm="", charset="UTF-8"` so browsers show their login prompt. ```ts theme={null} import { basic } from '@lousho/build-ai-agent/auth'; const operators = basic({ users: { ops: process.env.OPS_PASSWORD! }, realm: 'agent' }); // Or check against your own store (compare in constant time there): const fromDatabase = basic({ users: async (user, password) => user === 'svc' && password === process.env.SVC_PASSWORD }); ``` **Only use `basic()` over HTTPS**: the password travels with every request. ### `apiToken()` and `anonymous()` `apiToken(token, { id })` accepts `Authorization: Bearer ` (compared in constant time) as the service principal `{ id: id ?? 'api-token', type: 'service', authenticator: 'api-token' }`. It is what a token string has always meant: `createRouteHandler({ auth: '' })` and `LOUSHO_API_TOKEN` are `apiToken()`. An empty string (`auth: ''`, e.g. an env var that is set but blank) is refused with `LOUSHO_AUTH_CONFIG_INVALID` by `createRouteHandler()` when it is created and by `serveFetch()` on every request: it neither locks nor opens the routes silently. `anonymous()` accepts everyone as `{ id: 'anonymous', type: 'user', authenticator: 'anonymous' }`: put it last for routes that serve signed-in and anonymous callers, and read `principal.authenticator` in the run. ### Your own entry Any function of the `Request` is an entry. Declare the challenges it answers a `401` with, or it advertises `Bearer`: ```ts theme={null} import { AuthError, type AuthFn } from '@lousho/build-ai-agent/auth'; const tenantKey: AuthFn = async (request) => { const key = request.headers.get('x-tenant-key'); if (!key) return null; // not ours: ask the next entry if (key === 'suspended-tenant') throw new AuthError(403); // stop here return { id: key, type: 'service', authenticator: 'tenant-key' }; }; tenantKey.challenges = [{ scheme: 'Bearer', realm: 'tenants' }]; ``` ## 401, 403 and `WWW-Authenticate` * Nobody accepted the request (or an entry threw `AuthError(401)`): `401` with `{ "error": "Unauthorized" }` and one `WWW-Authenticate` header per distinct challenge of the list, in order (`Bearer` for `jwt`, `oidc`, `apiToken`; `Basic realm="..."` for `basic`). * An entry threw `AuthError(403)`: `403` with `{ "error": "Forbidden" }`. * An entry failed in another way: `500` with `{ "error": "Internal Server Error" }`. The body never says which check failed: an expired token, a bad signature and a wrong audience all get the same `401`. Every auth response carries `Cache-Control: no-store`. Tokens and passwords are never logged. ## Where it runs | Helper or server | Node 22+ | Cloudflare Workers, Vercel Edge, Bun, Deno | | - | - | - | | `jwt()`, `oidc()`, `basic()`, `apiToken()`, `anonymous()`, `routeAuth()` | yes | yes: Web Crypto and `fetch` only, no `node:` import | | `createRouteHandler({ auth })` | yes | yes | | The `node-server` and `docker` targets | yes | no (Node `http`) | | An agent directory's `auth.ts` | yes (`node-server`, `docker`) | no | | The `cloudflare-worker` build target | | `LOUSHO_API_TOKEN` only; no auth list yet | `oidc()` and `jwt({ jwksUrl })` need outbound `fetch` to the key set, which every listed runtime has. In a hand-written Worker, call `createRouteHandler` or `routeAuth()` yourself with any helper. ## `createRouteHandler` `auth` takes the list, one entry, a token string, or (as before) a function that returns a boolean. `true` accepts with the principal `{ id: 'anonymous', type: 'user', authenticator: 'custom' }`, `false` is a `401`. Without `auth` the route is open, and in production (`NODE_ENV=production`) it logs one warning. `GET /health` is never behind `auth`. The accepted principal reaches every route: `POST `, `/chat`, approvals and the `useChat` endpoint. See [Next.js](/nextjs#routes). ## The node server and `auth.ts` The generated server (it uses the bundle's internal `createDeployedServer()`) takes the same list (or one entry). When `LOUSHO_API_TOKEN` is set, `apiToken(LOUSHO_API_TOKEN)` is appended after your entries, so an operator's token keeps working. Channels under `/channels/` are not behind the list: they verify their own requests. In an [agent directory](/agent-directories), put the list in `auth.ts` (or `auth.js`), next to `agent.ts`: ```ts theme={null} // my-agent/auth.ts import { apiToken, oidc } from '@lousho/build-ai-agent/auth'; export default [ oidc({ issuer: 'https://login.example.com', audience: 'agent-api' }), apiToken(process.env.CI_TOKEN!, { id: 'ci' }), ]; ``` `lousho build --target=node-server` (or `docker`) bundles it, and the built server uses it (`resolveAgentDir()` returns it as `auth`; the manifest says `auth: true`). With an `auth.ts`, a token baked in with `adapter.scaffold(..., { auth: { token } })` is not used; `LOUSHO_API_TOKEN` still is. The `cloudflare-worker` target serves spec files only and keeps the `LOUSHO_API_TOKEN` token; see [Deployment](/deployment#auth). `auth.ts` may also export functions named `authorizeSession` and `authorizeApproval`, next to its default export. The built server uses them as the rules of [Who may use a session](#who-may-use-a-session) and [Who may decide an approval](#who-may-decide-an-approval), and `resolveAgentDir()` returns them under those names. Any other value under those names fails the load (`LOUSHO_AGENT_DIR_INVALID`). `lousho dev` has no route auth, so neither the list nor these rules apply there. ## Reading the principal in the run The principal is on the run context next to `sessionId` and `metadata`. The `vendor/` model prefix chooses the provider; with OpenRouter use `openrouter//` (e.g. `openrouter/openai/gpt-4o-mini`). ```ts theme={null} import { createAgent, defineMemory, inMemoryMemory, type Principal } from '@lousho/build-ai-agent'; import { mockModel } from '@lousho/build-ai-agent/testing'; const notes = defineMemory({ name: 'notes', // One memory per caller. Key on the issuer too: ids from different issuers can collide. scope: ({ principal }) => principal && `user:${principal.issuer ?? ''}:${principal.id}`, provider: inMemoryMemory(), }); const agent = createAgent({ provider: mockModel(['Hi!', 'Hi again!']), // one scripted reply per turn below model: ({ principal }) => (principal?.claims?.plan === 'pro' ? 'openai/gpt-4o' : 'openai/gpt-4o-mini'), instructions: ({ principal }) => (principal?.type === 'service' ? 'Answer in JSON.' : 'Be friendly.'), memory: [notes], }); // Outside a route, pass the principal yourself: const caller: Principal = { id: 'u-42', type: 'user', authenticator: 'jwt', issuer: 'https://auth.example.com' }; await agent.send('Hello', { principal: caller }); await agent.session({ id: 'chat-1' }).send('Hello again', { principal: caller }); ``` `principal` is separate from `metadata` on purpose: `metadata` is whatever the caller sent, `principal` is what auth verified. A run that is resumed from its checkpoint or after an approval keeps the principal it started with. The Slack, Discord, Teams, GitHub and Telegram channels set the sender as the principal (`authenticator: 'slack'`, `'discord'`, ...). Tools, approval policies, permission rules and sub-agents see it too: see [Principals in tools and approvals](#principals-in-tools-and-approvals). ## Security notes * **Sessions belong to the caller that started them by default.** Once route auth accepted a caller, a session id is bound to the first principal that sends it a turn (reading an id claims nothing), and any other principal gets `403` (`LOUSHO_SESSION_FORBIDDEN`). That record lives in memory, per agent, for the 100,000 most recently used sessions. A stored session whose owner is not on record (after a restart, on a second replica, past that cap) is refused to every principal, its owner too. With more than one instance, or a store that outlives the process, pass `authorizeSession` that checks ownership against your own data. See [Who may use a session](#who-may-use-a-session). * **Cookies need CSRF protection, which the routes give by default.** A POST must be JSON (`415` otherwise) and must not come from another site's page (`403`, `LOUSHO_REQUEST_FORBIDDEN`). Keep your session cookie `SameSite=Lax` or `Strict` as well. See [Cross-site requests](#cross-site-requests). * **Approvals.** A decision only reaches an approval of the session in the URL (another session's approval is a `404`). By default only the caller the run acts for may decide it. If an approval must come from someone else, such as a supervisor approving a customer's refund, set `authorizeApproval`. Otherwise the customer can approve their own call: the approval id is in their own stream. See [Who may decide an approval](#who-may-decide-an-approval). * **Errors.** The session routes send an error's `name` and `code` with a generic message. The full error (a provider's response body, a chat template) goes to the server log (`console.error`) and to `onEvent`. Pass `exposeErrors: true` to send the raw message, as `lousho dev` does. A missing provider package (`LOUSHO_PEER_MISSING`) always sends its message: the SDK's own `npm install` command. * `basic()` only over HTTPS; a bearer token is a password too, so terminate TLS in front of any server that is not on localhost. * Keep `issuer` and `audience` set for `jwt()`: a token minted for another of your services is otherwise accepted here. ## Principals in tools and approvals Everything that decides or acts on the caller's behalf gets the run's principal, frozen (a tool or a policy that assigns to it throws): | Where | How | | - | - | | A tool's `execute(args, ctx)` | `ctx.principal` | | A `needsApproval` function | `ctx.principal` (its second argument) | | A permission rule's `when(args, ctx)` | `ctx.principal` | | `preToolCall` / `postToolCall` hooks | `ctx.principal` | | `onPermissionDecision(entry, { principal })` | the second argument; the entry itself carries none | | A pending approval (`agent.approvals.list()`, `approve`, a channel's `approvers` function) | `request.principal`: whose call it is | | In-process sub-agents (`task`, background tasks) | the lead's principal, everywhere above | A run without a principal (no route auth, `send()` without `principal`) has none in any of these places. ### In a tool ```ts theme={null} import { defineTool } from '@lousho/build-ai-agent'; import { z } from 'zod'; export const myOrders = defineTool({ name: 'my_orders', description: "Lists the caller's orders", input: z.object({}), execute: async (_args, ctx) => { if (!ctx.principal) return { error: 'Sign in first.' }; // Key on the issuer too: the same id from another issuer is another caller. return { owner: `${ctx.principal.issuer ?? ''}:${ctx.principal.id}`, orders: [] }; }, }); ``` The principal comes only from route auth or a channel's verified sender, never from the request body or the model, and a tool cannot change it. ### Approval policies and permission rules ```ts theme={null} import { createAgent, defineTool } from '@lousho/build-ai-agent'; import { mockModel } from '@lousho/build-ai-agent/testing'; import { z } from 'zod'; const deploy = defineTool({ name: 'deploy', description: 'Deploys a service', input: z.object({ service: z.string() }), // Ask a human only when the caller has no `admin` claim. needsApproval: (_args, ctx) => ctx.principal?.claims?.admin !== true, execute: async ({ service }) => `deployed ${service}`, }); const agent = createAgent({ provider: mockModel(['Ready.']), tools: [deploy], // Service accounts (CI tokens) may not deploy at all. permissions: [{ tool: 'deploy', when: (_args, ctx) => ctx.principal?.type === 'service', action: 'deny', reason: 'Not for service accounts' }], }); await agent.send('Deploy the API', { principal: { id: 'ci', type: 'service', authenticator: 'api-token' } }); ``` ### Who approved A decision can name who made it. The approved tool sees the decider as `ctx.approval.by`, next to `ctx.approval.note`: ```ts theme={null} import { createAgent, defineTool, type Principal } from '@lousho/build-ai-agent'; import { mockModel } from '@lousho/build-ai-agent/testing'; import { z } from 'zod'; const sendEmail = defineTool({ name: 'send_email', description: 'Sends an email', input: z.object({ to: z.string() }), needsApproval: true, execute: async ({ to }, ctx) => `sent to ${to} for ${ctx.principal?.id}, approved by ${ctx.approval?.by?.id}`, }); const agent = createAgent({ provider: mockModel([{ toolCalls: [{ name: 'send_email', args: { to: 'sam@example.com' } }] }, 'Sent.']), tools: [sendEmail], }); const alice: Principal = { id: 'alice', type: 'user', authenticator: 'jwt' }; const lead: Principal = { id: 'lead', type: 'user', authenticator: 'jwt' }; const paused = await agent.send('Email Sam', { principal: alice }); // The tool runs for alice; lead is recorded as the approver. await agent.approvals.resolve({ id: paused.approvalId!, approved: true }, { principal: lead }); ``` `resolve()`, `answer()`, `streamResolve()` and `streamAnswer()` take `{ principal }`. The approvals route (`POST /chat/:sessionId/approvals/:id`) passes the caller its auth list accepted; body fields cannot set it. `mountChannels()` passes the user who clicked as `{ id, type: 'user', authenticator: }`, or, for a question answered with a message, that message's sender. Decisions made by an `approve` callback, or posted to `/channels//approvals/:id`, have no approver. The approver never becomes the run's principal: the approved call and the rest of the run act for the caller that paused it. A credential or a grant that belongs to that caller is used only for that caller's calls, whoever approves. ### Who may use a session By default, once route auth accepted a caller, a session belongs to the first principal that sends it a turn (principals are compared by `id`, `type`, `authenticator` and `issuer`). Another principal gets `403` (`LOUSHO_SESSION_FORBIDDEN`) on `GET /chat/:id`, `POST /chat`, `POST ` and the `useChat` endpoint; decisions on its approvals are left to `authorizeApproval` (below). Reading an id nobody has sent a turn to yet claims nothing, so a caller cannot lock a user out of a predictable id (`user-42`) by reading it first. A turn with an empty `input` is a `400` and claims nothing, and a turn that stores nothing (it failed before saving) drops its claim. A route without auth checks nothing. All callers of one `apiToken()` are one principal, so they share every session; a route guarded by one bearer token (`auth: ''`, the node server or the Worker with only `LOUSHO_API_TOKEN`) checks no owner at all. The owner is remembered in memory, per agent object, for the 100,000 most recently used sessions. A restart, a second replica, a session unused for longer, or a host that builds its agent per request forgets it. The default then fails closed: a session that is already stored (it has a transcript or an unfinished turn) but has no owner on record is refused to every principal, including the one that started it, so nobody can take over a session by getting its owner forgotten. Only a session that was never stored can be claimed again. So for anything that runs on more than one instance, or keeps sessions across restarts, check ownership yourself with `authorizeSession`. `createRouteHandler()` and the generated node server take `authorizeSession`, which replaces that default (in an agent directory, export it from `auth.ts`: see the example under [Who may decide an approval](#who-may-decide-an-approval)). It is asked before a caller reads a session (`action: 'read'`), sends it a turn (`'chat'`, also `POST ` and the `useChat` endpoint) or decides one of its approvals (`'approve'`). Return `false` for a `403` (`LOUSHO_SESSION_FORBIDDEN`). Deriving the id from the caller keeps the check stateless: ```ts theme={null} import { createAgent, createRouteHandler, memoryStore } from '@lousho/build-ai-agent'; import { jwt } from '@lousho/build-ai-agent/auth'; const agent = createAgent({ model: 'openai/gpt-4o-mini', store: memoryStore() }); // The user id a session id starts with: everything before its last `-`. A prefix match would let // `alice` reach `alice-x`'s sessions, so the suffix has no `-` (e.g. `crypto.randomUUID().replaceAll('-', '')`). const ownerOf = (sessionId: string) => sessionId.slice(0, Math.max(sessionId.lastIndexOf('-'), 0)); export const { GET, POST } = createRouteHandler(agent, { auth: jwt({ secret: process.env.JWT_SECRET!, issuer: 'https://auth.example.com', audience: 'agent-api' }), // Session ids are `-`: a user can only reach their own. authorizeSession: ({ principal, sessionId }) => principal !== undefined && ownerOf(sessionId) === principal.id, }); ``` `authorizeSession: () => true` lets every authenticated caller use every session (the behavior before this default). ### Cross-site requests A browser sends cookies with requests that another site's page makes, so with cookie-based auth a page on `evil.example` could otherwise start a turn or approve a call as your user. The session routes refuse that by default: * a POST whose `Origin` is not the server's own (its `Host`, or the host a proxy in front reports in `X-Forwarded-Host` or `Forwarded: host=`) and not in `allowedOrigins`, or that the browser marked `Sec-Fetch-Site: cross-site`, is `403` (`LOUSHO_REQUEST_FORBIDDEN`). Requests without an `Origin` (curl, a server, another agent) are not affected; * a POST body must be `Content-Type: application/json` (`415`, `LOUSHO_UNSUPPORTED_MEDIA_TYPE` otherwise): forms and `text/plain` are what another site can send without a CORS preflight. A front end on another origin is listed in `allowedOrigins` (it also needs CORS headers, which your host adds); `allowedOrigins: ['*']` turns the origin check off and `requireJsonBody: false` the content-type one. Entries are compared as origins: case, a trailing slash and a default port (`https://app.example:443`) do not matter. `allowedHosts` (off by default) refuses any request whose `Host` is not listed, against DNS rebinding of a server on a private address. All three work on `createRouteHandler()` and the generated node server; that server and the Cloudflare Worker read them from `LOUSHO_ALLOWED_ORIGINS` and `LOUSHO_ALLOWED_HOSTS` (comma-separated) and `LOUSHO_REQUIRE_JSON_BODY=false`. Behind a reverse proxy: * The proxy must tell the server the public host, or a browser's same-site POST is `403`: keep the original `Host` (nginx `proxy_set_header Host $host;`), or set `X-Forwarded-Host` (`proxy_set_header X-Forwarded-Host $host;`) or `Forwarded: host=...`. A default port in it (`agent.example:443`) matches an `https://agent.example` page. Otherwise list the public origin in `allowedOrigins`. * `allowedHosts` checks only the `Host` header the server receives, never the forwarded ones (a page that rebinds DNS can set those). Behind a proxy that rewrites `Host`, list the upstream name the proxy sends, not the public one. ```ts theme={null} import { createAgent, createRouteHandler } from '@lousho/build-ai-agent'; const agent = createAgent({ model: 'openai/gpt-4o-mini' }); export const { GET, POST } = createRouteHandler(agent, { auth: process.env.AGENT_TOKEN, allowedOrigins: ['https://app.example.com'], }); ``` ### Who may decide an approval A decision posted to `POST /chat/:sessionId/approvals/:id` must name the session the approval belongs to; any other session answers `404`, as for an approval that was already decided. `POST /approvals/:id` (the route `useLoushoAgent` posts to) names no session, so it is checked against the approval's own session. Then `authorizeApproval({ principal, sessionId, approval })` decides; `false` is a `403` (`LOUSHO_APPROVAL_FORBIDDEN`). The default, `callerOwnsApproval`, lets only the caller the paused run acts for decide (`approval.principal`, compared by `id`, `type`, `authenticator` and `issuer`). A user can confirm their own call, and cannot decide another user's. A run without a principal, or a route without auth, accepts any caller. When `needsApproval` means that someone else must approve, say who. Here a customer can no longer approve their own refund, and a supervisor can read any session (to see what they are approving) and decide in it: ```ts theme={null} import { createAgent, createRouteHandler, memoryStore } from '@lousho/build-ai-agent'; import { jwt } from '@lousho/build-ai-agent/auth'; const agent = createAgent({ model: 'openai/gpt-4o-mini', store: memoryStore() }); const isSupervisor = (principal?: { claims?: Readonly> }) => principal?.claims?.role === 'supervisor'; // Session ids are `-`; the owner is everything before the last `-`. const ownerOf = (sessionId: string) => sessionId.slice(0, Math.max(sessionId.lastIndexOf('-'), 0)); export const { GET, POST } = createRouteHandler(agent, { auth: jwt({ secret: process.env.JWT_SECRET!, issuer: 'https://auth.example.com', audience: 'agent-api' }), // Customers use their own sessions; supervisors may read and decide in any session. authorizeSession: ({ principal, sessionId, action }) => (principal !== undefined && ownerOf(sessionId) === principal.id) || (action !== 'chat' && isSupervisor(principal)), authorizeApproval: ({ principal }) => isSupervisor(principal), }); ``` In an agent directory built with `lousho build --target=node-server` (or `docker`), export the same rules from `auth.ts`. Here users file expense claims, a manager decides them, and nobody approves their own: ```ts theme={null} // my-agent/auth.ts import { apiToken } from '@lousho/build-ai-agent/auth'; import type { ApprovalAccessRequest, SessionAccessRequest } from '@lousho/build-ai-agent'; export default [ apiToken(process.env.ALICE_TOKEN!, { id: 'alice' }), apiToken(process.env.BOB_TOKEN!, { id: 'bob' }), apiToken(process.env.MANAGER_TOKEN!, { id: 'manager' }), ]; // Session ids are `-`. const ownerOf = (sessionId: string) => sessionId.slice(0, Math.max(sessionId.lastIndexOf('-'), 0)); // Users use their own sessions; the manager may open any session to review a claim. export function authorizeSession({ principal, sessionId }: SessionAccessRequest): boolean { return principal?.id === 'manager' || (principal !== undefined && ownerOf(sessionId) === principal.id); } // Only the manager decides, and never on a claim of their own. export function authorizeApproval({ principal, approval }: ApprovalAccessRequest): boolean { return principal?.id === 'manager' && approval.principal?.id !== 'manager'; } ``` Alice's paused claim in session `alice-1` is then decided by `POST /chat/alice-1/approvals/:id` with the manager's token (`200`); with alice's own token it is a `403` (`LOUSHO_APPROVAL_FORBIDDEN`). Without these exports the built server keeps the defaults, so only alice could decide it. Two requests that decide the same approval get one continuation: while the winning decision's continuation is still running (on the same server process), any other decision of that approval is a `409` (`LOUSHO_APPROVAL_CONFLICT`), whether it arrived at the same moment or while the decided tool was running; once that continuation has ended, a decision is a `404` (`LOUSHO_APPROVAL_NOT_FOUND`). A decision handled by another replica is a `404` there. ### Pauses, crashes and forks The principal is saved with the run's checkpoints and approval snapshots, so a run resumed in another process (after a restart, `agent.approvals.resolve()`, `agent.resume()`, a session's pending turn) acts for the same caller. A call that continues an unfinished checkpointed run must pass no principal (it then continues as the saved one) or the same one; another caller's `send(message, { sessionId, principal })` throws `LOUSHO_CONFIG_INVALID`, so its input never runs under someone else's identity. Checkpoints and snapshots saved before this release have no principal and resume without one. `agent.fork()` / `AgentExecutor.fork()` copy a run's checkpoint, principal included: the fork is the same caller's run. A pending approval is not copied; the fork asks again. `session.fork()` copies only the conversation: the fork has no principal of its own, each of its turns runs with the principal its `send()` passes, and a pending approval stays with the original session. ### Sub-agents In-process sub-agents (the `task` tool and background tasks) act for the lead's caller, also when their approval is resumed. A [remote sub-agent](/sub-agents) (`remoteAgent()`) is not told who the caller is: the remote server's own route auth decides its principal (for example the `apiToken()` service principal of the lead's token). ### What is stored and logged The principal, claims included, is stored in checkpoints and approval records, which already hold the transcript; treat those stores as personal data. It is not added to `AgentEvent`s (`permission.decision` included), OpenTelemetry spans or trace files, and error messages do not name callers. # Build a coding agent Source: https://lousho.com/build-a-coding-agent This page builds a terminal coding agent in the style of Claude Code: it reads and edits files in your project, runs commands, asks you before it writes a file or runs something risky, and keeps the conversation going for follow-up questions. You need Node 22, an API key in the environment variable your model provider expects (`OPENAI_API_KEY` for the model used here) and a project directory to run it in. The finished file is at the end of [step 5](#5-keep-the-conversation), and you run it as `npx tsx coding.ts "Fix the failing test"`. ## 1. Give the agent a workspace A workspace is a directory the agent may work in. `NodeWorkspace` is both a file system and a shell for it, and `createFsTools()` and `createShellTool()` turn it into tools. ```ts theme={null} import { NodeWorkspace, createFsTools, createShellTool } from '@lousho/build-ai-agent'; const workspace = new NodeWorkspace({ root: process.cwd() }); const tools = [...createFsTools(workspace), createShellTool(workspace)]; ``` The `root` matters: every path the model passes is confined to it, and `..`, absolute paths and drive letters are rejected (see the [security model](/workspace-tools#security-model)). Run the program from the project directory and use `process.cwd()` as the root, so the file tools cannot reach anything outside the project. The model sees seven tools: `read_file`, `write_file`, `edit_file`, `list_dir`, `glob` and `grep` from `createFsTools()`, and `shell` from `createShellTool()`. Their arguments and options are in [Workspace tools](/workspace-tools#the-tools). ## 2. Decide what needs approval A tool with `needsApproval` pauses the run before it executes, so a human decides. Reading is harmless, so gate the file tools that change things with a per-tool map. For the shell, pass a predicate over the command string and gate only the commands you consider risky. ```ts theme={null} import { NodeWorkspace, createFsTools, createShellTool } from '@lousho/build-ai-agent'; const DESTRUCTIVE = /\b(rm|mv|chmod|chown|git\s+(push|reset|clean|checkout))\b|>/; const workspace = new NodeWorkspace({ root: process.cwd() }); const tools = [ ...createFsTools(workspace, { needsApproval: { write_file: true, edit_file: true } }), createShellTool(workspace, { needsApproval: (command) => DESTRUCTIVE.test(command) }), ]; ``` The shell tool asks for approval by default (`needsApproval: true`); the predicate above lets everything else, such as `npm test` or `git status`, run without asking. The other way to skip the question is an `allow` list of the commands you trust, with `needsApproval: false`, as shown at the top of [Workspace tools](/workspace-tools). Use exact rules such as `{ command: 'npm test' }` there: a plain string such as `'npm test'` allows the command with any arguments, including flags that write outside the workspace. The regular expression is a convenience, not a security boundary: a shell command can be written in many ways. For anything you do not control, keep the default and approve every command, or run the shell in a sandbox (see `SandboxShell` in [Workspace tools](/workspace-tools#sandboxed-shell-sandboxshell)). ## 3. Run it and stream the answer Create the agent with the tools, then call `agent.stream()`. It returns a run you iterate for typed events: `text.delta` carries the model's text as it is written and `tool.start` announces each tool call (see [Streaming](/streaming)). ```ts theme={null} import { NodeWorkspace, createAgent, createFsTools, createShellTool } from '@lousho/build-ai-agent'; const workspace = new NodeWorkspace({ root: process.cwd() }); const agent = createAgent({ model: 'openai/gpt-4o-mini', instructions: 'You are a careful coding agent. Read before you edit; run the tests after every change.', maxSteps: 30, tools: [ ...createFsTools(workspace, { needsApproval: { write_file: true, edit_file: true } }), createShellTool(workspace), ], }); const run = agent.stream('Fix the failing test'); for await (const event of run) { if (event.type === 'text.delta') process.stdout.write(event.text); if (event.type === 'tool.start') console.log(`\n> ${event.toolName} ${JSON.stringify(event.args)}`); } ``` `maxSteps` bounds the model calls in one run, so a confused agent cannot loop forever. This program stops at the first gated call: the stream ends with an `approval.requested` event and the run finishes as `'awaiting-approval'`. The next step handles that. The `vendor/` model prefix chooses the provider; with OpenRouter use `openrouter//` (e.g. `openrouter/openai/gpt-4o-mini`). ## 4. Handle the pause When the stream ends, `await run.result` gives the final result. If the run paused, `result.approvalId` names the pending call. Ask the user, then continue with `agent.approvals.streamResolve()`, which returns a new run for the rest of the work. That run can pause again, so loop until there is no `approvalId`. ```ts theme={null} import { createInterface } from 'node:readline/promises'; import { createAgent, type AgentRun } from '@lousho/build-ai-agent'; const agent = createAgent({ model: 'openai/gpt-4o-mini', instructions: 'You are a coding agent.' }); const rl = createInterface({ input: process.stdin, output: process.stdout }); async function drain(first: AgentRun): Promise { let run = first; for (;;) { for await (const event of run) { if (event.type === 'text.delta') process.stdout.write(event.text); if (event.type === 'tool.start') console.log(`\n> ${event.toolName} ${JSON.stringify(event.args)}`); } const { approvalId } = await run.result; if (!approvalId) return; const answer = await rl.question('\nApprove this call? [y/N] '); run = agent.approvals.streamResolve({ id: approvalId, approved: answer.trim().toLowerCase() === 'y' }); } } try { await drain(agent.stream('Fix the failing test')); } finally { rl.close(); } ``` Two details of the continued stream. The call you just approved resumes with a `tool.resume` event - its `tool.start` was already printed by the run that paused, so the loop prints each call once (`tool.resume` is the event to match if you want to mark the call running again). And a rejected call does not fail the run: the model gets a rejection result instead of the tool output and carries on. To tell it why, pass a `note`: `streamResolve({ id, approved: false, note: 'Do not touch the lockfile' })`. The `try` / `finally` closes the `readline` interface even when `run.result` rejects because the run failed. The `approval.requested` event also carries `toolName` and `args`, so you can print what the call will do before you ask. See [Approvals](/approvals) for the whole API. ## 5. Keep the conversation So far each task starts from nothing. An `agent.session()` keeps the transcript, so a follow-up such as "now add a test for the empty case" sees the earlier turns. `session.stream(input)` is `agent.stream()` for a conversation and returns the same kind of run; a turn that pauses on a gated call continues in the same session when you resolve it through the same agent (see [Streaming a session turn](/sessions#streaming-a-session-turn)). Turn the loop into a REPL: one `session.stream()` per line the user types, with the same pause handling. ```ts theme={null} // coding.ts // Run: OPENAI_API_KEY=... npx tsx coding.ts "Fix the failing test" import { createInterface } from 'node:readline/promises'; import { NodeWorkspace, createAgent, createFsTools, createShellTool, type AgentRun } from '@lousho/build-ai-agent'; const DESTRUCTIVE = /\b(rm|mv|chmod|chown|git\s+(push|reset|clean|checkout))\b|>/; const workspace = new NodeWorkspace({ root: process.cwd() }); const agent = createAgent({ model: 'openai/gpt-4o-mini', instructions: 'You are a careful coding agent. Read before you edit; run the tests after every change.', maxSteps: 30, tools: [ ...createFsTools(workspace, { needsApproval: { write_file: true, edit_file: true } }), createShellTool(workspace, { needsApproval: (command) => DESTRUCTIVE.test(command) }), ], }); const session = agent.session(); const rl = createInterface({ input: process.stdin, output: process.stdout }); async function drain(first: AgentRun): Promise { let run = first; for (;;) { for await (const event of run) { if (event.type === 'text.delta') process.stdout.write(event.text); if (event.type === 'tool.start') console.log(`\n> ${event.toolName} ${JSON.stringify(event.args)}`); } const { approvalId } = await run.result; if (!approvalId) return; const answer = await rl.question('\nApprove this call? [y/N] '); run = agent.approvals.streamResolve({ id: approvalId, approved: answer.trim().toLowerCase() === 'y' }); } } try { let line = process.argv[2] ?? (await rl.question('> ')); while (line.trim() && line.trim() !== 'exit') { await drain(session.stream(line)); process.stdout.write('\n'); line = await rl.question('> '); } } finally { rl.close(); } ``` The first task comes from the command line (or a prompt if there is none); after each answer the program asks for the next line, and an empty line or `exit` ends it. The session lives in memory, so it ends with the process; to keep a conversation across runs, give the agent a store (see [Choosing a store](/sessions#choosing-a-store)). ### Long turns and restarts * **Context.** A coding turn reads files, runs tests and edits over many steps; every tool result stays in the transcript, so a 40-step turn can outgrow a 128k-token window before it finishes. Pass `createAgent({ compaction: true })` (or a summarizer, see [Context compaction](/compaction)): it compacts before each model call, between the steps of one turn too. * **Crashes.** With a durable store, a turn killed mid-way is finished by the next `session.send()` or by `session.resume()`. A tool call that was running when the process died runs again ([at-least-once](/durable-execution#at-least-once-tools-make-side-effects-idempotent)). The built-in `write_file` and `edit_file` are safe to re-run: `write_file` writes the same content again, and an `edit_file` whose edit is already in the file (`old_string` gone, `new_string` present) succeeds with "the edit was already applied" instead of failing. With [`WorkspaceCheckpoints`](/workspace-tools#the-tools), each change's backup is stored before the file is written, and a re-run keeps the first backup, so `rewind()` still restores the original after a crash. * **Progress while resuming.** `session.resume()` returns a result, not a stream, and `session.stream()` does not stream the pending turn it finishes before the new message. To show progress, register `session.on((event) => ...)` before calling it, or pass `createAgent({ onEvent })`: both get the resumed turn's `AgentEvent`s (`tool.start`, `text.delta`, ..., `run.done`) as they happen. * **Approvals after a restart.** `agent.approvals.streamResolve({ id, approved })` (or `resolve()`) from a fresh process, given the same durable store, continues the paused turn in its session; no `session.resume()` first. ## 6. Test it without a model The agent needs no network to test. A `MemoryWorkspace` keeps the files in memory and takes a scripted `exec` for the shell, and `mockModel` plays the model. Assert on `workspace.snapshot()` and `workspace.commands`. ```ts theme={null} import { createAgent, createFsTools, createShellTool, MemoryWorkspace } from '@lousho/build-ai-agent'; import { mockModel } from '@lousho/build-ai-agent/testing'; const workspace = new MemoryWorkspace({ files: { 'math.js': 'exports.add = (a, b) => a - b;\n' }, exec: (command) => (command === 'node --test' ? { stdout: 'pass 1\n' } : { exitCode: 1, stderr: 'unknown command\n' }), }); const agent = createAgent({ instructions: 'Fix bugs. Run the tests after every change.', provider: mockModel([ { toolCalls: [{ name: 'edit_file', args: { path: 'math.js', old_string: 'a - b', new_string: 'a + b' } }] }, { toolCalls: [{ name: 'shell', args: { command: 'node --test' } }] }, 'Fixed add(); the tests pass.', ]), tools: [...createFsTools(workspace), createShellTool(workspace, { needsApproval: false })], }); await agent.send('add() is broken'); console.log(workspace.snapshot()['math.js']); // 'exports.add = (a, b) => a + b;\n' console.log(workspace.commands.map((c) => c.command)); // ['node --test'] ``` To test the approval path, gate `write_file` the way the real agent does. `send()` resolves with `finishReason: 'awaiting-approval'` and an `approvalId`, the file is still unchanged, and `agent.approvals.resolve()` runs the write and finishes the turn. ```ts theme={null} import { createAgent, createFsTools, MemoryWorkspace } from '@lousho/build-ai-agent'; import { mockModel } from '@lousho/build-ai-agent/testing'; const workspace = new MemoryWorkspace({ files: { 'notes.txt': 'old\n' } }); const agent = createAgent({ instructions: 'Write notes.', provider: mockModel([ { toolCalls: [{ name: 'write_file', args: { path: 'notes.txt', content: 'new\n' } }] }, 'Wrote the file.', ]), tools: createFsTools(workspace, { needsApproval: { write_file: true } }), }); const paused = await agent.send('Update the notes'); console.log(paused.finishReason, workspace.snapshot()['notes.txt']); // 'awaiting-approval' 'old\n' const done = await agent.approvals.resolve({ id: paused.approvalId!, approved: true }); console.log(done.text, workspace.snapshot()['notes.txt']); // 'Wrote the file.' 'new\n' ``` To check the real prompt and tool schemas against a model once and replay the recording afterwards, see [Record and replay](/testing#record-and-replay). ## Next steps * [Workspace tools](/workspace-tools): the full tool reference, the security model, and `SandboxShell` to run commands in Docker. * [Undo the agent's file changes](/workspace-tools#the-tools): pass `createFsTools(workspace, { checkpoints })` a `WorkspaceCheckpoints`, then `checkpoints.rewind(turn, { sessionId: session.id, dryRun: true })` shows what rewinding to a turn would restore. * [Approvals](/approvals): durable approval stores, `approve` callbacks and deciding from another process. * [Streaming](/streaming): every event type, cancelling a run, and the web stream formats. * [Sessions](/sessions): stores, limits and durable sessions. * [Durable execution](/durable-execution): surviving a crash in the middle of a long run. * [Configuration](/configuration): `projectInstructions: true` appends your `AGENTS.md` or `CLAUDE.md` to the instructions. # Changelog Source: https://lousho.com/changelog Every notable change to @lousho/build-ai-agent, newest first. ## \[Unreleased] What is on `main` and not yet on npm. Entries marked **Behavior change** can break working code; [Upgrading](/upgrading) lists each one with its opt-out. ### Changed * **Behavior change:** the built node-server and Docker image refuse to start on a non-loopback host when no auth is configured (no `LOUSHO_API_TOKEN`, build token or `auth.ts`), with `LOUSHO_SERVER_UNPROTECTED`. Set a token, add `auth.ts`, bind to `127.0.0.1`, or set `LOUSHO_ALLOW_PUBLIC=1` to serve an open agent on purpose. `lousho build --target=docker` prints the run command with `-e LOUSHO_API_TOKEN=`. * The `limits.maxCostUsd` warning for a model with no known price names the model, says the limit is not enforced for it, and shows the `registerModel()` call that fixes it. It is printed once per model instead of on every step. * Scaffolded READMEs link to the docs site (`https://lousho.com/quickstart`) instead of the GitHub file on `main`. * [Sessions](/sessions#sharing-a-sqlite-file) has one statement on sharing a SQLite store, used across the docs: several processes on one host and a local disk are fine; network filesystems and multiple hosts are not. * **Behavior change:** the built node-server and Docker image resume runs a crash left in progress when they start (`agent.recover()`), and check again every 30 seconds until shutdown. Runs paused on an approval or a question are left alone, and a run another replica holds is skipped. `LOUSHO_RECOVER_ON_START=0` turns it off. See [Crash recovery](/deployment#crash-recovery). * **Behavior change:** `createAgent()` and `defineTool()` reject an option key they don't know with `LOUSHO_CONFIG_INVALID` and a suggestion (`requireApproval` → `needsApproval`, `maxOutputTokens` → `modelSettings.maxTokens`) instead of ignoring it. A mistyped `needsApproval` used to run the tool unapproved. Keys set to `undefined` are ignored. * **Behavior change:** the workspace file tools deny `.env` and `.env.*` by default as well as `.git`. `.env.example`, `.env.sample` and `.env.template` stay readable. A `deny` list you pass still replaces the defaults. * `POST /chat` answers `409` with `Retry-After: 2` and `code: LOUSHO_SESSION_BUSY` when another process or replica is running the session, instead of a `200` stream holding only the error. An `Idempotency-Key` resend that hits a running turn gets the same `409`. * Scaffolded agents (`lousho init`, `create-lousho-agent`) set `modelSettings: { maxTokens: 4096 }`, so a first run on a low-credit OpenRouter key no longer fails with "requires more credits, or fewer max\_tokens". The generated README explains the error. * Error `docs` links point to `https://lousho.com/errors#` instead of the GitHub file on `main`. * The Docker image creates `/app/data` owned by `node` and declares it a `VOLUME`, so a fresh named volume works with `LOUSHO_STORE=sqlite:/app/data/lousho.db`. * The [Authentication](/auth) examples read secrets from the environment and fail at startup when they are missing, instead of falling back to a literal secret. * **Behavior change:** the built node-server (and the Docker image) now guards `POST /channels/` and its approvals route for a channel without `verify` with `LOUSHO_API_TOKEN` / `auth.ts`, the same as `/chat`. Before, such a channel answered anyone even with a token set. Channels that verify their own requests (Slack, Discord, Telegram, GitHub, Teams, `webhookChannel` with a secret, any channel with `verify`) are unchanged, and the start-up log lists each channel route's guard (`self-verified`, `server-auth` or `open`). * **Behavior change:** `webhookChannel` replies with `{ sessionId, text, finishReason, approval? }` (plus `approvalPrompt` on a pause), as `httpChannel` does, instead of the full `ExecutionResult`. The full result echoed the system prompt and the whole transcript to the caller. `fullResult: true` restores the old body. * Channels answer a turn on a session that another process or replica is running (`LOUSHO_SESSION_BUSY`) with `409` and `Retry-After: 2` instead of a `500`, and report a turn that fails before the request is acknowledged to `onError` with stage `'turn'` (it said `'parse'`). * README and [Comparison](/comparison) no longer list ACP and MCP as differentiators, and describe Agent Forge as a local visual console. * CI is one workflow with two jobs: `ci` (type checks, lint, build, tests with coverage, docs, API and fallow gates, pack-smoke, size check, and a zod 4 type check) and `compat` (the newest peers, `ai` 7 with zod 4, plus the real-Docker sandbox tests). The separate Docker Engine, Agent Forge E2E and weekly registry-smoke workflows and the `ai` 6 / zod 3 matrix entries are gone; `npm run registry-smoke` and `npm run test:e2e:studio` still run locally. When the fallow gate fails, CI now prints its full health and duplication reports. ### Added * `lousho init --template telegram` scaffolds a long-polling Telegram bot: an owner lock (the first chat to message the bot claims it; other DMs are ignored unless their id is in `ALLOWED_CHATS`), `@mention`/reply triggers in groups (`ALLOW_GROUPS=true`), allow/deny/always approval buttons backed by saved rules, markdown→Telegram-HTML rendering, photo/voice/file intake, and `npm run task` (`--task-file`) one-shot mode other agents delegate to. `--bot-name`, `--bot-token`, `--bot-username`, `--allowed-chats` and `--persona` write `.env` and the persona directly and are rejected on other templates. See [Scaffolding a new project](/installation#scaffolding-a-new-project). * `GET /ready` on the built node-server and Docker image: `200 {"status":"ready"}` while serving, `503 {"status":"draining"}` once shutdown starts. `/health` is unchanged. See [Deployment](/deployment). * The built server exports OpenTelemetry traces over OTLP/HTTP when `OTEL_EXPORTER_OTLP_ENDPOINT` (or `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`) is set, next to any exporter the agent already has. It loads `@opentelemetry/sdk-trace-node`, `@opentelemetry/resources` and `@opentelemetry/exporter-trace-otlp-http` only then, says how to install them when missing, and honours `OTEL_SERVICE_NAME`. See [Observability](/observability). * `createAgent({ toolErrorMessage })` replaces the text of a thrown tool error (`kind: 'execution'`) before it reaches the model, `tool.error` events and hooks; a sanitizer that throws gives "The tool failed.". See [Tools](/tools). * `lousho init` and `create-lousho-agent` generate an `AGENTS.md` for coding agents (the project's real scripts, conventions and docs links) and a `CLAUDE.md` that imports it. * `lousho add` falls back to the registry bundled in the npm package (`registry/dist`) when `registry.lousho.com` and its GitHub fallbacks are unreachable, with a notice that the bundled copy may be older. * [Pause, restart and resume](/pause-and-resume): one tutorial for an approval that waits across a restart, crash recovery, and resending over HTTP. * `SECURITY.md`: supported versions, private reporting through GitHub, and the disclosure policy. * `httpChannel()` created without `verify` logs one warning that the channel is unauthenticated and its `sessionKey` values are guessable. [Channels](/channels) shows `verify` in the quick start, and [Deployment](/deployment) says which routes `LOUSHO_API_TOKEN` guards. * `POST /chat` accepts an `Idempotency-Key` header: a resend with the same key replays the finished turn's reply (`Idempotent-Replayed: true`, nothing runs), resumes a turn a crash interrupted instead of appending the message again, and is a `409` (`LOUSHO_SESSION_BUSY`) while that turn still runs. The key is stored on the turn's user message (`metadata.idempotencyKey`), so it survives restarts; `session.pending()` reports it as `idempotencyKey`. See [Resending a turn](/deployment#resending-a-turn). * `fromAiSdkTools(record)` converts Vercel AI SDK tools (`tool({ ... })` or plain `{ description, inputSchema | parameters, execute, needsApproval? }` objects) into Lousho tools, keyed by name: zod and Standard Schemas are kept, `jsonSchema()` wrappers and plain JSON Schemas are converted (their own `validate` still runs), `needsApproval` (boolean or function) pauses the run for approval, and `execute` gets the call id and the run's abort signal. It imports nothing from `ai`, so tools built on `ai` 4 to 7 work with any installed version. New guide: [Migrating from the AI SDK](/migrating-from-ai-sdk). * `lousho eval` without vitest installed now says it runs `*.eval.ts` files through vitest, the directory it searched from, and the install command; [Evals](/evals) states the requirement up front. * `result.usage.delegated.toolErrors`: the tool calls that failed inside a run's sub-agents (and theirs), which the lead's own `result.toolErrors` does not count. `ToolCallContext.onDelegatedUsage` takes the child's `toolErrors` as an optional second argument. * The session HTTP API (`createRouteHandler()`, the deployed node server, the Worker, `lousho dev`) writes an SSE comment (`: ping`) after 15 seconds without an event, so proxies and client idle timeouts no longer drop a turn whose model or tool is slow; `heartbeatMs` changes the interval (`0` turns it off). See [HTTP API](/deployment#http-api). * `google/` model strings (e.g. `createAgent({ model: 'google/gemini-2.5-flash' })`) and a `GoogleProvider`, backed by the optional peer `@ai-sdk/google` (`^1.2.0` with `ai` 4, `^3.0.0` with `ai` 6, `^4.0.0` with `ai` 7) and reading `GOOGLE_GENERATIVE_AI_API_KEY`. `lousho init --provider google`, `lousho doctor` (package pairing, key, `--ping`) and the missing-peer install hint cover it. See [Providers](/providers). * An unknown model prefix (`mistral/...`, `xai/...`) now says how to reach the vendor anyway: `openrouter//`, `pi//` when pi serves it, or `fromAiSdk()` with the vendor's AI SDK package. See [Vendors without a prefix](/providers#vendors-without-a-prefix). * `lousho doctor` fails an installed `ai` 5 (AI SDK v5 is not supported) with an upgrade hint to `ai` 6 or 7 and the matching `@ai-sdk/*` majors. ### Fixed * `lousho build` works again in a project without the optional `@opentelemetry/*` packages (every fresh scaffold): the OTel exporter added for the built server is loaded only when `OTEL_EXPORTER_OTLP_ENDPOINT` is set, and the build leaves those packages unresolved instead of failing with `Could not resolve "@opentelemetry/api"`. The scaffold end-to-end test now builds the server and checks `/health`. * The `httpChannel()` "no verify" warning no longer says the channel is unauthenticated when the built server guards it with `LOUSHO_API_TOKEN` or `auth.ts`, and [Channels](/channels) shows `webhookChannel`'s trimmed reply. * `lousho add` works again when `registry.lousho.com` is unreachable: the fallback now tries the release tag as the repository names it (`1.0.0-alpha.24`), then the `v`-prefixed tag, then `main`. It asked only for `v`, which 404'd. * The file tools' `deny` list (`.git` by default) is now also checked where a path really leads: a symlink or Windows junction inside the workspace pointing at `.git` or into it (`gitj -> .git`) let `edit_file gitj/config` rewrite `.git/config` (for example `core.hooksPath`, which runs code on the next git command) and `write_file gitj/PWNED` create files in `.git`. Reads, writes, edits, `list_dir`, and `glob`/`grep` given such a link as `path` are now refused. Providers with links can implement the new optional `FsProvider.realPath(path)`; `NodeWorkspace` does. See [Workspace tools](/workspace-tools#security-model). * `NodeWorkspace.writeFile` (and so `write_file` / `edit_file`) writes a temp file in the same directory and renames it into place, so a process killed mid-write no longer leaves a truncated file. A replaced file keeps its permission bits; a read-only file is still refused. * `WorkspaceCheckpoints` stores a file's backup before `write_file` / `edit_file` writes it, not after. A process killed between the write and the backup lost the original: on resume the re-run call backed up the agent's own content, so `rewind()` reported `restored` but put back the agent's version (or, for `edit_file`, recorded nothing). A re-run now keeps the turn's first backup, and rewind treats a file still holding its backed-up content as nothing to undo. `track()` also accepts a `PreparedWorkspaceWrite` (`{ prepare, write }`), which the built-in tools use. * `edit_file` re-run after a crash (the edit already in the file: `old_string` gone, a non-empty `new_string` present, also with `replace_all`) now succeeds with "the edit was already applied" and writes nothing, instead of failing with `old_string was not found` and sending the model into a loop. A `write_file` / `edit_file` that leaves the file unchanged no longer records a checkpoint backup. * [Sessions](/sessions) no longer says to call `resume()` before resolving an approval after a restart: `agent.approvals.resolve()` / `streamResolve()` from a fresh process finds the session itself. It and [Build a coding agent](/build-a-coding-agent) now say how to show a resumed turn's progress (`session.on()`, `onEvent`), and the guide points long coding turns at compaction. * MCP tool schemas with OpenAPI's `nullable: true` (any type) now accept `null`; the model's valid `null` was rejected with `ToolArgumentsValidationError`. * A failed MCP tool call (server exited, timeout, protocol error) now rejects with `LOUSHO_MCP_CALL_FAILED`, naming `__` with the MCP error as `cause`, instead of a bare `MCP error -32000: Connection closed`. [MCP](/mcp-integration) now says stdio `env` is merged onto the MCP SDK's default allow-list, not `process.env`. * Flows (consumer harness): a `{{variable}}` holding an object (a `toolCall` result, an MCP result) no longer reaches a prompt as `[object Object]`: an object whose only field is `text` (an MCP tool result) is that text, a raw MCP `{ content }` result its text parts, and any other object or array its JSON. Placeholders take dotted paths (`{{res.user.name}}`, `{{list.0}}`, own properties only), also as whole tool-argument values. An `evaluator` step stores its result in `outputVariable`. See [Template values](/flows#template-values). * Flows: a durable run is saved before its first step, so a run whose first step failed can be `resume()`d instead of failing with `LOUSHO_CHECKPOINT_NOT_FOUND`, whose hint for a flow run now points at `FlowExecutor.execute()` rather than forking session history. A step's checkpoint is saved before its `step-complete` event, so a crash in between no longer re-runs a step reported complete. * Flows: an `llmCall` whose model request fails rejects with the same compacted `CompactedLLMProviderError` as an agent run (`LOUSHO_PROVIDER_*` code, `compacted.category`), not the provider SDK's raw `APICallError`, and its `retry` no longer retries a failure marked `retryable: false` (a 401, an exhausted quota, a too-long prompt). * A provider answering a streaming request with HTTP 200 and a body that is neither an event stream nor valid JSON (for example a truncated `{"choices": [ {"oops`) no longer ends as a silent empty reply. It now fails as a retryable 502, so `withRetry` retries it and an exhausted retry surfaces as a provider error. * A sub-agent with an `output` schema that ran out of steps and gave a valid object in its forced final call is now a `task` result (the object, with a line saying the steps ran out), not a failure telling the lead it gave no final answer - which made leads re-run the task. * Sub-agents are held to their lead's token, cost and duration `limits` while they run, counted with what the lead and its other running sub-agents have spent, so parallel sub-agents no longer each spend the lead's whole budget (a lead with `maxCostUsd` could end at several times it). A sub-agent stopped this way reports the limit to the lead. A spend limit stays a hard stop with no forced `output` answer; the finished tool results stay in `result.messages` ([Budgets](/configuration#budgets)). * **Behavior change:** `POST /chat` to a session paused on an approval is a `409` JSON body (`LOUSHO_SESSION_AWAITING_APPROVAL`, with `approvalId` and `approvalIds`) instead of a `200` stream ending in a generic error. * A decision on an approval whose winning decision is still streaming is a `409` (`LOUSHO_APPROVAL_CONFLICT`), as docs/auth.md says, instead of a `404`. * The session routes' `400` bodies carry a `code`: `LOUSHO_REQUEST_INVALID` (invalid JSON, missing or empty fields), `LOUSHO_APPROVAL_DECISION_INVALID` (an approval body with neither `approved` nor `answer`), `LOUSHO_SESSION_ID_INVALID`. * `lousho chat` prints a failed turn with its error code, the provider's failure category and HTTP status, and the fix (`error: User not found. (auth-failure, HTTP 401)` then `[LOUSHO_PROVIDER_REQUEST_FAILED] ...`), as docs/cli.md says, instead of the bare provider message. The terminal chat of a project scaffolded by `lousho init` / `create-lousho-agent` does the same. * A `.env` saved with a UTF-8 byte order mark (PowerShell 5.1's `Set-Content -Encoding utf8`) no longer loses its first variable: the `lousho` commands and the scaffolded `src/index.ts` drop the mark before parsing. Variables already set in the environment still win. * `lousho doctor src/agent.ts` loads a `.ts`/`.js` agent module the way `lousho dev` does instead of failing with `LOUSHO_SPEC_UNSUPPORTED_FORMAT`, and a missing key for the module's provider is a failure. A scaffolded project's `npm run doctor` now runs `lousho doctor src/agent.ts`, so it fails when the agent's key is missing. * `lousho doctor --ping` checks OpenRouter at `/api/v1/key`, which needs the key (its `/models` answers without one, so a wrong key passed); a key the provider rejects is now a failure, and a ping of a custom `OPENAI_BASE_URL` says it shows reachability only. * `lousho doctor` fix lines use PowerShell syntax (`$env:NAME=""`) on Windows. * The `lousho eval` table shows the scores of passing scored gates (such as `t.check('words', n, atLeast(3))`), not only in `--json`. * **Behavior change.** An agent with `output` and tools no longer sends the strict `responseFormat` schema on calls that offer tools: `openai/gpt-4o-mini` via OpenRouter then called tools on every step until `maxSteps` and never answered. `responseFormat` now goes only on calls without tools (an agent without tools, the "answer now" call at `maxSteps`, the repair step); tool-offering calls answer from the schema in the prompt, with the existing parse and repair. `output: { schema, responseFormat: 'always' }` restores the old behavior, and is the default with `promptSchema: false`. `object.delta` events still stream on every step of an `output` run. See [Structured output](/structured-output#how-it-works). * An agent directory built with `lousho build --target=node-server` (or `docker`) can now change who may decide an approval and who may use a session: `auth.ts` may export functions named `authorizeApproval` and `authorizeSession` next to its default auth list, `resolveAgentDir()` returns them, and the built server applies them. Before, the built server always used the defaults, so a manager could not approve another user's paused call while that user could approve their own. See [Who may decide an approval](/auth#who-may-decide-an-approval). ### Documentation * Tool idempotency: `toolCallId` and `ctx.approval.id` identify one call; after a crash the model may retry with a new call, so side effects that must happen once need a key from your own data. New guidance for ending a run after a rejected approval, and for extract-then-approve-then-act with structured output. * [Durable execution](/durable-execution#recovering-after-a-crash): `agent.resume()` resolves with a result, not a stream (stream a resumed run with `agent.stream([], { sessionId })`), and `agent.pending()` also lists runs still active in another process. [Installation](/installation#typescript-settings): TypeScript 6+ projects need `"types": ["node"]` for the SDK's declarations. ## \[1.0.0-alpha.24] - 2026-10-17 Two fixes on top of 1.0.0-alpha.23, published from the release branch. ### Added * `model-unloaded` provider error category: "model is not loaded/unloaded" replies from a local server (LM Studio, Ollama) classify as retryable, so `withRetry()` rides out the server evicting or still loading a model. * A run ended by `maxSteps` or a budget without a final answer now returns explanatory text instead of an empty string. ### Changed * MCP: a warning is logged when a server's tool schemas exceed about 16K tokens (deferred tools excluded). A server marked `optional: true` connects in the background, so a slow or failing optional server no longer blocks the first `send()`. ### Fixed * Compaction: when the provider rejects a request for context size and the configured `contextWindow` is larger than the model's real one, the error is marked on the transcript and folded into it for a retry when a compaction hook exists; the hook compacts under the server's observed window (or the rejected request size when unnamed) for the rest of the run. * `toolCalls` of a run continued from a transcript (approval resolve, resume) are collected from its messages, so they no longer disappear from the result. * `recall_` with a query that matches nothing now says the slot is not empty, instead of returning a bare `[]` that contradicted the injected `` block. * The guardrail starter set's `ignore-instructions` signal also catches "ignore these/those/her/his/their/our instructions" and "ignore your instructions", without a qualifier such as "previous". ## \[1.0.0-alpha.23] - 2026-10-16 The same tree as 1.0.0-alpha.21; no code changes. create-lousho-agent 0.1.2-alpha.7. ## \[1.0.0-alpha.22] Never published to npm (the registry goes from 1.0.0-alpha.21 to 1.0.0-alpha.23); no changes. ## \[1.0.0-alpha.21] - 2026-10-16 Everything merged to `main` after 1.0.0-alpha.20. create-lousho-agent 0.1.2-alpha.6. ### Breaking * Removed the deprecated trigger adapters and their types from `@lousho/build-ai-agent/triggers`. `TriggerAdapter`, `TriggerRegistry`, the `WebhookAuth` helpers, `verifySlackSignature()` and `parseCronExpression()` stay. Migration (see [Migrating from triggers](/upgrading#migrating-from-triggers)): * `WebhookTriggerAdapter`, `WebhookTriggerAdapterOptions`, `WebhookTriggerHandle` -> `webhookChannel()` with `mountChannels()` (same `auth` option) * `SlackTriggerAdapter`, `SlackTriggerAdapterOptions` -> `slackChannel()` with `mountChannels()` * `CronTriggerAdapter`, `CronTriggerAdapterOptions`, `CronIntervalOptions`, `CronExpressionOptions` -> `defineSchedule()` with `startSchedules()` (no `intervalMs`: use a cron expression such as `*/5 * * * *`) ### Added * `agent.recover({ intervalMs?, signal?, onError?, onRecovered? })` finishes interrupted durable runs in the background: it polls `agent.pending()` and resumes each `'in-progress'` run once its crashed holder's run lease is free, leaving approval and question pauses (and non-retryable failures) alone; returns `{ done, stop() }` ([Durable execution](/durable-execution#recovering-automatically)). * An agent directory's config accepts `retry` (`agent.json`: `maxRetries`, `timeoutMs`, `backoff`, ...) and, in an `agent.ts` code config, `output` (a Standard Schema), so `lousho dev`/`chat`/`build ./agent` keep structured output and per-attempt timeouts. `loadAgentDir(dir, { output })` now returns `SimpleAgent>`, so `result.object` is typed. * `result.toolErrors` counts a run's tool calls that ended in an error result, so an answer the model gave after its tools failed (`finishReason: 'stop'`) is no longer indistinguishable from a good one; `limits.maxToolErrors` stops the run (`'budget-exceeded'`, `budget.limit: 'maxToolErrors'`) once that many failed. See [Failed calls in the result](/tools#failed-calls-in-the-result). * An error a run rejects with carries what the run spent as `error.usage` (also on `PropagatingToolError` and your own errors). A sub-agent's spend is saved on its lead's checkpoint as it runs, so a lead resumed after a crash mid-sub-agent counts the interrupted sub-agent's model calls. * A `send(msg, { sessionId })` or `agent.resume()` run that rejects records `lastError` and `attempts` on its checkpoint, and `agent.pending()` lists them (`attempts` is new for session turns too), so a restart loop can tell a failing run from a crashed one. * Docs: a webhook-redelivery recipe (run a job once by checking the checkpoint before `send()`), a note on structured-output schemas that mirror a side-effect tool, and the `fetch` option for proxies and fault injection on every provider class. ### Changed * **Behavior change:** the fallback default model for the OpenAI, OpenRouter and Pi providers (what `lousho init` scaffolds and what runs when no model is configured) is now `gpt-4.1-mini` instead of `gpt-4o-mini`; set `model` or `LOUSHO_MODEL` to keep the old one. README and Quick Start use it. Added `docs/comparison.md`; CI also runs on pushes to `main`; README no longer claims unverifiable live validation. * `createAgent({ prompt })` is deprecated: `instructions` is the one documented name (`prompt` still works as an alias, marked `@deprecated`). Docs, README and examples now use `instructions`, and `npm run docs:verify-snippets` fails a docs snippet that passes `prompt` to `createAgent`. * The published package no longer ships source maps (`dist/**/*.map`, \~3.7 MB; tsup no longer emits them). The tarball goes from 965 files / 18.9 MB unpacked / 5.0 MB packed to 774 files / 15.1 MB / 4.3 MB. `src/` still ships because `lousho build` bundles the SDK from it. `npm run size-check` now guards the tarball size and a hello-world bundle against `scripts/size-budget.json`. * `docs/comparison.md` is now a dated (October 2026) per-competitor table covering the AI SDK 7, Mastra, LangGraph.js, OpenAI Agents SDK, Claude Agent SDK, eve, VoltAgent and open-harness, and the README's differentiator list keeps only claims still true against them. ### Fixed * **Behavior change:** a session without a `checkpointStore` that pauses on an approval now keeps an in-process pause marker, so a second `send()` / `stream()` / `compact()` on it throws `SessionAwaitingApprovalError` (`LOUSHO_SESSION_AWAITING_APPROVAL`) — before, the new turn ran over the truncated transcript and the later `approvals.resolve()` silently overwrote the committed turn. The marker covers `send`, `stream`, `compact`, `clear`, `pending` and `discardPending`, and is dropped when the turn resolves, aborts, fails or is discarded. Across processes there is still no durable marker: a session that may pause needs a `checkpointStore`. * Session turn checkpoints no longer reuse a `.turn-` id after `compact()` shortened the transcript: the `n` now comes from a persisted high-water mark (`.turn-counter`), so `fork()` can no longer resolve a stale checkpoint of a different turn, and `prune()` keeps the counter while its session's paused run needs it. * Memory: two parallel `remember_` calls with an `itemKey` no longer store duplicate items — the check-and-replace is serialized per memory key. A remembered item is capped at 20,000 characters and the injected `` block at 20,000 (each line 4,000), so one item can no longer outgrow the context window of every later run. * `lousho add --dir ` no longer creates the target directory before the install is decided: a non-interactive refusal, a manifest refusal or a 'no' at the prompt now truly leaves "nothing was written" — before, an empty directory stayed behind. * A malformed or truncated provider response before any output (the AI SDK `JSONParseError`/`TypeValidationError` on a garbage first SSE chunk, or a stream cut by `UND_ERR_SOCKET` / "terminated" / "other side closed", also inside a `cause`) is now retried by `withRetry()` like other network errors, with a `provider.retry` event, instead of ending the turn with `LOUSHO_PROVIDER_REQUEST_FAILED`. Nothing is retried once text or a tool call was emitted. * `lousho build --target=node-server` now lists the provider package in the build's `package.json` when the agent module (or a code `agent.ts`) uses an SDK provider class such as `new OpenRouterProvider(...)`, not only a literal `model: '/...'`, and warns when it detects no provider. `docs/deployment.md` no longer tells readers to call the internal `createDeployedServer()`; it shows a graceful shutdown for a self-served agent. * Errors and cost (consumer harness): a session turn that fails outside its run (e.g. `LOUSHO_SESSION_BUSY`) now sends the error's stable `code` on the `error` event, with the message still sanitized; `provider.fallback` events carry `fromModel` / `toModel`, so two providers of the same name (both OpenRouter) can be told apart; and one step with no known price no longer discards the run's cost: `usage.costUsd` keeps the priced steps' sum and `usage.costPartial: true` flags it (also on `byModel` entries, `delegated` and `step.done` / `run.done` usage), and `limits.maxCostUsd` is enforced on that partial total. * `grep` no longer times out on a cheap pattern over a slow disk: the 10 s budget now counts only the time spent matching, not reading files. The timeout error says whether the pattern is slow or the many files used the budget, and how many files were scanned. New `createFsTools({ grepTimeoutMs })`. * Permissions: a `permissions` rule naming a tool the agent does not have (a typo such as `write_flie`, so a `deny` silently guarded nothing) now logs a `[lousho]` warning at `createAgent()` with a "did you mean", and `lousho doctor ` reports it as a warning. Wildcards, globs, patterns and agents whose tools may arrive later (MCP, tool search, sub-agents) are only checked for near-miss typos. * `lousho init` / `create-lousho-agent` template: `npm run dev` with piped input (`printf 'hi\n' | npm run dev`) no longer exits before the reply; it streams the reply and exits when the turn is done. * `lousho eval`: the "with soft failures" total (`softFailed` in the JSON summary) also counts cases that failed a gate or threw; the JUnit report keeps their soft failures as `` notes. * Sessions: a checkpointed turn whose first model call fails (a 5xx after retries, an exhausted 429, a malformed response) no longer loses the user's message. The turn is kept as an `in-progress` pending turn (new `Checkpoint.unstarted`), so `pending()` and the route's `GET` show it, and the next `send()` / `resume()` runs it again with the message in the transcript once. Over the session routes, that stored turn claims the session for its caller. * `lousho eval --replay` / `--record` skip a `mockModel()` provider: a scripted mock needs no network, so a mock eval no longer fails with `no cassette ... [LOUSHO_CASSETTE_INVALID]`. * `limits.maxCostUsd` no longer logs "cost limit is not enforced" on runs that use `mockModel()`, which has no price. * `lousho doctor` lists optional packages the agent does not use as one info line instead of a warning each. * Docs: `fileMemory({ dir })` in an agent directory's `memory/*.ts` resolves against the working directory (anchor it to the module); `lousho mcp` takes an agent directory; running `create-lousho-agent` from a local tarball; `lousho dev`/`chat` cannot pass run metadata. * Flows: a durable run (`checkpointStore` and `runId`) holds a run lease, like an agent run: a concurrent `FlowExecutor.execute()` or `resume()` of the same `runId` (in this process, another process or through another store object) is refused with `LOUSHO_SESSION_BUSY` and runs nothing, instead of running the remaining steps twice. A lease left by a crashed process expires, so the run can still be resumed. See [Durable runs](/flows#durable-runs). * Flows: a `toolCall` argument that is exactly `'{{name}}'` or `'$name'` passes the variable's value (a number, an object) instead of its text, so `{ n: '{{n}}' }` works with a `z.number()` tool. Mixed strings are still interpolated as text. See [Argument values](/flows#argument-values). * Flows: a `step` node (`StepNode`) fails with a message naming the supported way to run a `createAgent()` agent in a flow (a tool that calls `agent.send()`). See [Running an agent in a flow](/flows#running-an-agent-in-a-flow). * Sub-agents: a sub-agent's own `memory` works when the lead's `task` tool runs it (recall in its system prompt, its `remember_` / `recall_` tools), scoped by the lead run's `sessionId`, `metadata` and `principal`; before, its slots were silently ignored there. New `subagentOptions: { continuation: false }` leaves `taskId` and `mode` out of the `task` tool, and the error for another sub-agent's `taskId` tells the model to omit it for a new task. See [Sub-agents](/sub-agents#continuing-a-task). ### Security * **Behavior change:** the file tools from `createFsTools()` (`read_file`, `list_dir`, `glob`, `grep`, `write_file`, `edit_file`) refuse any path with a `.git` segment (`.git/config`, `./.git`, `a/../.git`, `.GIT`, a worktree's `.git` file; on Windows also 8.3 names such as `GIT~1`) with a `WorkspaceError`, and `glob`, `grep` and `list_dir` leave `.git` out of their results. `.git/config` can hold a CI checkout token (`http..extraheader`) and a worktree's `.git` names a host path. The new `deny` option sets the names (default `['.git']`; `[]` turns it off). The `shell` tool is not path-filtered. See [Security model](/workspace-tools#security-model). * `lousho build --target=node-server|docker` no longer copies an agent directory's `.env` files or its `.lousho/` session store (real user transcripts) into `dist/` and the Docker image. Only the files the server reads are copied: `instructions.md`, `instructions/`, `skills/` and a JSON/YAML config, in the directory and each sub-agent; `.env*`, `.lousho`, a JSON/YAML config's `store.dir`, `.git` and `node_modules` are skipped at any depth. `dist/` is emptied before each build so sessions a previous build's server wrote there are not resurrected, and the generated `.dockerignore` also lists `**/.env`, `**/.env.*` and `**/.lousho`. Rebuild and redeploy images built from an agent directory with an earlier version. See [Deployment](/deployment#agent-directories). * `createRouteHandler()`'s UI stream (`POST /ui`) and framework hook route (`POST `) now enforce the documented 1 MB body cap (`413`) like `POST /chat` does; they previously buffered the full body via `request.json()`. * A channel approval decision now binds to the session recorded in the approval's own durable snapshot, not the `sessionKey` the caller sends, so a request carrying a foreign session key can no longer resolve another conversation's pending approval. * `POST /chat/:sessionId/approvals/:id` checks the approval's kind after authorization: `answer` only decides question approvals and `approved`/`note` only tool approvals; a mismatched body is `400 LOUSHO_APPROVAL_KIND_MISMATCH` instead of silently approving a tool. * `POST /chat` message arrays may carry `role: 'user'` only; client-supplied `system`, `assistant` and `tool` messages are dropped before the turn (content-part arrays without roles are unaffected). * **Behavior change:** `crossSiteRequest` no longer trusts `X-Forwarded-Host` or `Forwarded: host=` as same-site evidence — those headers are client-controllable. A request is same-site when its `Origin` matches the server's actual `Host` (or an `allowedOrigins` entry); behind a trusted proxy, set `allowedOrigins` to the public origin. * Session-owner bookkeeping adds a per-principal cap (1024 claimed sessions per caller) under the global 100,000 LRU, so one caller flooding new session ids can no longer evict everyone else's ownership claims. * `webhookChannel()` only defines `verify` when an `auth` option is configured; an unauthenticated webhook is treated as an open channel and its cross-site requests are refused like `httpChannel()`'s. The deprecated `WebhookTriggerAdapter` treats a missing `verify` as success. * Malformed percent-encoding in route parameters (`%zz`, truncated UTF-8) answers `400` instead of a `URIError` 500 on `/chat/:sessionId/...` and channel approval routes. ## \[1.0.0-alpha.20] - 2026-10-16 create-lousho-agent 0.1.2-alpha.5. ### Security * **Behavior change:** with route auth and no `authorizeSession`, a session belongs to the first principal that sends it a turn (reading claims nothing); any other principal gets `403 LOUSHO_SESSION_FORBIDDEN`. Owners are kept in memory per agent for the 100,000 most recently used sessions, so a stored session whose owner is not on record (after a restart, on another replica, past the cap) is refused to everyone: pass `authorizeSession` for multi-instance deployments and durable stores, or `authorizeSession: () => true` for the old behavior. A route behind one bearer token has a single caller and checks no owner. `POST /chat` with an empty `input` (and `POST /ui` without a user message) is a `400` that claims nothing, and a turn that stores nothing drops its claim, so an owner can no longer be evicted by flooding. See [Who may use a session](/auth#who-may-use-a-session). * **Behavior change:** `POST /chat/:sessionId/approvals/:id` answers `404` unless the approval belongs to that session, and by default only the caller the paused run acts for may decide it; allow others (a supervisor) with `authorizeApproval`. * **Behavior change:** the session routes (`createRouteHandler()`, `createDeployedServer()`, the Worker, `lousho dev`) refuse cross-site requests: a POST whose `Origin` is neither the server's own (`Host`, `X-Forwarded-Host` or `Forwarded: host=`) nor in the new `allowedOrigins`, or that the browser marked `Sec-Fetch-Site: cross-site`, is `403 LOUSHO_REQUEST_FORBIDDEN`, and a POST body must be `Content-Type: application/json` (`415 LOUSHO_UNSUPPORTED_MEDIA_TYPE`). Requests without `Origin` are unaffected. Opt out with `allowedOrigins: ['*']` and `requireJsonBody: false`; `allowedHosts` refuses unlisted `Host` headers. Built servers read `LOUSHO_ALLOWED_ORIGINS`, `LOUSHO_ALLOWED_HOSTS` and `LOUSHO_REQUIRE_JSON_BODY=false`. The React/Vue/Svelte bindings no longer send `Content-Type` twice and accept a `Headers` instance. See [Cross-site requests](/auth#cross-site-requests). * **Behavior change:** `lousho dev` answers `403` to a non-loopback `Host` (off loopback: an IP address or the `--host` name; new `--allowed-hosts a,b`), so a web page cannot reach it through DNS rebinding, and warns when bound off loopback. A channel without `verify` refuses a cross-site browser request (`403`), and `httpChannel()` without `verify` requires a JSON body (`415`). * **Behavior change:** the Cloudflare Worker refuses every `/chat` route (`403 LOUSHO_REQUEST_FORBIDDEN`) when no `LOUSHO_API_TOKEN` secret is set; set the var `LOUSHO_ALLOW_OPEN_SESSIONS` (or the earlier `LOUSHO_ALLOW_OPEN_TRANSCRIPTS`) to `"true"` to allow it. `/health`, the OAuth callback and channels stay open, and `lousho build --target=cloudflare-worker` warns about the secret. * **Behavior change:** `WebhookTriggerAdapter` binds `127.0.0.1` by default (was `0.0.0.0`), refuses a non-loopback `host` without `auth` unless `allowUnauthenticated: true` (`LOUSHO_TRIGGER_INVALID`), refuses a foreign `Host` or an `Origin` on loopback without `auth`, caps bodies at 1 MB (`413`) before auth, and no longer echoes handler errors. See [Webhook](/triggers#webhook-authentication). * **Behavior change:** MCP tools ask for approval by default (`approval: 'always'`); a server's `readOnlyHint` / `destructiveHint` count only with `approval: 'annotations'`. An MCP tool named like a local tool is refused with `LOUSHO_CONFIG_INVALID` instead of replacing it. See [MCP](/mcp-integration#approval-for-mcp-tools). * Flows: a `toolCall` step passes the same gate as an agent's tool call (`permissions`, `permissionMode`, `onPermissionDecision` and a new `approve` option on `FlowExecutionContext`); a call that needs approval and is not approved fails with `LOUSHO_FLOW_TOOL_DENIED` without running. Step arguments are validated against the tool's schema (`LOUSHO_TOOL_ARGS_INVALID`) and a flow's declared `inputs` are enforced (`LOUSHO_VALIDATION_FAILED`). See [Flows](/flows#tool-calls). * Registry: the approval an install receipt requires for an `exec`, `needsApproval` or unattested item can no longer be waived from inside the agent directory (an `allow` rule, `acceptEdits` or the directory's own `approve` file); only a person or an `approve` passed in code decides it. Receipts now also cover a kit's `subagents//` tools. `coding-kit` / `coding-pi` refuse writes to their own config, tools and `.git/`, drop `git diff` from the shell allowlist and accept `node --test` only with test file paths (reinstall with `lousho add coding-kit --overwrite`). See [Registry](/registry). * Shell tool: an `allow` string pattern refuses arguments that escape the workspace (`..` segments, also in `--flag=../x`, `~`, absolute paths, quoted or escaped forms such as `cat "../x"`, globs that can expand to `..`, and `$`, `{`, `}`), and under cmd.exe `%` and `^` count as shell operators. Use a `CommandRule` or an anchored RegExp to allow such arguments. * Agent Forge (`lousho studio`) no longer answers any origin: it sends no CORS headers, refuses a foreign `Origin` or a non-loopback `Host`, and every API and WebSocket call needs the per-launch token printed in its URL. See [Agent Forge](/agent-forge#access-token). * Tracing: `redactContent` defaults to `true`, so prompts, instructions and tool output are no longer written to span attributes or `.lousho/traces` unless you pass `redactContent: false` (or `captureContent: true` for `gen_ai.*` content). * The session routes no longer send raw error text to clients: `error` events, `500`s and `provider.retry` / `provider.fallback` events carry the error's `name` and `code` with a generic message, and the full error is logged. Pass `exposeErrors: true` for the old behavior (`lousho dev` does). Channel `500`s no longer echo internal errors either. * `fromUIMessages()` (and `POST /ui`) keeps only `user` and `assistant` messages from the client; a client-sent `system` message is dropped. * OAuth: a principal without an issuer gets tokens namespaced by its authenticator, so the same id from Slack and GitHub no longer shares a token (docs/oauth.md has the one-time move); `telegramChannel` and `teamsChannel` send a sign-in link only in a 1:1 chat; the `[REDACTED]` net for `ctx.getToken()` tokens also covers thrown errors, object keys, URLs, class instances and base64 forms of the token. * `serveMcp` over HTTP validates `Host` and `Origin` against DNS rebinding (new `allowedHosts` / `allowedOrigins`), and loopback checks there and in `WebhookTriggerAdapter` accept exactly `localhost`, `127.x.x.x` and `::1` (`127.attacker.example` passed). * The `openrouter` provider without `apiKey` reads `OPENROUTER_API_KEY` and no longer sends `OPENAI_API_KEY` to openrouter.ai. * Slack and Discord signatures accept a timestamp at most a minute ahead (was five minutes either way), read in seconds; `discordChannel` refuses timestamps more than five minutes old. `githubChannel({ deliveries })` takes a shared `DeliveryStore` for `X-GitHub-Delivery` replay protection. * The `@modelcontextprotocol/sdk` peer range is `^1.31.0` (was `^1.30.1`): 1.30.x is affected by GHSA-6qxp-vccf-f47h, which MCP OAuth relies on. * `SubprocessSandbox`: `writeFile()` stays under the new `root` option (`LOUSHO_SANDBOX_PATH_DENIED`, also through symlinks), and containers run as `1000:1000` with all capabilities dropped, `no-new-privileges` and default limits (512 MiB, 256 pids, 1 CPU); pass `user: 'root'` for the old user. See [Guardrails](/guardrails#sandboxed-tools). ### Changed * **Behavior change:** session turns keep the checkpoint history of the latest 10 committed turns (`agent.session({ id, turnHistoryLimit })`; `0` keeps none); storage used to grow with the square of the conversation. Excess history already in a store is deleted over the next commits, also when each turn uses a new process or store object. New `fileStore(dir).prune({ olderThanMs })` returns `checkpointHistory` too; `fileStore()` returns the new `FileStore` type. * **Behavior change:** a checkpointed run holds a run lease while it executes (new optional `CheckpointStore.acquireLease()`, `releaseLease()`, `renewLease()`, `saveIfLeased()`; file, SQLite, memory and best-effort KV stores). A second process, replica or store object that sends to the same session, resumes it or resolves its approval gets `LOUSHO_SESSION_BUSY` instead of running its tool calls again, and a stalled holder can no longer overwrite the run that took over. A dead holder on the same host is taken over at once; on another machine 30-60 s after its last heartbeat. Schedule fires are claimed the same way, so replicas fire a tick once. See [Run leases](/durable-execution#run-leases). * **Behavior change:** approvals run at most once: deciding one re-saves the turn as `'in-progress'` with the call in `Checkpoint.startedToolCallIds`, and resuming after a crash mid-call gives it a `kind: 'interrupted'` tool error instead of running it again. Resolving an approval whose run was deleted or replaced fails with `LOUSHO_APPROVAL_ORPHANED` and withdraws it. * **Behavior change:** a session turn paused for approval locks the session id: a new turn on it throws `SessionAwaitingApprovalError` (`LOUSHO_SESSION_AWAITING_APPROVAL`) until the approval is decided or `discardPending()` drops the turn. * **Behavior change:** a deterministic provider error records `lastError` on the turn's checkpoint, and later calls on that session fail fast with `LOUSHO_SESSION_TURN_FAILED` instead of replaying the failing turn; `resume()` retries it. * **Behavior change:** run ids (`send({ sessionId })`, a fork's `newSessionId`) are 1-200 characters of `A-Za-z0-9_.-` on every store and may not end in `.turn-`, `.paused` or `.fire`; `''` is refused. Session ids (`agent.session({ id })`, the routes) stay 1-128 characters of `A-Za-z0-9_-`. The error messages now say which of the two rules applies. * **Behavior change:** with a store, tools, `needsApproval`, permission rules and hooks get the session id as `ctx.sessionId`, not the checkpoint key `.turn-` (new `ExecuteOptions.contextSessionId`). * **Behavior change:** a model step with several calls that need approval pauses once, on all of them (`result.approvalIds`, `agent.approvals.resolveAll()`); calls that need no approval run before the pause. See [Several calls in one step](/approvals#several-calls-in-one-step). * **Behavior change:** `once()` no longer remembers a yes from a `createAgent({ approve })` callback, and `'defer'` is reserved: an approval note that is exactly `'defer'` defers. * **Behavior change:** `approvalTtlMs` and an `ask` rule's `ttlMs` above 100 years throw `LOUSHO_CONFIG_INVALID` (they meant "never expires"). * **Behavior change:** memory items are keyed `#`, so two slots on one scope no longer share items. Run `migrateMemoryKeys(slot)` once to copy items stored under the old bare key. See [Memory](/memory#items-from-before-slot-name-keys). * **Behavior change:** a prompt schedule runs every fire in its own session `schedule--` (`defineSchedule({ sharedSession: true })` keeps one shared session), and a fire that does not finish `'stop'` reports the new `LOUSHO_SCHEDULE_RUN_INCOMPLETE`. * **Behavior change:** channels without a `store` keep transcripts in the agent's `createAgent({ store })` when it has `sessions`. * **Behavior change:** the routes let a session turn run to its end and save it when the client disconnects (`onDisconnect: 'abort'` for the old behavior; `waitUntil` keeps a serverless function alive). An aborted turn keeps the user's input, the tool calls that ran and the partial reply (`metadata: { interrupted: true }`), and its usage counts. * **Behavior change:** tool results are capped at `maxToolResultChars` (default `50_000`, `Infinity` for none) for every tool; MCP image/audio/blob data reaches the model as a placeholder and MCP tool descriptions are cut at 2,000 characters. * **Behavior change:** a user file part the provider cannot send rejects the call with `LOUSHO_UNSUPPORTED_CONTENT` (under fallback the next provider gets it) instead of a text note; `unsupportedFiles: 'text-note'` keeps the note. See [Multimodal input](/providers#multimodal-input). * **Behavior change:** `createAgent({ provider })` with a built-in or `fromAiSdk()` provider and no `retry` wraps it in `withRetry()` (provider's `maxRetries`, default 2), and `withRetry()` is the only retry layer: the built-in providers turn the `ai` SDK's own retries off (`retry: false` used to send 3 requests, `maxRetries: 3` sent 12). * **Behavior change:** a rate-limited call has the code `LOUSHO_PROVIDER_RATE_LIMITED`, and an out-of-credit account (`insufficient_quota`, 402) is the new non-retryable category `quota-exceeded`. * **Behavior change:** `OpenAIProvider` sends `store: false` on the Responses API unless `store: true` is set. * **Behavior change (types):** `LLMProviderConfig` has no index signature, so a misspelled option is a type error; `LLMProviderRegistry.register()` / `create()` take the provider's own config type (new `BuiltinProviderConfigs`). `ExecutionFinishReason` is a closed union, and an unknown provider finish reason is reported as `'other'`. * **Behavior change:** `agent.stream()` rethrows the run's error after the `error` and `run.done` events (`{ throwOnError: false }` for the old behavior). * **Behavior change (CLI):** every usage error exits 2 and 1 means a command ran and failed. `lousho eval`, `build`, `dev`, `chat`, `acp`, `mcp` and `doctor` load `./.env`. The fallback registry URL points at the installed version's release tag. * **Behavior change:** `connectMcp()` rethrows a server's start failure as `McpStartError` (`LOUSHO_MCP_START_FAILED`, with exit code and the last stderr lines) instead of wrapping it. * Evals: `t.maxCostUsd(n)` fails when a run reports no cost (`{ unknownCost: 'skip' }` for the old, now warning, behavior); `judge.model` and `llmJudge()` / `llmCritique()`'s `model` are optional. * Pruned tool results leave a stub naming the call and its arguments (`[pruned: search({"query":"deploy"}) result, 18234 chars; already read]`). * Agent Forge: a run on a real provider without an API key fails with `LOUSHO_PROVIDER_MISSING_API_KEY` (HTTP 422) instead of silently using the mock provider, and the provider pill shows what actually ran. * The examples import `@lousho/build-ai-agent` and its public subpaths instead of the repository's `src/`, so they can be copied into an app as they are. * The `package.json` that `lousho build --target=node-server|docker` writes lists only the optional peers the bundled server loads (the provider package, `@modelcontextprotocol/sdk` with `mcpServers`), no longer `@lousho/build-ai-agent`: `dist/server.js` bundles the SDK, `ai` and `zod`, so the image's install is smaller. * The npm package no longer ships the CommonJS build of the CLI (`dist/cli/*.js` and maps) or the CLI's type declarations: `bin/lousho.js` loads only `dist/cli/*.mjs`. ### Added * Providers: a `timeout` option (default 10 minutes until the response starts and between chunks; `0` or `Infinity` turns it off) and a `fetch` option on every built-in provider. `OpenAIProvider({ api: 'chat' })` and `LOUSHO_OPENAI_API=chat` use Chat Completions for OpenAI-compatible servers (LM Studio, Ollama, vLLM, llama.cpp; the built-in hosted tools are then refused with `LOUSHO_HOSTED_TOOL_UNSUPPORTED`); see [Local and OpenAI-compatible servers](/providers#local-and-openai-compatible-servers). `withRateLimit()` / `createRateLimiter()` and `createAgent({ rateLimit })` queue model calls client-side. `promptCaching: 'auto'` (the default) marks Anthropic cache breakpoints for Claude on `anthropic` and `openrouter`. `withRetry({ idleTimeoutMs })` fails a stream that stalls between chunks (`timeoutMs` now bounds only the wait for the first chunk). `withFallback()` results carry `servedBy`. * Model settings: `createAgent({ modelSettings })` and per-call `modelSettings` (`maxTokens`, `temperature`, `topP`, penalties, `stop`, `seed`, `toolChoice`), range-checked up front, also in `agent.json`. Per-call `maxSteps` and `instructions` on `send()` / `stream()`. See [Configuration](/configuration#model-settings). * Cost: built-in prices for more default models (Ollama `llama3` window, OpenRouter variants), cache read/write prices (`cachedInputCostPerMTok`, `cacheWriteCostPerMTok`, `cacheWriteTokens`) and OpenRouter's reported `usage.cost` for `costUsd` and `limits.maxCostUsd`. * Approvals: `remember: 'session'` and edited `args` on a decision, `'defer'` from an `approve` callback, `approvalTtlMs` / `ttlMs` deadlines (`expiresAt` on events and UI state), `agent.approvals.list()` across processes (new optional `ApprovalStore.list()`), `agent.pending()` for unfinished runs (new optional `CheckpointStore.list()`), and `resumedAfterApproval` on tool-call hook contexts. See [Approvals](/approvals) and [Recovering after a crash](/durable-execution#recovering-after-a-crash). * Routes: `authorizeSession` and `authorizeApproval` on `createRouteHandler()` / `createDeployedServer()`; approvals report their `sessionId` and error events their `code`. Built node and Docker servers drain on SIGTERM (`shutdown({ timeoutMs })`, `LOUSHO_SHUTDOWN_TIMEOUT_MS`). Channel routes decide each call of a multi-call pause and accept a `sessionKey` after a restart. * Flows: durable runs (`checkpointStore` + `runId`, `FlowExecutor.resume()`), pausing for approval (`status: 'awaiting-approval'`), per-node `retry` and `timeoutMs`, cancellation through `signal`, and `usage` on the result. See [Flows](/flows). * Structured output: partial-object streaming (`object.delta`, `partialObject` in the UI bindings), `result.outputRepaired`, `{ schema, promptSchema: false }`, a forced no-tools answer when `maxSteps` or `limits.maxSteps` runs out, and `outputError.kind: 'truncated'`. Unions, records, tuples and optional fields now work on strict endpoints. See [Structured output](/structured-output). * Memory: `expose: { forget: true }`, `itemSchema` / `itemKey` upserts (new optional `MemoryProvider.upsert()`), `memoryKey()`, de-duplicated `add()`, and `migrateMemoryKeys()`. See [Memory](/memory#correcting-memory). * MCP: `optional: true`, `cwd`, `stderr`, `connectTimeoutMs`, `timeoutMs`, `tools: { include, exclude }`, per-call `approval({ args })`, `onToolsChanged` and `McpConnections.reconnect()`. See [MCP](/mcp-integration). * Observability: `invoke_agent` spans carry the request model and token totals (`agentUsageTotals: false` leaves them off), `legacyAttributes: false`, `parentSpanId` on `send()` / `stream()`, retry attributes on `chat` spans, sub-agent names on their spans, and compaction calls as counted `chat` spans. See [Observability](/observability). * Tools and sub-agents: `defineTool({ timeoutMs })`, `subagentOptions: { background: false }`, `read_skill_file` for skill folders, `ToolDescriptor.transient`, `CommandRule` objects for the shell tool's `allow` / `deny`, and an optional `ShellProvider.shell` that tells the model which shell runs its commands. * Compaction: `reserveOutputTokens` and `onCompaction` on the object form; `GenerateHookContext.generate()` for counted side calls. * Evals: sub-agent tool calls in trajectory evals, `SendOptions.onEvent`, `repeat` / `passAt`, `{ agent }` for `setProviderInterceptor` callbacks, `lousho eval --timeout` and `defineEval({ timeoutMs })`, and `recordReplay({ rerecordHint })`. See [Evals](/evals). * Channels and schedules: `githubChannel({ pullRequestOpened: true })`, `webhookChannel({ principal })`, exported `fireSchedule()`, `startSchedules({ keepAlive: true })` and an awaitable `stop()`, and `mountFetchChannels({ waitUntil })`. * CLI: `lousho doctor --ping` and base URLs in its output, and `lousho --version`. * `lousho build --target=node-server|docker` builds a `.ts`/`.js` agent module (the file `lousho dev src/agent.ts` serves: a default or `agent` export holding `createAgent()` options or a built agent). The `cloudflare-worker` target says it builds only a spec or an agent directory. The minimal and tools scaffolds export their options as `src/agent.ts`'s default export, so `lousho dev|chat|mcp|acp|build src/agent.ts` work on a new project, and every scaffold has a `build` script (and `tsup`); the minimal and tools ones also have `dev:ui` (`lousho dev src/agent.ts`). * The Docker image `lousho build --target=docker` writes has a `HEALTHCHECK` on `GET /health`, and its `.dockerignore` keeps `.env`, `.env.*` and `*.tgz` out of the image. * `lousho add`, `studio`, `eval` and `traces --help` describe every flag; `lousho help ` prints that command's help. ### Fixed * Durability: concurrent commits on one session no longer lose a turn (new optional `SessionStore.saveIf()` compare-and-swap; `KVStore` stays best effort), and session objects re-read the store instead of caching the transcript. Concurrent `send({ sessionId })` calls in one process run in order. Parallel tool calls are checkpointed as each settles, so a crash no longer re-runs finished calls. A restarted process that resolves a session's approval commits the turn instead of dropping it. `session.clear()` deletes every turn's checkpoints and history, finished turns stay forkable, `session.fork()` works for 122-128 character ids, and sub-agent task ids are never reused. * Stores: `fileStore` file names are case-safe on Windows and macOS (`Alice` and `alice` no longer share a file), long ids and device names are hashed, older long names are still read, writes are `fsync`ed and Windows `EPERM` / `EBUSY` is retried. A corrupt checkpoint fails with `LOUSHO_CHECKPOINT_CORRUPT` and can be discarded. `SqliteStore` waits 50 ms instead of freezing the event loop, retries locked writes without blocking (`LOUSHO_STORAGE_BUSY`) and `prune()` keeps paused turns. `KVStore` approval claims run a tool once in the common case (`ctx.approval.id` serves as an idempotency key). Image and file bytes survive every store. Taking over a stale file lock is serialized, and `KVCheckpointStore.list()` throws without a `list` binding instead of returning `[]`. `agent.approvals.get()` returns `undefined` for an id that cannot name an approval. `fileMemory` and `sqliteMemory` keep every item with several writers. * OAuth: concurrent `ctx.getToken()` calls share one refresh, and a refused refresh no longer deletes a newer token. * Approvals and memory: a run resumed after an approval or a crash keeps its memory tools and the scope it paused with; memory stays on after a handoff; recall errors skip the slot with a warning; invalid scopes fail with `LOUSHO_MEMORY_INVALID`; keyword recall matches whole words; `` returns scores and metadata. * Providers on `ai` 4, 6 and 7: tool errors are no longer wrapped twice on `ai` 7, malformed or truncated tool-call JSON reaches the model as a repairable `LOUSHO_TOOL_ARGS_INVALID` error (light repairs are tried first; `tool.start` has `rawArgs`), prefixed tool names (`functions.read_file`) resolve, unknown tools suggest the closest name, duplicate tool-call ids both run, a completion sent to a streaming request is replayed, mid-stream and `finish_reason: "error"` failures become retryable 502s, a stream that fails before any output is retried, and unset `temperature` / `maxTokens` are left out. `withFallback()` keeps its state per call, so concurrent calls no longer misreport fallbacks or book cost under the wrong model, and `withRateLimit()` over `withRetry()` streams reports the final attempt and holds its slot until the retried stream ends. * Provider errors: local runtimes' context overflows are `context-length-exceeded`, refused connections name the URL, status and message are read together, nested OpenRouter reasons are shown, a long `Retry-After` fails at once instead of sleeping, negative hints are ignored, and compacted errors keep their category through `createAgent()`. A missing provider package fails with `LOUSHO_PEER_MISSING`, and the routes send its `npm install` command to the client. * Provider features: `reasoning` reaches more models and warns once when it is dropped; image MIME types are sniffed; `OpenRouterProvider` honours `baseURL` and `OllamaProvider` sends `headers` and `apiKey`; `supportsTools()` knows current models; registered providers work in model strings; retired fallback models are replaced; price lookup handles OpenRouter spellings; the forced answer at `maxSteps` works on Claude. * Runs: abort no longer waits on a provider or tool that ignores the signal (`finishReason: 'aborted'`, `result.abortReason`; `mockModel` honours `signal`), aborted streams keep their text, a throwing event listener or trace exporter no longer fails the run, tool results with cycles, BigInts, Maps or Sets serialize, `send(result.messages)` does not repeat the system prompt, `send([])` throws `LOUSHO_CONFIG_INVALID`, a `finish_reason: tool_calls` reply without calls ends `'stop'`, a `load_skill` tool that clashes with `skills` throws, `maxSteps` must be a whole number >= 1, and handoffs work on local models and Anthropic (no mid-conversation system message). * Structured output: the repair and forced-answer prompts stay out of transcripts, near-miss replies (prose, `` blocks, fenced JSON) are accepted without a repair call, a reply that repeats the schema gets a clearer repair message, and unsupported root unions or zod 4 `z.date()` fields throw a `ConfigurationError` naming the fix. `defineTool()` rejects inputs no model can satisfy and accepts `z.preprocess` dates on zod 4.1+. * Compaction: summarizing splits oversized histories, counts tool definitions and the output schema, warns when it falls back to a 128,000-token window for an unknown model, caps `protectedTokens` at half the threshold, retries the summarizer after a rejection, counts its call in usage and budgets, and reports `appliedStrategy`. * Sub-agents and handoffs: dynamic sub-agent config functions get the lead's `sessionId`, `metadata` and `principal`; a failed sub-agent's spend counts; truncated answers say so; agent-directory and Worker `subagents/` run as native sub-agents; a handoff target's `toolSearch` applies. * Schedules: a fixed-hour cron job in a spring-forward gap runs once after the gap instead of being skipped. * Flows: a failed `parallel` branch aborts its siblings, `forEach` variables are per iteration, unnamed steps get unique ids, and `resume()` refuses a changed flow (`LOUSHO_FLOW_CHANGED`). * MCP: `Not connected` counts as a dropped connection and reconnects, `tools/list` follows every page, dropped servers report `'failed'` with their stderr, calls honour abort, text-only results are sent once, tool names are made valid for every provider, object schemas with a sibling `oneOf` keep their shape, `agent.ready()` reconnects after `close()`, and `serveMcp()` over HTTP cancels the run a client cancels or abandons (matched by its new `Mcp-Session-Id`) and rejects its pending approvals. `serveAcp` keeps serving after a malformed JSON line. * Channels: redelivered webhooks run once (Telegram, GitHub, Slack), concurrent decisions on one approval decide it once (`409 LOUSHO_APPROVAL_CONFLICT`, also on the routes), `webhookChannel` authenticates on Fetch hosts and its pause replies carry the prompt, `githubChannel` ignores `/approve` for unknown approvals, and channel `metadata` reaches the run. `POST /ui` answers 400 for an invalid id, and `auth: ''` is `LOUSHO_AUTH_CONFIG_INVALID` on `createRouteHandler()` and `serveFetch()`. * UI bindings (React, Vue, Svelte): remote mode keeps one session across turns, a stream that ends early becomes an error, each step starts a new paragraph, abnormal endings set `status: 'error'`, React gains `reset()`, server error messages are kept, Svelte no longer aborts on a transient subscribe, `formatUsage()` shows small costs, and `parseEventStream()` joins multi-line `data:` fields. * Tracing and OpenTelemetry: open spans are bounded without losing parents, queued background sub-agents stay in the lead's trace, background sub-agent cost rolls up, and `execute_tool` spans carry the error message. * Evals: replay matches calls by request, sub-agent cassettes are named after the agent, cassette names include a hash of the case label, re-recording unchanged exchanges leaves no diff, tool order no longer breaks replay, reasoning and full usage are recorded, judge scores parse more formats (the last `SCORE:` wins) and judges see the user's input at temperature 0, `lousho eval` gives live cases 10 minutes and its reports are never empty when vitest fails. * CLI: `lousho dev` reloads CommonJS tool files and recreated folders, tool files no longer print `MODULE_TYPELESS_PACKAGE_JSON`, TypeScript syntax that type stripping cannot erase gets an error suggesting `--experimental-transform-types`, every agent command loads `tools/*.ts` of an installed project (one ESM build, shared brands, `instanceof` across ESM and CJS copies), `lousho build` reports bundling failures as `LOUSHO_DEPLOY_FAILED`, warns about missing provider packages, merges an existing `wrangler.toml` (new default `compatibility_date` `2026-09-01`), no longer needs unused provider packages for a Worker and prints where it wrote, Docker images run as `node`, `lousho doctor` and `lousho mcp` accept agent directories, `lousho add` checks flags and target directories up front and falls back to GitHub when the default registry is down, `lousho init` templates and Windows instructions are fixed, `init` says when the provider's API key is missing and git-ignores an `--sdk-path` tarball, and help texts and messages are clearer. * Agent directories: every `createAgent()` override passed to `loadAgentDir()` is forwarded, `mock/` models work from every entry point, and import failures throw `LOUSHO_AGENT_DIR_INVALID` with an install hint. `loadSkills()` skips a loose `README.md`. * Workspace tools: `grep` refuses catastrophic regexes and runs with a time budget, `edit_file` refuses non-UTF-8 files instead of corrupting them, and `tool_search` ranks synonyms and word families. * Agent Forge: agents are read from and written to `.lousho/agents/`, narrow screens and keyboard and screen-reader use work, invalid names and imports show errors instead of a blank page, finished runs report `done`, and several drawing and status glitches are fixed. * Packaging and types: ESM consumers get `.d.mts` declarations (plus `typesVersions` for `node10`), declarations type-check with `skipLibCheck: false` on zod 3 and 4, the pi peers are caret ranges, `llms-full.txt` no longer ships and the Worker entry has no source maps. * Docs: corrections throughout (sessions vs runs under one id, schedules, nextjs approvals, memory, `engines.node`), the snippet check now also runs every snippet that builds its own mock provider and checks fences indented in lists, and the ops-pipeline example works as described. * A checkpointed session turn that stalled past its run lease and then failed for good no longer stamps its `lastError` (`retryable: false`) on the checkpoint of the process that took the turn over, which bricked that turn forever with `LOUSHO_SESSION_TURN_FAILED`: the session now holds the turn's run lease itself (handed to the run as `HELD_RUN_LEASE`) and `recordFailure()` writes through `RunLease.save`, whose fenced save writes nothing once the lease is lost. * `agent.send(msg, { sessionId })` and a fresh `agent.session({ id })` no longer run beside a session turn that waits on an approval when its `.paused` marker was never written (a crash or a send between the executor's awaiting-approval checkpoint and `markPaused()`): `pausedSessionTurn` now also scans the store's `.turn-*` checkpoints for `status: 'awaiting-approval'` when the marker is absent, on stores that implement `list()`. * A streamed tool call whose deltas repeat its `id` while omitting `index` (the OpenAI-compatible / Gemini-proxy servers the tool-call id rewrite exists for) stays one call: each repeat no longer mints a `call_1_2`, `call_1_3`, … id that split the arguments JSON across phantom calls none of which parses; the call's slot in the delta's `tool_calls` stands in for the missing `index`. * A non-streamed request whose body carries a nested `"stream": true` (a `providerOptions` passthrough, tool arguments, gateway metadata) no longer has its JSON reply rewritten into an SSE stream the consumer cannot parse: whether a request asked for streaming is read from the parsed top-level `stream` field instead of a regex over the raw body. * Session routes: two concurrent first-touch turns on one unowned session no longer both pass the owner check - the claim is atomic now, so the loser is refused (`403 LOUSHO_SESSION_FORBIDDEN`) instead of streaming a turn inside the winner's session; a claim lost while a turn ran clears what it stored and ends the stream on the same coded error. * Approve-with-edits on a sub-agent's *grouped* approval validates the edited args against the tool's schema like a top-level or single sub-agent decision does: before, `snapshot.subagent` stored `decision.args` raw, so an invalid edit (e.g. a string where `z.number()` wants a number) ran the tool with garbage args; it now throws `LOUSHO_TOOL_ARGS_INVALID` and leaves the approval pending. * Aborting a run during a tool call no longer hangs `send()` / `stream()` / `approvals.resolve()` forever on a tool that ignores its `abortSignal`: the tool batch and the approved-tool wait of `resumeAfterApproval()` are raced against the run's signal the way the model call already was, the run ends `finishReason: 'aborted'` with a 'cancelled' result for calls still running past a short grace window (a result that lands within it is still kept), and the tool keeps running in the background. * A run that re-pauses on several approvals after a resume persists every pending approval id in its checkpoint: `markAwaitingApproval()` wrote only `approvalId`, so durable discovery (`pausedApprovalIds`, `agent.approvals.list()`) named 1 of N approvals; it now carries the group's `approvalIds` like `markGroupPending()` does. * The `inbox-triage` registry kit loads under `lousho dev`, `chat`, `acp`, `mcp` and `doctor`: its relative imports carry the `.ts` extension Node's type stripping needs (only bundlers resolved them). A test now installs every registry kit and loads it in a plain `node` process, as the CLI does. An extensionless relative import in an agent directory gets a hint naming the specifier to write (`'./lib/store.ts'`), and on a Node without built-in type stripping (before 22.18) the `.ts` load error says Node 22.19+ is required. * `OPENAI_BASE_URL` (and `ANTHROPIC_BASE_URL`) is used with `@ai-sdk/openai` 1.x (`@ai-sdk/anthropic` 1.x), the line that pairs with `ai` 4: the provider passes it when no `baseURL` is configured. Before, `model: 'openai/...'` called api.openai.com while `lousho doctor --ping` reported the custom endpoint. * `PropagatingToolError`, `McpToolError` and `WorkspaceError` pass `instanceof` across the ESM and CJS copies of the SDK, so a `.cjs` tool's "do not retry" error stops the run under the ESM `lousho` CLI instead of being handed to the model (and retried). * `lousho dev` re-evaluates the local helper files an edited tool or agent module imports (the `?t=` cache-bust is passed on to relative imports), instead of logging "reloaded" while serving the stale helper; editor temp files no longer show in the "changed:" list. * `lousho eval`: a case that vitest fails after the case finished (it ran past its time limit) is reported as that case failing, not as a pass plus an extra `vitest` row; a run that records no case at all (a mistyped `--tag`) exits 1 like a glob that matches nothing. * The yarn scaffold's `yarn test` works: the templates list `vite`, vitest's peer, which yarn 1 does not install. The README's "Next 3 commands" lists three, and the tools template's README counts its three tools. * CLI messages: a module that exports no agent names the command that loaded it (`lousho chat:`, not `lousho dev:`); `lousho mcp` without a path says a spec, directory or module is accepted, and `--port`/`--host` without `--http` are a usage error instead of ignored; `lousho version ` and `init --sdk-path ` exit 2; the unknown-provider error lists `mock`. ## \[1.0.0-alpha.19] - 2026-10-08 Everything published from 1.0.0-alpha.15 (2026-10-06) through 1.0.0-alpha.19 (alpha.17 on 2026-10-07, alpha.18 and alpha.19 on 2026-10-08). Most of it was prepared on `main` under the version number 1.0.0-rc.0, which was never published. Upgrading from an earlier `1.0.0-alpha.*` release? Read the [upgrade guide](/upgrading): imports moved to subpaths, dead and superseded APIs were removed, and the patch guardrails are now patch checks. What 1.0 considers public is defined there (every `exports` entry and the checked-in `api/` reports). ### Breaking * Flows, the Jira / GitHub / Slack / email tools, encryption, `StorageService` and the validators moved out of the package root. Import them from their subpath: `FlowBuilder`, `FlowExecutor`, `validateFlow` and every flow type (`AgentFlow`, `EditorStep`, `FlowExecutionEvent`,...) from `@lousho/build-ai-agent/flows`; `createJiraTools`, `createGitHubTools`, `createSlackTool`, `slackTool`, `postSlackAlert`, `createEmailTool` and their types from `@lousho/build-ai-agent/integrations`; `EncryptionUtils`, `DTOEncryptionFilter`, `DecryptionError`, `sha256`, `generatePassword`, `StorageService`, `StorageServiceApprovalStore`, `LocalStorageCheckpointStore` and the validators (`validateWithSchema`, `safeValidate`, `isValidEmail`,...) from `@lousho/build-ai-agent/utils`. The integrations are no longer in `@lousho/build-ai-agent/tools` and the flow types are no longer in `@lousho/build-ai-agent/types`. Nothing else changed: the classes and functions are the same. For file-backed stores with `createAgent()`, prefer `fileStore(dir)`. * Removed `AgentType`, `AgentTypeDescriptor`, `AgentConfig.agentType`, `AgentBuilder.setType()` and the agent-type registry (`agentTypesRegistry`, `getAgentTypeDescriptor`, `getAllAgentTypeDescriptors`, `isValidAgentType`, `validateAgentConfig`, `validateAgentTools`). They had no runtime effect since 1.0.0-alpha: delete the `setType(...)` call and the `agentType` field. Also removed, because nothing implemented or used them: the repository interfaces (`IRepository`, `IAgentRepository`, `ISessionRepository`, `IResultRepository`, `SessionData`, `ResultData`, `SDKRepositories`), `DataLoadingStatus`, `PaginationParams`, `PaginatedResponse`, `DeepPartial`, `Timestamped`, `IdEntity`, `AgentExecutionOptions`, `AgentExecutionResult`, `AgentDefinition` and `ToolSetting`. If your code used one of these types, copy its declaration into your project. A checkpoint saved with `agentType` still loads; the field is ignored. * Removed `ExecuteOptions.onEvent` and the `ExecutionEvent` / `ExecutionEventType` types, deprecated in an earlier alpha. Pass `onAgentEvent` to `AgentExecutor.execute()` / `stream()` / `resumeAfterApproval()` instead (with `createAgent()`, `onEvent` already receives `AgentEvent`s and is unchanged). The mapping from the old event names to `AgentEvent` types is in docs/streaming.md#listening-without-iterating ("Migrating from `onEvent` / `ExecutionEvent`"). The one-time "onEvent is deprecated" warning is gone with it. * Removed `createDelegateTool()`, `DelegateAgentOptions`, `DelegateAgentResult` and `DelegationDepthExceededError`. Use the `subagents` option instead, on `createAgent()` or `AgentExecutor.execute()`: create the child with `createAgent({ provider, instructions, description })` and pass `subagents: { billing }`. The model gets one `task` tool for every sub-agent, with the same inheritance (hooks, permissions, abort, usage roll-up) and a depth limit (`maxSubagentDepth`, default `1`). A run at the depth limit is not offered the `task` tool, so a delegation cycle can no longer start; `DelegationDepthExceededError` is gone. See docs/sub-agents.md. * The executor API moved out of the package root to `@lousho/build-ai-agent/executor`: `AgentExecutor`, `AgentBuilder`, `ExecuteOptions`, `resumeAfterApproval`, `streamResumeAfterApproval`, `resumeRequest`, `ResumeExecuteOptions`, `ResumeRequest`, `ToolRegistry` and `globalToolRegistry`. Change `import { AgentExecutor, AgentBuilder } from '@lousho/build-ai-agent'` to `import { AgentExecutor, AgentBuilder } from '@lousho/build-ai-agent/executor'`. `ToolRegistry` is also still in `@lousho/build-ai-agent/tools`. The `@lousho/build-ai-agent/core` subpath is removed: import `AgentBuilder` from `@lousho/build-ai-agent/executor`. `createAgent()`, `ExecutionResult` and every type `createAgent()` uses stay in the root. To move off the executor API entirely, see docs/migrating-to-create-agent.md. * The patch guardrails are now called patch checks, so "guardrail" only means the input/output/tool guardrails of `createAgent({ guardrails })`. Rename: `runGuardrails` to `runPatchChecks`, `Guardrail` to `PatchCheck`, `GuardrailResult` to `PatchCheckResult`, `ProposedAction` to `ProposedPatch`, `RunGuardrailsResult` to `RunPatchChecksResult`, `runGuardrailSafely` to `runPatchCheckSafely`, `secretScanGuardrail` to `secretScanCheck`, `createDiffSizeGuardrail` to `createDiffSizeCheck`, `createCommandGuardrail` to `createCommandCheck` (options type `CommandCheckOptions`), `createTestRunGuardrail` to `createTestRunCheck`, `createLintGuardrail` to `createLintCheck`. Arguments, results and behavior are unchanged. `SECRET_PATTERNS` keeps its name. ### Security * **Behavior change:** MCP tools now ask for approval by default (`approval: 'always'`). A server's own `readOnlyHint` / `destructiveHint` no longer switch approval off or let a tool run in plan mode unless its entry opts in with `approval: 'annotations'` (loaded descriptors then carry `metadata.mcp.annotationsTrusted: false`). An MCP tool whose `__` name equals a local tool's is refused with `LOUSHO_CONFIG_INVALID` instead of silently replacing it and its approval gate. Set `approval: 'annotations'` on servers you trust to keep the old behavior. See [MCP](/mcp-integration#approval-for-mcp-tools). * `openApiTools()` can refuse private destinations: with the new option `privateAddresses: 'refuse'` (default `'allow'`, unchanged), every operation request and the document fetch, each redirect hop included, fails when its host is or resolves to a loopback, link-local, private or reserved address. Host names go through the pinned DNS lookup `http_request` and `web_fetch` use (one resolution per connection, every address checked, the socket connects to the checked address), so DNS rebinding cannot reach a private address; IP literals in every form `URL` normalizes (decimal, octal, hex, IPv4-mapped IPv6) and `localhost` / `*.localhost` are refused when the tools are made. `allowPrivate` lists hosts that may be private. `'refuse'` cannot be combined with a custom `fetch` (it throws a `ConfigurationError`: a custom fetch's connections cannot be pinned) and needs Node.js (`undici`, `node:dns`); where undici cannot load, making the tools throws a `ConfigurationError`. See docs/openapi-tools.md#security-rules. * The registry permission manifest is now enforced when an agent directory loads and runs. `loadAgentDir` re-hashes every file the `lousho-registry.json` receipt lists; an item with edited or missing files is reported `unattested` (a warning naming the item and files, and `manifest.registry` on `resolveAgentDir()`, which also gains the new `Attestation`, `RegistryItemStatus` and `RegistryStatus` types) without refusing the load. Every tool a receipt item owns then runs inside the manifest accepted at install: an `exec: true` or `needsApproval: true` item's tools - and any unattested item's tools - always wait for approval (a tool's own `deny` still denies); `fetch` while the tool runs is limited to the declared `network` hosts; `process.env` shows only the declared `env` names; and a `requiresSandbox` tool gets a narrowed adapter (`run()` refused without `exec`, `writeFile()` without `filesystem: "write"`, a command env of the non-secret base plus the declared variables only). The envelope stays as narrow as the last accepted manifest until `lousho add --overwrite` attests the current files. Limits, unchanged: the guards intercept `fetch` and `process.env`, not `http`/`net`/`axios`, the filesystem, module-scope captures or a container's own network policy; the manifest is still not a sandbox. See docs/registry.md#permission-manifest. ### Changed * The published package is smaller: \~873 files / \~13.5 MiB unpacked / \~3.65 MiB packed, down from \~902 / \~20.8 MiB / \~5.6 MiB. Test-only files that still shipped (`*.test-d.ts` type tests, `*.testkit.ts` helpers, `__snapshots__/` and the test-replay `__cassettes__/` directories) and the Agent Forge server source map no longer ship, and the SDK's own source maps no longer embed `sourcesContent` — their `sources` still resolve to the shipped `src/`, so `--enable-source-maps` stack traces keep working. `pack-smoke` now also runs `build:studio`, so it measures the tarball that is actually published, and it fails when a file `lousho build` or `lousho studio` needs is missing. * Test files are type-checked (`npm run typecheck:tests`, in CI); `pack-smoke` also checks the published declarations with `skipLibCheck: false`. ### Added * Agent directories: new `agent.*` config keys `permissionMode`, `permissions` (a serializable rule form: `tool` is a name, a list or - in a code config - a `RegExp`; `when` narrows the rule with a record of argument names to matchers - a regular expression string or an operator object (`eq`/`ne`, `lt`/`lte`/`gt`/`gte` on `Number(arg)`, `matches`), or a predicate in a code config; `action` is `allow` / `ask` / `deny`), `compaction`, `limits`, plus `hooks` and `approve`, each a path (relative to the directory) to a file default-exporting the hook(s) / the approver function, or the value inline in a code config. `instructions/.md` appends to `instructions.md` when the resolved model id contains `` (e.g. `openai`, `anthropic`). `lousho build --target=node-server` (and `--target=docker`) bundles a root `hooks.*` / `approve.*` file next to the config and resolves the compiled `.js` sibling; the Cloudflare Worker target carries `permissionMode`, `permissions`, `compaction` and `limits` over and accepts `hooks` / `approve` from a code config (a file path is refused, a Worker cannot import it). See [Agent directories](/agent-directories). * Registry: a new item `type` `kit` is a whole agent directory - its `files` may sit at any (still safe) relative path - so `lousho add --dir ` installs `agent.*`, `instructions.md`, tools, skills and sub-agents in one item, under one permission manifest, and records it in `lousho-registry.json` like any other item. The default registry's first kit is `coding-kit`, a port of the `examples/coding-harness` baseline: workspace tools with checkpoint rewind, an allow-listed shell, permission rules, a loop guard and output cap as hooks, a compaction setting, a `fix-failing-test` skill and a read-only `explorer` sub-agent (manifest: `exec`, `fs-write`; install with `lousho add coding-kit --dir --yes --allow exec,fs-write`). See [Registry](/registry). * The `pi` provider: `createAgent({ model: 'pi//' })` routes calls through `@earendil-works/pi-ai` (`1.0.3`, a new optional peer - `npm install @earendil-works/pi-ai@1.0.3`), the engine `pi-coding-agent` runs on, so pi's whole catalog (OpenRouter, Anthropic, OpenAI, Google,...) is usable natively; e.g. `pi/openrouter/openai/gpt-4o-mini` is OpenRouter's `openai/gpt-4o-mini` (nested slashes are kept: the segment after `pi/` is the pi provider, the rest the model id). Messages (including reasoning blocks, tool calls/results and image parts), tool `parameters` (raw JSON Schema, zod 3/4 or Standard Schema), streamed chunks and usage/cost map to the same surface the `ai`-SDK providers expose, and pi's own retries are pinned off so `retry`/`fallbackModels` stay the only retry layer. Credentials are per nested provider: pi reads each one's own env var (`pi/openrouter/...` reads `OPENROUTER_API_KEY`), and a missing one fails the call with `LOUSHO_PROVIDER_MISSING_API_KEY` naming it; a missing package fails with `MissingPeerDependencyError` naming `npm install @earendil-works/pi-ai@1.0.3`. New export `PiProvider` (+ `PiProviderConfig`, whose `models` injects custom/faux catalog entries). `pi` is Node-only: the `cloudflare-worker` target refuses `pi/...` specs. See docs/providers.md#the-pi-provider. * `piAgent()` ("pi-coder"): a Pi coding-agent session (`@earendil-works/pi-coding-agent`, a new optional peer) usable in-process as a `createAgent({ subagents })` entry - `subagents: { coder: piAgent({ cwd, model, description, permissions }) }`. Each `task` call is one Pi session under the task's `sessionId`, so `taskId` resumes and approval resumes reopen the same JSONL transcript even across a restart. `permissions` (the same `PermissionRule`s as `createAgent()`) gate every Pi tool call through a `tool_call` extension handler: `deny` refuses it, `ask` pauses the lead run durably for approval when the caller can pause and refuses it otherwise; approving re-issues that exact call once (Pi has no run-blocked-call API, so a decision prompt asks the model to repeat it and the gate lets that one call through), rejecting sends the note as the next turn. Pi usage rolls into the lead's `result.usage`, and aborts propagate to the Pi session. Node-only: the package is loaded lazily, so a consumer without it still typechecks and `run()` fails with `LOUSHO_PEER_MISSING`. Also new: `defineRemoteSubagent(impl)` wraps any `RemoteSubagent` implementation (same contract as `remoteAgent()`), and `SubagentApprovalPause` is exported for such adapters. * Agent directories: three more `agent.*` config keys wire the approval surface. `approvalTtlMs` is `createAgent`'s run-level default for how long a pause for approval stays decidable (milliseconds); a `permissions` `ask` rule may set `ttlMs`, bounding that rule's pauses and winning over `approvalTtlMs` (both positive finite numbers, rejected otherwise). `store` declares a durable store so paused approvals survive restarts ("draft today, approve tomorrow"): `{ "dir": "./.lousho" }` (resolved against the agent directory, staying inside it) becomes a `fileStore()` - sessions, checkpoints and approvals as JSON files, with `historyLimit` / `tokenKey` also declarable; a code config may set an `AgentStore` instance instead. `store` is Node-only: the Cloudflare Worker target rejects it. `loadAgentDir(dir, { approve: null })` / `{ hooks: null }` strip the directory's wiring (the file the config names is not even imported), where `undefined` keeps it. The generated node-server / docker server keeps a config-declared `store` unless `LOUSHO_STORE` is set. See [Agent directories](/agent-directories). * Agent directories: `"engine": "pi"` in a `subagents//` config makes that directory a `piAgent()` coding sub-agent instead of a nested `createAgent()` one - it lands in the parent's `subagents` map (the `task` tool) and its sessions run in the parent's directory, the workspace both share. Its `model` is the usual `pi//` id and its `permissions` gate the Pi session's own tool calls; the remaining config keys are ignored (Pi carries its own prompt and tools). `loadAgentDir()`'s new `piAgent` override injects `PiAgentOptions` into every such sub-agent (a faux `modelRuntime` in tests, a `sessionDir` for durable approvals); the directory always wins `cwd`, `name` and `description`. Cloudflare Worker targets reject `engine` - Pi needs the Node runtime. The default registry's second kit, `coding-pi`, is `coding-kit` on the full Pi stack: pi-provider lead, dir-declared pi coder, same safety rails (install `--allow exec,fs-write,network,env`). See [Agent directories](/agent-directories#sub-agents). * The `approve` callback may return `'defer'` to abstain: the call stays pending (listed by `agent.approvals.list()`, resolvable by `agent.approvals.resolve()` / `streamResolve()` / `answer()`) and the run surfaces the pause - `send()` resolves with `finishReason: 'awaiting-approval'`, as if the callback had never been asked - so an approver can decide the calls it trusts and hand the rest to a human. Note: `'defer'` is now reserved; an approval note that is exactly `'defer'` defers instead of approving. See [Deciding in code](/approvals#deciding-in-code). * Tool-call hook contexts carry `resumedAfterApproval`: `true` only when `preToolCall` / `postToolCall` fire for the post-approval re-run of a call the run paused on (`agent.approvals.resolve()`, `resumeAfterApproval()`). Stateful hooks (loop guards, audit counters) can skip the re-fire with `if (ctx.resumedAfterApproval) return;`. See [Hooks](/hooks). * New `ToolDescriptor.transient` marks a tool bound per run (a memory slot's `remember_*` / `recall_*` tools, bound to the run's scope keys): it is left out of the agent fingerprint, so an approval-resumed run - whose registry intentionally does not rebind them - no longer reports `tools removed` agent drift on every resolve. * Approvals can expire. `createAgent({ approvalTtlMs })` (validated: positive finite) sets a default deadline for every pause, `send()`/`stream()` take a per-call override, a `permissions` `ask` rule may set `ttlMs` (wins over the run default), and `ExecuteOptions.approvalTtlMs` covers `AgentExecutor.execute()`; sub-agents inherit the lead's TTL unless they set their own. A pending approval past `expiresAt` resolves as denied with `'approval expired'` - enforced at decision time, so a durable-store resume (a restart, another process) can never approve a stale pause - and `list()` keeps expired entries (marked by `expiresAt`) so `resolve()` still continues the run. `approval.requested` events and `UIPendingApproval` carry `expiresAt` for UI countdowns; expiry writes an audited `deny` decision to `onPermissionDecision`. See [Approvals](/approvals). * `llmCritique(config)`: like `llmJudge` but returns `{ score, feedback }` - one judge call, a `SCORE:`/`FEEDBACK:` prompt, graceful degradation on malformed output - so evaluator-optimizer loops get actionable revision guidance, not just a number. See [Evals](/evals). * Testing helpers: `testToolContext(overrides?)` (`@lousho/build-ai-agent/testing`) builds a valid `ToolExecutionContext` for direct `tool.execute()` calls - `getToken`/`requireAuth` fail loudly unless stubbed - and `toolResultText(message)` (root export) unwraps a `tool` message's JSON-encoded content to its result text. * Channels: `ChannelInbound.metadata` now reaches the run as `SendOptions.metadata` (memory scopes and `when` contexts can key on channel-supplied data; it is unauthenticated, unlike `principal`), and `webhookChannel({ principal: (body, req) =>... })` can derive the caller principal from a verified body/header - so a webhook intake (PagerDuty, Grafana) attributes the run to the alerting system. See [Channels](/channels). * New registry kits and examples replicating the most-built agent archetypes: `inbox-triage` (channel intake → classify → draft → approval-gated send, per-sender memory, `@hourly` sweep; install `lousho add inbox-triage --allow env,fs-write`) and `incident-response` (webhook alert → diagnose → risk-tiered approvals → remediate → verify → audit timeline; install `--allow env,fs-write`); examples `deep-research` (orchestrator-workers fan-out with cited synthesis), `support-desk` (triage → `handoff()` to specialist agents), `evaluator-loop` (writer ↔ `llmCritique` revision loop) and `data-analyst` (text-to-SQL with read-only enforcement). Each runs offline on mock models and accepts `OPENROUTER_API_KEY` for a live pass. * `claudeProject(dir)`: loads a repository's Claude-Code-conventional context into options that spread into `createAgent()` — `CLAUDE.md`/`AGENTS.md` + `.claude/rules/*.md` become `instructions`, `.claude/skills/*/SKILL.md` load through `loadSkills()` unchanged, `.claude/agents/*.md` frontmatter becomes `subagents` (frontmatter `model` selects the sub-agent's model; `provider`/`model` options are the fallback), and `.claude/scratchpad/` gets a `scratchpad` note tool (name-validated `*.md` writes). Nothing in the directory executes: markdown is read as data, and `.claude/commands`, `settings*.json` and `mcp.json` are listed on `manifest.ignored` rather than loaded. See [Claude Code projects](/claude-projects). * `decide()`: typed answers from the OpenAI Decisions API (`POST /v1/decisions`, public beta — `gpt-6-luna` only) — `predicate` questions return a probability, `choice` returns a value plus per-option `probabilities` and `confidence`, `score` returns a rubric's probability-weighted level index, and any question may come back a `refusal`. Input is a string or messages with `input_text`/`input_image` (inline base64) parts; `apiKey` (default `OPENAI_API_KEY`), `baseURL`, `timeoutMs`, `signal` and `fetch` are overridable. It is the fast path for typed decisions (route/gate/score) — no agent turn and no generated prose — and does not go through providers or OpenRouter. See [Decisions](/decisions). * Four new examples covering the agentic-patterns catalog: `waves` (Workers · Aggregate · Verify · Extend — bounded parallel workers, deterministic merge, a typed `{ verdict, gaps }` verifier, extension waves seeded by its gaps), `phase-pipeline` (discuss → spec → plan → implement → proof gate → report on `FlowBuilder`/`FlowExecutor`, with the implement↔check retry ladder as a `oneOf` chain), `coding-agent-workflows` (a coding agent routed by a typed decision — `output` schema `{ route, confidence, reason }`, a confidence floor escalating low-confidence calls to the careful path), and `connectors` (MCP connectors à la Claude — runs fully offline against a mock `serveMcp` stdio server exposing `docs_*`/`slack_*` tools; README maps Google Docs, Slack, GitHub and Notion). Plus `hostinger-deploy`: build an agent with `lousho build --target=docker`, ship it to a Hostinger VPS over SSH and smoke-test `/health` + bearer auth + approval pause/resume. See [Prompting and context techniques](/prompting-techniques), [Connectors](/connectors) and [Hostinger VPS](/hostinger-vps). * `lousho add` has a default registry: without `--registry` or `LOUSHO_REGISTRY` it reads `https://registry.lousho.com/index.json` - the `registry/dist/` folder of this repository served as static JSON - instead of failing with "no registry configured; there is no hosted registry yet", so `lousho add --list` works out of the box. It carries five starter items: the tools `open-meteo-weather` (current weather by city through Open-Meteo, no API key) and `github-issues` (list and create issues through `api.github.com`; creating asks for approval), the skills `changelog` and `code-review`, and the `generic-webhook` channel (HMAC-SHA256 signature verification from `WEBHOOK_SECRET`). `--registry` and `LOUSHO_REGISTRY` still override it, and the new value `none` (in either place) disables the registry entirely with the old `LOUSHO_CONFIG_INVALID` "no registry configured" error, for offline or locked-down use. The items live in `registry/` as an `item.json` plus their files per folder; `npm run registry:build` validates each against the registry schemas, the path rules and the M7a code/manifest check, and writes the committed `registry/dist/` (`npm run registry:check` fails when it is stale and runs in CI). See docs/registry.md. ### Fixed * A channel button click (Slack, Discord), inline-keyboard tap (Telegram), Adaptive Card press (Teams) or `/approve` command (GitHub) after a restart is now bound to the conversation's session before the approval is resolved, so its continuation is appended to the session transcript again. The decision names the conversation it came from (`inbound`); with checkpointed sessions (`mountChannels` `store` with `checkpoints`, e.g. a `SqliteStore`) it is accepted only when that session's pending turn waits on exactly that approval id and kind - a decision for another conversation, a replay of a decided one and a decision of the wrong kind are refused (`LOUSHO_APPROVAL_NOT_FOUND`) before the approval is touched, and `onDecision` audits only decisions that pass, so a replayed click cannot run the tool twice and the thread's next message continues the same session. Without a checkpoint store nothing can be checked, so the approval store alone decides, as before. * A channel's function `approvers` works after a restart: `ctx.approval(id)` asked only this process's `agent.approvals.list()`, so a pause the restarted process did not make looked the same as a denial and the function failed closed. `ApprovalStore` gains an optional `load(id)` - `resolve()` without removing the record; every shipped store implements it, and a custom store without it keeps compiling and behaves as before - and the new `agent.approvals.get(id)` reads through it. `ctx.approval` falls back to that, so the function sees the same request (`toolName`, `input`, `sessionId`, `principal`) it saw before the restart and fails closed only when no store knows the id (a process-local approval store still forgets its pauses at a restart). * A hand-written Cloudflare Worker can now bundle `createAgent`. The package root also exports file stores, the agent-directory loader, MCP over stdio and sandboxes, so bundling it for `platform: 'browser'` failed with `Could not resolve "async_hooks"` (and `crypto`, `fs/promises`, `http`, `net`, `stream`) - only the generated Worker ever bundled, because `lousho build --target=cloudflare-worker` redirects those imports to shims itself. `@lousho/build-ai-agent/worker` is a new subpath exporting `createAgent` (and its config types), `memoryStore`, the KV stores (`KVStore`, `KVCheckpointStore`, `CHECKPOINT_KV_BINDING`), the schedule helpers (`defineSchedule`, `handleScheduled`), `serveFetch` (the `/chat` API as a fetch handler), the providers a Worker can run and the whole Worker-safe subset agent-directory code may import. The SDK's own build produces it through the same Node-builtin/sandbox/QuickJS shims the adapter applies (plus new `ollama-ai-provider` and `@modelcontextprotocol/sdk` ones, which an external specifier would leave unresolvable and `mcpServers` therefore fails on first connect - use `lousho build` for MCP on Workers), so the published entry carries no `node:` import for a user's own bundler to fail on; `ai`, `zod`, `@opentelemetry/api` and the `@ai-sdk/*` provider packages stay external specifiers resolved from the user's install. Guarded by `src/deploy/worker.test.ts`; see docs/cloudflare-workers.md#writing-your-own-worker. * The published `.d.ts` files no longer import the types of optional peers: `SubprocessSandboxOptions.dockerOptions` is now the structural `DockerConnectionOptions` (same fields as dockerode's `DockerOptions`, passed through unchanged) and `McpClientLike` is the structural subset of the MCP SDK's `Client` it already was (`listTools` / `callTool`). Before, `dist/index.d.ts` imported `dockerode` and the shared schema chunk imported `@modelcontextprotocol/sdk`, so a consumer compiling with `skipLibCheck: false` without those optional peers installed got TS2307 inside the SDK's own declarations. A dockerode `DockerOptions` or MCP `Client` value still satisfies both types. ## \[1.0.0-alpha.18] - 2026-10-08 See the 1.0.0-alpha.19 section, which covers everything from 1.0.0-alpha.15 through 1.0.0-alpha.19. ## \[1.0.0-alpha.17] - 2026-10-07 See the 1.0.0-alpha.19 section, which covers everything from 1.0.0-alpha.15 through 1.0.0-alpha.19. ## \[1.0.0-alpha.15] - 2026-10-06 See the 1.0.0-alpha.19 section, which covers everything from 1.0.0-alpha.15 through 1.0.0-alpha.19. ## \[1.0.0-alpha.9] - 2026-10-04 ### Security * `http_request` is no longer open to DNS rebinding. It resolved a host name once to check it against the private-address list and then let `fetch` resolve it again to connect, so a name that answered a public address to the check and `127.0.0.1` (or a cloud metadata or intranet address) to the connection reached the private address; the docs and a comment in the Worker runtime described the check as rebinding-safe. Now the one resolution is the check: the in-process path connects through an undici `Agent` whose `connect.lookup` checks every address and hands the socket the address it checked (each redirect hop included), and the sandboxed path (`sandboxExecute`, which agents use) resolves and checks the host in the agent's process and has the sandboxed process connect to that address (node:http/https with a fixed lookup; the host name still goes in `Host` and TLS SNI). The private-address list is now the shared one (below), which adds `0.0.0.0/8`, `100.64.0.0/10`, `192.0.0.0/24`, `198.18.0.0/15`, multicast and reserved `224.0.0.0/3`, `::`, `ff00::/8`, NAT64 `64:ff9b::/96` and 6to4 `2002::/16` to what `http_request` refused. Behavior changes: a host name that cannot be resolved now fails with the resolver's error instead of being passed to `fetch`; `undici` is loaded on the first `http_request` request (not only with `validateSSL: false`). New option `createHttpTool({ allowPrivate })` for hosts that may resolve to private addresses. Cloudflare Workers have no DNS hook, so neither tool exists in the Worker build; docs/deployment.md now says what a Worker can and cannot check. * A run's principal is fixed for the whole run: an approval resolved by another caller, a restart, `agent.resume()` or a session's pending turn continue as the caller that started the run, and the decider is recorded as the approver (`ctx.approval.by`), never swapped in as the run's principal, so a later caller cannot use an earlier caller's grant by approving their pending call. The principal comes only from route auth or a channel's verified sender; the approvals route and channels never read it from a body field. It is frozen before a tool, a policy or a hook sees it. Remote sub-agents are not told who the caller is. ### Changed * Behaviour and types: a tool whose `execute` returns an async iterator that is also async iterable (what an `async function*` returns) is now iterated: its last yielded value is the result, instead of the iterator object itself (which used to reach the model as `{}`). `AgentEvent` gains `tool.partial` (`ToolPartialEvent`), `isToolEvent()` narrows to it too, `UIToolCall` gains `partial?`, the `tool-output-available` UI chunk type gains `preliminary?: true`, and `DefinedTool` gains a third, defaulted type parameter (what `execute` resolves to). Migration: a `switch` over `event.type` meant to be exhaustive needs a `'tool.partial'` case; code using the result of `isToolEvent()` as start/done/error only should check `event.type`; a tool that means to return an async generator as a value should wrap it (`{ items: generator }`) - a value that is only async iterable, such as a `ReadableStream`, is still returned as it is. * Types only: `AgentEvent` gains the `handoff` event (`HandoffEvent`) and `ExecutionResult` the optional `agentName` (set on every result the executor returns: the agent of the final reply). Migration: a `switch` over `event.type` meant to be exhaustive needs a `handoff` case; a test comparing a whole `ExecutionResult` with `toEqual()` now sees `agentName`. Nothing changes at runtime for an agent without `handoffs`. * BREAKING (types only): `McpServerStatus` gains `'needs-auth'`, and `AgentOAuth` (the type of `agent.oauth`) gains `mcpSignInUrl(server)`. Migration: an exhaustive `switch` over `connectMcp(...).status()` values needs a `'needs-auth'` case (only servers with `oauth` ever report it); an object written by hand to satisfy `AgentOAuth` (a test double, say) needs an `mcpSignInUrl` method. Runtime behaviour without `oauth` is unchanged. * BREAKING (types only): `ToolExecutionContext` gains the methods `getToken(provider)` and `requireAuth(provider)`, and `ApprovalKind` gains `'sign-in'`. Migration: code that builds a tool's execute context by hand (for example to call `tool.execute(args, { toolCallId, messages })` in a unit test) must add the two methods or cast (`as ToolExecutionContext`); a `switch` over `PendingApproval.kind` / `approval.requested`'s `kind` that is meant to be exhaustive needs a `'sign-in'` case. Contexts the SDK builds have them already; nothing changes at runtime. * BREAKING: `send()` and `AgentExecutor.execute()` with a listener (`createAgent({ onEvent })`, `onAgentEvent`, or the deprecated `onEvent` option) now stream model calls: a step's text arrives as several `text.delta` events, as in `stream()`, instead of one `text.delta` per step after the step finished. What a listener receives otherwise does not change: the same event types in the same order, exactly one `text.done` per step with the step's whole text, the same `step.done` usage and `run.done`, the same `ExecutionResult` (text, tool calls, usage, steps). A provider without `stream()`, or whose `supportsStreaming(model)` is `false`, still gives one `text.delta` per step. Output guardrails on `send()` still check only the final reply (a `stream()` you iterate checks every step's text, as before). `send()` without a listener still calls `provider.generate()`. Hooks' `ctx.emit` is set whenever the run has listeners (it was already; the docs said "only when streamed"). Migration: concatenate `text.delta`, or listen to `text.done` for whole steps; `execute({ streamModelCalls: false })` (new option, default `true`) restores whole steps. * `MockLLMProvider` / `createMockProvider()`: `stream()` now streams the same step `generate()` returns: the same tool calls (it streamed none, so a streamed mock run never called a tool), finish reason and usage, and text chunks that add up to exactly the generated text (the old chunks ended with an extra space: `'This is a mock response. '`). Runs on the mock provider with a listener (Agent Forge's, for one) therefore give the same results as before, and `stream()` runs on it now call tools as `send()` does. * Agent Forge: runs now stream their model calls, so the run WebSocket (`WS /agents/:id/stream`) carries several `text.delta` events per step; the chat, logs and trace views are unchanged (they use `text.done` and the reconciled messages). Stop is checked between streamed chunks and after the last one, as it was after each `generate()` call. * CI peer matrix: the `typecheck-ai7` and `typecheck-zod4` jobs are replaced by one `peers` job with five entries, each installed for real on top of the default install and run through `tsc`, `test:types`, both builds and `npx vitest run`: `ai4-zod4`, `ai6-zod3`, `ai6-zod4`, `ai7-zod3`, `ai7-zod4`. The two zod 4 entries on `ai` 6/7 also install `ollama-ai-provider-v2` (3.x / 4.x), and the new `src/providers/ollamaV2.contract.test.ts` runs `OllamaProvider` against the real package and a local fake Ollama server (`generate()`, `stream()`, a tool-call turn through `createAgent().send()` and `.stream()`). The `ai-v6` dev alias replaces the hand-made `ai` 6 stand-in in `aiMajorPeers.test.ts`. * `lousho init --provider ollama` now scaffolds `ai@^7.0.0` with `ollama-ai-provider-v2@^4.0.0` and `zod@^4.0.0` (it was `ai@^4.3.19` with `ollama-ai-provider@^1.2.0` and zod 3). Existing projects are not touched; to stay on the old pairing keep `ai@^4.3.19` and `ollama-ai-provider@^1.2.0`. * A run paused inside a sub-agent compares the sub-agent with its current definition on resume, under the lead's `onAgentDrift`. Behaviour change: with `onAgentDrift: 'error'`, `agent.approvals.resolve()` (and `resumeAfterApproval()`) now also rejects with `LOUSHO_AGENT_DRIFT` when a paused sub-agent's instructions, model or tools changed; before, only tool and provider default-model changes of a sub-agent were seen, and always as a warning. This holds at any depth (a sub-agent of a sub-agent uses the lead's mode). With `'warn'` (still the default) the `agent.drift` event carries `subagent`. A rejected resume puts the lead's approval record and its `'awaiting-approval'` checkpoint back, so fixing the sub-agent and resolving again finishes the run; before, a sub-agent's missing-tool error became an error result of the `task` call and the run continued. Migration: none for `'warn'` / `'ignore'`; with `'error'`, resolve approvals paused before a sub-agent changed with the old definition, or use `'warn'`. See docs/durable-execution.md#resuming-with-a-changed-agent. * `lousho add` enforces the permission manifest at install. Behaviour change: an item whose code does not match its manifest is now refused with the new code `LOUSHO_REGISTRY_MANIFEST_MISMATCH`, and nothing is written. Before writing, every JavaScript / TypeScript file of the item is scanned (comments stripped) for `child_process` / `execa` / `shelljs` / `createShellTool` / `SubprocessSandbox` (needs `exec: true`), `fs` imports and `createFsTools` (needs `filesystem: "read"`), file-writing calls (needs `"write"`), `fetch(` and network modules (needs a non-empty `network`), `http(s)://` hosts in strings (each must match a `network` entry) and `process.env.NAME` reads (each must be in `env`); `eval(`, `new Function(`, a computed `process.env[...]` and a non-literal `import()` / `require()` are always refused. Each finding is listed as `:: ()`. `network` entries must now be host patterns and `env` entries variable names (`LOUSHO_REGISTRY_INVALID` otherwise). With `--yes`, an item asking for `exec: true`, `filesystem: "write"`, a `network` or an `env` is refused unless each is named in the new `--allow exec,fs-write,network,env` flag; the interactive prompt lists them, and the printed manifest marks them `[elevated:...]`. After writing, `lousho add` records the item (type, registry, time, permissions, each file's sha256) in `/lousho-registry.json`. Migration: registry authors declare what their code uses; scripts that run `lousho add --yes` add `--allow` with the permissions they accept. It is a static check, not a sandbox; see docs/registry.md#permission-manifest. The example item in docs/registry.md now uses `input` (not `inputSchema`). * Behaviour change: continuing an unfinished checkpointed run (`send(message, { sessionId, principal })`, `AgentExecutor.execute({ sessionId, checkpointStore, principal })`) with a principal other than the one it was saved with now throws `LOUSHO_CONFIG_INVALID` instead of running the new input; without a principal (`agent.resume()`, a session's pending turn) the run continues as the saved caller. Migration: resume such a run without `principal`, or start a new session. `onPermissionDecision` is called with a second argument, `{ principal }` (callbacks that take one argument are unaffected). Checkpoints, approval snapshots and pending approvals now store the run's principal (claims included): treat those stores as personal data. Checkpoints and snapshots written before this release resume with no principal; no migration. ### Added * `lousho dev` and `lousho chat` take `--traces` (or `--traces=`) to write local traces with `fileTraceExporter()`: `.lousho/traces` by default, the directory `lousho traces` reads, for spec files, agent directories and modules (`specToAgent()` now also accepts an `exporter` option). It is opt-in because trace files hold prompt and tool content even with `captureContent: false` (only `redactContent: true` removes it); the command prints where it writes. `lousho init` projects already ignore `.lousho/`. * Code mode: `createAgent({ codeMode: true | CodeModeOptions })` adds a `run_code` tool. The model writes the body of an async JavaScript function that calls the agent's tools as `await tools.(args)`, loops and combines their results, and returns one JSON value; `run_code` returns `{ result, logs, toolCalls }` and only that enters the transcript. Scripts run in a QuickJS WebAssembly isolate from the new optional peer `quickjs-emscripten` (`^0.32.0`, listed by `lousho doctor`; without it the first run of a `codeMode` agent fails with `MissingPeerDependencyError`): a fresh runtime per script, no `require`, `import`, `process`, `fetch`, timers or host objects, values passed as JSON copies, and the limits `timeoutMs` (30,000, whole script including awaited tool calls), `memoryLimitBytes` (64 MiB), `maxToolCalls` (50) and `maxOutputChars` (20,000, return value plus logs). `tools` picks the callable tools (default: all but `run_code`, `ask_question`, sub-agent tools, `tool_search`, `load_skill` and deferred tools); `exclusive: true` takes them off the model's tool list. Every inner call (id `:`) goes through the same gate as a direct call: validation, hooks, permission rules and modes (plan mode refuses non-read-only inner calls; `run_code` itself is read-only), tool guardrails (a block stops the run), `needsApproval`, sandbox routing and the principal. A call that would pause the run (approval, sign-in, a sub-agent's approval) throws inside the script instead and the run does not pause; `run_code` itself can be paused for approval by a rule and runs after the decision. A failed script's error lists its first five tool calls with their (clipped) results. New export: type `CodeModeOptions`. Types: `parentToolCallId?` on the `tool.start`, `tool.partial`, `tool.done` and `tool.error` events (set on inner calls; their `tool.done` / `tool.error` comes before `run_code`'s); inner calls get `execute_tool` spans under the `run_code` span with the attribute `lousho.tool.parent_call_id`. No migration needed: nothing changes without `codeMode`. See docs/code-mode.md. * Streaming partial tool results: a tool's `execute` may be an `async function*`. Every `yield` is a complete snapshot of its output, streamed as the new `tool.partial` event (`{ toolCallId, toolName, output, index }`, `index` from 0) between the call's `tool.start` and `tool.done`; the last yield is the result the model gets (and `tool.done`'s `result`), a `return` value is ignored and a generator that yields nothing has the result `undefined`. Snapshots are not sent to the model, not added to the transcript, not checkpointed and not recorded in trace spans; hooks see only the result; tokens from `ctx.getToken()` are redacted in snapshots like in the result. The main loop, a streamed resume after an approval (`agent.approvals.streamResolve()`) and a flow's tool-call node iterate the generator; aborting the run stops reading it and calls its `return()`; `toolConcurrency` counts the whole generator run. A call that pauses for a sign-in mid-stream runs again from the start (index from 0); partial output is lost on a crash. `defineTool` types it: `ToolOutput` is the yielded type (new helper type `ToolResultOf`). The UI reducer (`useLoushoAgent()` and the Vue / Svelte bindings) keeps the latest snapshot as `toolCalls[i].partial`, and `toUIMessageStream()` sends snapshots as the AI SDK's preliminary tool output (`tool-output-available` with `preliminary: true`). See docs/tools.md#streaming-partial-results. * Tool search: tools marked `deferLoading` (`defineTool({ deferLoading: true })`, or every tool of an MCP server with `mcpServers..deferLoading: true`, also in spec files and agent directories) are withheld from the model, which finds them with a built-in `tool_search` tool (`{ query }` -> `{ loaded: [{ name, description }], more }`); the tools it finds are sent from the next step on, and `tool_search` stops being offered once everything is loaded. New option `toolSearch` on `createAgent()` and `AgentExecutor.execute()`: `{ thresholdPercent? (default 0.1 of the context window; 0 always defers), maxResults? (5), contextWindow?, search? }`, or `false` to send every tool upfront. Below the threshold nothing is deferred and no `tool_search` exists. Which tools are loaded is read from the transcript at each model call, so loading survives crash resume, approvals, forks and session turns; compaction that prunes a `tool_search` result unloads its tools; a handoff target or a sub-agent never inherits the lead's loaded tools. Skills, memory, `task`, `ask_question` and handoff tools are never deferred. New exports: `ToolSearchOptions`, `ToolSearchResult`, `rankToolsByKeywords()`. Types: `ToolDescriptor.deferLoading`, `DefineToolOptions.deferLoading`, `McpStdioServerSpec` / `McpHttpServerSpec.deferLoading`, `LoadMcpToolsOptions.deferLoading`, `SubagentSpec.toolSearch`, `ToolMetadata.mcp.server`. Behaviour change: tools loaded by `loadMcpTools()` / `connectMcp()` now carry their server name in `metadata.mcp.server`; a test comparing an MCP tool's whole `metadata` with `toEqual()` needs that key. A user tool named `tool_search` is refused (`LOUSHO_CONFIG_INVALID`) only when deferral is active. See docs/tool-search.md. * Handoffs: `createAgent({ handoffs: [billing, handoff(refunds, options)] })` lets an agent hand the whole conversation to another `createAgent()` agent (with a `name` and a `description`), offered to the model as one `transfer_to_` tool each. The handoff call is not run as a tool: once the other calls of its step are done, the run goes on in the same `send()` / `stream()` as the target, with its instructions (replacing the system prompt), model, tools, skills, sub-agents, reasoning and own handoffs, the lead's guardrails and permission rules before its own, on the transcript the handoff's `inputFilter` returns (`handoffFilters.removeToolCalls`, `handoffFilters.lastUserMessage`, or your own). Hooks, `limits` and spend, `maxSteps` (counted across agents), the signal, the approval, checkpoint and token stores, listeners, `output`, the permission mode and the principal stay the run's. `handoff()` options: `toolName`, `description`, `input` (validated; bad arguments get a tool error and no handoff), `inputFilter`, `onHandoff`, `isEnabled` (a `PerRun`). Only the first handoff call of a turn is honored; `maxHandoffs` (default 5) caps one run. The transcript marks each handoff with `metadata.handoff = { from, to }`; a session's next turn, `agent.resume()`, `session.resume()` and `agent.approvals.resolve()` continue as the agent it last handed off to (`agent.send()` without a session starts at the lead), and the resume drift check compares with that agent. Approvals remembered with `once()` are dropped at a handoff. A `transfer_to_*` call passes the run's gate like a tool call: `permissions` rules apply to it (`deny` gives the model a `kind: 'denied'` tool error and no handoff happens; `ask` pauses the run for approval and the decided call then hands off or reports the rejection), pre/post tool hooks may deny it or rewrite its arguments, and its decision is audited to `onPermissionDecision` / `permission.decision`. Each handoff appends a routing system note (`metadata.handoff`) carrying the validated call arguments to the transcript the target reads; `handoffFilters.removeToolCalls` keeps it and a custom `inputFilter` may drop or replace it. `createAgent()` warns once when a reachable handoff target was created with run-level options (`approve`, `approvalStore`, `permissionMode`, `approvalTtlMs`, `store`) - a run keeps the entry agent's. New `handoff` event (`from`, `to`, `toolCallId`) and `ExecutionResult.agentName`; `ExecuteOptions.handoffs` / `maxHandoffs` for the executor API. Remote agents cannot be handoff targets. See docs/handoffs.md. * OAuth for HTTP MCP servers, per the MCP authorization spec: an `mcpServers` HTTP entry takes `oauth: { redirectUri, clientId?, clientSecret?, scopes?, clientName? }` (new type `McpOAuthOptions`; refused on stdio entries by the spec schema). The MCP SDK's client does discovery (RFC 9728 protected resource metadata, RFC 8414 authorization server metadata), dynamic client registration (RFC 7591) when no `clientId` is given, PKCE and resource indicators (RFC 8707), and refreshes an expired token; tokens are kept in the agent's encrypted token store as provider `mcp-`, owned by the app. An operator signs the agent in once with the new `agent.oauth.mcpSignInUrl(server)`; N9b's `agent.oauth.complete()` and `GET /oauth/callback` finish it. Without a usable token the server's status is the new `'needs-auth'` and connecting fails with the new error code `LOUSHO_MCP_AUTH_REQUIRED` (a tool call refused mid-run is a tool error with that code; nothing pauses). `connectMcp(servers, { tokens })` takes the token store. Security: `oauth` needs an https `url` (http only for localhost, 127.0.0.1 and \[::1]); an `Authorization` header together with `oauth` is refused; with `oauth`, `headers` go only to the MCP server's origin, never to the authorization server; a stored grant is only used for the `url` it was obtained for (a changed `url` needs a new sign-in); no token, code, verifier or client secret appears in errors, logs, `status()`, events or results. Out of scope for now: per-user MCP grants, a `lousho mcp login` command, OAuth over stdio. See docs/oauth.md, "MCP servers with OAuth". * Parallel input guardrails: `IoGuardrail.runInParallel` (default `false`) makes an `input` guardrail run while the first model call is already in flight, so a slow, model-backed check no longer delays the answer by its whole latency; `promptInjectionGuardrail({ runInParallel })` and `moderationGuardrail({ runInParallel })` take it as an option. The blocking input guardrails run first as before (rewrites included), then the parallel ones start together on that input. Until all have passed, the call's `text.delta`, `reasoning.*` and provider-run tool events are held in order (on the iterator and `onAgentEvent` alike), its tool calls do not run, and `onLLMResponse`, post-generate hooks, output guardrails, `text.done` and the checkpoint after the response wait. A trip aborts the call (its signal only, not the run's), drops the held events and ends the run with `finishReason: 'guardrail'`, `result.guardrail` and `guardrail.tripped` (or `GuardrailError` under `onTripped: 'throw'`), with nothing of the call in the transcript or a checkpoint; the call's usage counts in `result.usage` only when the provider reported it. A parallel check cannot rewrite (`action: 'rewrite'` blocks, the reason prefixed `runInParallel guardrails cannot rewrite: `), and a check that throws fails the run. Ignored on `output` and `tools` guardrails. No change for guardrails without the flag. See docs/guardrails.md#input-and-output-guardrails. * OAuth sign-in for tools: `defineOAuthProvider({ name, displayName?, credentialOwner?, authorizationUrl, tokenUrl, clientId, clientSecret?, clientAuth?, scopes?, redirectUri, authorizationParams?, fetch? })`, and in a tool `ctx.getToken(provider)` and `ctx.requireAuth(provider)`. Without a usable token the run pauses durably like an approval (`finishReason: 'awaiting-approval'`, `kind: 'sign-in'`, `signIn: { provider, displayName?, url }` on the pending approval and the `approval.requested` event); the authorization URL uses PKCE S256 and a single-use 256-bit `state` that expires after 10 minutes, and the verifier is kept only in the encrypted pending record. The new route `GET /oauth/callback` (`lousho dev`, the node server, the Worker) and `GET /oauth/callback` (`createRouteHandler`) is not behind route auth (a browser redirect carries no API token): it calls the new `agent.oauth.complete({ state, code?, error? })`, which stores the token for the paused run's principal and answers a small HTML page (400 for an unknown, used or expired state). Approving the pause (`agent.approvals.resolve({ id, approved: true })`, or the approvals route) runs the tool call again; before the callback it throws `LOUSHO_SIGNIN_PENDING` (HTTP 409) and the run stays paused. Tokens within 60 s of expiry are refreshed; `requireAuth()` deletes a refused token and pauses. A user credential needs a principal (`LOUSHO_OAUTH_PRINCIPAL_REQUIRED`); an app credential (`credentialOwner: 'app'`) is never asked of a chat user (`LOUSHO_OAUTH_APP_SIGNIN_REQUIRED`) and is connected by the operator with `agent.oauth.signInUrl(provider)`. The `approve` callback is never asked about a sign-in. Slack sends the link with `chat.postEphemeral` and Discord as an ephemeral follow-up, both without buttons; GitHub never posts it in a public thread; behind `createDeployedServer()` and `lousho dev` the callback continues the paused channel turn. The UI state (`pendingApproval.signIn`), the `data-lousho-approval` part, `lousho dev`'s page, `lousho chat` and `lousho acp` show the link. A tool result that contains a token handed out during that call is redacted to `[REDACTED]` with a warning. New error codes `LOUSHO_OAUTH_PRINCIPAL_REQUIRED`, `LOUSHO_OAUTH_APP_SIGNIN_REQUIRED`, `LOUSHO_OAUTH_STORE_MISSING`, `LOUSHO_OAUTH_STATE_INVALID`, `LOUSHO_SIGNIN_PENDING`, `LOUSHO_OAUTH_TOKEN_EXCHANGE_FAILED`. See [OAuth](/oauth#tools-that-need-sign-in). * Hosted tools on Anthropic and OpenRouter: `webSearch()` and `codeInterpreter()` now also run on the Anthropic provider (`ai` 6 with `@ai-sdk/anthropic` 3, `ai` 7 with `@ai-sdk/anthropic` 4): `webSearch()` is the newest `tools.webSearch_*()` the installed package exports (options `maxUses`, `allowedDomains`, `blockedDomains`, `userLocation`), `codeInterpreter()` the newest `tools.codeExecution_*()`; events and `usage.hostedToolCalls` keep the neutral name `code_interpreter`. `fileSearch()` on Anthropic, and `codeInterpreter()` / `fileSearch()` on OpenRouter, are `LOUSHO_HOSTED_TOOL_UNSUPPORTED`. `webSearch()` works with every `openrouter/...` model on any `ai` major: the provider adds OpenRouter's `openrouter:web_search` server tool to the request body (after the function tools, composing with the `reasoning` field; `maxUses`, `allowedDomains`, `blockedDomains` and `searchContextSize` map to its parameters) and reports one hosted call per step that searched, read from the response: the cited urls from `url_citation` annotations as `sources`, and `result: { requests }` when OpenRouter reports `usage.server_tool_use.web_search_requests`. OpenRouter bills each search on top of tokens (not in `costUsd`). `supportsHostedTool()` on both providers reflects this. The per-provider table in docs/hosted-tools.md is complete, and a replayable cassette from a live OpenRouter search is in the test suite (`npm run test:live` re-records it with `OPENROUTER_API_KEY`). * Hosted provider tools: `webSearch()`, `codeInterpreter()`, `fileSearch()` and `hostedTool(name, aiSdkTool)` go in `createAgent({ tools })` next to local tools (array or record form; in the record form the key must be the tool's name), and the provider runs them inside the model request. OpenAI maps them to `openai.tools.webSearch()` / `.codeInterpreter()` / `.fileSearch()` on `ai` 6 with `@ai-sdk/openai` 3 and `ai` 7 with `@ai-sdk/openai` 4 (options it does not take, such as `maxUses`, are dropped with one warning); `hostedTool()` passes any AI SDK provider tool through on `ai` 6 or 7, including on `fromAiSdk()` models. Provider-executed calls are never handed to the executor as local tool calls (on `ai` 6/7 they used to come back as calls of an unknown tool): they are reported as `tool.start` / `tool.done` / `tool.error` with the new optional field `executedBy: 'provider'` (as they stream in, or after a non-streamed call in call order; results capped at 20,000 characters of JSON), kept on the step's assistant message as `metadata.hostedToolCalls` (with url sources; not sent back to the model), counted in `usage.hostedToolCalls` and `run.done` usage per tool name (fees not in `costUsd`), and named on the `chat` span as `lousho.hosted_tool_calls`. A step whose only calls the provider ran ends the run even when the provider reports `tool-calls`. No permission rule, guardrail, approval or hook runs for them; instead, in permission mode `'plan'` only the read-only `webSearch()` and `fileSearch()` are sent to the provider (`codeInterpreter()` and `hostedTool()` are left out of the request, read before every model call), and every other mode sends them all. New: `ExecuteOptions.hostedTools`, `GenerateOptions.hostedTools`, `GenerateResult.hostedToolCalls`, `HostedToolCall`, stream chunks `hosted-tool-call` / `hosted-tool-result`, optional `LLMProvider.supportsHostedTool(type)`, `mockModel` turns with `hostedToolCalls`, cassettes that keep hosted calls, error code `LOUSHO_HOSTED_TOOL_UNSUPPORTED` (ai 4, Ollama, a provider without `supportsHostedTool()`, or an `@ai-sdk/openai` without the tool). Sub-agents do not inherit the lead's hosted tools; the agent fingerprint includes them. Anthropic and OpenRouter mappings follow in N1b (below). See docs/hosted-tools.md. * `session.history()` and `session.fork({ fromStep, id?, patch? })` on `agent.session()` sessions. `history()` lists the steps of the committed transcript (one per model response: `step` numbered from 1 across the session, `turn`, `messageIndex`, `text`, and `toolCalls` with `args` and `result`). `fork()` saves the transcript up to and including a step (`0` keeps nothing), optionally with one tool result replaced (`patch.toolResult`), under a new id in the same store (default `-fork-`) and returns a session of the same agent and options (with the permission mode the session has at that moment); the original is not changed. The spend recorded for `limits` up to that point carries over. A turn waiting on an approval or a question, or an interrupted checkpointed turn, stays with the source session, so it is resolved once; workspace files are not rewound. Works on every shipped store (memory, `fileStore()`, `SqliteStore`, `KVStore`), with one contract suite run against each. New types `SessionHistoryStep`, `SessionForkOptions` and `SessionSpawner`; new error codes `LOUSHO_SESSION_STEP_NOT_FOUND`, `LOUSHO_SESSION_EXISTS` and `LOUSHO_SESSION_FORK_UNSUPPORTED`; `AgentSession`'s constructor takes an optional fourth argument, the function that creates its forks. See [Forking a session](/sessions#forking-a-session). * Permission modes: `createAgent({ permissionMode })`, `send()` / `stream()` `{ permissionMode }`, `agent.session({ permissionMode })` and `session.setPermissionMode()` put an agent in `'plan'` (only read-only tools run; everything else is refused with `kind: 'denied'`, also when an `allow` rule matched, and a run that starts in plan mode gets one system-prompt paragraph saying so), `'acceptEdits'` (file edits that would ask run without asking) or `'dontAsk'` (a call that would ask is refused; nothing pauses). The modes are presets applied after hooks, permission rules, tool guardrails and `needsApproval`, so no mode turns a deny into a run. A function mode, and a session's mode, are read at every tool call, so a switch applies from the next call, also mid-turn and in a paused turn continued by `agent.approvals.resolve()`. Sub-agents run under the lead's mode unless it is `'default'`; a remote sub-agent is refused in plan mode. Audit: under a mode other than `'default'` every call is audited, `PermissionDecisionEntry.mode` says when the mode changed the outcome, and the new `onPermissionModeChange` records each switch. New: `PermissionMode` and `PermissionModeChange` types, `defineTool({ editsFiles })` (`metadata.editsFiles`, set on `write_file` and `edit_file`), `readOnlyHint: true` on `load_skill`, `recall_`, `agent_status` and `agent_await`. The mode is not saved in checkpoints. Example: `examples/plan-mode`. See [Permission modes](/permission-modes). * Semantic recall: a memory slot can recall by meaning. New `EmbeddingProvider` (`{ id, embed(texts) }`) and `aiSdkEmbedder(model, { id?, maxBatch? })`, which wraps any AI SDK embedding model through `embedMany` from the installed `ai` (4, 6 or 7; `ai` is loaded on first use, no new dependency), and two providers that rank by cosine similarity: `inMemoryVectorMemory({ embedder, maxItems?, minScore? })` from the root and `sqliteVectorMemory(store, options)` from `@lousho/build-ai-agent/sqlite` (a new `memory_vectors` table, added to an existing database file on open; `memory_items` and `sqliteMemory()` are unchanged). With a query, `list()` returns the scope key's items by score (floor `minScore`, default 0.2; score in `metadata.score`); without one it lists the newest first. Items embedded by another embedder id are skipped when ranking until `provider.reindex(scopeKey?)` re-embeds them. A `MemoryProvider` may set `ranking: 'relevance'`, which makes the `recall_` tool say "by meaning, most relevant first". `hashEmbedder({ dimensions? })` from `@lousho/build-ai-agent/testing` is a deterministic offline embedder for tests. `describeMemoryProviderContract` takes `{ query: 'filter' | 'rank' }`. docs/memory.md has a new `## Semantic recall` section at the end. * `teamsChannel({ appId, appPassword, tenantId?, name?, fetch?, approvers?, onError? })`: an agent behind an Azure Bot in Microsoft Teams, mounted like the other channels (`POST /teams`). The inbound Bot Framework token is verified with the auth module (`oidc()`: RS256, the key set of the fixed Bot Framework OpenID metadata, issuer `https://api.botframework.com`, audience `appId`, 5-minute tolerance) before the body is parsed, and its `serviceurl` claim must equal the activity's `serviceUrl`; replies go only to that service URL (https). Personal chats always reach the agent, group chats and channels only on an `@mention` (stripped from the text); one session per conversation; Markdown replies split at 25,000 characters; tool approvals are Adaptive Cards with Approve and Deny buttons (`approvers` as on the other channels; the card updates to "Approved by..." and names its conversation, so a copied or replayed reference decides nothing); `ask_question` is answered by the next message, also after a restart with durable stores. No `node:*` import. Telegram and GitHub now set the sender as the run's `principal` too (`authenticator: 'telegram'` with the user id, `'github'` with the login), as Slack and Discord do. See docs/channels.md#microsoft-teams. * Cloudflare Worker target builds agent directories (config, instructions, TypeScript tools, skills): `npx lousho build./my-agent --target=cloudflare-worker`. The build reads the directory on Node and generates `agent.module.ts` (static imports of `tools/*` and of an `agent.ts` config; `instructions.md`, a JSON/YAML config and the skills embedded as JSON), and the Worker builds the agent with the rules of `resolveAgentDir()` (the config check and the tool-export rules now live in Node-free modules both use). The config's `model` must name a Worker provider, or `agent.ts` sets a `provider` instance. `subagents/`, `schedules/`, `channels/`, `memory/`, `projectInstructions` and an unsupported provider are rejected with `LOUSHO_DEPLOY_FAILED`, naming what to remove. In a Worker build, an agent file's `@lousho/build-ai-agent` import resolves to a Worker-safe subset (`defineTool`, `defineSkill`, the Worker providers, `fromAiSdk`, errors,...); another name fails the build with the list. The leak check now names the file that imported a Node builtin (`"node:fs" imported by my-agent/tools/files.ts`) instead of failing with esbuild's "Could not resolve". Tools run in the Worker's isolate with its rights: see docs/cloudflare-workers.md#build-and-deploy. * Workspace rewind: `createFsTools(fs, { checkpoints })` with a new `WorkspaceCheckpoints` backs up every file `write_file` and `edit_file` change (content and, where the provider has the new optional `FsProvider.getMode` / `chmod`, permission bits), grouped by session and turn. `checkpoints.list({ sessionId })` shows the changed paths per turn; `checkpoints.rewind(toTurn, { sessionId, dryRun, force })` restores every file changed in that turn or later, deletes files the agent created, and skips files changed since by something else (`'changed-since'`), files over `maxFileBytes` (`'too-large'`) and paths that are no longer files. Every path is checked before anything is written, and restores go through the provider, so `NodeWorkspace` keeps them inside its root. Retention: the newest `maxTurns` turns per session (default 20), the old content only for a turn's first write of a path, rewound turns dropped, `clear({ sessionId })`. Stores: `MemoryWorkspaceCheckpointStore` (default) and `FileWorkspaceCheckpointStore(dir)` (one JSON file per session, atomic writes). `NodeWorkspace` gains `getMode()` and `chmod()`. Without `checkpoints` the tools behave as before. See [Workspace tools](/workspace-tools#the-tools). * Route auth and principals: a new subpath, `@lousho/build-ai-agent/auth`, with `jwt()`, `oidc()`, `basic()`, `apiToken()`, `anonymous()` and `routeAuth()`. A route takes an ordered list of entries: the first one that returns a `Principal` accepts the request, `null` skips to the next, `AuthError(401 | 403)` stops; when every entry skips the answer is a generic `401` with one `WWW-Authenticate` header per challenge (`Bearer`, `Basic realm="..."`), and the body never says which check failed. JWTs are verified with Web Crypto only (no new dependency, no `node:` import, so the helpers run on Workers and edge routes): `alg: none`, algorithms outside the configured list, a `crit` header and algorithm confusion (an HS token checked against a public key) are refused; `exp` is required, `nbf` / `iat` honored, `iss` / `aud` checked, with a clock tolerance of 60 s (at most 300); JWKS key sets are cached for 10 minutes and refetched at most once per 30 s, and a key-set URL is never taken from a token. `oidc()` uses the configured issuer's discovery document and requires it to name the same issuer. `basic()` and `apiToken()` compare in constant time. `createRouteHandler({ auth })` and `createDeployedServer(agent, { auth })` take the list (a token string and a boolean function work as before); the node server appends `apiToken(LOUSHO_API_TOKEN)` when that variable is set; an agent directory's `auth.ts` is bundled by `lousho build --target=node-server` / `docker` and guards the built server (`resolveAgentDir()` returns it as `auth`, the manifest has `auth: true`). The accepted principal reaches the run: `send()` / `stream()` / session turns take `principal`, and `RunConfigContext` (the `model` / `instructions` / `tools` functions) and `MemoryScopeContext` (memory scopes) carry it; Slack and Discord channels set the sender as the principal. `Principal` is also exported from the root. Bad helper options throw the new error code `LOUSHO_AUTH_CONFIG_INVALID` when the helper is created. `createRouteHandler()` without `auth` logs one warning when `NODE_ENV` is `production`. Route auth does not check session ownership, the principal does not reach tools or approval policies yet, and the Cloudflare Worker target keeps the `LOUSHO_API_TOKEN` token only. See [Route auth and principals](/auth). * OAuth token storage: `AgentStore` gains a fourth optional part, `tokens` (`OAuthTokenStore`: `get` / `set` / `delete` / `list` per provider and credential owner, single-use pending sign-ins with `putPending` / `takePending`, and `getClient` / `setClient` for dynamically registered clients). Owners are `{ owner: 'app' }` or `{ owner: 'user', principalId, issuer? }`; `tokenStoreKey(provider, owner)` is the stable, percent-encoded record key (`|app`, `|user||`). `memoryStore()` keeps tokens in process memory; `fileStore(dir)`, `SqliteStore` and `KVStore` encrypt every record with AES-256-GCM (Web Crypto, a fresh 12-byte IV per write, the record key as additional authenticated data) under a 32-byte key the application supplies as `tokenKey` or `LOUSHO_TOKEN_KEY`; there is no default key. Several keys (newest first, or comma-separated in the variable) rotate: writes use the first, reads try each. New error codes `LOUSHO_TOKEN_KEY_MISSING` (first write, or a read of an existing record, without a key) and `LOUSHO_TOKEN_DECRYPT_FAILED` (wrong key or a changed record; names the provider, never the token). `list()` returns metadata only. `generateTokenKey()` makes a key. SQLite: schema migration 4 adds the `oauth_tokens` and `oauth_pending` tables, and `prune()` also deletes expired pending sign-ins (its result gains `oauthPending`). KV: keys under `oauth/`, pending sign-ins written with an `expirationTtl`; `KVBinding` gains an optional `list()` (used only by `tokens.list()`), and `KVListOptions` / `KVListResult` are exported from `/kv`. The generated Cloudflare Worker passes its `LOUSHO_TOKEN_KEY` secret to `KVStore`. Nothing signs in yet; the sign-in flow follows. New page [OAuth](/oauth); new `## OAuth` section at the end of docs/errors.md. * `githubChannel({ webhookSecret, botName, token?, app?, botLogin?, name?, apiUrl?, fetch?, triggers?, approvers?, onError? })`: an agent that answers GitHub issue, pull-request and review comments. `@` in a comment starts a turn and the reply is a new comment in the same thread (a review thread gets a reply under it; text over 60,000 characters is split); one session per issue or pull request and one per review thread, and later comments in a thread with a session are follow-ups. `X-Hub-Signature-256` is verified over the raw body in constant time before the body is parsed (401 otherwise); the webhook is acknowledged at once. Comments by bots, by the channel's own account and every comment the channel posts (a hidden marker) are ignored, and so are edits, deletions and other events. Replies are posted with `token` (a personal access token, or a function) or as a GitHub App (`app: { appId, privateKey }`: a PKCS#1 or PKCS#8 key signs an RS256 JWT with Web Crypto, exchanged for an installation token that is cached per installation until 5 minutes before it expires; `src/channels/githubAppAuth.ts`, not exported). Approvals are comments: the prompt asks for `/approve ` or `/deny ` (first line only, notes below it), and by default only a commenter whose `author_association` is `OWNER`, `MEMBER` or `COLLABORATOR` may decide, read from the command comment itself; `approvers` (logins, or a function that sees the association in `user.roles`) overrides it, and `triggers` (logins, or a function) restricts who may start a turn or answer a question (default: everyone who can comment, so restrict it on a public repository). A comment is untrusted input to the agent; see the security note in docs/channels.md. An `ask_question` is answered by the next comment in the thread and survives a restart given durable stores. Errors name the call and the HTTP status, never the token. The `LOUSHO_CHANNEL_INVALID` hint, docs/agent-directories.md and docs/errors.md now list `githubChannel()`. New section `## GitHub` at the end of docs/channels.md. * Agent Forge keeps the traces of its runs: every run is written with `fileTraceExporter()` to `.lousho/agents//traces` (the files `lousho traces` reads), and the Trace tab gets a list of the agent's past runs (time, duration, model calls, tokens, cost, status) with a "Live" entry while a run is active; choosing one opens its spans in the waterfall, with span kind and error status. New server routes `GET /agents/:id/traces?limit=N` and `GET /agents/:id/traces/:traceId`; the server reads only inside the agent's trace folder. The live `span` WebSocket messages now carry `kind` and `status` (optional fields of `SpanEvent`). See [Agent Forge](/agent-forge). * A background sub-agent that needs approval pauses the lead when the lead awaits it (`agent_await`), and resumes with `approvals.resolve()` / `resume()`. The lead's result is `awaiting-approval` with the child's call (`subagentPath: ['']`) in `agent.approvals.list()`; the decision runs or rejects the child's call, the child finishes, and the continued lead gets its answer as the `agent_await` result. A child that pauses again, or several awaited tasks that are all paused, pause the lead one approval at a time. The child's usage is added to the lead's, and its conversation is saved under its `taskId`. The paused tasks are kept on the lead's approval record (`SubagentSuspension.background`, plain JSON), never in the tool arguments the model sees. Without `agent_await` nothing changes: the task reports `awaiting-approval` and is not resumable. The `task` and `agent_status` tool descriptions tell the model that a task awaiting approval continues only when it calls `agent_await` on it. Docs: docs/sub-agents.md ("When the lead run ends" and "Continuing a task"). * Guardrail starter set: `piiGuardrail()` (email, phone, credit card with a Luhn check, IBAN with its mod-97 check, US SSN, IPv4; blocks, or rewrites each match to `[]`), `secretsGuardrail()` (private keys; Anthropic, OpenRouter, OpenAI, AWS, GitHub, Slack, Google and Stripe live keys; JWTs; `Authorization: Bearer` tokens; `extraPatterns`; rewrites to `[secret]` by default), `promptInjectionGuardrail()` (phrase, role-marker and Unicode-tag heuristics, plus an optional model check that must answer `SAFE` or `INJECTION: `) and `moderationGuardrail({ model })` (one model call that answers `NONE` or the categories that apply: `hate`, `harassment`, `self-harm`, `sexual`, `sexual-minors`, `violence`, `illicit`). The PII, secret and injection checks are heuristic pattern checks: they catch common cases and miss others, and are not a security boundary. Model-backed checks fail closed on a reply they cannot read. A guardrail result can now carry `info?: GuardrailTripInfo` (`pii`, `secret`, `prompt-injection`, `moderation` or `custom`), copied to `result.guardrail`, `guardrail.tripped` and `guardrail.rewrote`; it holds types, labels and offsets, never the matched text. Agent spec files can name them in `policy.guardrails` as `pii`, `secrets`, `prompt-injection` and `moderation`. New exported types `GuardrailTripInfo`, `PiiType`, `ModerationCategory`. Docs: [Guardrails](/guardrails#input-and-output-guardrails), [Configuration](/configuration#policy-policy), [Stream events](/stream-events). * `telegramChannel({ botToken, secretToken, botUsername?, name?, fetch?, approvers?, onError? })`: an agent behind a Telegram bot, mounted like the Slack and Discord channels (`POST &channels&telegram`). The webhook secret token header (`X-Telegram-Bot-Api-Secret-Token`) is compared in constant time (401 otherwise); updates are acknowledged with `200` and the turn runs after. A private chat is one session per chat; in a group only `&ask`, `&ask@`, an `@` mention or a reply to the bot wakes it, and a forum topic is its own session. Replies are plain text, split at 4096 characters. Tool approvals are an inline keyboard (Approve & Deny) that only `approvers` (default: the user who started the turn) can tap; the tap names its chat, so it works after a restart. An `ask_question` is sent with `force_reply`; a pending one survives a restart given durable stores, like Slack and Discord. The bot token is never put in an error message or log line. `src&channels&channelSupport.ts` gains the internal `secretsEqual()` (constant-time compare with Web Crypto). The `LOUSHO_CHANNEL_INVALID` hint, docs\&agent-directories.md and docs\&errors.md now list `discordChannel()` and `telegramChannel()`. New section `## Telegram` at the end of docs\&channels.md (attachments are not read yet).\&r * `todo.updated` stream event and `useTodos()` (React, Vue) / `loushoTodos()` (Svelte): every successful `todo_write` emits `todo.updated { todos, counts, toolCallId }` right after its `tool.done` (sub-agents too, with `subagent`). The shared UI reducer keeps the list in a new `AgentUIState.todos` (carried across turns, cleared by `reset()`), `useLoushoAgent()` / `loushoAgent()` expose it, `useTodos(agent)` / `loushoTodos(agent)` add counts, the current item and progress (`todoView()` computes the same view framework-free), and `toUIMessageStream()` sends a `data-lousho-todos` part for the AI SDK UI. See [Todos](/react#todos). * Cloudflare Worker target: the `openrouter` provider and the `http` tool (with a required `LOUSHO_HTTP_ALLOW` host allowlist). `openrouter` reads its key from the `OPENROUTER_API_KEY` binding and is `@ai-sdk/openai` pointed at OpenRouter, so its bundle passes the `node:` leak check like `openai`. The Worker's `http` is `http_request` with the Node tool's input, over the platform `fetch()`: since a Worker cannot see or pin where a host name resolves, it reaches only the host names (`api.github.com`) and `*.` wildcards (subdomains only) listed in the comma-separated `LOUSHO_HTTP_ALLOW` binding, refuses IP-address hosts even when listed and any scheme but `http:`/`https:`, checks every redirect hop the same way, and refuses every request when the binding is unset or empty. An invalid entry fails every request with an error naming it. `wrangler.toml` gets a commented `[vars]` `LOUSHO_HTTP_ALLOW` line when the spec lists `http`. `ollama` and `web-fetch` stay unsupported on Workers. Internally, the request, redirect and response logic of `http_request` moved to the Node-free `src/tools/built-in/httpCore.ts`, shared by both tools; the Node tool's behaviour is unchanged. See docs/cloudflare-workers.md#providers-and-tools. * `fromAiSdk(model)`: any AI SDK `LanguageModel` (Google, Bedrock, Azure, Mistral, Gateway,...) as `createAgent({ provider })`. Options `name` (default: the model's `provider` field), `fileMediaTypes`, `replaysReasoning` and `maxRetries` (default 0); exported with `FromAiSdkOptions` from the package root. Throws `LOUSHO_CONFIG_INVALID` for a model id string, for a model built for another `ai` major than the installed one, and for a call with another model id than the wrapped one. See docs/providers.md#any-ai-sdk-model-fromaisdk. * Two ready-made durable stores. `@lousho/build-ai-agent/kv` is a new subpath exporting `KVStore`, `KVCheckpointStore`, `CHECKPOINT_KV_BINDING` and the types `KVStoreOptions`, `KVBinding`, `KVPutOptions`, so a hand-written Cloudflare Worker can use `createAgent({ provider, store: new KVStore(env.AGENT_KV) })`; nothing in its import graph touches `node:*`, so it bundles without shims (`assertSessionId` moved to the Node-free `src/session/sessionId.ts` and is still exported where it was). `fileStore(dir, { historyLimit? })` (root export) is an `AgentStore` of plain JSON files: `sessions/.json`, `checkpoints/.json`, `checkpoint-history/.json` and `approvals/.json`, each written to a temp file and renamed into place, with no lock files and no `StorageService`; resolving an approval claims it with an exclusive create, so of two processes resolving one approval only one gets it. It replaces combining `FileSessionStore`, `LocalStorageCheckpointStore` and `StorageServiceApprovalStore` by hand. See docs/sessions.md#choosing-a-store and docs/deployment.md. * `web_fetch` built-in tool: `webFetchTool` / `createWebFetchTool(options)`, and `web-fetch` in spec files. It `GET`s one public web page and returns `{ url, finalUrl, status, contentType, content, truncated }`: HTML converted to text by a small built-in converter (scripts and styles dropped, link URLs kept, entities decoded), JSON and `text/*` as sent, a note for other types; a 4xx/5xx is returned with its body. Loopback, private and link-local destinations are refused on every hop with the connection pinned to the checked address; redirects (10), bytes read (2 MiB), characters returned (50 000) and time (30 s) are capped; `allowedHosts` / `blockedHosts` are checked before DNS and `allowPrivate` exempts named hosts. Node only. See docs/tools.md#built-in-tools. * `src/security/privateAddress.ts` (internal): `isPrivateAddress()` and `pinnedLookup()`, shared by the credential broker, `http_request` and `web_fetch`. The credential broker uses it instead of its own list, so it also refuses `192.0.0.0/24`, `198.18.0.0/15`, NAT64 and 6to4 addresses. * Slack and Discord: a pending `ask_question` survives a restart. Given durable stores for sessions, checkpoints and approvals, the next message in the Slack thread (or the next `/ask` in the Discord channel) after a restart is still the answer: the turn continues, the reply is posted, and the question, the answer and the reply are appended to the session transcript (before, the answer became a new turn and the question was orphaned). A message in a conversation that waits on a tool approval still does not decide it. New `ChannelContext.pendingQuestion(sessionKey)` for custom channels: the id of the `ask_question` that key's session waits on, also one asked before a restart (it binds the approval to the session, so the continuation is recorded); handed out once until that answer has run. A channel's answer and click continuations now run one at a time with the session's turns. Supporting additions: `Checkpoint.approvalKind`, `PendingTurn.approvalKind` (`session.pending()`) and `SessionAwaitingApprovalError.approvalKind` are `'question'` for a turn paused on an `ask_question`. Slack thread replies that answer a pending question no longer need a saved transcript first (a first turn paused on a question with checkpoints has none yet). See docs/channels.md. * Local traces and `lousho traces`: `createAgent({ exporter, captureContent })` traces every run of the agent (`send()`, `stream()`, session turns, `agent.resume()` and runs continued by `agent.approvals.resolve()`; sub-agents join the lead's trace). The new subpath `@lousho/build-ai-agent/traces` exports `fileTraceExporter({ dir })`, which writes each run as JSON Lines to `//.jsonl` (default `.lousho/traces`; one finished span per line; a write error warns once and never fails the run), and `listTraces()` / `readTrace()` to read them. `npx lousho traces [--dir D] [--limit N] [--json]` lists recent runs with duration, model and tool calls, tokens and cost; `npx lousho traces [--json] [--content]` prints one run as a span tree with duration bars. Projects made by `lousho init` now ignore `.lousho/`. See docs/observability.md#local-traces. * `openApiTools(document, options)`: an OpenAPI 3.0 / 3.1 document (object, JSON or YAML string, or https URL) becomes one typed tool per operation, exported from the root and `./tools`. Names come from `operationId` (or `_`); the input has path, query, header and cookie parameters plus an `application/json` `body`, with `$ref`s resolved; every HTTP response is returned as `{ status, statusText, body }`. GET, HEAD and OPTIONS run and other methods ask for approval by default (`approval`), with read-only / destructive annotations. Options: `baseUrl`, `include`, `exclude`, `prefix`, `headers`, `bearerToken`, `providedArguments` (removed from the model's schema), `timeoutMs`, `maxResponseChars`, `fetch`. The base URL must be https (http only for loopback), credentials never reach events or the transcript, and redirects are followed only on the base origin. Swagger 2.0, multipart bodies and remote `$ref`s are refused. `jsonSchemaToZod(schema, root?)` takes an optional root for `$ref` resolution. New page docs/openapi-tools.md. * Built-in OpenAI, Anthropic and OpenRouter providers send PDF file parts on `ai` 6 and 7 (Anthropic also `text/plain`); `ai` 4 and Ollama keep the text note. A provider subclass opts more media types in with `fileMediaTypes()` (`acceptsFileParts = true` still sends every type). The text-note warning is now once per provider and media type and names the type and the `ai` major. Added `npm run test:live` (`vitest.live.config.ts`) for `*.live.test.ts` files, which the default suite excludes. See docs/providers.md#multimodal-input. * Principals in tools, approval policies and permissions: the run's principal reaches a tool's `execute(args, ctx)` as `ctx.principal`, `needsApproval` functions (`ApprovalCheckContext.principal`), permission rules' `when(args, ctx)` (`PermissionContext.principal`), `preToolCall` / `postToolCall` hooks (`ToolCallHookContext.principal`), `onPermissionDecision`'s new second argument (`PermissionAuditContext`), pending approvals (`PendingApproval.principal`, seen by `approve` and by a channel's `approvers` function as `request.principal`) and in-process sub-agents (`task`, background tasks, `createDelegateTool()`; `remoteAgent()` does not forward it). New `ExecuteOptions.principal`, set by `createAgent()` for `send()`, `stream()`, session turns and channel turns. It is saved in checkpoints (`Checkpoint.principal`) and approval snapshots (`ExecutionSnapshot.principal`), so a static or dynamic run resumed after an approval or a crash acts for the same caller. `agent.approvals.resolve()` / `answer()` / `streamResolve()` / `streamAnswer()` take `{ principal }` (new `ResolveApprovalOptions`) for who decides, and the approved tool sees it as `ctx.approval.by`; `resumeAfterApproval()` takes it as `executeOptions.approver`. The approvals route (`POST /chat/:sessionId/approvals/:id`) passes the caller route auth accepted, and `mountChannels()` the clicking user (`{ id, type: 'user', authenticator: }`) or, for a question answered by a message, its sender. `agent.fork()` keeps the source run's principal; `session.fork()` copies only the conversation. The principal is not added to events, spans or trace files. See [Principals in tools and approvals](/auth#principals-in-tools-and-approvals). ### Deprecated * `SlackTriggerAdapter`, `CronTriggerAdapter` and `WebhookTriggerAdapter` (`@lousho/build-ai-agent/triggers`) are deprecated and will be removed in a future major version. Use `slackChannel()` for Slack (sessions per thread, approval buttons), `defineSchedule()` with `startSchedules()` for cron (or `schedules/` in an agent directory, or cron triggers in a spec file), and `webhookChannel()` for webhooks, each mounted with `mountChannels()`. `verifySlackSignature()`, the `WebhookAuth` helpers, `parseCronExpression()`, `TriggerAdapter` and `TriggerRegistry` are not deprecated. Their options types (`SlackTriggerAdapterOptions`, `CronTriggerAdapterOptions` with `CronIntervalOptions` and `CronExpressionOptions`, `WebhookTriggerAdapterOptions`) and `WebhookTriggerHandle` carry `@deprecated` too. Nothing changes at run time. See docs/triggers.md. ### Fixed * `lousho` CLI surface: `lousho dev`/`lousho chat --traces` now writes traces for a `.ts`/`.js` module that exports an already-built `createAgent()` agent - the module's export could not take the flag's exporter after the fact, so nothing was written. `createAgent()` remembers the config it was built with (readable via the internal `createAgentConfigOf()`), and the loader rebuilds the agent with the flag's exporter (the module's own `exporter` is replaced while the flag is set; a module exporting an agent not made by `createAgent()` prints a warning and keeps its own). A spec path that does not exist (e.g. `lousho chat missing.yaml`) now fails with the coded `LOUSHO_SPEC_NOT_FOUND` instead of a raw ENOENT. `lousho add --dry-run` no longer fails when a target file already exists; it prints the file list with an "(exists - a real install needs --overwrite)" marker and exits 0 (the symlink checks still run). Top-level `lousho --help` lists the implemented flags again: `--no-schedules`/`--traces` on `dev` and `chat`, `eval`'s record/replay/drift/`--config` flags, `init`'s `--sdk-path`, and `build`'s positional form. * A cassette failure keeps its type through `agent.send()`: a replay mismatch (or a missing cassette raised by the provider-interception seam that `lousho eval` installs) was compacted into a generic `CompactedLLMProviderError`, losing the cassette path, the call number and the re-record hint. `CassetteMismatchError` is now an `SDKError` with code `LOUSHO_CASSETTE_INVALID` (docs/errors.md already claimed cassette errors carry it), and the run loop rethrows cassette errors untouched instead of compacting them, so `send()` rejects with the `CassetteMismatchError` itself (`.cassette`, `.callNumber`). `setProviderInterceptor()` and the `ProviderInterceptor` type are now exported from `@lousho/build-ai-agent/testing`, the public entry for the model-boundary seam docs/evals.md describes. * A tool call approved with `agent.approvals.resolve()` (or `resumeAfterApproval()`) now has its `execute_tool` span. It ran before the continued run started, outside any span, so OpenTelemetry and `fileTraceExporter()` showed the continued run's `invoke_agent` and `chat` spans but not the approved tool. The continued run's `invoke_agent` span now opens first and the approved call runs inside it: its `execute_tool` span is a child of it (a `run_code` call's inner calls and a sub-agent the tool starts are children of the tool span), with the same `captureContent` / `redactContent` rules as any tool span, and no span is made for a rejected call. A pause that happens again (sign-in) leaves the `invoke_agent` span with the tool span and no `chat`. * A tool that returns `undefined` (or an async generator that yields nothing) no longer writes a `tool` message with `content: undefined` into the transcript. Its result is now `null` everywhere: the transcript and the next model request carry `"null"`, and `tool.done`, approval resumes, `run_code` and flows see `null`. Providers, session stores and checkpoints get a valid string. * `recordReplay` matches a tool's parameter schema the same way under zod 3 and zod 4. A zod 4 schema was fingerprinted from its JSON Schema (with `$schema`, `required`, `additionalProperties`) while a zod 3 one, and so every recorded cassette, used a fingerprint of the schema itself, so cassettes recorded with zod 3 failed to replay with zod 4 installed (the CI `peers` jobs `ai4-zod4`, `ai6-zod4` and `ai7-zod4` failed on every replay test). Both now give the same fingerprint for the same schema; existing cassettes stay valid and need no re-recording, and a real change to a tool's parameters still fails the match. * Parallel `task` calls now get their `taskId`s, and background tasks start, in call order: allocating an id awaited a SHA-256 digest whose completion order is not fixed, so with `maxConcurrent: 1` the second task could take the slot first (#296, the flaky "queues tasks beyond maxConcurrent" test). * `SubprocessSandbox.run()` no longer fails with HTTP 404 "no such container" for a command that exits at once: the container is `AutoRemove`d, and `wait()` was only called after `start()`, so a fast command could be gone by then (10 of 40 parallel `true` runs failed on Docker Desktop). It now begins `wait({ condition: 'next-exit' })` before `start()`, and removes the container if `start()` itself fails. * The `cloudflare-worker` build's Node-builtin leak check now accepts the runtime-guarded `loadNodeModule("node:module" | "node:dns")` probes that `@ai-sdk/provider-utils` 4 (installed with `ai` 6) uses, as it already did for `ai` 7's `loadBuiltinModule`; without it a Worker bundle built on `ai` 6 failed with "Node builtins leaked" (found by the new `ai6` matrix entries). * `isAgentEvent()` is now exhaustive (the runtime type list is a `Record`, so the compiler rejects a missing type). It had no `agent.drift`, so remote UI clients (`parseEventStream()`, the React, Vue and Svelte bindings) silently dropped that event. * A remote sub-agent's token usage is added to the lead's `result.usage`. It was dropped, so budgets and cost reports undercounted delegation to a `remoteAgent()`. The lead adds what the remote agent reports on its `run.done` event to its totals, to `usage.delegated` and to `byModel['remote:']`, which keeps the remote's own `costUsd` (the lead's `costUsd` is `undefined` when the remote sends none). A remote run that pauses for an approval adds what it spent up to the pause, and the continuation adds only what it spent after it; background remote tasks roll up the same way. A deployment that sends no usage adds nothing. `RemoteRunOptions` has a new optional `onUsage(usage)` callback; `RemoteSubagent.run()` still returns `Promise`, so hand-written ones compile unchanged. See docs/sub-agents.md#remote-sub-agents. * `lousho studio` finds the Agent Forge build that ships inside the installed package when it is run from a project that does not have `apps/agent-forge` (it used to fail with "could not find apps/agent-forge" everywhere except the SDK repo). A project's own `apps/agent-forge` still wins, and `.lousho/` is still created in the directory you ran it from. `--dev` from an installed package still needs the TypeScript source and says so. * Documentation corrections: install commands, CLI command lists, optional peers, durable execution and compaction descriptions, the Status section, reasoning and file-part notes in the providers guide, and the `KVStore` note in the deployment guide now match the code. Ticket ids are gone from user-facing prose. ### Tests * Two more load-sensitive tests fixed. `SandboxShell.test.ts` "passes only set values and allowed host names, never planted secrets" starts two real `node -e` processes and timed out at the 5 s default in 1 of 20 parallel runs; it has a 30 s timeout now (pure slowness, no race: it passes in under a second alone). `backgroundSubagents.test.ts` "reports a sub-agent paused for approval as awaiting-approval" waited a fixed 10 ms for the child's pause and failed in 9 of 20 parallel runs with the task still `running`; the lead now asks `agent_status` again until the task has left `running`. * `http.test.ts` "rejects near the configured timeout" no longer compares real elapsed time to a real timeout (flaky under load, #324): it drives the request timer with a fake clock and checks the request is still pending at 499 ms, rejects with "timed out after 500ms" at 500 ms, and that the connection is closed. * `src/tools/workspace/checkpoints.node.test.ts` "rewinds three turns" has a 30 s timeout: it takes well under a second alone, but timed out at the 5 s default when eight copies of the suite ran at once. The rewind itself is correct; every failure was the timeout. * Live-model cassettes recorded: real gpt-4o-mini runs through OpenRouter are now replayed in CI with no key, for the coding agent in the docs, the Telegram, GitHub and Teams channels, plan mode, `web_fetch`, streaming `send()`, the guardrail starter set, `fromAiSdk()` and PDF/PNG file parts, plus a recorded `lousho traces` fixture. The live files that never ran (`sendStreams`, `guardrailStarterSet`, `fileTraceExporter`, `remoteUsage`) were missing the `import '../providers'` that registers OpenRouter; fixed. New live tests (skipped without a key): ai 6 through OpenRouter and `aiSdkEmbedder()` over OpenRouter embeddings. * `pack-smoke` allows a 16 MiB unpacked tarball (was 14 MiB): `main` was 45 KB under the old cap and permission modes went over it; see #316 for shrinking the package instead. * Sandbox egress and the credential broker are tested against a real Docker Engine: a new CI workflow, "Docker Engine" (`.github/workflows/docker.yml`, on pull requests touching the sandbox, broker or docker deploy adapter, on demand and weekly), runs `npm run test:docker` on GitHub's `ubuntu-latest` runner (rootful Engine 28.0.4). `src/security/sandboxEgress.docker.test.ts` checks nine cases of `SubprocessSandbox({ network: { allow }, broker })` with `curlimages/curl`: an allowed HTTPS host answers through the broker, another host gets a 403, bypassing the proxy and raw IPs have no route, outside names do not resolve, the broker injects a credential the container never sees, a container outside the internal network cannot use the gateway listener, an aborted run and `close()` leave no container or network, and a reused non-internal network is refused. The docker deploy image test (`/health` and `/chat`) moved to `src/deploy/adapters/docker.docker.test.ts` and runs in the same job; it was skipped in CI before and passes there. No product defect was found. `*.docker.test.ts` files are excluded from `npm test` and `npm run test:coverage`; they skip without a daemon unless `LOUSHO_DOCKER_TESTS=1` is set (the workflow sets it), which makes a missing daemon fail. docs/workspace-tools.md says what the job checks; CONTRIBUTING.md mentions `npm run test:docker`. * `src/execution/principal.test.ts` and `principal.test-d.ts`: the principal in tools (send, stream, session and channel turns), policies, rules, hooks and sub-agents (local, background, remote), pause and resume with another approver, a restart, a crash resume on an on-disk `SqliteStore`, old checkpoints and snapshots, forks, the approvals route and channel clicks; frozen in strict mode; absent from events and spans. * Test files can be type-checked: `npm run typecheck:tests` runs `tsc` over `src/` including every `*.test.ts` through the new `tsconfig.tests.json`, and the test files under `src/execution`, `src/context`, `src/session`, `src/subagents`, `src/acp` and `src/testing` now pass it. The check is not in CI yet. One type change: `textOf()` also accepts a message or content whose parts are `readonly`, such as a `mockModel` request snapshot. ### Docs * The `vendor/` model prefix note: every docs page whose snippets hardcode a `provider/model` string now says the prefix chooses the provider and that with OpenRouter the id is `openrouter//` (e.g. `openrouter/openai/gpt-4o-mini`), so `createAgent()` does not throw `LOUSHO_PROVIDER_MISSING_API_KEY` when only `OPENROUTER_API_KEY` is set. Stale model ids refreshed: `anthropic/claude-3-5-haiku-latest` examples are now `anthropic/claude-haiku-4-5`, `'anthropic/claude-3-5-sonnet-latest'` in errors.md is `'anthropic/claude-sonnet-4-5'`, `google('gemini-2.0-flash')` is `google('gemini-2.5-flash')` and the OpenRouter fallback example uses `openrouter/meta-llama/llama-3.3-70b-instruct`. (`anthropic/claude-3-5-sonnet-latest` stays where it documents the `ANTHROPIC_API_KEY` env default, which is still that id in `src/providers/providerSpec.ts`.) * Corrections in [Errors](/errors), [Troubleshooting](/troubleshooting) and [Testing](/testing): not every intentional error is an `SDKError` (the named `Error` subclasses — `PropagatingToolError`, `CassetteMismatchError`, `CronExpressionError`, `DecryptionError` — carry no `code`); the `Find a code by area` table now lists the `Auth` section and every code that has one; `LOUSHO_CASSETTE_INVALID` no longer claims to cover request mismatches (that is `CassetteMismatchError`), and its example uses `webhookChannel()` rather than a `webhookTrigger()` that does not exist; `createAgent()`'s default retry is documented as applying only to model strings and env-resolved models, not a `provider` instance; the file-parts entry now matches providers.md (PDFs and `text/plain` do reach providers on `ai` 6/7); `connectMcp().status()` lists `'needs-auth'`; the `mockModel` `usage` default is "none reported" (the run estimates it) rather than zero; and the request-assertion snippet notes where `temperature` comes from, since `createAgent()` has no `temperature` option. * [Stream events](/stream-events) gains the missing `agent.drift` row (emitted on a resume under `onAgentDrift: 'warn'`, before the continuation's first step) and the fields the table understated: `executedBy: 'provider'` on `tool.start` / `tool.done` / `tool.error` (hosted tools), `replacedByHook` on `tool.done`, `hook` on `permission.decision`, `trigger: 'manual'` on `compaction.start` / `compaction.done`, and the full `usage` shape (`inputTokens` / `outputTokens`, `estimated`, `costUsd`, and `modelCalls` / `hostedToolCalls` on `run.done`). The ordering guarantees now say that a streamed approval continuation reports `agent.drift` and the decided call's `tool.start` / `tool.done` before its first `step.start`, so those events fall outside a step. [Runs](/runs) uses the canonical event names `tool.start` / `tool.done` / `run.done` instead of the deprecated `tool-call` / `tool-result` / `finish`, and [Testing](/testing) no longer claims a `mockModel` turn without `usage` reports zeros (it reports none). * Long-tail corrections across the guides: `lousho eval --config`, `lousho init`'s `InitUsageError` and `-y`, `.cts` among the dev-server module extensions and streamed approval continuations in docs/cli.md; `lousho build --target=...` (not `lousho deploy`) and the public `createRouteHandler().handler` (not the internal `serveFetch()`) in docs/sub-agents.md; `description` as a `createAgent()` option and `createDeployedServer()` as build-internal in docs/agent-directories.md, docs/channels.md and docs/schedules.md; approval continuations on Workers emit `tool.start` / `tool.done`, `deploy-runtime-worker` is bundler-internal and `handleScheduled` needs a store in docs/cloudflare-workers.md; `schedule-` sessions, `checkpoint-history/` and the late-appearing `approvals/` directory in docs/schedules.md and docs/sessions.md; `saveAttachment`/`readAttachment` signatures in docs/utilities.md; 256-bit OAuth state in docs/oauth.md; the `recall_` description's `` in docs/memory.md; `ctx.sessionId` in docs/tools.md; the `throw` flow node in docs/flows.md; the `triggers` spec field in docs/configuration.md; `principal` in a permission rule's `when` in docs/approvals.md; the `'not-a-file'` rewind skip and `NodeWorkspace`'s required `root` in docs/workspace-tools.md; a built-in channel's `name` setting its route in docs/channels.md; the `completed()` 'max-steps' message in docs/evals.md; `formatUsage`'s `$0.0000` in docs/models-and-cost.md; the completed registry static-check list and the leading letter/digit name rule in docs/registry.md; `exporter`, `captureContent` and `redactContent` as `createAgent()` options in docs/migrating-to-create-agent.md; `connectMcp().tools` as a `Record` that combines with array tools in docs/mcp.md; `HookRegistry.size()` as a method in docs/hooks.md; `*.example.com` matching subdomains only in docs/tools.md, docs/workspace-tools.md and docs/registry.md; `resumeAfterApproval()` named as an export, not an executor method, and the fork `divergedAt` comment corrected in docs/durable-execution.md. * New page, [Permission modes](/permission-modes): the modes and their table, which tools are read-only, the `editsFiles` marker, the order of evaluation, switching, the audit log, sub-agents and resume. [Approvals](/approvals) "Permission policies" links to it; [Tools](/tools), [Stream events](/stream-events) and [Sub-agents](/sub-agents) ("What a sub-agent inherits") gained a row each. * New page, [Route auth and principals](/auth): the ordered list and its three outcomes, each helper, 401 / 403 / `WWW-Authenticate`, which helpers run where (Node, Workers, edge), `createRouteHandler`, the node server and `auth.ts`, reading the principal in the run, and security notes (no session ownership check; `basic()` only over HTTPS). docs/errors.md gains a `## Auth` section at the end (`LOUSHO_AUTH_CONFIG_INVALID`). One-paragraph links from deployment.md (`### Auth`), nextjs.md (`## Routes`), memory.md (`## Scopes`), agent-directories.md (layout and deploy), api-overview\.md and a "Route auth" row in the cloudflare-workers.md limits table; no heading of an existing page changed. * New page, [Cloudflare Workers](/cloudflare-workers): a table of what the Worker target supports and what it does not comes first, then build and deploy, bindings, sessions, checkpoints and approvals in KV, scheduled runs, consistency, and bundle size and Node builtins. The Worker sections moved out of docs/deployment.md unchanged (`Bindings, sessions and the API on Workers`, `Cron triggers and handleScheduled` and `Durable execution (pause/resume) on Workers` are gone from it); deployment.md keeps a short `cloudflare-worker` summary that links to the page. Links to `deployment.md#cron-triggers-and-handlescheduled` moved to `cloudflare-workers.md#scheduled-runs`. * Two dead heading links fixed: docs/executor-api.md now links to `tools.md#advanced-toolregistry`, and docs/troubleshooting.md links to `mcp.md#use-mcp-servers-in-an-agent` (the configuration anchor moved with the MCP section). No other link in `docs/` or the README points to a missing heading. docs/approvals.md lists `fileStore(dir)` among the approval stores. * Long pages split; text moved unchanged. New pages: [Runs](/runs), [Models and cost](/models-and-cost), [Stream events](/stream-events), [Queued input and steering](/queue-and-steer). Moved anchors (old to new): `api-overview.md#finish-reasons`, `#cancellation` and `#parallel-tool-calls` to `runs.md` (same anchors); `api-overview.md#models-tokens-and-cost` to `models-and-cost.md#models-and-the-price-table`; `api-overview.md#usage-and-cost-of-a-run` to `models-and-cost.md#usage-and-cost-of-a-run`; `api-overview.md#flow-expressions` to `flows.md#flow-expressions`; `api-overview.md#todo-tools` to `tools.md#todo-tools`; `api-overview.md#tool-errors` to `tools.md#errors`; `api-overview.md#context-compaction` to `compaction.md`; `streaming.md#event-schema-version-1`, `#ordering-guarantees`, `#typescript` and `#versioning` to `stream-events.md` (same anchors); `streaming.md#queued-input` and `#steering` to `queue-and-steer.md` (same anchors); the `AgentExecutor.execute()` options table of `configuration.md` to `executor-api.md#options-of-agentexecutorexecute`. The error codes page gained a "Find a code by area" table; its headings are unchanged. * The API overview, providers, structured output, tools, tracing, guardrails and testing guides teach `createAgent()` first: usage and multimodal examples use it, the testing tool example uses `defineTool()`, and every executor, `AgentBuilder` or `ToolRegistry` snippet sits under an "Advanced" heading that links to [the executor API](/executor-api). They say that `createAgent()` does not take `exporter`, `captureContent` or `sandbox` yet. * New page, [Triggers](/triggers): the `@lousho/build-ai-agent/triggers` adapters, a table that says when to use triggers, channels or schedules, the `TriggerAdapter` interface (with a custom adapter and `TriggerRegistry`), and the webhook, Slack and cron sections that moved from the API overview, unchanged. The API overview now points to it; links to `api-overview.md#triggers`, `#webhook-authentication` and `#slack-request-signatures` moved to `triggers.md`. The missing-`auth` warning of `WebhookTriggerAdapter` and the missing-`signingSecret` warning of `SlackTriggerAdapter` now link to `docs/triggers.md`. * New page docs/migrating-to-create-agent.md: from `AgentBuilder`, `AgentExecutor`, `ToolRegistry` and `resumeAfterApproval()` to `createAgent()`, with a before-and-after example, a mapping table, the `ExecuteOptions` that `createAgent()` does not take yet (with alternatives), the behavior differences and a step-by-step checklist. Linked from docs/executor-api.md and the README docs table. * New page, [Hooks](/hooks): the four hook points, the outcomes of a `preToolCall` / `postToolCall` hook, four type-checked recipes (deny a tool, add a default argument, redact a result, inject context), ordering, and `HookRegistry`. The `HookRegistry` bullet and the `### Hook outcomes` section moved there from the API overview, which now points to it; links to `api-overview.md#hook-outcomes` moved to `hooks.md#hook-outcomes`. * Approvals, sessions, streaming, sub-agents, compaction and skills guides show `createAgent()` first. Executor snippets (`AgentExecutor`, `resumeAfterApproval`, `streamResumeAfterApproval`, `InputQueue`, `HookRegistry`) moved under headings named "Advanced: the executor API" (links to docs/executor-api.md); `approvals` and `streaming` renamed a section to that name. `createAgent({ hooks: [createCompactionHook(...)] })` is the compaction example. Removed a statement from the sub-agents guide that `createAgent()` takes no `approvalStore` (it does, and `agent.approvals` resolves sub-agent pauses). * Stale statements removed: ticket ids in the Agent Forge page, the "removed next minor" promise on `AgentType` in the API overview, and the installation note that read as if every user needs Node 22.19 for the `http` tool (it comes from the `undici@8` dependency, which is loaded only for `validateSSL: false` requests). The `ProposedAction` doc comment no longer refers to an unlanded ticket. * New page docs/mcp.md, "MCP (Model Context Protocol)": using MCP servers in an agent, `connectMcp()`, approval for MCP tools, `loadMcpTools()`, serving an agent with `serveMcp()` and `lousho mcp`, and a Limits list. These sections moved out of docs/configuration.md, which keeps the `mcpServers` spec field and links to the new page; links from approvals, CLI, tools, API overview and the README now point to `mcp.md`. * `docs/durable-execution.md` and `docs/workspace-tools.md` lead with `createAgent()` (`send()` with a `sessionId`, `agent.resume()`, `agent.approvals.resolve()`, `agent.fork()`) instead of `AgentExecutor`. The executor snippets (`execute()` with a `checkpointStore`, `resumeAfterApproval()`, `AgentExecutor.fork()`, the `ToolRegistry` setup) moved under "Advanced: the executor API". In durable-execution the heading "What happens when you call `execute()` again with the same `sessionId`" is now "What happens when you send again with the same `sessionId`". * New page docs/troubleshooting.md: start from the symptom (setup, runs that end without an answer, tools, providers, sandbox and MCP), with cause, fix and a link for each. README docs table, `docs/installation.md` and `docs/errors.md` link to it. * New guide, [Build a coding agent](/build-a-coding-agent): a terminal coding agent in six steps (workspace tools confined to the project, approval before writes and risky commands, a streamed UI that handles the approval pause, a session for follow-up questions, offline tests with `MemoryWorkspace` and `mockModel`), with an offline test of the same agent (`docs/buildACodingAgent.test.ts`; its replay of a recorded real-model run is skipped until the cassette `docs/__cassettes__/build-a-coding-agent.json` is recorded). * The Quick Start is rewritten around `createAgent()`: hello world, a tool, streaming, a session, an approval, an offline test with `mockModel`, a custom provider and spec files, each runnable with no API key (the first excepted). The `AgentBuilder` + `AgentExecutor` section moved to a new page, docs/executor-api.md (with the `ToolRegistry` note); the Quick Start no longer shows either API. * `create-lousho-agent` has a README (its npm page was empty) and `license`, `homepage` and `repository` fields; they appear on npm with its next release. `lousho init --help` no longer says `--sdk-path` is needed until the package is on npm. * docs/auth.md gains a section at its end, `## Principals in tools and approvals` (with `### In a tool`, `### Approval policies and permission rules`, `### Who approved`, `### Pauses, crashes and forks`, `### Sub-agents`, `### What is stored and logged`); its "Reading the principal in the run" and "Security notes" text is updated. One-sentence links, no new headings: docs/approvals.md (`needsApproval` context, `approve`), docs/tools.md (`ctx.principal` and `ctx.approval` rows), docs/permission-modes.md (the audit log) and docs/channels.md (`approvers`). * UI and server examples corrected: docs/ai-sdk-ui.md's client snippet used `useChat({ api: '/api/chat' })`, the `ai` 4 signature, which is invalid for the documented `ai` 6/7 combos — it now uses `useChat({ transport: new DefaultChatTransport({ api }) })` and a note says which `ai` majors are supported (`ai` 4 keeps `useChat({ api })` and returns `input` / `handleInputChange` / `handleSubmit`; `ai` 5 is not in the peer range). docs/nextjs.md's `useChat` snippet has the same version note. The route handlers of ai-sdk-ui.md and nextjs.md opened `agent.session({ id })` per request with no `store`, so a second turn forgot the first: both now pass `store: memoryStore()` and say to use a durable store on serverless; docs/react.md's server section says the same for a per-request session. The `data-lousho-approval` part no longer reads as if `kind` / `question` only mark an `ask_question`: a tool waiting on an OAuth sign-in carries `kind: 'sign-in'` and `signIn: { provider, displayName?, url }`; docs/react.md's `pendingApproval` row says the same. ## \[1.0.0-alpha.8] - 2026-10-02 ### Changed * No `any` left in the SDK's types: `npm run lint` now fails on any ESLint warning, and `no-explicit-any`, `no-unused-vars` and `ban-ts-comment` are errors. Runtime behavior is unchanged; some exported types are stricter, which can be a compile error where code read an `any` value without checking it. What changed, and how to migrate: * Values the SDK cannot know are `unknown` (narrow them, or cast to the shape you expect): `FlowExecutionResult.output` and `.variables`, `FlowExecutionContext.variables` / `session` / `memory`, `GenerateResult.rawResponse`, `StreamChunk.toolResult.result`, extra keys of `LLMProviderConfig`, `AgentConfig.expectedResult` / `events` / `metadata`, `AgentExecutionResult.result`, `ResultData.result`, `SessionData.data`, `JiraTicket.customFields`, the extra keys of `formatZodError()`'s result, `ToolConfiguration.options`, `ToolSetting.options`, `FlowToolSetting.options`, `ToolNode.toolOptions`, `UIComponentNode.componentProps`, `FlowChunkEvent.issues` / `input` / `toolResults[].args` / `componentProps`, and `ToolDefinition.function.parameters`. * `FlowExecutionEvent` is a union discriminated on `type`, with `data` typed per event type (new `FlowExecutionEventOf` and `FlowExecutionEventDataMap`): check `event.type` before reading `event.data` (`if (event.type === 'tool-call') event.data?.tool`). * `AgentConfig.settings` (and `AgentBuilder.setSettings()`) is the new `AgentSettings` (`{ model?: string; [key: string]: unknown }`). * `StorageService.readPlainJSONAttachment()` (and `IStorageService`'s) defaults `T` to `unknown`: pass the type you stored, `readPlainJSONAttachment(key)`. * `OpenRouterProvider.getModelInfo()` returns the new `OpenRouterModel` (`{ id: string; [key: string]: unknown }`), `null` or `undefined`. * `FlowBuilder.addAgent()` / `setAgents()` take `FlowAgentDefinition`; `injectVariables()` and `applyInputTransformation()` take a `FlowDefinitionNode` (`injectVariables()` returns the type it was given); `validateFlowInput()` takes `Record`; `validateAgentTools()` takes `Record`; `createDynamicZodSchemaForInputs()` returns `z.ZodObject>`. * Still accepted as before: `needsApproval` predicates typed for a tool's own arguments, any argument to `formatAxiosError()` and `hasRequiredKeys()`, and anything as `writePlainJSONAttachment()` data. The deprecated `ExecutionEvent` keeps `toolResult.result: any`. * Eval cassettes hook in at the model boundary: `lousho eval --record / --replay / --drift` no longer reassigns `AgentExecutor.execute` at runtime. The run loop routes every model call through a small provider-interception seam (`setProviderInterceptor()` / `interceptProvider()` in `src/providers/interception.ts`; a no-op unless an interceptor is installed), and the eval runner installs one that answers with the per-case `recordReplay()` wrapper. Cassette files, keying, `--drift` output and JUnit reports are unchanged, so existing cassettes keep replaying. Streamed runs and sub-agents inside an eval case are covered by tests. * More errors carry SDK error codes: the plain `Error`s thrown by `src/execution` (`historyLimit`, `toolConcurrency`, a second iteration of an `AgentRun`), the tool registry, the built-in tools, `serveMcp()`, `NodeWorkspace` and the `lousho init` / `studio` / chat helpers are now `SDKError`s with an existing code (`LOUSHO_CONFIG_INVALID` via `ConfigurationError`, `LOUSHO_RUN_ALREADY_ITERATED`, `LOUSHO_APPROVAL_NOT_FOUND`, `LOUSHO_PROVIDER_UNKNOWN`, `LOUSHO_TOOL_EXECUTION_FAILED`). Messages are unchanged; `error.code`, `error.hint` and a `[code] hint (docs)` line (not on the built-in tools' run-time failures, whose message is the model-visible tool result) are added. Code that matched `error.constructor === Error` or `error.name === 'Error'` needs `error instanceof SDKError`. * The remaining user-reachable plain `Error`s carry SDK error codes: agent directories, flows, memory, skills, storage (SQLite), triggers, deploy, evals, record/replay cassettes, `defineTool()`, sub-agent options, channels, sandbox/credential-broker options and the `llm` registry now throw an `SDKError` (message text unchanged, plus the `[code] hint (docs)` line, except for tool results and messages tests assert, which stay as written). New codes: `LOUSHO_TOOL_ARGS_INVALID` (what `ToolArgumentsValidationError` carries now), `LOUSHO_AGENT_DIR_INVALID`, `LOUSHO_SKILL_INVALID`, `LOUSHO_FLOW_INVALID`, `LOUSHO_STORAGE_FAILED`, `LOUSHO_TRIGGER_INVALID`, `LOUSHO_DEPLOY_FAILED`, `LOUSHO_EVALS_INVALID`, `LOUSHO_TEST_FAILED`, `LOUSHO_CASSETTE_INVALID` and `LOUSHO_CHANNEL_REQUEST_FAILED`, all in `docs/errors.md`. Code that matched these errors by `error.constructor === Error` or `error.name === 'Error'` must check `instanceof SDKError` / `error.code` instead. `ToolExecutionError` takes an optional fourth `code` argument. A new test (`src/utils/plainErrors.test.ts`) fails when new SDK code throws a plain `Error`; the 5 that remain are listed there with the reason. * The last two allowlisted plain `Error`s are `SDKError`s: OpenRouter's model-catalog fetch (`LOUSHO_PROVIDER_REQUEST_FAILED`; `getModels()` / `getModelInfo()` still catch it and fall back) and the UI runner's failed `POST` to a remote agent (`LOUSHO_REMOTE_REQUEST_FAILED`, as used by `useLoushoAgent()` and the Vue/Svelte bindings). Both keep their message text exactly (no help line appended), so the UI's error event shows the same message, now with `name: 'SDKError'` instead of `'Error'`. `SDKError` itself moved to `src/utils/sdkError.ts` (re-exported from the same places as before) so the browser entries can throw it without importing `ai`. The allowlist in `src/utils/plainErrors.test.ts` is down to the 3 intended sites. * zod 3 or zod 4: the `zod` peer is now `^3.25.76 || ^4.0.0`. Schemas of either major (including `zod/v4` on zod 3.25 and `zod/v3` on zod 4) work in `defineTool({ input })`, structured `output` and argument validation: zod 4 schemas are sent to the model as `z.toJSONSchema` output (also to `ai` v4, whose own converter only reads zod 3), zod 3 schemas through the `ai` SDK's converter as before, and both are parsed with their own `safeParse`. `defineTool` also takes any Standard Schema that exposes `~standard.jsonSchema`; tool arguments validate with any Standard Schema's `~standard.validate`. The SDK's own schemas (AgentSpec, registry documents, built-in tools, MCP tool conversion) run on whichever major is installed, and issue messages read the same on both (zod 4's `Invalid input: expected string, received undefined` is reported as `Required`). `lousho init` keeps zod 3 for the `ai` 4 scaffold (Ollama), whose packages peer on zod 3. Ollama on `ai` 6/7 (`ollama-ai-provider-v2`, which peers on zod 4) is no longer blocked by the SDK's zod peer. CI runs the type check, build and suite on zod 4 too. Migration: `DefineToolOptions`, `DefinedTool` and `ToolDescriptor.inputSchema` are typed with the new exported `StandardSchemaV1` (parsed type: `InferSchemaOutput`) instead of zod 3's `ZodTypeAny`; code that called zod methods on `descriptor.inputSchema` needs a cast. The `task` tool's error for an unknown sub-agent now reads `Unknown sub-agent "x". Expected one of: 'a', 'b'`. `createAgent({ output })` is still typed with the installed major's `ZodType`. * Slack and Discord approvals are starter-only by default (behaviour change): without `approvers`, only the user who started the turn (the Slack message author, the Discord command's user) can approve or deny a tool call; before, anyone who could see the message could. Migration: pass `approvers: () => true` for the old behaviour, or a list of user ids / a function for a team. Approval button values (Slack) and custom ids (Discord) now carry the starter (`:`), so buttons posted before the upgrade carry no starter and only an `approvers` list or function can approve them. * Package entries share one copy of each module: tsup code splitting is on, so `@lousho/build-ai-agent`, `/hooks`, `/tools`, `/mcp` and the rest import shared chunks instead of each bundling their own copy. `HookRegistry`, `SDKError` and module-level registries are now the same object across entries (they were duplicated, so `instanceof` and singletons could disagree), and `dist/` shrinks from about 25 MB to 8.8 MB. `instanceof SDKError` and `instanceof HookRegistry` also hold between the ESM and CJS copies a mixed-format process loads (a `Symbol.for` brand). See [Installation](/installation#entry-points-share-code-esm-and-cjs). * A crash resume of a dynamic agent restores the run's config: checkpoints now carry `runConfig` (the `ctx` and model the run was resolved with, as approval snapshots already did), and `agent.resume(id)` / `session.resume()` / `send(…, { sessionId })` on an unfinished run use the saved model and re-resolve `tools` and `instructions` with the saved `ctx`, instead of `input: []` and no `metadata`; a model chosen from `metadata` no longer changes mid-run. Static agents are unchanged. * Resumed approved calls must run with the approved arguments (behaviour change): after the pre-tool hooks ran on the resumed call, the arguments are compared (deep, key order ignored) with the approved ones whether a hook returned `{ input }` or changed `ctx.args` in place, and a difference is refused with the existing `kind: 'validation'` error ("approved with different input"); a hook that rebuilds the same arguments in another key order is no longer refused. **Migration:** a hook that rewrites arguments (a redactor, a normalizer) must also run on the original run, so the approval record already holds the rewritten input; a hook added only after the pause, that changes the arguments, now refuses the call. A call whose tool no longer exists after an approval (snapshots with an agent fingerprint) now rejects with `LOUSHO_RESUME_TOOL_MISSING` instead of giving the model a `not-found` tool result; older snapshots keep the old behaviour. * Cancelled and settled tool calls use the shared tool-error shape: a tool call cancelled before it ran (the run was aborted or steered) and a sub-agent call dropped because the lead run was already pausing for another approval now reach the model as `{ error: 'ToolNotRunError', toolName, message, kind: 'not-run' }` with the transcript message flagged `isError`. Before, the result was `{ error: '' }` (and the cancelled one was not flagged `isError`); the text is now under `message`. * A `permissions` `allow` (or `ask`) rule no longer overrides a tool's own `needsApproval` deny (behaviour change): the tool's `needsApproval` is now called for those calls too, and its `'deny'` / `{ deny }` denies the call; an `allow` still skips the tool's ask. A `deny` rule still wins without calling `needsApproval`. Migration: a tool that should run under an `allow` rule must not deny it from `needsApproval`. See docs/approvals.md. * `agent.session()` passes the agent's `createAgent({ compaction })` to the session, so `session.compact()` uses the agent's strategy and sizes unless `agent.session({ compaction })` sets its own. * The `cloudflare-worker` target builds with `ai` v7 installed. With `ai` v7 (`@ai-sdk/openai`/`@ai-sdk/anthropic` v4), `lousho build --target=cloudflare-worker` failed with "Node builtins leaked" (`node:module`, `node:dns`, `node:diagnostics_channel`, `node:async_hooks`). Those are not imports: `ai` v7 and `@ai-sdk/provider-utils` v5 probe them at run time through `process.getBuiltinModule()`, only when running on Node, so the bundle never loads them on Workers. The build's leak check now exempts exactly those four ids, and only as the argument of a `getBuiltinModule`/`loadBuiltinModule` call; any other `node:` reference still fails the build. No `nodejs_compat` flag is needed. The 16 Worker bundle tests that were skipped on `ai` v7 now run on both majors. The bundle is about 3.4 MB raw on v7 (1.7 MB on v4), well under the 64 MiB limit. * BREAKING: sandboxed and workspace commands get an allowlisted environment. Commands run by `NodeWorkspace.exec()` and `SandboxShell` no longer see the host's `process.env`, so `env`/`printenv` cannot reveal API keys or cloud credentials. They get a small base a shell needs (`PATH`, `HOME`/`USERPROFILE`, `TMP`/`TEMP`/`TMPDIR`, `LANG`, `LC_*`, `TERM`, plus `SystemRoot`, `SystemDrive`, `ComSpec`, `PATHEXT`, `WINDIR` on Windows) and what you add. A container (`SubprocessSandbox`) gets only what you add, never the host env. `SandboxShell` gains `env` and `inheritEnv` options; `NodeWorkspaceOptions.inheritEnv` and `SandboxShellOptions.inheritEnv` accept `true`; `SandboxRunOptions` gains `inheritEnv` (`NoopSandbox` keeps passing the host env unless it is `false`, which `SandboxShell` sets). `SubprocessSandbox` gains `network: 'none' | 'default' | { allow: string[] }` (default `'none'`, as before); `{ allow }` is validated and kept on `sandbox.network` but runs with no network until the egress proxy enforces it (fail closed). **Migration:** pass what commands need, either values (`env: { NODE_ENV: 'test' }`) or host names to copy (`inheritEnv: ['CI', 'NODE_OPTIONS']`); to restore the old behavior, pass `inheritEnv: true`. See docs/workspace-tools.md. * Internal: the test suite runs with `ai` v7 installed as `ai`. A test helper (`src/providers/aiMajor.testkit.ts`: `installedAiMajor`, `describeOnAiV4`, `itOnAiV4`) reads the installed major; tests whose behavior is the same on both majors are made major-agnostic, the 66 that only make sense on v4 (v4 message and call shapes, `MockLanguageModelV1`) or on a Worker bundle that still leaks `node:` built-ins on v7 are skipped there. The `typecheck-ai7` CI job now also builds and runs `npx vitest run`. Test and CI change only; no product change. * Internal: the SDK source now type-checks against `ai` v7 (with `@ai-sdk/openai`/`@ai-sdk/anthropic` v4) as well as `ai` v4. `AiSdkProvider` builds its `ai` v4 request with SDK-owned structural types instead of the v4-only `CoreMessage`/`CoreAssistantMessage`/`CoreToolMessage`/`Output` types and no longer calls `ai`'s `tool()` (an identity function on v4); the legacy `.tool` that `defineTool()`/`toolDescriptorFromSchema()` hand-build is cast once at that boundary. No runtime change on v4. A new CI job runs `tsc --noEmit` with `ai@7` installed. The `ai`/`@ai-sdk/*` peer ranges are unchanged for now. * MCP tools without `readOnlyHint` now ask for approval by default (BREAKING for MCP users): `loadMcpTools()`, `connectMcp()` and `createAgent({ mcpServers })` set `needsApproval` from each tool's MCP annotations: `readOnlyHint: true` runs, `destructiveHint` true or absent (the MCP spec default) asks, `destructiveHint: false` runs. Choose per server with the new `approval: 'annotations' | 'always' | 'never' | ({ name, annotations }) => boolean` (`loadMcpTools(client, name, { approval })`, `connectMcp()` server entries, `createAgent({ mcpServers })` and `AgentSpec.mcpServers`, which validates it; a spec file takes the three strings). The descriptor keeps the raw annotations on the new `ToolDescriptor.metadata?.mcp.annotations` (types `McpToolAnnotations`, `ToolMetadata`, `McpApproval`) and uses the annotation `title` as `displayName`. Migration: pass `approval: 'never'` to restore the old behaviour (every MCP tool runs without asking). `createAgent()` agents pause with `finishReason: 'awaiting-approval'`, while a bare `AgentExecutor` without an `approvalStore` now throws for such tools. See docs/configuration.md#mcp-tool-approval-approval. * Small built-in tools are defined with `defineTool` (not breaking): `currentDateTool`, `dayNameTool`, `createEmailTool()`, `createHttpTool()`, `createSlackTool()`, `createDelegateTool()` and the descriptors `loadMcpTools()` builds no longer call `ai`'s `tool()`. They now carry canonical `inputSchema` / `execute` (the legacy `.tool` stays), and the built-ins have the tool names `current_date`, `day_name`, `send_email`, `http_request`, `slack_alert` and `delegate_to_`. Descriptions, schemas, approval and sandbox behaviour and the factory signatures are unchanged. MCP descriptors are built with the new internal `toolDescriptorFromSchema()`, which (unlike `defineTool`) accepts any server-provided name or empty description. `routeFetchThroughSandbox()` reads the canonical `execute` first. * GitHub and Jira tools are defined with `defineTool` (not breaking): the 28 `createGitHubTools()` tools and the 20 `createJiraTools()` tools no longer call `ai`'s `tool()`, so nothing under `src/tools` imports `ai` any more. They now carry canonical `inputSchema` / `execute` (the legacy `.tool` stays). Tool names, descriptions, schemas, the sandbox flags and the factory signatures are unchanged. The GitHub out-of-scope tools (branch/file writes, code search, issue CRUD, merge) are disabled through the canonical `execute` as well as `tool.execute`, so calling one still throws "out of scope" before any HTTP request. `JiraTools.register()` now also routes tools registered with `defineTool` through the sandbox seam. * BREAKING (types only): `Message.content` is widened from `string` to `string | ContentPart[]`. Messages you build keep working unchanged, but code that reads `message.content` as a string (`.length`, `.startsWith()`, a template literal) no longer type-checks. **Migration:** read `textOf(message)` instead, or narrow with `typeof message.content === 'string'`. A custom `LLMProvider` that forwards `content` should handle the parts or send `textOf(message)`. * The execute context is our own type (not breaking): the new exported `ToolExecutionContext` (`{ toolCallId, messages: readonly Message[], abortSignal?, sessionId?, onDelegatedUsage? }`, `Message` from `@lousho/build-ai-agent`) replaces the `ai` SDK's `ToolExecutionOptions` as the type of the second argument of `defineTool()`'s `execute`, `ToolDescriptor.execute`, `DefinedTool.execute` and the third argument of `sandboxExecute`, and is what `buildToolRunContext()` returns; the public types no longer import `ToolExecutionOptions` from `ai`. A function typed for `ai`'s options that only reads `toolCallId` or `abortSignal` stays assignable. `ToolRunContext` is now a deprecated alias of the optional fields of `ToolExecutionContext` (it already existed with `onDelegatedUsage` and `toolCallId`). `AgentExecutionOptions.messages` is typed `Message[]` instead of the `ai` SDK's `CoreMessage[]` (the field is unused by the SDK). The legacy `ToolDescriptor.tool` (`ai` v4 `Tool`) is unchanged. See docs/tools.md#execute-context. * BREAKING (small): `SDKError.code` values are now the `LOUSHO_*` codes: `AGENT_EXECUTION_ERROR` -> `LOUSHO_AGENT_EXECUTION_FAILED`, `TOOL_EXECUTION_ERROR` -> `LOUSHO_TOOL_EXECUTION_FAILED`, `LLM_PROVIDER_ERROR` -> `LOUSHO_PROVIDER_REQUEST_FAILED`, `SESSION_AWAITING_APPROVAL` -> `LOUSHO_SESSION_AWAITING_APPROVAL`, `FLOW_EXECUTION_ERROR` -> `LOUSHO_FLOW_EXECUTION_FAILED`, `CONFIGURATION_ERROR` -> `LOUSHO_CONFIG_INVALID`, `VALIDATION_ERROR` -> `LOUSHO_VALIDATION_FAILED`, `TIMEOUT_ERROR` -> `LOUSHO_OPERATION_TIMEOUT`, `RATE_LIMIT_ERROR` -> `LOUSHO_PROVIDER_RATE_LIMITED`; `code` is no longer optional (`new SDKError(message)` gets `LOUSHO_GENERIC_ERROR`). No class was renamed. The messages of `SDKError`s other than `ToolExecutionError`, `LLMProviderError`, `TimeoutError` and `RateLimitError` gain a second line with the code, hint and docs link, and `loadSpec()` now rejects a spec whose top-level field is a likely typo of a spec field (`LOUSHO_SPEC_UNKNOWN_FIELD`; other unknown fields are still ignored). **Migration:** compare `code` against the new values (or use `instanceof`); match messages with `toContain` / a regex, or compare `error.detail`, instead of exact equality. * BREAKING (small): tool failures that did not go through the thrown-error path now use the shared shape, so `error` is an error name instead of a message. Before: an unknown tool or a missing registry gave `{ error: "Tool 'x' not found" }` / `{ error: 'No tool registry available' }`; a rejected approval gave `{ error: 'Tool execution was rejected by the reviewer', note }`; a tool that threw after an approval gave `{ error: '' }`; a call left without a result on resume gave `{ error: 'Tool call was not run: ...' }`. Now each is `{ error: ' | ToolNotRunError>', toolName, message: '', kind, ... }`. A tool approved but missing from the registry on resume used to reject `resumeAfterApproval()` with `Tool 'x' not found in registry`; it now resolves with a `not-found` error result. The sandbox guard throws `SandboxRequiredError` (same message, still an `Error`). **Migration:** read `message` instead of `error` when you want the text, and branch on `kind`. `ToolArgumentsValidationError.toToolResult()` now also returns `kind: 'validation'`. * Background sub-agents still running when the lead run ends are now cancelled instead of running to completion on their own; a lead run that pauses for approval cancels them too (the resumed run starts with none). **Migration:** to keep them, have the lead collect them with `agent_await` before its final answer, or set `subagentOptions: { awaitBackgroundOnFinish: true }`. * Heavy dependencies are optional peers: `dockerode` (Docker sandboxing, `SubprocessSandbox`), `@modelcontextprotocol/sdk` (`serveMcp`, `lousho mcp`, MCP clients) and `prompts` (the interactive questions of `lousho init`) moved from `dependencies` to `peerDependencies`, marked optional in `peerDependenciesMeta`. They were already loaded lazily; now a project that uses none of them does not install them. Using one without its package fails that call with `MissingPeerDependencyError`, which now also carries `feature` and names the exact command (for example `npm install dockerode@^5.0.1`). `lousho doctor` lists the three with what they enable (a missing `dockerode` fails the check when the agent spec uses a sandboxed tool), and its version lookup no longer returns `undefined` for packages whose `exports` map hides `package.json`. **Migration:** install the peer if you use Docker sandboxing (`npm install dockerode`), MCP (`npm install @modelcontextprotocol/sdk`), or interactive `lousho init` (`npm install prompts`; `npm create lousho-agent` already does, and `lousho init --yes` needs nothing). See docs/installation.md. * The `http` tool no longer loads `undici` for default requests: with TLS verification on (the default) it uses the runtime's global `fetch`; `undici` (still a regular dependency) is loaded only for `validateSSL: false`, whose per-request TLS dispatcher the global `fetch` cannot express. `yaml` stays a dependency (agent specs and skills need a YAML parser). * Retries no longer stack: the built-in providers (`OpenAIProvider`, `AnthropicProvider`, `OllamaProvider`, `OpenRouterProvider`) now pass `maxRetries` to the `ai` SDK calls: `config.maxRetries`, or 2 (the `ai` SDK's default, so behaviour is unchanged) when unset. `createAgent()` builds the providers it resolves from strings with `maxRetries: 0` and retries in its `withRetry()` wrapper instead, so retries happen in one place (at most 3 calls by default, as before) and each one is observable. If you wrap a built-in provider in `withRetry()` / `resilientProvider()` yourself, build it with `maxRetries: 0`; note that a `maxRetries` you already set in a provider config is now honoured by the `ai` SDK. * `AgentType` is optional and deprecated (not breaking): `AgentBuilder.build()` no longer requires `setType()` and `AgentConfig.agentType` is now optional (`createAgent()` and `specToAgent()` agents carry no type). `AgentType`, `setType()` and the `agent-types` registry/validators are marked `@deprecated`: they have no runtime effect and will be removed in the next minor; they stay exported for now. Drop your `setType(...)` calls. * BREAKING: `encrypt()` now generates a random salt per call instead of a hardcoded one. Ciphertext produced before this change cannot be decrypted with the new code and must be re-encrypted. * BREAKING: The mock repositories (`MockAgentRepository`, `MockSessionRepository`, `MockResultRepository`, `MockMemoryRepository`, `MockAttachmentRepository` — previously re-exported from `src/data/mocks.ts` via the package root/`./data` subpath; this package has no `createMockRepositories` factory) are no longer exported from the package root. Import them from `@lousho/build-ai-agent/testing` instead. * One session-API client behind `remoteAgent()` and `remoteTarget()`: both now share an internal client (`src/server/sessionClient.ts`) for `POST /chat`, the SSE read, bearer-token scrubbing and the error mapping, and use one pair of codes, `LOUSHO_REMOTE_REQUEST_FAILED` and `LOUSHO_REMOTE_UNAUTHORIZED`. The unreleased `LOUSHO_REMOTE_AGENT_FAILED` is removed: a `remoteAgent()` failure now carries `LOUSHO_REMOTE_REQUEST_FAILED` (network, non-2xx, truncated stream, or a remote run that ends in an error, with "the remote run ended in an error" in the message) or `LOUSHO_REMOTE_UNAUTHORIZED` (401). No option or behavior of either function changed. ### Added * Remote sub-agent approvals go through the lead run: a `remoteAgent()` task whose remote run pauses for a tool approval now pauses the lead run the way a local sub-agent does (it used to fail with `LOUSHO_SESSION_AWAITING_APPROVAL`): the lead's pending approval (`agent.approvals.list()`, channel buttons, the dev chat, ACP permission requests) has the remote tool's name and input and `subagentPath: []`, and a remote `ask_question` arrives as a question. Deciding it on the lead (`resolve`, `streamResolve`, `answer`) posts the decision to the remote `POST /chat/:sessionId/approvals/:id` and the continuation's final answer is the `task` result; a further pause pauses the lead again. The lead's approval snapshot stores the remote session id, the remote approval id, the `taskId` and the agent name (never the token), so a fresh lead process on the same store can decide it. Failures while deciding (401, other non-2xx including the remote's 404 for an approval no longer pending, network) are the `task` call's structured tool error with `LOUSHO_REMOTE_UNAUTHORIZED` / `LOUSHO_REMOTE_REQUEST_FAILED`. A remote agent with an `output` schema now returns its object as JSON with the footer (the V4.2 shape), read from `run.done`'s `object`. `RemoteSubagent.run()` takes `pausable` and `decision` (type `RemoteRunOptions`); the internal session client gains `resolveRemoteApproval()` and `SessionTurnSummary.object`. Only a lead run without an approval store keeps the old `LOUSHO_SESSION_AWAITING_APPROVAL` error. See docs/sub-agents.md#remote-approvals. * Structured output for sessions and sub-agents, typed for either zod major: `createAgent({ output })` and `ExecuteOptions.output` accept a zod 3 schema, a zod 4 schema (`zod/v4` on zod 3.25, or zod 4) or a Standard Schema that can produce JSON Schema, and `result.object` is inferred from each without a cast (`InferSchemaOutput`, as `defineTool`). `AgentSession` is generic (`AgentSession`): `agent.session().send()` and `.stream()` results carry the typed `object`. A sub-agent with its own `output` returns its validated object as JSON (then the `taskId` footer) as the `task` and `agent_await` result; its `output-invalid` finish is a structured tool error. Sub-agents do not inherit the lead's `output`; `remoteAgent()` returns text only. The exported spec schemas (`agentSpecSchema`, ...) are typed as `SpecSchema` / `SpecObjectSchema`, so the published `schema-*.d.ts` no longer depends on zod 3 generics (`skipLibCheck: false` projects on zod 4). The Ollama missing-peer note now says `ollama-ai-provider-v2` needs zod 4 (`npm install zod@^4.0.0`). See docs/structured-output.md. * Publish readiness: `npm run pack-smoke` (scripts/pack-smoke.ts, a CI job) packs the SDK and `create-lousho-agent`, checks the tarball (no `.env`, tests or secret-looking strings; entry count and size caps), runs `npm publish --dry-run` for both (nothing is published), installs the tarballs plus peers from the registry into a fresh project and verifies ESM and CJS loads of every `exports` entry, a mock-model agent turn, the `lousho` bin (`--help`, `doctor`) and `tsc` with `moduleResolution` bundler and node16. * One event system: `AgentEvent` listeners for callers who do not iterate a run. `createAgent({ onEvent: (event: AgentEvent) => void })` and the new `ExecuteOptions.onAgentEvent` (also taken by `AgentExecutor.stream()` and `resumeAfterApproval()`) are called synchronously with every `AgentEvent` of the run, on `send()` / `execute()` as on `stream()`, sub-agents' events included: the same events in the same order as the stream yields (a non-streamed run generates each model step whole, so its text is one `text.delta` per step). A run with a listener also reports hook events, permission decisions, budgets and guardrails to it when it is not streamed. A non-streamed `resumeAfterApproval()` with a listener now reports the decided call (`run.start`, `tool.start`, `tool.done`) like a streamed one. See docs/streaming.md#listening-without-iterating. * Reasoning: new `reasoning` option on `createAgent()`, `send()` / `stream()` (per call, overriding the agent's), `ExecuteOptions` and `GenerateOptions`: `'none' | 'minimal' | 'low' | 'medium' | 'high'` or `{ effort?, budgetTokens?, summary?: 'auto' | 'none', force? }` (types `ReasoningOption`, `ReasoningEffort`, `ReasoningSettings`). The built-in providers send it through `providerOptions` on `ai` 4.3 and 6/7: OpenAI `reasoningEffort` (+ `reasoningSummary`), Anthropic `thinking: { type: 'enabled', budgetTokens }` (effort table 1024 / 2048 / 8192 / 24576 tokens), Ollama `think` (`ollama-ai-provider-v2` only; ignored with one warning on `ai` 4), and OpenRouter's unified `reasoning` body field (`effort` or `max_tokens`). It is only sent to model families known to accept it (`force: true` overrides); `'none'` sends nothing. New events `reasoning.start`, `reasoning.delta` (`text`) and `reasoning.done` (`text`, `tokens?`) in the `AgentEvent` union, emitted inside the step before its first text or tool call, from `ai` v4 `reasoning` parts and v6/v7 `reasoning-start/delta/end`; `ExecutionResult.reasoning` holds the run's reasoning text and `GenerateResult.reasoning` a call's blocks (new type `ReasoningBlock`); new stream chunk types `reasoning-delta` / `reasoning-end` (`StreamChunk.reasoning`). Reasoning is not written to the transcript as text; an assistant tool-call turn keeps Anthropic's signed or redacted thinking blocks in the new `Message.reasoning`, which the Anthropic provider sends back unmodified (it survives checkpoints and resumes). The UI reducer keeps `UIMessage.reasoning`, `toUIMessageStream()` emits `reasoning-start` / `reasoning-delta` / `reasoning-end`, `lousho acp` sends `agent_thought_chunk`, `lousho chat` prints reasoning dimmed, and `chat` spans carry `lousho.usage.reasoning_tokens`. Cassettes record the reasoning chunks. See docs/reasoning.md. * Tools know their session: `ToolExecutionContext.sessionId` is now set whenever the run has one: `AgentExecutor.execute({ sessionId })`, `send({ sessionId })`, every `agent.session()` turn (the session id; a checkpointed turn's own `.turn-`, as hooks see it), approval resumes (the paused run's), and sub-agents (`/`). A session turn's run now carries the session id even without a checkpoint store (no checkpoint is written), so hooks and the `invoke_agent` span's conversation id see it too. * Hardened Slack and Discord channels: `approvers` (a list of platform user ids, or `(user: { id, name?, roles? }, { toolName, input, sessionId }) => boolean | Promise`) says who may click Approve / Deny (a refused click gets an ephemeral "not allowed" and the approval stays pending); `onError(error, { channel, stage, sessionId? })` on both channels, on `mountChannels()` and on `defineChannel()` receives failures after the acknowledgment (reply delivery, a failed turn, an approval continuation; default `console.error` with channel, stage, session and SDK error code, never a token) and a failed turn tells the user so; `mountChannels({ onDecision })` reports who decided; `parse` gets a third `ctx` argument (`ChannelContext`: pending approval, session id, `hasSession`) and a `{ decision }` may carry `inbound` and `approver`. Pending approvals now resolve from the click itself and the Slack 'active thread' check asks the session store, so both survive a restart. Slack: the clicked message is replaced with the outcome (buttons removed) and direct messages (`message.im`, scope `im:history`) are sessions keyed on the DM channel. See docs/channels.md. * Resumable sub-agent tasks: every `task` result ends with a `taskId` (the footer is now `[sub-agent '': N step(s), finish reason '', taskId 'task_1']`; a remote one gains `, taskId '...'` too), and `task` takes optional `taskId` and `mode: 'new' | 'resume' | 'fork'` (default `new` without a `taskId`, `resume` with one). `resume` continues that sub-agent with its transcript and the new prompt as the next user turn; `fork` starts a new task (new `taskId`) from a copy of it and leaves the original alone. Conversations are kept per lead session in the agent's `store.sessions` (new `SubagentOptions.sessions`, set by `createAgent({ store })`) for lead runs with a `sessionId` (checkpointed `send()` and session turns), under a key hashed from the lead session and the taskId, so they survive a restart and cannot be reached from another session; other runs keep them in memory for the run. Background tasks share the ids (a background task's id is now `task_` counted with the synchronous ones): a finished one can be resumed, a running one is refused with the new `LOUSHO_SUBAGENT_TASK_BUSY`; an unknown, foreign or other sub-agent's id gets the new `LOUSHO_SUBAGENT_TASK_NOT_FOUND`, both as the structured tool error. An approval inside a resumed child pauses and resumes the lead as before (streamed or not), and a resumed child's usage counts toward the lead like a fresh call. `remoteAgent()` tasks resumed by `taskId` continue in the same remote session (`RemoteSubagent.run()` takes `sessionId` and `taskId`); remote approvals are still not proxied, but a task whose remote run paused can be continued once the approval was decided on the remote agent. The model-facing `task` description and the sub-agents prompt block say how to continue a task. See docs/sub-agents.md#continuing-a-task. * Agent-directory and spec builds keep optional peers external, and the node server runs spec cron triggers: `lousho build --target=node-server|docker` leaves every optional peer of the SDK (read from its `peerDependenciesMeta`) out of the bundle, replacing the hand-kept `dockerode` / `ollama-ai-provider-v2` list, so a build no longer needs peers the agent does not use; a spec's `{ type: 'cron' }` triggers now start on the built server (and are validated at build time), as on the Cloudflare Worker target. See [Deployment](/deployment#optional-peers-in-node-and-docker-builds). * Agent fingerprint on resume: every checkpoint and approval snapshot now carries `agentFingerprint` (new type `AgentFingerprint`: the model id, each tool's name with a hash of its JSON input schema, a hash of the instructions, and a SHA-256 prefix over them, via Web Crypto; functions are ignored), and a resume compares it with the resuming agent's before any model call or tool runs. New option `onAgentDrift: 'warn' | 'error' | 'ignore'` (type `AgentDriftMode`) on `createAgent()`, `AgentExecutor.execute()` and `resumeAfterApproval()`: `'warn'` (default) emits the new `agent.drift` event (`AgentDriftEvent`: `model?`, `toolsAdded`, `toolsRemoved`, `toolsChanged`, `instructions`) and a `console.warn`, `'error'` rejects with the new `LOUSHO_AGENT_DRIFT` and leaves the checkpoint (or the pending approval) untouched. A pending tool call whose tool no longer exists always rejects with the new `LOUSHO_RESUME_TOOL_MISSING`. Works for crash resume, `session.resume()` and approval resume, streamed or not; old checkpoints and snapshots have no fingerprint and resume unchecked. A sub-agent's nested approval snapshot carries its own fingerprint, but only the top-level agent is compared. `resumeAfterApproval()`'s options take `currentAgent` (set by `createAgent()`) so instructions can be compared. See docs/durable-execution.md#resuming-with-a-changed-agent. * Docker sandbox network allowlists are enforced through the credential broker: `new SubprocessSandbox({ network: { allow }, broker })` creates (or reuses, by `networkName`) an internal Docker network (`Internal: true`, label `com.lousho.sandbox=egress`), has the broker listen on its gateway address for that subnet only, and starts each container on it with `HTTP_PROXY`/`HTTPS_PROXY` pointing there, so the broker (allowlist: its rule hosts plus `allow`) is the container's only route out. New `sandbox.close()` stops the listener and removes the network it created. New `broker.listen({ host, clients, allow? })` adds a listener on another address that drops peers outside the `clients` subnet; the broker still listens on loopback only by default. Enforced on Docker Engine 25.0.5+ for Linux with the agent on the same host; Docker Desktop, rootless Docker, a remote daemon, a non-internal reused network and older Engines are refused with the new code `LOUSHO_SANDBOX_EGRESS_UNSUPPORTED` (no container is started). Without a `broker`, `{ allow }` still means no network. See docs/workspace-tools.md. * The Docker sandbox honors cancellation: `SubprocessSandbox.run()` takes `signal` (`SandboxRunOptions`). When it aborts, the container is killed and force-removed (errors from an already stopped or removed container are ignored) and `run()` rejects with an `AbortError`, like `NoopSandbox`; an already-aborted signal starts no container; a timeout uses the same cleanup. `SandboxShell` passes the tool's abort signal down, so a cancelled `shell` call stops its container (it used to run until the timeout) and reports `aborted: true`. See docs/workspace-tools.md. * Schedules on Cloudflare Workers: the `cloudflare-worker` target writes the spec's `{ type: 'cron', cron, input, name?, timezone? }` triggers (deduplicated) to `wrangler.toml` as `[triggers] crons = [...]`, and the generated Worker exports `scheduled(controller, env, ctx)`, which runs every trigger whose expression equals `controller.cron` as an agent turn inside `ctx.waitUntil()`, in session `schedule:` (visible in the KV store when bound). A failing trigger is logged with `console.error` (name and error code) and never stops the others. The build fails with `LOUSHO_SCHEDULE_INVALID` naming the trigger for what Cloudflare would not fire: a `timezone` (crons are UTC), a seconds field or `@daily` shortcut, a numeric day-of-week (Cloudflare counts 1 as Sunday). New `handleScheduled(agent, schedules, controller, ctx)` (root and `deploy-runtime-worker`) wires `defineSchedule` schedules to a hand-written Worker's `scheduled()`. `startSchedules` shares its fire step with it. See docs/schedules.md#on-cloudflare-workers. * `ai` v6 and v7 as peers: `peerDependencies` now accept `ai` `^4.3.19 || ^6.0.0 || ^7.0.0` and `@ai-sdk/openai` / `@ai-sdk/anthropic` `^0.0.42 || ^1.0.0 || ^3.0.0 || ^4.0.0` (still optional), plus the new optional peer `ollama-ai-provider-v2` `^2.0.0 || ^3.0.0 || ^4.0.0`, so a project on the current `ai` major installs without peer conflicts. Pairings: `ai` 4 with `@ai-sdk/*` 0.0.x/1.x and `ollama-ai-provider` 1.x; `ai` 6 with `@ai-sdk/*` 3.x and `ollama-ai-provider-v2` 2.x/3.x; `ai` 7 with `@ai-sdk/*` 4.x and `ollama-ai-provider-v2` 4.x. A missing provider package's `MissingPeerDependencyError.installCommand` names the version for the installed `ai` major (`npm install @ai-sdk/openai@^4.0.0` on `ai` 7; unchanged on `ai` 4). `OllamaProvider` loads `ollama-ai-provider-v2` on `ai` 6/7; that package needs zod 4, which the SDK does not support yet, so its install hint says so and Ollama stays on `ai` 4 for now. `lousho doctor` checks provider packages against the installed `ai` major and flags a mismatched pair (e.g. `ai` 7 with `@ai-sdk/openai` 1.x) with the version to install; a missing or unsupported `ai` gets `npm install ai@^7.0.0`. `lousho init` / `npm create lousho-agent` scaffold `ai@^7.0.0` with `@ai-sdk/openai@^4.0.0` / `@ai-sdk/anthropic@^4.0.0` for OpenAI and Anthropic, and keep `ai@^4.3.19` for Ollama (zod 4) and OpenRouter (`@ai-sdk/openai` 2+ defaults to the Responses API, not yet verified there). The hint of `resolveProvider()`'s rare synchronous missing-peer error lists the command for each `ai` major. See [Installation](/installation#provider-packages). * `lousho add`: `lousho add [--registry ] [--dir ] [--yes] [--overwrite] [--dry-run]` installs a tool, skill, channel, schedule or memory slot from a static JSON registry (an index plus one document per item that carries the file contents) into an agent directory as source you own; `lousho add --list` prints the index. The registry comes from `--registry` or `LOUSHO_REGISTRY` (there is no hosted registry yet). It prints the item's permission manifest (hosts, env vars, filesystem, exec, whether its tools ask for approval) and the files first and asks for confirmation (`--yes` skips it; a non-interactive stdin without `--yes` refuses; `--dry-run` writes nothing). Paths must be relative, inside the folder of the item's type and inside the agent directory (no `..`, absolute paths, drive letters, backslashes or symlink escapes), files and items have size caps, existing files need `--overwrite`, fetches time out and only `http(s)` and local paths are read; nothing from a registry is executed and `dependencies` are only printed as an `npm install` line. New error codes `LOUSHO_REGISTRY_UNREACHABLE`, `LOUSHO_REGISTRY_ITEM_NOT_FOUND`, `LOUSHO_REGISTRY_INVALID`, `LOUSHO_REGISTRY_UNSAFE_PATH`, `LOUSHO_REGISTRY_FILE_EXISTS`. See docs/registry.md. * Checkpoint history for `KVCheckpointStore` and Agent Forge's file store: `KVCheckpointStore` (and `KVStore`, with the new `historyLimit` option) keeps the bounded per-session history `memoryStore()` keeps, as one index key (`#history`) and one key per entry (`#history/`) next to the unchanged latest-checkpoint key, so `agent.fork()`, `compareTrajectories()` and time travel work for Worker-deployed agents; a store written before this change still loads. A save now makes one `get` and two `put`s more (`historyLimit: 0` turns the history off), and two concurrent saves of one session can lose an index update (KV has no transactions). Agent Forge's `FileCheckpointStore` reads a partially written history file as empty and writes its files atomically. The history contract suite now runs against `KVCheckpointStore` too. See docs/durable-execution.md#checkpoint-history. * Hook outcomes: a `preToolCall` hook may return `{ deny: reason }` (the call does not run; the model gets the same `kind: 'denied'` tool error as a `needsApproval` deny, streams see `tool.error`, and `onPermissionDecision` / `permission.decision` record `decision: 'deny'` with the new `hook` field and `reason`), `{ result: value }` (the call does not run; `value` is its result) or `{ input: args }` (the call runs with `args`, validated against the tool's schema again; a mismatch is a `kind: 'validation'` tool error naming the hook). A `postToolCall` hook may return `{ result: value }` to replace the result the model sees. Pre-hooks run in registration order: the first deny or result skips the later ones, inputs chain. A replaced result is marked `replacedByHook` on the `tool.done` event, the `ToolCallOutcome` / `tool-result` event and the transcript's `tool` message `metadata`. Hooks still run first (after argument validation, before permission rules, tool guardrails and `needsApproval`), so the approval sees the hook's input; on a resumed approved call the pre-hooks run again and an `{ input }` that differs from the approved input is refused. Sub-agents inherit the outcomes. Hooks that return nothing type-check and behave as before; `HookRegistry.runPreToolCall()` now resolves to a `PreToolCallDecision` and `runPostToolCall()` to the name of the replacing hook (new types `PreToolCallOutcome`, `PostToolCallOutcome`, `PreToolCallDecision`). A hook that throws still rejects the run. See docs/api-overview\.md#hook-outcomes. * `lousho acp`: `lousho acp [--model provider/model]` serves an agent over the Agent Client Protocol (v1) on stdio, so Zed and other ACP editors can run it as an external agent; stdout carries only JSON-RPC lines (the agent's `console.log` goes to stderr). It implements `initialize`, `session/new`, `session/prompt` (text and `resource_link` blocks) and `session/cancel`, streams `session/update`s (`agent_message_chunk`, `tool_call`, `tool_call_update`), asks `session/request_permission` (Allow / Reject) when a tool needs approval and streams the continuation, and maps finish reasons to stop reasons (`max-steps` to `max_turn_requests`, `budget-exceeded` / `length` to `max_tokens`, `guardrail` to `refusal`, a cancel to `cancelled`). One ACP session is one SDK session. An `ask_question` pause ends the turn with the question as the agent's message; the next prompt answers it. A failed run answers a JSON-RPC error whose `data.code` is the SDK error code. The protocol core is exported as `serveAcp(agent, { input, write, store? })` (new type `ServeAcpOptions`), transport-independent. Not supported yet: `session/load`, the client's `fs/*` and `terminal/*` methods, images, audio and embedded resources, `plan` and thought chunks. See docs/acp.md. * Agent directories deploy and serve with their schedules and channels: `lousho build --target=node-server` (and `docker`; the path may also be `--agent`) builds a directory into a server whose entry calls `resolveAgentDir()` and `createDeployedServer(agent, { schedules, channels })`, so the deployed process starts the `schedules/` and mounts the `channels/` under `/channels` and logs which it found. The code files are pre-bundled by the existing tsup step into `dist/agent/**.js` (one ESM build sharing one SDK copy; `dist/` is marked ESM) and the rest of the directory is copied, so nothing needs a TypeScript loader at run time; spec builds are unchanged. `lousho dev ` mounts the directory's channels and starts its schedules, swapping both on hot reload (old schedules stopped first); `--no-schedules` starts none. The deploy runtime also exports `createAgent`, `resolveAgentDir` and `storeFromEnv`. The Cloudflare Worker target is unchanged (P9). See docs/agent-directories.md#deploy-it-with-lousho-build. * Route handler: `createRouteHandler(agent, { basePath?, auth?, uiMessageStream? })` returns `{ GET, POST, handler }`, Fetch handlers (`(request: Request) => Promise`) that serve the deployed session API under `basePath` (default `/api/agent`; trailing slash accepted, other paths 404) from a Next.js App Router `app/api/agent/[[...path]]/route.ts`, SvelteKit `+server.ts`, Hono or Bun.serve, with no framework or `node:*` import. `auth` is a bearer token or a `(request) => boolean | Promise` (default none: a public route needs one; `GET /health` stays open). `useLoushoAgent({ url, approvalsUrl })` works against it (`POST ` with `{ input, sessionId? }`, `POST /approvals/:id`), and `uiMessageStream: true` adds `POST /ui` for `useChat`. `hasBearerToken` in `src/server/fetchRoutes.ts` is now exported for it. See docs/nextjs.md. * AI SDK UI stream: `toUIMessageStream(run)` maps a run's events to the Vercel AI SDK's UI message stream chunks (`start`, `start-step`, `text-start` / `text-delta` / `text-end`, `tool-input-start` / `tool-input-available` / `tool-output-available` / `tool-output-error`, `finish-step`, `error`, `finish` with the AI SDK finish reason), and `toUIMessageStreamResponse(run, init?)` returns the SSE `Response` (`data: [DONE]`, header `x-vercel-ai-ui-message-stream: v1`) for a route handler, so `useChat` can render a Lousho run. An approval pause or `ask_question` is a `data-lousho-approval` part (`approvalId`, `toolCallId`, `toolName`, `input`, `kind?`, `question?`); usage and the run's own finish reason are in the `finish` chunk's `messageMetadata`. `fromUIMessages(messages, { lastUserOnly? })` converts the `UIMessage[]` `useChat` posts (text, image and file parts; others ignored) to an `AgentInput`. Own structural chunk types (`LoushoUIMessageChunk`, `UIMessageLike`, ...), no `ai` import and no `node:*`, so it also runs on Workers; exported from the package root (a dedicated subpath can follow). See docs/ai-sdk-ui.md. * Approval policies: a `needsApproval` function may return (or resolve to) `'approve'`, `'deny'`, `'ask'` or `{ deny: reason }` besides a boolean (`true` is `'ask'`, `false` is `'approve'`; new type `ApprovalOutcome`), and gets a second argument `{ toolName, toolCallId, sessionId?, messages }` (new type `ApprovalCheckContext`). A deny does not pause: the call is not run, the model gets a `kind: 'denied'` tool error with the reason, streams see `tool.error`, and `onPermissionDecision` / `permission.decision` record `decision: 'deny'` with the new `reason` field (no `rule`); the audit entry is now reported once the call is decided (after tool guardrails) instead of before them. New helpers `always()`, `never()` and `once({ per?: 'tool' | 'args' })` (with type `ApprovalPolicy`): `once()` asks the first time a tool is called in a session and runs later calls once a human approved one (a rejection is not remembered; `per: 'args'` keys on the arguments' hash). The memory is the approved call's `tool` message (`metadata.approval`, set by `resumeAfterApproval()` and its streamed form), so it persists with sessions, checkpoints and approval snapshots and survives a resume in another process. A `permissions` `deny` rule wins; an `allow` or `ask` rule replaces the tool's ask but not its deny (see Changed); the tool's outcome applies when no rule matches. Existing boolean functions type-check and behave as before; a test asserting `needsApproval` was called with exactly one argument now sees the context too. See docs/approvals.md#approve-deny-or-ask. * Approval continuations stream live: `POST /chat/:sessionId/approvals/:id` (node server, Cloudflare Worker, `lousho dev`) now streams the continuation from `agent.approvals.streamResolve()` / `streamAnswer()` instead of replaying it after the turn ends; `createAgentRunner`'s `approve()` / `reject()` / `answer()` (React, Vue, Svelte) read that SSE stream event by event (an `ApprovalOutcome` JSON from an older server still works), and `lousho chat` prints the continuation as it streams. The UI reducer maps a streamed `tool.error` named `ToolRejectedError` to a `rejected` tool call. A second pause arrives as `approval.requested`. The approvals route has always answered with SSE, so no client opt-in was added. * `session.compact()` and `session.clear()`: `await session.compact({ strategy?, protectedTokens?, contextWindow?, signal? })` compacts the session's transcript now, whatever its size, with the given strategy, else `agent.session({ compaction })` (new option, the value of `createAgent({ compaction })`), else by pruning old tool results; it saves the result through the session's store and resolves to `{ messagesBefore, messagesAfter, tokensBefore, tokensAfter, strategy, error? }`. `clear()` (which already existed and forgot the conversation) now emits `context.cleared` (new `ContextClearedEvent` in the `AgentEvent` union) and rejects with `LOUSHO_SESSION_AWAITING_APPROVAL` when a durable turn waits on an approval; it keeps the session id. New `session.on(listener)` receives `compaction.start` / `compaction.done` (which gain an optional `trigger: 'manual'`) and `context.cleared`. Both reject with the new `LOUSHO_SESSION_BUSY` while a turn is in flight; `compact()` also with `LOUSHO_SESSION_TURN_PENDING` for an interrupted durable turn. `lousho chat` gains `/compact` and `/clear`. See docs/compaction.md#compacting-a-session. * Discord channel: `discordChannel({ publicKey, applicationId, botToken?, name?, fetch? })` serves Discord's HTTP Interactions endpoint through `mountChannels()` (Web Crypto and `fetch` only, Worker-safe, no gateway, no new dependency). It verifies `X-Signature-Ed25519` / `X-Signature-Timestamp` (Ed25519 over `timestamp + body`, 401 otherwise) and answers `PING` with `PONG`. A slash command (`/ask prompt:`) is acknowledged at once with a deferred response; its session is keyed on guild + channel (+ thread), and the reply edits the original response, with text over 2000 characters continued in follow-up messages. A tool approval is posted with Approve / Deny buttons and resolved by the click; an `ask_question` is answered by the next `/ask` in the channel. See docs/channels.md#discord. * Streaming resume after an approval: `streamResumeAfterApproval(decision, approvalStore, toolRegistry, provider, options?, checkpointStore?)` takes `resumeAfterApproval()`'s arguments and returns an `AgentRun` whose `result` is what `resumeAfterApproval()` returns. Its events are `run.start`, the decided call's `tool.start` / `tool.done` (`tool.error` for a rejection), then the continuation's events as in a fresh `stream()` run; a further pause ends it with `approval.requested`. Abort, `enqueue()` and `steer()` work as on any run, and the approval can be resolved in another process. `createAgent()` agents get `agent.approvals.streamResolve(decision, { signal })` and `streamAnswer({ id, answer }, { signal })` (a pause made in a session continues in it, `run.done` after the transcript is saved; a dynamic agent keeps the model the paused run used). Also new: the `ResumeRequest` type and `resumeRequest(request)` (`resumeAfterApproval()` with one object argument). Additive: `resolve()`, `answer()` and `resumeAfterApproval()` are unchanged. See docs/streaming.md#streaming-after-an-approval. * Credential broker: `createCredentialBroker({ rules, allow?, allowPrivate?, port?, host?, pathFormScheme? })` starts a host-side HTTP forward proxy (Node-only, on an ephemeral `127.0.0.1` port by default) and resolves to `{ url, env, baseUrl(host), close() }`. `rules` map a host (exact or `*.suffix`) to headers to inject, as strings or per-request functions, so a command the model runs can call an authenticated API without the token entering its environment: pass `broker.env` (`HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`, both cases) to `NodeWorkspace` or `SandboxShell`'s `env`. Hosts outside the rules and `allow` get a 403 without a connection; hosts resolving to loopback, link-local or private addresses are refused unless in `allowPrivate`; a client `Authorization` header for a brokered host is replaced; hop-by-hop headers are dropped. Injection covers plain-HTTP requests and the `/__broker//` form (forwarded to `https:///`, returned by `baseUrl(host)`); HTTPS `CONNECT` tunnels are allowlist-checked and passed through untouched (no TLS interception). `SubprocessSandbox`'s `network: { allow }` still fails closed: routing containers through the broker is a separate follow-up. New types `CredentialBroker`, `CredentialBrokerOptions`, `BrokerHeaderValue`. See docs/workspace-tools.md#credential-broker. * Memory in agent directories: `memory/*.ts|js|mjs` files default-export a memory slot (`defineMemory()`, or the same options without a `name`; name = file name unless set). Unlike schedules and channels they are part of the agent: `loadAgentDir()` passes them to `createAgent({ memory })`, `resolveAgentDir()` lists them in `manifest.memory`, and a `memory` override is merged by name (the override wins a clash). A bad file fails with the new `LOUSHO_MEMORY_INVALID` naming the file. A directory without `memory/` is unchanged. See docs/agent-directories.md#memory. * Channels in agent directories: `channels/*.ts|js|mjs` files default-export a channel (`defineChannel()` or a built-in factory; name = file name unless set); `resolveAgentDir()` returns them as `channels` and `manifest.channels`, and a file that exports no channel fails with the new `LOUSHO_CHANNEL_INVALID` naming the file. `createDeployedServer(agent, { channels })` mounts them under `/channels` next to the chat routes. `loadSchedules()` and `loadChannels()` share one loader (schedule files that do not export a schedule now throw an `SDKError` with `LOUSHO_SCHEDULE_INVALID`, same message). `lousho dev` and the Cloudflare Worker target do not mount them yet. See docs/agent-directories.md#channels. * `serveMcp` annotations: `defineTool({ annotations })` (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`, `title`; stored as `metadata.mcp.annotations`) is sent in `tools/list` verbatim, and a tool with `needsApproval` is advertised `readOnlyHint: false, destructiveHint: true` (never read-only). An unannotated tool sends none. A Lousho agent consuming the server therefore runs read-only tools without an approval pause, pauses for `needsApproval` ones and keeps asking for unannotated ones. The built-in `read_file`, `list_dir`, `glob`, `grep`, `todo_read`, `current_date` and `day_name` tools are marked read-only. See docs/configuration.md#annotations. * Svelte store: new `@lousho/build-ai-agent/svelte` subpath with `loushoAgent(source, options?)`. It returns a Svelte store (`subscribe(run) => unsubscribe`, so `$agent` works in Svelte 4 and 5) whose value is the same state as the React hook and the Vue composable (`messages`, `status`, `pendingApproval`, `error`, `usage`, `lastEvent`), plus `send()`, `stop()`, `approve()`, `reject()`, `answer()` and `reset()`. The run in flight is aborted by `stop()` and when the last subscriber unsubscribes. The store contract is implemented by hand: the entry imports nothing from `svelte`, so there is no new peer dependency. See docs/svelte.md. * Per-run dynamic config: `createAgent()`'s `model`, `instructions` / `prompt` and `tools` each also take a function of the run, `(ctx) => value | Promise` (new types `PerRun` and `RunConfigContext` = `{ sessionId?, input, metadata? }`), resolved once at the start of each run and of each session turn, before the first model call. `fallbackModels`, `retry`, `projectInstructions`, memory, MCP tools, sub-agents, permissions, approvals and guardrails apply to the resolved values; a dynamic agent used as a sub-agent resolves with the task prompt as `input`. `session.send()` / `session.stream()` gain the `metadata` option (`send()` / `stream()` already had it, and now pass it to these functions too). A function that throws fails the run with the new `LOUSHO_CONFIG_RESOLVER_FAILED` (`field` names the option, `cause` is the error); a session keeps its transcript. A run paused for approval stores its `ctx` and resolved model in the approval snapshot (`agent.metadata.loushoRunConfig`): resuming it uses that model and re-resolves the tools with the same `ctx`; a crash resume from a checkpoint resolves with `input: []`. Static options behave and type as before. `SessionRunner` / `SessionStreamRunner` take an optional fourth `call` argument (new type `SessionTurnCall`). See docs/api-overview\.md#dynamic-config. * Vue composable: new `@lousho/build-ai-agent/vue` subpath with `useLoushoAgent(source, options?)` for Vue 3. It takes the same sources and options as the React hook (either may be a `ref`, read when a turn starts) and returns `messages`, `status`, `pendingApproval`, `error`, `usage` and `lastEvent` as read-only refs, plus `send()`, `stop()`, `approve()`, `reject()`, `answer()` and a new `reset()` (aborts, forgets the in-process session, clears the chat). The run in flight is aborted by `stop()` and when the owning scope is disposed (`onScopeDispose`: component unmount, `effectScope().stop()`). `vue` (`^3`) is an optional peer dependency, loaded by no other entry. To share the logic, the reducer and the stream parser moved from `src/react/` to a framework-neutral `src/ui/`, together with the run logic extracted from the React hook (`createAgentRunner()`); the `./react` exports are unchanged, and the reducer gains an additive `ui.reset` action. See docs/vue.md. * One flag parser for the `lousho` CLI: every command (`dev`, `chat`, `eval`, `build`, `mcp`, `doctor`, `studio`; `init` already did) parses its flags with `node:util` `parseArgs` in strict mode through `src/cli/args.ts`. `--flag value` and `--flag=value` work everywhere, `--` ends the flags, `-h` / `--help` prints the usage and exits 0, and an unknown flag, a flag without its value or an extra argument fails with `LOUSHO_CONFIG_INVALID` and the command's usage as the hint. **Behaviour change (migration):** `dev`, `studio`, `mcp`, `build` and `doctor` used to ignore unknown flags, and a flag missing its value could take the next flag or a prefix match (`--portx 1` read as `--port`); they now fail with exit 1 and the usage line, so remove typos from scripts. A value that starts with `-` must be written `--flag=-x` (a bare `--flag -x` is a missing value); `--port` must be an integer from 0 to 65535 for `dev`, `studio` and `mcp`; `dev`, `mcp` and `doctor` reject a second path; `studio --prod --dev` is rejected instead of preferring `--prod`. `lousho dev` and `lousho studio` parsing moved from `bin/lousho.js` into `src/cli/dev.ts` / `src/cli/studio.ts` (`parseDevArgs`, `runDev`, `parseStudioArgs`, `runStudio`). See docs/cli.md. * `sqliteMemory(store, { maxItems? })` (exported from `@lousho/build-ai-agent/sqlite`) keeps `defineMemory` slots in the agent's `SqliteStore` file: a new `memory_items` table (migration 3, one JSON array per scope key, with the same serialization, `maxItems` and query rules as `fileMemory`). An existing database gains the table on open and keeps its sessions and checkpoints. The memory provider contract tests now run against the in-memory, file and SQLite providers. See docs/memory.md#sqlite. * Slack channel: `slackChannel({ signingSecret, botToken, name?, fetch? })` (new export, with types `SlackChannelOptions`, `SlackChannelEvent`, `SlackThread`) serves a Slack app's Events API and interactivity requests through `mountChannels()`. It verifies the `v0` request signature with Web Crypto (no `node:*` import; the timestamp and header checks are shared with `verifySlackSignature()`), answers the `url_verification` challenge, and acknowledges every request before the turn runs. Each Slack thread is one session (team + channel + thread root `ts`): a mention starts or continues it, and later messages in a thread the bot was mentioned in continue it. Retries (`X-Slack-Retry-Num`) and bot messages are skipped. Replies go to the thread with `chat.postMessage` over `fetch`; a tool approval is posted as Approve / Deny buttons whose click resumes the session, and an `ask_question` as text answered by the next thread message. The channel contract gains two additive hooks for this: `parse(req, respond)` may call `respond` to answer the request before the turn runs, and may return `{ decision }` (new type `ChannelDecision`; `ChannelApprovalDecision` now lives in `defineChannel.ts`, same public export) to resolve a pause instead of starting a turn. See docs/channels.md#slack. * Remote sub-agents: `remoteAgent({ url, auth?, name?, description?, headers?, fetch? })` (new export, with types `RemoteAgentOptions` and `RemoteSubagent`) makes a deployed agent usable wherever a local sub-agent is: in `createAgent({ subagents })` records and `SubagentCatalog.resolve()`, delegated to by the `task` tool and by `background: true` tasks. Each task opens a fresh session on the remote agent over `POST /chat` (bearer `auth`: a string or a function, called per task), reads the SSE stream to the end and returns the remote agent's final text; the lead run's abort signal aborts the request. Failures (network, 401 or other non-2xx, malformed stream, remote run error) are structured tool errors with the new code `LOUSHO_REMOTE_AGENT_FAILED`, and the token never appears in errors or events; a remote run that pauses for approval fails with `LOUSHO_SESSION_AWAITING_APPROVAL` naming the remote session (proxying approvals is a follow-up). Worker-safe (`fetch` only). See docs/sub-agents.md#remote-sub-agents. * Eval against a deployed agent: `lousho eval --url [--token ]` (token also from `LOUSHO_EVAL_TOKEN`) runs every case of the trajectory eval files against a deployment (node server or Cloudflare Worker) instead of the in-process agent: one remote session per case over `POST /chat { sessionId, input }`, the SSE stream read to `run.done` and turned into the usual result (tool calls, reply, finish reason, steps, usage), so checks, scorers, `t.judge()`, the summary and `--junit` work unchanged. A check that needs data the stream lacks (`maxTokens` without usage) fails and names what is missing. Programmatic equivalent: `defineEval({ target: remoteTarget({ url, auth?, fetch? }) })` (new `remoteTarget()`, `EvalTarget`; `agent` is now optional when a `target` is given, and `AgentSource` also accepts any `{ send() }`). `--url` with `--record` / `--replay` / `--drift` is rejected with `LOUSHO_CONFIG_CONFLICTING_OPTIONS`; an unreachable deployment, a non-2xx answer or a truncated stream fails the case with the new `LOUSHO_REMOTE_REQUEST_FAILED`, and 401 with `LOUSHO_REMOTE_UNAUTHORIZED`. The token never reaches output or reports. See docs/evals.md#run-evals-against-a-deployment. * Schedules: `defineSchedule({ cron, timezone?, name?, prompt | run })` declares a cron schedule (a `prompt` sent to the agent as a new turn, or `run({ agent, firedAt, name })`); an invalid cron expression, or not exactly one of `prompt`/`run`, throws the new `LOUSHO_SCHEDULE_INVALID` at definition time. An agent directory's `schedules/*.ts|js|mjs` files default-export one each (name = file name unless set); `resolveAgentDir()` returns them as `schedules` and `manifest.schedules`. `startSchedules(agent, schedules, { now?, setTimer?, onError? })` runs them with injectable clock and timers, isolates errors, skips a fire while the previous one runs and returns `stop()`; `createDeployedServer(agent, { schedules })` starts them on listen and stops them on close. The Cloudflare Worker target is not covered yet. See docs/schedules.md. * Channels: `defineChannel({ name, verify?, parse, reply, onApproval?, sessionId?, stream? })` describes one surface: `verify` authenticates a request (`false` or `{ ok: false }` answers 401), `parse` maps it to `{ sessionKey, input, metadata?, replyTo }` (or `null` to acknowledge it without a turn), `reply` delivers the agent's reply (with `stream: true`, also partial text as the model writes) and `onApproval` renders a pause (default: a text prompt through `reply`). `mountChannels(agent, channels, { store, basePath })` returns a framework-free `(req, res)` handler, in the style of the `/chat` routes, that serves `POST /` (verify -> parse -> a turn in session `` `${name}:${sessionKey}` `` -> reply; turns of one session run one at a time) and `POST //approvals/` (`{ approved, note? }` or `{ answer }`); `handler.resolveApproval()` decides a pause from code (e.g. a button callback) and replies through the same channel. Built in: `httpChannel()` (JSON `{ sessionKey, input }` in, `{ sessionId, text, finishReason, approval? }` out) and `webhookChannel({ secret })` (`WebhookTriggerAdapter`'s behaviour; the adapter now delegates its auth and parsing to it, unchanged for callers). Slack and Discord channels are separate follow-ups. See docs/channels.md. * Cloudflare Worker parity: the `cloudflare-worker` target now serves the same API as the node server: `POST /chat { sessionId, input }` streamed as SSE, history per session, `GET /chat/:sessionId`, `POST /chat/:sessionId/approvals/:id` and `GET /health`; the deprecated `POST /chat { message, sessionId? }` still works and still checkpoints under `sessionId`. `LOUSHO_API_TOKEN` (a Worker secret: `wrangler secret put LOUSHO_API_TOKEN`) makes every route except `/health` require `Authorization: Bearer ` (constant-time compare). New `KVStore(kvBinding, { prefix?, ttl? })` (`src/deploy/kvStore.ts`) is an `AgentStore` on a Workers KV namespace: `sessions/` (transcript JSON, bytes as `{ "$bytes" }`), `checkpoints/` (`KVCheckpointStore`, which gains an optional TTL) and `approvals/`, with optional per-kind TTLs; the Worker builds it from the existing `AGENT_CHECKPOINTS` binding, and keeps sessions in the isolate's memory without one. An approval paused on one request can be decided by another request on a new isolate. The routes are now Fetch-native (`src/server/fetchRoutes.ts`: `Request` in, `Response` and a `ReadableStream` SSE body out, bearer auth with Web Crypto); `src/server/chatRoutes.ts` adapts them to `node:http` for `lousho dev` and the node server, and `src/server/bearerAuth.ts` is gone. The built Worker runs a `createAgent()` agent, so the build swaps its Node-only imports for `src/deploy/shims/node.worker.ts`, and the `node:` leak check now also catches bare builtin specifiers; the bundle grows to about 1.7 MB. The generated `wrangler.toml` documents the KV binding and both secrets. The `wrangler dev` test now streams a session turn through workerd with a KV binding and a token. See docs/deployment.md#bindings-sessions-and-the-api-on-workers. * Steering: `run.steer(input)` on `AgentRun` (from `agent.stream()`, `session.stream()` and `AgentExecutor.stream()`), and `InputQueue.steer(input)` for non-streaming callers, redirect a running run to new user input. If a model call is in flight and has not emitted text or tool calls yet, it is aborted with a per-call signal (not the run's), its partial output is discarded, the input is appended as a user message and the model is called again (`applied: 'immediate'`; that step ends with `step.done` `'steered'` and counts toward `maxSteps`; a provider that ignores the signal is not waited for). Once the model has emitted output, the input waits for the next safe point like `enqueue()` (`'queued'`); after the run has finished, `applied` is `false`. Tool calls already running finish and keep their results; calls of that turn not started yet get the usual "cancelled before it ran" result. It returns `{ id, applied, joined }` (new type `SteerResult`; `joined` is a promise of whether the input reached the transcript). New event `input.steered` `{ id, text, mode: 'immediate' | 'queued' }` (`InputSteeredEvent`, additive, schema v1), followed by the existing `input.applied`. With checkpointing, the input is checkpointed when it is taken, before the model is called again, and the discarded partial turn is never checkpointed. `agent.session({ turnPolicy: 'steer' })` makes a `send()` / `stream()` made while a turn runs steer that turn (`'queue'` and `'wait'` are unchanged). `AgentRun` gains the required `steer()` member, so a hand-written `AgentRun` object must add it. See docs/streaming.md#steering and docs/sessions.md. * `ai` v6/v7 `stream()`: the built-in providers' `stream()` (and so `agent.stream()`) now works on `ai` v6 and v7 as well as v4, through a compatibility layer (`streamCompat()`): the request is mapped as for `generate()`, and either major's `fullStream` is read into the same `StreamChunk`s: text deltas, tool calls (the whole call, or one assembled from `tool-input-start`/`-delta`/`-end` when the model sends none), and a `finish` chunk with the finish reason and usage, including cached and reasoning tokens. An `error` part now rejects the stream with its error on both majors (on v4 the step used to end with finish reason `error` and the partial text), an `abort` part with the signal's reason; reasoning deltas are dropped until a reasoning chunk type exists. `StreamChunk`/`StreamResult` are unchanged. The protected `AiSdkProvider.convertMessages()` now returns our structural `AiSdkMessage[]` instead of `ai` v4's `CoreMessage[]` (which v6/v7 do not export) and is marked deprecated; an override returning `CoreMessage[]` still compiles. The `ai`/`@ai-sdk/*` peer ranges still name v4. Tested on the real `ai` v7 (`MockLanguageModelV4`), with `agent.stream()` events asserted by one helper on v4 and v7. See docs/providers.md#vercel-ai-sdk-versions. * `spec.policy` is enforced: `specToAgent()` compiles the policy block into `createAgent()` options through the new `compilePolicy()`. `requiresApproval` (`true` or a list of tool names) becomes `permissions: [ask(...)]`; `guardrails` (`max-length`, `secret-scan`, `regex`, `deny-topics`, `llm-judge`, by name or `{ name, ...options, on }`) become input and output guardrails; `limits`, `askQuestion` and `compaction` pass through. `loadSpec()` validates the known fields (an unknown guardrail name fails with the names available and a did-you-mean; unknown policy keys are still kept for the generators), and `lousho doctor ` prints one line per policy block and flags unknown guardrail names. **Behaviour change:** `requiresApproval: true` now actually pauses tool calls (`finishReason: 'awaiting-approval'`); before, a spec that set it ran its tools without asking, and a spec with a guardrail name outside the built-ins (for example `diff-size-cap`) now fails validation. See docs/configuration.md#policy-policy. * `ai` v6/v7 `generate()`: the built-in providers' `generate()` now works on `ai` v6 and v7 as well as v4, through a compatibility layer that detects the installed major (v5+ exports `stepCountIs`). On v6/v7 it sends messages as `ModelMessage`s (tool calls with `input`, tool results as `output`, `mediaType`), `maxTokens` as `maxOutputTokens`, one step as `stopWhen: stepCountIs(1)`, tools as `inputSchema` without `execute` and `responseFormat` as a JSON-mode `output`, and reads usage from `inputTokens`/`outputTokens`/`totalTokens` (cached and reasoning tokens from their details) into the same `GenerateResult`. `LLMProvider`, `GenerateOptions` and `GenerateResult` are unchanged, and v4 behaves as before. `stream()` on v6/v7 throws an error until that is supported; the `ai` and `@ai-sdk/*` peer ranges still name v4. Tested against the real `ai` v7 (`MockLanguageModelV4`) next to the v4 contract tests. See docs/providers.md#vercel-ai-sdk-versions. * Queued follow-up input: `run.enqueue(input)` on `AgentRun` (from `agent.stream()`, `session.stream()` and `AgentExecutor.stream()`) adds user input to a run that is still going; it joins the transcript at the next safe point (after the current step's tool results, before the next model call) and the run continues as if the user had typed it, with one more step when it arrives during the final reply. It returns `{ id, applied }`: `applied` is `false` when the run had already finished (send the input as a new turn, e.g. `session.send()`), otherwise a promise of whether it was applied. New events `input.queued` `{ id, text }` and `input.applied` `{ id, step }` (additive, schema v1). Non-streaming callers pass an `InputQueue` (new export) as `ExecuteOptions.inputQueue` and call its `push(input)`. With checkpointing, a waiting input is saved right away at the end of the checkpoint (the queued-input slot), so a crash does not lose it. `agent.session({ turnPolicy: 'queue' })` makes a `send()` / `stream()` made while a turn runs (or waits to start) join that turn instead of waiting for its own (`'wait'`, the default, keeps today's behaviour). `AgentRun` gains the required `enqueue()` member, so a hand-written `AgentRun` object must add it. Steering (aborting the in-flight model call to redirect) is a separate follow-up. See docs/streaming.md#queued-input and docs/sessions.md. * Input and output guardrails: `guardrails?: { input?, output?, tools?, onTripped? }` on `createAgent()` and `ExecuteOptions` (new type `AgentGuardrails`). A guardrail is `{ name, check(ctx) }` (new type `IoGuardrail`; `ctx` is `{ kind: 'input' | 'output' | 'tool', text, messages, toolName?, args?, signal? }`) returning `{ ok: true }` or `{ ok: false, reason, action?: 'block' | 'rewrite', replacement? }`. Input guardrails run on each new user message before the first model call; output guardrails on the final reply (in a streamed run, on every step's text, before its `text.done`); tool guardrails on a call's arguments after the permission rules (skipped on `deny`) and before `needsApproval`. A block ends the run with the new `finishReason: 'guardrail'` and `result.guardrail` (`{ name, kind, reason, toolName? }`, new type `GuardrailTrip`), with no model call for an input block; `onTripped: 'throw'` rejects with the new `GuardrailError` (`LOUSHO_GUARDRAIL_TRIPPED`) instead. A rewrite replaces the text. `stream()` emits the new `guardrail.tripped` (before `run.done`) and `guardrail.rewrote` events. Sub-agents run their parent's guardrails, then their own. Built-ins: `maxLengthGuardrail({ maxChars })`, `regexGuardrail({ name, pattern?, action?, replacement? })` (defaults to the secret-scan patterns, now exported as `SECRET_PATTERNS`), `denyTopicsGuardrail({ topics })` and `llmJudgeGuardrail({ model, instruction })`. The patch guardrails (`runGuardrails()`, `secretScanGuardrail`, ...) are unchanged; the new types are named `IoGuardrail*` because `Guardrail` and `GuardrailResult` are theirs. `spec.policy.guardrails` in agent spec files is not wired yet. See docs/guardrails.md#input-and-output-guardrails. * Deployed `/chat` upgrade: the `node-server` and `docker` targets (`dist/server.js`) now serve the `lousho dev` protocol: `POST /chat { sessionId, input }` streams the turn as SSE (`data: `, then `event: done`) with history kept per session, `GET /chat/:sessionId`, `POST /chat/:sessionId/approvals/:id` (`{ approved, note? }` or `{ answer }`) and `GET /health`; the single-turn `POST /chat { message }` still works (now with a `Deprecation: true` header). The routes are one shared module, `src/server/chatRoutes.ts`, used by `lousho dev` and the deployed runtime. `LOUSHO_STORE` (`memory`, the default, or `sqlite:`, Node >= 22) picks where sessions live. Bearer auth: set `LOUSHO_API_TOKEN` (or pass `{ auth: { token } }` as the new optional third argument of `adapter.scaffold()`; the variable wins) and every route except `/health` requires `Authorization: Bearer ` (constant-time compare, `401` JSON otherwise); treat it as required for anything not on localhost. The `docker` image is now based on `node:22-slim` (was `node:20-slim`) for `node:sqlite`. `specToAgent(spec, { store })` takes an optional `store`. The Cloudflare Worker target is unchanged (sessions over KV and SSE there are a separate follow-up). See docs/deployment.md#http-api. * `lousho chat`: a terminal REPL for an agent. `lousho chat [--model provider/model] [--session id] [--store sqlite:]` loads the target like `lousho dev` and reads one turn per line through `agent.session({ id, store }).stream()`: text deltas as they arrive, each tool call as a dim `[tool_name] {args}` line followed by ` -> result`, `Approve (args)? [y/N]` for a tool that needs approval, a numbered prompt with free text for an `ask_question` call, and `[usage]` tokens and cost after each turn. Slash commands: `/new`, `/model ` (rebuilds the agent, the session continues), `/history`, `/quit`. SDK errors print with their `[LOUSHO_...]` code and fix. The loop is `runChatRepl({ input, output, createAgent })` in `src/cli/chatRepl.ts` (testable with scripted lines and a `mockModel` agent); `loadTarget()` is now exported from `src/cli/devReload.ts`. See docs/cli.md#lousho-chat. * Memory slots: `defineMemory({ name, description?, scope, provider, recall?, expose? })` (new export) defines a named long-term memory, and `createAgent({ memory: [slot] })` wires it in. On the first model call of every run (`send()`, `stream()`, each session turn), a `preGenerate` hook recalls the slot's newest `recall.maxItems` (default 10) items into the system prompt as a `` block (`recall.query: 'last-input'` passes the last user message as the provider query; `recall.onSessionStart: false` turns recall off). The model gets `remember_({ text })` and `recall_({ query?, limit? })` tools (`expose: { remember?, recall? }`, both on by default). `scope` is `'global'`, `'session'` (keyed by `agent.session({ id })`'s id or `send()`'s `sessionId`) or a function of `{ sessionId, metadata }`; the new `SendOptions.metadata` feeds it. Providers implement `MemoryProvider` (`list` / `add` / `remove`); built in are `inMemoryMemory()` and `fileMemory({ dir })` (one JSON file per scope key), both bounded by `maxItems` (default 1000, oldest dropped). See docs/memory.md. * Budgets: `limits?: { maxTokens?, maxInputTokens?, maxOutputTokens?, maxCostUsd?, maxDurationMs?, maxSteps?, onExceeded? }` on `createAgent()` and `ExecuteOptions` (new type `RunLimits`). Limits are checked before every model call (so after every tool batch) and after a model call that asks for tools, and `maxDurationMs` aborts an in-flight model or tool call through the run's signal. A tripped limit ends the run with the new `finishReason: 'budget-exceeded'` and `result.budget: { limit, value, max, scope }` (`BudgetExceeded`); tool calls of that step get a cancelled result and the run is checkpointed as finished, like `'max-steps'`. `stream()` emits the new typed `budget.exceeded` event (`BudgetExceededEvent`) before `run.done` (additive, the event schema version stays 1). `onExceeded: 'throw'` rejects with the new `BudgetExceededError` (`LOUSHO_BUDGET_EXCEEDED`, no longer reserved) instead. `limits.maxSteps` is an alias of `maxSteps` (the stricter wins; alone it replaces the default of 10). Sub-agents' usage counts toward their lead's budget. `agent.session({ id, limits })` applies limits across a session's turns: what the turns spent is saved as `metadata.sessionUsage` on the transcript's last message (new types `BudgetSpent`, `SessionBudget`, `SessionTurnOptions`; `ExecuteOptions.sessionBudget` carries it to the run), so `maxCostUsd` per session holds across processes. See docs/configuration.md#budgets. * Stateful streaming dev chat: `lousho dev` serves one session per browser tab. `POST /chat` with `{ sessionId, input }` streams the turn as SSE (`data: ` per event, then `event: done`) through `agent.session({ id, store }).stream(input)` over an in-process `memoryStore()` (or the agent's own `store`, when a module's `createAgent()` options or `overrides` set one); `GET /chat/:sessionId` returns the transcript and the pending approval; `POST /chat/:sessionId/approvals/:id` with `{ approved, note? }` or `{ answer }` decides an approval or answers a question and streams the continuation (rebuilt from the continued run's result, since `agent.approvals.resolve()` is not streamed). The chat page renders text deltas, tool calls with arguments and results, Approve / Reject and question buttons, errors and `run.done` usage and cost, with a "New session" button and the id kept in `sessionStorage`. Sessions survive a hot reload and continue on the new agent. The `{ message }` body of `POST /chat` still works and is deprecated (no session, JSON `ExecutionResult`, `Deprecation: true` header). See docs/cli.md#lousho-dev and docs/streaming.md. * Agent Forge History tab: the bottom drawer's new **History** tab lists the loaded agent's run steps (step, status, finish reason, tool calls, tokens, cost). "Edit and replay from here" appends a user message or edits one of the step's tool results, forks the run at that step through `POST /runs/:id/fork`, and shows the fork next to the original as a side-by-side trajectory with the diverging turn and the drift entries highlighted, refreshed when the fork finishes. See docs/agent-forge.md#the-history-tab. * `lousho dev` for agent directories and TS agents, with hot reload: `lousho dev ` takes a spec file (as before), an agent directory (`loadAgentDir()`) or a `.ts`/`.js` module whose default (or `agent`) export is a `SimpleAgent` or a `createAgent()` options object, detected by path type and extension (a bad path, extension or export fails with `LOUSHO_CONFIG_INVALID` / `LOUSHO_SPEC_UNSUPPORTED_FORMAT`). It watches the directory tree, or the module and its relative imports, and rebuilds the agent about 100 ms after a change with a `?t=