Agent2Agent on Spring Boot: one specialist, one orchestrator, no megaprompt

I used to dump skills matching, salary checks, and summary writing into one Spring AI agent. It worked in demos. It fell apart the moment I needed a second team to own one capability. Agent2Agent fixed the boundary, not the model.

I used to dump skills matching, salary checks, and summary writing into one Spring AI agent. It worked in demos. It fell apart the moment I needed a second team (or a second service) to own one capability without inheriting the whole prompt. That is the problem Agent2Agent (A2A) is meant to solve: a standard way for agents to discover each other and exchange work , even when they run as separate apps. Why this showed up in my work At ITJobOpportunities I keep building screening and assessment flows: résumé signals, job fit, Code Training Lab exercises, recruiter-facing summaries. The temptation is always the same. One fat system prompt. One ChatClient . Every new rule bolted on until nobody wants to touch it. I wanted the opposite shape: small specialists with clear ownership, and an orchestrator that only decides whom to call. A2A gave me a protocol for that shape instead of another private REST convention between "agent" microservices. So I built a short exercise on Java 26 , Spring Boot 4.1 , Spring AI 2 , and the Spring AI Community spring-ai-a2a server starter, with OpenRouter behind the OpenAI-compatible client. Two processes. One happy path a recruiter can hit with curl. The thesis A2A is discovery plus messages. Your product still decides what is deterministic and what is generative. In my skills matcher, overlap scoring lives in plain Java. The LLM calls a tool, then writes a short summary. The orchestrator never invents a fit percentage. That split is the architecture lesson. The protocol is just how the two Spring apps talk. "A2A is not another ChatClient wrapper. It is how agents find each other and exchange work." Mental model (four nouns) Agent Card — JSON metadata: name, skills, public URL Message — Natural-language task from a client agent Task — Server-side unit of work created from that message Artifact — Result payload (usually text parts) returned on the task Flow Recruiter HTTP → Screening orchestrator (A2A client + ChatClient + tool) → Skills matcher (A2A server + ChatClient + match-skills tool) → Artifact text → Short screening verdict Notice the double hop through an LLM. That is normal. One model plans the delegation. The specialist model (or the same provider with a different system prompt) decides when to call tools. Cost and latency follow. Do not pretend this is a free abstraction. Components diagram Two Spring Boot apps over A2A, with OpenRouter behind both ChatClients. Left to right: Recruiter hits public REST screening-orchestrator owns API, thin service, ChatClient, and A2A client pieces ( AgentRegistry , RemoteAgentClient , send-message-to-agent ) skills-agent owns Agent Card / Executor, specialist ChatClient, match-skills tool, and SkillsMatcher domain scoring OpenRouter is the shared OpenAI-compatible LLM behind both apps Green boxes are tool boundaries. The yellow SkillsMatcher box is where the numeric score is born. Build it in technical steps Here is the order I would rebuild this exercise from an empty multi-module Maven project. Each step has a reason, not just a class name. Step 1 — Split the modules before you write prompts Create two Spring Boot apps: skills-agent on port 8081 with context path /a2a screening-orchestrator on port 8080 with a public REST API Why: A2A only pays off when ownership is real. If both "agents" live in one process with one classpath, you are practicing packaging, not interoperability. Step 2 — Put scoring in plain Java first In skills-agent , SkillsMatcher normalizes comma-separated skill lists, computes overlap, and returns score + matched + missing. Scoring rule score = round(100 * matched / required) ≥75 STRONG · ≥40 PARTIAL · else WEAK For the sample later: required Java, Spring Boot, AWS, Kafka vs candidate Java, Spring Boot, Azure, Kafka → 75 , missing aws . Why: if the number matters to a recruiter, it must be unit-testable without ChatClient. The LLM should narrate the tool result, not invent the percentage. "If the score matters to a recruiter, compute it in Java and let the LLM narrate." Step 3 — Expose scoring as a Spring AI @Tool SkillsMatcherTools is a thin adapter. The tool name is match-skills . The skills ChatClient system prompt says: always call that tool before you summarize. Why: tools are the contract between generative prose and deterministic code. Keep the adapter boring so the domain stays the source of truth. Step 4 — Publish an Agent Card and an AgentExecutor Spring AI Community A2A server autoconfiguration expects beans you own: AgentCard — name Skills Matcher Agent , skill skills_matching , and a public URL clients will call AgentExecutor — bridge from inbound A2A message text into ChatClient ChatClient — already wired with match-skills The card is served at /.well-known/agent-card.json under /a2a . Clients discover first. Then they send work. Minimal executor shape: Java @Bean AgentExecutor agentExecutor(ChatClient skillsChatClient) { return new DefaultAgentExecutor(skillsChatClient, (client, ctx) -> { String userMessage = DefaultAgentExecutor.extractTextFromMessage(ctx.getMessage()); return client.prompt().user(userMessage).call().content(); }); } Why: the card is discovery metadata. The executor is the runtime hook. Mixing them into one mega-bean hides the protocol boundary. Step 5 — Discover cards at orchestrator startup On the client side, AgentRegistry reads configured base URLs (for local JVM: http://localhost:8081/a2a ) and loads: Discovery GET {base}/.well-known/agent-card.json Cards are keyed by exact agent name. If any URL fails, the orchestrator does not start . Why: an empty registry creates worse bugs later. Fail fast when the specialist is down. Step 6 — Send work with the A2A Java SDK RemoteAgentClient does five things: Resolve the card by agent name Build a client with JSONRPCTransport Register a consumer for TaskEvent Send A2A.toUserMessage(task) Block (up to 60s here) until artifact text arrives Why: this is the protocol hop. REST between "agents" would work for a demo. A2A gives you a standard card + message + task + artifact shape other teams can implement without copying your DTOs. Step 7 — Give the orchestrator LLM exactly one delegation tool RemoteAgentTools exposes send-message-to-agent(agentName, task) . The orchestrator system prompt lists discovered agent names and tells the model: do not score candidates yourself; call the specialist. ScreeningService stays thin: Java String verdict = orchestratorChatClient.prompt() .user(request.toString()) .call() .content(); Delegation happens inside the tool loop, not as a pile of imperative if branches in the service. Why: the orchestrator owns routing language. The specialist owns the skill. That split is the whole point. Step 8 — Keep the public API boring HTTP POST /api/v1/screenings Content-Type: application/json { "name": "Jane Doe", "email": "jane@example.com", "jobTitle": "Backend Developer", "requiredSkills": "Java, Spring Boot, AWS, Kafka", "candidateSkills": "Java, Spring Boot, Azure, Kafka", "expectedSalary": 110000 } What should happen on a good run: Orchestrator LLM reads the request and calls send-message-to-agent with the skills agent name A2A JSON-RPC delivers a Message to skills-agent Skills LLM calls match-skills → Java returns score 75 / STRONG_MATCH / missing aws Skills LLM writes a short summary; A2A wraps it as an Artifact on the Task Orchestrator LLM turns that artifact into a recruiter-facing verdict The verdict prose can vary by model. The score should not. What I actually wired (package map) I keep packages boring on purpose. skills-agent domain — SkillsMatcher computes score / matched / missing tools — @Tool adapter only config — ChatClient, Agent Card, AgentExecutor screening-orchestrator api — ScreeningController + DTOs + Problem Details application — ScreeningService a2a — registry, remote client, artifact extraction a2a.tools — send-message-to-agent config...