@treeship/a2a
Drop-in Treeship attestation for A2A (Agent2Agent) servers and clients.
A2A solves how agents communicate. It does not solve what proof exists that they did what they claimed. The @treeship/a2a package fills that gap. Every A2A task receipt, completion, and handoff becomes a signed Treeship artifact, and every outbound A2A artifact carries a receipt URL the receiving agent can verify.
A2A makes agents interoperable. Treeship makes that interoperability trustworthy and auditable.
Install
npm install @treeship/a2aThe package is framework-agnostic, it does not import any specific A2A SDK. You wire its hooks into whichever A2A server you already run.
What gets attested
| Phase | Artifact created |
|---|---|
| Task arrives | Intent, who sent it, which skill, A2A task and message IDs |
| Task completes | Receipt chained to the intent, elapsed time, status, artifact digest |
| Outbound artifact | treeship_artifact_id and treeship_receipt_url injected into metadata |
| Handoff to another A2A agent | Signed handoff, from-agent, to-agent, context |
AgentCard at /.well-known/agent.json | A treeship.dev/extensions/attestation/v1 extension publishing your ship ID |
Publish a Treeship-attested AgentCard
import { buildAgentCard } from '@treeship/a2a';
app.get('/.well-known/agent.json', (_req, res) => {
res.json(
buildAgentCard(
{
name: 'OpenClaw Research Agent',
version: '1.2.0',
url: 'https://openclaw.example/a2a',
capabilities: { streaming: true, pushNotifications: true },
skills: [
{ id: 'web-research', name: 'Web Research' },
],
},
{
ship_id: process.env.TREESHIP_SHIP_ID!,
verification_key: 'ed25519:abc123...',
},
),
);
});The output AgentCard contains the canonical Treeship extension:
{
"extensions": [
{
"uri": "treeship.dev/extensions/attestation/v1",
"required": false,
"params": {
"ship_id": "shp_4a9f2c1d",
"receipt_endpoint": "https://treeship.dev/receipt",
"verification_key": "ed25519:abc123..."
}
}
]
}verification_key appears only when you supplied one; receipt_endpoint defaults to https://treeship.dev/receipt.
Wrap your task handler
import { TreeshipA2AMiddleware } from '@treeship/a2a';
const treeship = new TreeshipA2AMiddleware({
shipId: process.env.TREESHIP_SHIP_ID!,
});
app.post('/a2a/tasks', async (req, res) => {
const { taskId, skill, from, messageId } = req.body;
await treeship.onTaskReceived({ taskId, skill, fromAgent: from, messageId });
const start = Date.now();
let status: 'completed' | 'failed' = 'completed';
let artifact;
try {
artifact = await runMyAgent(req.body);
} catch (e) {
status = 'failed';
throw e;
} finally {
const result = await treeship.onTaskCompleted({
taskId,
elapsedMs: Date.now() - start,
status,
artifactDigest: artifact ? TreeshipA2AMiddleware.digestArtifact(artifact) : undefined,
});
if (artifact) artifact = treeship.decorateArtifact(artifact, result);
}
res.json(artifact);
});The artifact your peer receives now carries a verifiable trail:
{
"artifactId": "research-output-001",
"parts": [{ "kind": "text", "text": "Research findings..." }],
"metadata": {
"treeship_artifact_id": "art_7f8e9d0a1b2c3d4e",
"treeship_receipt_url": "https://treeship.dev/receipt/art_7f8e9d0a1b2c3d4e",
"treeship_handoff_id": "art_2c3d4e5f6a7b8c9d",
"treeship_session_id": "ssn_01HR9W2D4Q4M7A0C",
"treeship_ship_id": "shp_4a9f2c1d"
}
}treeship_receipt_url is <receiptBaseUrl>/<receipt-artifact-id> — the path segment is the receipt artifact ID (art_…, the same value as treeship_artifact_id), not the session ID. The base defaults to https://treeship.dev/receipt and is configurable via the middleware's receiptBaseUrl option.
Only digests and metadata enter the artifact. Raw task content is never stored. The digest proves which data was involved without exposing the data itself.
Verify a peer before delegating
import { fetchAgentCard, hasTreeshipExtension, verifyArtifact } from '@treeship/a2a';
const card = await fetchAgentCard('https://partner-agent.example');
if (!hasTreeshipExtension(card)) {
throw new Error('Refusing to delegate: peer is not Treeship-attested');
}
// ... send your A2A task ... and when the artifact comes back:
const verification = await verifyArtifact(remoteArtifact.metadata);
if (!verification || !verification.withinDeclaredBounds) {
throw new Error('Peer artifact failed Treeship verification');
}verifyArtifact fetches the receipt from treeship_receipt_url and runs the structural WASM checks. Read the result precisely:
structurallyConsistent— the receipt's Merkle structure and inclusion proofs recompute (structural-pass). This does not establish who signed it.cryptographicallyVerified— true only if envelope signatures were actually verified; for a URL-fetched receipt this isfalse.withinDeclaredBounds—true/falseonly when the receipt is structurally consistent and carries a declaration;undefinedotherwise (never a default "true"). The!verification.withinDeclaredBoundsgate above therefore fails closed on undeclared or inconsistent receipts.
Gate foreign work before it runs
onTaskReceived throws for a task that carries fromAgent unless admitTask ran first. The gate is the one place attestation is allowed to break the agent path: failing to record work is no reason to refuse it, but foreign work that never proved who sent it is.
// 1. Mint the nonce this ship will require. Hand it to the calling agent.
const nonce = await treeship.mintTaskChallenge(taskId);
// 2. They answer it: treeship present agent://them --challenge <nonce>
// and send back a presentation file. Confine the path they name:
const presentationPath = resolveSenderPath(inboxDir, task.metadata.presentation_path);
// 3. Decide, BEFORE running anything.
const gate = await treeship.admitTask({ taskId, presentationPath, maxStapleAge: '1h' });
if (!gate.allowed) return refuse(gate.refusal, gate.message); // no_presentation | no_challenge | challenge_failed | untrusted_issuer | revoked | stale | verification_failed | gate_unavailable
// 4. Now the intent, and -- because the gate verified -- a receiver-signed
// handoff recording custody: live, bound to the presentation digest and
// the nonce this ship minted.
await treeship.onTaskReceived({ taskId, fromAgent: task.from, skill });Refusals are signed too (a2a.gate.refused names the failing step), so "refused the work" and "never received the work" are not the same silence. The opt-out TREESHIP_A2A_UNVERIFIED=1 executes but signs a2a.gate.skipped; a skipped gate never gets a live handoff.
onTaskCompleted returns handoffId when the gate verified, and decorateArtifact stamps it as treeship_handoff_id. treeship verify <handoffId> prints custody: live -- card …, verified by <this actor>; a handoff recorded any other way prints custody: asserted with the reason.
Record a handoff
await treeship.onHandoff({
toAgent: 'agent://openclaw',
taskId: 'a2a-task-7f8e9d',
context: 'Research phase delegated: find comparable Merkle MMR implementations',
messageId: 'msg_abc123',
});This is the same artifact treeship attest handoff produces from the CLI, it appears in the parent session's receipt as a delegation boundary. It records the sender's delegation and is custody: asserted; the receiver's live-verified handoff is the one onTaskReceived mints after admitTask.
Environment variables
| Variable | Effect |
|---|---|
TREESHIP_DISABLE=1 | Skips all attestation. Hooks return undefined. |
TREESHIP_SESSION_ID | Inherited from treeship session start; auto-included in payloads. |
TREESHIP_DEBUG=1 | Logs attestation failures to stderr. |
Design rules
- Treeship errors never fail the underlying A2A handler.
- Intent attestation is awaited so the proof exists before the agent runs.
- The middleware has zero runtime dependencies and is framework-agnostic.
- Handoffs and AgentCard extensions are opt-in but on by default.