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.
Snippets on this page leave some names to you:
provideris anyLLMProvider(offline:createMockProvider()).
| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
yes | Agent name. |
prompt |
string |
yes | System prompt. |
provider.type |
string |
yes | openai, anthropic, ollama, openrouter or mock. |
provider.model |
string |
yes | Model id every call uses (the provider's configured model; an agent's own settings.model would override it). |
tools |
string[] |
no | Built-in tool names (see below). |
policy |
AgentSpecPolicy |
no | Approvals, guardrails, limits and more, enforced when the spec becomes an agent (see below). |
triggers |
AgentSpecTrigger[] |
no | What runs the agent on its own — cron schedules ({ type: 'cron', cron, input, name?, timezone? }); see Schedules and Triggers. |
mcpServers |
Record<string, McpServerSpec> |
no | MCP servers the agent uses, keyed by name (see below). |
A missing or invalid field fails with an error naming the exact field, e.g.
'prompt': Required.
name: support-bot
prompt: You are a friendly support agent.
provider:
type: openai
model: gpt-4o-mini
tools:
- current-date
- http| Name | Tool |
|---|---|
http |
httpTool - HTTP requests |
web-fetch |
webFetchTool - read a public web page as text |
current-date |
currentDateTool - current date/time (ISO, UTC) |
day-name |
dayNameTool - day of the week |
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.
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.
| Field | Compiles to | Description |
|---|---|---|
requiresApproval |
permissions (ask) |
true: every tool call pauses for approval. A list: only those tools pause. |
guardrails |
guardrails (input and output) |
Built-in guardrail names, or { name, ...options } (table below). |
limits |
limits (budgets) |
maxTokens, maxInputTokens, maxOutputTokens, maxCostUsd, maxDurationMs, maxSteps, maxToolErrors, onExceeded. |
askQuestion |
askQuestion |
true adds the built-in ask_question tool. |
compaction |
compaction |
true, or { thresholdPercent: 0.8 } (a fraction of the context window, above 0 up to 1). |
name: support-bot
prompt: You are a friendly support agent.
provider:
type: openai
model: gpt-4o-mini
tools:
- http
policy:
requiresApproval: [http] # or `true` for every tool
guardrails:
- secret-scan
- name: max-length
maxChars: 4000
on: input
- name: deny-topics
topics: [medical advice, legal advice]
limits:
maxTokens: 50000
maxCostUsd: 0.25
maxSteps: 10
askQuestion: true
compaction:
thresholdPercent: 0.8Guardrail names (see Input and output guardrails):
| Name | Options | Does |
|---|---|---|
max-length |
maxChars (default 10000) |
Blocks texts longer than maxChars. |
secret-scan |
action (block or rewrite), replacement |
Blocks (or redacts) private keys, OpenAI-style and AWS keys. |
regex |
pattern (required), flags, action, replacement |
Blocks (or rewrites) texts matching pattern. |
deny-topics |
topics (required, a list) |
Blocks texts that mention a topic (case-insensitive). |
llm-judge |
model (required, "provider/model"), instruction |
Asks a model whether the text is acceptable; one call per check. |
pii |
types (a list of email, phone, credit-card, iban, us-ssn, ip-address; default all), action (default block) |
Blocks (or rewrites to [email], ...) personal data found by pattern. Heuristic. |
secrets |
action (default rewrite), extraPatterns (a list of regular expressions) |
Rewrites (or blocks) API keys, tokens and private keys found by pattern. Heuristic. |
prompt-injection |
model (optional, "provider/model") |
Blocks common prompt-injection phrasing and markers; with model, also asks the model. Heuristic. |
moderation |
model (required, "provider/model"), categories (default all) |
Asks a model which harmful-content categories apply; one call per check. |
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.
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': missing 'command' (stdio server) or 'url' (HTTP server).
An optional approval (always, the default, annotations or never) says which of the server's tools ask for approval; see Approval for MCP tools.
An optional deferLoading: true withholds the server's tools from the model until it finds them with tool_search; see Tool search.
An optional optional: true leaves out a server that cannot connect (with a warning) instead of failing every send; see Use MCP servers in an agent.
An HTTP entry may set oauth (redirectUri, optional clientId, clientSecret, scopes and clientName) to sign in to the server with OAuth instead of a static Authorization header; see MCP servers with OAuth.
lousho doctor checks each stdio command is resolvable.
mcpServers:
filesystem:
command: npx
args: [-y, '@modelcontextprotocol/server-filesystem', ./data]
env:
LOG_LEVEL: warn
docs:
url: https://example.com/mcp
headers:
Authorization: Bearer <token>import { agentSpecSchema, specToAgent, type McpServerSpec } from '@lousho/build-ai-agent';
const filesystem: McpServerSpec = { command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem'] };
const spec = agentSpecSchema.parse({
name: 'support-bot',
prompt: 'You are a friendly support agent.',
provider: { type: 'mock', model: 'mock-1' },
mcpServers: { filesystem, docs: { url: 'https://example.com/mcp' } },
});
// The servers connect on agent.ready() or the first send() / stream().
const agent = specToAgent(spec);
console.log(Object.keys(agent.mcpServers)); // ['filesystem', 'docs']specToAgent() passes the servers to createAgent({ mcpServers }), described
in MCP, so lousho dev and lousho mcp agents get their tools.
MCP clients, connectMcp(), tool approval and serving an agent over MCP are on their own page: MCP (Model Context Protocol).
Real providers are resolved by resolveProvider('<provider>/<model>')
(also used for spec files), which reads the credential from the environment:
| Provider | Environment variable |
|---|---|
openai |
OPENAI_API_KEY |
anthropic |
ANTHROPIC_API_KEY |
openrouter |
OPENROUTER_API_KEY |
ollama |
OLLAMA_BASE_URL |
pi |
the nested provider's own variable (pi/openrouter/... reads OPENROUTER_API_KEY) |
The mock provider needs no credentials and returns canned responses; it is
what the examples and the Quick Start use by default.
createAgent() retries failed model calls on its own, and can fall back to
other models. The vendor/ model prefix chooses the provider; with OpenRouter
use openrouter/<vendor>/<model> (e.g. openrouter/openai/gpt-4o-mini).
import { createAgent } from '@lousho/build-ai-agent';
const agent = createAgent({
model: 'openai/gpt-4o-mini',
retry: { maxRetries: 3, backoff: { initialMs: 1000 } }, // default { maxRetries: 2 }; false turns it off
fallbackModels: ['anthropic/claude-haiku-4-5'], // tried in order once the retries are used up
});
for await (const event of agent.stream('Hello!')) {
if (event.type === 'provider.retry') console.warn(`retry ${event.attempt} in ${event.delayMs}ms: ${event.error.message}`);
if (event.type === 'provider.fallback') console.warn(`falling back from ${event.from} to ${event.to}`);
}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 built-in provider instance (
OpenAIProvider,AnthropicProvider,OpenRouterProvider,OllamaProvider) or afromAiSdk()provider is wrapped inwithRetry()too, even withoutretry: then with the provider's ownmaxRetries(default 2), so its retries emitprovider.retryevents like a model string's. Its calls go out with theaiSDK's own retries off, so{ maxRetries: 2 }makes at most 3 requests, andretry: falsesends each call once. Any otherproviderinstance (your ownLLMProvider) is wrapped only when you setretry.fallbackModelswork with all of them. 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; itsfromandtoare provider names (such asopenai) andfromModel/toModelthe model ids (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:
import { createAgent, resolveProvider, withFallback, withRetry } from '@lousho/build-ai-agent';
const provider = withFallback(
[
withRetry(resolveProvider('openai/gpt-4o-mini'), {
maxRetries: 3,
backoff: { initialMs: 500, maxMs: 10_000 },
onRetry: ({ attempt, delayMs }) => console.warn(`retry ${attempt} in ${delayMs}ms`),
}),
withRetry(resolveProvider('anthropic/claude-haiku-4-5')),
],
{ onFallback: ({ from, to }) => console.warn(`falling back from ${from} to ${to}`) }
);
const agent = createAgent({ instructions: 'You are helpful.', provider });withRetryretriesgenerate()andstream()(defaultmaxRetries: 2) on rate limits, timeouts, network errors (including a dropped socket), malformed or truncated responses 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 (retry-after-msorRetry-After, on a 429 or a 503) replaces the backoff delay, unless it is longer thanbackoff.maxMs(default 30 s): then the call fails at once with aCompactedLLMProviderErrorwhosecompacted.retryAfterMsholds the hint, so your code decides whether to wait. PassretryOn(error, attempt)to change the rule,timeoutMsfor a per-attempt time limit, andsignalto stop retrying. Forstream(),timeoutMsonly bounds the wait for the first chunk: a stream that has started is never cut off by it.idleTimeoutMs(stream only) fails a stream that goes quiet for longer than that between chunks, with aTimeoutError(retried only while no text or tool call was out).- A
stream()call is retried when it rejects, and when its stream fails before any output: reasoning chunks and empty text deltas don't count, so a local runtime that sends an emptyreasoning-endand then a 500 (LM Studio) is retried. A failure after text, a tool call or the finish is not retried. An agent streams its model calls whenever it has an event listener, so this is what keepsagent.stream()as resilient assend(). - Reasoning streamed before such a failure was already reported. In an agent
run the failed attempt's reasoning ends with its
reasoning.done, then comes theprovider.retryevent, then the retried attempt's reasoning starts again withreasoning.start. The step's (and the run's)reasoningis the successful attempt's only. 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. Every call keeps its own fallback state, so concurrent calls never see each other's switches.nameanddefaultModelare the first provider's; the result of a call (and a stream) names the provider and model that served it asservedBy, and the run books that call's usage and cost under that model inusage.byModel.resilientProvider(provider, { maxRetries, timeout })applies theLLMProviderConfigfields of the same names.withRetryis the only retry layer: the built-in providers send its calls with theaiSDK's own retries off, whatever their config'smaxRetries. ThatmaxRetries(default 2) is theaiSDK's retry count for a provider used unwrapped (called directly), and thewithRetry()countcreateAgent()uses for that provider whenretryis unset.- A rate-limited call (a 429) fails, once the retries are used up, with
LOUSHO_PROVIDER_RATE_LIMITED; other failed calls withLOUSHO_PROVIDER_REQUEST_FAILED(see Errors). - Each retry is recorded on the call's
chatspan aslousho.retry.countandlousho.retry.errors(see Observability).
withRateLimit(provider, limits) queues a provider's generate() and
stream() calls so they stay under client-side limits, so sub-agent fan-out,
flow waves and eval suites cannot burst unbounded model calls. Every limit is
optional:
import { withRateLimit, resolveProvider } from '@lousho/build-ai-agent';
const provider = withRateLimit(resolveProvider('openai/gpt-4o-mini'), {
requestsPerMinute: 60, // calls started per rolling minute
tokensPerMinute: 200_000, // estimated tokens per rolling minute
maxConcurrent: 4, // calls in flight at once
});- Calls are admitted first in, first out. A call waiting for its turn rejects
with its
signal's reason as soon as it is aborted, and never starts. tokensPerMinutebooks each call atestimateTokens()of its messages and tool definitions plus itsmaxTokens, then re-books it at the usage the provider reports. A single call larger than the limit still runs, alone in its window.- A
stream()holds itsmaxConcurrentslot until the stream ends. - Invalid limits (not a positive number, or
maxConcurrentnot a whole number) throwLOUSHO_CONFIG_INVALID. - Wrap outside
withRetry()to count a call and its retries as one, or inside to count each attempt. - Pass a
createRateLimiter(limits)instead of the limits to share one budget between several providers.
createAgent({ rateLimit }) takes the same limits (or a limiter). One budget
covers every model call of the agent's runs, including the sub-agents and
handoff targets a run starts (a sub-agent inherits the lead run's budget, so
its own provider is queued too). Give several agents the same
createRateLimiter() to share it across them.
import { createAgent } from '@lousho/build-ai-agent';
const researcher = createAgent({ model: 'openai/gpt-4o-mini', instructions: 'You research.', description: 'Finds sources' });
createAgent({ model: 'openai/gpt-4o-mini', subagents: { researcher }, rateLimit: { maxConcurrent: 2 } });| Option | Description |
|---|---|
model |
A 'provider/model' string such as 'openai/gpt-4o-mini', resolved with resolveProvider() (key from the env var above). Alternative to provider. |
provider |
An LLMProvider instance (real or mock). Alternative to model. If you pass both, provider is used and model becomes the agent's per-run model setting (a bare model id such as 'gpt-4o'). |
instructions |
System prompt. Optional (defaults to 'You are a helpful assistant.'). |
prompt |
Deprecated alias of instructions; passing both is an error. |
tools |
An array of defineTool() results, or a Record<string, ToolDescriptor> keyed by the name the agent uses (see Tools). |
name |
Agent name (default 'agent'). |
description |
What the agent does, in a sentence. Required when it is used as a sub-agent. |
maxSteps |
Passed through to AgentExecutor.execute(). A whole number >= 1; anything else throws LOUSHO_CONFIG_INVALID. |
modelSettings |
Sampling settings sent on every model call: { maxTokens?, temperature?, topP?, frequencyPenalty?, presencePenalty?, stop?, seed?, toolChoice? }. A send() / stream() call's own modelSettings win key by key. See Model settings. |
limits |
Budgets of each run: { maxTokens?, maxInputTokens?, maxOutputTokens?, maxCostUsd?, maxDurationMs?, maxSteps?, maxToolErrors?, onExceeded? }. A tripped limit ends the run with finishReason: 'budget-exceeded'. See Budgets. |
toolConcurrency |
How many tool calls of one model turn run at once: a positive integer or 'unbounded' (default). See Parallel tool calls. |
skills |
Skills from defineSkill() / loadSkills(); see Skills. |
subagents, maxSubagentDepth |
Named sub-agents behind one task tool, and how deep they may nest (default 1); see Sub-agents. |
handoffs, maxHandoffs |
Agents this agent can hand the whole conversation to, one transfer_to_<name> tool each, and how many handoffs one run may make (default 5); see Handoffs. |
toolSearch |
Tunes tool search for tools marked deferLoading: { thresholdPercent?, maxResults?, contextWindow?, search? }, or false to send every tool on every call. |
store |
An AgentStore (SqliteStore, memoryStore(), or { sessions?, checkpoints?, approvals? }): the default stores of agent.session(), approvals, and send(message, { sessionId }) runs; agent.resume(id) finishes an interrupted one. See Durable sessions. |
memory |
Memory slots from defineMemory(): recalled into the system prompt at the start of each run, with remember_<name> / recall_<name> tools. See Memory. |
approvalStore |
Where a needsApproval pause is saved (default: store.approvals, else a per-agent InMemoryApprovalStore); see Approvals. |
approve |
(call) => boolean: decide approvals in code instead of pausing. |
projectInstructions |
true or { cwd?, files? }: append the nearest AGENTS.md / CLAUDE.md to the instructions (off by default; see Project instructions). |
retry |
withRetry() options for failed model calls, or false. Default { maxRetries: 2 } for model strings, and the provider's own maxRetries (default 2) for a built-in or fromAiSdk() provider instance; any other provider instance is wrapped only when set. See Provider retries and fallback. |
fallbackModels |
provider/model strings tried in order when the model still fails after its retries. |
rateLimit |
{ requestsPerMinute?, tokensPerMinute?, maxConcurrent? } (or a createRateLimiter()): queue the agent's model calls, and its sub-agents' and handoff targets', under one budget. See Rate limits. |
hooks |
AgentHook[] run around every model call and tool call, in order, before the compaction hook (see Hooks). |
compaction |
true (prune old tool results above 90% of the context window) or { strategy?, thresholdPercent?, contextWindow?, protectedTokens?, reserveOutputTokens?, onCompaction?, summarizer? }; summarizer ('provider/model' or an LLMProvider) selects the two-phase strategy. stream() reports compaction.start / compaction.done. See Context compaction. |
mcpServers |
MCP servers to connect, keyed by name (stdio { command, args?, env? } or HTTP { url, headers? }); their tools are named <server>__<tool> and ask for approval by default. optional: true leaves out a server that cannot connect. See MCP. |
subagentOptions |
Background sub-agent options: maxConcurrent (default 3), awaitBackgroundOnFinish (default false) and background: false to offer only the task tool. See Sub-agents. |
codeMode |
true or CodeModeOptions: adds a run_code tool that lets the model call the agent's tools from one short script in a QuickJS sandbox. See Code mode. |
guardrails |
Input, output and tool guardrails of each run; sub-agents inherit them. See Guardrails. |
maxToolResultChars |
The most characters of one tool result the model gets (default 50_000; Infinity turns the cap off). See Tools. |
onAgentDrift |
What a resume does when the agent differs from the one that paused the run: 'warn' (default), 'error' or 'ignore'. See Durable execution. |
reasoning |
How much the model reasons before it answers: an effort, or { effort, budgetTokens, summary, force }. See Reasoning. |
promptCaching |
'auto' (default) marks Anthropic cache_control breakpoints for Claude models; false marks none. See Prompt caching. |
output |
A schema for the final reply; send() / stream() resolve with result.object. See Structured output. |
onEvent |
Called with every AgentEvent of the agent's runs, in the order stream() yields them. See Listening without iterating. |
exporter, captureContent, redactContent |
Tracing: a TraceExporter that receives a span for every run, whether message and tool content is recorded on the spans (off by default), and whether the deprecated content attributes are left out (default true). See Observability. |
approvalTtlMs |
How long a pause for approval stays decidable, in milliseconds; decided later, the call is denied. Unset: pauses never expire. See Approval expiry. |
askQuestion |
Adds the built-in ask_question tool; the run pauses until agent.approvals.answer(). Off by default. See Asking the user a question. |
permissions, permissionMode |
Permission rules (allow / ask / deny) and a named preset over them ('default', 'plan', 'acceptEdits' or 'dontAsk'). See Permission policies and Permission modes. |
onPermissionDecision, onPermissionModeChange |
Audit callbacks: one entry per tool call's permission decision, and one per mode switch. See Permission modes. |
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-sonnet-4-5), 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.
modelSettings sets the sampling options of every model call an agent makes.
A send() or stream() call can pass its own, which win key by key over the
agent's:
import { createAgent } from '@lousho/build-ai-agent';
const agent = createAgent({
model: 'openai/gpt-4o-mini',
modelSettings: { maxTokens: 1024, temperature: 0.2 },
});
// This run sends maxTokens: 256 and temperature: 0.2.
await agent.send('Summarize the log in one line.', { modelSettings: { maxTokens: 256 } });The keys are maxTokens, temperature, topP, frequencyPenalty,
presencePenalty, stop, seed and toolChoice ('auto', 'required', 'none' or { type: 'function', function: { name } }; not sent on a call without tools). A key you leave out is not sent at all,
so the provider's own default applies, and so does a value that a wrapping
provider adds to the request. Sub-agents and handoff targets use their own
modelSettings, not the lead's. An agent directory can set the same object as
modelSettings in its agent.json.
The values are checked before any model call, at createAgent() and at each
send() / stream(): temperature 0 to 2, topP 0 to 1, frequencyPenalty
and presencePenalty -2 to 2, maxTokens a whole number of at least 1, seed
a whole number, stop an array of strings. A value out of range, or an unknown
key (such as the AI SDK's maxOutputTokens), throws a ConfigurationError
with code LOUSHO_CONFIG_INVALID whose field names the key, e.g.
modelSettings.temperature. A provider with a narrower range (Anthropic's
temperature is 0 to 1) still rejects a value outside it.
Besides modelSettings, a send() or stream() call can set its own
maxSteps (instead of the agent's; a whole number of at least 1, checked like
createAgent's) and instructions, which are appended to the agent's
instructions for that run:
import { createAgent } from '@lousho/build-ai-agent';
const agent = createAgent({ model: 'openai/gpt-4o-mini', instructions: 'You triage support tickets.' });
await agent.send('Summarize the ticket.', { maxSteps: 2, instructions: 'Answer in French.' });The appended instructions go with every model call of the run (a handoff
target's too) but are not written to the transcript, so a later call with the
same sessionId runs without them unless it passes them again. A run continued
by agent.approvals.resolve() or agent.resume() uses the agent's maxSteps
and instructions.
To share options between agents, type them as CreateAgentBase and spread
them into createAgent(). Do not use Partial<CreateAgentConfig>: that type
keeps model / provider and instructions / prompt mutually exclusive,
so TypeScript rejects spreading it next to a literal that sets one of them.
import { createAgent, type CreateAgentBase } from '@lousho/build-ai-agent';
const shared: CreateAgentBase = { maxSteps: 8, modelSettings: { temperature: 0 } };
function triageAgent(overrides: Partial<CreateAgentBase> = {}) {
return createAgent({ model: 'openai/gpt-4o-mini', instructions: 'Triage the incident.', ...shared, ...overrides });
}limits caps what a run may spend. Set it on createAgent() (every run of the
agent) or on AgentExecutor.execute() / stream():
| Limit | Counts |
|---|---|
maxTokens |
Prompt plus completion tokens of the run (usage.totalTokens). |
maxInputTokens |
Prompt tokens (usage.inputTokens). |
maxOutputTokens |
Completion tokens (usage.outputTokens). |
maxCostUsd |
USD (usage.costUsd): the cost the provider reported for each call (OpenRouter's usage.cost), else the price table estimate. Not checked while a model used has unknown pricing (costUsd is undefined); a warning is logged once. |
maxDurationMs |
Wall-clock time of the execute() / stream() call. |
maxToolErrors |
Tool calls that ended in an error result (result.toolErrors, see Tools). The run stops before its next model call once that many have failed. Counted per run, also on agent.session({ limits }) (each turn's own). |
maxSteps |
Model steps. An alias of the maxSteps option: when both are set the stricter wins (the option's tie reports 'max-steps'); alone, it replaces the default of 10. With output set, running out of it makes the same forced answer call as maxSteps (Structured output); the run still ends 'budget-exceeded', with object when the answer validated. |
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. A run that finishes on its own within the step that reached a
limit keeps its own finish reason, like maxSteps.
Sub-agents share their lead's budget. Usage of sub-agents counts toward
their lead's limits, and a sub-agent is held to them while it runs: before each
of its model calls (and after a model call that asks for tools) it checks the
lead's token, cost and duration limits against what the lead, the lead's other
sub-agents still running, and it itself have spent - on top of its own
limits. Once they are reached the sub-agent stops with 'budget-exceeded'
(always 'stop'; the lead's onExceeded decides how the lead ends), the
task call reports Sub-agent '<name>' stopped at the <limit> limit, and the
lead stops before its next model call. Parallel sub-agents can still go over by
the model calls they had in flight when the limit was reached (at most one
each), not by a whole run each. A nested sub-agent is held to every lead above
it. A sub-agent resumed after an approval is held to its own limits only.
A spend limit (tokens, cost, duration) is a hard stop: a run with output
that reaches one makes no forced answer call (that call would spend past the
limit), so result.object is unset. What the run's tool calls returned before
the limit tripped - finished sub-agents' answers included - stays in
result.messages as tool results. Only running out of steps (maxSteps or
limits.maxSteps) gets the forced answer call.
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.
import { BudgetExceededError, createAgent } from '@lousho/build-ai-agent';
const agent = createAgent({
provider,
limits: { maxTokens: 50_000, maxCostUsd: 0.25, maxDurationMs: 60_000 },
});
const result = await agent.send('Research this thoroughly');
if (result.finishReason === 'budget-exceeded') {
console.warn(`Stopped by ${result.budget?.limit}: ${result.budget?.value} of ${result.budget?.max}`);
}
try {
await createAgent({ provider, limits: { maxCostUsd: 0.01, onExceeded: 'throw' } }).send('Go');
} catch (error) {
if (error instanceof BudgetExceededError) console.error(error.budget);
}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.
import { createAgent, memoryStore } from '@lousho/build-ai-agent';
const agent = createAgent({ provider, store: memoryStore(), limits: { maxTokens: 20_000 } });
const session = agent.session({ id: 'user-42', limits: { maxCostUsd: 1 } });
const reply = await session.send('Hello');
if (reply.budget?.scope === 'session') console.log('This conversation used up its budget.');Many repositories keep guidance for coding agents in an AGENTS.md (or
CLAUDE.md) file. createAgent can append it to the agent's instructions:
import { createAgent } from '@lousho/build-ai-agent';
const agent = createAgent({
instructions: 'You review pull requests.',
provider,
projectInstructions: true, // or { cwd: './packages/api', files: ['AGENTS.md'] }
});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.
The options of AgentExecutor.execute(), the lower-level entry point, are listed on the executor API page.
lousho dev, lousho build, lousho mcp and the other commands, with their
flags, are described in CLI; lousho mcp is also covered in MCP.