When one ADK for Java deployment serves many customers, the question "which agent handles this request" depends on who is asking. Some agents belong to the platform and every tenant may use them. Some are included only in certain plans. Some are private: a tenant configured them, and nobody else may see that they exist. A registry that answers by name alone will eventually hand one customer another customer's agent, or reveal its name in a listing, and both are security incidents rather than bugs.

This article designs the tenant layer of an agent registry: namespaces, a deterministic resolution rule, entitlement checks at lookup, failures that reveal nothing, tenant agents defined as data rather than code, and per-tenant discovery. It assumes the single-tenant design in agent registry design, where a validated spec and a factory produce each agent tree, and adds the tenant dimension on top. Nothing here is a built-in ADK API; the registry is your code, and the page is careful to say where ADK's own behaviour ends.

What ADK gives you, and what it does not

ADK gives you agents that find each other by name inside a tree. BaseAgent.findAgent(name) searches an agent and its descendants, and findSubAgent searches only below it, so names must be unique within one tree. Sessions, state and artifacts are keyed by app name, user id and session id, which is covered in tenant data separation. There is no tenant parameter anywhere in agent lookup, and nothing stops a Runner from being handed any tree you build.

The dev tooling has the same shape. The source I read defines an AgentLoader interface in the dev module with listAgents() and loadAgent(name), neither of which receives a caller identity; check that your release ships it before depending on it. So if a dev UI or an admin console lists agents, the tenant must already be bound into the loader instance, for example one loader view per authenticated request. Everything tenant-aware lives in front of these interfaces.

Namespaces and the resolution rule

Resolving an agent name for a tenantRequestprincipal + nameTenant viewvisible set for tenantacme/ namespaceprivate specsplatform/ namespaceshared specsEntitlementsplan grants platform agents12filterResolved specref + versionNotFoundsame for all denialsAgent builderspec to LlmAgentBuilt-agent cachetenant, ref, version, planA bare name tries the tenant namespace, then permitted platform agents;registration forbids a tenant name that would shadow a platform name
The tenant view merges the tenant's private namespace with the platform agents its plan grants. Every denial becomes the same NotFound, and resolved specs are built and cached per tenant, reference, version and plan.

Give every agent a reference made of a namespace and a name: platform/summarizer, acme/claims_triage. Platform agents live in platform; each tenant owns exactly one namespace equal to its tenant id. A caller may use a qualified reference or a bare name, and the rule for bare names must be fixed and documented, because every ambiguity in it becomes a support ticket or a leak.

Request from acmeResultWhy
claims_triageacme/claims_triageBare names try the tenant's namespace first
summarizerplatform/summarizerNot in acme's namespace; platform agent is entitled
contract_reviewNotFoundPlatform agent exists but acme's plan does not include it
globex/pricingNotFoundOther tenants' namespaces are never visible
platform/summarizerplatform/summarizerQualified references skip the search

Tenant-first ordering means a tenant agent could hide a platform agent with the same name, and a platform release could suddenly be shadowed for one customer. Forbid it at registration time instead: a tenant may not register a name that exists in the platform namespace, and the platform may not publish a name that any tenant already uses without first renaming one side. The check is cheap, and it makes the bare-name result depend only on entitlements.

Specs, entitlements and resolution in code

Keep specs, entitlements and the resolution function small and explicit. Tenants supply data: an instruction, a model alias chosen from an allowlist, tool ids from the platform's catalogue, and references to sub-agents. They never supply classes, scripts or URLs that the platform will execute.

public record AgentRef(String namespace, String name) {
  public static AgentRef parse(String s) {
    int i = s.indexOf('/');
    return i < 0 ? new AgentRef(null, s) : new AgentRef(s.substring(0, i), s.substring(i + 1));
  }
  public String qualified() { return namespace + "/" + name; }
}

public record AgentSpec(AgentRef ref, long version, String description, String instruction,
                        String modelAlias, List<String> toolIds, List<String> subAgents) {}

public sealed interface Resolution permits Resolution.Found, Resolution.NotFound {
  record Found(AgentSpec spec) implements Resolution {}
  record NotFound() implements Resolution {}
}

public final class TenantAgentRegistry {
  private final SpecStore store;            // versioned specs per namespace
  private final Entitlements entitlements;  // plan -> allowed platform agent names

  public Resolution resolve(Principal who, String requested) {
    AgentRef r = AgentRef.parse(requested);
    String tenant = who.tenantId();          // from authentication, never from the request body
    if (r.namespace() == null) {
      Optional<AgentSpec> own = store.current(tenant, r.name());
      if (own.isPresent()) return new Resolution.Found(own.get());
      r = new AgentRef("platform", r.name());
    }
    if (r.namespace().equals(tenant)) {
      return store.current(tenant, r.name())
          .<Resolution>map(Resolution.Found::new).orElse(new Resolution.NotFound());
    }
    if (r.namespace().equals("platform") && entitlements.allows(tenant, r.name())) {
      return store.current("platform", r.name())
          .<Resolution>map(Resolution.Found::new).orElse(new Resolution.NotFound());
    }
    return new Resolution.NotFound();       // other tenants, unknown names, unentitled agents
  }
}

The single NotFound is deliberate. If an unentitled platform agent returned "forbidden" while a missing one returned "not found", a tenant could enumerate the platform catalogue; if another tenant's namespace returned "forbidden", a caller could confirm that a company is a customer. Log the real reason server-side with the tenant id; return the same response to the caller every time.

Building agents from tenant specs

Resolution yields a spec; something must turn it into an LlmAgent. Validate at registration and again at build, because the catalogue and the plan can change in between. Sub-agent references are resolved through the same tenant view, so a tenant can compose its own agents with platform agents it is entitled to, and never with another tenant's.

BaseAgent build(Principal who, AgentSpec spec, Set<String> seen) {
  if (!seen.add(spec.ref().qualified())) throw new InvalidSpec("cycle at " + spec.ref().qualified());
  List<BaseAgent> subs = new ArrayList<>();
  for (String child : spec.subAgents()) {
    if (!(registry.resolve(who, child) instanceof Resolution.Found f)) {
      throw new InvalidSpec(spec.ref().qualified() + " references unavailable agent " + child);
    }
    subs.add(build(who, f.spec(), seen));
  }
  return LlmAgent.builder()
      .name(spec.ref().name())                       // unique within this tree, checked below
      .description(spec.description())
      .model(models.require(who.tenantId(), spec.modelAlias()))
      .instruction(spec.instruction())
      .tools(toolCatalog.require(who.tenantId(), spec.toolIds()))
      .subAgents(subs)
      .build();
}

The require calls are where tenant policy lives: the model alias must be in the tenant's allowlist and every tool id must be in the catalogue and permitted for the tenant, as in the tool registry design. After building, walk the tree and reject duplicate names, since findAgent returns the first match and a duplicate silently routes transfers to the wrong agent.

Caching and running sessions

Building trees on every request is wasteful, so cache them, but the key must include everything that changes the result: tenant id, qualified reference, spec version, and an entitlement version for the tenant's plan. Leave out the entitlement version and a tenant downgraded from a premium plan keeps using a premium sub-agent until the cache entry expires. Bound the cache by size, since tenants times agents times versions grows without limit, and expose its size and hit rate as metrics. Invalidate explicitly on three events: a new spec version, an entitlement change and a catalogue change that removes a tool.

Running sessions need a rule too. A session that started on version 7 of an agent should normally finish on version 7, so record the resolved reference and version in session state on the first turn and resolve by that pin afterwards, unless the version was withdrawn for safety. Entitlement loss is different: it should take effect on the next turn even for pinned sessions, because it is a contractual decision, not a rollout.

Listing and discovery

Listing is resolution in bulk and must use the same view: the tenant's own agents plus the entitled platform agents, nothing else, with no hint of what a higher plan would add unless product wants an upsell page, which should then be a separate, deliberate endpoint. The same applies when agents are exposed over A2A. Serve agent cards per tenant from routes that authenticate first, and generate each card from the tenant's view, so a private agent's card is reachable only by its owner and the platform does not publish one global directory of every tenant's agents.

Worked example: one name, two owners

Acme registers a private agent claims_triage with tools policy_lookup and claim_status and a sub-agent reference summarizer. Registration resolves summarizer through Acme's view to platform/summarizer, confirms both tool ids are in the catalogue and allowed for Acme, and stores version 1. Requests for claims_triage from Acme now resolve to acme/claims_triage; the same request from Globex resolves to platform/claims_triage if it existed, and here returns NotFound.

Months later the platform team prepares a general claims_triage agent. Publishing fails the shadowing check because Acme already uses the name, so the platform agent ships as claims_intake instead. Then Acme downgrades its plan, which removes summarizer. The entitlement version changes, the cache entries for Acme's trees are invalidated, and the next build of claims_triage fails validation because its sub-agent no longer resolves. The registry marks the spec as needing attention and alerts Acme's administrators instead of quietly building a tree without the sub-agent, which would have changed the agent's behaviour with no one noticing.

Operating the registry

Run the registry like the security boundary it is. Write every registration, spec change, entitlement change and validation failure to an append-only audit log with the acting principal. Emit resolution metrics per tenant with the internal denial reason as a label, never in the response: a sudden rise in foreign-namespace lookups from one tenant is an enumeration attempt, and a rise in unentitled lookups after a release usually means a client still references a renamed agent. Track built-agent cache size, hit rate and build latency, because a cold cache after a deploy makes every first turn slow.

Give operators a read-only tool that answers "what would this name resolve to for this tenant, and why" by running the real resolution function with tracing. Most support questions about the registry are exactly that question, and answering it by reading configuration by hand is how mistakes in the rule go unnoticed.

Failure modes

  • Tenant id from the request body. Resolution trusts whatever namespace the caller claims. Derive it from authentication only.
  • Shadowing. A tenant name hides a platform agent, or the reverse after a release. Enforce uniqueness across the boundary at registration.
  • Distinguishable denials. Different errors for missing, unentitled and foreign agents leak the catalogue and the customer list.
  • Stale entitlements in caches. A downgraded tenant keeps premium agents until expiry. Put the entitlement version in the cache key.
  • Cross-tenant sub-agent references. A spec references globex/pricing. Resolve children through the owner's view.
  • Registry outage. If the spec store is down, serve the last known good view rather than failing every request, as discussed in registry high availability, but never serve a view newer than the entitlements you hold.

Trade-offs

Namespaces with a fixed bare-name rule keep tenant configuration simple but require the shadowing check and occasional renames. Making tenants always use qualified references removes the ambiguity at the cost of noisier configuration. Tenant agents as data are safe and auditable but cap what a tenant can build; letting tenants upload code is a different product with a sandbox, review process and isolation model, described for runtimes in tenant isolation. A shared runtime with per-tenant views is efficient; a runtime per large tenant buys stronger isolation and costs more to operate.

What to do next

  1. Write down your bare-name resolution rule and publish it to tenant administrators.
  2. Introduce namespace/name references and migrate existing agents to platform/.
  3. Implement resolution that returns one NotFound for every denial and logs the real reason.
  4. Add the shadowing check to both tenant registration and platform publishing.
  5. Validate model aliases, tool ids and sub-agent references at registration and at build.
  6. Key the built-agent cache by tenant, reference, version and entitlement version, and bound it.
  7. Pin agent versions per session; apply entitlement loss on the next turn.
  8. Serve listings and A2A agent cards from the same tenant view, behind authentication.
  9. Test with two tenants that use the same names, and assert neither can observe the other.
Key takeaway: ADK finds agents by name within a tree and knows nothing about tenants, so the tenant layer is yours. Give agents namespaced references, resolve bare names with one documented rule, forbid shadowing, check entitlements at lookup and answer every denial with the same NotFound. Treat tenant agents as validated data, and cache built trees by everything that can change them.