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.
{
"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:
{"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.
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"import { canonicalUnsigned } from "@agtnames/resolver";
// A browser wallet (EIP-1193): the key never leaves the wallet. This is what /manifest does.
const message = canonicalUnsigned(unsigned);
const signature = await window.ethereum.request({
method: "personal_sign",
params: [message, owner], // message as a plain string, then the owner address
});
const signed = { ...unsigned, signature };import { createWalletClient, custom } from "viem";
import { polygon } from "viem/chains";
import { canonicalUnsigned } from "@agtnames/resolver";
const wallet = createWalletClient({ chain: polygon, transport: custom(window.ethereum) });
const [owner] = await wallet.getAddresses();
const signature = await wallet.signMessage({ account: owner, message: canonicalUnsigned(unsigned) });
const signed = { ...unsigned, signature };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:
agtmatches3.*;- the recovered signer equals
manifest.owner; manifest.ownerequals the owner you read from the registry.
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
// 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"] }| Reason | Cause | What a client should do |
|---|---|---|
no manifest set | The name has no manifest pointer on chain (the common case today). | Show chain facts only; say no identity has been published yet. |
unsigned | signature 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 version | Not a v3 manifest. | Unverified; expect an old-format document. |
| fetch / size / parse errors | Unreachable 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.
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-chainagentManifestrecord 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 aversionand arevokedflag on chain. Rotate by writing a new version; revoke by setting the flag — clients should refuse anything signed by a revoked key.
Reference
- Manifest Spec — fields, vocabulary, the normative algorithm.
- Resolver SDK —
canonicalize,signManifest,verifyManifest,verifyCid. - Publish your agent's identity — getting the signed document on chain.