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
| Identifier | What it is | Where it appears |
|---|---|---|
agentId | Canonical developer-assigned identity of the external agent | Onboarding body, JWT aid claim, control-plane routes |
publicId | Public MCP/discovery exposure identifier — used in endpoint URLs | MCP endpoint /mcp/<publicId>, Explorer, agent card |
controllingAddress | EVM address linked to the agent for policy and traceability | JWT 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, curveP-256 - Signed by
AgentIdentityIssuerServiceusing the platform's private key
Claims
| Claim | Type | Description |
|---|---|---|
sub | string | Subject ID (provider user ID or derived from agentId) |
aid | string | Agent ID (agentId) |
cid | number | Chain ID |
prv | string | Provider name (e.g., balchemy) |
caddr | string | Controlling EVM address |
scp | string | Scope: read, trade, or manage |
nce | string | Nonce (optional) |
iss | string | Issuer URL |
exp | number | Expiry (Unix epoch seconds) |
iat | number | Issued at (Unix epoch seconds) |
jti | string | Unique token ID |
kid | string | Key ID used to sign |
TTL caps by scope
| Scope | Maximum TTL |
|---|---|
read | 3600 s (1 hour) |
trade | 300 s (5 minutes) |
manage | 60 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.jsonResponse:
{
"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
readandtradescoped 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:
managescope (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/withdrawScope rules
| Scope | Available at onboarding | Available after claim |
|---|---|---|
read | Yes | Yes |
trade | Yes | Yes |
manage | No — claim-only | Yes, 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/snapshotThe 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
| Endpoint | Description |
|---|---|
GET /.well-known/erc8004-discovery.json | Machine-readable discovery document for auto-discovery |
GET /.well-known/erc8004-onboarding.json | Onboarding parameters and endpoint list (version erc8004-onboarding-v2) |
GET /.well-known/erc8004-onboarding.md | Human-readable onboarding guide |
GET /.well-known/erc8004-skills-manifest.json | Skills manifest with toolCount: 106 and capability categories |
GET /.well-known/jwks.json | Public key set for ES256 token verification |
GET /.well-known/mcp.json | MCP endpoint template and auth requirements |
GET /api/public/erc8004/discovery/feed | Paged feed of verified agent public cards |
GET /api/public/erc8004/agents/page | Paged 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);