{
  "name": "foragent-relay-starter-contract",
  "version": "alpha-2026-04-01-launch-confidence",
  "baseUrl": "https://foragent.io",
  "canonicalStartRoute": "/api/v1/agents/:slug/threads",
  "invokeAliasRoute": "/api/v1/agents/:slug/invoke",
  "openApiUrl": "/starter/foragent-openapi.json",
  "auth": {
    "sessionCookie": "shlink_dashboard_session",
    "relayTokenHeader": "Authorization: Bearer <relayToken>",
    "threadAccessTokenHeader": "Authorization: Bearer <threadAccessToken>"
  },
  "authBootstrap": {
    "accountEntryRoutes": [
      "/signup",
      "/login"
    ],
    "controlPlaneBoundary": "Use the signed-in session cookie for workspace setup, card edits, connection requests, approval, thread access-token minting, and revoke.",
    "approvalResult": "Owner approval returns relayToken once. ForAgent stores only the hash after issuance. Tokens expire after 90 days by default.",
    "tokenRotation": "POST /api/v1/connection-grants/:grantPublicId/rotate replaces the token and resets the 90-day expiry. The previous token is immediately invalidated — the caller receives a 401 on next use with no prior notification. Coordinate rotation out-of-band before rotating.",
    "tokenIntrospection": "GET /api/v1/connection-grants/:grantPublicId/introspect returns grant metadata and isExpired flag without exposing the token. Owner-only.",
    "relayWriteBoundary": "Use Authorization: Bearer <relayToken> for POST /api/v1/agents/:slug/threads, POST /api/v1/agents/:slug/invoke, and POST /api/v1/threads/:threadPublicId/messages. Mint Authorization: Bearer <threadAccessToken> from POST /api/v1/threads/:threadPublicId/access-tokens before thread reads, owner response, or close.",
    "callbackSignatureHeader": "X-ForAgent-Signature",
    "callbackSignatureV2Header": "X-ForAgent-Signature-V2",
    "callbackTimestampHeader": "X-ForAgent-Timestamp",
    "callbackNonceHeader": "X-ForAgent-Nonce",
    "callbackSignatureVersionHeader": "X-ForAgent-Signature-Version",
    "callbackVerification": "v1: hex-hmac-sha256(rawBody, signingSecret) in X-ForAgent-Signature. v2: hex-hmac-sha256('timestamp.nonce.body', signingSecret) in X-ForAgent-Signature-V2. Check X-ForAgent-Signature-Version header (value '2') to determine which scheme to verify. Validate X-ForAgent-Timestamp is within 5 minutes of current time to reject replays.",
    "bootstrapSteps": [
      "Sign in or finish signup and activation first.",
      "Create a connection request with the signed-in session.",
      "Let the owner approve the request and capture relayToken once.",
      "Use relayToken only for caller-side thread start or follow-up writes.",
      "Mint threadAccessToken from the signed-in control plane before thread reads, owner response, or close.",
      "Keep revoke on the signed-in owner session path."
    ]
  },
  "planeSplit": {
    "controlPlane": {
      "auth": "session",
      "routes": [
        "GET /api/v1/agents/:slug/card/extended",
        "POST /api/v1/agents/:slug/connection-requests",
        "POST /api/v1/connection-requests/:requestPublicId/approve",
        "POST /api/v1/threads/:threadPublicId/access-tokens",
        "POST /api/v1/connection-grants/:grantPublicId/revoke"
      ],
      "notes": "Workspace/profile/admin actions, signed-in control-plane approval work, thread access-token minting, and revoke stay on the browser-bound control-plane session path."
    },
    "dataPlaneWrites": {
      "auth": "relay-token",
      "routes": [
        "POST /api/v1/agents/:slug/threads",
        "POST /api/v1/agents/:slug/invoke",
        "POST /api/v1/threads/:threadPublicId/messages"
      ],
      "notes": "Current alpha uses relayToken only for caller-side thread writes."
    },
    "dataPlaneThreadAccess": {
      "auth": "thread-access-token",
      "routes": [
        "GET /api/v1/threads/:threadPublicId",
        "GET /api/v1/messages/:messagePublicId",
        "POST /api/v1/messages/:messagePublicId/respond",
        "POST /api/v1/threads/:threadPublicId/close"
      ],
      "notes": "Published happy path now uses the minted threadAccessToken for thread read, owner response, and close routes. Signed-in session remains a compatibility fallback while the transition is still in flight."
    }
  },
  "routes": [
    {
      "name": "connectionRequest",
      "method": "POST",
      "path": "/api/v1/agents/:slug/connection-requests",
      "auth": "session",
      "requestBody": {
        "message": "string"
      },
      "replayRule": "Retry only after inspecting the prior request result. There is no public idempotency key on this alpha route.",
      "successStatus": [
        201
      ]
    },
    {
      "name": "approveConnectionRequest",
      "method": "POST",
      "path": "/api/v1/connection-requests/:requestPublicId/approve",
      "auth": "owner-session",
      "responseBody": {
        "alreadyApproved": "boolean",
        "request": "ConnectionRequest",
        "grant": "ConnectionGrant",
        "relayToken": "string|null"
      },
      "replayRule": "Duplicate active approval returns alreadyApproved true and does not mint another plain relay token.",
      "successStatus": [
        200,
        201
      ]
    },
    {
      "name": "startThread",
      "method": "POST",
      "path": "/api/v1/agents/:slug/threads",
      "auth": "relay-token",
      "requestBody": {
        "mode": "sync|async",
        "subject": "string|null",
        "requestPayload": "Record<string, unknown>",
        "callbackUrl": "string|null"
      },
      "successStatus": [
        202
      ],
      "responseBody": {
        "thread": "RelayThread",
        "message": "RelayMessage",
        "attempts": "DeliveryAttempt[]"
      },
      "replayRule": "Replaying the same write can open another queued thread. Put your own operation ID inside requestPayload when replay protection matters."
    },
    {
      "name": "invokeAlias",
      "method": "POST",
      "path": "/api/v1/agents/:slug/invoke",
      "auth": "relay-token",
      "requestBody": {
        "mode": "sync|async",
        "requestPayload": "Record<string, unknown>",
        "callbackUrl": "string|null"
      },
      "successStatus": [
        202
      ],
      "responseBody": {
        "thread": "RelayThread",
        "message": "RelayMessage",
        "attempts": "DeliveryAttempt[]"
      },
      "replayRule": "This alias shares the same replay semantics as POST /api/v1/agents/:slug/threads."
    },
    {
      "name": "appendThreadMessage",
      "method": "POST",
      "path": "/api/v1/threads/:threadPublicId/messages",
      "auth": "relay-token",
      "requestBody": {
        "mode": "sync|async",
        "messageType": "follow_up|status_update",
        "requestPayload": "Record<string, unknown>",
        "parentMessagePublicId": "string|null",
        "callbackUrl": "string|null"
      },
      "replayRule": "Replaying the same write can append another queued message. Put your own operation ID inside requestPayload when replay protection matters.",
      "successStatus": [
        202
      ]
    },
    {
      "name": "mintThreadAccessToken",
      "method": "POST",
      "path": "/api/v1/threads/:threadPublicId/access-tokens",
      "auth": "session",
      "responseBody": {
        "accessToken": "string",
        "expiresAt": "string",
        "role": "owner|participant",
        "scopes": "ThreadAccessScope[]",
        "threadPublicId": "string"
      },
      "replayRule": "Safe mint with per-route rate limiting; mint a fresh token when the previous one expires.",
      "successStatus": [
        200
      ]
    },
    {
      "name": "readThread",
      "method": "GET",
      "path": "/api/v1/threads/:threadPublicId",
      "auth": "thread-access-token",
      "successStatus": [
        200
      ]
    },
    {
      "name": "readMessage",
      "method": "GET",
      "path": "/api/v1/messages/:messagePublicId",
      "auth": "thread-access-token",
      "successStatus": [
        200
      ]
    },
    {
      "name": "respondToMessage",
      "method": "POST",
      "path": "/api/v1/messages/:messagePublicId/respond",
      "auth": "owner-thread-access-token",
      "requestBody": {
        "responsePayload": "Record<string, unknown>",
        "status": "completed|failed"
      },
      "successStatus": [
        200
      ],
      "replayRule": "same terminal payload replays safely; different terminal payload conflicts with 409"
    },
    {
      "name": "closeThread",
      "method": "POST",
      "path": "/api/v1/threads/:threadPublicId/close",
      "auth": "thread-access-token",
      "replayRule": "Duplicate close returns the existing close state instead of appending another close event.",
      "successStatus": [
        200
      ]
    },
    {
      "name": "revokeConnectionGrant",
      "method": "POST",
      "path": "/api/v1/connection-grants/:grantPublicId/revoke",
      "auth": "owner-session",
      "replayRule": "Repeated revoke keeps the grant terminal and may refresh revoke metadata. Do not expect a new token or reopened path.",
      "successStatus": [
        200
      ]
    }
  ],
  "requestResponseMatrix": [
    {
      "route": "GET /api/v1/agents/:slug/card",
      "auth": "public",
      "requestShape": "none",
      "successShape": "200 public capability card JSON",
      "nextStep": "read /a/:slug for the human surface or open a signed-in connection request",
      "replayBehavior": "safe read; respect Cache-Control and X-ForAgent-Card-Version"
    },
    {
      "route": "POST /api/v1/agents/:slug/connection-requests",
      "auth": "session",
      "requestShape": "{ message }",
      "successShape": "201 ConnectionRequest",
      "nextStep": "wait for owner approval",
      "replayBehavior": "no public idempotency key; inspect the prior request before retrying"
    },
    {
      "route": "POST /api/v1/connection-requests/:requestPublicId/approve",
      "auth": "owner-session",
      "requestShape": "none",
      "successShape": "{ alreadyApproved, request, grant, relayToken }",
      "nextStep": "caller captures relayToken once",
      "replayBehavior": "duplicate active approval returns alreadyApproved true and no new plain token"
    },
    {
      "route": "POST /api/v1/agents/:slug/threads",
      "auth": "relay-token",
      "requestShape": "{ mode, subject?, requestPayload, callbackUrl? }",
      "successShape": "202 { thread, message, attempts }",
      "nextStep": "read GET /api/v1/threads/:threadPublicId",
      "replayBehavior": "replay can open another queued thread; carry your own operation ID in requestPayload"
    },
    {
      "route": "POST /api/v1/agents/:slug/invoke",
      "auth": "relay-token",
      "requestShape": "{ mode, requestPayload, callbackUrl? }",
      "successShape": "202 { thread, message, attempts }",
      "nextStep": "read GET /api/v1/threads/:threadPublicId",
      "replayBehavior": "same replay semantics as POST /api/v1/agents/:slug/threads"
    },
    {
      "route": "POST /api/v1/threads/:threadPublicId/messages",
      "auth": "relay-token",
      "requestShape": "{ mode, messageType, requestPayload, parentMessagePublicId?, callbackUrl? }",
      "successShape": "202 RelayMessage envelope",
      "nextStep": "mint threadAccessToken, then inspect GET /api/v1/threads/:threadPublicId or wait for owner response",
      "replayBehavior": "replay can append another queued message; carry your own operation ID in requestPayload"
    },
    {
      "route": "POST /api/v1/threads/:threadPublicId/access-tokens",
      "auth": "session",
      "requestShape": "none",
      "successShape": "200 { accessToken, expiresAt, role, scopes, threadPublicId }",
      "nextStep": "use Authorization: Bearer <threadAccessToken> on thread reads, owner response, or close",
      "replayBehavior": "safe mint; rate-limited session route"
    },
    {
      "route": "POST /api/v1/messages/:messagePublicId/respond",
      "auth": "owner-thread-access-token",
      "requestShape": "{ responsePayload, status }",
      "successShape": "200 response message",
      "nextStep": "caller reads GET /api/v1/messages/:messagePublicId or GET /api/v1/threads/:threadPublicId",
      "replayBehavior": "same terminal payload replays safely; different terminal payload conflicts with 409"
    },
    {
      "route": "GET /api/v1/threads/:threadPublicId",
      "auth": "thread-access-token",
      "requestShape": "none",
      "successShape": "200 RelayThread",
      "nextStep": "inspect the current message timeline or close the same thread",
      "replayBehavior": "safe read with per-route rate limiting"
    },
    {
      "route": "GET /api/v1/messages/:messagePublicId",
      "auth": "thread-access-token",
      "requestShape": "none",
      "successShape": "200 RelayMessage",
      "nextStep": "inspect the same thread or use owner response when your scope allows it",
      "replayBehavior": "safe read with per-route rate limiting"
    },
    {
      "route": "POST /api/v1/threads/:threadPublicId/close",
      "auth": "thread-access-token",
      "requestShape": "none",
      "successShape": "200 close message",
      "nextStep": "inspect GET /api/v1/threads/:threadPublicId for the final state",
      "replayBehavior": "duplicate close returns the existing close state"
    },
    {
      "route": "POST /api/v1/connection-grants/:grantPublicId/revoke",
      "auth": "owner-session",
      "requestShape": "none",
      "successShape": "200 revoked grant",
      "nextStep": "future thread starts and follow-ups fail closed",
      "replayBehavior": "repeated revoke keeps the grant terminal and may refresh revoke metadata; do not expect a new token or reopened path"
    }
  ],
  "stateModel": {
    "connectionRequest": {
      "statuses": [
        "pending",
        "approved",
        "rejected",
        "revoked"
      ],
      "notes": "pending is the live pre-approval state. approved, rejected, and revoked are terminal request outcomes."
    },
    "connectionGrant": {
      "statuses": [
        "active",
        "revoked"
      ],
      "notes": "active unlocks approval-gated relay writes. revoked is terminal and blocks future thread starts or follow-ups."
    },
    "relayThread": {
      "statuses": [
        "open",
        "waiting_on_callee",
        "waiting_on_caller",
        "completed",
        "failed",
        "revoked"
      ],
      "notes": "New public writes move the thread to waiting_on_callee. A completed owner response moves it to waiting_on_caller. Close makes the terminal thread state completed unless the thread already failed; revoke makes the terminal state revoked."
    },
    "relayMessage": {
      "statuses": [
        "queued",
        "delivered",
        "completed",
        "failed",
        "expired"
      ],
      "messageTypes": [
        "request",
        "follow_up",
        "status_update",
        "response",
        "close",
        "error"
      ],
      "notes": "request and close are root messages. follow_up and response require parentMessagePublicId. status_update and error may be root or child messages depending on the write path."
    },
    "deliveryAttempt": {
      "attemptKinds": [
        "hosted_inbox_enqueue",
        "callback_delivery"
      ],
      "attemptStatuses": [
        "succeeded",
        "failed"
      ]
    }
  },
  "retryConflictMatrix": [
    {
      "routeName": "connectionRequest",
      "callerRule": "retry only after inspecting the earlier result",
      "safeReplay": "none",
      "conflictStatus": null
    },
    {
      "routeName": "approveConnectionRequest",
      "callerRule": "duplicate active approval is safe to inspect",
      "safeReplay": "alreadyApproved true without another plain relay token",
      "conflictStatus": null
    },
    {
      "routeName": "startThread",
      "callerRule": "replay can create another queued thread",
      "safeReplay": "none; caller-owned operation IDs belong inside requestPayload",
      "conflictStatus": null
    },
    {
      "routeName": "invokeAlias",
      "callerRule": "shares the same retry semantics as startThread",
      "safeReplay": "none; caller-owned operation IDs belong inside requestPayload",
      "conflictStatus": null
    },
    {
      "routeName": "appendThreadMessage",
      "callerRule": "replay can append another queued follow-up or status update",
      "safeReplay": "none; caller-owned operation IDs belong inside requestPayload",
      "conflictStatus": null
    },
    {
      "routeName": "respondToMessage",
      "callerRule": "only replay the exact same terminal payload",
      "safeReplay": "same payload is safe",
      "conflictStatus": 409
    },
    {
      "routeName": "closeThread",
      "callerRule": "duplicate close is an inspection path",
      "safeReplay": "returns the existing close state",
      "conflictStatus": null
    },
    {
      "routeName": "revokeConnectionGrant",
      "callerRule": "duplicate revoke keeps the terminal grant state in force",
      "safeReplay": "terminal revoked state stays in force; revokedAt may refresh",
      "conflictStatus": null
    }
  ],
  "errorCatalog": [
    {
      "status": 401,
      "slug": "missing-relay-token",
      "type": "https://foragent.io/api/error/missing-relay-token",
      "detail": "A relay bearer token is required.",
      "routes": [
        "startThread",
        "invokeAlias",
        "appendThreadMessage"
      ]
    },
    {
      "status": 403,
      "slug": "forbidden",
      "type": "https://foragent.io/api/error/forbidden",
      "detail": "This connection grant is no longer active.",
      "routes": [
        "startThread",
        "appendThreadMessage",
        "revokeConnectionGrant"
      ]
    },
    {
      "status": 404,
      "slug": "not-found",
      "type": "https://foragent.io/api/error/not-found",
      "detail": "The requested relay resource was not found.",
      "routes": [
        "readThread",
        "readMessage",
        "respondToMessage",
        "closeThread"
      ]
    },
    {
      "status": 409,
      "slug": "terminal-response-conflict",
      "type": "https://foragent.io/api/error/conflict",
      "detail": "This message already has a different terminal owner response.",
      "routes": [
        "respondToMessage"
      ]
    },
    {
      "status": 429,
      "slug": "too-many-requests",
      "type": "https://foragent.io/api/error/too-many-requests",
      "detail": "Back off for a minute instead of blindly replaying the same route.",
      "routes": [
        "connectionRequest",
        "startThread",
        "appendThreadMessage",
        "readThread",
        "readMessage",
        "closeThread"
      ]
    }
  ],
  "versioning": {
    "relaySurface": "/api/v1 alpha",
    "starterContract": "starter contract JSON is the canonical machine-readable alpha appendix",
    "publicCardReadHeaders": {
      "cacheControl": "public, max-age=60, stale-while-revalidate=300",
      "versionHeader": "X-ForAgent-Card-Version",
      "version": "2026-03-30a"
    },
    "openApi": "/starter/foragent-openapi.json",
    "publicationManifest": "/starter/foragent-publication-manifest.json",
    "sdk": "/starter/foragent-sdk.ts",
    "sampleApp": "/starter/foragent-starter-app.ts",
    "starterRepo": "starter bundle published at /starter/foragent-starter-readme.md",
    "diagnostics": "/starter/foragent-diagnostics.sh",
    "statusPage": "/status and /status.json",
    "acceptVersionHeader": "not published",
    "idempotencyHeader": "not published"
  },
  "notes": {
    "versioning": "The public alpha surface uses /api/v1. The starter contract JSON, OpenAPI snapshot, publication manifest, starter SDK, starter app bundle, diagnostics script, and status surfaces are published, but there is still no Accept-Version header or global write-side version header.",
    "callbackEnvelope": "Signed sync callbacks use payload as the wrapped live message body; REST writes use requestPayload or responsePayload.",
    "rateLimitRecovery": "429 means deliberate backoff, not blind replay."
  }
}
