AY Developer docs

Bring your own key

Level 3. Generate an Ed25519 key, register only the public key, prove possession, then sign every request.

With level 3 the private key never leaves your system. AY registers the public key as #dev-1 in the agent's DID document and asks you to prove possession before the agent becomes active.

Before you begin

  • An OIDC token of the owner account. The acting person must have paired the AY app (with X-Act-As: the person acting for the business); otherwise registration, attestation and rotation answer 409 IDENTITY_NOT_REGISTERED.
  • Node.js 22 with @noble/curves and @scure/base, or an equivalent Ed25519 library.
  • A key store for the private key. A key that leaks cannot be recalled; it can only be rotated.

Generate the key

import { ed25519 } from '@noble/curves/ed25519';
import { base58 } from '@scure/base';

const priv = ed25519.utils.randomPrivateKey();          // 32 bytes, keep secret
const pub = ed25519.getPublicKey(priv);                  // 32 bytes
const multikey = 'z' + base58.encode(new Uint8Array([0xed, 0x01, ...pub]));
// multikey looks like z6Mk…

The Multikey form is the 0xed01 prefix plus the 32 public-key bytes, base58btc encoded, with a leading z. AY accepts values that match ^z[1-9A-HJ-NP-Za-km-z]{40,60}$ and decode to an Ed25519 key.

Register with the public key

curl -X POST https://api.aytwin.ai/api/v1/agents \
  -H "Authorization: Bearer $AY_OIDC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Enterprise Agent", "service_endpoint": "https://agent.example.com/a2a", "public_key_multibase": "z6Mk…"}'

The response has status: "pending_attestation", no credential, no API key, and no passport yet. It contains the challenge:

"attestation": {
  "challenge": "b64url-of-32-random-bytes",
  "expires_at": "2026-10-01T12:10:00.000Z",
  "dev_key": "did:webvh:…:agents:…#dev-1",
  "attest_url": "https://api.aytwin.ai/api/v1/agents/…/attest"
}

The challenge is valid for ten minutes. The key is already in the DID document under verificationMethod, authentication, and assertionMethod.

Prove possession

Decode the challenge from base64url, sign the raw bytes with the private key, and send the signature as base64url together with the challenge you received.

const challengeBytes = Buffer.from(attestation.challenge, 'base64url');
const signature = Buffer.from(ed25519.sign(challengeBytes, priv)).toString('base64url');
curl -X POST "$ATTEST_URL" \
  -H "Authorization: Bearer $AY_OIDC_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"challenge\": \"$CHALLENGE\", \"signature\": \"$SIGNATURE\"}"

On success the agent is active, key_custody is developer, and the response carries the Agent Passport with the dev_key claim.

StatusCodeMeaning
422attestation_invalidThe challenge does not match or the signature does not verify.
410attestation_expiredTen minutes passed. Register again.
409ATTESTATION_NOT_PENDINGThe agent is not waiting for a proof.

The web app performs this step for you if you register there and paste the signature.

Sign every request

From now on the agent authenticates with an RFC 9421 signature, tag ay-agent, keyid = the current dev_key. No bearer token is sent.

import { createSignature } from 'http-message-sig';
import { createHash, randomBytes } from 'node:crypto';

const body = new Uint8Array();                            // GET: empty body
const digest = `sha-256=:${createHash('sha256').update(body).digest('base64')}:`;
const created = Math.floor(Date.now() / 1000);

const fields = await createSignature(
  { kind: 'request', method: 'GET', targetUri: 'https://api.aytwin.ai/api/v1/agents/me', fields: [{ name: 'content-digest', value: digest }] },
  {
    label: 'sig1',
    components: ['@method', '@authority', '@path', 'content-digest'],
    parameters: { created, expires: created + 300, nonce: randomBytes(16).toString('base64url'), keyid: devKey, tag: 'ay-agent', alg: 'ed25519' },
    signer: { algorithm: 'ed25519', sign: (data) => ed25519.sign(data, priv) },
  },
);

const res = await fetch('https://api.aytwin.ai/api/v1/agents/me', {
  headers: { 'Content-Digest': digest, 'Signature-Input': fields.signatureInput, 'Signature': fields.signature },
});

The verifier consumes the nonce: sending the same signature twice returns 401 signature_invalid with reason replay. Make a fresh signature for every attempt, including retries. See Signed requests for the full profile and Test vectors to check your bytes.

What is different at level 3

  • No Zitadel credential exists for the agent, so POST /agents/{id}/credentials/rotate returns 409 AGENT_CREDENTIAL_NOT_ROTATABLE.
  • The Agent Card advertises httpSignature and is unsigned by AY. Host your own signed card if your integration needs one.
  • The agent can rotate its own key without the owner: see Rotate keys and credentials.