Once an Agent Development Kit for Java service grows past a handful of agents, the hard questions are no longer about prompts. Which agents exist? Which ones can be combined into a tree? Which build does the developer UI show? Which agent does a YAML config mean when it says code: refunds? An agent registry answers those questions in one place. Designing one well depends on a few behaviours in the ADK core that are easy to miss: how names are checked, how a transfer finds its target, and what happens when the same agent object is placed under two parents.
Framework behaviour here is taken from the current google/adk-java source on the main branch; check the same classes in your release. The registry code is ours, not a class the framework ships.
What the framework enforces, and what it does not
Every agent extends BaseAgent, and its constructor enforces three things about names. A name cannot be null or empty. It must match ^_?[a-zA-Z0-9]*([. _-][a-zA-Z0-9]+)*$, which allows dots, hyphens and even single spaces between alphanumeric runs. And it cannot be user, which is reserved for end-user input in the event stream. The constructor also rejects duplicate names among one parent's direct sub-agents, with a message that starts Agent named '...' has sub-agents with duplicate names.
That check compares siblings only. Two agents called order_lookup under different parents pass, although the field comment on name says it must be unique within the agent tree.
The second behaviour is lookup. findAgent(name) returns this agent if the name matches, otherwise findSubAgent(name), which streams over the sub-agents in order, recurses, and takes the first hit. When an LLM agent emits a transfer, the flow resolves the target with rootAgent().findAgent(...). So transfer resolution is a depth-first, first-match search from the root of whatever tree the current agent believes it is in.
The third behaviour is parenthood. At the end of construction, the parent calls subAgent.parentAgent(this) on each child. The setter does not check for an existing parent; it overwrites, so nothing stops one agent from being placed under two parents. rootAgent() walks parent pointers upward, so an agent object shared between two trees reports whichever tree was built last as its root.
So a registry must hold recipes, not objects; check names across the whole tree; and use a stricter naming rule than the framework, because names are what the model types into transfer_to_agent.
A spec per agent, with a factory
Start with a spec: the data the registry stores for each agent. The spec names its children by registry name rather than holding them, and it carries a factory that builds a new agent each time it is called.
public record AgentSpec(
String name,
String description, // used for routing, so it is required
String version,
Kind kind, // LOCAL or REMOTE
List<String> children, // registry names, not objects
BiFunction<AgentSpec, List<BaseAgent>, BaseAgent> factory) {
public enum Kind { LOCAL, REMOTE }
public AgentSpec {
children = List.copyOf(children);
}
}A local factory passes the spec's own name and description into the ADK builder, so the registry stays the single source of both:
AgentSpec refunds = new AgentSpec(
"refunds",
"Handles refund requests for orders placed in the last 90 days.",
"3",
AgentSpec.Kind.LOCAL,
List.of("refund_order_lookup"),
(spec, kids) -> LlmAgent.builder()
.name(spec.name())
.description(spec.description())
.model("gemini-2.5-flash")
.instruction("Confirm the order, check eligibility, then issue the refund.")
.subAgents(kids)
.build());The description is mandatory because a parent LLM agent routes on its sub-agents' names and descriptions. Hierarchical agents in ADK Java covers how those descriptions drive delegation.
Validating the whole registry at once
Validation runs once, when the registry is created, and reports every problem together so one deploy shows the full list. It checks names against a stricter pattern than the framework's, rejects the reserved name, requires a description, rejects duplicate registrations, checks that every child exists, and looks for cycles.
public final class AgentRegistry {
// Stricter than ADK: lower snake case only, so the model has one spelling to reproduce.
private static final Pattern NAME = Pattern.compile("^[a-z][a-z0-9_]{1,47}$");
private final Map<String, AgentSpec> specs;
private AgentRegistry(Map<String, AgentSpec> specs) { this.specs = Map.copyOf(specs); }
public static AgentRegistry of(List<AgentSpec> list) {
List<String> problems = new ArrayList<>();
Map<String, AgentSpec> byName = new LinkedHashMap<>();
for (AgentSpec s : list) {
if (!NAME.matcher(s.name()).matches()) problems.add(s.name() + ": name must match " + NAME);
if (s.name().equals("user")) problems.add("user: reserved by ADK for end-user input");
if (s.description() == null || s.description().isBlank()) problems.add(s.name() + ": empty description");
if (byName.putIfAbsent(s.name(), s) != null) problems.add(s.name() + ": registered twice");
}
for (AgentSpec s : byName.values()) {
for (String child : s.children()) {
if (!byName.containsKey(child)) problems.add(s.name() + ": unknown child " + child);
}
}
findCycle(byName).ifPresent(path -> problems.add("cycle: " + String.join(" -> ", path)));
if (!problems.isEmpty()) {
throw new IllegalStateException("Agent registry invalid:\n " + String.join("\n ", problems));
}
return new AgentRegistry(byName);
}
public Set<String> names() { return specs.keySet(); }
}findCycle is an ordinary depth-first search that returns the path when it reaches a node already on it; without it, a cycle shows up as a stack overflow on the first build. Validation deliberately allows one spec to be the child of two parents. That only becomes a problem when both parents land in the same tree, which is checked at build time.
Building trees and checking them
The tree builder turns a root name into a fresh agent tree. It builds children before parents, because ADK builders take sub-agents at construction time. It calls each factory once per node, so no object is ever shared between trees. And it tracks names already placed, so the same name cannot appear twice in one tree.
public BaseAgent buildTree(String rootName) {
BaseAgent root = build(rootName, new HashSet<>());
verifyTree(root);
return root;
}
private BaseAgent build(String name, Set<String> placed) {
if (!placed.add(name)) {
throw new IllegalStateException("'" + name + "' appears twice in one tree; "
+ "register a second spec with its own name for the second position");
}
AgentSpec spec = specs.get(name);
List<BaseAgent> kids = new ArrayList<>();
for (String child : spec.children()) kids.add(build(child, placed));
BaseAgent agent = spec.factory().apply(spec, List.copyOf(kids));
if (!agent.name().equals(name)) {
throw new IllegalStateException("factory for '" + name + "' built '" + agent.name() + "'");
}
return agent;
}
private static void verifyTree(BaseAgent root) {
Deque<BaseAgent> stack = new ArrayDeque<>(List.of(root));
while (!stack.isEmpty()) {
BaseAgent a = stack.pop();
if (a.rootAgent() != root) throw new IllegalStateException(a.name() + " reports a different root");
if (root.findAgent(a.name()).orElseThrow() != a) throw new IllegalStateException(a.name() + " is shadowed");
stack.addAll(a.subAgents());
}
}The post-build check catches factories that break the rules, such as one returning a cached singleton. rootAgent() != root catches an object shared with another tree; findAgent(name) != a catches a name a transfer would resolve elsewhere. Both use framework methods, so they test what the runner will do.
Remote agents without a network call at build time
Remote agents reached over the Agent2Agent protocol fit the same shape: a spec whose factory builds a RemoteA2AAgent. Two details in its current constructor shape the factory. If the builder is not given an agent card, the constructor asks the A2A client for one, which is a network call during construction. And if the builder's description is empty, the constructor copies the description from the card.
The first makes startup depend on every remote agent being reachable. The second puts text written by another team, possibly another company, into your router's prompt. So the factory passes a cached card and always sets the description from the spec:
static AgentSpec remote(String name, String description, String version,
AgentCard cachedCard, Supplier<Client> clients) {
return new AgentSpec(name, description, version, AgentSpec.Kind.REMOTE, List.of(),
(spec, kids) -> RemoteA2AAgent.builder()
.name(spec.name()) // our name, not the card's
.description(spec.description()) // reviewed text, never copied from the card
.agentCard(cachedCard) // no fetch inside the constructor
.a2aClient(clients.get())
.build());
}Refresh cards on a schedule outside the request path and alert when a skill list or endpoint changes. ADK Java and A2A shows how to build the client; which third-party agents may enter the registry at all is covered in agent marketplace patterns.
One registry behind the runner, the dev UI and YAML
One registry can feed the three places in ADK Java that need to find agents by name.
The runner. Production code calls registry.buildTree("support_root") at startup and hands the fresh tree to the runner.
The developer UI. The dev module defines an AgentLoader interface with two methods: listAgents(), which must return an empty list rather than null, and loadAgent(name), documented to throw NoSuchElementException for an unknown name and IllegalStateException when the agent exists but fails to load. An adapter over the registry follows that contract and lists only roots, so developers do not start sessions on a leaf agent by accident:
public final class RegistryAgentLoader implements AgentLoader {
private final AgentRegistry registry;
private final ImmutableList<String> roots;
public RegistryAgentLoader(AgentRegistry registry, List<String> roots) {
this.registry = registry;
this.roots = ImmutableList.copyOf(roots);
}
@Override public ImmutableList<String> listAgents() { return roots; }
@Override public BaseAgent loadAgent(String name) {
if (!roots.contains(name)) throw new NoSuchElementException("no root agent named " + name);
try {
return registry.buildTree(name);
} catch (RuntimeException e) {
throw new IllegalStateException("failed to build " + name, e);
}
}
}How the dev server is told to use a custom loader varies between releases, so check the dev module of the version you run.
YAML agent configs. When a config lists a sub-agent as code: some_key, ConfigAgentUtils resolves the key through ComponentRegistry.resolveAgentInstance, a global map that returns the object registered under the key: an instance, not a factory. Two configs pointing at one key share that object between two parents. register also replaces an existing key with only an info-level log. If you bridge into it, register one built instance per key, use each key in one config only, and fail on duplicate registration.
Worked example: two lookups called order_lookup
Take a support desk with a router at the root and three specialists: billing, refunds and faq. Billing and refunds both need to look orders up, and the first version of the registry gives each of them a child spec called order_lookup. ADK accepts the tree, because the two lookups are not siblings.
Now follow a refund conversation. The router transfers to refunds. The refunds agent transfers to its lookup child by name. The flow calls rootAgent().findAgent("order_lookup"), walks the router's children in order, enters billing first, and finds billing's lookup. Control lands in the billing branch, and when the lookup hands back to its parent, that parent is billing, which asks the user about an invoice they never mentioned. Nothing fails or logs an error; the only trace is the event author field.
With the registry above, the build fails before the service starts: 'order_lookup' appears twice in one tree. The fix the message suggests is two specs, billing_order_lookup and refund_order_lookup, sharing one factory method and differing only in name and description. Each description can now say which conversation it serves, which also improves routing.
A subtler variant passes one lookup instance to both specialists. The names clash the same way, and the shared object's parent pointer names whichever specialist was built last; verifyTree catches both.
Snapshots, versions and live sessions
Treat the registry as an immutable snapshot: validate a new one, build every root once, then swap it in with an AtomicReference, so a spec error blocks the change instead of the service. Running sessions keep the tree they started with, because their stored events name its agents; record the registry version on each session and keep one runner per live version until they drain. ADK Java versioning covers which changes old sessions survive.
Failure modes
| Failure | What you see | Prevention |
|---|---|---|
| Same name in two branches | Transfers land in the wrong branch; no error | Tree-wide name check at build time |
| Agent object shared between trees | rootAgent() points at another tree; transfers resolve there | Factories, never instances; post-build root check |
| Remote card fetched in constructor | Slow or failed startup when one partner is down | Pass a cached card to the builder |
| Description copied from a card | External text steers your router | Always set the description from the spec |
| YAML code key used twice | Shared instance under two parents | One key per config; fail on duplicate registration |
| Name with spaces or dots | Model mistypes the transfer target | Stricter name pattern than the framework |
| Cycle in children | Stack overflow on first build | Cycle check when the registry is created |
| Hot swap under live sessions | Events name agents that no longer exist | Pin sessions to a registry version |
Trade-offs
Specs in code are compiler-checked and easy to test, but every change is a deploy. YAML through ComponentRegistry lets non-Java authors compose agents, at the cost of string keys and a global mutable map. A central registry service adds discovery and ownership but puts a network call in startup and still needs client-side tree checks. Most teams do well with specs in code, roots in config, and cached remote cards. The split between catalogue and per-agent profile works for tools, as in tool registry design, and for models, as in model registration and discovery.
What to do next
- Write down every agent name in your service and search for any that appear in two branches of one tree.
- Introduce
AgentSpecrecords with factories, and replace every shared agent instance with a factory call. - Validate the registry at startup and report every problem together.
- Build each root tree once in a unit test and run the post-build root and shadowing checks.
- Give remote specs a cached agent card and a reviewed description; refresh cards on a schedule and alert on changes.
- Adapt the registry to
AgentLoaderfor the dev UI, listing roots only. - Pin each session to the registry version it started on before you enable reloading the registry while it runs.