JSON-RPC 2.0 · ES256 signed · multi-turn · zero-deps

Integreer de A2A v1.0 agent.

Alles wat u nodig heeft om de Co·Legal Public Assistant aan te roepen vanuit uw eigen agent, script of integratie. Volledige Linux Foundation A2A v1.0 compliance: JSON-RPC 2.0 over HTTPS, signed AgentCard (ES256 detached JWS over JCS), multi-turn via taskId, google.rpc.Status error details.

Discovery

Begin bij de well-known paden.

Een spec-conforme A2A client probeert eerst /.well-known/agent-card.json. Onze card is ES256-gesigneerd; verifieer de signature tegen de publieke sleutel uit JWKS voordat u actie onderneemt op basis van de inhoud.

AgentCard ophalen

curl https://agent.co-legal.be/.well-known/agent-card.json | jq .

JWKS ophalen

curl https://agent.co-legal.be/.well-known/jwks.json | jq .

# Verificatie: zie sectie "Signature verificatie" onderaan.

Alle well-known paden:

Endpoint

POST /a2a/jsonrpc

Eén endpoint voor alle methodes — JSON-RPC 2.0 envelope. Geen URL-routing per method, geen Bearer/OAuth (alleen optionele x-api-key header). Returnt altijd HTTP 200 met een JSON-RPC envelope; HTTP 429 alleen bij rate-limiting.

Methodes

MethodWat het doetAuth
message/send Stuur een vraag (nieuw of voortzetting via taskId) Optioneel
tasks/get Haal volledige history van één task op Optioneel (eigenaar-scoped)
tasks/list Lijst recente tasks (gescoped op uw sleutel) Optioneel
tasks/cancel Flip een task naar CANCELLED Optioneel (eigenaar-scoped)
tasks/pushNotificationConfig/* Niet ondersteund — return -32003
Publieke skills

Vier free-tier skills, anoniem aanroepbaar.

Skill-IDs zijn reverse-DNS en forever-stable. Volledige JSON-Schemas voor elk komen uit het /.well-known/agent-card.jsoncapabilities.extensions[0].params[skill_id].

Skill IDWatBronTier
be.legal.lookup Belgische wetsreferentie (code + optioneel article) → canonical Justel-URL. Codes: BW, WVV, WIB92, VCF, WBTW, WBE, Sw, Ger.W. ejustice.just.fgov.be (FOD Justitie) free / anoniem
be.legal.search Trefwoord → Justel zoek-URL + statute-hints (welke codex regelt het thema). Result-pagina's zijn JS-rendered, dus we returnen de live click-through URL. ejustice.just.fgov.be (FOD Justitie) free / anoniem
be.kbo.lookup 10-cijferig KBO/BCE-nummer → ondernemingsnaam, rechtsvorm, status, startdatum. kbopub.economie.fgov.be (FOD Economie) free / anoniem
be.vies.validate EU BTW-nummer (alle 27 lidstaten + XI Noord-Ierland) → geldigheids-status + handelsnaam + adres waar door de lidstaat blootgesteld. ec.europa.eu/taxation_customs/vies free / anoniem

capabilities.extendedAgentCard: true in de AgentCard advertiseert dat een authenticated GET /extendedAgentCard met x-api-key header een superset van skills levert. Vandaag is deze identiek aan de publieke card — placeholder voor toekomstige paid skills (entity-graph extraction, fiscal compute, DOCX clause-draft) die we via deze gate gaan blootstellen zonder de publieke card te wijzigen.

Twee aanroep-modi

Natuurlijke taal of directe skill-dispatch.

Beide modi gebruiken dezelfde endpoint (POST /a2a/jsonrpc) en methode (message/send). Het verschil zit in de part-kind: een tekst-part stuurt door naar de LLM; een data-part dispatcht direct naar de tool.

Path A · LLM-routing

Voor humans & generieke agents.

Stuur een natuurlijke vraag als kind:"text". De LLM ziet de AgentCard.skills, beslist autonoom welke tool relevant is, roept ze aan, en integreert het resultaat met bron-citatie in een sober antwoord.

parts: [
  { "kind": "text",
    "text": "Geef de Justel-link voor art. 4.71 BW" }
]
Path B · directe dispatch

Voor peer-agents & scripts.

Stuur een kind:"data" part met {tool, args}. De server bypast de LLM volledig, valideert de args tegen het JSON-Schema, dispatcht naar de handler, en retourneert text + structured data. Nul LLM-cost, nul hallucinatie, deterministisch.

parts: [
  { "kind": "data",
    "data": { "tool": "be.legal.lookup",
              "args": { "code": "BW",
                        "article": "4.71" } } }
]

Welke kies je? Path A voor verkenning / open-eind vragen / als je niet weet welke tool je nodig hebt. Path B als je weet wat je nodig hebt, voor latency-kritische integraties, of als je exact zeker wilt zijn over de output-shape (geen LLM-reformulering). Beide retourneren een spec-conforme A2A v1.0 message.

Snippet

Direct te kopiëren — kies uw taal.

# PATH A — natural language → LLM autonomously calls tools
curl -X POST https://agent.co-legal.be/a2a/jsonrpc \
  -H 'Content-Type: application/json' \
  -d '{
    "jsonrpc": "2.0", "id": 1, "method": "message/send",
    "params": { "message": { "parts": [
      {"kind": "text", "text": "Is BE0403170701 een geldig BTW-nummer?"}
    ]}}
  }'

# PATH B — direct skill dispatch (no LLM)
curl -X POST https://agent.co-legal.be/a2a/jsonrpc \
  -H 'Content-Type: application/json' \
  -d '{
    "jsonrpc": "2.0", "id": 2, "method": "message/send",
    "params": { "message": { "parts": [
      {"kind": "data",
       "data": { "tool": "be.vies.validate",
                 "args": { "vat": "BE0403170701" } }}
    ]}}
  }'

# Multi-turn — bewaar de taskId uit turn 1, stuur 'm terug
curl -X POST https://agent.co-legal.be/a2a/jsonrpc \
  -H 'Content-Type: application/json' \
  -d '{
    "jsonrpc": "2.0", "id": 3, "method": "message/send",
    "params": {
      "taskId": "8c1ad2…",
      "message": { "parts": [
        {"kind": "text", "text": "En voor BE0888778965?"}
      ]}
    }
  }'
import httpx

AGENT = "https://agent.co-legal.be"
HEADERS = {"Content-Type": "application/json"}
# Optional: HEADERS["x-api-key"] = "sk_agent_..."

def ask(text: str, task_id: str | None = None) -> tuple[str, str]:
    params = {"message": {"parts": [{"kind": "text", "text": text}]}}
    if task_id:
        params["taskId"] = task_id
    r = httpx.post(
        AGENT + "/a2a/jsonrpc",
        headers=HEADERS, timeout=30,
        json={
            "jsonrpc": "2.0", "id": 1,
            "method": "message/send", "params": params,
        },
    )
    r.raise_for_status()
    res = r.json()["result"]
    return res["parts"][0]["text"], res["taskId"]

# Single turn
answer, tid = ask("Wat is erfbelasting in Vlaanderen?")
print(answer)

# Multi-turn — vervolg op zelfde context
answer2, _ = ask("En voor schenkingen?", task_id=tid)
print(answer2)
const AGENT = "https://agent.co-legal.be";
let taskId: string | null = null;

async function ask(text: string): Promise<string> {
  const params: Record<string, unknown> = {
    message: { parts: [{ kind: "text", text }] },
  };
  if (taskId) params.taskId = taskId;

  const r = await fetch(`${AGENT}/a2a/jsonrpc`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      jsonrpc: "2.0", id: Date.now(),
      method: "message/send", params,
    }),
  });
  const json = await r.json();
  if (json.error) throw new Error(json.error.message);
  taskId = json.result.taskId;
  return json.result.parts[0].text;
}

// Multi-turn werkt automatisch — taskId is module-state
console.log(await ask("Wat is BTW?"));
console.log(await ask("En voor verzekeringen?"));
Multi-turn semantics

Hoe een task echt werkt.

Een task is een sessie. De eerste message/send zonder taskId maakt 'm aan; de server stuurt een verse taskId mee in result.taskId. Elke vervolg-call met diezelfde taskId rehydrateert de OpenAI- context server-side via previous_response_id — u stuurt nooit de history mee.

Limieten op een task: 50 berichten totaal, 24 u idle-TTL (na 24 u zonder activiteit verloopt de OpenAI-context, krijgt u CONTEXT_EXPIRED en moet u een nieuwe task starten). Tasks zijn per eigenaar afgeschermd: een anonieme taskId is gebonden aan de hashed IP-bucket; een keyed taskId aan de x-api-key.

Een task kan in deze statussen leven: SUBMITTEDWORKINGCOMPLETED (niet-terminaal — een COMPLETED task kan een vervolg-message krijgen en wordt dan weer WORKING) → FAILED of CANCELLED (terminaal).

Authenticatie

Optionele API-sleutel voor hogere quota.

Anonieme calls hebben een tight rate limit (60 vragen/uur per IP, burst 100) — bewust krap zodat de agent niet als gratis API-rate-onbeperkt cron-target dient.

Voor productie-integraties geven we API-sleutels uit. Mail ops@co-legal.be met uw use-case; we minten een sleutel en delen 'm via een beveiligd kanaal.

Anoniem

geen header

60 vragen/uur per IP · burst 100

X-RateLimit-Tier: anon

Met sleutel

x-api-key: sk_agent_<48hex>

200 vragen/uur per sleutel · burst 100

X-RateLimit-Tier: keyed

Elke 200-respons bevat:

Bij 429: Retry-After header (seconden) + JSON-RPC error -32029.

Errors

JSON-RPC + google.rpc.Status details.

Alle errors volgen de A2A v1.0 conventie: standaard JSON-RPC error-code plus een google.rpc.Status detail-blok in error.data dat een aggregatie-aware monitor kan parsen.

JSON-RPC codeReasonWanneer
-32700INVALID_ARGUMENTBody is geen geldige JSON
-32600INVALID_ARGUMENTEnvelope mist jsonrpc:"2.0"
-32601UNIMPLEMENTEDOnbekende method (bv. tikfout)
-32602INVALID_ARGUMENTParams verkeerde shape / missing field
-32603INTERNALLLM-fout of unexpected
-32603CONTEXT_EXPIREDtaskId is verlopen — start een nieuwe
-32001TASK_NOT_FOUNDtaskId onbekend of niet van u
-32002TASK_NOT_CANCELABLEtasks/cancel op terminale task
-32003UNSUPPORTED_FEATUREpushNotificationConfig/*
-32029RATE_LIMIT_EXCEEDEDHTTP 429, zie Retry-After

Voorbeeld error body

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32001,
    "message": "Task 'unknown-task-id' not found",
    "data": {
      "code": 5,
      "message": "Task 'unknown-task-id' not found",
      "details": [{
        "@type": "type.googleapis.com/google.rpc.ErrorInfo",
        "reason": "TASK_NOT_FOUND",
        "domain": "a2a",
        "metadata": {}
      }]
    }
  }
}
Signature verificatie

Vertrouw, maar verifieer.

De AgentCard wordt gesigneerd met ES256 detached JWS (RFC 7515) over JCS-canonical bytes (RFC 8785). Verificatie-recept:

  1. Fetch /.well-known/agent-card.json
  2. Fetch /.well-known/jwks.json
  3. Strip de signatures array uit de card
  4. JCS-canonicaliseer de rest (sorted keys, no whitespace, UTF-8)
  5. Reconstrueer de signing input: protected_b64 + "." + base64url(canonical)
  6. Decode protected header → check alg=ES256, kid matcht JWKS
  7. Verifieer de raw-r||s signature tegen de public key uit JWKS

Onze eigen Python-implementatie (~80 regels) is open-source in backend/services/a2a_signer.py op (implementatie op aanvraag, ops@co-legal.be) — vrij te kopiëren voor uw eigen verifier.