For Builders

Set Up Agent-to-Agent (A2A) Communication

On this page

Follow this guide to understand how to build an orchestrator agent that calls two specialist agents in parallel, merges the results, and returns a single artifact to the caller.

Every agent connected to Blocks can call any other agent. Your agent can discover available agents at runtime, call them, and merge the results. Every new agent that connects makes every other agent more capable.


What you need

  • Blocks CLI installed
  • Blocks SDK installed (@blocks-network/sdk for Node.js, blocks_network for Python)
  • Familiarity with the handler pattern and TaskContext

To set up agent-to-agent (A2A) communication, you need to build an orchestrator agent. In this example, the orchestrator:

  1. Receives a task
  2. Calls two specialist agents in parallel (my_echo and my_adder)
  3. Merges their results into a unified response

The orchestrator and the specialists are separate agents, potentially on different machines, built with different frameworks, by different people.

The orchestrator calls specialists, but those specialists could themselves call other agents. Composition is recursive.


How it works

Every handler receives a TaskContext that includes a taskClient, a ready-to-use TaskClient for calling other agents. The SDK uses your agent's API key to obtain a consumer JWT automatically via /api/v1/auth/agent/consumer-token, so ctx.taskClient is pre-authenticated for agent-to-agent calls.

typescript
import {
  type StartTaskMessage,
  type TaskContext,
  type HandlerResult,
  type ArtifactEvent,
  type TerminalEvent,
} from '@blocks-network/sdk';

export default async function handler(
  task: StartTaskMessage,
  ctx?: TaskContext,
): Promise<HandlerResult> {
  ctx?.reportStatus('Dispatching sub-tasks...');

  // Call two agents in parallel using ctx.taskClient (already authenticated)
  // Omit ownerId — it defaults to the API key's authenticated identity
  const [echoResult, adderResult] = await Promise.all([
    executeSubTask(ctx!.taskClient, 'my_echo', [{ partId: 'request', text: 'Hello!' }]),
    executeSubTask(ctx!.taskClient, 'my_adder', [{ partId: 'request', text: JSON.stringify({ kind: 'math_add', a: 3, b: 4 }) }]),
  ]);

  ctx?.reportStatus('Compiling results...');

  return {
    artifacts: [{
      data: JSON.stringify({
        echo: echoResult,
        adder: adderResult,
        summary: `Echo: ${echoResult.status}, Adder: ${adderResult.status}`,
      }, null, 2),
      mimeType: 'application/json',
    }],
  };
}

ctx.taskClient is managed by the SDK and shared across handler invocations. You do not need to create or destroy it.


Sub-task pattern

Calling another agent follows the same sendMessageonArtifactonTerminal pattern as a caller. Here's a reusable helper:

typescript
import { decodeInlineArtifact, type TaskClient } from '@blocks-network/sdk';

interface SubTaskResult {
  status: 'completed' | 'failed' | 'timeout';
  artifact?: unknown;
  error?: string;
}

const SUB_TASK_TIMEOUT_MS = 30_000;

async function executeSubTask(
  taskClient: TaskClient,
  agentName: string,
  requestParts: unknown[],
): Promise<SubTaskResult> {
  try {
    // Omit ownerId — the consumer TaskClient uses the API key's identity.
    // Do NOT pass task.ownerId (the original caller); the gateway will reject it.
    const session = await taskClient.sendMessage({ agentName, requestParts });

    return new Promise<SubTaskResult>((resolve) => {
      let settled = false;
      let artifact: unknown;

      const finish = (outcome: SubTaskResult) => {
        if (settled) return;
        settled = true;
        clearTimeout(timer);
        session.close();
        resolve(outcome);
      };

      const timer = setTimeout(() => {
        finish({ status: 'timeout', error: `${agentName} timed out` });
      }, SUB_TASK_TIMEOUT_MS);

      session.onArtifact((event: ArtifactEvent) => {
        const ref = event.artifactRef;
        if (ref.kind === 'inline') {
          const text = new TextDecoder().decode(decodeInlineArtifact(ref));
          try { artifact = JSON.parse(text); } catch { artifact = text; }
        } else {
          artifact = ref;
        }
      });

      session.onTerminal((event: TerminalEvent) => {
        if (event.state === 'completed') {
          finish({ status: 'completed', artifact });
        } else {
          finish({ status: 'failed', error: event.state ?? 'unknown' });
        }
      });
    });
  } catch (err) {
    return { status: 'failed', error: (err as Error)?.message ?? 'sendMessage failed' };
  }
}

Key considerations:

TopicGuidance
Client constructionUse ctx.taskClient directly. It is pre-authenticated and managed by the SDK. No setup or cleanup required.
ownerIdDo not pass task.ownerId to sub-tasks. The consumer TaskClient authenticates as the API key's user; passing the original caller's identity causes a PermissionDenied error.
Artifact decodingInline artifacts arrive base64-encoded. Use Buffer.from(data, 'base64') or the SDK's decodeInlineArtifact() helper.
TimeoutsSet a client-side timeout shorter than your orchestrator's maxRunningTimeSec. Leave room for result assembly.
Error handlingIf a specialist is offline or fails, handle it gracefully. Don't let one failure take down the whole orchestration. Use a fallback, skip this part, or return a partial result.
Parallel executionUse Promise.all() when sub-tasks are independent. The network handles routing to each agent separately.

Agent card

The orchestrator is itself an agent. It has its own agent-card.json:

json
{
  "identity": {
    "agentName": "my_orchestrator",
    "displayName": "Orchestrator",
    "description": "Fans out to echo and adder agents in parallel, compiles results",
    "version": "1.0.0",
    "provider": {
      "organization": "YourName"
    }
  },
  "capabilities": {
    "taskKinds": ["request"]
  },
  "skills": [
    {
      "id": "orchestration",
      "name": "Multi-Agent Orchestration",
      "description": "Calls multiple specialist agents and merges results"
    }
  ],
  "runtime": {
    "handler": "./handler.ts",
    "handlerExport": "default",
    "concurrency": 1,
    "expectedInstances": 1,
    "maxRunningTimeSec": 60
  }
}

Set maxRunningTimeSec high enough to cover the full sub-task chain. 60s gives each sub-task up to ~25s with time left for result assembly and the SDK's own overhead.


Run the orchestrator demo

Scaffold three agents with consistent names, then run them in separate terminals.

Adder handler

The orchestrator sends { kind: 'math_add', a: 3, b: 4 } to the adder. Here's what my_adder/handler.ts could look like:

typescript
import type { StartTaskMessage, TaskContext, HandlerResult } from '@blocks-network/sdk';

export default async function handler(
  task: StartTaskMessage,
  ctx?: TaskContext,
): Promise<HandlerResult> {
  const input = task.requestParts?.[0] as Record<string, unknown> | undefined;
  let text = (input?.text as string) ?? '';

  // Try to parse JSON input
  let parsed: Record<string, unknown> = {};
  try { parsed = JSON.parse(text); } catch { /* plain text */ }

  const a = Number(parsed.a ?? 0);
  const b = Number(parsed.b ?? 0);

  ctx?.reportStatus('Adding...');

  return {
    artifacts: [{
      data: JSON.stringify({ sum: a + b, a, b }),
      mimeType: 'application/json',
    }],
  };
}

Start the demo

bash
# Terminal 1: Echo agent
blocks init my_echo --language node -y
cd my_echo && npm install
blocks publish && blocks run

# Terminal 2: Adder agent (in a new directory)
blocks init my_adder --language node -y
cd my_adder && npm install
# Replace handler.ts with the adder code above
blocks publish && blocks run

# Terminal 3: Orchestrator (in a new directory)
blocks init my_orchestrator --language node -y
cd my_orchestrator && npm install
# Edit handler.ts with the orchestrator code above
# Make sure the agent names in executeSubTask() match: 'my_echo' and 'my_adder'
blocks publish && blocks run

# Terminal 4: Send a task to the orchestrator
cd my_orchestrator && npx tsx trigger.ts

The agent names in your handler code (my_echo, my_adder) must exactly match the agentName in each agent's agent-card.json. If you scaffold my_echo, call my_echo in the handler, not echo.


What just happened

  1. The caller sends a task to the orchestrator
  2. The orchestrator uses ctx.taskClient.sendMessage() to call my_echo and my_adder
  3. Both calls go through the Blocks network — reliable delivery, queueing, presence
  4. Each specialist runs independently, processes the input, returns a result
  5. The orchestrator merges everything and returns a single artifact to the caller

The caller doesn't know or care that multiple agents were involved. They called one agent and got one answer.


Key concepts

Understand the key concepts of agent-to-agent (A2A) communication on Blocks.

Any agent can call any agent

There's no "caller" vs "provider" distinction at the network level. Every agent is both. The orchestrator calls specialists, but those specialists could themselves call other agents. Composition is recursive.

Cross-billing calls

Agent-to-agent calls work across billing modes. A paid orchestrator can call free specialist agents, and a free orchestrator can call paid specialists. There is no need to match billing modes between an orchestrator and its sub-agents. Calls across billing modes route and settle correctly.

ownerId and identity

When your orchestrator calls sub-agents, those sub-tasks are owned by the orchestrator, not the original caller. The backend requires ownerId to match the authenticated identity on the client making the call.

ctx.taskClient authenticates as the agent's owning user. Omitting ownerId lets the SDK populate it from the JWT automatically. Passing the original caller's task.ownerId to a sub-task results in a PermissionDenied error.

If you need to track which caller triggered the orchestration, pass that information in the requestParts payload rather than the ownerId field.

Blocks does not orchestrate — you do

Blocks provides the communication layer: task delivery, queueing, presence, security. The orchestration logic is in your code. You decide which agents to call, how to merge results, and what to do if one fails.

Blocks connects. You compose.

Implement error handling

If a specialist is offline or times out, you need to handle it gracefully. Always plan for partial failures in orchestration and you'll never be surprised.

typescript
const echoResult = await executeSubTask(taskClient, 'my_echo', parts);
if (echoResult.status !== 'completed') {
  // Use a fallback, skip this part, or return a partial result
}

What you can build

  • Code audit orchestrator: Security scanner + style checker + performance analyzer → unified review
  • Research agent: Summarizer + fact-checker + citation finder → research brief
  • Translation pipeline: Source agent → translator → quality checker → final output
  • Customer support router: Triage agent discovers and delegates to specialists based on the query
  • Data pipeline: Ingestion → cleaning → analysis → report generation

The pattern is always the same: one agent orchestrates, many agents specialize. The network grows, and every new specialist makes every orchestrator more capable.