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.
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.
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 throughlogger.lazy(defaulttrue): afterclose()or a dropped connection, the next tool call reconnects. Withfalsethat call fails instead. Listing tools needs a connection, solazynever delays the first connect.logger: receives skipped-server and skipped-tool warnings (default: none).
{ tools, close(), status() }; status() maps each server to
'idle', 'connected' or 'failed'.
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 }) => booleandecides per tool (nameis the bare tool name;annotationsis{}when the server sent none). Only in code; a spec file takes the three strings.
allow, deny or ask.
MCP (Model Context Protocol) tools
Tools of aClient 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: truewith a message saying so. Tools flaggedneedsApprovalare not exposed directly unless you passallowApprovalTools: 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.1by default. Addauth: { type: 'bearer', token }to require anAuthorization: Bearerheader; binding a non-loopback host withoutauthlogs 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.
lousho mcp serves an agent spec file (stdio by default):
.mcp.json for Claude Code):
Provider credentials
Real providers are resolved byresolveProvider('<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:
retrytakes thewithRetry()options below. It applies to themodelstring (or the model picked from the environment) and to everyfallbackModelsentry. createAgent builds those providers with theaiSDK’s own retries off (maxRetries: 0), so retries happen in one place and the default{ maxRetries: 2 }makes as many calls as before.- A
providerinstance you pass keeps its own retry behaviour; it is wrapped inwithRetry()only when you setretry.fallbackModelswork with it too. fallbackModelsareprovider/modelstrings, resolved likemodelwhen the agent is created. The agent runswithFallback([withRetry(primary), withRetry(fallback1), ...]): every call starts with the primary model.stream()andsession.stream()report each retry as aprovider.retryevent and each switch asprovider.fallback(see Streaming).send()returns the final result as before.
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:
withRetryretriesgenerate()andstream()(defaultmaxRetries: 2) on rate limits, timeouts, network errors and 5xx responses, using the same classification ascompactProviderError(). Auth failures, invalid requests and context-length errors are not retried; neither is a cancellation. A provider’sretryAfterMshint (aRetry-Afterheader) replaces the backoff delay. PassretryOn(error, attempt)to change the rule,timeoutMsfor a per-attempt time limit, andsignalto stop retrying.- A
stream()call is retried only when it rejects. An error inside a stream that was already returned is not retried. withFallbacktries each provider in order and rethrows the last error when all fail. By default it falls back on any error except a cancellation (fallbackOnchanges that). Each fallback runs on its owndefaultModel.nameanddefaultModelreport the provider that served the latest call.resilientProvider(provider, { maxRetries, timeout })applies theLLMProviderConfigfields of the same names.- The built-in providers pass their config’s
maxRetries(default 2) to theaiSDK’s own internal retries, which run inside eachwithRetryattempt. Build a provider you wrap withmaxRetries: 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.
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 anAGENTS.md (or
CLAUDE.md) file. createAgent can append it to the agent’s instructions:
## 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.