Developers · A2A
Agent to agent (A2A)
A2A (Agent2Agent) lets an AI agent call another agent with typed requests. Foral Data A2A gives your agents the Foral Data API as A2A skills: the same data, key and quota as the REST API, with no language model. Company agents on Foral speak A2A too (last section).
1. Foral Data A2A
Foral Data A2A is an A2A 1.0 agent over the Foral Data API. Your agent sends a typed request — a skill and its input — and gets the data back at once: public procurement (Portal BASE), municipal signals, municipalities, your alerts and the indicator catalogue. The answers are deterministic reads: no language model, no task to follow.
It is for Premium accounts and their agents. It is the same data, the same key and the same quota as the REST API and the Data MCP.
| What | Value |
|---|---|
| Agent card | https://data.foral.ai/.well-known/agent-card.json |
| Endpoint (JSON-RPC 2.0) | https://data.foral.ai/a2a |
| Protocol | A2A 1.0 — send A2A-Version: 1.0 on every request |
| Authentication | The Data API key (sk_live_…) or an OAuth access token |
2. Read the agent card
The card is public: no key is necessary. With A2A-Version: 1.0 (header or ?A2A-Version=1.0) you get the A2A 1.0 card. Without it you get the older 0.3 compatibility card.
The card lists every skill. The JSON Schema of each skill's input is in the Foral extension of the card (capabilities.extensions[0].params.inputSchemas).
Read the card
curl -s https://data.foral.ai/.well-known/agent-card.json \
-H "A2A-Version: 1.0"3. Authentication
Send Authorization: Bearer sk_live_… — the same key as the REST API. Create it on the API keys page. Keep it on your server — never in a browser page.
An OAuth access token also works: the token a client gets for the Data MCP (resource=https://data.foral.ai/mcp) is accepted on /a2a too. The card names the authorization server. You can disconnect an app at any time in Connected apps.
4. Call a skill
SendMessage takes one message with exactly ONE data part: {"skill": "<id>", "input": {…}}, with mediaType: application/json. Text, file and URL parts are refused with -32005.
The answer is a message with one data part: the same JSON as the REST endpoint of the skill. A list (the two lookups) comes as {"items": [...]}. No task is created.
messageId is yours (1–128 characters). It is not de-duplicated: every skill is a read, and each call counts as one request, like a repeated GET.
procurement_lookup_municipalities
curl -s https://data.foral.ai/a2a \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-H "A2A-Version: 1.0" \
-d '{
"jsonrpc": "2.0", "id": 1, "method": "SendMessage",
"params": {"message": {
"messageId": "msg-0001", "role": "ROLE_USER",
"parts": [{"data": {"skill": "procurement_lookup_municipalities",
"input": {"q": "Faro"}},
"mediaType": "application/json"}]
}}
}'Answer
{
"jsonrpc": "2.0", "id": 1,
"result": {"message": {
"role": "ROLE_AGENT", "messageId": "msg_…",
"parts": [{"data": {"items": [
{"municipality": "<code>", "slug": "faro", "name": "Faro",
"district_name": "Faro", "match": "exact", …}
]}, "mediaType": "application/json"}],
"metadata": {"skill": "procurement_lookup_municipalities",
"rest": "GET /v1/procurement/lookup/municipalities"}
}}
}5. Your skills (extended card)
GetExtendedAgentCard returns the card cut to the skills that your key can call: your plan's data domains and the key's scopes. A skill outside them is refused with -32004 and the error names the reason (DOMAIN_NOT_IN_PLAN or INSUFFICIENT_SCOPE).
Extended card
curl -s https://data.foral.ai/a2a \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-H "A2A-Version: 1.0" \
-d '{"jsonrpc": "2.0", "id": 2, "method": "GetExtendedAgentCard"}'6. Skills
The skills are the Data MCP tools, with the same names and the same input. Start with me: it returns your plan, domains, scopes and limits.
| Skill | Data domain |
|---|---|
me | — |
municipalities_search | indicators |
municipalities_get | indicators |
indicators_list | indicators |
indicators_series | indicators |
indicators_snapshot | indicators |
signals_list | signals |
signals_get | signals |
signal_cases_list | signals |
procurement_contracts | procurement |
procurement_aggregate | procurement |
procurement_entities | procurement |
procurement_entity_profile | procurement |
procurement_lookup_municipalities | procurement |
procurement_lookup_entities | procurement |
procurement_announcements | procurement |
procurement_amendments | procurement |
alert_rules | alerts |
alert_matches | alerts |
7. Python
A plain JSON-RPC client with httpx: find a municipality code by name, then aggregate its contracts by year.
foral_data_a2a.py
# pip install httpx
import uuid
import httpx
URL = "https://data.foral.ai/a2a"
HEADERS = {
"Authorization": "Bearer sk_live_...",
"A2A-Version": "1.0",
}
def skill(name: str, data: dict) -> dict:
body = {"jsonrpc": "2.0", "id": str(uuid.uuid4()), "method": "SendMessage",
"params": {"message": {
"messageId": str(uuid.uuid4()), "role": "ROLE_USER",
"parts": [{"data": {"skill": name, "input": data},
"mediaType": "application/json"}],
}}}
reply = httpx.post(URL, json=body, headers=HEADERS, timeout=60).json()
if "error" in reply:
raise RuntimeError(reply["error"])
return reply["result"]["message"]["parts"][0]["data"]
faro = skill("procurement_lookup_municipalities", {"q": "Faro"})["items"][0]
totals = skill("procurement_aggregate", {"executed_in": [faro["municipality"]],
"from": "2025-01-01", "group_by": ["year"]})
print(totals)8. Limits and quota
Same limits as the REST API. Every HTTP request counts against your per-minute rate limit. Each SendMessage to a data skill counts as one request against your monthly quota; me, the card and GetExtendedAgentCard do not use quota. A 429 carries Retry-After (seconds).
9. Troubleshooting
A refusal of the data (quota, plan, scope, not found, invalid filter) is a JSON-RPC error. Its data holds a google.rpc.ErrorInfo (the reason is the error code of the REST API) and the full problem document of the REST API.
| Answer | What it means |
|---|---|
401 | Missing, wrong, expired or revoked key or token. |
403 | The request came from a browser page on another site (Origin header), or your plan has no Data API access (api-access-required). |
-32009 | No A2A-Version: 1.0 header (an empty version means 0.3). |
-32005 | A text, file or URL part. Send one data part {skill, input}. |
-32602 | Unknown skill or invalid input — the error names the field. Also an invalid filter or cursor (VALIDATION_ERROR, INVALID_CURSOR). |
-32004 | Refused: QUOTA_EXCEEDED (monthly quota used), DOMAIN_NOT_IN_PLAN, INSUFFICIENT_SCOPE, NOT_FOUND, SERVICE_UNAVAILABLE. Also SendStreamingMessage (no streaming). |
-32001 | Task not found. This agent never creates tasks; ListTasks is always empty. |
429 | Rate limit. Wait Retry-After seconds. |
10. Company agents and Mercado
A company's Foral agent can also be published on the A2A channel. Another company's agent then talks to it with typed requests: check a service area, assess a project, research a public buyer.
Card: https://agents.foral.ai/<slug>/.well-known/agent-card.json. Endpoint: https://agents.foral.ai/<slug>/a2a. <slug> is the deployment slug shown in the company studio. The rules for A2A-Version: 1.0 and data parts are the same as above.
Authentication: an agent credential (sk_live_…) bound to ONE deployment and to the A2A channel. A company admin creates it in the studio (Deploy step). See Foral Agents.
Buyers on the Mercado: a licensed credential reaches the agent's read skills. Its card is the authenticated one (GetExtendedAgentCard).
Limits: 60 requests/minute per credential and 200/minute per deployment (defaults), shared with the agent's other machine channels.
Read a company card
curl -s https://agents.foral.ai/<slug>/.well-known/agent-card.json \
-H "A2A-Version: 1.0"assess_project_fit → task
curl -s https://agents.foral.ai/<slug>/a2a \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-H "A2A-Version: 1.0" \
-d '{
"jsonrpc": "2.0", "id": 2, "method": "SendMessage",
"params": {"message": {
"messageId": "msg-0002", "role": "ROLE_USER",
"parts": [{"data": {"skill": "assess_project_fit",
"input": {"project_type": "moradia", "municipality": "Lagos",
"area_m2": 180}},
"mediaType": "application/json"}]
}}
}'Read the task
curl -s https://agents.foral.ai/<slug>/a2a \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-H "A2A-Version: 1.0" \
-d '{"jsonrpc": "2.0", "id": 3, "method": "GetTask", "params": {"id": "<task id>"}}'| Skill | What it does | Answer |
|---|---|---|
check_service_area | Is a municipality inside the declared service area. | message |
assess_project_fit | Typed project facts in, a typed verdict with reasons out. | task |
procurement_research_buyer | One public buyer and a period: profile and contracts, with citations. | task |
procurement_compare_contracts | Municipalities on one dimension and year, or two NIFs side by side. | task |
procurement_track_opportunities | Open notices by district and CPV prefix, soonest closing first. | task |
prepare_action / commit_action | A consequential action: first a quote, then a separate commit. | task |
get_artifact | A released brief of this agent, as a reference. | message |
task is read with GetTask until its state is final. The card of each deployment is the source of truth: only the skills it lists are callable. A 404 means a wrong <slug>, a deployment that is not live, or a credential of another deployment.