Entry point for the estate: understands a request, consults the specialists that can settle it, composes one answer. LangGraph 1.2.11, a2a-sdk 1.1.2, both current as of August 2026. It holds no data, and the realm is what enforces that rather than the prompt. classic-models-agent-assistant is granted two optional scopes — one per specialist, plus the inference endpoint — and every domain audience it could request comes back Invalid scopes. An injection that succeeds here finds no path to a domain because there is none. "No scopes at all" was too absolute: the agent has to reason, and inference is not a domain. The two-hop token chain was the heaviest unproven thing in the design and it holds: sub stays the end user through all four hops, so their own roles apply at the far end and the MCP server filters tool discovery for the right human. azp names the last agent, not the first — Keycloak accepts actor_token and emits no act claim, so an orchestrator's identity survives only in the traces. Verified end to end: a routed question answered from the specialist's data; a question outside every remit refused rather than answered from memory; and a write proposed through the orchestrator, held by the specialist in input-required, relayed here unchanged, approved by the user through the same conversation, and written. The orchestrator never owns that decision. The graph is plan → delegate → plan, tools described by what a specialist decides rather than what it holds, delegations in one step run concurrently and capped at eight counted in calls. Rebuilt per turn, because its tools carry the user's token. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Assistant — the orchestrating agent
The single entry point for users. Understands a request, consults the specialists that can settle it, and composes one answer.
Framework: LangGraph · Mode: conversational · Client id:
classic-models-agent-assistant
It holds nothing, and that is enforced by the realm
This is the component that sees raw user text, so it is the prompt-injection target. It has no MCP server, no domain scope, and no data of its own. That is not a convention in the prompt — it is what the realm grants:
OBTAINED classic-models-agent-catalogue-audience aud=[classic-models-agent-catalogue]
refused mcp-catalog-audience → Invalid scopes
refused mcp-sales-audience → Invalid scopes
refused catalog-api-audience → Invalid scopes
An injection that succeeds against this agent finds no path to a domain, because there is none to find. Its two optional scopes are one specialist and the inference endpoint — inference is not a domain, and the agent has to reason.
Adding a specialist is an entry in specialists.py plus an audience scope
granted in the realm. Add the entry without the grant and every call to it fails
at the token exchange with Invalid scopes, loudly, before any data moves.
The chain
Four hops, sub unchanged throughout — which is what makes the end user's own
roles apply at the far end:
1. user sub=pm-user azp=librechat aud=[…-agent-assistant]
2. assistant→cat sub=pm-user azp=classic-models-agent-assistant aud=[…-agent-catalogue]
3. cat→mcp sub=pm-user azp=classic-models-agent-catalogue aud=[mcp-catalog, token-exchange-broker]
4. MCP call → 200
azp names the last agent in the chain, not the first. Keycloak accepts
actor_token and emits no act claim, so an orchestrator's identity does not
survive a second exchange — the delegation is visible in the traces, not in the
token.
What it does with an answer
A specialist returns an answer, never data. The orchestrator composes; it does not aggregate records it was not given.
Two behaviours worth naming, both verified end to end:
- Out of remit → it says so. Asked for a company-wide revenue figure, it reports that no specialist can settle it rather than answering from what the model knows. An invented answer is indistinguishable from one the estate gave.
- A held write is relayed verbatim. Catalogue parks a proposed write in
input-required; this agent passes that message through unchanged and stops. The user'sapprovecomes back through the same conversation and reaches the specialist, which owns the decision. The orchestrator never approves on anyone's behalf.
The graph
plan → delegate → plan → … until the model stops calling tools. One tool per
specialist, described by what it decides. Delegations in the same step run
concurrently — that is the point of fanning out at all — and are capped by
ASSISTANT_MAX_DELEGATIONS (8), counted in calls rather than model turns,
because one specialist consulted twice for the same question is the loop worth
catching.
The graph is rebuilt per turn: its tools carry the user's token, and a graph held across turns would be a token held across turns.
Running it
uv venv && uv pip install -e '.[dev]'
ASSISTANT_CLIENT_SECRET=… ASSISTANT_CA_BUNDLE=… \
ASSISTANT_URL_CATALOGUE=http://localhost:8099/ \
python -m assistant_agent
ASSISTANT_URL_<NAME> overrides a specialist's address, for running outside the
cluster against a port-forward. In the cluster nothing sets it.
macOS resolves every .local name through mDNS before falling back to
/etc/hosts, at a flat ~5s per host. ASSISTANT_HTTP_TIMEOUT defaults to 60s
for that reason; in the cluster the names resolve instantly.
Deployment
gitea.sttlab.eu/stt/classic-models-agent-assistant:v0.1.0
k8s/ is what runs, dev/ is the Flux plumbing. Two manual applies close the
loop once — the agents namespace and the sttlab-ca ConfigMap already exist,
created by the catalogue agent's repo, and are deliberately not declared here so
two Kustomizations do not fight over pruning them.
kubectl apply -f dev/gitrepository.yaml
kubectl apply -f dev/bootstrap-kustomization.yaml
replicas: 1 is a constraint: conversation history lives in the process, and a
specialist's approval round trip passes back through here.