An agent can be defined as a directory. loadAgentDir() reads it and calls
createAgent() with the options the files describe, so it returns exactly what
createAgent() returns. The typed code API stays the source of truth: a
directory is a different way to write the same options, and you can switch to
code at any time without a rewrite.
Security: loading an agent directory executes its code (
agent.ts, everything intools/). Only load directories you trust. No sandboxing is implied; the files run with your process's full permissions.
import { loadAgentDir } from '@lousho/build-ai-agent';
const agent = await loadAgentDir('./my-agent');
const { text } = await agent.send('Hello!');my-agent/
agent.{ts,mts,js,mjs,cjs,json,yaml,yml} # optional config
instructions.md # system prompt
instructions/<family>.md # appended to the prompt when the model id contains <family>
hooks.{ts,mts,cts,js,mjs,cjs} # hooks file (or any path `hooks` names), exports a hook or a list
approve.{ts,mts,cts,js,mjs,cjs} # approver file (or any path `approve` names), exports a function
tools/*.{ts,mts,cts,js,mjs,cjs} # defineTool() tools
skills/ # same layouts as loadSkills()
subagents/<name>/ # nested agent directories
schedules/*.{ts,mts,cts,js,mjs,cjs} # defineSchedule() cron schedules (see schedules.md)
channels/*.{ts,mts,cts,js,mjs,cjs} # a channel each: defineChannel(), webhookChannel(), ... (see channels.md)
memory/*.{ts,mts,cts,js,mjs,cjs} # a memory slot each: defineMemory() (see memory.md); part of the agent
auth.{ts,mts,cts,js,mjs,cjs} # route auth for the deployed server: jwt(), oidc(), basic(), ... (see auth.md),
# plus optional named authorizeSession / authorizeApproval exports
Only one config file may exist. Files inside tools/ and the entries of
skills/ and subagents/ are read in sorted order, so loading is
deterministic.
// agent.ts
export default {
model: 'openai/gpt-4o-mini',
description: 'Triages bug reports',
maxSteps: 8,
toolConcurrency: 2,
};| Key | Maps to |
|---|---|
name |
createAgent({ name }) (default: the directory name) |
description |
manifest.description — the directory loader does not forward it to createAgent (though createAgent({ description }) accepts it); what a parent agent reads for a sub-agent (required there) |
model |
createAgent({ model }); the vendor/ prefix chooses the provider — with OpenRouter use openrouter/<vendor>/<model> |
provider |
createAgent({ provider }) (code config files only) |
instructions |
createAgent({ instructions }) (use this or instructions.md, not both) |
maxSteps |
createAgent({ maxSteps }) |
toolConcurrency |
createAgent({ toolConcurrency }) |
projectInstructions |
createAgent({ projectInstructions }) |
permissionMode |
createAgent({ permissionMode }): 'default', 'plan', 'acceptEdits' or 'dontAsk' (a function in a code config) |
permissions |
createAgent({ permissions }), in the serializable rule form below |
compaction |
createAgent({ compaction }): true or an options object |
hooks |
createAgent({ hooks }): a path (relative to the directory) to a file default-exporting a hook or a list, or the hooks inline in a code config |
approve |
createAgent({ approve }): a path to a file default-exporting the approver function, or the function inline in a code config |
approvalTtlMs |
createAgent({ approvalTtlMs }): milliseconds before an unanswered approval expires; an ask rule's ttlMs wins over it |
limits |
createAgent({ limits }), e.g. { "maxCostUsd": 0.05 } |
modelSettings |
createAgent({ modelSettings }), e.g. { "maxTokens": 1024, "temperature": 0.2 }; see Model settings |
retry |
createAgent({ retry }), e.g. { "maxRetries": 4, "timeoutMs": 30000, "backoff": { "initialMs": 1000 } }; timeoutMs bounds each attempt, false sends each call once. retryOn, onRetry and signal are functions, so code configs only |
output |
createAgent({ output }): a Standard Schema (e.g. a zod object) the final answer must match. Code config files only (JSON/YAML cannot hold a schema); from code, loadAgentDir(dir, { output }) also types result.object |
store |
createAgent({ store }): { "dir": "./.lousho" } (resolved against the agent directory) becomes a fileStore() — sessions, checkpoints and paused approvals survive a restart; historyLimit and tokenKey may be set too. A code config may give an AgentStore instance instead. Node only |
Unknown keys are an error with a suggestion ('modle' (did you mean 'model'?)).
A permissions rule is { "tool", "when"?, "action", "reason"?, "ttlMs"? }:
tool is a
name, a list of names ('*' covers every tool) or a RegExp in a code config;
action is 'allow', 'ask' or 'deny'; ttlMs is only allowed on 'ask'
rules — how long the pause waits for a decision before it expires ('approval
expired'), winning over the agent's approvalTtlMs. In JSON/YAML when is a record of
argument names to matchers, all of which must hold. A bare string is a regular
expression tested against String(args[name]); an object picks operators:
eq/ne compare the argument to the operand with ===/!==, lt/lte/gt/gte
compare Number(args[name]) with the operand (a non-numeric argument never
matches) and matches is a regular expression. Several operators on one
argument must all hold, so { "replicas": { "gte": 1, "lte": 10 } } matches 1
through 10. A code config may give the predicate itself. The first matching
rule decides.
// agent.json
{
"permissions": [
{ "tool": "shell", "when": { "command": "\\brm\\b" }, "action": "deny", "reason": "No deletes" },
{ "tool": "deploy", "when": { "environment": { "eq": "staging" }, "replicas": { "lte": 10 } }, "action": "ask" },
{ "tool": ["read_file", "shell"], "action": "allow" },
{ "tool": ["write_file", "edit_file"], "action": "ask" }
],
"hooks": "hooks.ts",
"approve": "approve.ts",
"limits": { "maxCostUsd": 0.05 }
}// hooks.ts — the default export is a hook or a list of them
import type { AgentHook } from '@lousho/build-ai-agent';
const audit: AgentHook = {
name: 'audit',
postToolCall(ctx) {
console.log(ctx.toolName);
},
};
export default audit;// approve.ts — the default export decides every `ask`ed call
import type { ApproveToolCall } from '@lousho/build-ai-agent';
const approve: ApproveToolCall = ({ args }) => !String(args.path ?? '').endsWith('.test.js');
export default approve;A hooks / approve path must be relative and stay inside the directory;
lousho build bundles root hooks.* and approve.* files next to the config
and resolves the compiled .js sibling, so .ts paths keep working after the
build.
instructions/<family>.md appends to instructions.md when the resolved model
id contains <family>: instructions/openai.md applies to
openai/gpt-4o-mini and to openrouter/openai/gpt-4o-mini. The first match in
sorted order wins; an unrelated model id gets the base prompt only.
Each file in tools/ default-exports a defineTool() tool, and may also export
more tools by name (or an array of tools). Other exports are ignored, but a file
that exports no tool at all is an error, as are two tools with the same name
(both files are named in the message).
// tools/send_email.ts
import { z } from 'zod';
import { defineTool } from '@lousho/build-ai-agent';
export default defineTool({
name: 'send_email',
description: 'Send an email',
input: z.object({ to: z.string(), body: z.string() }),
execute: async ({ to, body }) => ({ sent: true, to, length: body.length }),
});skills/ accepts what loadSkills() accepts: skills/<name>/SKILL.md and
skills/<name>.md, each with a description in its frontmatter. See
Skills.
Every directory in subagents/ is itself an agent directory (and can have its
own subagents/). It must have a description in its config. Each one is a
native sub-agent of the parent: it lands in the parent's subagents map, so
the parent's model delegates to it with the task tool (see
Sub-agents). The parent also gets a delegate_to_<name> tool,
kept as a backward-compatible alias: it takes the task text and returns the
sub-agent's final answer, and it runs through the same runtime as task.
A sub-agent uses its own model if it sets one, otherwise it inherits the
parent's; a provider override passed to loadAgentDir() reaches all of them.
Either way the sub-agent runs inside the parent's run, like any createAgent()
sub-agent:
- the parent's
permissionsrules come first in the sub-agent, so adeny('write_file')on the parent holds for the whole tree; - a sub-agent call that needs approval pauses the parent run
(
finishReason: 'awaiting-approval', the pending approval'ssubagentPathnames the sub-agent) andagent.approvals.resolve()continues it. The sub-agent directory's ownapprovedecides its calls first; without one, anapprovepassed toloadAgentDir()in code does, and otherwise the call waits for a human. The parent directory's ownapprovefile does not decide its sub-agents' calls; - its token usage is added to the parent's (
result.usage.delegated), its events reach the parent's listeners tagged withevent.subagent, and its spans nest under the parent's trace.
Nested subagents/ directories raise the parent's maxSubagentDepth to the
depth of the tree (unless you pass one), so every level can delegate.
A sub-agent directory whose config sets "engine": "pi" becomes a Pi
coding-agent sub-agent instead: it lands in the parent's subagents map (the
task tool) without a delegate_to_<name> alias, and its sessions
run in the parent's directory - the workspace the parent's file tools and the
Pi session share. model takes the usual pi/<provider>/<model> id, and
permissions rules gate the Pi session's own tool calls (read, bash,
edit, write, ...). Other keys in its config are ignored: Pi carries its
own system prompt and coding tools. Running a pi sub-agent needs the
optional @earendil-works/pi-coding-agent peer and the Node runtime - a
Cloudflare Worker target rejects it.
// subagents/coder/agent.json
{
"description": "Implements code changes in this workspace.",
"engine": "pi",
"model": "pi/openrouter/openai/gpt-4o-mini",
"permissions": [
{ "tool": "bash", "when": { "command": "\\brm\\b" }, "action": "deny" },
{ "tool": "*", "action": "allow" }
]
}loadAgentDir(dir, { piAgent: { ... } }) injects piAgent() options into
every engine: 'pi' sub-agent (e.g. a faux modelRuntime in tests, a
sessionDir for durable approvals); the directory always wins cwd, name
and description.
Each file in channels/ default-exports a channel made with
defineChannel() or a built-in factory (webhookChannel(), httpChannel(),
slackChannel(), discordChannel(), telegramChannel(), githubChannel(), teamsChannel()). The channel's name is the one it sets, else the file name.
A file that does not export a channel fails with LOUSHO_CHANNEL_INVALID
naming the file. resolveAgentDir() returns them as channels (and their names
as manifest.channels); loadAgentDir() does not mount them. The node server
that lousho build --target=node-server emits mounts them under /channels
next to the chat routes (createDeployedServer(agent, { channels }), an
internal helper of the generated bundle); with your own server, use
mountChannels():
import { createServer } from 'node:http';
import { createAgent, mountChannels, resolveAgentDir } from '@lousho/build-ai-agent';
// channels/support.ts: export default webhookChannel({ secret: process.env.HOOK_SECRET ?? '' })
const { config, channels } = await resolveAgentDir('./my-agent');
const handler = mountChannels(createAgent(config), channels);
createServer((req, res) => void handler(req, res).then((handled) => handled || res.writeHead(404).end())).listen(3000);lousho dev mounts them too, and lousho build deploys them (see below).
Each file in memory/ default-exports a memory slot: the result of
defineMemory({ ... }), or the same options without a name, in which case the
file name is the slot name. Unlike schedules and channels, slots are part of the
agent: loadAgentDir() passes them to createAgent({ memory }), so the
remember_<name> / recall_<name> tools and recall into the prompt work with no
extra code, and manifest.memory lists their names. Each slot uses its own
provider. A file that does not export a slot fails with LOUSHO_MEMORY_INVALID
naming the file. A memory override passed to loadAgentDir(dir, { overrides })
is merged with the directory's slots by name: the override wins a name clash.
import { defineMemory, fileMemory, resolveAgentDir } from '@lousho/build-ai-agent';
// memory/notes.ts: export default { scope: 'global', provider: fileMemory({ dir: './.lousho/memory' }) }
const { manifest } = await resolveAgentDir('./my-agent', {
memory: [defineMemory({ name: 'notes', scope: 'global', provider: fileMemory({ dir: './.lousho/memory' }) })],
});
console.log(manifest.memory); // names found in memory/A relative dir in a memory/*.ts file resolves against the process working directory, not the agent directory (unlike store.dir in agent.ts, which is relative to the agent directory). A bare ./.lousho/memory therefore puts memory in a different tree from the sessions, and it moves when you start the agent from another directory or from a build. Anchor it to the module instead, so it always lands next to the agent's other state:
// memory/notes.ts
import { fileURLToPath } from 'node:url';
import { fileMemory } from '@lousho/build-ai-agent';
export default {
scope: 'global',
provider: fileMemory({ dir: fileURLToPath(new URL('../.lousho/memory', import.meta.url)) }),
};The path follows the module: a copied or bundled build keeps its memory next to the build, so point it at a fixed absolute path (or a database provider) if memory must survive redeploys.
| Directory | createAgent() option |
|---|---|
instructions.md |
instructions |
agent.* keys |
the options in the table above |
tools/ |
tools: [...] |
skills/ |
skills: await loadSkills('./skills') |
subagents/<name>/ |
one subagents entry (the task tool), plus a delegate_to_<name> alias in tools |
resolveAgentDir() returns the assembled options and a manifest, which is
handy for tests and tooling:
import { resolveAgentDir, createAgent } from '@lousho/build-ai-agent';
const { config, manifest } = await resolveAgentDir('./my-agent');
console.log(manifest.tools, manifest.skills, manifest.subagents);
const agent = createAgent({ ...config, maxSteps: 3 });When the directory has a lousho-registry.json install receipt
(Registry), manifest.registry reports whether each installed
item's files still match it, and the tools of a receipt item run inside the
permission manifest it was installed with.
The second argument takes the same options as createAgent() and wins over the
files. Every option is passed on to the agent, including the ones no file
declares (guardrails, onEvent, retry, reasoning, redactContent,
output, ...). Use it to swap the model in tests:
import { loadAgentDir } from '@lousho/build-ai-agent';
import { mockModel } from '@lousho/build-ai-agent/testing';
const agent = await loadAgentDir('./my-agent', { provider: mockModel(['Hi there.']) });tools and skills overrides replace the discovered ones (they are not
merged). A provider/model string in a file is dropped when you override
provider, so the instance is used as-is. approve: null / hooks: null
strip the directory's wiring (the file its config names is not even imported);
undefined keeps it.
Call resolveAgentDir(), print config, and paste what you need into a
createAgent() call; or load the directory and override just the parts you want
to own in code. Tools are plain defineTool() values, so a tool file can be
imported from code unchanged.
.ts files are loaded with a dynamic import(), so the process must already
run under a TypeScript loader: npx tsx your-script.ts (or ts-node, bun, deno,
or Node's built-in type stripping). Without one, loading fails with an error that
says so. Compile the directory first, or use .js/.mjs tools with an
agent.json / agent.yaml config, which work everywhere. A .js tool using
import syntax needs "type": "module" in the nearest package.json (or the
.mjs extension).
npx lousho dev ./my-agentServes the chat UI and POST /chat for the directory and reloads it when
instructions.md, the config file, tools/, skills/ or subagents/ change
(see lousho dev for the details). A tool file is
imported afresh on each reload, so an edit to tools/*.ts takes effect on the
next message. A failed reload (a syntax error, an empty instructions.md) is
logged and shown in the chat page, and the previous agent keeps answering.
The directory's channels/ are mounted under /channels and its schedules/
are started, and a reload swaps both: the old schedules are stopped before the
new ones start, so no timer or route outlives its file. Pass --no-schedules to
mount the channels but not fire the crons (see Schedules in
dev).
npx lousho build ./my-agent --target=node-server # or dockerThe agent directory is the unit of deployment: the built server loads it with
resolveAgentDir() at start-up, starts its schedules/ and mounts its
channels/ under /channels, guards its chat routes with auth.ts when there
is one (Route auth and principals), and prints which it found. A
function auth.ts exports as authorizeSession or authorizeApproval
decides who may use a session and who may decide an approval, e.g. a manager
approving a user's paused claim (Who may decide an approval);
without them the server keeps the defaults (a session and its approvals belong to the caller that started it). The code files (the
config, auth.ts, a root hooks.* / approve.* file, tools/, schedules/, channels/, memory/, and the same in each
sub-agent) are bundled to dist/agent/**.js, and only instructions.md (with instructions/<family>.md), skills/
and a JSON/YAML config (the same in each sub-agent) are copied next to them, so the server needs no TypeScript
loader, sources or node_modules. Nothing else is copied: .env files, the .lousho/ session store
(or the config's store.dir) and any other file stay out of the build and its Docker image. See Deployment.
The Cloudflare Worker target builds a directory too, embedding every folder: subagents/ become delegate_to_ tools, schedules/ become cron triggers, channels/ are mounted under /channels and memory/ slots run on kvMemory() (Cloudflare Workers).
lousho mcp takes an agent directory as well as a spec file (Configuration):
lousho mcp ./my-agent serves it as an MCP server (MCP).