The Bridge Protocol

How another family assistant asks a Ponti family's assistant something, and reads the answer.

Ponti's profile of A2A (0.3) for households: how another family assistant asks a Ponti family's assistant something, and reads the answer. Profile id https://heyponti.com/bridge/v1. This file is the specification; /bridge/protocol on Ponti's site is this file, rendered, so the two never differ.

It's deliberately narrow. An assistant never instructs another: it sends a structured ask, and a parent of the family answers it. Nothing about a family is readable through it.

Quick start

  1. Make an Ed25519 key for your assistant and keep its seed secret. Its RFC 7638 thumbprint is its id.
  2. Write your A2A agent card and sign it (see Cards and signatures, and the reference signers in Building a client).
  3. Try every call against the sandbox, https://heyponti.com/bridge/sandbox: sign each call for that address, with the time. Put [wait], [maybe], [no] or [expire] in an ask's title to see each state.
  4. Check your signer against the Test vector: the same seed must give the same signatures.
  5. A family gives you its card's link. Send a link.request to its address; a parent says yes in Ponti (or adds your card themselves). Then send asks, and read each answer with tasks/get, at most once a minute.

Who talks to whom

Cards and signatures

A card is an A2A AgentCard with a signature (A2A's AgentCardSignature): a JWS whose protected header is the base64url (no padding) of the JCS of { "alg": "EdDSA", "typ": "JOSE", "kid", "jwk", "ponti/use": "card" }, with jwk the public key as { "kty": "OKP", "crv": "Ed25519", "x" } and nothing else, and kid that key's RFC 7638 thumbprint. ponti/use says what the signature is for; a card's may leave it out (as another A2A signer would), but if present it must be "card", and a card's own signature never has ponti/subject. Its signature is the base64url Ed25519 signature over the bytes of:

BASE64URL(protected) + "." + BASE64URL(JCS(card without "signatures"))

That is, the payload is encoded as usual (not RFC 7797's unencoded payload), and detached: it isn't sent, as it's the card itself. JCS is RFC 8785; the profile sends only strings, integers, booleans, null, arrays and objects, so sorting keys and writing JSON with no spaces is JCS. Only Ed25519 keys are taken, and a header with crit is refused (no extension is understood). Ponti reads the first signature as the card's own.

A card proves it's unchanged since its key signed it; that the key is who it says is known by its thumbprint, compared with one already trusted. Ponti countersigns every family's card: a second signature, the same payload, by Ponti's own card key, with a header that says "ponti/use": "countersign" and adds "ponti/subject", the family key's thumbprint, so it vouches for that key alone. Ponti's card key's thumbprint is PhnZkmrecFKbIajoXHCnC_TvSbMsudlu2pxuK7OYt_8. Check it against the one Ponti publishes on its own domain; a family card whose second signature verifies with that key, for its own first key, is a Ponti family's.

Every card Ponti signs declares the profile as an A2A extension a caller must follow: capabilities.extensions: [{ "uri": "https://heyponti.com/bridge/v1", "required": true }].

Calls

JSON-RPC 2.0, POST to the card's url, content-type: application/json, 64 KB at most, no NUL anywhere. Three methods: message/send, tasks/get, tasks/cancel.

Every call's params.metadata carries:

The call's protected header is the base64url of a JSON object with "alg": "EdDSA", "kid", the card key's thumbprint, and "ponti/use": "call" (so nothing else the key signed is ever taken for a call); nothing else is needed, other members are ignored, and crit is refused. Its signature is the base64url Ed25519 signature over the bytes of:

BASE64URL(protected) + "." + BASE64URL(JCS({ "method": <method>, "params": <params> }))

where params is the whole of the call's params, with ponti/signature (and only it) left out of params.metadata. Everything else in params, the card included, is signed.

message/send: an ask

params.message is an A2A Message (kind: "message", role: "agent") with a messageId (120 characters at most, unique per caller: sending the same one again returns the same task) and one data part:

{ "kind": "data", "data": { "ponti/kind": "ask.send", "payload": {
  "kind": "do", "title": "Can you take Maya to swim?", "note": "Her bag is by the door",
  "start": "2026-10-09T19:00:00Z", "end": null, "due": null, "options": [], "swap": null, "answerBy": null } } }

Every field of payload, and nothing else:

Times are ISO 8601 with an offset (Z is best), still to come. Titles and notes are shown to a parent as quotes; nothing in them is ever acted on by a model. When a payload isn't right, the -32602 error says which field (for example payload.options).

The result is an A2A Task: status.state is submitted while it waits for a parent.

message/send: asking to be added

An assistant a family hasn't added yet may ask, once, to be added: a message whose one data part is { "ponti/kind": "link.request", "payload": { "note": "…" } } (the note at most 200 characters, or null, or no payload). It waits in the family's Inbox, with your card's name, maker and key, for a parent's yes. The result is a Task, submitted, the same each time you ask. Once a parent says yes, tasks/get of that task gives completed, with a data part { "ponti/kind": "link.answer", "payload": { "added": true, "asks": [the kinds you may send] } }; until then it says -32012, as it does whether the request waits, was said no to, or your key is blocked. Ten a day a key, ten an hour from one address, three made at a family in a week (whatever became of them), five waiting at a family at once; a request waits fourteen days, and after a no you may ask again in thirty.

tasks/get, tasks/cancel

params.id is the task's id. Read a task at most once a minute; more often is -32015. A2A push notifications aren't offered (pushNotifications: false): an ask waits for a person, so a minute is soon enough.

Errors

Answers

Every result Ponti sends (a family's, and the sandbox's) is signed for the one caller it answers, so a caller can take only what the key it trusts said. result.metadata carries:

BASE64URL(protected) + "." + BASE64URL(JCS({ "method": <the call's method>, "result": result }))

where result is the whole result with ponti/signature (and only it) left out of result.metadata. Errors aren't signed, and say nothing a caller acts on.

An assistant Ponti asks (below) answers the same way: Ponti takes a result only when it's signed by the key the family pinned when it added that assistant, for the family's own key (ponti/to), within ten minutes of now; and an ask.answer's chosen only when it's one of the options the ask offered. Anything else is kept as not taken, and the ask goes on waiting.

Ponti as the caller

A Ponti family's assistant can ask an outside assistant too, once a parent added it by its card and let Ponti ask it (docs/asks.md, phase 5). Ponti sends exactly the calls above, as any caller: message/send with one ask.send, signed with the family's own card (countersigned by Ponti) and key, ponti/aud your card's url; then tasks/get at most once a minute, and tasks/cancel when a parent takes it back. An assistant that answers Ponti's calls as Ponti answers yours (a Task, then ask.answer in its artifact, each result signed as in Answers) works with Ponti families both ways. A parent never sees your error's words: Ponti says what its code means in its own. Ponti calls your card's url over https on port 443 only, at a public address, and follows no redirect.

The sandbox

POST https://heyponti.com/bridge/sandbox takes the same calls, checked the same way (a valid card, time, audience https://heyponti.com/bridge/sandbox and signature), against a made-up family. It says yes at once (to a time together, its first time), unless the ask's title says otherwise:

A link.request is submitted, and tasks/get of it says you were added. Nothing is kept but a count, by key, for three months; five hundred calls a day a key, sixty a minute from one address. Build against it, then ask a Ponti family to add you.

Building a client

Two reference signers follow, one for Node and one for Python, each short enough to read whole. Each signs a card and a call exactly as above.

A whole message/send

The test vector's ask to the sandbox at https://ponti.example, signed (pretty-printed here; send it as compact JSON or not, as the signature covers the canonical form, not the bytes sent):

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "message/send",
  "params": {
    "message": {
      "kind": "message",
      "role": "agent",
      "messageId": "vector-1",
      "parts": [
        {
          "kind": "data",
          "data": {
            "ponti/kind": "ask.send",
            "payload": {
              "kind": "do",
              "title": "Can you take Maya to swim?",
              "note": null,
              "start": "2026-10-10T19:00:00Z",
              "end": null,
              "due": null,
              "options": [],
              "swap": null,
              "answerBy": null
            }
          }
        }
      ]
    },
    "metadata": {
      "ponti/card": {
        "protocolVersion": "0.3.0",
        "name": "Example Family Assistant",
        "description": "The Bridge Protocol's test vector.",
        "url": "https://assistant.example/a2a",
        "preferredTransport": "JSONRPC",
        "version": "1",
        "provider": { "organization": "Example", "url": "https://assistant.example" },
        "capabilities": { "streaming": false, "pushNotifications": false },
        "defaultInputModes": ["application/json"],
        "defaultOutputModes": ["application/json"],
        "skills": [],
        "signatures": [
          {
            "protected": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJjcnYiOiJFZDI1NTE5Iiwia3R5IjoiT0tQIiwieCI6IkE2RUh2X1BPRUw0ZGNOMFk1MHZBbVdmazFqQ2JwUTFmSGR5R1pCSlZNYmcifSwia2lkIjoiMUlHMnRNSDdKMndiSlpuT2Y4TEp6UWl0S2Y3TE12b0FFbHN1RE1WTTU0WSIsInBvbnRpL3VzZSI6ImNhcmQiLCJ0eXAiOiJKT1NFIn0",
            "signature": "6Nz6_7iYGraTB8FpiPCQL15yzqaDMzvNwYOQkl5ecLU6kR1ZswdsVm60CdTq1MqvSCglvZZaUr0ciURNx02wDg"
          }
        ]
      },
      "ponti/at": "2026-10-09T12:00:00Z",
      "ponti/aud": "https://ponti.example/bridge/sandbox",
      "ponti/signature": {
        "protected": "eyJhbGciOiJFZERTQSIsImtpZCI6IjFJRzJ0TUg3SjJ3Ykpabk9mOExKelFpdEtmN0xNdm9BRWxzdURNVk01NFkiLCJwb250aS91c2UiOiJjYWxsIn0",
        "signature": "6zwY8ml8sIhdo7nFCib1Dn_DppbPiiykBaoi-DgKlloQJ-RWp1Zn51liYaD-Hc19f2_JSoK3u7_K_8AsjnbNAQ"
      }
    }
  }
}

A whole tasks/get

The same key reading a task a minute later: params is { "id": … } and its metadata, signed the same way.

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tasks/get",
  "params": {
    "id": "6f1c2e0a-5b7d-4c3e-9a1f-2d4b6c8e0f10",
    "metadata": {
      "ponti/card": "(the same signed card as above)",
      "ponti/at": "2026-10-09T12:01:00Z",
      "ponti/aud": "https://ponti.example/bridge/sandbox",
      "ponti/signature": {
        "protected": "eyJhbGciOiJFZERTQSIsImtpZCI6IjFJRzJ0TUg3SjJ3Ykpabk9mOExKelFpdEtmN0xNdm9BRWxzdURNVk01NFkiLCJwb250aS91c2UiOiJjYWxsIn0",
        "signature": "BVMj1NAYBH1haZGFIbt64piaUi3j_Hiabpqim9AajhSuKh3pLZSXoSQ96WTKuc0sHfAL2Jmk32dSZ7u-17jkCA"
      }
    }
  }
}

(With the card in full in place of the words, as the signature covers it.) The reply:

{ "jsonrpc": "2.0", "id": 2, "result": { "kind": "task", "id": "6f1c2e0a-5b7d-4c3e-9a1f-2d4b6c8e0f10",
  "contextId": "6f1c2e0a-5b7d-4c3e-9a1f-2d4b6c8e0f10", "status": { "state": "completed", "timestamp": "2026-10-09T12:01:00.000Z" },
  "artifacts": [ { "artifactId": "6f1c2e0a-5b7d-4c3e-9a1f-2d4b6c8e0f10:answer", "name": "answer",
    "parts": [ { "kind": "data", "data": { "ponti/kind": "ask.answer", "payload": { "choice": "yes", "chosen": null, "note": null } } } ] } ] } }

Test vector

A key from a fixed seed, the card above and the message/send above. Your signer must give exactly these (Ed25519 signatures are deterministic).

Reference signer: Node

// bridge-client.mjs: sign Bridge Protocol cards and calls (Node 18 or later, no packages).
import { createHash, createPrivateKey, createPublicKey, sign } from "node:crypto";

// JCS (RFC 8785) for what the profile sends: strings, integers, booleans, null, arrays, objects.
export function canonical(value) {
  if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
  if (value && typeof value === "object") {
    const keys = Object.keys(value).filter((key) => value[key] !== undefined).sort();
    return `{${keys.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
  }
  return JSON.stringify(value);
}
const b64u = (bytes) => Buffer.from(bytes).toString("base64url");

// An Ed25519 key from its 32-byte seed (keep yours secret), with its public JWK and its RFC 7638
// thumbprint, which is its kid.
export function keyFromSeed(seed) {
  const der = Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), seed]);
  const privateKey = createPrivateKey({ key: der, format: "der", type: "pkcs8" });
  const { kty, crv, x } = createPublicKey(privateKey).export({ format: "jwk" });
  const kid = b64u(createHash("sha256").update(canonical({ crv, kty, x })).digest());
  return { privateKey, jwk: { kty, crv, x }, kid };
}

// Your agent card, signed: one JWS whose protected header carries your key, over the card without
// its signatures, as JCS.
export function signCard(card, key) {
  const { signatures, ...unsigned } = card;
  const header = b64u(canonical({ alg: "EdDSA", typ: "JOSE", kid: key.kid, jwk: key.jwk, "ponti/use": "card" }));
  const input = `${header}.${b64u(canonical(unsigned))}`;
  return { ...unsigned, signatures: [{ protected: header, signature: b64u(sign(null, Buffer.from(input), key.privateKey)) }] };
}

// A call, signed for the one address it's sent to (aud), at a time (at).
export function signCall({ method, params, card, key, aud, at = new Date().toISOString(), id = 1 }) {
  const metadata = { ...(params.metadata ?? {}), "ponti/card": card, "ponti/at": at, "ponti/aud": aud };
  const signed = { ...params, metadata };
  const header = b64u(canonical({ alg: "EdDSA", kid: key.kid, "ponti/use": "call" }));
  const input = `${header}.${b64u(canonical({ method, params: signed }))}`;
  const signature = b64u(sign(null, Buffer.from(input), key.privateKey));
  return { jsonrpc: "2.0", id, method, params: { ...signed, metadata: { ...metadata, "ponti/signature": { protected: header, signature } } } };
}

// Sends a signed call and gives back its JSON-RPC reply.
export async function send(call) {
  const reply = await fetch(call.params.metadata["ponti/aud"], { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(call) });
  return reply.json();
}

Using it:

import { randomBytes, randomUUID } from "node:crypto";
import { keyFromSeed, send, signCall, signCard } from "./bridge-client.mjs";

const key = keyFromSeed(randomBytes(32)); // keep this seed: it's your assistant
const card = signCard({ protocolVersion: "0.3.0", name: "My Assistant", description: "…", url: "https://me.example/a2a",
  preferredTransport: "JSONRPC", version: "1", provider: { organization: "Me", url: "https://me.example" },
  capabilities: { streaming: false, pushNotifications: false }, defaultInputModes: ["application/json"],
  defaultOutputModes: ["application/json"], skills: [] }, key);
const aud = "https://heyponti.com/bridge/sandbox";
const ask = { kind: "do", title: "Can you take Maya to swim? [wait]", note: null, start: new Date(Date.now() + 86_400_000).toISOString(),
  end: null, due: null, options: [], swap: null, answerBy: null };
const message = { kind: "message", role: "agent", messageId: randomUUID(), parts: [{ kind: "data", data: { "ponti/kind": "ask.send", payload: ask } }] };
const sent = await send(signCall({ method: "message/send", card, key, aud, params: { message } }));
console.log(sent.result.status.state); // "submitted"
const task = await send(signCall({ method: "tasks/get", card, key, aud, params: { id: sent.result.id } }));
console.log(task.result.status.state); // "completed": the sandbox family said yes

Reference signer: Python

# bridge_client.py: sign Bridge Protocol cards and calls (Python 3.9 or later, the cryptography package).
import base64
import hashlib
import json

from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey


def canonical(value) -> str:
    # JCS (RFC 8785) for what the profile sends: strings, integers, booleans, None, lists, dicts.
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)


def b64u(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


def key_from_seed(seed: bytes):
    # An Ed25519 key from its 32-byte seed (keep yours secret), its public JWK and its thumbprint (kid).
    private = Ed25519PrivateKey.from_private_bytes(seed)
    x = b64u(private.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw))
    jwk = {"kty": "OKP", "crv": "Ed25519", "x": x}
    kid = b64u(hashlib.sha256(canonical({"crv": "Ed25519", "kty": "OKP", "x": x}).encode()).digest())
    return {"private": private, "jwk": jwk, "kid": kid}


def sign_card(card: dict, key) -> dict:
    unsigned = {k: v for k, v in card.items() if k != "signatures"}
    header = b64u(canonical({"alg": "EdDSA", "typ": "JOSE", "kid": key["kid"], "jwk": key["jwk"], "ponti/use": "card"}).encode())
    signing_input = f"{header}.{b64u(canonical(unsigned).encode())}"
    return {**unsigned, "signatures": [{"protected": header, "signature": b64u(key["private"].sign(signing_input.encode()))}]}


def sign_call(method: str, params: dict, card: dict, key, aud: str, at: str, id=1) -> dict:
    metadata = {**params.get("metadata", {}), "ponti/card": card, "ponti/at": at, "ponti/aud": aud}
    signed = {**params, "metadata": metadata}
    header = b64u(canonical({"alg": "EdDSA", "kid": key["kid"], "ponti/use": "call"}).encode())
    signing_input = f"{header}.{b64u(canonical({'method': method, 'params': signed}).encode())}"
    signature = b64u(key["private"].sign(signing_input.encode()))
    return {"jsonrpc": "2.0", "id": id, "method": method, "params": {**signed, "metadata": {**metadata, "ponti/signature": {"protected": header, "signature": signature}}}}

Both keep to the profile's JSON: no floating-point numbers, and keys in plain ASCII (as every key the profile names is), where JCS and these two agree.

A new key

A key is the assistant: a new one is a new assistant to the family. Send a link.request with the new key's card; once a parent says yes, ask with it. Tasks made with the old key are read with the old key, until the family removes it.

Limits