Files
classic-models-agent-platform/CLAUDE.md
T
Stéphane Tailland 3e4e8e363f docs: stop naming the middleware
An agent talks to URLs that honour a contract: an MCP endpoint, an OIDC token
endpoint, an OpenAI-shaped LLM endpoint. Whatever serves those URLs is
replaceable, and naming it in an agent's code or docs implies a coupling that
does not exist.

Every product name is gone from prose, comments and identifiers, replaced by
what the agent actually depends on. Where a behaviour of the current middleware
leaks into the contract — the second audience scope, the tool ACL — the text
now says what the caller must do and why, not who imposes it.

One string survives and cannot be fixed here: the broker client an agent must
name in its audience scopes is itself named after the middleware. Closing that
means renaming it in Keycloak and following the rename through the gateway
configuration and LibreChat. Recorded as a blocker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:35:13 +02:00

10 KiB

classic-models-agent-platform

Shared ground for the classic-models agent estate: templates, conventions, and the identity plumbing every agent repeats. What k8s-platform is to the gateway estate, this is to the agent estate.

Nothing runs from this repo. It holds the ${VAR}-parameterised manifests a domain's Flux Kustomization builds, plus the reference documentation below.

The estate this plugs into

The agents replace LibreChat as the MCP client and orchestrator. Everything below them already exists and does not change: an MCP server per domain, an OpenAI-shaped LLM endpoint, Keycloak, and the domain APIs behind them.

Name none of the middleware. An agent talks to URLs that honour a contract. Whatever serves those URLs today is replaceable — swap it and nothing in an agent changes, as long as the contract holds. So write "the MCP server", "the LLM endpoint", "the gateway"; never a product name, in code, comments, commit messages or docs. Where a behaviour of the current middleware leaks into the contract, say what the caller must do and why, not who imposes it.

Agent Repo Framework Mode
Assistant classic-models-agent-assistant LangGraph conversational
Ventes classic-models-agent-ventes CrewAI conversational
Catalogue classic-models-agent-catalogue PydanticAI + FastA2A conversational
Support classic-models-agent-support Google ADK conversational
Approvisionnement classic-models-agent-flows n8n autonomous
Recouvrement classic-models-agent-flows n8n autonomous

Five runtimes is deliberate. The point is proving A2A interoperability between stacks that do not know each other; a single framework would prove nothing.

Topology

A2A between agents, MCP between an agent and data.

Assistant ──→ every agent          (orchestration; holds no scopes itself)
Ventes    ──→ Approvisionnement, Recouvrement
Support   ──→ Ventes
Catalogue ──→ Approvisionnement

An A2A call returns an answer, not data. Ventes asks Approvisionnement "can I promise delivery on 15 March?" and receives yes/no — it never gains read access to the catalog domain. Agent Cards therefore expose narrow, decision-shaped skills (check_availability, check_creditworthiness), never a generic query. An agent that exposes "ask me anything about my domain" has become a data proxy and defeats the scoping.

The Assistant holds no scopes at all. It is the component that sees raw user text, so it is the prompt-injection target, and it must be unable to exfiltrate anything.

Identity

Each agent is its own Keycloak client. That is what makes the actor identifiable: the exchanged token carries azp = the agent's client id alongside sub = the end user.

{ "sub": "alice", "azp": "classic-models-agent-ventes",
  "aud": ["mcp-catalog"], "scope": "openid profile email mcp-catalog-audience" }

Two measured facts that constrain the implementation:

actor_token is silently ignored. Keycloak 26.6's Standard Token Exchange accepts the parameter, returns a valid token, and emits no act claim. No error. Do not build delegation on RFC 8693 act — use azp.

The audience is requested via a scope, not the audience parameter. Passing audience=mcp-catalog returns Requested audience not available. Pass scope=openid mcp-catalog-audience.

The exchange

Do what LibreChat already does — a POST per target audience, roughly twenty lines. Do not build a sidecar, and do not push the exchange down into the gateway; both were considered and rejected as over-engineering, the second also because it would leave the agent holding one broad token instead of several narrow ones.

POST https://keycloak.sttlab.local/realms/classic-models/protocol/openid-connect/token
  grant_type=urn:ietf:params:oauth:grant-type:token-exchange
  client_id=<the agent's client id>
  client_secret=<from the agent's Secret>
  subject_token=<the end user's access token>
  subject_token_type=urn:ietf:params:oauth:token-type:access_token
  scope=openid mcp-<domain>-audience

Neither LangGraph nor CrewAI injects a per-call MCP token natively (langchain-mcp-adapters issues #172/#194/#235; CrewAI's MCPServerAdapter fixes headers at construction). This is a non-issue: build the MCP client once per conversation turn with freshly exchanged tokens. A turn lasts seconds, an access token five minutes.

Endpoints

MCP servers, one per domain:

Domain URL Audience scopes to request
catalog https://catalog-dev.sttlab.local/catalog-mcp mcp-catalog-audience kong-token-exchange-broker-audience
customers https://customers-dev.sttlab.local/customers-mcp mcp-customers-audience kong-token-exchange-broker-audience
payments https://payments-dev.sttlab.local/payments-mcp mcp-payments-audience kong-token-exchange-broker-audience
sales https://sales-dev.sttlab.local/sales-mcp mcp-sales-audience kong-token-exchange-broker-audience
workforce https://workforce-dev.sttlab.local/workforce-mcp mcp-workforce-audience kong-token-exchange-broker-audience

Two scopes per server, not one. The MCP endpoint exchanges the token again on its own account before reaching the domain API, so the token must also name the broker client that performs that second exchange. Without it the call fails with a bare 401 and no usable detail — the real cause (invalid status code received from the token endpoint (403)) appears only in the server's own log. Measured on catalog: with both scopes the MCP server answers 200, with mcp-catalog-audience alone it answers 401. LibreChat has always requested both.

This is a property of the contract as it stands, not of any agent's design. The broker client is still named after the middleware that introduced it, which is a leak worth closing — see the blockers.

LLM inference goes through the estate's LLM endpoint, never direct to a model provider:

https://llm-dev.sttlab.local/llm/v1/chat/completions

It speaks the OpenAI protocol, applies rate limiting and prompt guards, and routes onward to a provider. Sending inference anywhere else bypasses all of that, and loses the audit trail with it.

Authenticate to it with an exchanged token carrying the llm-proxy audience, not a static API key. A key proves which application is calling; the token carries azp and sub, so inference is attributable to the same (agent, end user) pair as every tool call.

These hostnames are lab-only with a self-signed certificate. /etc/hosts:

192.168.139.103  catalog-dev.sttlab.local
192.168.139.105  llm-dev.sttlab.local
192.168.139.106  customers-dev.sttlab.local
192.168.139.107  payments-dev.sttlab.local
192.168.139.108  sales-dev.sttlab.local
192.168.139.109  workforce-dev.sttlab.local
192.168.139.101  keycloak.sttlab.local

The CA is sttlab-local-ca (cert-manager ClusterIssuer). Trust that one CA rather than disabling verification.

Adding an agent

An agent is a declaration first. Put it here, let the generator produce the rest — the Keycloak client is generated from this, never hand-written in the agent's own repo, so the realm stays the single source of truth.

name: ventes
client_id: classic-models-agent-ventes
framework: crewai
surfaces: [conversational]
mcp_servers: [catalog, sales]
scopes: [catalog:read, sales:read, sales:write]
a2a_calls: [approvisionnement, recouvrement]
limits:
  max_tool_calls_per_run: 20
  max_records_read_per_run: 500

scopes becomes the client's optionalClientScopes; mcp_servers becomes the audience scopes it may request; a2a_calls becomes the A2A authorisations.

The realm actually deployed is infra-keycloak/k8s/keycloak/configmap-realm.yaml — note that the copy under the gateway estate is older, partial, and not authoritative.

The realm file is re-imported with --override true on every pod start. An import-realm initContainer replays it before Keycloak serves traffic, so the file wins over the database and nothing created through the admin console survives a restart. Add clients here, never in the console.

Client secrets and user passwords in that file are ${KC_SECRET_*} / ${KC_USER_*} placeholders, substituted from the keycloak-realm-secrets Secret (Vault + ExternalSecret). Never commit a literal secret back into it.

Serving A2A

The a2a-sdk 1.x server rejects a request that does not carry the protocol version header:

A2A-Version: 1.0

DefaultRequestHandler speaks the older 0.3 shape; use DefaultRequestHandlerV2, and mount both create_agent_card_routes(...) and create_jsonrpc_routes(handler, "/", enable_v0_3_compat=True). An executor must enqueue the Task before any status update, or the client blocks waiting for a task it never saw. AgentInterface names the field protocol_binding, not transport.

Known blockers

No employeeNumber in tokens. Sales territory scoping (a rep sees their territory, a manager sees all) resolves through employee → officeCode → offices.territory, but the token carries no employee identity, so the API cannot filter server-side. The realm's users (admin-user, pm-user, …) are role placeholders, not real employees.

The broker client is named after the middleware. Every agent must request kong-token-exchange-broker-audience alongside the server's own audience, so the one thing an agent cannot avoid naming is the very thing it should not know about. Closing it means renaming the client and its scope in Keycloak, and following that rename through the gateway configuration, LibreChat and each agent's config. Until then, treat the string as an opaque identifier owned by the realm.

Conventions

  • Deliverables in English: code, comments, READMEs, Agent Cards. Conversation with the maintainer is in French.
  • GitOps throughout. Never kubectl apply an object carrying kustomize.toolkit.fluxcd.io/name — Flux reverts it within its interval, and tests run against config that no longer exists.
  • Secrets never committed. Follow the secret.example.yaml pattern used by the infra-* repos.