# classic-models-agent-platform Shared ground for the classic-models agent estate: templates, conventions, and the identity plumbing every agent repeats. Plays the role `kong-k8s-platform` plays for the gateways. **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: Kong gateways per domain, MCP servers served by Kong's `ai-mcp-proxy`, Keycloak, and the domain APIs. | 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. ```json { "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 or move the exchange into Kong; 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= client_secret= subject_token= subject_token_type=urn:ietf:params:oauth:token-type:access_token scope=openid mcp--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, served by Kong: | Domain | URL | Audience scope | |---|---|---| | catalog | `https://catalog-dev.sttlab.local/catalog-mcp` | `mcp-catalog-audience` | | customers | `https://customers-dev.sttlab.local/customers-mcp` | `mcp-customers-audience` | | payments | `https://payments-dev.sttlab.local/payments-mcp` | `mcp-payments-audience` | | sales | `https://sales-dev.sttlab.local/sales-mcp` | `mcp-sales-audience` | | workforce | `https://workforce-dev.sttlab.local/workforce-mcp` | `mcp-workforce-audience` | LLM inference goes through the Kong AI gateway, never direct to a provider: ``` https://llm-dev.sttlab.local/llm/v1/chat/completions ``` It applies rate limiting, prompt guards and semantic guards, and routes to OpenRouter. Sending inference anywhere else bypasses all of them. 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. ```yaml 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 `kong-v2/classic-models-realm.yaml` is an older partial copy and is **not** authoritative. ## Known blockers **Konnect is not persisting configuration.** The operator loops on the same objects without converging and the dataplanes stay behind the control plane, so no new Kong config reaches the gateways — including any new agent audience. Resolve this before expecting an end-to-end run. **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 `kong-token-exchange-broker` secret does not match the realm** — its `client_credentials` returns `unauthorized_client`. ## 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.