Two-stage Dockerfile: the runtime image carries neither uv nor a build toolchain, runs as uid 10001, drops every capability and needs no writable filesystem (verified with --read-only and no tmpfs). k8s/ is what runs, dev/ is the Flux plumbing that applies it, self-managing after three manual applies — the same shape every kong-* repo settled on. Three choices worth naming: - One namespace for the estate. What isolates an agent is its Keycloak client and the audiences it may request, not a namespace boundary. - The GitRepository uses anonymous HTTPS rather than SSH with a Vault-held deploy key. The repo is already anonymously readable on gitea, so a key would guard nothing while adding two objects to create by hand before anything deploys. If the repos ever go private this becomes the kong-* SSH pattern. - The lab CA certificate is committed. It is a certificate, never a key, and checking it in means the deployment has no hidden prerequisite. replicas: 1 is a constraint, not a default — conversation history lives in the process, so a second replica would route a user's "yes" to a pod that never saw the question. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Catalogue
Product management agent for the Classic Models catalog domain. Serves a product manager: what exists, how it is priced, whether it sells, what to discontinue.
Reached over A2A; reads data over MCP through the Kong gateways; thinks through the Kong AI gateway. It is one MCP client among others, exactly as LibreChat is — nothing under it changes to accommodate it.
Running it
uv venv && uv pip install -e ".[dev]"
export CATALOGUE_CLIENT_SECRET=... # Keycloak client secret, no default
export CATALOGUE_CA_BUNDLE=/path/to/sttlab-local-ca.crt
python -m catalogue_agent
Everything else has a working default in config.py and is overridable with
CATALOGUE_-prefixed variables.
curl localhost:8080/.well-known/agent-card.json
curl -X POST localhost:8080/ \
-H 'Content-Type: application/json' \
-H 'A2A-Version: 1.0' \
-H "Authorization: Bearer $USER_TOKEN" \
-d '{"jsonrpc":"2.0","id":1,"method":"SendMessage",
"params":{"message":{"messageId":"m1","role":"ROLE_USER",
"parts":[{"text":"How many product lines are there?"}]}}}'
A2A-Version: 1.0 is required. Without it the SDK assumes 0.3 and answers
VERSION_NOT_SUPPORTED, which reads as a protocol error even though the request
is fine.
How a turn works
A2A SendMessage ──► exchange the user's token, once per audience
──► build one MCP toolset per server, with that turn's tokens
──► PydanticAI runs the model through the Kong AI gateway
──► answer, toolsets dropped
The MCP client is built per turn. A token that outlives the turn it was minted for is a token that can be replayed after the user's session ended, and holding one long-lived connection would mean one long-lived token.
Several narrow tokens rather than one broad one: a token minted for
mcp-catalog is refused by the sales gateway, so a prompt injection that talks
the agent into reaching the wrong domain fails at Kong rather than succeeding
quietly.
What it can and cannot do
| Reads | catalog (products, product lines), sales (read-only, to judge whether a product sells) |
| Writes | catalog: create-product, and only after the user confirms the exact record |
| Calls | nothing yet. Approvisionnement.check_availability comes with discontinuation decisions |
| Exposes | describe_catalog |
Who may write is decided by Kong, not by this agent. ai-mcp-proxy carries a
per-tool ACL — create-product allows the admin and product_manager
consumer groups — and the group comes from realm_access.roles in the end
user's own token. The filtering happens at discovery, not just at call time:
pm-user catalog ['create-product', 'get-product', 'list-products']
ro-user catalog ['get-product', 'list-products']
So a read-only user's turn has no write tool in it at all, and no prompt injection can conjure one. The agent holds no standing write access of its own; the system prompt's confirm-before-writing rule is a courtesy on top of that, not the control.
Writing needs conversation history — the agent restates the record, the user
says yes, and "yes" only means something if the previous turn is still there.
History is kept in memory, keyed by A2A contextId, bounded, and never
persisted. More than one replica therefore needs sticky routing on contextId
or a shared store.
Skills are decision-shaped on purpose. An agent exposing a generic query has
become a data proxy and defeats the per-domain scoping the estate is built on,
so skills get added when another agent has a specific question — not in advance.
Two Keycloak facts this depends on
Both measured against Keycloak 26.6, both costly to rediscover:
actor_token is silently ignored. Standard Token Exchange accepts it,
returns a valid token and emits no act claim — no error. Delegation cannot be
built on RFC 8693 act; the actor is identified by azp, which carries this
agent's client id while sub stays the human.
The audience is requested as a scope. audience=mcp-catalog returns
"Requested audience not available". scope=openid mcp-catalog-audience works.
Library choices
Pinned to what was current on 2026-08-28, ranges below the next major so patch fixes arrive but an upgrade stays deliberate.
a2a-sdk rather than FastA2A. The earlier design said FastA2A, on the
grounds that Agent.to_a2a() was going away in PydanticAI v2 — which has since
happened, so that argument no longer distinguishes them. This estate exists to
prove A2A interoperability across five runtimes, and the reference
implementation is the safer thing for four other stacks to interoperate with.
httpx2 rather than httpx. PydanticAI deprecates httpx clients for
OpenAI-compatible providers and removes them in v3.
Notes on a2a-sdk 1.1 that are not guessable, all established against the
running library:
a2a.typesare protobuf messages, not pydantic models.- Use
DefaultRequestHandlerV2;DefaultRequestHandlerspeaks 0.3. - JSON-RPC methods are
SendMessage,GetTask… The 0.3 spelling (message/send) still works —enable_v0_3_compatis on. - An executor must enqueue a
Taskbefore any status update whencontext.current_taskis None.
Not done yet
- The Keycloak client does not exist.
classic-models-agent-cataloguehas to be declared ininfra-keycloak/k8s/keycloak/configmap-realm.yaml, withmcp-catalog-audienceandmcp-sales-audienceas optional scopes. Until then the exchange fails and the agent says so. - Writes, and the A2A call to Approvisionnement.
- Deployment manifests — they belong in
classic-models-agent-platformonce a second agent shows what is actually shared.
Deployment
ghcr.io/staillanibm/classic-models-agent-catalogue:v0.1.0
Built from the Dockerfile here — two stages, so the runtime image carries no
build toolchain; non-root, read-only root filesystem, all capabilities dropped.
The image is built and pushed by hand for now: this repo lives on gitea and
gitea Actions is not set up, so there is no pipeline to do it.
docker build -t ghcr.io/staillanibm/classic-models-agent-catalogue:vX.Y.Z .
docker push ghcr.io/staillanibm/classic-models-agent-catalogue:vX.Y.Z
k8s/ is what runs; dev/ is the Flux plumbing that applies it. Three manual
applies close the loop once — nothing can watch ./dev before the namespace and
the source that serves it exist:
kubectl apply -f k8s/namespace.yaml
kubectl apply -f dev/gitrepository.yaml
kubectl apply -f dev/bootstrap-kustomization.yaml
After that every file here reconciles from git, dev/ included. Never
kubectl apply the objects under k8s/ afterwards — Flux reverts them within
its interval and the cluster silently stops matching what you edited.
What the deployment assumes:
| Namespace | agents — one for the estate; what isolates an agent is its Keycloak client, not its namespace |
| Secret | CATALOGUE_CLIENT_SECRET, from the same Vault entry the realm import reads, through the vault-keycloak store |
| CA | k8s/ca-configmap.yaml holds the lab CA certificate — public by construction, checked in so the deployment has no hidden prerequisite. Regenerate it when the CA rotates (expires 2026-11-24) |
| DNS | every *.sttlab.local name resolves in-cluster already; no hostAliases needed |
| Replicas | exactly one. Conversation history lives in the process, so a second replica would route a user's "yes" to a pod that never saw the question |