> ## Documentation Index
> Fetch the complete documentation index at: https://lousho.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Changelog

> Every notable change to @lousho/build-ai-agent, newest first.

## \[Unreleased] - 2026-09-28

### Published

* `@lousho/build-ai-agent` and `create-lousho-agent` are on npm. The README and the installation, quick start, CLI and Agent Forge docs no longer say the package is unpublished; "Installing before the first release" is now "Installing from a local build" (docs/installation.md#installing-from-a-local-build) and covers trying an unreleased commit. The hint printed after a failed `lousho init` install no longer mentions a 404; it points to `--sdk-path`. The docs site moved to [https://lousho.com](https://lousho.com).

### Renamed

* The project is now **lousho** (it was `loushy`), before the first npm release, so nothing was ever published under the old name. Everything that carried the name changed with it, and this changelog uses the new names throughout, including in older entries: the package `@lousho/build-ai-agent` (was `@loushy/build-ai-agent`), the `lousho` CLI (was `loushy`), `create-lousho-agent` (was `create-loushy-agent`), the exports `useLoushoAgent`, `loushoAgent`, `LoushoAgentSource`, `LoushoUIMessageChunk` and the other `Lousho*` types, every `LOUSHO_*` error code and environment variable (`LOUSHO_MODEL`, `LOUSHO_API_TOKEN`, `LOUSHO_STORE`, ...), the `.lousho/` directory, the `lousho.*` span attributes and the `data-lousho-approval` stream part. Migration for a checkout that used the old name: replace `loushy` with `lousho` (keeping the case) in imports, scripts, environment variables and config, and rename an existing `.loushy/` directory to `.lousho/`.

### Added

* Remote sub-agent approvals go through the lead run (LOU-Y7.3): 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: [<remote agent>]`, and a remote `ask_question` arrives as a question. Deciding it on the lead (`resolve`, `streamResolve`, `answer`) posts the decision to the remote `POST <url>/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 (LOU-V4.2): `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<TObject = unknown>`): `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<T>` / `SpecObjectSchema<T>`, 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 (LOU-D49): `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 (LOU-D41): `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.

### Deprecated

* `ExecuteOptions.onEvent`, `ExecutionEvent` and `ExecutionEventType` (LOU-D41). `onEvent` keeps working: the executor now emits only `AgentEvent`s, and an adapter derives the old events from them in the old order (`start`, `text-complete`, `tool-call`, `tool-result`, `error`, `abort`, `finish`, sub-agents' tagged with `subagent`), with a one-time `console.warn`. Differences: `finish` now comes after the run's checkpoint is written (it was just before), and a run that fails outside a step (an input guardrail that throws, `onRunEnd` throwing) now gets an `error` event too. Migration: replace `onEvent` with `onAgentEvent` (or `createAgent({ onEvent })`); `start` -> `run.start`, `text-complete` -> `text.done` (step usage on `step.done`), `tool-call` -> `tool.start`, `tool-result` -> `tool.done` / `tool.error`, `abort` / `finish` -> `run.done` (`finishReason`), `error` -> `error` (`{ name, message }`). The full table is in docs/streaming.md#migrating-from-onevent--executionevent.

### Removed

* `ExecuteOptions.streaming` (LOU-D41): it was never read. Use `AgentExecutor.stream()` / `agent.stream()` to stream a run. Passing it in an object literal is now a type error; remove the property.
* `ToolDescriptor.injectStreamingController` and `defineTool({ injectStreamingController })` (LOU-D41.2, deprecated earlier in this release): no SDK path ever called it, so removing the property changes nothing at runtime. Passing it is now a type error; remove the property.
* `AgentExecutionOptions.streaming` (LOU-D41.2): it was never read (`AgentExecutionOptions` is a legacy type no SDK API takes). Remove the property; stream a run with `agent.stream()` / `AgentExecutor.stream()`.

### Fixed

* 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.
* `lousho --help`, `-h` and `help` print the usage and exit 0 (they were "unknown command" with exit 1) (LOU-D49).
* Install truth (LOU-U20): the README, installation, CLI, quick start and Agent Forge docs, `lousho init --help` and the message after a failed `lousho init` install now say the package is not on npm yet and point to one section, "Installing before the first release" (docs/installation.md); `npm install github:LinuxDevil/agent-sdk` is documented as not working. Stale "planned / not yet" statements about agent-directory channels, `toolCallId` in sandboxed tools and the Agent Forge Settings tab are corrected.

### Changed

* No `any` left in the SDK's types (LOU-D16): `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<T>` 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<T>()` (and `IStorageService`'s) defaults `T` to `unknown`: pass the type you stored, `readPlainJSONAttachment<MyRecord>(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<string, unknown>`; `validateAgentTools()` takes `Record<string, { tool?: unknown }>`; `createDynamicZodSchemaForInputs()` returns `z.ZodObject<Record<string, z.ZodTypeAny>>`.
  * 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 (LOU-D46.2): `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 (LOU-D2.2): 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 (LOU-D2.3): 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 (LOU-D16): 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.

### Added

* Reasoning (LOU-V13): 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 (LOU-D23.2): `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 `<id>.turn-<n>`, as hooks see it), approval resumes (the paused run's), and sub-agents (`<parent session>/<task tool call id>`). 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 (LOU-P5.2): `approvers` (a list of platform user ids, or `(user: { id, name?, roles? }, { toolName, input, sessionId }) => boolean | Promise<boolean>`) 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 (LOU-Y6): every `task` result ends with a `taskId` (the footer is now `[sub-agent '<name>': N step(s), finish reason '<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_<n>` 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 (LOU-Y7.2; `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 (LOU-P8.3): `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 (LOU-W9.2): 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 (LOU-X12.2): `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 (LOU-U23): `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 (LOU-P9): 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:<name>` (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 (LOU-D28d): `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` (LOU-D50): `lousho add <name> [--registry <url-or-path>] [--dir <agent-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 (LOU-D43.2): `KVCheckpointStore` (and `KVStore`, with the new `historyLimit` option) keeps the bounded per-session history `memoryStore()` keeps, as one index key (`<prefix><sessionId>#history`) and one key per entry (`<prefix><sessionId>#history/<id>`) 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 (LOU-X3): 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` (LOU-Z6): `lousho acp <spec.yaml|spec.json|agent-dir|agent.ts> [--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 (LOU-P8.2): `lousho build <agent-dir> --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 <agent-dir>` 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 (LOU-P4): `createRouteHandler(agent, { basePath?, auth?, uiMessageStream? })` returns `{ GET, POST, handler }`, Fetch handlers (`(request: Request) => Promise<Response>`) 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<boolean>` (default none: a public route needs one; `GET /health` stays open). `useLoushoAgent({ url, approvalsUrl })` works against it (`POST <basePath>` with `{ input, sessionId? }`, `POST <basePath>/approvals/:id`), and `uiMessageStream: true` adds `POST <basePath>/ui` for `useChat`. `hasBearerToken` in `src/server/fetchRoutes.ts` is now exported for it. See docs/nextjs.md.
* AI SDK UI stream (LOU-P1): `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 (LOU-X8): 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 (LOU-D32.2): `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()` (LOU-W8): `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 (LOU-P6): `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:<text>`) 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 (LOU-V14): `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 (LOU-X12): `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/<host>/<path>` form (forwarded to `https://<host>/<path>`, 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 LOU-X12.2. New types `CredentialBroker`, `CredentialBrokerOptions`, `BrokerHeaderValue`. See docs/workspace-tools.md#credential-broker.
* Memory in agent directories (LOU-W6.3): `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 (LOU-P7.2): `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 (LOU-Z5.2): `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 (LOU-P3): 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 (LOU-V15): `createAgent()`'s `model`, `instructions` / `prompt` and `tools` each also take a function of the run, `(ctx) => value | Promise<value>` (new types `PerRun<T>` 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 (LOU-P2): 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 (LOU-U21): 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? })` (LOU-W6.2, 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 (LOU-P5): `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 (LOU-Y7): `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 <url>/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 (LOU-D47): `lousho eval --url <baseUrl> [--token <bearer>]` (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 (LOU-P8): `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 (LOU-P7): `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 <basePath>/<name>` (verify -> parse -> a turn in session `` `${name}:${sessionKey}` `` -> reply; turns of one session run one at a time) and `POST <basePath>/<name>/approvals/<id>` (`{ 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 LOU-P5 / LOU-P6. See docs/channels.md.
* Cloudflare Worker parity (LOU-D51): 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 <token>` (constant-time compare). New `KVStore(kvBinding, { prefix?, ttl? })` (`src/deploy/kvStore.ts`) is an `AgentStore` on a Workers KV namespace: `sessions/<id>` (transcript JSON, bytes as `{ "$bytes" }`), `checkpoints/<id>` (`KVCheckpointStore`, which gains an optional TTL) and `approvals/<id>`, 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 (LOU-V10): `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()` (LOU-D27): the built-in providers' `stream()` (and so `agent.stream()`) now works on `ai` v6 and v7 as well as v4, through the LOU-D26 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 (LOU-V13). `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 for LOU-D28; an override returning `CoreMessage[]` still compiles. The `ai`/`@ai-sdk/*` peer ranges still name v4 (LOU-D28). 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 (LOU-X5): `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 <spec>` 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()` (LOU-D26): 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 naming LOU-D27 until that ticket lands; the `ai` and `@ai-sdk/*` peer ranges still name v4 (LOU-D28). 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 (LOU-V9): `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 LOU-U8 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 the follow-up, LOU-V10. See docs/streaming.md#queued-input and docs/sessions.md.
* Input and output guardrails (LOU-X4): `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 (LOU-X5). See docs/guardrails.md#input-and-output-guardrails.
* Deployed `/chat` upgrade (LOU-D14): 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: <AgentEvent JSON>`, 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:<path>`, 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 <token>` (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 LOU-D51). See docs/deployment.md#http-api.
* `lousho chat` (LOU-D33): a terminal REPL for an agent. `lousho chat <spec|dir|module> [--model provider/model] [--session id] [--store sqlite:<file>]` 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 <tool>(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 <provider/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 (LOU-W6): `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 `<memory name="...">` block (`recall.query: 'last-input'` passes the last user message as the provider query; `recall.onSessionStart: false` turns recall off). The model gets `remember_<name>({ text })` and `recall_<name>({ 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 (LOU-V6): `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 (LOU-D32): `lousho dev` serves one session per browser tab. `POST /chat` with `{ sessionId, input }` streams the turn as SSE (`data: <AgentEvent JSON>` 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 (LOU-D45.2): 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 (LOU-D31): `lousho dev <path>` 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=<time>` cache-busted import, so edited tools reload; the new agent is built and `ready()` before it replaces the old one, which is then `close()`d. A failed rebuild keeps the last good agent and reports the error in the console, in a banner on the chat page and in the new `GET /dev/status` (`{ kind, path, reloads, error }`). `startDevServer(path, port?, host?, { overrides?, debounceMs? })` gains the options argument. Spec-file behaviour is unchanged; the chat is not streamed or per-tab (LOU-D32). See docs/cli.md#lousho-dev and docs/agent-directories.md#run-it-with-lousho-dev.
* Multimodal input through the public API (LOU-V12): `agent.send()` / `agent.stream()`, `session.send()` / `stream()`, `t.send()` in evals and `useLoushoAgent().send()` take the new exported `AgentInput` (`string | ContentPart[] | Message[]`): a string or parts become one user message, a `Message[]` is passed through (in a session it is appended to the transcript). Checkpoints, `agent.resume()` and queued input hold the parts like any message. The React user bubble shows the text with an `[image]` / `[file]` marker per non-text part. `SqliteStore` sessions and checkpoints save `Uint8Array` parts as `{ "$bytes": "<base64>" }`, as `FileSessionStore` does. Finishes LOU-V11 (its note that `agent.send()` / `session.send()` take no parts is out of date). See docs/providers.md#multimodal-input.
* Built-in `ask_question` tool (LOU-X9): `createAgent({ askQuestion: true })` (off by default) or `askQuestionTool()` gives the agent `ask_question({ question, options?, allowFreeText? })`. A call pauses the run through the approval mechanism, so it waits durably like any approval (sessions, `memoryStore()` / `SqliteStore`, a restart between question and answer). The pending record gains optional `kind: 'question'` and `question: { text, options?, allowFreeText? }` (new types `ApprovalKind`, `ApprovalQuestion`; `describeApproval()` and `ASK_QUESTION_TOOL_NAME` are exported), and so do the `approval.requested` event and the React hook's `pendingApproval`. The new `agent.approvals.answer({ id, answer })` (the same as `resolve({ id, approved: true, note: answer })`) continues the run with `{ answer, option? }` as the tool result (`option`: index of the matching option); `approved: false` gives the model a `kind: 'rejected'` error saying the user declined to answer. `useLoushoAgent()` gets `answer(text)`. The `approve` callback may now return a string (`ApproveToolCall` returns `boolean | string`), which approves with that note, so code can answer questions. A tool run after an approval sees the decision's note as `ctx.approval.note` (new optional `ToolExecutionContext.approval`). See docs/approvals.md "Asking the user a question".
* Agent Forge time travel API (LOU-D45): Agent Forge's `FileCheckpointStore` keeps the bounded per-session checkpoint history (`history()`, `historyLimit` option, `delete({ keepHistory })`) with the same semantics as `LocalStorageCheckpointStore`, checked by the SDK's history contract suite. The runtime control server gains `GET /runs/:id/history` (each step's status, save time, finish reason, tool calls, tokens and cost), `POST /runs/:id/fork` (`{ fromStep, patch?: { toolResult?, appendInput?, businessState? } }`, forks with `AgentExecutor.fork()` and starts the fork as a run of its own) and `GET /runs/compare?a=&b=` (`compareTrajectories()`). Fixed on the way: Agent Forge's chat transcript compiles against multimodal `Message.content` (LOU-V11) again, keeping each message's text. See docs/agent-forge.md#time-travel.
* Fork and replay (LOU-D44): `AgentExecutor.fork({ sessionId, fromStep, newSessionId?, checkpointStore, patch? })` takes the newest entry of `fromStep` in the session's checkpoint history, applies `patch` (`messages(messages)`, `businessState`, `toolResult: { toolCallId, result }` replaced in place so the transcript stays provider-valid, `appendInput` queued as a user message) and saves it as the `'in-progress'` checkpoint of `newSessionId` (default `<sessionId>.fork-<n>`), leaving the original session untouched; it returns `{ sessionId, step, checkpoint }`, and `execute({ sessionId, checkpointStore, input: [] })` continues the fork. A missing step throws the new `LOUSHO_CHECKPOINT_NOT_FOUND` code. `createAgent()` agents gain `agent.fork(sessionId, { fromStep, newSessionId?, patch? })` over `store.checkpoints` (resume with `agent.resume()`). `compareTrajectories(a, b)` compares two checkpoints or transcripts step by step: each run's steps (text, tool calls and results), the first diverging step and the `lousho eval --drift` diff. New types `ForkOptions`, `ForkPatch`, `ForkResult`, `TrajectoryComparison`, `TrajectoryStep` and `DriftEntry`. See docs/durable-execution.md#fork-and-replay.
* Multimodal message parts (LOU-V11): `Message.content` is now `string | ContentPart[]`, where a `ContentPart` is `{ type: 'text', text }`, `{ type: 'image', image, mimeType? }` (an `http(s)` URL, a `data:` URL or a `Uint8Array`) or `{ type: 'file', data, mimeType, filename? }` (new types `ContentPart`, `TextContentPart`, `ImageContentPart`, `FileContentPart`). The built-in providers map the parts of `user` messages to the `ai` v4 user-content parts: images go to the model with all four (the `ai` SDK downloads an image URL first for Anthropic and Ollama); file parts become a `[file <name> (<mimeType>) not sent]` text note with a one-time `console.warn`, since the pinned peers (`@ai-sdk/*` 0.0.x, `ollama-ai-provider`) cannot send files (a subclass sets `acceptsFileParts = true` to send them). `system`, `assistant` and `tool` messages are sent as their text. The new `textOf(message)` returns a message's text (the string, or its text parts joined), and is what `estimateTokens()` (each image or file part counts 1,000 tokens), compaction, `recordReplay()` fingerprints (cassette messages gain an optional `attachments` list: each image or file as its URL or a digest of its bytes), `mockModel()`, `MockLLMProvider`, OpenTelemetry content attributes and the React reducer (`ui.send` takes parts too) now read. `FileSessionStore` saves `Uint8Array`s as `{ "$bytes": "<base64>" }` and loads them back. Pass multimodal messages as `AgentExecutor.execute({ input: Message[] })`; `agent.send()` / `session.send()` taking parts is LOU-V12. See docs/providers.md#multimodal-input.
* Declarative permission policies (LOU-X2): `permissions?: PermissionRule[]` on `createAgent()`, `ExecuteOptions` and `resumeAfterApproval()` options. A rule is `{ tool: string | string[] | RegExp | '*', when?: (args, { toolName, toolCallId, sessionId }) => boolean | Promise<boolean>, action: 'allow' | 'deny' | 'ask', reason? }`; rules are checked in order for each tool call (after argument validation and `preToolCall` hooks, before `needsApproval`) and the first match decides: `allow` runs the call without approval even when `needsApproval` would ask, `deny` gives the model a tool error with the new `kind: 'denied'` (`ToolDeniedError`, plus `reason`) and streams `tool.error`, `ask` pauses for approval (or asks `approve`). No match keeps today's behaviour. New helpers `allow(tools)`, `deny(tools, reason?)`, `ask(tools)` and types `PermissionRule`, `PermissionAction`, `PermissionToolMatcher`, `PermissionContext`, `PermissionDecision`, `PermissionDecisionEntry`, `PermissionOptions`. Audit log: `onPermissionDecision(entry)` gets `{ toolName, toolCallId, decision: 'allow' | 'deny' | 'ask' | 'default', rule?: { index, reason? }, args?, at }` per call (`args` left out under `redactContent`), and streams get the same entry as the new typed `permission.decision` event (`PermissionDecisionEvent`; additive, the event schema version stays 1). Neither is produced unless `permissions` or `onPermissionDecision` is set. Sub-agents inherit the lead's rules (checked before their own) and its `onPermissionDecision`. See docs/approvals.md#permission-policies.
* Compaction events and the `compaction` option (LOU-W3.2): `agent.stream()`, `session.stream()` and `AgentExecutor.stream()` emit two new typed events whenever the compaction hook compacts a request: `compaction.start` (`strategy`, `tokensBefore`, `contextWindow`, `thresholdTokens`) before the work and `compaction.done` (`strategy`, `tokensBefore`, `tokensAfter`, `prunedToolCallIds`, `summary?`, `error?: { message }`) after it, inside the step and before the model call; `error` is set when the strategy failed and the hook fell back. Additive: the event schema version stays 1. `GenerateHookContext` gains the optional `emit(event)` (set in streamed runs only) that the hook uses. `createAgent({ compaction })` takes `true` (the prune hook with its defaults) or `{ strategy?, thresholdPercent?, contextWindow?, protectedTokens?, summarizer? }`, where `summarizer` (a `'provider/model'` string or an `LLMProvider`) selects `twoPhaseStrategy()` with that model; `summarizer` together with `strategy` is a `ConfigurationError`. `createAgent({ hooks })` (new) takes any `AgentHook[]`, run before the compaction hook. See docs/compaction.md.
* Connect MCP servers from config (LOU-Z4): `connectMcp(servers, { lazy?, onError?, logger? })` (`@lousho/build-ai-agent/mcp` and the root) connects stdio (`command`/`args`/`env`) and streamable HTTP (`url`/`headers`) servers, lists their tools as `<server>__<tool>` descriptors and returns `{ tools, close(), status() }`. Every server connects up front (listing tools needs a connection); `lazy` (default `true`) reconnects on the next tool call after `close()` or a dropped connection. `onError: 'skip'` leaves a failing server out with a warning instead of rejecting. `createAgent({ mcpServers })` connects them on the new `agent.ready()` or the first `send()` / `stream()` and adds their tools; the new `agent.close()` disconnects them (both are no-ops without `mcpServers`). `specToAgent()` now connects `spec.mcpServers` the same way (closes TODO(LOU-D20.2)); `agent.mcpServers` still lists them. `loadMcpTools()` accepts any `{ listTools, callTool }` (`McpClientLike`). See docs/configuration.md#connect-mcp-servers-mcpservers-connectmcp.
* Error codes with fixes (LOU-D2): every `SDKError` now has a stable `code` (`LOUSHO_<AREA>_<NAME>`, e.g. `LOUSHO_CONFIG_MISSING_PROVIDER`, `LOUSHO_PEER_MISSING`, `LOUSHO_SPEC_INVALID`, `LOUSHO_SESSION_AWAITING_APPROVAL`), a one-sentence `hint` and a `docs` link to its section of the new docs/errors.md; its message ends with a `[code] hint (docs)` line (`detail` is the message without it), and `toString()` always shows it. `ERROR_CODES` / `ErrorCode` (new exports) are the registry, and a test keeps it, the codes used in `src/` and docs/errors.md in sync. The plain `Error`s thrown by `createAgent()` (no model, `instructions` + `prompt`, `sessionId` without checkpoints), `resolveProvider()` (bad spec, unknown prefix, missing API key, missing peer), `AgentExecutor.execute()` / `stream()` (missing `provider` / `agent` / `input`, approval with no `approvalStore`), `resumeAfterApproval()` (unknown approval), sessions (invalid id, corrupt file, second iteration, no stream runner), `loadSpec()` and `specToAgent()` are now `ConfigurationError` / `ValidationError` / `SDKError` with codes; `MissingPeerDependencyError` extends `SDKError`. Existing message text is unchanged (the help line is appended). `loadSpec()` suggests the field a top-level typo meant (`unknown field 'promt' (did you mean 'prompt'?)`). See docs/errors.md.
* Checkpoint history (LOU-D43): `memoryStore()`, `SqliteStore` and `LocalStorageCheckpointStore` keep a bounded history per session: every `save()` also appends the record to a ring (`historyLimit` option, default 50, `0` keeps none; the oldest are dropped), and the new optional `CheckpointStore.history(sessionId, { limit? })` returns `{ step, savedAt, status, checkpoint }` entries newest first. `CheckpointStore.delete(sessionId, { keepHistory: true })` keeps the history (by default `delete()` clears it too), `getCheckpointHistory(store, sessionId)` reads it and returns `undefined` for a store without `history()`, and `SqliteStore` gains the `checkpoint_history` table (added when an existing database file is opened; `prune()` removes old entries). New types `CheckpointHistoryEntry`, `CheckpointHistoryOptions`, `CheckpointDeleteOptions` and `MemoryStoreOptions`. `KVCheckpointStore` and Agent Forge's file store keep no history yet (LOU-D43.2). This is the first step of time-travel from a past step. See docs/durable-execution.md#checkpoint-history.
* `lousho eval --record / --replay / --drift` (LOU-D46): `--record` runs every eval case against its agent's real provider through `recordReplay()` and writes one cassette per case next to the eval file (`__cassettes__/<eval-name>/<case>.json`, the normal cassette format); `--replay` runs every case from its cassette with no network, and a missing cassette fails the case with the `--record` command to run. Plain `lousho eval` with `CI` set replays the cases that have a cassette; without `CI` it is unchanged. `--drift` re-records into a temp directory and compares each case with its committed cassette (ordered tool names, JSON-normalized tool arguments, step count, finish reason; tokens with `--drift-usage`): the summary gets a `Drift:` table and each drifted case a soft `drift` assertion (a JUnit `<system-out>` note), or a gate failure with `--strict`. Existing eval files need no change: while a case runs, `AgentExecutor.execute()` wraps its provider for that case. `EvalResult` gains `cassettes`. Fixed on the way: trajectory and classic eval results now carry their `file` (it was read before the test ran and was always empty). See docs/evals.md#record-replay-and-drift.
* OpenTelemetry GenAI metrics and cost (LOU-D48): `createOtelTraceExporter()` (`@lousho/build-ai-agent/otel`) now also records the GenAI client metrics `gen_ai.client.token.usage` (histogram, `{token}`, one record per `gen_ai.token.type` `input`/`output`) and `gen_ai.client.operation.duration` (histogram, `s`, for every model call and tool call) through `@opentelemetry/api`'s metrics API: the global `MeterProvider`, or the new `meter` option; `metrics: false` turns them off. Without a registered `MeterProvider` they are discarded by OpenTelemetry's no-op meter. Spans gain `lousho.cost_usd` (estimated USD: per step on `chat` spans, cumulative on `invoke_agent` spans, absent when the price table does not know a model) and `lousho.usage.estimated` on `invoke_agent` spans (it was already on `chat` spans). Any `TraceExporter` sees the cumulative attributes: `withSpan()` sums the cost of finished child spans onto their parent. See docs/observability.md.
* One `store` option (LOU-D30): `createAgent({ store })` takes an `AgentStore` (`{ sessions?, checkpoints?, approvals? }`, new export) and wires all three from it. `SqliteStore` is one; the new `memoryStore()` returns in-memory sessions, checkpoints and approvals, and docs/sessions.md shows the file-based combination (`FileSessionStore`, `LocalStorageCheckpointStore`, `StorageServiceApprovalStore`). `agent.session({ id })` then keeps its transcript in `store.sessions` and checkpoints every turn in `store.checkpoints`; `store.approvals` is the default `approvalStore`. `SendOptions.sessionId` makes `send()` / `stream()` a durable run checkpointed in `store.checkpoints` (an error without one), so a one-shot durable job needs no `AgentExecutor`, and `agent.approvals.resolve()` keeps checkpointing it. The new `agent.resume(sessionId)` finishes an interrupted `send(..., { sessionId })` run, or else the session's pending turn (as `session.resume()`), and returns `null` when nothing is pending. A session's own `store` / `checkpointStore` and an explicit `approvalStore` still win, part by part. Without `store`, nothing changes.
* One tool-error shape (LOU-U14): every failed tool call reaches the model as `{ error, toolName, message, kind }` (message capped at 2,000 characters, transcript message flagged `isError`), whichever path failed: `kind` is `'execution'`, `'validation'` (plus `issues`), `'not-found'`, `'rejected'` (plus `note`), `'not-run'`, `'mcp'` or `'sandbox'`. New exports `toolErrorResult()`, `ToolErrorResult`, `ToolErrorKind` and `ToolErrorInput`; `McpToolError` and the sandbox guard's error (named `SandboxRequiredError`, not exported) carry a `toolErrorKind`. `kind` is additive on thrown-error and validation results. The paths that built their own shapes changed, see Changed. See docs/tools.md.
* Background sub-agents end with the lead run (LOU-Y4.2): when a run ends (any `finishReason`, or an error), its background sub-agents still queued or running are cancelled, and the run resolves once their runs have stopped, so none of their events reaches `onEvent` afterwards. `withSubagentOptions(subagents, { awaitBackgroundOnFinish: true })` waits for them instead (an aborted or failed lead still cancels them). The new `result.backgroundTasks` lists the run's background tasks as `{ taskId, agent, status, elapsedMs, ... }` (absent when it started none). `createAgent({ subagentOptions: { maxConcurrent, awaitBackgroundOnFinish } })` sets the same options (over any attached with `withSubagentOptions()`, without changing the `subagents` value). The executor hook behind it is public: `ExecuteOptions.onRunEnd({ result } | { error })` is called exactly once per `execute()` / `stream()` run, however it ends; an error it throws rejects the run. See docs/sub-agents.md.
* Durable sessions (LOU-W9): `agent.session({ id, store, checkpointStore })` (or a `store` that carries both, `{ sessions, checkpoints }`, such as a `SqliteStore`) runs each turn with `sessionId: '<id>.turn-<n>'` and that `CheckpointStore`, so it is checkpointed after every model response and tool result. `session.resume()` finishes a turn interrupted by a crash, a failed checkpoint write or a `PropagatingToolError` (also in a new process) without re-running recorded tool calls or model responses, adds it to the transcript as `send()` would and returns its result (`null` when nothing is pending); `session.pending()` reports an unfinished turn and `session.discardPending()` drops it. `send()` / `stream()` resume a pending turn before sending the new message. A turn paused for approval stays in its checkpoint until it finishes: `resume()` / `send()` throw `SessionAwaitingApprovalError`, and `agent.approvals.resolve()` continues it in the session. Sessions without a checkpoint store are unchanged. See docs/sessions.md.
* Structured output (LOU-V4): `createAgent({ output: zodSchema })` (and `ExecuteOptions.output`) makes the final reply a JSON object validated by the schema. `send()`, `run.result` and `agent.approvals.resolve()` resolve with it as `result.object`, typed `z.output<typeof schema>` from `send()` and `stream()` of `createAgent()` agents (`SimpleAgent`, `AgentRun` and `ExecutionResult` gain a type parameter that defaults to `unknown`); `result.text` keeps the raw JSON. The system prompt gets an `## Output format` section with the schema as JSON Schema, and each model call carries the new `GenerateOptions.responseFormat: { type: 'json', schema? }` hint, which the `ai`-SDK providers map to `experimental_output` JSON mode. The reply is parsed (a code fence is tolerated) and validated; when invalid, one repair step sends the model the issues (it counts against `maxSteps`), and a reply still invalid ends the run with the new `finishReason: 'output-invalid'`, no `object` and `outputError: { message, issues }`. `run.done` gains an optional `object` field (additive: the event schema version stays 1). See docs/structured-output.md.
* Background sub-agents (LOU-Y4): the `task` tool takes `background: true`, which starts the sub-agent and returns `{ taskId, status: 'running', agent }` at once. Agents with `subagents` also get `agent_status({ taskId? })` (`queued`/`running`/`done`/`failed`/`cancelled`/`awaiting-approval`, plus `elapsedMs`), `agent_await({ taskId | taskIds, timeoutMs? })` (the synchronous `task` result, or `status: 'timeout'`) and `agent_cancel({ taskId })`. `withSubagentOptions(subagents, { maxConcurrent })` bounds how many run at once per lead run (default 3; the rest queue). Background sub-agents inherit the lead run's runtime and `maxSubagentDepth`, and aborting the lead cancels them. Not yet: resuming one that paused for approval (it reports `awaiting-approval` with its `approvalId`). A user tool named `agent_status`, `agent_await` or `agent_cancel` now conflicts with `subagents`, like `task`. See docs/sub-agents.md.
* React hook (LOU-D15): new `@lousho/build-ai-agent/react` subpath with `useLoushoAgent(source, options?)`. `source` is `{ agent, sessionId? }` (in process, via `agent.stream()` or `agent.session({ id }).stream()`) or `{ url, headers?, fetch? }` (POSTs `{ input }` and reads the SSE or NDJSON event stream). It returns `messages` (text and tool calls with status, args and result), `status` (`idle`/`streaming`/`awaiting-approval`/`error`), `pendingApproval`, `error`, `usage`, `lastEvent`, `send()`, `stop()` (aborts; unmount aborts too), `approve(note?)` and `reject(note?)` (via `agent.approvals.resolve()` in process, or `POST ${approvalsUrl}/${id}` remotely). The framework-neutral `reduceAgentEvents()` reducer and `parseEventStream()` parser are exported from the same subpath. `react` (`^18 || ^19`) is an optional peer dependency. See docs/react.md.
* Summarizing compaction (LOU-W3): `summarizeStrategy({ model, protectedTokens?, prompt?, maxSummaryTokens? })` replaces the turns before the protected tail with one `[Conversation summary]` user message written by `model` (an `LLMProvider` or a `"provider/model"` spec), keeping system and pinned messages and each assistant turn together with its tool results; if the summary call fails it falls back to pruning and reports `error`. `twoPhaseStrategy()` (the recommended strategy) prunes first and summarizes only when still above the threshold. `pinMessage()` / `isPinned()` and the new optional `Message.metadata` (never sent to providers) mark messages no built-in strategy compacts. `CompactionStrategy.compact()` may now be async, so `compactMessages()` returns a Promise; `CompactionResult` / `CompactionInfo` gain `summary` and `error`, and `CompactionInput` gains `thresholdTokens` and `signal`. A strategy that throws no longer fails the run: the hook reports the `error` to `onCompaction`. See docs/compaction.md.
* `createAgent({ retry, fallbackModels })` (LOU-V7.2): `retry` (`withRetry()` options, or `false`) retries failed model calls and defaults to `{ maxRetries: 2 }` for models given as `provider/model` strings (or picked from the environment); a `provider` instance is wrapped only when `retry` is set. `fallbackModels` are `provider/model` strings tried in order once the primary model's retries are used up (`withFallback([withRetry(primary), withRetry(fallback), ...])`). `agent.stream()`, `session.stream()` and `AgentExecutor.stream()` report them as two new typed events, `provider.retry` (`attempt`, `maxRetries`, `delayMs`, `error: { message, category? }`, `provider`) and `provider.fallback` (`from`, `to`, `error: { message }`), for any `withRetry()` / `withFallback()` provider. Additive: the event schema version stays 1.
* Context compaction (LOU-W2): `createCompactionHook()` returns an `AgentHook` that, before a model call estimated above `thresholdPercent` (default 0.9) of the model's context window, replaces tool results older than the newest `protectedTokens` (default 40,000) with a `[pruned: <tool> result, N chars]` marker, in the run's transcript itself. `compactMessages()` compacts a conversation by hand; `CompactionStrategy` / `pruneToolResultsStrategy()` make the strategy pluggable. See docs/compaction.md.
* `AgentSpec.mcpServers` (LOU-D20): a validated map of MCP servers (stdio `command`/`args`/`env` or HTTP `url`/`headers`) in agent spec files. `loadSpec()` reports a bad entry with its name, `lousho doctor` reads the validated field instead of the raw file, and `specToAgent()` exposes the parsed servers as `agent.mcpServers` (connecting them is TODO(LOU-D20.2)). Existing specs are unaffected.
* `createAgent()` agents can pause for approval (LOU-D21): a `needsApproval` tool no longer fails the run with "requires approval but no approvalStore". The run pauses (`finishReason: 'awaiting-approval'`) in a per-agent `InMemoryApprovalStore` (or the new `approvalStore` option), and `agent.approvals.list()` / `agent.approvals.resolve({ id, approved, note? })` continue it, in its session if it paused in one. The `approve` option decides calls in code without pausing.
* Tools own their contract (LOU-D22): `ToolDescriptor` gains optional `inputSchema` (zod) and `execute(args, ctx)`, which are now the canonical fields. `defineTool()` sets both and no longer calls `ai`'s `tool()`. Argument validation, the schema sent to the model, and tool execution read them first and fall back to `tool.parameters` / `tool.execute`. `ToolDescriptor.tool` (the `ai` v4 `Tool`) is now legacy: it is still built by `defineTool()` and still accepted on hand-written descriptors this release, but new code should set `inputSchema` and `execute`.

### Removed

* BREAKING: `validateTokenQuotas()` (`src/security/quotas.ts`) and the SaaS-era types `SaaSContext`, `QuotaConfig`, `UsageStats` and `QuotaValidationResult` (LOU-D36) no longer ship from the package root. Nothing in the SDK called them. No replacement; copy the file into your project if you relied on it.
* BREAKING: `ConfigManager` and `SDKConfig` (`src/core/ConfigManager.ts`) (LOU-D36) no longer ship from the package root or `./core` (which still exports `AgentBuilder`). Nothing in the SDK used them. No replacement; copy the file into your project if you relied on it.
* BREAKING: The formatters (LOU-D36) `getCurrentTS()`, `getTS()`, `formatDate()`, `safeJsonParse()`, `removeCodeBlocks()`, `findCodeBlocks()`, `checkApiKey()` and the types `CodeBlock`, `CodeBlockError`, `CodeBlockResult` (`src/utils/formatters.ts`) no longer ship from the package root. No replacement; copy the file into your project if you relied on it.
* BREAKING: The JSON path helpers `getObjectByPath()` and `setRecursiveNames()` (`src/utils/json-path.ts`) (LOU-D36) no longer ship from the package root. No replacement; copy the file into your project if you relied on it.
* BREAKING: The file extraction helpers `getMimeType()`, `getFileExtensionFromMimeType()`, `replaceBase64Content()`, `isBinaryData()`, `extractBase64Data()`, `createDataUri()` and `ProcessFilesParams` (`src/utils/file-extractor.ts`) (LOU-D36) no longer ship from the package root. No replacement; copy the file into your project if you relied on it.
* BREAKING: The flows-ai converters (LOU-D35) are gone: `convertToFlowDefinition()` and `convertFromFlowDefinition()` (`src/flows/converters.ts`) no longer ship from the package root or `./flows`. There is no replacement: the flows-ai engine they targeted was never a dependency of this package and `FlowExecutor` never used them. Run flows with `FlowExecutor` directly.
* BREAKING: `MemoryManager` and its types `MemoryManagerConfig`, `StoreMemoryOptions`, `RecallMemoryOptions` and `MemorySearchResult` (`src/execution/MemoryManager.ts`) (LOU-D38) no longer ship from the package root. Nothing in the SDK used them (`AgentExecutor`, `createAgent`, `resume` and the flows never called them; only their own test and the `execution/index.ts` re-export). Migrate to `defineMemory()` and `createAgent({ memory })` (memory slots, see docs/memory.md).
* BREAKING: `ContextBuilder`, `ContextBuilderOptions` and `ExecutionContext` (`src/execution/ContextBuilder.ts`) (LOU-D38) no longer ship from the package root. Nothing in the SDK used them; they truncated by message count only. Migrate to `createAgent({ compaction })` (token-aware compaction, see docs/compaction.md).
* BREAKING: The `retry()` family (`retry`, `retryOnError`, `retryWithTimeout`, `retryBatch`, `RetryableOperation`, `RetryOptions`, `RetryResult`; `src/execution/retry.ts`) (LOU-D38) no longer ships from the package root. Provider calls retry through the provider wrappers; nothing else in the SDK used it. Migrate to `createAgent({ retry, fallbackModels })`, or copy the file into your project for general-purpose retries.
* BREAKING: The `data/` module (LOU-D39) is gone: the classes `Agent`, `Session`, `Result`, `Memory`, `Attachment` and the repository interfaces `BaseRepository`, `AgentRepository`, `SessionRepository`, `ResultRepository`, `MemoryRepository`, `AttachmentRepository`, `RepositoryCollection`, `RepositoryFactory`, `PaginationOptions`, `PaginatedResult` no longer ship from the package root. They were donor-product data tables that nothing in the SDK used once `MemoryManager` was removed. No replacement: unused. Use `AgentConfig`, sessions (`agent.session()`) and `defineMemory()` for the SDK's own concepts.
* BREAKING: `@lousho/build-ai-agent/testing` exports test utilities only (LOU-D39). The in-memory repository mocks `MockAgentRepository`, `MockSessionRepository`, `MockResultRepository`, `MockMemoryRepository` and `MockAttachmentRepository` are removed with the `data/` module. No replacement: unused. `mockModel` and `recordReplay` are unchanged.
* BREAKING: The template engine (`TemplateManager`, `renderTemplate()` and the types `TemplateFilter`, `TemplateContext`, `TemplateOptions`, `ITemplateManager`; `src/templates`) (LOU-D37) no longer ships from the package root. Nothing in the SDK, `lousho init` or Agent Forge used it (the executor never rendered prompts through it; the scaffold templates are separate files under `src/cli/init`). No replacement: unused; use a template literal or your own renderer.
* `nanoid` is no longer a dependency (LOU-D35). Generated ids (agent, run, trace, memory ids) now come from `globalThis.crypto.randomUUID()` via an internal `newId()`, so they are UUIDs instead of 21-character nanoids. Nothing public depended on the old format.
* `finishReason: 'max-steps'` (LOU-U19): a run that exhausts `maxSteps` while the model still wanted to continue (last turn ended in tool calls) now resolves with `finishReason: 'max-steps'` instead of the stale last-turn reason (usually `'tool_calls'`), on `ExecutionResult`, the `finish` event and `run.done`. Resumed runs count `initialSteps` toward the budget. A run that finishes naturally within the budget is unchanged. Code that treated `'tool_calls'` as "hit maxSteps" should check `'max-steps'` instead (the eval `completed()` message and the MCP server's agent tool do).

### Docs

* README revamped into a short front page (LOU-D52); the details it dropped moved to `docs/` (new pages: tools, approvals, providers, cli, flows, guardrails, utilities).

### Fixed

* On `ai` 5+ the providers send images as `file` parts with an image `mediaType` (`image/*` when unknown) instead of the deprecated `image` part, which made `ai` 7 log a deprecation warning for every image. `ai` 4 still gets `image` parts.
* OpenRouter on `@ai-sdk/openai` 2+, and the Ollama base URL (LOU-D28f, behaviour fix): `OpenRouterProvider` now builds its model with `provider.chat(modelId)` (Chat Completions, the only API OpenRouter implements) instead of the bare call, which targets the Responses API from `@ai-sdk/openai` 2 on; no change on `ai` 4. `OllamaProvider` appends `/api` to a base URL that has no path (`http://host:11434` or `http://host:11434/`), the form `ollama-ai-provider` and `ollama-ai-provider-v2` expect, so a configured bare host no longer hits `/chat` outside `/api` (before, `getModels()` hit `<baseURL>/api/tags` but chat requests went to `<baseURL>/chat`); a URL ending in `/api` or with any other path is used as it is, and `getModels()` no longer doubles `/api` for a base URL that has it. `lousho init` (and `create-lousho-agent`) now scaffolds OpenRouter projects on `ai@^7.0.0` with `@ai-sdk/openai@^4.0.0`.

### Changed

* zod 3 or zod 4 (LOU-D29): 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 (LOU-P5.2, 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 (`<starter>:<approval id>`), 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 (LOU-D42): 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 (LOU-V15.2): 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 (LOU-X3.2, 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 (LOU-U14.2): 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: '<text>' }` (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 (LOU-X8 follow-up, 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 (LOU-W8 follow-up), 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 (LOU-D28c). 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 LOU-D28b 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 (LOU-X11). 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 of LOU-X12 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 (LOU-D28b): 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 (LOU-D28c) 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 (LOU-D28a): 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 (LOU-D28d).
* MCP tools without `readOnlyHint` now ask for approval by default (LOU-Z5, 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` (LOU-D24, 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_<agent>`. 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` (LOU-D25, 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[]` (LOU-V11). 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 (LOU-D23, 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 (LOU-D2): `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 (LOU-U14), 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: '<message>' }`; a call left without a result on resume gave `{ error: 'Tool call was not run: ...' }`. Now each is `{ error: '<ToolNotFoundError | ToolRejectedError | <thrown error name> | ToolNotRunError>', toolName, message: '<the old text>', 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 (LOU-Y4.2) 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 (LOU-D40): `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 (LOU-D40): 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 (LOU-V7.2): 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 (LOU-D34, 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()` (LOU-D53): both now share an internal client (`src/server/sessionClient.ts`) for `POST <url>/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` (LOU-Y7) 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.

## \[1.0.0-alpha.8] - 2025-10-05

### Added

* ✅ Complete Phase 5 migration: Security, Storage, Templates, and Utils modules
* ✅ Security module with encryption, hashing, and quota validation
* ✅ Storage service with file locking mechanism
* ✅ Template rendering engine with Jinja2-like syntax
* ✅ Comprehensive utility functions (errors, formatters, validators)
* ✅ File extraction and processing utilities
* ✅ JSON path navigation utilities
* ✅ Framework-agnostic architecture (zero framework dependencies)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.