A2A (Agent2Agent)
Another agent can find 50heads from its agent card, send it a question, and get back a task that completes with what the heads answered. It is the same account, sign-in, spend caps and tasks as the MCP server.
The agent card
https://mcp.50heads.com/.well-known/agent-card.json describes the agent: its skills, how to sign in and the endpoint, https://mcp.50heads.com/a2a (JSON-RPC 2.0 over HTTPS). It speaks A2A 1.0, and 0.3 for older peers: send A2A-Version: 0.3 for the 0.3 card, or read https://mcp.50heads.com/.well-known/agent.json. The endpoint takes both versions' method names (SendMessage and message/send, and so on) and answers in the shapes of the one used. Push notifications, streaming (SendStreamingMessage and SubscribeToTask, as Server-Sent Events) and the authenticated extended card (GetExtendedAgentCard) are supported. The same methods are also served over the HTTP+JSON binding at https://mcp.50heads.com/a2a/rest (for example POST /message:send and GET /tasks/{id}; 0.3 peers use the /v1 paths).
Signing in and scopes
The same credentials as the MCP server: an OAuth access token from https://auth.50heads.com (authorization code with PKCE, the device code flow, or client credentials for an organization's service account), or a 50heads API key as a bearer token. Tokens are for the resource https://mcp.50heads.com/mcp, which is the default when a client leaves the resource parameter out; any other resource is refused. A missing token is a 401 with a WWW-Authenticate challenge; a missing scope is a 403 that names it.
| Method | Scopes |
|---|---|
| SendMessage (estimate or ask), CancelTask | questions:write |
| SendStreamingMessage | questions:write |
| GetTask, ListTasks, SubscribeToTask | questions:read |
| CreateTaskPushNotificationConfig, DeleteTaskPushNotificationConfig | questions:read, webhooks:write |
| GetTaskPushNotificationConfig, ListTaskPushNotificationConfigs | questions:read, webhooks:read |
Prices show in the account's currency with account:read. All scopes: questions:read, questions:write, templates:read, account:read, webhooks:read, webhooks:write, apps:read, apps:write, organization:read, organization:write, members:read, members:write, audit:read, events:read.
Estimate first
estimate is the default skill and never spends. Send text, a goal such as “Which of these two names sounds like a bakery”, and 50heads drafts a question; or send a data part {"question": {...}} with the same question object as the MCP ask tool. The reply is a message, not a task: the price, the credits and any fixes, and the question to send back to ask. Show a person the price before you ask when one is present.
Ask
Set "skill": "ask" in the message metadata (or in the data part) and send the question object. The reply is a task whose id is the question id: the same task the MCP ask tool returns, so you can read it from either protocol or the REST API. The messageId is the idempotency key: send the same message again and you get the same task, charged once. A contextId you send is kept as the question's own reference; without one, the context is the task id.
{
"jsonrpc": "2.0",
"id": 1,
"method": "SendMessage",
"params": {
"message": {
"messageId": "6f1c0a52-8d1e-4f3b-9a51-0b8f1d2c7e44",
"role": "ROLE_USER",
"parts": [
{
"data": {
"skill": "ask",
"question": {
"type": "single_choice",
"text": "Which name sounds most like a bakery?",
"language": "en",
"options": [
{
"label": "Crumb & Co"
},
{
"label": "Steelworks"
}
],
"n": 50
}
},
"mediaType": "application/json"
}
]
},
"configuration": {
"returnImmediately": true
}
}
}{
"jsonrpc": "2.0",
"id": 1,
"result": {
"task": {
"id": "q_8k2f",
"contextId": "q_8k2f",
"status": {
"state": "TASK_STATE_WORKING",
"message": {
"messageId": "status-q_8k2f-working",
"role": "ROLE_AGENT",
"parts": [
{
"text": "0 of 50 answered"
}
]
},
"timestamp": "2026-10-13T10:00:00.000Z"
},
"metadata": {
"questionId": "q_8k2f",
"status": "live",
"answered": 0,
"heads": 50,
"creditsReserved": 1000,
"creditsSpent": 0
}
}
}
}A2A 1.0 waits by default until the task finishes or needs a person, for up to ten minutes, then answers with the task as it is. Set returnImmediately to get the task at once, then poll GetTask or set a push notification config. A2A 0.3 returns at once unless blocking is true.
Task states
| State | When |
|---|---|
TASK_STATE_SUBMITTED | Scheduled, not live yet. |
TASK_STATE_WORKING | Live: heads are answering. metadata.answered counts the answers so far. |
TASK_STATE_INPUT_REQUIRED | An organization's spend approval policy holds the ask until a person decides. |
TASK_STATE_COMPLETED | Complete, or closed with fewer answers than asked for (the rest is refunded). Carries the results. |
TASK_STATE_CANCELED | Canceled. The answers not given are refunded; the results so far are attached. |
TASK_STATE_REJECTED | Review refused the question, or an approver denied it, or nobody approved it in time. Nothing is spent. |
Results
A completed or canceled task carries one artifact, results: a data part with the results object (the same one the MCP results tool returns: how many chose each option, the leader and how strong its lead is, written reasons) and a text part summarizing it. Results never identify the people who answered.
Spend approvals
When an organization's policy holds an ask, the task is TASK_STATE_INPUT_REQUIRED. The credits are held and nothing goes to heads. metadata.approval.approveUrl is where an approver decides, in the 50heads app or on the web; a message to the task does not approve it. Approved, the task goes on working; denied or expired, it is rejected and the credits are released.
Cancel and list
CancelTask stops a task that has not finished and reports metadata.refundCredits; a finished task is TaskNotCancelableError. ListTasks lists the account's tasks, newest first, and filters by status, contextId and statusTimestampAfter; artifacts come only with includeArtifacts, 20 tasks a page. totalSize counts the tasks on the page.
Push notifications
Set a push notification config on a task (in SendMessage's configuration, or with CreateTaskPushNotificationConfig) and 50heads POSTs each change of state to your https URL: live, waiting for approval, approved, completed, canceled or rejected. Up to five configs on a task. Each push carries:
Authorization: <scheme> <credentials>when the config hasauthentication;X-A2A-Notification-Token, the config's token (made up and returned when you leave it out);webhook-id,webhook-timestampandwebhook-signature(Standard Webhooks), signed with the secretwhsec_+ base64(token).
{
"statusUpdate": {
"taskId": "q_8k2f",
"contextId": "q_8k2f",
"status": {
"state": "TASK_STATE_COMPLETED",
"message": {
"messageId": "msg_evt_3kd9",
"role": "ROLE_AGENT",
"parts": [
{
"text": "Complete. Call GetTask for the results."
}
]
},
"timestamp": "2026-10-13T10:12:00.000Z"
},
"metadata": {
"event": "question.completed",
"questionId": "q_8k2f",
"creditsSpent": 1000,
"refundCredits": 0
}
}
}A 0.3 config gets the task object instead. Then call GetTask for the results. Deliveries are retried for about a day with backoff, like webhooks. You can also manage configs with a key under /v1/a2a/tasks/{taskId}/push-configs (see the API reference).
import { createHmac, timingSafeEqual } from "node:crypto";
// Every push is signed with Standard Webhooks headers. The secret is "whsec_" + base64(token):
// the token you set on the push notification config (or the one 50heads made and returned).
export function verifyPush(rawBody: string, headers: Headers, token: string, toleranceSec = 300): boolean {
const id = headers.get("webhook-id");
const timestamp = headers.get("webhook-timestamp");
const signatures = headers.get("webhook-signature");
if (!id || !timestamp || !signatures) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSec) return false;
const expected = createHmac("sha256", Buffer.from(token, "utf8"))
.update(`${id}.${timestamp}.${rawBody}`)
.digest();
return signatures.split(" ").some((entry) => {
const [version, sig] = entry.split(",");
const given = Buffer.from(sig ?? "", "base64");
return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
});
}Errors
JSON-RPC errors with A2A's codes: -32001 task not found (including another account's), -32002 not cancelable, -32004 unsupported (a message to, or a subscription on, a finished task, or a streaming method sent without a stream), -32005 a file part (put image URLs in the question object), -32009 an A2A version other than 1.0 or 0.3, and -32602 invalid parameters. 50heads' own refusals (not enough credits, a spend cap, refused content, a pause) are -32000. error.data holds google.rpc.ErrorInfo objects whose reason names the case, such as SPEND_CAP_EXCEEDED, and google.rpc.BadRequest field violations.
Limits
1 MB request bodies, one request at a time (no batches). 60 requests a minute per connection and 10 asks a minute, shared with the MCP server.