Skip to content

Latest commit

 

History

History
392 lines (324 loc) · 19.8 KB

agent-directories.md

File metadata and controls

392 lines (324 loc) · 19.8 KB

Agent directories

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 in tools/). 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!');

Layout

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.

Config file

// 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.

Tools

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

skills/ accepts what loadSkills() accepts: skills/<name>/SKILL.md and skills/<name>.md, each with a description in its frontmatter. See Skills.

Sub-agents

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 permissions rules come first in the sub-agent, so a deny('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's subagentPath names the sub-agent) and agent.approvals.resolve() continues it. The sub-agent directory's own approve decides its calls first; without one, an approve passed to loadAgentDir() in code does, and otherwise the call waits for a human. The parent directory's own approve file 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 with event.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.

Channels

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).

Memory

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.

How it maps to createAgent()

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.

Overrides

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.

Moving from files to code

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.

Loading TypeScript

.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).

Run it with lousho dev

npx lousho dev ./my-agent

Serves 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).

Deploy it with lousho build

npx lousho build ./my-agent --target=node-server    # or docker

The 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).

Serving over MCP

lousho mcp takes an agent directory as well as a spec file (Configuration): lousho mcp ./my-agent serves it as an MCP server (MCP).