Sign and verify manifests

A .agt manifest is trustworthy because three things agree: the wallet that signed it, the owner the document declares, and the wallet that owns the name on chain right now. This guide is the recipe — canonical form, signing from a server or a wallet, verification, CID checks — with real output from the SDK. Every value below was produced by @agtnames/resolver 1.0.2 with a public test key.

1. Build the document

Start from the v3 fields. agt must be a 3.x version, name ends in .agt, and owner is the wallet that holds the name. Leave signature out until the end.

unsigned manifestjson
{
  "agt": "3.0",
  "name": "exampleagent.agt",
  "owner": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
  "updated": "2026-09-12T00:00:00Z",
  "description": "Research and source citation agent.",
  "endpoints": [{ "protocol": "mcp", "url": "https://exampleagent.example/mcp", "version": "2025-11-05" }],
  "capabilities": [{ "id": "research" }],
  "keys": [], "payments": [], "registrations": []
}

2. Canonical form

Signatures cover bytes, so both sides must serialise identically. The canonical form is JCS-lite: object keys sorted at every level, no whitespace, signature removed, and empty values (undefined, "", []) dropped. canonicalUnsigned(manifest) produces it:

canonicalUnsigned(unsigned) — one line, sorted keysjson
{"agt":"3.0","capabilities":[{"id":"research"}],"description":"Research and source citation agent.","endpoints":[{"protocol":"mcp","url":"https://exampleagent.example/mcp","version":"2025-11-05"}],"keys":[],"name":"exampleagent.agt","owner":"0x70997970C51812dc3A010C7d01b50e0d17dc79C8","payments":[],"registrations":[],"updated":"2026-09-12T00:00:00Z"}

3. Sign (EIP-191)

The canonical string is signed as an Ethereum personal message (EIP-191 personal_sign), so any wallet can produce it and any client can recover the signer. Put the 65-byte hex signature in signature.

server or CIts
import { signManifest } from "@agtnames/resolver";

// A raw private key: servers, CI, agents that hold their own owner key.
const signed = signManifest(unsigned, process.env.OWNER_KEY);
signed.signature
// "0xf2e8ab9949fdf9a32526b47d5fc547b3918fadc36ab6430f3716b90fcd3cfd0e0fb7dc0fa6d8680a4667726866d7818da71d27abcc9530cd787897c3457dd56d1c"

The signature above is real: the hardhat test key 0x7099…79C8 signing the canonical string from step 2.

4. Verify — three ways at once

verifyManifest(manifest, onchainOwner) recovers the signer from the canonical string and the signature, then checks:

  1. agt matches 3.*;
  2. the recovered signer equals manifest.owner;
  3. manifest.owner equals the owner you read from the registry.
verify against the chaints
import { AgtResolver, verifyManifest } from "@agtnames/resolver";

// verifyManifest is pure — hand it the document and the owner you read from the chain.
const agt = new AgtResolver({ chain: "polygon" });
const { owner } = await agt.resolve("exampleagent.agt");
verifyManifest(signed, owner);
// { verified: true, signer: "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", reasons: [] }

// resolveAgent() does the fetch + this check for you and reports the same fields.

5. What each failure looks like

reasons[] you will actually seets
// real outputs from @agtnames/resolver 1.0.2

verifyManifest(signed, "0x37007a1c233f00b423bc0d177ac5b50ca9417596")   // signed by someone who is not the owner
// { verified: false, reasons: ["manifest.owner 0x7099…79C8 != on-chain owner 0x3700…7596"] }

verifyManifest({ ...signed, description: "Totally trustworthy agent." }, owner)   // one field edited after signing
// { verified: false, signer: "0x2b00…08c2", reasons: ["signer 0x2b00…08c2 != manifest.owner 0x7099…79C8"] }

verifyManifest(unsigned, owner)
// { verified: false, signer: null, reasons: ["unsigned"] }

verifyManifest({ ...signed, agt: "2.0" }, owner)
// { verified: false, reasons: ["unsupported agt version 2.0", "signer 0x243b…45f8 != manifest.owner 0x7099…79C8"] }
ReasonCauseWhat a client should do
no manifest setThe name has no manifest pointer on chain (the common case today).Show chain facts only; say no identity has been published yet.
unsignedsignature missing.Treat as unverified content.
signer … != manifest.owner …Content changed after signing, or signed by the wrong key.Reject: this is exactly the tampering the signature exists to catch.
manifest.owner … != on-chain owner …Name changed hands, or the document names the wrong owner.Unverified; the new owner must re-sign.
unsupported agt versionNot a v3 manifest.Unverified; expect an old-format document.
fetch / size / parse errorsUnreachable URI, over the size cap, or not JSON.Unverified with the fetch error; retry later or read the URI yourself.

resolveAgent() never throws for any of these — they land in reasons with verified: false. Only network and configuration errors throw.

6. Content addressing (optional, recommended)

Publish the manifest at an ipfs:// URI and the resolver also checks that the fetched bytes hash to the CID. Only raw sha2-256 CIDv1 (bafkrei…) is judged; anything else reports unsupported rather than failing.

rawCidV1 + verifyCid — real valuests
import { rawCidV1, verifyCid } from "@agtnames/resolver";

const bytes = new TextEncoder().encode(JSON.stringify(signed));
const cid = rawCidV1(bytes);                 // "bafkreihvpfrehece6corcn4pbjh3frmhqqnqvpfqws626mnzls7itrdlu4"
verifyCid(cid, bytes);                       // "match"
verifyCid(cid, new TextEncoder().encode("x")); // "mismatch"
verifyCid("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", bytes); // "unsupported" (CIDv0 / dag-pb — not judged)

In a resolution result this appears as cid: "match", "mismatch", "unsupported" or "not-ipfs". A mismatch is added to reasons.

7. Updating and rotating

  • Update the manifest: edit, bump updated, re-canonicalise, re-sign, publish the new document, and point the on-chain agentManifest record at the new URI. Old copies remain valid signatures of old content, so clients must always read the pointer from the chain rather than caching a URI.
  • The name changes hands: every existing manifest stops verifying (manifest.owner != on-chain owner) until the new owner signs one. This is deliberate.
  • Agent keys (agentKey(node, purpose), separate from the owner wallet) carry a version and a revoked flag on chain. Rotate by writing a new version; revoke by setting the flag — clients should refuse anything signed by a revoked key.

Reference