An on-call
SRE orchestrator investigates a broken database and delegates
the diagnosis to a DBA specialist over the A2A protocol. The
specialist answers in one contract: root_cause,
severity, fix, runbook_url. You will
build the specialist twice, enforcing that shape two different ways, and watch
the orchestrator consume either one without noticing the difference.
Part 1 (this page) — the contract. Two agents agree on one JSON shape and pass it across an A2A call. You build the specialist two ways (a declarative agent forced through an MCP tool schema, and a BYO Google ADK agent with a pydantic output_schema) and prove both return the identical shape. Runs on OSS kagent; no gateway needed.
Part 2 — the identity. A diagnosis is only a recommendation until someone acts on it, and acting needs privilege. Two agents get two identities, and enterprise agentgateway scopes which MCP tools each may call — so one agent can fix the database and the other can only diagnose it. Read Part 2 →
The shape is the interface
Most agent-to-agent hops today carry an LLM's prose. That is fine until the
caller needs to act on a field: gate on severity, open the
runbook_url, paste the fix into a change. The moment
you want to act, you want a shape you can rely on. So we design one and
make the specialist answer in it. Here is the whole contract:
jsonyaml/contract/diagnosis.schema.json
{
"type": "object",
"additionalProperties": false,
"required": ["root_cause", "severity", "fix", "runbook_url"],
"properties": {
"root_cause": { "type": "string", "maxLength": 500 },
"severity": { "type": "string", "enum": ["low", "medium", "high", "critical"] },
"fix": { "type": "string", "maxLength": 500 },
"runbook_url": { "type": "string", "maxLength": 280 }
}
}
Do you need agentgateway for this?
No. The shape is enforced at the agent, not on the wire, and kagent speaks A2A on its own, so the typed result rides the hop without a gateway in the middle. Agentgateway is the front door you reach for when you want one entry point for A2A and MCP together, JWT validation and token exchange at the edge, guardrails, and cross-agent observability. It governs who is allowed to call and what is allowed to cross. It does not decide the shape. So this lab is kagent-only, and it runs on OSS.
The components
Everything runs on one kind cluster. The orchestrator reads the broken database
through the Kubernetes tool server, then delegates to one of two DBA specialists.
Both specialists return the same Diagnosis shape, so they are
interchangeable.
Two ways to enforce one shape
The specialist has to produce those four fields and nothing else. You build it two ways here.
Declarative
An MCP tool holds the schema
- Agent
type: Declarative- Trick
- one tool,
record_diagnosis, whose input schema is the contract - Prompt
- "answer only by calling the tool" — no free text is possible
- A2A card
outputModes: [text, data]
BYO · Google ADK
A pydantic model holds the schema
- Agent
type: BYO- Trick
- ADK
LlmAgent(output_schema=Diagnosis) - Prompt
- reasons over the evidence; ADK holds it to the model
- Constraint
- no tools alongside
output_schema, so it is handed the evidence
The declarative specialist: MCP holds the shape
This path leans entirely on MCP, so it is worth seeing all of it. There are four
moving parts: you define the tool in a tiny MCP server, MCP
publishes its input schema (that JSON Schema is the
contract), you register the server with kagent as a
RemoteMCPServer, and the agent is told to answer only by
calling that one tool.
1. Define the tool. The Python signature is the shape — the
Literal becomes an enum, the defaulted argument becomes optional.
pythonimages/record-tools/record_tools/server.py
from typing import Literal
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("record-tools", host="0.0.0.0", port=8080)
Severity = Literal["low", "medium", "high", "critical"]
@mcp.tool()
def record_diagnosis(root_cause: str, severity: Severity, fix: str, runbook_url: str = "") -> dict:
"""Return the database diagnosis. You MUST answer only by calling this tool."""
return {"root_cause": root_cause.strip(), "severity": severity,
"fix": fix.strip(), "runbook_url": runbook_url.strip()}
if __name__ == "__main__":
mcp.run(transport="streamable-http")
2. MCP publishes the schema. Ask the running server over its
/mcp endpoint (tools/list) and this is what it advertises
— the shape every caller, including the agent's model, is held to. Captured live
from the cluster:
jsonrecord-tools /mcp · tools/list → record_diagnosis.inputSchema
{
"type": "object",
"required": ["root_cause", "severity", "fix"],
"properties": {
"root_cause": { "type": "string" },
"severity": { "type": "string", "enum": ["low", "medium", "high", "critical"] },
"fix": { "type": "string" },
"runbook_url": { "type": "string", "default": "" }
}
}
3. Register the server with kagent. A RemoteMCPServer
points at the in-cluster service; kagent discovers record_diagnosis
from it.
yamlyaml/mcp/record-tools.yaml (RemoteMCPServer)
apiVersion: kagent.dev/v1alpha2
kind: RemoteMCPServer
metadata:
name: record-tools
namespace: kagent
spec:
description: Holds the Diagnosis contract as the record_diagnosis tool.
url: http://record-tools.kagent.svc.cluster.local:8080/mcp
protocol: STREAMABLE_HTTP
timeout: 30s
4. The agent calls only that tool. It references the server and
the one tool, and the system message forbids prose — so the model's only move is
to call record_diagnosis with the four typed fields. That tool call
is the answer.
yamlyaml/agents/dba-agent-declarative.yaml
apiVersion: kagent.dev/v1alpha2
kind: Agent
metadata:
name: dba-agent-declarative
namespace: kagent
spec:
type: Declarative
declarative:
modelConfig: default-model-config
systemMessage: |
You are a database reliability specialist. Work out the root cause from the
evidence and record it. Do NOT answer in prose. Respond ONLY by calling the
record_diagnosis tool with the structured fields. Never reply without it.
tools:
- type: McpServer
mcpServer:
apiGroup: kagent.dev
kind: RemoteMCPServer
name: record-tools # the RemoteMCPServer above
toolNames: [record_diagnosis] # the one tool it may call
a2aConfig:
skills:
- id: diagnose-db
name: Diagnose a database incident
description: Map incident evidence to a strict Diagnosis
inputModes: [text]
outputModes: [text, data] # advertises it can return structured data
tags: [database, postgres, sre]
At call time the model emits a record_diagnosis tool call, MCP
validates the arguments against that published schema, and the result rides back
to the caller as the A2A data part you saw earlier. The shape is created by the
MCP tool and enforced by MCP — the agent never gets the chance to freehand it.
The BYO specialist
Same shape, enforced in code. A Google ADK LlmAgent is handed the
Diagnosis pydantic model as its output_schema, and ADK
holds the model to it. One ADK rule matters here: an agent with an output schema
cannot also carry tools, so this one has none. That is why the orchestrator does
the investigating and hands the specialist the evidence; the specialist only
maps evidence to verdict.
pythonimages/dba-adk/dba/agent.py
from google.adk.agents import LlmAgent
from google.adk.models.lite_llm import LiteLlm
from pydantic import BaseModel, Field
class Diagnosis(BaseModel):
root_cause: str = Field(description="What is actually broken.")
severity: str = Field(description="low, medium, high, or critical.")
fix: str = Field(description="The exact remediation.")
runbook_url: str = Field(default="", description="Matching runbook, or empty.")
root_agent = LlmAgent(
model=LiteLlm(model="anthropic/claude-sonnet-4-5-20250929"),
name="dba_agent_byo",
instruction="You are handed incident evidence. Return the Diagnosis. No prose.",
output_schema=Diagnosis,
disallow_transfer_to_parent=True,
disallow_transfer_to_peers=True,
)
kagent runs the image as a type: BYO Agent and serves it on the
same A2A endpoint as any declarative agent, so the orchestrator references both
specialists the same way.
yamlyaml/agents/dba-agent-byo.yaml (full)
apiVersion: kagent.dev/v1alpha2
kind: Agent
metadata:
name: dba-agent-byo
namespace: kagent
spec:
type: BYO
description: >-
Database SRE specialist (BYO Google ADK). Returns a strict Diagnosis enforced
by a pydantic output_schema.
byo:
deployment:
image: dba-adk:dev
imagePullPolicy: IfNotPresent
env:
- name: ANTHROPIC_API_KEY
valueFrom:
secretKeyRef:
name: dba-anthropic
key: ANTHROPIC_API_KEY
- name: DBA_MODEL
value: anthropic/claude-sonnet-4-5-20250929
# KAGENT_URL / KAGENT_NAMESPACE / KAGENT_NAME are injected by the kagent
# controller for BYO agents, so they are not set here.
The orchestrator is itself a declarative agent. It carries its own Kubernetes
read tools and references both specialists as tools[].type: Agent,
so it can delegate to either over A2A.
yamlyaml/agents/sre-orchestrator.yaml (full)
apiVersion: kagent.dev/v1alpha2
kind: Agent
metadata:
name: sre-orchestrator
namespace: kagent
spec:
type: Declarative
description: >-
On-call SRE orchestrator. Investigates cluster incidents and delegates
database diagnosis to a DBA specialist over A2A.
declarative:
modelConfig: default-model-config
systemMessage: |
You are the on-call SRE. Triage the incident by inspecting Kubernetes
resources, events and pod logs. When the problem is database-related,
collect the evidence (the failing pod's status, its recent logs, and
related events) and DELEGATE the diagnosis to a DBA specialist, passing that
evidence in your message to it.
If the user names a specialist (the declarative DBA or the ADK DBA), use
that one; otherwise use dba-agent-declarative. Both specialists reply with
the same structured Diagnosis: root_cause, severity, fix, runbook_url. Read
those fields and produce a short incident summary. State the severity
plainly, and if it is high or critical, say the on-call should be paged.
Note that the diagnosis came from the DBA specialist.
tools:
- type: McpServer
mcpServer:
apiGroup: kagent.dev
kind: RemoteMCPServer
name: kagent-tool-server
toolNames:
- k8s_get_resources
- k8s_get_events
- k8s_describe_resource
- k8s_get_pod_logs
# A2A delegation: both DBA specialists, referenced as tools.
- type: Agent
agent:
name: dba-agent-declarative
- type: Agent
agent:
name: dba-agent-byo
Build it
Standalone single kind cluster. The only secret is an Anthropic key.
export ANTHROPIC_API_KEY=sk-ant-...
./scripts/quick.sh up
That creates the cluster, installs OSS kagent with Anthropic and the bundled
Kubernetes tool server, builds and loads the two lab images, and applies the
agents plus a Postgres that is broken on purpose (no POSTGRES_PASSWORD,
so it crashloops with a clear log line).
See the contract
Each command prints the full manifest for that specialist (and,
for the declarative one, the record-tools MCP server that holds the
schema), then calls it directly over A2A with a fixed piece of evidence and shows
the Diagnosis that rode back:
./scripts/contract.sh declarative # via the record_diagnosis MCP tool schema
./scripts/contract.sh byo # via the ADK pydantic output_schema
Both return the same shape. What differs is only how it
rides the hop. The declarative agent's answer arrives as a real A2A
data part — the record_diagnosis tool call, whose
args is the contract. The BYO agent's answer arrives as the
text part, but that text is the pydantic-serialised object, so it
is strict JSON. Once you unwrap the tool call's args, the two are
identical. Captured live from the cluster, normalised to the same four fields:
contract.sh declarative
Arrived as an A2A DataPart
How the contract arrived: A2A DataPart
(record_diagnosis tool call,
unwrapped from .args)
{
"root_cause": "The Postgres container is
missing the required POSTGRES_PASSWORD
environment variable...",
"severity": "high",
"fix": "Add the POSTGRES_PASSWORD env var
to the orders-db Deployment... or
reference it from a Secret...",
"runbook_url": ""
}
contract.sh byo
Arrived as strict-JSON text
How the contract arrived: strict-JSON
text part (pydantic output_schema)
{
"root_cause": "Postgres container requires
POSTGRES_PASSWORD... but the Deployment
only specifies POSTGRES_DB...",
"severity": "high",
"fix": "Add POSTGRES_PASSWORD env var...
kubectl create secret generic... then
patch the Deployment to reference it...",
"runbook_url": ""
}
outputModes: [text, data] and delivers a genuine
data part, so a caller reads args without parsing. The ADK agent
enforces the shape in code and delivers it as strict-JSON text, which the caller
parses into one clean object. Both are enforceable; pick the one that matches how
the caller wants to consume it.
How a request flows
The orchestrator investigates first (it has the Kubernetes tools), then hands the
evidence to a specialist and reads back the contract. This is the declarative
path; the BYO path is identical except the specialist enforces the shape with its
output_schema instead of the record-tools call.
End to end
Now the whole story. Ask the orchestrator to deal with the incident. It inspects the cluster, gathers the failing pod's evidence, delegates to a specialist, and folds the returned verdict into its summary. Because both specialists return the same contract, you can steer it to either and the summary reads the same fields.
./scripts/ask.sh "the orders database won't start - investigate and fix"
./scripts/ask.sh "... and use the ADK DBA"
In the verified run the orchestrator reads the crashlooping pod's status, logs
and events, delegates to dba-agent-declarative, and returns a
summary that ends with the specialist's verdict: severity high,
the missing POSTGRES_PASSWORD as the root cause, the exact fix, and
a line to page the on-call, signed off "Diagnosis provided by DBA specialist
(dba-agent-declarative)". Point it at the ADK specialist instead and the
summary reads the same fields, because the contract did not change.
A declarative agent calling a BYO agent over A2A
The orchestrator is a type: Declarative agent and
dba-agent-byo is a type: BYO Google ADK agent, so
steering the orchestrator to the ADK specialist exercises a declarative-to-BYO
A2A hop directly. Fire it and watch the orchestrator's own log:
./scripts/ask.sh "orders-db is down. investigate, then delegate the diagnosis to the ADK DBA dba-agent-byo and report its verdict"
# in the orchestrator pod log, the A2A hop to the BYO agent:
GET http://dba-agent-byo.kagent:8080/.well-known/agent-card.json "HTTP/1.1 200 OK"
POST http://dba-agent-byo.kagent:8080 "HTTP/1.1 200 OK"
The orchestrator resolves the BYO agent's card, then sends it the A2A
message/send and gets 200 back. The declarative agent
and the BYO agent speak the same protocol, so one calls the other with no shim.
/.well-known/agent-card.json, and the caller uses the card's
url as the A2A endpoint. The default scaffold ships
url: localhost:8080, which makes the caller POST to itself
and the delegation fails. Set the card's url to the in-cluster
address (http://dba-agent-byo.kagent:8080) so other agents can reach
it. kagent sets this for declarative agents automatically; for BYO it serves what
your image advertises.
Extending it
- Version the contract. The shape lives in one file. Bump it and update both enforcements together; the orchestrator keeps reading the fields it knows.
- Put agentgateway in front. Front the A2A and MCP endpoints with agentgateway when you want edge auth, token exchange and observability. The contract does not change; the governance moves to the edge.
- More specialists. Add a network or storage agent that answers in its own contract and let the orchestrator pick from each agent card's skills.
- Reject on drift. Make
record_diagnosisvalidate harder (enum, URL format) so a malformed field is refused at the tool, not discovered downstream.
See also
- Part 2: agent identity and scoped tools — one agent can act, the other can't (enterprise)
- kagent docs: tools and agent-as-tool
- Google ADK docs: structured output
- The A2A protocol
- Sibling lab: A2A delegation and the OBO identity hop (enterprise)
Versions
Built and verified on:
2.4.01.92.00.9.40.9.4