Skip to main content

Agent spec files (AgentSpec)

A declarative agent is a YAML (.yaml/.yml) or JSON (.json) file, validated with zod by loadSpec() (src/spec/schema.ts). It is the format lousho dev serves and lousho build deploys. A missing or invalid field fails with an error naming the exact field, e.g. 'prompt': Required.

Tools a spec can reference

github and jira need credentials a spec has no field for; referencing them throws an error telling you to build the agent with createAgent() and pass a configured tool instead.

Policy (policy)

specToAgent() compiles policy into createAgent() options, so a spec enforces what it declares. The known fields are validated by loadSpec(); other keys are kept for the other harness generators (Claude Code, Codex, Pi), which read the raw policy.
Guardrail names (see Input and output guardrails): Every guardrail also takes on: input, output or tools (a tool call’s arguments), or a list of them. The default is [input, output]. An unknown name or option fails validation, naming the guardrails available and a “did you mean” suggestion: 'policy.guardrails.0': unknown guardrail 'deny-topic' (did you mean 'deny-topics'?). requiresApproval pauses the run (finishReason: 'awaiting-approval') until agent.approvals.resolve(); before this, a spec that set it ran its tools without asking. lousho doctor agent.yaml prints one line per policy block.

MCP servers (mcpServers)

mcpServers declares the MCP servers an agent uses, as a map from a server name (it namespaces that server’s tools) to either a stdio server (command, optional args and env) or an HTTP server (url, optional headers). Each entry sets exactly one of command / url; args/env apply only to stdio and headers only to HTTP. The field is validated by loadSpec(), and an invalid entry fails with the entry name in the message, e.g. 'mcpServers.files': AgentSpec validation failed: missing 'command' (stdio server) or 'url' (HTTP server). An optional approval (annotations, always or never) says which of the server’s tools ask for approval; see MCP tool approval. lousho doctor checks each stdio command is resolvable.
specToAgent() passes the servers to createAgent({ mcpServers }), described next, so lousho dev and lousho mcp agents get their tools.

Connect MCP servers (mcpServers, connectMcp())

createAgent({ mcpServers }) takes the same map. The servers connect on await agent.ready() or, automatically, on the first send() / stream(); each server’s tools are added as <server>__<tool> (e.g. docs__search). A server that cannot connect fails that call, and the next call tries again. agent.close() disconnects them (stops stdio processes); a later tool call reconnects. Without mcpServers, ready() and close() do nothing.
To share servers between agents, or to choose how failures are handled, call connectMcp(servers, options?) and pass its tools yourself. It connects every server and lists its tools before it resolves, so tools are known up front. Options:
  • onError: 'throw' (default) rejects when a server cannot connect, after closing the others; 'skip' leaves that server out and warns through logger.
  • lazy (default true): after close() or a dropped connection, the next tool call reconnects. With false that call fails instead. Listing tools needs a connection, so lazy never delays the first connect.
  • logger: receives skipped-server and skipped-tool warnings (default: none).
It returns { tools, close(), status() }; status() maps each server to 'idle', 'connected' or 'failed'.
stdio servers are spawned with command and args; env is added to the default environment (PATH and the like), not a replacement for it. HTTP servers use the streamable HTTP transport with headers on every request. @modelcontextprotocol/sdk is an optional peer: install it to use MCP.

MCP tool approval (approval)

MCP servers describe each tool with annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint and a title). They are hints, but the SDK uses them as the default for approvals: a tool with readOnlyHint: true runs; a tool with destructiveHint: true, or one that sends no destructiveHint (the MCP spec’s default is destructive), pauses the run until a human approves; destructiveHint: false runs. A tool without annotations therefore asks. The raw annotations stay on descriptor.metadata.mcp.annotations, and title becomes the displayName. Set approval on a server entry (mcpServers, createAgent, connectMcp()) or in loadMcpTools(client, name, { approval }):
  • 'annotations' (default): as above.
  • 'always' / 'never': ask for every tool / none of them.
  • A function ({ name, annotations }) => boolean decides per tool (name is the bare tool name; annotations is {} when the server sent none). Only in code; a spec file takes the three strings.
Permission rules run first and can still allow, deny or ask.

MCP (Model Context Protocol) tools

Tools of a Client you connected yourself can also be loaded by hand - connect a Client from @modelcontextprotocol/sdk yourself and load its tools with loadMcpTools(), then pass the result to createAgent() (or register it on a ToolRegistry):
loadMcpTools is also available from the package root and from @lousho/build-ai-agent/tools.

Serve an agent over MCP

serveMcp() is the reverse of loadMcpTools(): it exposes an agent (and, optionally, some of its tools) as an MCP server, so Claude Code, Cursor and other MCP clients can call it.
  • Stateless. Every call to the agent tool is a fresh conversation.
  • Cancellation. Cancelling the MCP request aborts the agent run (agent.send(message, { signal })).
  • Errors. An agent failure comes back as an MCP result with isError: true.
  • Approvals. Approval-gated tools cannot be approved over MCP. A run that pauses for approval returns isError: true with a message saying so. Tools flagged needsApproval are not exposed directly unless you pass allowApprovalTools: true; if you do, clients run them with no human gate.
  • stdio. Nothing but the MCP protocol is written to stdout; warnings go to stderr.
  • HTTP. Binds 127.0.0.1 by default. Add auth: { type: 'bearer', token } to require an Authorization: Bearer header; binding a non-loopback host without auth logs a warning.

Annotations

tools/list carries MCP annotations so clients can tell what a tool does. A tool that needs approval is advertised readOnlyHint: false, destructiveHint: true; state hints yourself with annotations (sent verbatim). A tool with no hints sends none, so a Lousho agent consuming this server keeps asking before it runs (see approval on connectMcp()); readOnlyHint: true runs without asking. A tool that needs approval is never advertised read-only. The built-in read_file, list_dir, glob, grep, todo_read, current_date and day_name tools are read-only.
From the command line, lousho mcp serves an agent spec file (stdio by default):
To use it from an MCP client, add it to the client’s MCP config (for example .mcp.json for Claude Code):

Provider credentials

Real providers are resolved by resolveProvider('<provider>/<model>') (also used for spec files), which reads the credential from the environment: The mock provider needs no credentials and returns canned responses; it is what the examples and the Quick Start use by default.

Provider retries and fallback

createAgent() retries failed model calls on its own, and can fall back to other models:
  • retry takes the withRetry() options below. It applies to the model string (or the model picked from the environment) and to every fallbackModels entry. createAgent builds those providers with the ai SDK’s own retries off (maxRetries: 0), so retries happen in one place and the default { maxRetries: 2 } makes as many calls as before.
  • A provider instance you pass keeps its own retry behaviour; it is wrapped in withRetry() only when you set retry. fallbackModels work with it too.
  • fallbackModels are provider/model strings, resolved like model when the agent is created. The agent runs withFallback([withRetry(primary), withRetry(fallback1), ...]): every call starts with the primary model.
  • stream() and session.stream() report each retry as a provider.retry event and each switch as provider.fallback (see Streaming). send() returns the final result as before.
To build the same thing by hand, or for providers you construct yourself, withRetry(provider, options) and withFallback(providers, options) wrap any LLMProvider and return another one, so they compose and can be passed anywhere a provider is accepted:
  • withRetry retries generate() and stream() (default maxRetries: 2) on rate limits, timeouts, network errors and 5xx responses, using the same classification as compactProviderError(). Auth failures, invalid requests and context-length errors are not retried; neither is a cancellation. A provider’s retryAfterMs hint (a Retry-After header) replaces the backoff delay. Pass retryOn(error, attempt) to change the rule, timeoutMs for a per-attempt time limit, and signal to stop retrying.
  • A stream() call is retried only when it rejects. An error inside a stream that was already returned is not retried.
  • withFallback tries each provider in order and rethrows the last error when all fail. By default it falls back on any error except a cancellation (fallbackOn changes that). Each fallback runs on its own defaultModel. name and defaultModel report the provider that served the latest call.
  • resilientProvider(provider, { maxRetries, timeout }) applies the LLMProviderConfig fields of the same names.
  • The built-in providers pass their config’s maxRetries (default 2) to the ai SDK’s own internal retries, which run inside each withRetry attempt. Build a provider you wrap with maxRetries: 0 (new OpenAIProvider({ apiKey, maxRetries: 0 })) to retry in one place. createAgent() does this for the models it resolves.

createAgent() options

With neither model nor provider, createAgent() resolves from the environment: LOUSHO_MODEL (a 'provider/model' string) if set, otherwise the first provider whose variable is set, checked in this order: OPENAI_API_KEY (openai/gpt-4o-mini), ANTHROPIC_API_KEY (anthropic/claude-3-5-sonnet-latest), OPENROUTER_API_KEY (openrouter/openai/gpt-4o-mini), OLLAMA_BASE_URL (ollama/llama3). If none is set it throws an error listing exactly which options or variables fix it. Misconfiguration errors say how to fix themselves: a missing key names the variable (createAgent: OPENAI_API_KEY is not set. ...), an unknown prefix lists the supported ones and suggests the closest, and a missing optional peer dependency prints the exact npm install command.

Budgets

limits caps what a run may spend. Set it on createAgent() (every run of the agent) or on AgentExecutor.execute() / stream(): Limits are checked before every model call (so after every tool batch) and after a model call that asks for tools; maxDurationMs also aborts an in-flight model or tool call through the run’s signal. A limit trips once the run reaches it. Usage of sub-agents counts toward their lead’s budget: it is added when the sub-agent returns, so the lead stops before its next model call. A run that finishes on its own within the step that reached a limit keeps its own finish reason, like maxSteps. When a limit trips, the run stops with finishReason: 'budget-exceeded' and result.budget ({ limit, value, max, scope }). Tool calls the model asked for in that step get a “cancelled” result, so the transcript stays valid, and with a checkpoint store it is checkpointed as finished, like 'max-steps'. stream() emits a budget.exceeded event before run.done. With onExceeded: 'throw' the run rejects with BudgetExceededError (LOUSHO_BUDGET_EXCEEDED, with the same budget) instead.
Run and session limits. createAgent({ limits }) applies to each run on its own: every send(), and every turn of a session, starts from zero. agent.session({ id, limits }) adds limits across all of the session’s turns: a turn stops once what the session has spent, in all its turns, reaches a limit (budget.scope is then 'session'), and later turns stop before calling the model. What the turns spent (tokens, cost, steps and run time) is saved with the transcript, as metadata.sessionUsage on its last message, so a session continued from its store keeps its budget. Both apply together; the first limit reached stops the turn. A turn that was aborted is not counted.

Project instructions

Many repositories keep guidance for coding agents in an AGENTS.md (or CLAUDE.md) file. createAgent can append it to the agent’s instructions:
The file is added after your own instructions under the heading ## Project instructions (from AGENTS.md). It is read once, when the agent is created. The lookup walks up from cwd (default process.cwd()) and uses the nearest directory that has one of files (default ['AGENTS.md', 'CLAUDE.md'], first match wins), stopping at the nearest directory that contains .git or at the filesystem root. Content over 32,000 characters is cut with a truncation marker. If no file is found nothing is added. This is opt-in on purpose: reading files from disk by default would surprise people who embed the SDK in a server, where the working directory is not the project the agent is about. To find the file yourself (for example to show it), use loadProjectInstructions({ cwd, files, stopAt, maxChars }), which returns { path, content } or undefined.

AgentExecutor.execute() options

AgentExecutor is static: call AgentExecutor.execute(options). Only agent, input and provider are required. Commonly used options: It resolves to an ExecutionResult: { text, messages, toolCalls, usage, finishReason, steps, approvalId? }.

CLI

lousho dev, lousho build, lousho mcp and the other commands, with their flags, are described in CLI.