MastertheAgent
Solo · kagent · A2A · structured output · declarative vs BYO · Google ADK · kind
Built · contract verified live on kind (OSS)

The agentic contract (Part 1): one JSON shape, shared across an A2A call

Part 1 · OSS Part 2: agent identity & scoped tools (enterprise) →

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 }
  }
}
🤖 SRE orchestrator Declarative agent Reads the failing pod's status, logs and events with its Kubernetes tools, then delegates. 🤖 DBA specialist declarative or BYO ADK Answers in the contract. Two builds, one shape either way — swap one for the other and nothing else changes. A2A · THE CONTRACT root_cause severity fix runbook_url one shape, two enforcements message · the incident evidence → ← Diagnosis · the structured verdict

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.

Client ask.sh · contract.sh kagent-controller A2A · :8083 sre-orchestrator Declarative model: default-model-config kagent-tool-server · MCP k8s read tools orders-db (broken) orders namespace dba-agent-declarative Declarative model: default-model-config dba-agent-byo BYO · Google ADK output_schema (pydantic) record-tools · MCP record_diagnosis = the contract Anthropic (Claude) model inference A2A MCP reads logs A2A A2A MCP LiteLlm model A2A / MCP call model inference Both DBA specialists return the same Diagnosis { root_cause, severity, fix, runbook_url } — swap one for the other and the orchestrator reads the same fields.

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": ""
}
Note. Same four fields, same types, either way. The declarative agent advertises 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.

Client kagent-controller sre-orchestrator kagent-tool-server dba-agent record-tools message/send: orders-db is down run agent k8s_get_pod_logs / events logs: POSTGRES_PASSWORD missing A2A delegate: hand over the evidence record_diagnosis(…) validated record Diagnosis — DataPart (decl.) / strict-JSON text (BYO) incident summary severity: high · fix · page on-call BYO variant: no record-tools call — the ADK output_schema enforces the same 4-field shape in code.

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.

Gotcha worth knowing. A BYO agent serves its own /.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

See also

Versions

Built and verified on:

OSS
Google ADK2.4.0
LiteLLM1.92.0
kagent (OSS)0.9.4
kagent-adk base image0.9.4