Agent-to-agent (A2A)
A2A lets one governed agent (the caller) open a task on another (the callee), using the A2A protocol shape (SendMessage, GetTask, CancelTask). Tasks pass through the same gateway that governs a model call. Forgebench never runs the callee itself. It relays the task to the callee's own endpoint (push) or hands it to the callee's own polling process (pull). Either way, it writes the whole exchange to the ledger.
It applies the governance Forgebench already uses for model calls to "one agent asking another to do something". A binding replaces the model allowlist and a task replaces the completion. The audit row, the budget-adjacent guardrails and the principle that nothing crosses this edge ungoverned stay the same.
How it works
- Authorization check. Forgebench checks that a binding exists from caller to callee, that it's approved and not revoked, and that the callee is in a callable state (not paused or retired). That's the only check on this edge — not the caller's tools, not the callee's tools, not either agent's budget. A binding answers exactly one question: may this caller open a task on this callee.
- Ledger write. A task row and an audit row are written before
delivery is attempted, tagged with the caller's lineage
(
X-Parent-Call). Whichever way delivery goes next — relayed, claimed, answered, failed, never picked up — the task already exists on the record. - Delivery. If the callee has an
endpoint_urlset, Forgebench POSTs the task there (push). If not, the callee's own process claims the task by long-pollingGET /v1/agents/me/tasks/next(pull).
A binding only grants "caller may open a task on callee" — it says nothing about what the callee is allowed to do while working on it. The callee's own model and tool calls are still gated by the callee's own bindings, independent of who opened the task. That separation keeps a multi-agent workflow auditable per agent. Agent A calling agent B never means A borrowed B's tools.
1. Grant a binding
A caller can only open a task on a callee it's bound to. Grant one from the caller's Calls tab, or the same operation via the API:

# caller's owner or an admin, granting caller -> callee(s)
import os
import httpx
r = httpx.post(
f"https://api.forgebench.ai/v1/agents/{os.environ['CALLER_ID']}/agents",
headers={"Authorization": f"Bearer {os.environ['ADMIN_KEY']}"},
json={"callees": ["invoice-classifier", "refund-approver"]},
)
r.raise_for_status()
bindings = r.json() # one entry per callee// caller's owner or an admin, granting caller -> callee(s)
const r = await fetch(
"https://api.forgebench.ai/v1/agents/" + process.env.CALLER_ID + "/agents",
{
method: "POST",
headers: {
Authorization: "Bearer " + process.env.ADMIN_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ callees: ["invoice-classifier", "refund-approver"] }),
},
);
if (!r.ok) throw new Error("HTTP " + r.status + ": " + (await r.text()));
const bindings = await r.json(); // one entry per callee// caller's owner or an admin, granting caller -> callee(s)
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;
ObjectMapper json = new ObjectMapper();
String payload = json.writeValueAsString(Map.of(
"callees", List.of("invoice-classifier", "refund-approver")));
HttpResponse<String> r = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI.create(
"https://api.forgebench.ai/v1/agents/" + System.getenv("CALLER_ID") + "/agents"))
.header("Authorization", "Bearer " + System.getenv("ADMIN_KEY"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build(),
HttpResponse.BodyHandlers.ofString());
if (r.statusCode() / 100 != 2) throw new IllegalStateException("HTTP " + r.statusCode() + ": " + r.body());
List<?> bindings = json.readValue(r.body(), List.class); // one entry per callee# caller's owner or an admin, granting caller -> callee(s)
curl -X POST https://api.forgebench.ai/v1/agents/$CALLER_ID/agents \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"callees": ["invoice-classifier", "refund-approver"]}'# caller's owner or an admin, granting caller -> callee(s)
import os
import httpx
r = httpx.post(
f"https://api.forgebench.ai/v1/agents/{os.environ['CALLER_ID']}/agents",
headers={"Authorization": f"Bearer {os.environ['ADMIN_KEY']}"},
json={"callees": ["706608c0-4160-452a-86ac-ded1186326ac", "8b6a2e1f-2f3a-4b9a-9d0e-1c2d3e4f5a6b"]},
)
r.raise_for_status()
bindings = r.json() # one entry per callee// caller's owner or an admin, granting caller -> callee(s)
const r = await fetch(
"https://api.forgebench.ai/v1/agents/" + process.env.CALLER_ID + "/agents",
{
method: "POST",
headers: {
Authorization: "Bearer " + process.env.ADMIN_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ callees: ["706608c0-4160-452a-86ac-ded1186326ac", "8b6a2e1f-2f3a-4b9a-9d0e-1c2d3e4f5a6b"] }),
},
);
if (!r.ok) throw new Error("HTTP " + r.status + ": " + (await r.text()));
const bindings = await r.json(); // one entry per callee// caller's owner or an admin, granting caller -> callee(s)
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;
ObjectMapper json = new ObjectMapper();
String payload = json.writeValueAsString(Map.of(
"callees", List.of("706608c0-4160-452a-86ac-ded1186326ac", "8b6a2e1f-2f3a-4b9a-9d0e-1c2d3e4f5a6b")));
HttpResponse<String> r = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI.create(
"https://api.forgebench.ai/v1/agents/" + System.getenv("CALLER_ID") + "/agents"))
.header("Authorization", "Bearer " + System.getenv("ADMIN_KEY"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build(),
HttpResponse.BodyHandlers.ofString());
if (r.statusCode() / 100 != 2) throw new IllegalStateException("HTTP " + r.statusCode() + ": " + r.body());
List<?> bindings = json.readValue(r.body(), List.class); // one entry per callee# caller's owner or an admin, granting caller -> callee(s)
curl -X POST https://api.forgebench.ai/v1/agents/$CALLER_ID/agents \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"callees": ["706608c0-4160-452a-86ac-ded1186326ac", "8b6a2e1f-2f3a-4b9a-9d0e-1c2d3e4f5a6b"]}'Each entry in the response has caller_agent_id, callee_agent_id,
source, revoked, and approved_at.
- If the granting principal can also speak for the callee (an admin, or a
person who owns both agents),
approved_atis set immediately and the binding is live right away — that's the row in the screenshot. - Otherwise
approved_atisnull: the binding is pending until the callee's owner (or an admin) approves it from the callee's own Calls tab, or via the API:
# callee's owner or an admin — agent_id is the CALLEE, binding_id from the grant response
import os
import httpx
r = httpx.post(
f"https://api.forgebench.ai/v1/agents/{os.environ['CALLEE_ID']}/agents/{os.environ['BINDING_ID']}/approve",
headers={"Authorization": f"Bearer {os.environ['ADMIN_KEY']}"},
)
r.raise_for_status()
binding = r.json() # approved_at is now set// callee's owner or an admin — agent_id is the CALLEE, binding_id from the grant response
const r = await fetch(
"https://api.forgebench.ai/v1/agents/" + process.env.CALLEE_ID +
"/agents/" + process.env.BINDING_ID + "/approve",
{
method: "POST",
headers: { Authorization: "Bearer " + process.env.ADMIN_KEY },
},
);
if (!r.ok) throw new Error("HTTP " + r.status + ": " + (await r.text()));
const binding = await r.json(); // approved_at is now set// callee's owner or an admin — agent_id is the CALLEE, binding_id from the grant response
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
HttpResponse<String> r = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI.create("https://api.forgebench.ai/v1/agents/"
+ System.getenv("CALLEE_ID") + "/agents/" + System.getenv("BINDING_ID") + "/approve"))
.header("Authorization", "Bearer " + System.getenv("ADMIN_KEY"))
.POST(HttpRequest.BodyPublishers.noBody())
.build(),
HttpResponse.BodyHandlers.ofString());
if (r.statusCode() / 100 != 2) throw new IllegalStateException("HTTP " + r.statusCode() + ": " + r.body());
Map<?, ?> binding = new ObjectMapper().readValue(r.body(), Map.class); // approved_at is now set# callee's owner or an admin — agent_id is the CALLEE, binding_id from the grant response
curl -X POST https://api.forgebench.ai/v1/agents/$CALLEE_ID/agents/$BINDING_ID/approve \
-H "Authorization: Bearer $ADMIN_KEY"Two-sided approval means a caller can't grant itself access to a callee it doesn't own. It's the same "both owners sign off" shape any cross-team integration needs. Same-owner or admin-granted pairs skip the second step because the same person already speaks for both sides.
Re-granting an already-revoked pair un-revokes the same row instead of creating a new one — the history of who was ever bound to whom stays intact.
Read both directions for one agent:
GET /v1/agents/{agent_id}/agents
-> {"outbound": [...], "inbound": [...]}Revoke a binding from either side, from the Calls tab's Revoke button or
DELETE /v1/agents/{agent_id}/agents/{binding_id}. The row is kept and marked revoked: true, not deleted, so a later security review can see that an edge existed and was cut. Revocation takes effect on the caller's next task.
Next
SendMessage, GetTask, CancelTask — the caller side, with a worked
example.
Run an agent as a callee: pull with serve(), or push over a webhook.
The A2A discovery document, and every refusal shape specific to this edge.
AgentsRegister the two agents you're wiring together.

