Agents
The agents/ directory defines custom subagents and lets you override built-in agents. Each subdirectory is an agent with an AGENT.md file.
agents/
├── explore/ ← override: replaces default explore agent
│ └── AGENT.md
├── plan/ ← override: replaces default plan agent
│ └── AGENT.md
├── compliance-checker/ ← subagent: custom task agent
│ └── AGENT.md
└── vendor-lookup/ ← subagent: custom task agent
└── AGENT.mdReserved Names (Overrides)
These directory names override built-in agents:
| Name | What It Overrides |
|---|---|
explore | The explore sub-agent that gathers data from connected systems |
plan | The plan agent that reasons before executing |
main | The primary agent prompt |
Override agents use the raw AGENT.md content as the system prompt.
Custom Subagents
Any directory that isn't a reserved name defines a custom subagent — a reusable task agent the primary agent can dispatch by name for specialized work.
agent.json + AGENT.md (preferred)
Config lives in agent.json; the sibling AGENT.md holds the prompt (a ## Prompt section is honored if present, otherwise the whole file). This mirrors the tool.json + handler.ts split for tools.
agents/compliance-checker/
├── agent.json ← config (single source of truth)
└── AGENT.md ← prompt{
"name": "Compliance Checker",
"description": "Checks regulatory compliance across transactions and flags violations.",
"tools": ["request", "store__research_notes__query", "dispatch_task"],
"maxDepth": 2,
"maxToolCalls": 15,
"timeout": 60,
"modelTier": "advanced",
"targetOutputMin": 200,
"targetOutputMax": 500
}You are a compliance specialist. When dispatched:
1. Load the relevant compliance KB documents for the regulation in question
2. Query the transaction system for the entities specified
3. Check each entity against the compliance rules
4. Return a structured report:
- Compliant items (brief)
- Violations (detailed, with rule references)
- RecommendationsAll agent.json fields are optional; a bare {} (or no agent.json at all) gives a prompt-only agent with platform defaults. Resource-scoping fields (skills, connections, stores, mcp, subagents) let the agent serve as a conversation root.
Each of those accepts a bare name or an object — ["review", {"name": "adjudicate"}] — so an entry reads the same whether it lives here or in agent.ts, where it can also carry a condition.
Removed: the legacy heading-based format's
## ConfigYAML block inside AGENT.md is no longer parsed. An AGENT.md without anagent.jsonis prompt-only and gets platform defaults; non-default config must live inagent.json(or frontmatter, below).
agent.ts (capabilities that depend on who is asking)
Everything in agent.json is fixed when you write it. Sometimes it should not
be: a skill that only applies to joint missions, a subagent only a manager may
call, a store only an auditor may read.
Add a sibling agent.ts and any entry can carry a conditional — a function
that decides whether that capability is part of the surface for this caller.
It is discovered by presence, the same way eval.ts is found beside eval.md;
nothing points at it.
agents/requests/
├── agent.json ← config (optional once agent.ts exists)
├── agent.ts ← config, in code — the only form that can hold a predicate
└── AGENT.md ← promptimport type {AgentDefinition, AgentSurfaceContext} from '@amodalai/types';
const isManager = (ctx: AgentSurfaceContext) => ctx.claims.role === 'manager';
export default {
tools: ['review', 'check_conflicts'],
skills: [
'request-review',
{name: 'redbook-review', conditional: (ctx) => ctx.context.mission_type === 'joint'},
],
subagents: [
'prescreen',
{name: 'adjudicator', conditional: isManager},
],
stores: {
requests: 'read',
decisions: {mode: 'rw', conditional: isManager},
},
} satisfies AgentDefinition;Any entry accepts either a bare name or {name, conditional} — tools,
skills, connections, mcp, subagents, and stores (which uses
{mode, conditional}, since it is a map rather than a list).
A predicate can only subtract
The entries written in the file are the ceiling. A predicate decides whether a listed capability survives for this caller; it can never add one that is not written there.
That is deliberate, and it is what keeps the file worth reading: "what can this agent do, at most" is still answerable by opening it, without running anything against a particular user. Anything more powerful would trade that away.
ctx.claims vs ctx.context — the one thing to get right
interface AgentSurfaceContext {
claims: Record<string, string>; // verified JWT claims
context: Record<string, string>; // request context — CLIENT-SUPPLIED
scopeId: string;
userId?: string;
orgId?: string;
humanPresent: boolean; // false for cron, webhooks, backfills
isSubagent: boolean; // running as a delegated specialist
agentName: string;
}context is the same payload CONTEXT.md renders against, and the caller
controls it. claims comes from the verified JWT and the caller cannot forge
it.
| Your intent | Read | Example |
|---|---|---|
| Curation — which playbook, which specialist fits the situation | ctx.context | page, open record, mission type |
| Authorization — who is allowed to do this | ctx.claims | role, tenant, theater |
Reading ctx.context for curation is safe: the worst a caller can do is narrow
their own surface. Reading it for authorization is not — a caller who wants
adjudicate simply sends {"role": "manager"} in the request body and gets it.
If a predicate exists to withhold something, it must read ctx.claims.
isSubagent, and when you need it
An authorization predicate keeps working when the agent is invoked as a specialist: it sees the claims of the session that delegated to it.
A curation predicate does not. ctx.context.page === 'requests' describes what
the user is looking at, which says nothing about whether a headless specialist
should hold the tool. isSubagent is how one predicate stays right in both
places:
{name: 'lookup', conditional: (ctx) => ctx.isSubagent || ctx.context.page === 'requests'}Rules worth knowing
- Predicates must be synchronous and pure. They run when the session is built and again on each turn. No network calls, no database reads — if you need a value fetched, have the host supply it in the request context.
- Gating a skill takes its tools with it. A skill's
allowedToolscome in with the skill, so withholding the skill withholds them too — otherwise the capability would stay reachable without the instructions for using it. - Everything fails closed. A predicate that throws, or a session with no caller context at all, excludes the entry. An error never grants.
agent.jsonandagent.tsmerge, andagent.tswins per field, with a warning naming what it overrode. Keep the declarative fields inagent.jsonand put only the conditional ones inagent.ts, or move the agent entirely.- A
conditionalinagent.jsonis rejected at load. JSON cannot hold a function, so accepting it would leave you believing an entry was gated when it was granted to everyone. - Only the default export is read. An agent is identified by its directory, so a named export is an error rather than a second agent.
It re-resolves when the caller changes
Conditionals are evaluated when the session is built. If a later turn arrives with different context — the user navigated, their claims changed — the surface is recomputed and swapped in place, keeping the conversation. An agent that declares no conditionals skips this entirely and costs nothing.
CONTEXT.md (facts that change while the user works)
AGENT.md is compiled once when a session starts and is marked for prompt
caching, so it can only state things that stay true for that whole session — who
the agent is, which mission is open, what it may do. State something changeable
there and it freezes at the first turn: the agent will keep asserting the
opening value for the rest of the conversation.
An optional sibling CONTEXT.md is the other half. It is rendered fresh on
every turn against that turn's request context, and sent after the cache
breakpoint.
agents/requests/
├── agent.json ← config
├── AGENT.md ← stable prompt (cached, once per session)
└── CONTEXT.md ← volatile prompt (re-rendered every turn)Both files can read the request's context payload under scope, using
Nunjucks:
AGENT.md — who and where, fixed for the session:
You review spectrum requests and recommend a disposition.
{% if scope.mission_id %}
The active mission is {{ scope.mission_name }} (`{{ scope.mission_id }}`).
{% else %}
No mission is active — ask which one before running mission-scoped reads.
{% endif %}CONTEXT.md — what is true right now:
{% if scope.request_status %}
## Right now
Status: {{ scope.request_status }}. Readiness: {{ scope.request_readiness }}.
{% endif %}Send the values on context with each request:
POST /chat/stream
{
"message": "why has this not been approved?",
"context": {
"mission_id": "ex-ozark-relay-26",
"request_status": "Pending review",
"request_readiness": "40%"
}
}Which file does a fact go in?
Ask whether it can change while the user is working.
| file | example | |
|---|---|---|
| fixed for the session | AGENT.md | which mission is open, which record the user opened |
| changes as they work | CONTEXT.md | status, readiness, current selection, what is on screen |
| only the server knows | neither — use a tool | anything you would have to fetch anyway |
Getting it wrong costs cache hits, not correctness: a changing value in
AGENT.md makes the cached prefix miss more often, and the agent may state a
stale value until the session restarts.
These three are all about what the agent knows. If the answer changes what
the agent may do — this caller should not have that tool at all — that is a
conditional capability in
agent.ts, not a paragraph in a prompt. Telling a model not to use a tool it
holds is a request; not giving it the tool is a fact.
Notes
- Both files are optional. No
CONTEXT.mdmeans a single cached prompt, exactly as before. - Values are data, never templates. A
contextvalue containing{{ }}or{% %}is rendered as inert text — it cannot execute. - Every value is a string. So
"false"and"0"are truthy under{% if %}. Compare explicitly:{% if scope.flag == "false" %}. - Empty and missing are the same. An empty value is dropped, so
{% if scope.x %}means "present and non-empty" and{{ scope.x | default("none") }}fires. - Values are capped at 2,000 characters each, and the whole
scopenamespace at 16,000. - To show template syntax literally in a prompt, wrap it in
{% raw %}...{% endraw %}.
AGENT.md Format (frontmatter)
---
displayName: Vendor Lookup
description: Enriches vendor profiles from CRM and public data
tools: [request, store__research_notes__query]
maxDepth: 1
maxToolCalls: 10
timeout: 30
modelTier: simple
---
Query the vendor management system for the requested vendor.
Cross-reference with public data sources.
Return a standardized vendor profile with:
- Company info, industry, size
- Contract history
- Risk indicatorsConfiguration Fields
| Field | Type | Default | Description |
|---|---|---|---|
displayName | string | directory name | Human-readable name |
description | string | displayName | Short description |
tools | entry[] | [] (no tools) | Tools this sub-agent can use when dispatched. Opt-in: an agent that declares nothing gets no tools (there is no implicit shell_exec or load_knowledge). Names must exist in the parent's registered tool set. |
skills | entry[] | — | Skills in scope. Declaring one also brings the tools in its allowedTools. |
connections | entry[] | — | Connections whose tools are exposed. |
mcp | entry[] | — | MCP servers (keys of amodal.json mcp.servers). |
subagents | entry[] | — | Specialists this agent may invoke by name. |
stores | map | — | Per-store access: "read" or "rw". |
maxDepth | number (1-4) | 1 | Dispatch depth (1 = no sub-agents) |
maxToolCalls | number (1-100) | 10 | Max tool calls per execution |
timeout | number (5-600) | 20 | Timeout in seconds |
targetOutputMin | number (50-2000) | 200 | Min output tokens |
targetOutputMax | number (50-2000) | 400 | Max output tokens |
modelTier | simple | default | advanced | — | Model selection tier |
Model Tiers
modelTier is a routing hint for platform/runtime model policy:
| Tier | Use Case |
|---|---|
simple | Data gathering, API queries, structured extraction |
default | Standard reasoning |
advanced | Complex analysis, multi-step reasoning |
Available Tools
Use any tool name registered on the parent's tool registry: request, store__<store>__get, store__<store>__set, store__<store>__query, store__<store>__list, store__<store>__remove, dispatch_task, present, stop_execution, plus any custom tools or MCP tools. Knowledge is pre-loaded into the sub-agent's context at dispatch time (no load_knowledge tool — knowledge comes in the system prompt, same as the primary agent).
How Subagents Are Dispatched
The primary agent uses the dispatch tool to invoke subagents by name:
Primary Agent: "I need to check compliance for these transactions"
dispatch({
agent: "compliance-checker",
query: "Check SOX compliance for transactions TXN-001 through TXN-050"
})
→ Compliance checker agent runs in isolated context
→ Returns 200-500 token summary
→ Primary agent continues reasoning with the resultEach subagent gets its own context window. Results are returned as clean summaries, keeping the primary agent's context focused on reasoning.
Context Isolation
Without subagents: With subagents:
[System prompt: 2K] [System prompt: 2K]
[User: "Check compliance"] [User: "Check compliance"]
[Compliance rules: 8K] ← stuck [Subagent result: 300 tokens]
[Raw transactions: 5K] ← stuck [Subagent result: 250 tokens]
Context: 20K+ and growing Context: 3K — clean, focused