Back to Blog
July 21, 2026

Level Up Your SDLC: From Isolated Agents to an A2A and AP2 Agentic Mesh

Your agents already write code; now give them contracts, identities, durable tasks, and spending boundaries worthy of production.

A2A agentic mesh with an AP2 authorization boundary

Your first coding agent probably felt magical. You handed it an issue, it changed a repository, and you reviewed the diff. Then you added a test agent, a security reviewer, and a release agent. The magic turned into glue: copied prompts, shared API keys, polling loops, mystery JSON, and one giant coordinator that knows every agent's private implementation.

That is the moment a vibe-coded agent stack meets the same distributed-systems problems the rest of your SDLC already solved: discovery, contracts, authentication, retries, ownership, auditability, and compensation. The upgrade is not “more agents.” It is replacing implicit coordination with an agentic mesh: independently deployable agents that advertise capabilities and exchange durable work across explicit trust boundaries.

A2A, the Agent2Agent protocol, gives the mesh a standard interaction model. AP2, the Agent Payments Protocol, adds proof that a human authorized an agent to transact within stated limits. They solve different layers. A2A moves work; AP2 constrains economic authority. Neither replaces your workflow engine, database, policy service, model, or payment processor.

The 2025 Stack Overflow Developer Survey found that 52% of developers either did not use agents or stayed with simpler AI tools, while 38% had no plans to adopt agents. The professional advantage is therefore not the number of bots you launch; it is whether your system remains predictable when they disagree, time out, or spend money. See the survey's AI-agent section.

What changes when you move from isolated agents to an agentic mesh?

An agentic mesh turns prompts into durable contracts: agents advertise skills, exchange typed tasks, and surface progress without sharing internals.

An isolated agent is a function with a fuzzy middle: input arrives, a model reasons, tools run, output returns. A mesh treats the boundary around that function as a product. The boundary says what the agent can do, which content types it accepts, how callers authenticate, whether it streams, and how a long-running task reaches a terminal state.

The official A2A v0.3 specification models messages, stateful tasks, parts, and artifacts. Agents publish an Agent Card, commonly at /.well-known/agent-card.json. A client can send work with message/send, poll with tasks/get, stream status with Server-Sent Events, or receive authenticated push notifications.

Crucially, A2A does not require one agent to expose another agent's prompt, memory, or tools. Your reviewer can be Python on a private cluster while your coding agent is TypeScript on a hosted platform. They agree on a card and task lifecycle, not an internal framework. If you need to make each individual agent's reasoning loop testable first, read our guide to agent architecture testing before adding network boundaries.

The migration in one code comparison

ConcernIsolated-agent codeMesh boundary
DiscoveryREVIEWER_URL in every appValidated Agent Card or governed registry
Invocationfetch(url, prompt)message/send with typed parts and stable IDs
Long workwhile (!done) sleep()Task states, SSE, polling, or signed push
Moneyif (total < budget) pay()AP2 intent, payment mandate, and receipt chain
RecoveryRerun the whole promptResume, compensate, or replay from durable state

Do not turn this table into a rewrite project. Keep direct function calls inside one process. Introduce a protocol boundary where agents differ in deployment, owner, framework, scaling profile, or privilege. A mesh is valuable at trust boundaries; inside a monolith, it can be expensive ceremony.

Step 1: discover agents without opening an SSRF hole

Agent Cards look like harmless metadata, but a user-supplied card URL can make your orchestrator fetch cloud metadata, localhost admin panels, or a DNS-rebound private address. Discovery therefore belongs in a controlled registry, not directly in a prompt. The following standalone Node 22 TypeScript program fetches a card with a timeout, rejects redirects and private addresses, limits the response, validates capabilities, and re-validates the advertised service URL.

// discover-agent.ts — run: node --experimental-strip-types discover-agent.ts https://agent.example
import { lookup } from 'node:dns/promises';

type AgentCard = {
  name: string;
  url: string;
  capabilities?: { streaming?: boolean };
  skills: Array<{ id: string; name: string }>;
};

function isPrivateAddress(ip: string): boolean {
  if (ip.startsWith('::ffff:')) return isPrivateAddress(ip.slice(7));
  return ip === '::1' || ip === '::' || ip.startsWith('fc') ||
    ip.startsWith('fd') || ip.startsWith('fe8') || ip.startsWith('fe9') ||
    ip.startsWith('fea') || ip.startsWith('feb') || ip.startsWith('ff') ||
    ip.startsWith('2001:db8:') ||
    /^(127|10)./.test(ip) || /^192.168./.test(ip) ||
    /^169.254./.test(ip) || /^172.(1[6-9]|2d|3[01])./.test(ip);
}

async function assertPublicHttps(input: string): Promise<URL> {
  const url = new URL(input);
  if (url.protocol !== 'https:' || url.username || url.password) {
    throw new Error('Agent endpoints must use HTTPS without URL credentials');
  }
  const addresses = await lookup(url.hostname, { all: true, verbatim: true });
  if (addresses.length === 0 || addresses.some(({ address }) => isPrivateAddress(address))) {
    throw new Error('Agent hostname resolves to a private or invalid address');
  }
  return url;
}

async function discover(origin: string): Promise<AgentCard> {
  const base = await assertPublicHttps(origin);
  const cardUrl = new URL('/.well-known/agent-card.json', base);
  const response = await fetch(cardUrl, {
    redirect: 'manual',
    signal: AbortSignal.timeout(5_000),
    headers: { accept: 'application/json' },
  });
  if (!response.ok) throw new Error('Card request failed with HTTP ' + response.status);
  if (response.status >= 300 && response.status < 400) throw new Error('Card redirects are forbidden');
  if (!response.headers.get('content-type')?.includes('application/json')) {
    throw new Error('Agent Card did not return JSON');
  }
  const body = await response.text();
  if (body.length > 64_000) throw new Error('Agent Card exceeds 64 KB');

  const card = JSON.parse(body) as Partial<AgentCard>;
  if (!card.name || !card.url || !Array.isArray(card.skills) || card.skills.length === 0) {
    throw new Error('Agent Card is missing name, url, or skills');
  }
  if (card.skills.some((skill) => !skill?.id || !skill?.name)) {
    throw new Error('Agent Card contains a malformed skill');
  }
  await assertPublicHttps(card.url);
  return card as AgentCard;
}

discover(process.argv[2] ?? '')
  .then((card) => console.log(JSON.stringify(card, null, 2)))
  .catch((error: unknown) => {
    console.error(error instanceof Error ? error.message : error);
    process.exitCode = 1;
  });

In a real registry, pin the card's owner, permitted egress domain, authentication scheme, and last-approved hash. Cache cards briefly, but invalidate on credential or skill changes. The subtle edge case is DNS rebinding: hostname validation without validating every resolved address is not enough. For high-risk networks, perform the actual request through an egress proxy that blocks private ranges after resolution.

Step 2: delegate durable work through A2A

The next upgrade is replacing “call agent and hope” with a task lifecycle. A2A tasks can be working, completed, failed, cancelled, rejected, or interrupted because they need input or authentication. A terminal task cannot be restarted. That rule matters: reusing an old task ID after a timeout is not a retry; it is an invalid state transition.

This runnable client sends a stable message ID, polls long-running work, caps its wait, recognizes human-intervention states, and retries only transient transport failures. A2A does not guarantee universal exactly-once execution, so the example requires the receiving service to deduplicate the stable message ID before enabling retries.

// delegate-review.ts — run with AGENT_URL and AGENT_TOKEN in Node 22
import { randomUUID } from 'node:crypto';

type State = 'submitted' | 'working' | 'input-required' | 'auth-required' |
  'completed' | 'failed' | 'cancelled' | 'rejected';
type Task = { kind: 'task'; id: string; contextId: string; status: { state: State }; artifacts?: unknown[] };
type AgentMessage = { kind: 'message'; parts: unknown[] };
type RpcResponse = { result?: Task | AgentMessage; error?: { code: number; message: string } };

const endpoint = process.env.AGENT_URL;
const token = process.env.AGENT_TOKEN;
if (!endpoint || !token) throw new Error('AGENT_URL and AGENT_TOKEN are required');

function isTransportFailure(error: unknown): boolean {
  return error instanceof TypeError ||
    (error instanceof DOMException && ['AbortError', 'TimeoutError'].includes(error.name));
}

async function rpc(method: string, params: object, retryable: boolean): Promise<Task | AgentMessage> {
  const payload = { jsonrpc: '2.0', id: randomUUID(), method, params };
  for (let attempt = 0; attempt < 4; attempt++) {
    try {
      const response = await fetch(endpoint, {
        method: 'POST',
        signal: AbortSignal.timeout(10_000),
        headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token },
        body: JSON.stringify(payload),
      });
      const transient = response.status === 429 || [502, 503, 504].includes(response.status);
      if (transient && retryable && attempt < 3) {
        await new Promise((resolve) => setTimeout(resolve, 250 * 2 ** attempt));
        continue;
      }
      if (!response.ok) throw new Error('A2A HTTP failure ' + response.status);
      const body = await response.json() as RpcResponse;
      if (body.error) throw new Error('A2A RPC ' + body.error.code + ': ' + body.error.message);
      if (!body.result) throw new Error('A2A response has no result');
      return body.result;
    } catch (error) {
      if (!retryable || !isTransportFailure(error) || attempt === 3) throw error;
      await new Promise((resolve) => setTimeout(resolve, 250 * 2 ** attempt));
    }
  }
  throw new Error('Unreachable retry state');
}

async function delegatePullRequestReview(repo: string, sha: string): Promise<unknown[]> {
  if (!/^[a-f0-9]{40}$/.test(sha)) throw new Error('Expected a full immutable Git SHA');
  const messageId = randomUUID(); // persist this before sending in a production outbox
  const first = await rpc('message/send', {
    message: {
      kind: 'message', role: 'user', messageId,
      parts: [{ kind: 'data', data: { operation: 'review-pr', repo, sha } }],
    },
    configuration: { blocking: false, acceptedOutputModes: ['application/json'] },
  }, true); // safe only when the peer contract deduplicates messageId

  if (first.kind === 'message') return first.parts;
  let task = first;
  for (let poll = 0; poll < 40; poll++) {
    const state = task.status.state;
    if (state === 'completed') return task.artifacts ?? [];
    if (state === 'input-required' || state === 'auth-required') {
      throw new Error('Task paused for human action: ' + state + ' (task ' + task.id + ')');
    }
    if (['failed', 'cancelled', 'rejected'].includes(state)) {
      throw new Error('Task reached terminal state ' + state + '; create a new task to retry');
    }
    await new Promise((resolve) => setTimeout(resolve, Math.min(1_000 * 1.3 ** poll, 8_000)));
    const next = await rpc('tasks/get', { id: task.id, historyLength: 5 }, true);
    if (next.kind !== 'task') throw new Error('tasks/get returned a message, not a task');
    task = next;
  }
  throw new Error('Review exceeded polling budget; task remains ' + task.id);
}

delegatePullRequestReview('acme/shop', process.argv[2] ?? '')
  .then((artifacts) => console.log(JSON.stringify(artifacts, null, 2)))
  .catch((error: unknown) => {
    console.error(error instanceof Error ? error.message : error);
    process.exitCode = 1;
  });

The production addition is a transactional outbox: persist the message ID and intent in the same database transaction as your workflow state, then dispatch asynchronously. On crash recovery, resend the same message ID. The peer must keep a deduplication record long enough to cover your retry window. Exactly-once is not a network feature; it is a business invariant built from at-least-once delivery plus idempotent effects.

How do A2A and AP2 fit together?

A2A moves work and artifacts between agents; AP2 proves who authorized a purchase, under which limits, and what payment actually occurred.

Imagine a release agent that can buy a temporary load-testing environment. A2A lets it ask a procurement agent for the environment. That request alone should never authorize a charge. AP2 introduces typed mandates: an intent records what the user permits, a payment mandate binds that permission to concrete payment details, and a receipt closes the audit trail. Google's official protocol guide describes the chain as IntentMandate → signed PaymentMandate → PaymentReceipt.

AP2 is not a new payment rail. Your commerce protocol determines the cart, and your processor moves funds. AP2 supplies verifiable authorization and guardrails. That separation prevents a common design bug: interpreting “the agent chose this item” as “the user authorized this charge.”

Step 3: enforce AP2 mandates at the payment boundary

Keep protocol-specific parsing in an adapter and normalize the result before policy evaluation. The next Node 22 program verifies a normalized AP2-derived envelope with an Ed25519 public key, checks expiry, merchant, currency, integer minor units, refundability, cart binding, and replay. It fails closed on unknown or malformed data. In production, replace the in-memory nonce set with a unique database constraint and let the official AP2 SDK build and parse wire objects.

// verify-mandate.ts — run: PUBLIC_KEY_PEM="..." node --experimental-strip-types verify-mandate.ts envelope.json
import { readFile } from 'node:fs/promises';
import { verify } from 'node:crypto';

type Intent = {
  id: string; subject: string; merchants: string[]; currency: string;
  maxMinor: number; expiresAt: string; refundableRequired: boolean;
};
type Payment = {
  mandateId: string; intentId: string; merchant: string; currency: string;
  amountMinor: number; cartHash: string; refundable: boolean; nonce: string;
};
type Envelope = { intent: Intent; payment: Payment; signature: string };
const consumedNonces = new Set<string>(); // use a durable UNIQUE column in production

function canonical(value: unknown): string {
  if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
  if (value && typeof value === 'object') {
    return '{' + Object.entries(value as Record<string, unknown>)
      .sort(([a], [b]) => a.localeCompare(b))
      .map(([key, child]) => JSON.stringify(key) + ':' + canonical(child)).join(',') + '}';
  }
  const primitive = JSON.stringify(value);
  if (primitive === undefined) throw new Error('Undefined is not valid signed mandate data');
  return primitive;
}

function authorize(envelope: Envelope, expectedCartHash: string, publicKey: string): Payment {
  const { intent, payment, signature } = envelope;
  if (!intent || !payment || !signature) throw new Error('Malformed mandate envelope');
  const signed = Buffer.from(canonical({ intent, payment }));
  const validSignature = verify(null, signed, publicKey, Buffer.from(signature, 'base64url'));
  if (!validSignature) throw new Error('Invalid mandate signature');

  const expiry = Date.parse(intent.expiresAt);
  if (!Number.isFinite(expiry) || expiry <= Date.now()) throw new Error('Intent is expired or has invalid time');
  if (payment.intentId !== intent.id) throw new Error('Payment is bound to a different intent');
  if (!intent.merchants.includes(payment.merchant)) throw new Error('Merchant is outside authorized scope');
  if (payment.currency !== intent.currency) throw new Error('Currency mismatch');
  if (!Number.isSafeInteger(intent.maxMinor) || intent.maxMinor <= 0) throw new Error('Invalid intent limit');
  if (!Number.isSafeInteger(payment.amountMinor) || payment.amountMinor <= 0) {
    throw new Error('Amount must be a positive integer in minor units');
  }
  if (payment.amountMinor > intent.maxMinor) throw new Error('Payment exceeds user-authorized limit');
  if (intent.refundableRequired && !payment.refundable) throw new Error('Non-refundable purchase is forbidden');
  if (payment.cartHash !== expectedCartHash) throw new Error('Cart changed after authorization');
  if (!payment.nonce || consumedNonces.has(payment.nonce)) throw new Error('Mandate replay detected');
  consumedNonces.add(payment.nonce);
  return payment;
}

async function main(): Promise<void> {
  const path = process.argv[2];
  const publicKey = process.env.PUBLIC_KEY_PEM;
  const expectedCartHash = process.env.CART_SHA256;
  if (!path || !publicKey || !expectedCartHash) {
    throw new Error('Provide envelope path, PUBLIC_KEY_PEM, and CART_SHA256');
  }
  const envelope = JSON.parse(await readFile(path, 'utf8')) as Envelope;
  const payment = authorize(envelope, expectedCartHash, publicKey);
  console.log(JSON.stringify({ authorized: true, mandateId: payment.mandateId }));
}

main().catch((error: unknown) => {
  console.error(error instanceof Error ? error.message : error);
  process.exitCode = 1;
});

Why minor units? Floating-point money turns €19.99 into an approximation and makes equality checks unreliable. Why hash the cart? A valid signature over “buy hosting” must not survive a later swap to a larger plan. Why store the nonce before calling the processor? If you record it afterward and the process crashes between payment and persistence, a retry can charge twice. Use one durable state transition or the processor's idempotency key to close that gap.

One more gotcha: currency minor units are not universally two decimals. Your normalized adapter should use the currency's defined exponent and never let the model convert display strings into payment amounts. Policy inputs come from trusted commerce data, not conversational text.

Orchestration is a state machine, not a group chat

Once delegation crosses services, the coordinator should persist a small, explicit workflow state: task IDs, context IDs, input artifact hashes, policy decisions, deadlines, and compensation status. Do not store the whole chain-of-thought. You need evidence of transitions, not a transcript of private reasoning.

  • Plan: Resolve required skills and freeze immutable inputs such as repository SHA.
  • Dispatch: Write an outbox record before sending an A2A message.
  • Wait: Consume SSE, signed push, or bounded polling; never wait forever inside a request.
  • Validate: Treat artifacts as untrusted input and verify schema, provenance, and policy.
  • Commit: Apply the side effect once with an idempotency key.
  • Compensate: Revoke a preview, cancel a reservation, or open human review when full rollback is impossible.

Compensation is not the same as rollback. You can delete a preview environment, but you cannot “un-send” a disclosure or reliably reverse every payment. Model irreversible edges explicitly and require stronger approval before crossing them. A circuit breaker should also stop one failing specialist from consuming the coordinator's entire latency and token budget.

Keep context IDs scoped to one user goal. Parallel tasks may share that context, but they should not mutate one shared scratchpad. Pass artifact references with content hashes instead. This prevents a late security review from silently overwriting the exact build artifact a release agent already approved.

Observability: trace contracts, not thoughts

The Stack Overflow 2025 survey reports that, among respondents using or developing agents, 43% used Grafana plus Prometheus for agent observability and 31.8% used Sentry. That is a useful signal: you can extend the reliability tools you already know instead of buying an “AI dashboard” before you understand your failure modes.

Put a trace ID, A2A task ID, context ID, agent-card version, message ID, artifact hash, mandate ID, and policy decision on every boundary event. Record durations and state transitions. Redact prompts, credentials, payment instruments, and personal data before export. High-cardinality IDs belong in traces and structured logs; aggregate state, agent, and error class into metrics.

A useful SLO is not “the model answered.” Measure the percentage of orchestrations that reach the correct terminal state before their deadline, without policy violations or duplicated side effects.

Troubleshooting the failures that only appear in a mesh

The task finished, but the coordinator still polls

Inspect the raw task state and IDs. A webhook may refer to a different context, an SSE reconnect may have missed a terminal event, or your reducer may not recognize rejected. Reconcile with tasks/get; event streams are notifications, while persisted task state is the recovery source.

One request produced two pull requests or two charges

Search by stable message ID, mandate ID, and processor idempotency key. If every retry minted new IDs, deduplication was impossible. Persist IDs before dispatch, enforce unique constraints at the effect boundary, and never retry a non-idempotent call merely because the client timed out.

The Agent Card works locally but fails in production

Check TLS hostname, DNS resolution from the egress network, content type, redirects, card size, and authentication for extended cards. Do not weaken SSRF rules to make a private hostname work; register an approved private endpoint and route it through an authenticated service mesh.

A task is stuck at input-required or auth-required

Those are pause states, not transport errors. Surface the requested action to a human, preserve task and context IDs, and continue with a new message referencing the same task only after the requirement is satisfied. Blind retries create noise and may trigger lockouts.

A signed mandate is valid but policy rejects it

Signature validity proves integrity and signer identity; it does not prove the purchase is allowed now. Compare expiry, subject, merchant, amount, currency, cart hash, refundability, and revocation state. Log the failed rule without logging payment secrets.

Edge cases worth designing before launch

  • Version drift: Pin tested protocol and card versions; reject unsupported fields at security-sensitive boundaries.
  • Artifact mutation: Content-address artifacts so a URL cannot serve different bytes after approval.
  • Late completion: Ignore or quarantine results that arrive after compensation or deadline expiry.
  • Partial streaming: Do not publish an artifact until its final chunk and checksum are verified.
  • Cancellation races: A remote effect may complete while cancellation travels; reconcile before compensating.
  • Card poisoning: Require registry approval before an advertised skill can receive privileged work.
  • Delegation loops: Carry hop count and visited agent IDs; reject cycles and enforce a depth budget.
  • Approval expiry: Compare trusted server time, allow limited clock skew, and never let the model choose the clock.

A rollout that does not pause your product

Start with one read-only handoff, such as sending an immutable commit SHA to a review agent. Publish its Agent Card, validate structured artifacts, and trace the task lifecycle. Next, put the coordinator behind an outbox and rehearse crash recovery. Then introduce a reversible write, such as creating a preview environment, with compensation and idempotency.

Only after those paths are boring should you add AP2-authorized purchases. Begin with a tiny allowlist, short expiry, integer limits, mandatory receipts, and human approval above the autonomous threshold. Run a shadow policy that records decisions without spending, then test duplicate delivery, revoked authorization, mutated carts, currency mismatch, and processor timeout.

Finally, contract-test every agent independently. Feed it malformed parts, unknown task states, expired credentials, duplicate message IDs, oversized artifacts, and cancellations at each transition. Your end-to-end suite should prove only the most important business journeys. The protocol matrix belongs in fast deterministic tests, not in expensive model-driven runs. For a practical test-automation baseline, see how to speed up flaky Playwright tests.

Level up the boundaries, not the agent count

The professional move is not replacing one chat window with twelve autonomous personalities. It is making each handoff inspectable and recoverable. A2A gives independently built agents a shared language for discovery, messages, tasks, and artifacts. AP2 gives economic actions an authorization chain that survives audits and disputes.

Keep local calls local. Add A2A at real ownership and trust boundaries. Normalize and verify AP2 mandates at the payment edge. Persist IDs before side effects, design compensation for irreversible actions, and observe state transitions with the tools your team already operates. That is how an agent demo becomes an SDLC system you can trust on release day.

Ready to level up your dev toolkit?

Desplega.ai helps developers transition to professional tools smoothly, with observable automation and production-grade quality controls.

Get Started

Frequently Asked Questions

Do I need A2A when all my agents use the same framework?

Not immediately. Add A2A when agents cross deployment, ownership, or trust boundaries. Inside one process, typed function calls remain simpler and easier to debug.

Does AP2 execute a payment?

AP2 records authorization and binds intent to a payment mandate and receipt. Your commerce and payment providers still execute checkout and move the actual funds.

Should the orchestrator share one memory with every agent?

No. Send the minimum task context and preserve references to source artifacts. Shared mutable memory couples agents, leaks data, and makes replay nondeterministic.

How do I test an agentic mesh without calling live models?

Contract-test Agent Cards and task envelopes, then run a fake A2A server through every state. Reserve live-model tests only for a small set of end-to-end journeys.