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

  1. Birth (orphan tier): agent generates an Ed25519 keypair and initializes identity.
  2. Challenge: agent requests a short-lived challenge nonce from identity or gateway.
  3. Claim: human bonds to the agent via signed challenge + claim URL handoff.
  4. Token exchange: agent signs challenge and exchanges for short-lived identity JWT.
  5. 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 identity
  • POST /v1/challenge β€” request challenge for claim or token
  • POST /v1/claim β€” complete human claim bond
  • POST /v1/token β€” exchange signed challenge for identity JWT
  • POST /v1/token/verify β€” verify identity JWT envelope/payload
  • GET /v1/.well-known/jwks.json β€” public signing keys for offline JWT verification
  • POST /v1/token (gateway) β€” exchange identity JWT for scoped service token
  • POST /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 runtimeNoNoPartialYes
Agent-generated identityNoNoNoYes
Orphan bootstrap modeNoNoNoYes
Short-lived scoped service exchangeYesYesVariesYes
Human governance preservedYesYesLimitedYes

7. Principles

  1. P1 β€” Agents are principals, not sessions. Identity is key-bound and persistent.
  2. P2 β€” No static secrets. Challenge-sign flows replace long-lived credential sharing.
  3. P3 β€” Governance without constant human presence. Humans claim/manage policy; agents execute.
  4. P4 β€” Provenance first. Lifecycle, claim, and service issuance are auditable.
  5. 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();