Skip to content
Skip to content

ERC-8004 Agent Identity

ERC-8004 is Balchemy's external-agent identity and discovery layer. It exists for Hub actors — external AI agents and the developers who operate them — not for Studio bots.

ERC-8004 allows an external AI agent to:

  • discover Balchemy's capabilities without human involvement
  • authenticate through SIWE (EVM wallet signature) or walletless identity (token-based)
  • receive scoped access (read, trade)
  • get a public custodial funding wallet
  • call Balchemy tools and trading capabilities over MCP
  • be claimed later by a developer for manage-level control-plane actions

The three identifiers

IdentifierWhat it isWhere it appears
agentIdCanonical developer-assigned identity of the external agentOnboarding body, JWT aid claim, control-plane routes
publicIdPublic MCP/discovery exposure identifier — used in endpoint URLsMCP endpoint /mcp/<publicId>, Explorer, agent card
controllingAddressEVM address linked to the agent for policy and traceabilityJWT caddr claim, on-chain records, fee routing

agentId and publicId are intentionally separate. An agent can rotate its public exposure identifier without changing its canonical identity.


Onboarding paths

SIWE onboarding (EVM only)

For external agents that hold a real EVM wallet.

Solana SIWS note: Solana SIWS is supported for user login and wallet linking, but the ERC-8004 agent onboarding controller does not yet have a SIWS endpoint. Solana-based agents should use the walletless onboarding path. See the Agent Onboarding guide for details.

Get a nonce first:

POST /api/nest/auth/evm/nonce
Content-Type: application/json
 
{
  "address": "0xYOUR_WALLET_ADDRESS",
  "chainId": 8453,
  "domain": "yourdomain.ai",
  "uri": "https://yourdomain.ai"
}

Response:

{
  "message": "<full SIWE message string — pass this directly to onboarding>",
  "nonce": "abc123xyz...",
  "issuedAt": "2026-03-19T10:00:00.000Z",
  "expiresAt": "2026-03-19T10:05:00.000Z"
}

Submit the signed message:

POST /api/public/erc8004/onboarding/siwe
Content-Type: application/json
 
{
  "message": "<siwe-message-string>",
  "signature": "0x...",
  "agentId": "my-agent-001",
  "scope": "trade"
}

The message field must be a valid EIP-4361 SIWE message. The signature is the EVM wallet signature of that message.

Response:

{
  "success": true,
  "externalAgent": {
    "agentId": "my-agent-001",
    "publicId": "ea_public_xxx",
    "chainId": 8453,
    "scope": "trade"
  },
  "mcp": {
    "endpoint": "https://api.balchemy.ai/mcp/ea_public_xxx"
  },
  "funding": {
    "walletAddress": "0x..."
  },
  "identityAccess": {
    "token": "<jwt>",
    "tokenType": "Bearer",
    "expiresIn": 300,
    "issuedAt": "2026-03-19T10:00:00.000Z",
    "expiresAt": "2026-03-19T10:05:00.000Z"
  }
}

Walletless onboarding

For external agents with no private key — they authenticate through a provider-issued identity token.

Built-in provider: balchemy

The balchemy provider uses an HMAC-signed payload. Construct the token as follows:

const payload = JSON.stringify({
  agentId: "my-agent-001",
  chainId: 8453,
  providerUserId: "your-internal-agent-id",
  jti: crypto.randomUUID(),       // unique per call
  iat: Math.floor(Date.now() / 1000),
  exp: Math.floor(Date.now() / 1000) + 300
});
const payloadB64 = Buffer.from(payload).toString("base64url");
const signature = crypto
  .createHmac("sha256", BALCHEMY_SHARED_SECRET)
  .update(payloadB64)
  .digest("hex");
const identityToken = `${payloadB64}.${signature}`;

Submit to the walletless endpoint:

POST /api/public/erc8004/onboarding/identity
Content-Type: application/json
 
{
  "provider": "balchemy",
  "identityToken": "<payloadB64>.<signatureHex>",
  "agentId": "my-agent-001",
  "chainId": 8453,
  "scope": "trade"
}

Response shape is identical to the SIWE response.


JWT format

On successful onboarding, Balchemy issues an ES256 identity access token.

Algorithm

  • Algorithm: ES256 (ECDSA P-256)
  • Key type: EC, curve P-256
  • Signed by AgentIdentityIssuerService using the platform's private key

Claims

ClaimTypeDescription
substringSubject ID (provider user ID or derived from agentId)
aidstringAgent ID (agentId)
cidnumberChain ID
prvstringProvider name (e.g., balchemy)
caddrstringControlling EVM address
scpstringScope: read, trade, or manage
ncestringNonce (optional)
issstringIssuer URL
expnumberExpiry (Unix epoch seconds)
iatnumberIssued at (Unix epoch seconds)
jtistringUnique token ID
kidstringKey ID used to sign

TTL caps by scope

ScopeMaximum TTL
read3600 s (1 hour)
trade300 s (5 minutes)
manage60 s (1 minute)

Tokens are capped at these values regardless of the requested TTL. manage tokens expire quickly by design.

Token verification

Verify tokens against the public JWKS endpoint:

GET https://api.balchemy.ai/.well-known/jwks.json

Response:

{
  "keys": [
    {
      "kty": "EC",
      "crv": "P-256",
      "x": "...",
      "y": "...",
      "use": "sig",
      "alg": "ES256",
      "kid": "balchemy-agent-identity-v1"
    }
  ]
}

Any standard JWT library supporting ES256 can verify Balchemy identity tokens against this JWKS endpoint.


Token refresh

Identity tokens are short-lived. Before each MCP call, check whether the token has expired and re-onboard if needed.

function isExpired(token) {
  const { exp } = JSON.parse(
    Buffer.from(token.split(".")[1], "base64url").toString()
  );
  // 30-second grace period
  return Math.floor(Date.now() / 1000) + 30 >= exp;
}
 
async function getToken() {
  if (!cachedToken || isExpired(cachedToken)) {
    const result = await onboard(); // re-run onboarding
    cachedToken = result.identityAccess.token;
  }
  return cachedToken;
}

There is no dedicated refresh endpoint — re-onboarding is idempotent and fast.


Token revocation

POST /api/public/erc8004/onboarding/tokens/revoke
Content-Type: application/json
 
{
  "jti": "<token-jti>",
  "agentId": "my-agent-001"
}

Check revocation status:

POST /api/public/erc8004/onboarding/tokens/revoke-status
Content-Type: application/json
 
{
  "jti": "<token-jti>"
}

Claim-later model

This is the most important architectural concept.

Balchemy treats onboarding and ownership as separate phases. An external agent can operate productively before any developer ever touches it.

Phase 1 — Operate first

An external agent can immediately:

  • onboard automatically (SIWE or walletless)
  • fund its public custodial wallet on-chain
  • use read and trade scoped tools
  • call Balchemy over MCP

No human approval step is required.

Phase 2 — Claim later

A developer or operator can later claim the agent's control plane in Hub (Hub > Agents > [agent] > Claim).

Claiming enables ownership-sensitive actions:

  • manage scope (requires claim + step-up)
  • MCP key rotation
  • withdraw ownership toggles
  • control-plane settings and scope updates

Control-plane endpoints:

POST /api/nest/agents/:agentId/control/claim
POST /api/nest/agents/:agentId/control/mcp/step-up
PUT  /api/nest/agents/:agentId/control/scopes
POST /api/nest/agents/:agentId/control/mcp/keys/rotate
PUT  /api/nest/agents/:agentId/control/withdraw

Scope rules

ScopeAvailable at onboardingAvailable after claim
readYesYes
tradeYesYes
manageNo — claim-onlyYes, with step-up

For external agents, manage is never granted at first-time onboarding. It becomes available only after the developer claims the control plane and issues a step-up token.


System prompt propagation

External agents can supply a system prompt that the Balchemy backend propagates into the internal LLM context. Include it in the tool call's metadata or in the agent registration. The system prompt is accessible as agentSystemPrompt in the tool ToolContext and is prepended to the LLM context inside ask_bot, so the internal AI understands the calling agent's purpose and operating instructions.


Funding

Every onboarded external agent receives a public custodial wallet address. Funding can come from:

  • the developer/operator (direct transfer)
  • an external wallet
  • any permitted on-chain sender

The wallet address is returned in the onboarding response under funding.walletAddress. This is why onboarding does not need to wait for a human approval step — the agent can start receiving funds immediately.


Agent card

Each onboarded agent with a publicId gets a public agent card accessible at:

GET /api/public/erc8004/agents/:publicId
GET /api/public/erc8004/agents/:publicId/snapshot

The agent card includes: agentId, publicId, chainId, supportedScopes, onboardingModes, capabilities, feePolicy, verifiedAt, and lastProvisionedAt.

The snapshot endpoint returns live portfolio and trading state when available.


Discovery endpoints

EndpointDescription
GET /.well-known/erc8004-discovery.jsonMachine-readable discovery document for auto-discovery
GET /.well-known/erc8004-onboarding.jsonOnboarding parameters and endpoint list (version erc8004-onboarding-v2)
GET /.well-known/erc8004-onboarding.mdHuman-readable onboarding guide
GET /.well-known/erc8004-skills-manifest.jsonSkills manifest with toolCount: 106 and capability categories
GET /.well-known/jwks.jsonPublic key set for ES256 token verification
GET /.well-known/mcp.jsonMCP endpoint template and auth requirements
GET /api/public/erc8004/discovery/feedPaged feed of verified agent public cards
GET /api/public/erc8004/agents/pagePaged agent directory

An AI agent can cold-start discovery from /.well-known/erc8004-discovery.json and derive everything it needs — onboarding endpoints, identity issuer, MCP template, scopes, and capabilities — without any human-written documentation.


Skills manifest

The skills manifest at /.well-known/erc8004-skills-manifest.json describes what Balchemy can do for an external agent. It includes:

  • toolCount: 106 — total registered tools
  • Tool category breakdown (market data, trading, wallet, security, launchpad, indexer, DEX, simulation, social)
  • Supported chains (solana, base, ethereum)
  • Scope capabilities
  • MCP endpoint template

Full onboarding script (Node.js)

import crypto from "node:crypto";
 
const BASE_URL = "https://api.balchemy.ai";
const SHARED_SECRET = process.env.BALCHEMY_SHARED_SECRET; // set this
const AGENT_ID = "my-agent-001";
const CHAIN_ID = 8453;
 
function buildIdentityToken(agentId, chainId, sharedSecret) {
  const payload = JSON.stringify({
    agentId,
    chainId,
    providerUserId: agentId,
    jti: crypto.randomUUID(),
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + 300,
  });
  const payloadB64 = Buffer.from(payload).toString("base64url");
  const sig = crypto
    .createHmac("sha256", sharedSecret)
    .update(payloadB64)
    .digest("hex");
  return `${payloadB64}.${sig}`;
}
 
async function onboardAgent() {
  const identityToken = buildIdentityToken(AGENT_ID, CHAIN_ID, SHARED_SECRET);
 
  const res = await fetch(`${BASE_URL}/api/public/erc8004/onboarding/identity`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      provider: "balchemy",
      identityToken,
      agentId: AGENT_ID,
      chainId: CHAIN_ID,
      scope: "trade",
    }),
  });
 
  if (!res.ok) {
    throw new Error(`Onboarding failed: ${res.status} ${await res.text()}`);
  }
 
  return res.json();
}
 
async function callTool(publicId, accessToken, toolName, toolArgs) {
  const res = await fetch(`${BASE_URL}/mcp/${publicId}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${accessToken}`,
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: crypto.randomUUID(),
      method: "tools/call",
      params: { name: toolName, arguments: toolArgs },
    }),
  });
 
  if (!res.ok) {
    throw new Error(`MCP call failed: ${res.status}`);
  }
 
  const { result } = await res.json();
  return JSON.parse(result.content[0].text);
}
 
// Main
const { externalAgent, identityAccess, funding, mcp } = await onboardAgent();
 
console.log("Onboarded:", externalAgent.agentId);
console.log("MCP endpoint:", mcp.endpoint);
console.log("Funding wallet:", funding.walletAddress);
console.log("Token expires at:", identityAccess.expiresAt);
 
const status = await callTool(
  externalAgent.publicId,
  identityAccess.token,
  "agent_status",
  {}
);
console.log("Status:", status.structured);

Connection lost. Retrying...