Open protocol draft for autonomous agent identity lifecycle, claim, and scoped service authorization.
SAL β Sovereign Agent Lifecycle Protocol
SAL (Sovereign Agent Lifecycle) is an identity-first protocol model for autonomous agents. It defines how agents are born, claimed, and authorized without requiring human presence at runtime.
Draft 0.1
Reference implementation is live on VibeBase. This page documents the protocol model and maps each concept to current implementation behavior.
1. Overview / Problem
OAuth and OIDC are strong standards, but they are designed around human-attended session flows. Autonomous agents need to bootstrap identity, request scoped access, and execute continuously without browser redirects or human-step credential choreography.
SAL treats agents as first-class principals from birth. Human governance still exists, but as a structured bond step, not a required runtime dependency.
2. Lifecycle
- Birth (orphan tier): agent generates an Ed25519 keypair and initializes identity.
- Challenge: agent requests a short-lived challenge nonce from identity or gateway.
- Claim: human bonds to the agent via signed challenge + claim URL handoff.
- Token exchange: agent signs challenge and exchanges for short-lived identity JWT.
- Gateway service access: agent JWT is exchanged for scoped service tokens.
Implementation terminology uses tier (for example orphan, claimed) and
separate suspension/status flags. Some SAL writeups use βstateβ as an alias.
3. Identity Claims
SAL papers often describe extended sal_* claims. The current VibeBase reference JWT payload envelope is:
{
"success": true,
"data": {
"sub": "676328ca-c46f-4d7f-a994-edbc8b4141af",
"tier": "claimed",
"humanId": "user_abc123",
"type": "agent",
"iat": 1714396800,
"exp": 1714400400,
"jti": "7f1779f1-..."
}
} Claim URL lifecycle fields are first-class protocol metadata and returned at birth/link time, not inside the JWT payload itself:
{
"success": true,
"data": {
"id": "676328ca-c46f-4d7f-a994-edbc8b4141af",
"tier": "orphan",
"claimUrl": "https://console.vibebase.app/claim/<token>",
"claimUrlExpiresAt": "2026-05-02T12:35:38.999Z"
}
} 4. Endpoints
Minimum viable SAL interface as implemented in VibeBase today:
POST /v1/agent/initβ initialize orphan agent identityPOST /v1/challengeβ request challenge forclaimortokenPOST /v1/claimβ complete human claim bondPOST /v1/tokenβ exchange signed challenge for identity JWTPOST /v1/token/verifyβ verify identity JWT envelope/payloadGET /v1/.well-known/jwks.jsonβ public signing keys for offline JWT verificationPOST /v1/token(gateway) β exchange identity JWT for scoped service tokenPOST /integrations/:slug/provisionβ provision partner integration via broker
Agent init request/response:
POST https://identity.vibebase.app/v1/agent/init
{
"publicKey": "83a54e...a2d",
"name": "my-agent",
"metadata": {}
}
{
"success": true,
"data": {
"id": "676328ca-c46f-4d7f-a994-edbc8b4141af",
"publicKey": "83a54e...a2d",
"tier": "orphan",
"status": "active",
"createdAt": "2026-04-29T12:00:00Z",
"claimedAt": null,
"claimedBy": null,
"name": "my-agent",
"handle": null,
"metadata": {},
"servicePolicy": { "mail": true, "db": true, "pages": true },
"lostKey": false,
"replacedByAgentId": null,
"replacesAgentId": null,
"claimUrl": "https://console.vibebase.app/claim/<token>",
"claimUrlExpiresAt": "2026-05-02T12:35:38.999Z",
"claimNotificationSubscription": null
}
} Token exchange response:
{
"success": true,
"data": {
"token": "eyJhbGciOi...",
"expiresAt": "2026-05-02T12:34:56.000Z"
}
} Gateway service token response:
{
"success": true,
"data": {
"serviceToken": "vb_email_agent_...",
"endpoint": "https://email.vibebase.app",
"expiresIn": 3600,
"permissions": ["send"],
"quota": {
"tier": "claimed",
"remainingRequests": 9999,
"remainingStorage": "1024.0 MB"
}
}
} 5. Integrations Broker
Integrations provisioning is modeled as a SAL primitive: authenticate once with agent JWT, enforce policy, route through broker adapter, persist state, and emit audit events.
Agent
-> POST /integrations/:provider/provision
-> Verify JWT
-> Policy Check
-> ProviderBroker
-> Partner Adapter
-> Persist + Event Log Provision response shape:
{
"success": true,
"data": {
"status": "provisioned",
"integration": "agentmail",
"agent_id": "676328ca-c46f-4d7f-a994-edbc8b4141af",
"inbox_address": "agent_676328ca-c46f-4d7f-a994-edbc8b4141af@mail.vibebase.app",
"capabilities": ["email.send", "email.receive"],
"provisioned_at": "2026-05-02T12:00:00.000Z"
}
} 6. Comparison
| Capability | OAuth 2.1 | OIDC | Service Accounts | SAL |
|---|---|---|---|---|
| No human at runtime | No | No | Partial | Yes |
| Agent-generated identity | No | No | No | Yes |
| Orphan bootstrap mode | No | No | No | Yes |
| Short-lived scoped service exchange | Yes | Yes | Varies | Yes |
| Human governance preserved | Yes | Yes | Limited | Yes |
7. Principles
- P1 β Agents are principals, not sessions. Identity is key-bound and persistent.
- P2 β No static secrets. Challenge-sign flows replace long-lived credential sharing.
- P3 β Governance without constant human presence. Humans claim/manage policy; agents execute.
- P4 β Provenance first. Lifecycle, claim, and service issuance are auditable.
- P5 β Orphan is a feature. Safe autonomous bootstrap before human claim.
Testing Your Integration
The full sal-smoke-test.ts is the reference end-to-end integration sample for SAL identity flow:
keypair generation, init envelope handling, claim challenge, token challenge/JWT exchange,
gateway service token request, and claim URL extraction.
Important
The smoke test intentionally skips claim submission because claim requires a real signed-in console user. This is expected protocol behavior, not a platform gap.
#!/usr/bin/env bun
/**
* SAL Identity Flow Smoke Test
* Tests the full Sovereign Agent Lifecycle identity flow against identity.vibebase.app
*
* Usage: bun sal-smoke-test.ts
*/
import { webcrypto } from "crypto";
const BASE_URL = "https://identity.vibebase.app/v1";
const GATEWAY_URL = "https://gateway.vibebase.app/v1";
const AGENT_NAME = `smoke-test-${Date.now()}`;
// βββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const green = (s: string) => `\x1b[32mβ ${s}\x1b[0m`;
const red = (s: string) => `\x1b[31mβ ${s}\x1b[0m`;
const dim = (s: string) => `\x1b[2m ${s}\x1b[0m`;
const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
let passed = 0;
let failed = 0;
function pass(label: string, detail?: string) {
console.log(green(label));
if (detail) console.log(dim(detail));
passed++;
}
function fail(label: string, detail?: string) {
console.log(red(label));
if (detail) console.log(dim(detail));
failed++;
}
async function post(url: string, body: unknown, token?: string) {
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (token) headers["authorization"] = `Bearer ${token}`;
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
});
const text = await res.text();
let json: unknown;
try {
json = JSON.parse(text);
} catch {
json = { raw: text };
}
return { status: res.status, ok: res.ok, json };
}
// βββ Ed25519 Key Generation & Signing βββββββββββββββββββββββββββββββββββββββ
async function generateKeyPair() {
const keyPair = await webcrypto.subtle.generateKey(
{ name: "Ed25519" },
true,
["sign", "verify"],
);
return keyPair;
}
async function exportPublicKeyHex(key: CryptoKey): Promise<string> {
const raw = await webcrypto.subtle.exportKey("raw", key);
return Buffer.from(raw).toString("hex");
}
async function sign(privateKey: CryptoKey, message: string): Promise<string> {
const encoded = new TextEncoder().encode(message);
const signature = await webcrypto.subtle.sign(
{ name: "Ed25519" },
privateKey,
encoded,
);
return Buffer.from(signature).toString("hex");
}
// βββ Test Steps βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async function step1_generateKeys() {
console.log(bold("\nStep 1: Generate Ed25519 Keypair"));
try {
const keyPair = await generateKeyPair();
const publicKeyHex = await exportPublicKeyHex(keyPair.publicKey);
pass("Keypair generated", `pubkey: ${publicKeyHex.slice(0, 16)}...`);
return { keyPair, publicKeyHex };
} catch (e) {
fail("Keypair generation failed", String(e));
throw e;
}
}
async function step2_initAgent(publicKeyHex: string) {
console.log(bold("\nStep 2: Agent Init (Orphan Birth)"));
try {
const res = await post(`${BASE_URL}/agent/init`, {
publicKey: publicKeyHex,
name: AGENT_NAME,
metadata: {},
});
if (!res.ok) {
fail(
`POST /agent/init β ${res.status}`,
JSON.stringify(res.json, null, 2),
);
return null;
}
const data = res.json as any;
const agentId =
data?.agentId ?? data?.agent?.id ?? data?.data?.id ?? data?.id;
if (!agentId) {
fail("No agentId in response", JSON.stringify(data, null, 2));
return null;
}
pass(
`Agent created β ${agentId}`,
`state: ${data?.state ?? data?.data?.tier ?? "orphan"}`,
);
const claimUrl = data?.data?.claimUrl ?? data?.claimUrl;
if (claimUrl) pass(`Claim URL ready`, claimUrl);
return { agentId, data };
} catch (e) {
fail("Agent init request failed", String(e));
return null;
}
}
async function step3_claimChallenge(agentId: string) {
console.log(bold("\nStep 3: Get Claim Challenge"));
try {
const res = await post(`${BASE_URL}/challenge`, {
action: "claim",
agentId,
});
if (!res.ok) {
fail(
`POST /challenge (claim) β ${res.status}`,
JSON.stringify(res.json, null, 2),
);
return null;
}
const data = res.json as any;
const challenge = data?.challenge ?? data?.data?.challenge;
if (!challenge) {
fail("No challenge in response", JSON.stringify(data, null, 2));
return null;
}
pass("Claim challenge received", `challenge: ${challenge.slice(0, 32)}...`);
return challenge as string;
} catch (e) {
fail("Claim challenge request failed", String(e));
return null;
}
}
async function step4_submitClaim() {
console.log(bold("\nStep 4: Submit Claim (Bond Agent)"));
console.log(
dim("β³ SKIPPED β claim requires a real console user (Clerk sign-up)."),
);
console.log(
dim(
" This is correct behavior. Orphan agents proceed with constrained scope.",
),
);
}
async function step5_tokenChallenge(agentId: string) {
console.log(bold("\nStep 5: Get Token Challenge"));
try {
const res = await post(`${BASE_URL}/challenge`, {
action: "token",
agentId,
});
if (!res.ok) {
fail(
`POST /challenge (token) β ${res.status}`,
JSON.stringify(res.json, null, 2),
);
return null;
}
const data = res.json as any;
const challenge = data?.challenge ?? data?.data?.challenge;
if (!challenge) {
fail("No challenge in response", JSON.stringify(data, null, 2));
return null;
}
pass("Token challenge received", `challenge: ${challenge.slice(0, 32)}...`);
return challenge as string;
} catch (e) {
fail("Token challenge request failed", String(e));
return null;
}
}
async function step6_exchangeToken(
agentId: string,
challenge: string,
privateKey: CryptoKey,
) {
console.log(bold("\nStep 6: Exchange for Identity JWT"));
try {
const signature = await sign(privateKey, challenge);
const res = await post(`${BASE_URL}/token`, {
agentId,
signature,
challenge,
});
if (!res.ok) {
fail(`POST /token β ${res.status}`, JSON.stringify(res.json, null, 2));
return null;
}
const data = res.json as any;
const jwt =
data?.token ??
data?.jwt ??
data?.accessToken ??
data?.data?.token ??
data?.data?.jwt;
if (!jwt) {
fail("No JWT in response", JSON.stringify(data, null, 2));
return null;
}
pass("Identity JWT received", `token: ${jwt.slice(0, 32)}...`);
return jwt as string;
} catch (e) {
fail("Token exchange failed", String(e));
return null;
}
}
async function step7_gatewayToken(
agentId: string,
jwt: string,
privateKey: CryptoKey,
) {
console.log(bold("\nStep 7: Request Gateway Service Token"));
try {
// Get gateway challenge
const challengeRes = await post(
`${GATEWAY_URL}/challenge`,
{ action: "token", agentId },
jwt,
);
if (!challengeRes.ok) {
fail(
`POST gateway/challenge β ${challengeRes.status}`,
JSON.stringify(challengeRes.json, null, 2),
);
return false;
}
const challenge =
(challengeRes.json as any)?.challenge ??
(challengeRes.json as any)?.data?.challenge;
if (!challenge) {
fail(
"No gateway challenge in response",
JSON.stringify(challengeRes.json, null, 2),
);
return false;
}
pass("Gateway challenge received");
// Sign and request scoped token
const signature = await sign(privateKey, challenge);
const tokenRes = await post(
`${GATEWAY_URL}/token`,
{
service: "email",
action: "send",
scope: ["send"],
challenge,
signature,
agentId,
},
jwt,
);
if (!tokenRes.ok) {
fail(
`POST gateway/token β ${tokenRes.status}`,
JSON.stringify(tokenRes.json, null, 2),
);
return false;
}
pass("Gateway service token received", `service: email, scope: send`);
return true;
} catch (e) {
fail("Gateway token request failed", String(e));
return false;
}
}
// βββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async function main() {
console.log(bold("βββββββββββββββββββββββββββββββββββββββ"));
console.log(bold(" SAL Identity Flow Smoke Test"));
console.log(bold(" identity.vibebase.app"));
console.log(bold("βββββββββββββββββββββββββββββββββββββββ"));
console.log(dim(`Agent name: ${AGENT_NAME}`));
try {
// Step 1
const { keyPair, publicKeyHex } = await step1_generateKeys();
// Step 2
const initResult = await step2_initAgent(publicKeyHex);
if (!initResult) {
console.log(red("\nCannot continue β agent init failed."));
process.exit(1);
}
const { agentId } = initResult;
// Step 3 β get claim challenge (validates endpoint, not used in orphan flow)
await step3_claimChallenge(agentId);
// Step 4 β expected skip, requires real Clerk console user
await step4_submitClaim();
// Step 5
const tokenChallenge = await step5_tokenChallenge(agentId);
// Step 6
let jwt: string | null = null;
if (tokenChallenge) {
jwt = await step6_exchangeToken(
agentId,
tokenChallenge,
keyPair.privateKey,
);
}
// Step 7
if (jwt) {
await step7_gatewayToken(agentId, jwt, keyPair.privateKey);
} else {
console.log(dim("\nSkipping gateway test β no JWT available."));
}
} catch (e) {
console.log(red(`\nUnexpected error: ${String(e)}`));
}
// βββ Summary βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
console.log(bold("\nβββββββββββββββββββββββββββββββββββββββ"));
console.log(bold(" Results"));
console.log(bold("βββββββββββββββββββββββββββββββββββββββ"));
console.log(green(`Passed: ${passed}`));
if (failed > 0) console.log(red(`Failed: ${failed}`));
console.log(
bold(
failed === 0
? "\nπ’ SAL flow is healthy β safe to send the email."
: "\nπ΄ Some steps failed β review above before sending.",
),
);
}
main();