Subagent Development Guide¶
A guide to defining and registering subagents from code (Java)
This document explains how to define and register a subagent from Java code, with no agents/*.md
markdown file involved. For the design rationale and how the decisions were reached, see
design/subagent/code-defined-registration.md.
Table of contents¶
- Overview
- When to use a code definition
- Core concepts
- Using Subagent.builder()
- Registration: InMemorySubagentRegistry
- Bootstrap wiring
- Composition priority — the code definition is authoritative
- Verifying the behaviour: the paths it is exposed on automatically
- A full example
- Code-behaviour (custom behavior) subagents
- The background fan-out pattern
- Markdown vs a code definition
- Checklist
Overview¶
A subagent is a specialised agent that TaskTool (Task) invokes. It shares the same ReAct execution model, but
carries its own system prompt and tool allow-list.
A subagent is an immutable value object (Subagent) that is agnostic about its source (markdown or code). So
whether you define it in markdown (agents/*.md) or in code (Subagent.builder()), execution, hooks, cancellation
propagation, the tool permission gate and LLM usage attribution all behave exactly the same.
Core principles¶
| Principle | Description |
|---|---|
| Source-agnostic | The execution layer never asks where a Subagent came from |
| Immutability | Subagent is an immutable value object — once built it cannot be changed |
| Data definitions only | A code definition supplies the prompt, tools and model/iteration/permission settings only. A per-subagent custom executor is not supported |
| Markdown parity | The builder's defaults are identical to the markdown parser's (maxIterations=1000, and so on) |
| Authoritative | A code definition cannot be shadowed by a user .md of the same name (security: it protects the curated tool allow-list) |
Package structure¶
at.aimon.core.subagent/
├── Subagent.java # the immutable value object + Subagent.builder()
├── SubagentMetadata.java # description / tools / model / iterations / permissions
├── SubagentContent.java # the system prompt
├── SubagentRegistry.java # the read-only registry interface
├── MutableSubagentRegistry.java # writes (register/unregister) — CQRS
├── InMemorySubagentRegistry.java # the in-memory implementation for code definitions
├── CompositeSubagentRegistry.java # layer composition (later-wins)
└── DefaultSubagentRegistry.java # the file-based (agents/*.md) implementation
When to use a code definition¶
| Situation | Recommended |
|---|---|
| A subagent your application ships built in (which the user must not be able to delete) | A code definition |
| You have to enforce a curated, narrow tool allow-list (security) | A code definition (it cannot be shadowed by a user .md) |
| The prompt is fixed at build/deploy time | A code definition |
| The user adds and edits them freely at runtime | agents/*.md (file-based) |
| It is updated by hot-reload while running | agents/*.md (a code definition is not a reload target — see below) |
Code definitions and file definitions coexist. A code definition does not touch the existing file-based path at all.
Core concepts¶
[file source] agents/*.md ─► DefaultSubagentRegistry(user) ──┐
│
[bundle] AgentBundle.getSubagentRegistry() ─────────────┤ CompositeSubagentRegistry(
│ [bundled, user, code])
[code] Subagent.builder()...build() │ │ ← code wins
└► InMemorySubagentRegistry.register(...) ───────┘ ▼
(unchanged) TaskTool · SkillFork · /agents · the executor
Subagent— an immutable value object made of a name plusSubagentMetadataandSubagentContent.InMemorySubagentRegistry— theMutableSubagentRegistryimplementation that keeps code-registeredSubagents in memory.CompositeSubagentRegistry— composes the bundled, user and code registries into one view. The later an entry sits in the list, the higher its priority.
Since the consumers (TaskTool, the executor, /agents, a skill fork) see nothing but the read-only
SubagentRegistry abstraction, whatever you define in code is exposed, executed and listed automatically,
with no change to the calling layer's code.
Where a tool restriction takes effect¶
allowed-tools (markdown) and .tools(...) (code) are the same allow-list, and it acts at two points
during a run. Both read the one Subagent.getAllowedTools(), so the two cannot disagree.
| Point | What it does |
|---|---|
| The prompt | A tool whose name is absent from the allow-list is left out of the tool definitions sent to the LLM — the model cannot pick it in the first place |
ToolSearch |
It is subject to the allow-list too. In a deployment using deferred tools, an allow-list that omits ToolSearch leaves no route to them |
| Dispatch | A call made anyway is refused with ToolPermissionViolationException |
A pattern entry such as Bash(git:*) still offers its tool. A list of tools cannot say "which arguments" —
Bash stays visible to the model, and a command that is not git is refused at dispatch.
If an allow-list matches no registered tool at all — a typo, a tool from a module that is not wired, a missing
ToolSearch — the subagent answers with no tools, and that execution is still recorded as a success. A warning is
logged when it happens, so start there if a subagent returns an answer without acting.
Using Subagent.builder()¶
import java.util.List;
import at.aimon.core.subagent.Subagent;
Subagent dbTriage = Subagent.builder()
.name("db-triage") // required
.description("First-pass DB incident triage. Narrows down likely causes after inspecting metrics and logs.") // TaskTool exposes it to the LLM
.whenToUse("When a DB incident has occurred and needs a first-pass triage") // optional — exposed as a trigger in the TaskTool description
.tools(List.of("Read", "Grep", "Bash(psql:*)")) // parsed the same way as markdown allowed-tools
// No model: runs on its parent's model (usually the main agent's). Set one only to run on another model: an id the configured provider serves, sent as written
.maxIterations(50) // the ReAct loop ceiling
.systemPrompt("You are a database triage specialist...") // required
.build();
The builder methods¶
| Method | Required | The default when unset (markdown parity) |
|---|---|---|
name(String) |
✅ | — (null makes build() throw NullPointerException) |
systemPrompt(String) |
✅ | — (null makes build() throw NullPointerException) |
description(String) |
null |
|
whenToUse(String) |
null (an optional trigger condition, exposed in the TaskTool description) |
|
tools(List<String>) |
an empty list → hasToolRestrictions() == false (no tool restriction) |
|
model(String) |
null (the executor's default model) |
|
maxIterations(int) |
1000 |
The tool string format is the same as markdown's
allowed-tools:"Read","Bash(git:*)","Bash(npm install)"and so on. Internally it goes throughAllowedTool.parse(...), so the parsing logic is not duplicated.
Markdown parity (important)¶
The builder passes only the fields the caller actually set to SubagentMetadata.builder(). An unset field is
therefore left at the same default the markdown parser relies on. The two definitions below produce equivalent
Subagents.
Both end up with maxIterations=1000, model=null, whenToUse=null and no tool restriction.
Registration: InMemorySubagentRegistry¶
InMemorySubagentRegistry is an implementation of MutableSubagentRegistry (read/write separation, CQRS).
import at.aimon.core.subagent.InMemorySubagentRegistry;
InMemorySubagentRegistry codeSubagents = new InMemorySubagentRegistry();
codeSubagents.register(dbTriage); // a name collision replaces
codeSubagents.register(Subagent.builder().name("log-analyzer").systemPrompt("...").build());
// remove where necessary (returns the removed entry, or Optional.empty())
codeSubagents.unregister("db-triage");
- Thread-safe — backed by a
ConcurrentHashMap. - reload is a safe no-op — a code definition has no "external source to re-read".
CompositeSubagentRegistrypropagates reload unconditionally to every layer, soInMemorySubagentRegistry.reloadAll()/reloadSubagent()leave the state exactly as it was (no throw ❌, no clear ❌). Were those methods to clear, a file hot-reload would evaporate the code definitions along with it.
A naming caution: for subagents the
Default*name (DefaultSubagentRegistry) is already taken by the file-based implementation. The in-memory implementation is therefore namedInMemory*— the opposite ofDefaultToolRegistry/DefaultAgentRegistrybeing the in-memory ones (an unavoidable asymmetry).
Bootstrap wiring¶
Configure the code-definition registry once in the bootstrap as application-scoped, and inject it into
OrcaAgentRuntimeFactory with withCodeSubagentRegistry(...).
CLI (AgentSetupFactory)¶
final InMemorySubagentRegistry codeSubagentRegistry = new InMemorySubagentRegistry();
codeSubagentRegistry.register(Subagent.builder()...build()); // the built-in code subagents (zero of them is fine)
final OrcaAgentRuntimeFactory factory = new OrcaAgentRuntimeFactory(/* ... */)
.withSkillRegistry(skillRegistry)
.withCodeSubagentRegistry(codeSubagentRegistry) // ← the wiring
.withPendingTurnRegistry(pendingTurnRegistry)
/* ... */;
Web / an embedding application¶
aimon-core's web and session modules do not create the factory themselves — the application bootstrap injects an
already-built factory through OrcaAgentRuntimeManager.Builder.agentRuntimeFactory(...). So on the web path too,
you call withCodeSubagentRegistry(...) in exactly the same way when you build the factory.
⚠️ Careful (C2): if you ship code subagents as built-ins, you must wire them in every bootstrap (CLI and web). Wire only one of them and the code subagents go quietly missing on the other transport.
happens-before¶
Build, finish every register(...), and only then hand it over with withCodeSubagentRegistry(...). That way
a partially filled registry is never observed at composition time. (A single-threaded bootstrap satisfies this naturally.)
Composition priority — the code definition is authoritative¶
The composition order is [bundled < user < code], and the code layer — last in the list — has the highest
priority. CompositeSubagentRegistry.getSubagent looks up from the back, and getAllSubagents lets a later layer
overwrite an earlier one, so where the same name exists, the code definition always wins.
| Layer | Source | Priority |
|---|---|---|
| bundled | AgentBundle.getSubagentRegistry() |
lowest |
| user | agents/*.md (DefaultSubagentRegistry) |
middle |
| code | InMemorySubagentRegistry |
highest (un-shadowable) |
A deliberate asymmetry¶
Skills and commands follow a user-first convention, yet subagents alone are the reverse — code-first. The
reason is security: it blocks a user from overwriting a code built-in that carries a curated tool allow-list with
an .aimon/agents/*.md that grants broader permissions.
Verifying the behaviour: the paths it is exposed on automatically¶
A code subagent is included automatically on the following paths, with no change to the calling layer's code.
TaskTool(Task) — thesubagent_nameinput carries no enum constraint, and the tool description is generated dynamically fromregistry.getAllSubagents()on every call, so a code registration is exposed to the LLM.- The
/agentscommand — it shows the composite registry's list as it is. - A skill fork — delegating by the target agent's name resolves against the composite registry.
- A direct
@subagentcall — the same.
A full example¶
import java.util.List;
import at.aimon.core.agent.impl.orca.OrcaAgentRuntimeFactory;
import at.aimon.core.subagent.InMemorySubagentRegistry;
import at.aimon.core.subagent.Subagent;
// 1) define the code subagent
Subagent dbTriage = Subagent.builder()
.name("db-triage")
.description("A first-pass DB incident triage specialist. Use it to diagnose slow queries and lock contention.")
.whenToUse("When a DB incident such as a slow query or lock contention needs a first-pass triage")
.tools(List.of("Read", "Grep", "Bash(psql:*)")) // reads plus psql only — a narrow allow-list
// No model: runs on its parent's model (usually the main agent's). Set one only to run on another model: an id the configured provider serves, sent as written
.maxIterations(50)
.systemPrompt("""
You are a database triage specialist.
1. Inspect slow-query and lock metrics.
2. Narrow down likely root causes.
3. Report findings; do NOT mutate production data.
""")
.build();
// 2) build the registry and register (application-scoped, once at bootstrap)
InMemorySubagentRegistry codeSubagents = new InMemorySubagentRegistry();
codeSubagents.register(dbTriage);
// 3) inject it into the factory (the same for CLI and web)
OrcaAgentRuntimeFactory factory = new OrcaAgentRuntimeFactory(/* ... */)
.withCodeSubagentRegistry(codeSubagents);
// from then on: callable as Task(subagent_name="db-triage", ...), and listed by /agents
Code-behaviour (custom behavior) subagents¶
Everything so far has been a code definition (data: the prompt, the tools, the settings), with execution left to the LLM ReAct loop. You can go one step further and implement the subagent's behaviour itself in Java — deterministic or custom logic that bypasses the ReAct loop.
When you need pure code logic, a Tool is generally the first choice. A code-behaviour subagent is for when you need something that "is invoked as a subagent (exposed to
Task,/agentsand@name) but whose internals are code rather than an LLM loop".
The concept: a data + behaviour pair¶
A code-behaviour subagent is a pair sharing one name.
| Piece | Registry | Role |
|---|---|---|
The Subagent data entry |
SubagentRegistry (the code layer) |
Discovery (the TaskTool list), the description, the tool allow-list |
SubagentBehavior |
SubagentBehaviorRegistry |
Execution — it replaces the ReAct loop |
On dispatch the execution manager (DefaultSubagentExecutionManager) checks "is there a behaviour registered under this
name?" and runs the behaviour if there is, the existing ReAct loop if there is not. The data-subagent path is
unchanged (origin-agnosticism is preserved).
⚠️ Register a behaviour with no data entry and the manager cannot resolve the name, so it fails with
SubagentNotFoundException(fail-fast). Always register the pair —SubagentBehaviorRegistrardoes both at once.
The SubagentBehavior SPI¶
@FunctionalInterface
public interface SubagentBehavior {
SubagentExecutionResult execute(SubagentExecutionContext context, SubagentExecutionRequest request,
SubagentBehaviorSupport support);
}
- It receives the same
SubagentExecutionContext(the subagent, the tool and hook registries, the environment, the cancellation signal, knowledge) andSubagentExecutionRequest(goal, principal, attributes, metadata) as the ReAct executor, and returns the sameSubagentExecutionResult→TaskTooland the background consumers cannot tell the difference. support(SubagentBehaviorSupport) supplies the cancellation signal and the result builders:cancellationSignal(),isCancelledOrInterrupted(),success(finalAnswer),failure(errorMessage)— there is no need to assemble the conversation snapshot or the metadata yourself.- An implementation may use tools and the LLM through
context.getToolRegistry()/getEnvironment()and the like, but the baseline expectation is pure code. As withTool.execute(), returningsupport.failure(...)rather than throwing is recommended (the runner does provide a safety net that shapes a throw or a null into a failure).
An LLM call plus ReAct-parity inputs (optional)¶
Deterministic logic is the default for a code behaviour, but it can also call the model directly. support supplies
inputs resolved exactly as the ReAct path resolves them — not the raw values of getDefaultModel()/getToolRegistry():
support accessor |
Same as what ReAct uses? | Description |
|---|---|---|
resolvedModel() |
✅ | The resolved model: the per-invocation override, else the subagent's model, else the default's name, with the default's temperature and max tokens. The name is sent as written (no alias is resolved) and may be empty, in which case the client sends its own default model. (raw ctx.getDefaultModel() reflects neither the override nor model) |
scopedToolRegistry() |
✅ | The registry filtered by the subagent's allow-list (exposure only, not enforcement — trusted code can still reach everything through ctx.getToolRegistry()) |
effectiveLlmCallMetadata() |
✅ | The metadata for subagent usage attribution (component = the name, feature = "subagent") |
llmGateway() |
✅ | A gateway with the same config as ReAct's (default retries, no fallback). Optional.empty() when no LlmClient is wired |
public SubagentExecutionResult execute(SubagentExecutionContext ctx, SubagentExecutionRequest req,
SubagentBehaviorSupport support) {
var gw = support.llmGateway().orElseThrow(); // Optional.empty() when unwired
var model = support.resolvedModel(); // the subagent's resolved model
List<ToolDefinition> tools = support.scopedToolRegistry().findAll().stream()
.map(Tool::getDefinition).toList(); // scoped to the allow-list
// (A) the simple form — retries and fallback apply, but no usage attribution
LlmResponse a = gw.sendMessage("You are ...", messages, tools, model);
// (B) with attribution — the parts overload + effectiveLlmCallMetadata()
SystemPromptParts parts = SystemPromptParts.of(List.of(SystemPromptPart.builder()
.content("You are ...").staticness(Staticness.STATIC).kind("system").build()));
LlmResponse b = gw.sendMessage(parts, messages, tools, model, support.effectiveLlmCallMetadata());
return support.success(b.getTextContent());
}
raw
ctx.getDefaultModel()reflects neither the per-invocation override nor the subagent'smodel, andctx.getToolRegistry()is the full registry with no allow-list applied. To behave the way ReAct does, usesupport.resolvedModel()andsupport.scopedToolRegistry().
Registration and wiring¶
import at.aimon.core.subagent.InMemorySubagentRegistry;
import at.aimon.core.subagent.Subagent;
import at.aimon.core.subagent.behavior.SubagentBehaviorRegistrar;
import at.aimon.core.subagent.behavior.InMemorySubagentBehaviorRegistry;
InMemorySubagentRegistry codeData = new InMemorySubagentRegistry();
InMemorySubagentBehaviorRegistry codeBehavior = new InMemorySubagentBehaviorRegistry();
// register the data entry and the behaviour together under the same name (this prevents name drift)
SubagentBehaviorRegistrar.register(
Subagent.builder().name("clock").description("Returns the current server time. Use it when you need the time.")
.systemPrompt("(code behavior)").build(),
(ctx, req, support) -> support.success("Current server time: " + java.time.Instant.now()),
codeData, codeBehavior);
// wiring: the data goes to the context factory, the behaviour to the executor factory
factory.withCodeSubagentRegistry(codeData); // OrcaAgentRuntimeFactory
agentExecutorFactory.withSubagentBehaviorRegistry(codeBehavior); // OrcaAgentExecutorFactory
systemPromptis a required field on the data entry so it has to be filled with something, but since the behaviour replaces the ReAct loop it is never actually used by a code-behaviour subagent (leave it as a placeholder).
A limitation: the OnStart/OnStop hooks do not fire¶
The code path bypasses the ReAct loop, so OnStart and OnStop — the hooks inside the loop — do not fire (OnStart's conversation-feedback injection only means anything when there is a conversation loop, and OnStop's termination signal duplicates SubagentStop). The dispatch-boundary hooks SubagentStart and SubagentStop still fire as they always did, so nothing is lost for observability or auditing.
A full example¶
public final class ClockSubagentBehavior implements SubagentBehavior {
@Override
public SubagentExecutionResult execute(SubagentExecutionContext ctx, SubagentExecutionRequest req,
SubagentBehaviorSupport support) {
if (support.isCancelledOrInterrupted()) {
return support.failure("Execution interrupted");
}
return support.success("Current server time: " + java.time.Instant.now());
}
}
The background fan-out pattern¶
Sometimes you want to run several subagents at once to cut a turn's wall-clock time. Do not try to parallelise it
in the foreground by making the Task tool CONCURRENT_SAFE — Task carries the EXTERNALLY_TERMINATED interrupt
behaviour, so the parallelism gate (§ the parallel tool execution guide) excludes it automatically. Express a
fan-out on the background path.
The mechanism¶
- Throw several into the background.
Task(subagent_name=..., run_in_background=true)returns immediately with a task ID. The subagent runs its ReAct loop on an independent thread. - Poll for progress and completion.
TaskList— lists the running and completed background tasks (with their states).AgentOutput(task_id=...)— reads a particular task's live output and final result by offset.TaskStop(task_id=...)— stops a task where necessary (a transition toKILLED, preserving the partial result).- Collect the results. Once every task you care about has finished, gather each result with
AgentOutputand move on to the next step.
Consistent with the reference philosophy: "a coordinator is all async" — the coordinator (the parent) does not block; it throws several jobs and converges by polling.
Completion push notifications (G11) — you need not depend on polling¶
When a background task reaches a terminal state (COMPLETED/FAILED/KILLED), the framework actively notifies the
parent. Polling with TaskList/AgentOutput is still valid, but the notification means the polling loop does not
have to run tightly. Two channels fire at the same moment:
- Delivery to the message queue (guaranteeing the model notices). On completion a
NEXT-priority notification is pushed onto the parent's message queue, and it is injected on the parent's next ReAct iteration as a user turn wrapped in a<system-reminder>. The body is plain text saying which task ended with what result, plus a note to retrieve the full result withAgentOutput(taskId=...). Which is to say: even if the parent was idle, it certainly learns of the completion on its next turn. - An
agent.streamevent (live display and observation). At the same moment aSubagentTaskCompletedevent is emitted onto the parent executor's event stream. The CLI prints the completion line immediately, and a web embedding can push it over SSE. This channel is best-effort, though — it is dropped when the parent turn is idle and there is no listener, and in that case (1) is what guarantees the model notices.
A practical tip: throw several into the background, then keep doing other useful work until the completion notification is injected. When it arrives, call
AgentOutputwith thattaskIdand collect the full result. A tightTaskListpolling loop is no longer mandatory.
A ceiling via a finite pool (important)¶
A background fan-out is not unbounded. SubagentBackgroundConfig caps the number of concurrently running background
subagents. Leave it unset and you get the old unbounded cached thread pool, where an explosive fan-out can generate
threads and LLM traffic without limit.
import at.aimon.core.subagent.SubagentBackgroundConfig;
import at.aimon.core.agent.impl.orca.OrcaAgentExecutorFactory;
import at.aimon.core.agent.session.transcript.DefaultTranscriptManager;
import at.aimon.core.agent.session.transcript.TranscriptManager;
// run at most 4 concurrently, the rest wait (the queue is effectively unbounded — a burst is delayed, not refused)
TranscriptManager transcriptManager = new DefaultTranscriptManager(sessionRecordStore);
OrcaAgentExecutor executor = new OrcaAgentExecutorFactory()
.withSubagentBackgroundConfig(SubagentBackgroundConfig.of(4))
.create(llmClient, transcriptManager);
// to refuse outright when saturated (load shedding), state the queue capacity too
// SubagentBackgroundConfig.of(4, 16); // maxConcurrency=4, queueCapacity=16
- The defaults (
SubagentBackgroundConfig.defaults()):maxConcurrency = min(4, availableProcessors), an unbounded queue. → the thread and LLM fan-out is capped but a burst is not refused ("everything runs eventually"). - State a queue and the excess is refused when saturated, which behaves as load shedding.
- A spawn beyond the ceiling is not refused; it waits in the queue and runs when a slot frees up (under the default settings).
When to use it¶
- When you want several independent sub-investigations or collections to proceed at once (with no dependency between them).
- When each subagent takes long enough that sequential execution dominates the wall clock.
- Since the capped finite pool prevents a resource runaway, even a wide fan-out converges safely.
For the full design of the background lifecycle (lookup, listing, stopping), live output, result boundaries, cancellation and multi-instance sharing, see
docs/design/subagent/execution.md(§5.1–5.3).
Markdown vs a code definition¶
| Item | agents/*.md |
Subagent.builder() |
|---|---|---|
| Where it is defined | The file system | Java code |
| Priority | The user layer (middle) | The code layer (highest) |
| Can a user shadow it? | Yes (with a file of the same name) | No |
| hot-reload | Supported (reloadAll) |
Not applicable (a no-op) |
| Tool parsing | AllowedTool.parse |
AllowedTool.parse (identical) |
| Defaults | The SubagentMetadata defaults |
Identical |
| Execution model | ReAct | ReAct (identical) |
Checklist¶
Check these when you add a code subagent.
The definition¶
- Have you set
nameandsystemPrompt? (both are required) - Does the
descriptionexplain to the LLM when and how to use it? - Is the tool allow-list narrowed to the minimum necessary scope? (security)
- Where you need markdown parity, have you left the unset fields alone?
Registration and wiring¶
- Have you configured
InMemorySubagentRegistryonce, application-scoped? - Have you handed it over with
withCodeSubagentRegistry(...)after everyregister(...)finished? - Have you wired it in every bootstrap, CLI and web? (C2 — do only one and it goes missing)
Verification¶
- Does it appear in
getAllSubagents()(the listTaskToolreads)? - Does the code definition take priority over a user
.mdof the same name?
Related documents¶
- Code-based subagent registration design — the rationale, the alternatives considered, the constraints
- Tool development guide — the Tools a subagent uses
- SOLID principles
- Built-in Agent/Skill guide — built-in vs a user override