Serving tasks
An agent becomes a callee the moment another agent has an approved binding to call it. What that callee does with an incoming task is up to its own process. Forgebench hands the task over, by letting the callee pull it or by pushing it to a URL, and waits for a reply in the same shape either way.
Pull: the callee claims its own work
If the callee has no endpoint_url set, it's pull-only: its own process
claims work by long-polling GET /v1/agents/me/tasks/next with its own
agent credential, then posts the result back. Nothing has to be reachable
from the outside — no inbound port, no public IP, no firewall rule to open.
That's the same shape a Temporal worker or a CI runner already uses to pull
jobs instead of accepting them.
The claim query uses FOR UPDATE SKIP LOCKED, so running several replicas
of the same callee polling at once is safe: each replica gets a different
task, and none is ever handed out twice.
import os
from forgebench import Forgebench
client = Forgebench(api_key=os.environ["CALLEE_AGENT_KEY"],
base_url="https://api.forgebench.ai")
def handle(task):
invoice_id = task.data.get("invoice_id")
if invoice_id is None:
return task.ask("which invoice_id should I classify?")
try:
category = classify(invoice_id, parent_call_id=task.call_id)
except ClassificationError as exc:
return task.fail(str(exc))
return task.done(data={"category": category})
# blocks; returns the number of tasks handled
client.agents.serve(handle, max_tasks=None)import { Forgebench } from "@seedlinglabs/forgebench-sdk";
const forgebench = new Forgebench({
baseUrl: "https://api.forgebench.ai",
apiKey: process.env.CALLEE_AGENT_KEY!,
});
await forgebench.agents.serve(async (task, reply) => {
const invoiceId = task.input.message?.parts.find((p) => p.data)?.data?.invoice_id;
if (!invoiceId) return reply.ask("which invoice_id should I classify?");
try {
const category = await classify(invoiceId, { parentCallId: task.call_id ?? undefined });
return reply.done({ data: { category } });
} catch (err) {
return reply.fail(err instanceof Error ? err.message : String(err));
}
});import ai.forgebench.Forgebench;
import java.util.Map;
Forgebench client = Forgebench.builder()
.apiKey(System.getenv("CALLEE_AGENT_KEY"))
.baseUrl("https://api.forgebench.ai")
.build();
// blocks; returns the number of tasks handled
client.agents().serve(task -> {
Object invoiceId = task.getData().get("invoice_id");
if (invoiceId == null) {
return task.ask("which invoice_id should I classify?");
}
try {
String category = classify(invoiceId, task.getCallId());
return task.done(Map.<String, Object>of("category", category));
} catch (ClassificationException e) {
return task.fail(e.getMessage());
}
});# one turn of the loop serve() runs: claim the next task (long-poll up to 20s;
# 204 No Content when there is none) ...
curl "https://api.forgebench.ai/v1/agents/me/tasks/next?wait=20" \
-H "Authorization: Bearer $CALLEE_AGENT_KEY"
# ... then post the handler's answer, using the claimed task's id
curl -X POST "https://api.forgebench.ai/v1/agents/me/tasks/$TASK_ID/result" \
-H "Authorization: Bearer $CALLEE_AGENT_KEY" \
-H "Content-Type: application/json" \
-d '{
"state": "completed",
"artifacts": [{"name": "response", "parts": [{"data": {"category": "software"}}]}]
}'
# or ask back: -d '{"state": "input_required", "message": {"role": "agent", "parts": [{"text": "which invoice_id should I classify?"}]}}'
# or fail: -d '{"state": "failed", "error": "..."}'serve() claims a task, hands it to your handler, and posts the reply,
looping until max_tasks/maxTasks is reached or stop()/an aborted
signal says to quit. The handler can return:
- a
TaskReplybuilt fromtask.done(...)/task.ask(...)/task.fail(...)(Python, Java) or thereplybuilder (TypeScript) - a plain string, dict/object, or list of artifacts — converted into a
completedreply automatically, so a handler that only wants to answer doesn't have to build the envelope by hand - nothing at all — raise/throw, and the exception's message fails the task instead of crashing the whole loop
Finishing a claimed task manually
Without serve(), claim and reply are two separate calls — reach for this
if you already have your own polling loop or worker framework and serve()
would be fighting it:
task = client.agents.next_task(wait=20)
if task is not None:
reply = task.done(text="done") # or task.ask(...) / task.fail(...)
client.agents.reply(task.id, reply)const task = await forgebench.agents.nextTask({ wait: 20 });
if (task) {
await forgebench.agents.reply(task.id, {
state: "completed",
artifacts: [{ name: "response", parts: [{ text: "done" }] }],
});
}Task task = client.agents().nextTask(Duration.ofSeconds(20));
if (task != null) {
TaskReply reply = task.done("done"); // or task.ask(...) / task.fail(...)
client.agents().reply(task.getId(), reply);
}# claim: 200 with the task, or 204 No Content if none arrived within wait
curl "https://api.forgebench.ai/v1/agents/me/tasks/next?wait=20" \
-H "Authorization: Bearer $CALLEE_AGENT_KEY"
# reply (or "input_required" with a message / "failed" with an error)
curl -X POST "https://api.forgebench.ai/v1/agents/me/tasks/$TASK_ID/result" \
-H "Authorization: Bearer $CALLEE_AGENT_KEY" \
-H "Content-Type: application/json" \
-d '{"state": "completed", "artifacts": [{"name": "response", "parts": [{"text": "done"}]}]}'A reply is only accepted while the task is working, and only from the callee that claimed it. If the caller cancels the task first, the reply is refused with 409 and isn't applied to the canceled task.
Push: a webhook instead of polling
Setting an endpoint_url on the callee switches delivery from pull to
push: Forgebench POSTs every task to that URL instead of the callee
polling for one. You trade "no inbound port" for "no polling loop to run". It's worth it when the callee already has an always-on HTTP service, such as a FastAPI app or a Lambda behind a URL.
Configure it from the callee's own Calls tab, or the same call via the API:

import os
import httpx
r = httpx.put(
f"https://api.forgebench.ai/v1/agents/{os.environ['CALLEE_ID']}/endpoint",
headers={"Authorization": f"Bearer {os.environ['ADMIN_KEY']}"},
json={
"url": "https://callee.example.com/a2a",
"auth": {"type": "bearer", "secret": "s3cret"},
},
)
r.raise_for_status()
endpoint = r.json()const r = await fetch(
"https://api.forgebench.ai/v1/agents/" + process.env.CALLEE_ID + "/endpoint",
{
method: "PUT",
headers: {
Authorization: "Bearer " + process.env.ADMIN_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://callee.example.com/a2a",
auth: { type: "bearer", secret: "s3cret" },
}),
},
);
if (!r.ok) throw new Error("HTTP " + r.status + ": " + (await r.text()));
const endpoint = await r.json();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;
ObjectMapper json = new ObjectMapper();
String payload = json.writeValueAsString(Map.of(
"url", "https://callee.example.com/a2a",
"auth", Map.of("type", "bearer", "secret", "s3cret")));
HttpResponse<String> r = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI.create(
"https://api.forgebench.ai/v1/agents/" + System.getenv("CALLEE_ID") + "/endpoint"))
.header("Authorization", "Bearer " + System.getenv("ADMIN_KEY"))
.header("Content-Type", "application/json")
.PUT(HttpRequest.BodyPublishers.ofString(payload))
.build(),
HttpResponse.BodyHandlers.ofString());
if (r.statusCode() / 100 != 2) throw new IllegalStateException("HTTP " + r.statusCode() + ": " + r.body());
Map<?, ?> endpoint = json.readValue(r.body(), Map.class);curl -X PUT https://api.forgebench.ai/v1/agents/$CALLEE_ID/endpoint \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://callee.example.com/a2a",
"auth": {"type": "bearer", "secret": "s3cret"}
}'Notes on the endpoint config:
auth.secretis sealed in the vault and never returned by any API response or shown again in the console. Omitauthon a laterPUTto keep the current credential, or send"clear_auth": trueto drop it."url": nullreverts the agent to pull-only.- A private-range URL (RFC 1918, etc.) requires
"allow_private_network": trueplus admin privilege to set. Loopback and cloud-metadata addresses are always blocked, both when the URL is saved and on every dial after that — a hostname that gets re-pointed at a private address later doesn't get a free pass because it passed the check once.
Forgebench POSTs the task to the configured URL:
POST https://callee.example.com/a2a
Content-Type: application/json
X-Forgebench-Task: <task_id>
X-Parent-Call: <task's own call_id>
Authorization: Bearer s3cret # or your configured header
{"task_id": "...", "call_id": "...", "message": {"role": "user", "parts": [...]}}Your handler answers in the same shape a pull reply uses (TaskResultIn):
from fastapi import FastAPI, Request
app = FastAPI()
@app.post("/a2a")
async def handle_task(request: Request):
body = await request.json()
parts = body["message"]["parts"]
text = next((p["text"] for p in parts if "text" in p), "")
# pass this to parent_call_id on this agent's own governed calls
parent_call_id = body["call_id"]
return {
"state": "completed",
"artifacts": [{"name": "response", "parts": [{"text": f"handled: {text}"}]}],
}import express from "express";
const app = express();
app.use(express.json());
app.post("/a2a", (req, res) => {
const parts: Array<{ text?: string }> = req.body.message.parts;
const text = parts.find((p) => p.text !== undefined)?.text ?? "";
// pass this to parentCallId on this agent's own governed calls
const parentCallId: string = req.body.call_id;
res.json({
state: "completed",
artifacts: [{ name: "response", parts: [{ text: "handled: " + text }] }],
});
});
app.listen(8080);import java.util.List;
import java.util.Map;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class A2aController {
@PostMapping("/a2a")
@SuppressWarnings("unchecked")
public Map<String, Object> handleTask(@RequestBody Map<String, Object> body) {
Map<String, Object> message = (Map<String, Object>) body.get("message");
List<Map<String, Object>> parts = (List<Map<String, Object>>) message.get("parts");
String text = parts.stream().filter(p -> p.containsKey("text"))
.map(p -> (String) p.get("text")).findFirst().orElse("");
// pass this to parentCallId on this agent's own governed calls
String parentCallId = (String) body.get("call_id");
return Map.of(
"state", "completed",
"artifacts", List.of(Map.of("name", "response",
"parts", List.of(Map.of("text", "handled: " + text)))));
}
}The response body doesn't have to match TaskResultIn exactly — this is
what makes a plain webhook with no A2A awareness at all still work:
- a JSON object without a
state/artifactskey is wrapped as adataartifact holding the whole body - a non-JSON (plain text) response becomes a
textartifact {"state": "working"}tells Forgebench this callee will finish asynchronously; it later posts the real result toPOST /v1/agents/me/tasks/{id}/resultwith its own agent credential, the same route a pull callee uses to finish up
Lineage: the call tree
Every governed call returns call_id in its response body (and
X-Call-Id on the streaming path). Send X-Parent-Call: <call_id> on a
call that another call caused, and Forgebench stamps
parent_call_id/root_call_id on it. This is bookkeeping, not policy — no
gate reads it, and nothing is refused because of it.
A task participates in the same lineage:
- the caller's
parent_call_id/root_call_idare stamped on the task row when it's opened - the task's own
call_idbecomes the parent for every governed call the callee makes while working on it
Chain a few hops (agent A calls agent B, which calls a model and then agent C) and the whole workflow becomes one tree on the ledger. No orchestrator has to declare the relationship up front. One query gets its total cost:
SELECT sum(cost_usd) FROM metering_events WHERE root_call_id = :root;Pass parent_call_id (Python) / parentCallId (TypeScript, Java) to
agents.call(...) with the governed call that led to opening the task. A
Task object exposes its own call_id/parent_call_id/root_call_id so a
serve() handler can continue the chain downward.

