
A2a Setup
- 4 installs
- 550 repo stars
- Updated July 17, 2026
- win4r/openclaw-a2a-gateway
Helps with ai & agent building tasks.
About
a2a-setup is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- a2a-setup
- AI & Agent Building
- AI-coding skill
A2a Setup by the numbers
- 4 all-time installs (skills.sh)
- Ranked #13,372 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/win4r/openclaw-a2a-gateway --skill a2a-setupAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| repo stars | ★ 550 |
| Last updated | July 17, 2026 |
| Repository | win4r/openclaw-a2a-gateway ↗ |
What it does
Helps with ai & agent building tasks.
Files
A2A Gateway Setup
Configure the OpenClaw A2A Gateway plugin for cross-server agent-to-agent communication using the A2A v0.3.0 protocol.
Prerequisites
- OpenClaw ≥ 2026.3.0 installed and running on each server
- Network connectivity between servers (Tailscale recommended, LAN or public IP also work)
- Node.js ≥ 22
Step 1: Install the Plugin
mkdir -p <WORKSPACE>/plugins
cd <WORKSPACE>/plugins
git clone https://github.com/win4r/openclaw-a2a-gateway.git a2a-gateway
cd a2a-gateway
npm install --productionReplace <WORKSPACE> with the agent workspace path. Find it with:
openclaw config get agents.defaults.workspaceStep 2: Register Plugin in OpenClaw
Get current allowed plugins first to avoid overwriting:
openclaw config get plugins.allowThen add a2a-gateway to the existing array (do NOT drop existing plugin ids):
# Example only — include your existing plugins too
openclaw config set plugins.allow '["<existing...>", "a2a-gateway"]'
openclaw config set plugins.load.paths '["<ABSOLUTE_PATH>/plugins/a2a-gateway"]'
openclaw config set plugins.entries.a2a-gateway.enabled trueCritical: Use the absolute path in plugins.load.paths. Relative paths will fail.
Step 3: Configure Agent Card
openclaw config set plugins.entries.a2a-gateway.config.agentCard.name '<AGENT_NAME>'
openclaw config set plugins.entries.a2a-gateway.config.agentCard.description '<DESCRIPTION>'
openclaw config set plugins.entries.a2a-gateway.config.agentCard.url 'http://<REACHABLE_IP>:18800/a2a/jsonrpc'
openclaw config set plugins.entries.a2a-gateway.config.agentCard.skills '[{"id":"chat","name":"chat","description":"Bridge chat/messages to OpenClaw agents"}]'URL field rules
| Field | Points to | Example |
|---|---|---|
agentCard.url | JSON-RPC endpoint (default) | http://100.x.x.x:18800/a2a/jsonrpc |
peers[].agentCardUrl | Agent Card discovery (preferred) | http://100.x.x.x:18800/.well-known/agent-card.json |
Do NOT confuse these two. agentCard.url tells peers where to send messages. agentCardUrl tells you where to discover the peer.
Note: this plugin also serves the legacy alias /.well-known/agent.json, but the official SDK default is /.well-known/agent-card.json.
Step 4: Configure Server
openclaw config set plugins.entries.a2a-gateway.config.server.host '0.0.0.0'
openclaw config set plugins.entries.a2a-gateway.config.server.port 18800Step 5: Configure Security
TOKEN=$(openssl rand -hex 24)
echo "Save this token: $TOKEN"
openclaw config set plugins.entries.a2a-gateway.config.security.inboundAuth 'bearer'
openclaw config set plugins.entries.a2a-gateway.config.security.token "$TOKEN"Share this token with peers who need to send you messages.
Step 6: Configure Routing
openclaw config set plugins.entries.a2a-gateway.config.routing.defaultAgentId 'main'Step 7: Add Peers
openclaw config set plugins.entries.a2a-gateway.config.peers '[
{
"name": "<PEER_NAME>",
"agentCardUrl": "http://<PEER_IP>:18800/.well-known/agent-card.json",
"auth": {
"type": "bearer",
"token": "<PEER_INBOUND_TOKEN>"
}
}
]'For multiple peers, include all in one JSON array.
Step 8: Restart and Verify
openclaw gateway restart
# Verify Agent Card
curl -s http://localhost:18800/.well-known/agent-card.json | python3 -m json.tool
# Verify peer connectivity
curl -s http://<PEER_IP>:18800/.well-known/agent-card.json | python3 -m json.toolStep 9: Configure TOOLS.md
This step is critical. Without it, the agent won't know how to use A2A.
Read references/tools-md-template.md and append the A2A section to the agent's TOOLS.md, replacing placeholders with actual peer info.
For outbound messaging, use the SDK script (scripts/a2a-send.mjs).
To use the SDK script, ensure @a2a-js/sdk is installed in the plugin directory:
cd <WORKSPACE>/plugins/a2a-gateway && npm ls @a2a-js/sdkStep 10: End-to-End Test
node <WORKSPACE>/plugins/a2a-gateway/skill/scripts/a2a-send.mjs \
--peer-url http://<PEER_IP>:18800 \
--token <PEER_TOKEN> \
--message "Hello, what is your name?"The script uses @a2a-js/sdk ClientFactory to auto-discover the Agent Card, handle authentication, and print the peer agent's response.
Async task mode (recommended for long-running prompts)
For prompts that may take longer than a typical request timeout (e.g., multi-round discussions, long summaries), use non-blocking mode + polling:
node <WORKSPACE>/plugins/a2a-gateway/skill/scripts/a2a-send.mjs \
--peer-url http://<PEER_IP>:18800 \
--token <PEER_TOKEN> \
--non-blocking \
--wait \
--timeout-ms 600000 \
--poll-ms 1000 \
--message "Discuss A2A advantages in 3 rounds and provide final conclusion"This sends configuration.blocking=false and then polls tasks/get until the task reaches a terminal state.
Server-side timeout configuration (OpenClaw dispatch)
If you still see Request accepted (no agent dispatch available), the underlying OpenClaw agent run may be timing out. Increase:
plugins.entries.a2a-gateway.config.timeouts.agentResponseTimeoutMs(default: 300000)
Optional: Route to a specific OpenClaw agentId (OpenClaw extension)
By default, the peer will route inbound A2A messages to routing.defaultAgentId.
To route a single request to a specific agentId (e.g., coder) on the peer, pass --agent-id:
node <WORKSPACE>/plugins/a2a-gateway/skill/scripts/a2a-send.mjs \
--peer-url http://<PEER_IP>:18800 \
--token <PEER_TOKEN> \
--agent-id coder \
--message "Run tests and summarize failures"Note: this uses a non-standard message.agentId field understood by the OpenClaw A2A Gateway plugin. It is most reliable over JSON-RPC/REST. gRPC transport may drop unknown Message fields.
Network: Tailscale Setup (if needed)
When servers are on different networks, use Tailscale:
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
# Authenticate via the printed URL (use same account on all servers)
tailscale ip -4 # Get the 100.x.x.x IPUse Tailscale IPs in all A2A configuration. Verify with:
ping <OTHER_SERVER_TAILSCALE_IP>Mutual Peering Checklist
For two-way communication, repeat Steps 1-9 on BOTH servers:
- [ ] Server A: plugin installed, Agent Card configured, token generated
- [ ] Server B: plugin installed, Agent Card configured, token generated
- [ ] Server A: has Server B in peers (with B's token)
- [ ] Server B: has Server A in peers (with A's token)
- [ ] Server A: TOOLS.md updated with Server B peer info
- [ ] Server B: TOOLS.md updated with Server A peer info
- [ ] Both:
openclaw gateway restartdone - [ ] Both: Agent Cards accessible (
curl /.well-known/agent-card.json) - [ ] Test: A → B message/send works
- [ ] Test: B → A message/send works
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| "no agent dispatch available" | (1) No AI provider configured, or (2) OpenClaw agent dispatch timed out | Check openclaw config get auth.profiles; for long prompts use async mode (--non-blocking --wait) or increase config.timeouts.agentResponseTimeoutMs |
| "plugin not found: a2a-gateway" | Load path missing or wrong | Verify plugins.load.paths uses absolute path |
| Agent Card 404 | Plugin not loaded | Check plugins.allow includes a2a-gateway |
| Port 18800 connection refused | Gateway not restarted | Run openclaw gateway restart |
| Peer auth fails | Token mismatch | Verify peer config token matches target's security.token |
| Agent doesn't know about A2A | TOOLS.md not configured | Add A2A section from the template (Step 9) |
TOOLS.md A2A Section Template
Append this section to the agent's TOOLS.md file, replacing all <PLACEHOLDERS> with actual values.
---
## A2A Gateway (Agent-to-Agent Communication)
You have an A2A Gateway plugin running on port 18800. You can communicate with peer agents on other servers.
### Peers
| Peer | IP | Auth Token |
|------|-----|------------|
| <PEER_NAME> | <PEER_IP> | <PEER_TOKEN> |
### How to send a message to a peer
When the user says "通过 A2A 让 <PEER_NAME> 做 xxx" / "Send to <PEER_NAME>: xxx" / "Ask <PEER_NAME> to ..." or similar, use the exec tool to run:
node <WORKSPACE>/plugins/a2a-gateway/skill/scripts/a2a-send.mjs \ --peer-url http://<PEER_IP>:18800 \ --token <PEER_TOKEN> \ --message "YOUR MESSAGE HERE"
Optional (OpenClaw extension): route to a specific peer OpenClaw agentId
--agent-id coder
The script uses `@a2a-js/sdk` ClientFactory to:
- Auto-discover the peer's Agent Card
- Handle bearer token authentication
- Select the best transport (JSON-RPC by default; REST or GRPC if preferred/available)
- Print the peer agent's response text directly
### Notes
- For long-running prompts (multi-round discussions, long summaries), use async task mode:
- add: `--non-blocking --wait --timeout-ms 600000 --poll-ms 1000`
- If the peer returns an error, check the token and network connectivity
- The script handles messageId generation and response parsing automatically---
Placeholder Reference
| Placeholder | Description | Example |
|---|---|---|
<PEER_NAME> | Display name of the peer agent | Server-A |
<PEER_IP> | IP address reachable from this server | 100.76.43.74 |
<PEER_TOKEN> | The peer's inbound security token | 9489c2c7ce10... |
<WORKSPACE> | Agent workspace absolute path | /home/ubuntu/.openclaw/workspace |
For multiple peers, add one row per peer to the table.
#!/usr/bin/env node
/**
* Send a message to an A2A peer using the official @a2a-js/sdk.
*
* Usage:
* node a2a-send.mjs --peer-url <PEER_BASE_URL> --token <TOKEN> --message "Hello!"
* node a2a-send.mjs --peer-url http://100.76.43.74:18800 --token abc123 --message "What is your name?"
* node a2a-send.mjs --peer-url <URL> --token <TOKEN> --message "Follow up" --task-id <TASK_ID> --context-id <CONTEXT_ID>
*
* Async task mode (recommended for long-running prompts):
* node a2a-send.mjs --peer-url <URL> --token <TOKEN> --non-blocking --wait --message "..."
*
* Options:
* --peer-url <url> Peer base URL, e.g. http://100.76.43.74:18800
* --token <token> Bearer token for the peer inbound auth
* --message <text> Text to send
* --task-id <id> Reuse an existing A2A task for follow-up turns
* --context-id <id> Reuse an existing A2A context for multi-round conversation routing
* --non-blocking Send with configuration.blocking=false (returns quickly with a Task)
* --wait When non-blocking, poll tasks/get until terminal state
* --timeout-ms <ms> Max wait time for --wait (default: 600000)
* --poll-ms <ms> Poll interval for --wait (default: 1000)
* --help Show this help text
*
* Optional (OpenClaw extension):
* --agent-id <agentId> Route the inbound A2A request to a specific OpenClaw agentId on the peer.
* Note: this works reliably over JSON-RPC/REST. gRPC transport may drop unknown
* Message fields, so gRPC is disabled when --agent-id is used.
*
* Requires: npm install @a2a-js/sdk
*/
import {
ClientFactory,
ClientFactoryOptions,
DefaultAgentCardResolver,
JsonRpcTransportFactory,
RestTransportFactory,
createAuthenticatingFetchWithRetry,
} from "@a2a-js/sdk/client";
import { GrpcTransportFactory } from "@a2a-js/sdk/client/grpc";
import { randomUUID } from "node:crypto";
import { readFileSync, statSync } from "node:fs";
import { extname } from "node:path";
const USAGE = `Usage: node a2a-send.mjs --peer-url <URL> --token <TOKEN> --message <TEXT> [--file-uri <url>] [--file-path <localpath>] [--task-id <id>] [--context-id <id>] [--non-blocking] [--wait] [--stream] [--timeout-ms <ms>] [--poll-ms <ms>] [--agent-id <openclaw-agent-id>] [--help]`;
const MAX_INLINE_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const CLI_MIME_MAP = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
".pdf": "application/pdf", ".txt": "text/plain", ".csv": "text/csv",
".json": "application/json", ".mp3": "audio/mpeg", ".wav": "audio/wav",
".mp4": "video/mp4", ".webm": "video/webm", ".zip": "application/zip",
};
const CLI_ALLOWED_MIME_PATTERNS = [
"image/*", "application/pdf", "text/plain", "text/csv",
"application/json", "audio/*", "video/*",
];
function detectMimeFromPath(filePath) {
const ext = extname(filePath).toLowerCase();
return CLI_MIME_MAP[ext] || "application/octet-stream";
}
function isMimeAllowed(mimeType) {
const normalized = mimeType.toLowerCase();
for (const pattern of CLI_ALLOWED_MIME_PATTERNS) {
if (normalized === pattern) return true;
if (pattern.endsWith("/*")) {
const prefix = pattern.slice(0, -1);
if (normalized.startsWith(prefix)) return true;
}
}
return false;
}
function usageAndExit(code = 1) {
const stream = code === 0 ? console.log : console.error;
stream(USAGE);
process.exit(code);
}
function parseArgs() {
const args = process.argv.slice(2);
const opts = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (!arg?.startsWith("--")) continue;
const key = arg.replace(/^--/, "");
const next = args[i + 1];
if (next && !next.startsWith("--")) {
opts[key] = next;
i++;
} else {
opts[key] = true;
}
}
if (opts.help || opts.h) {
usageAndExit(0);
}
const peerUrl = String(opts["peer-url"] || opts.peerUrl || "").trim();
const message = String(opts.message || "").trim();
const fileUri = String(opts["file-uri"] || opts.fileUri || "").trim();
const filePath = String(opts["file-path"] || opts.filePath || "").trim();
// At least one of --message, --file-uri, --file-path required
if (!peerUrl || (!message && !fileUri && !filePath)) {
usageAndExit(1);
}
return { ...opts, peerUrl, message, fileUri, filePath };
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function extractFirstTextParts(parts) {
if (!Array.isArray(parts)) return undefined;
for (const p of parts) {
if (p && typeof p === "object" && p.kind === "text" && typeof p.text === "string") {
return p.text;
}
}
return undefined;
}
async function main() {
const opts = parseArgs();
const peerUrl = opts.peerUrl;
const token = typeof opts.token === "string" ? opts.token : "";
const message = opts.message;
const targetAgentId = (opts["agent-id"] || opts.agentId || "").toString().trim();
const continuationTaskId = (opts["task-id"] || opts.taskId || "").toString().trim().slice(0, 256);
const continuationContextId = (opts["context-id"] || opts.contextId || "").toString().trim().slice(0, 256);
const nonBlocking = Boolean(opts["non-blocking"] || opts.nonBlocking);
const wait = Boolean(opts.wait);
const stream = Boolean(opts.stream);
const timeoutMsRaw = opts["timeout-ms"] || opts.timeoutMs;
const pollMsRaw = opts["poll-ms"] || opts.pollMs;
// Default wait timeout: 10 minutes. Long agent runs are common in multi-round discussions.
const timeoutMs = timeoutMsRaw ? Number(timeoutMsRaw) : 600_000;
const pollMs = pollMsRaw ? Number(pollMsRaw) : 1_000;
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
console.error("Invalid --timeout-ms");
usageAndExit(2);
}
if (!Number.isFinite(pollMs) || pollMs <= 0) {
console.error("Invalid --poll-ms");
usageAndExit(2);
}
// Build auth handler
const authHandler = token
? {
headers: async () => ({ authorization: `Bearer ${token}` }),
shouldRetryWithHeaders: async () => undefined,
}
: undefined;
const authFetch = authHandler
? createAuthenticatingFetchWithRetry(fetch, authHandler)
: fetch;
// If using OpenClaw extension agentId routing, disable gRPC transport to avoid
// protobuf dropping unknown message fields.
const transports = targetAgentId
? [
new JsonRpcTransportFactory({ fetchImpl: authFetch }),
new RestTransportFactory({ fetchImpl: authFetch }),
]
: [
new JsonRpcTransportFactory({ fetchImpl: authFetch }),
new RestTransportFactory({ fetchImpl: authFetch }),
new GrpcTransportFactory(),
];
const factory = new ClientFactory(
ClientFactoryOptions.createFrom(ClientFactoryOptions.default, {
cardResolver: new DefaultAgentCardResolver({ fetchImpl: authFetch }),
transports,
})
);
// Discover agent card and create client
const client = await factory.createFromUrl(peerUrl);
// Build message parts: text + optional file
const outboundParts = [];
if (message) {
outboundParts.push({ kind: "text", text: message });
}
const fileUri = opts.fileUri;
const filePath = opts.filePath;
if (filePath) {
// Read local file, base64-encode, auto-detect MIME
const stat = statSync(filePath);
if (stat.size > MAX_INLINE_FILE_SIZE) {
console.error(`File too large: ${(stat.size / 1048576).toFixed(1)}MB exceeds 10MB limit`);
process.exit(2);
}
const mimeType = detectMimeFromPath(filePath);
if (!isMimeAllowed(mimeType)) {
console.error(`MIME type not allowed: ${mimeType}`);
process.exit(2);
}
const fileBuffer = readFileSync(filePath);
const base64 = fileBuffer.toString("base64");
const name = filePath.split("/").pop() || "file";
outboundParts.push({
kind: "file",
file: { bytes: base64, mimeType, name },
});
} else if (fileUri) {
// URI-based file reference
outboundParts.push({
kind: "file",
file: { uri: fileUri },
});
}
if (outboundParts.length === 0) {
console.error("No message content to send");
process.exit(2);
}
const outboundMessage = {
kind: "message",
messageId: randomUUID(),
role: "user",
parts: outboundParts,
...(continuationTaskId ? { taskId: continuationTaskId } : {}),
...(continuationContextId ? { contextId: continuationContextId } : {}),
...(targetAgentId ? { agentId: targetAgentId } : {}),
};
const requestOptions = token ? { serviceParameters: { authorization: `Bearer ${token}` } } : undefined;
const sendParams = {
message: outboundMessage,
...(nonBlocking ? { configuration: { blocking: false } } : {}),
};
// SSE streaming mode: subscribe to task event stream
if (stream) {
console.log("[stream] connecting...");
const eventStream = client.sendMessageStream(sendParams, requestOptions);
for await (const event of eventStream) {
const kind = event?.kind;
if (kind === "task") {
const state = event.status?.state;
const text = extractFirstTextParts(event.status?.message?.parts);
if (state === "working") {
console.log(`[stream] working... (${event.status?.timestamp || ""})`);
} else if (text) {
console.log(`[stream] ${state}: ${text}`);
} else {
console.log(`[stream] ${state}: ${JSON.stringify(event.status)}`);
}
} else if (kind === "status-update") {
const state = event.status?.state;
const text = extractFirstTextParts(event.status?.message?.parts);
console.log(`[stream] status-update: ${state}${text ? ` — ${text}` : ""}`);
} else {
console.log(`[stream] ${kind || "unknown"}: ${JSON.stringify(event)}`);
}
}
console.log("[stream] done");
return;
}
const result = await client.sendMessage(sendParams, requestOptions);
const printTaskHandle = (task) => {
if (!task || typeof task !== "object") return;
const responseTaskId = typeof task.id === "string" ? task.id : typeof task.taskId === "string" ? task.taskId : "";
if (!responseTaskId) return;
const responseContextId =
typeof task.contextId === "string"
? task.contextId
: typeof continuationContextId === "string" && continuationContextId
? continuationContextId
: "";
console.log(`[task] id=${responseTaskId} contextId=${responseContextId || "-"}`);
};
// If the user didn't request waiting, print the immediate response.
if (!nonBlocking || !wait) {
if (result?.kind === "message") {
const text = extractFirstTextParts(result.parts);
console.log(text || JSON.stringify(result, null, 2));
return;
}
if (result?.kind === "task") {
printTaskHandle(result);
const text = extractFirstTextParts(result.status?.message?.parts);
console.log(text || JSON.stringify(result, null, 2));
return;
}
console.log(JSON.stringify(result, null, 2));
return;
}
// Async task mode: wait for terminal task state via tasks/get.
const responseTaskId = result?.kind === "task" ? result.id : result?.taskId;
if (!responseTaskId || typeof responseTaskId !== "string") {
// Can't wait if we don't know the task id.
console.log(JSON.stringify(result, null, 2));
return;
}
if (result?.kind === "task") {
printTaskHandle(result);
}
const startedAt = Date.now();
const terminalStates = new Set(["completed", "failed", "canceled"]);
while (true) {
const task = await client.getTask({ id: responseTaskId, historyLength: 20 }, requestOptions);
const state = task?.status?.state;
if (state && terminalStates.has(state)) {
const text = extractFirstTextParts(task.status?.message?.parts);
console.log(text || JSON.stringify(task, null, 2));
return;
}
if (Date.now() - startedAt > timeoutMs) {
console.error(`Timeout waiting for task ${responseTaskId} after ${timeoutMs}ms`);
console.log(JSON.stringify(task, null, 2));
process.exit(3);
}
await sleep(pollMs);
}
}
main().catch((err) => {
console.error("Error:", err?.stack || err?.message || String(err));
process.exit(1);
});