| title | Protocol API Reference | |||||||
|---|---|---|---|---|---|---|---|---|
| type | spec | |||||||
| tags |
|
|||||||
| created | 2026-03-26 | |||||||
| updated | 2026-08-09 |
Complete reference for all HTTP endpoints exposed by the protocol server. All routes are prefixed with /api (global prefix). The server runs on port 3001 by default.
- Authentication Patterns
- Non-Controller Routes
- Auth
- Agents
- Chat
- Conversation
- Debug
- Network
- Integration
- Intent
- Link
- Fast Signal Intake
- Opportunity
- Network Opportunity
- Enrichment
- Storage
- Subscribe
- Unsubscribe
- Tools
- User
- Queue Monitoring (Dev Only)
- Secure standalone Hermes and Index-owner authorization
Most endpoints require the AuthGuard, which accepts either a stateless Better Auth JWT or an Index API key.
- JWT header:
Authorization: Bearer <jwt> - JWT fallback:
?token=<jwt>query parameter - API-key header:
x-api-key: <key> - Errors:
401—Access token or API key required(no credential provided)401—Invalid or expired access token(JWT verification failed)401—Invalid API key(API key lookup or principal resolution failed)
The guard returns an AuthenticatedUser object with id, email (nullable), and name fields, which is passed to the handler as the second argument. Individual controllers may return additional 403/404 errors for user-level access checks.
A small set of endpoints accept only a session JWT, never an API key. These are the operations where a leaked agent API key must not be able to act: deleting the account, and creating or modifying agents, their tokens, permissions, and transports (a key that can mint successor credentials defeats rotation of the leaked key).
- JWT header /
?token=: same asAuthGuard - API key: rejected —
403—This endpoint requires a session token; API keys are not accepted - No credential:
401—Access token required
Session-only endpoints:
DELETE /api/auth/accountPOST /api/agents,PATCH /api/agents/:id,DELETE /api/agents/:idPOST /api/agents/:id/tokens,DELETE /api/agents/:id/tokens/:tokenIdPOST /api/agents/:id/permissions,DELETE /api/agents/:id/permissions/:permissionIdPOST /api/agents/:id/transports,DELETE /api/agents/:id/transports/:transportId
Debug endpoints additionally require the DebugGuard, which gates access based on environment:
- Enabled when:
NODE_ENV === 'development'orENABLE_DEBUG_API === 'true' - Error:
404—Not found(when disabled)
Debug endpoints apply both guards: DebugGuard first, then AuthGuard.
Some routes have no guard at all:
GET /api/auth/providersGET /api/chat/shared/:tokenGET /api/networks/share/:codeGET /api/networks/public/:idPOST /api/subscribe/GET /api/unsubscribe/:tokenGET /api/storage/avatars/:userId/:filenameGET /api/storage/index-images/:userId/:filename
All error responses follow a consistent JSON format:
{ "error": "Error message description" }These routes are handled directly in main.ts before the controller routing loop.
GET /health
Auth: None
Response:
{
"status": "ok",
"timestamp": "2026-03-26T00:00:00.000Z",
"service": "protocol-v2"
}The following paths are delegated to Better Auth and are not handled by controllers:
/api/auth/sign-in/api/auth/sign-up/api/auth/sign-out/api/auth/session/api/auth/callback/api/auth/error/api/auth/get-session/api/auth/forget-password/api/auth/magic-link/api/auth/reset-password/api/auth/verify-email/api/auth/change-password/api/auth/change-email/api/auth/delete-user/api/auth/list-sessions/api/auth/revoke-session/api/auth/revoke-other-sessions/api/auth/update-user/api/auth/token/api/auth/jwks/api/auth/api-key/create/api/auth/api-key/list/api/auth/api-key/delete
Refer to the Better Auth documentation for details on these endpoints. Generic API-key management requires a genuine Better Auth browser-cookie session. API keys are not promoted into Better Auth sessions, so x-api-key principals cannot create, list, inspect, retag, or delete keys through these routes. CLI credential minting and revocation use the constrained controller endpoints below.
API keys created for personal agents include metadata.agentId. MCP auth resolves API keys into { userId, agentId? } identities, so the same user can authorize multiple agents with separate keys.
MCP clients acting for a Telegram user MAY send that user's handle as x-index-telegram-username (or the alias x-index-telegram-handle; the username header wins when both are present). The value is normalized before use — a leading @ and a t.me / telegram.me URL prefix are stripped, and the remainder must match [A-Za-z0-9_]{5,32}. A missing or unparseable value is treated as no handle at all.
When a usable handle is present, the MCP auth resolver tries to bind it to the authenticated identity. Binding is additive and optional — a handle the server cannot attribute to the caller is skipped, never rejected, because the caller is already authenticated as themselves and any client (not just Telegram ones) may send the header:
- If the authenticated user already has a stored
telegramsocial and none of the stored values match the header, the binding is skipped and a warning is logged (authenticated_user_handle_mismatch). - If the handle is already stored for a different user, the binding is skipped and a warning is logged (
handle_belongs_to_other_user). - Otherwise the handle is upserted into
user_socialsunderlabel = 'telegram', preserving the user's other socials. A persistence failure is logged and does not fail the request.
The verification reads are the one fatal step: if the socials lookup or the handle-owner lookup throws, the request is rejected with a Telegram identity error rather than binding unverified. Both the mismatch decision and the final write leave the request intact.
This is identity binding only: it records where the user is reachable on Telegram (which Telegram opportunity delivery consumes) and never influences a redirect, rendering, or routing decision. The former routing header x-index-surface, the clientSurface value it threaded through the protocol, and the connect links it steered no longer exist — the MCP API has no surface header.
Opportunity and profile links are plain https://index.network/... URLs that open in the Index macOS app when it is installed, and fall back to a web page when it is not. The web app (apps/web), not this protocol server, owns that origin.
Association file. The web server answers GET/HEAD /.well-known/apple-app-site-association directly — no redirect, Content-Type: application/json, Cache-Control: no-store:
{
"applinks": {
"details": [
{
"appIDs": ["<APPLE_TEAM_ID>.network.index.system6"],
"components": [
{ "/": "/c/*" },
{ "/": "/o/*" },
{ "/": "/l/*" },
{ "/": "/u/*/?*", "exclude": true },
{ "/": "/u/*" }
]
}
]
}
}The ordered /u/*/?* exclusion requires a non-empty deeper profile segment, so
base /u/<id> profiles are claimed by the following /u/* component while routes
such as /u/<id>/chat remain browser-only.
APPLE_TEAM_ID comes from the web host's environment. It is not set in deploys yet: the server logs a startup warning and serves the literal placeholder TEAMIDPLACEHOLDER, so universal links do not resolve to the app until the real team ID is configured and a signed, notarized app ships. Until then the links behave as ordinary web URLs.
Paths. These pages are only reached when the app did not intercept the URL:
| Path | Behavior without the app |
|---|---|
/o/:id |
Static landing page: "Open in the Index app", a macOS download CTA (UA sniff, presentation only) or an "open this link on your Mac" note elsewhere, plus a copyable link. No auth and no API call. |
/c/:code |
Same landing page (retired connect links). |
/l/:code |
Network share landing: previews the network by its share code (no auth). Works for both public (joinPolicy: anyone) and private (invite_only) networks. Inline web sign-in, then POST /networks/invitation/:code/accept, then redirect to /download. Owners copy the link from network settings; manual regeneration via PATCH /networks/:id/regenerate-invitation invalidates old codes. |
/u/:id |
The existing public profile page, unchanged. |
The macOS CTA on that landing page links to /download, the app's install page. /download reads its artifact URL from VITE_MAC_APP_DOWNLOAD_URL: while that is unset it states that the app is not yet publicly available and offers no download button, and once a Developer ID-signed, notarized build is published it renders a real download with no code change. The CTA therefore never dead-ends, in either state.
Legacy short links. GET /c/:code on this protocol server is a tombstone for links already delivered in chats. main.ts rewrites the unprefixed path onto ConnectLinkController, which performs no database lookup and no opportunity side effects: a code matching ^[A-Za-z0-9]{10}$ gets a 302 to ${WEB_APP_URL}/c/<code> (default https://index.network) with the request's query string preserved — already-delivered links carry ?link_preview=false, and dropping it would resurrect preview cards in chat clients — and anything else gets a 404 HTML page. The resolution stack behind it — /c/:code/go, connect-link minting, connect tokens, surface routing — is deleted.
Client-side links. The macOS app parses index://o|u|c/<value> (plus index://u/<value> as a profile alias). o opens the opportunity card, u a profile, and c only a "no longer supported" notice. Network join is web-native on https://index.network/l/<code> — the macOS and Hermes apps copy that URL from network settings but do not handle in-app deep-link join for network links. Opportunity cards returned to MCP callers carry appUrl = ${WEB_APP_URL}/o/<opportunityId> (default https://index.network), minted by the protocol next to profileUrl and rendered into the card prose, so every MCP client gets the link. The Hermes plugin attaches the same link to any other opportunity-shaped payload it forwards from an MCP call (origin overridable with INDEX_APP_BASE_URL) and never overwrites one the protocol already set. appUrl is navigation only: it opens a card, it stores nothing, and this protocol API mints no acceptance URL. Acceptance stays an authenticated call (POST /api/opportunities/:id/start-chat, PATCH /api/opportunities/:id/status, or the update_opportunity MCP tool) — no link carries authority to accept.
Every MCP tools/call runs under the shared tool runtime. The runtime applies a timeout class, request cancellation signal, progress bridge, and output cap before returning a text block to the client.
Timeout classes:
| Class | Default deadline | Override |
|---|---|---|
fast |
10 seconds | MCP_TOOL_TIMEOUT_FAST_MS |
bounded_slow |
45 seconds | MCP_TOOL_TIMEOUT_BOUNDED_SLOW_MS |
async_candidate |
50 seconds | MCP_TOOL_TIMEOUT_ASYNC_CANDIDATE_MS |
A single tool can be overridden with MCP_TOOL_TIMEOUT_<TOOL_NAME>_MS, where <TOOL_NAME> is uppercased and non-alphanumeric characters are replaced with _; for example, MCP_TOOL_TIMEOUT_CREATE_INTENT_MS.
Size limits:
MCP_MAX_REQUEST_BYTESrejects oversized HTTP request bodies before JSON-RPC handling. Default:1000000bytes.MCP_TOOL_MAX_OUTPUT_BYTEScaps encoded tool output. Default:1000000bytes.MCP_TOOL_MAX_OUTPUT_<TOOL_NAME>_BYTESoverrides one tool, for exampleMCP_TOOL_MAX_OUTPUT_READ_DOCS_BYTES.
Invalid or non-positive numeric values are ignored and the default is used.
Cancellation:
Clients may cancel in-flight MCP calls with notifications/cancelled. HTTP request aborts exposed by the MCP SDK are treated the same way. The runtime propagates the abort signal into graph, LLM, scraper, and embedding paths where supported.
Runtime error envelope:
When the runtime rejects a tool call, the MCP text content contains a stable JSON envelope:
{
"success": false,
"code": "TOOL_TIMEOUT",
"error": "Tool create_intent timed out after 50000ms.",
"data": {
"tool": "create_intent",
"timeoutClass": "async_candidate",
"timeoutMs": 50000,
"maxOutputBytes": 1000000
}
}code is one of:
TOOL_TIMEOUT— the server-side deadline expired.TOOL_CANCELLED— the client cancelled before completion.TOOL_OUTPUT_TOO_LARGE— the encoded result exceeded the configured output cap.
GET /dev/performance
Auth: None (only available when NODE_ENV !== 'production')
Response: JSON object with performance statistics.
Controller prefix: /auth
Returns the list of configured social auth providers.
Auth: None (public)
Response:
{
"providers": ["google"],
"emailPassword": true
}providers— array of enabled social providers (currently only"google"if configured)emailPassword—truewhenNODE_ENV !== 'production'
Returns the current authenticated user with their full profile.
Auth: AuthGuard
Response:
{
"user": {
"id": "...",
"name": "...",
"email": "...",
"intro": "...",
"avatar": "...",
"location": "...",
"timezone": "...",
"socials": { ... },
"notificationPreferences": { ... },
"createdAt": "...",
"updatedAt": "..."
}
}Side effect: If the user has a name and at least one social link but no profile, a background profile sync is triggered automatically.
Updates the authenticated user's profile fields and/or notification preferences.
Auth: AuthGuard
Request body:
{
"name": "string (optional)",
"intro": "string (optional)",
"avatar": "string (optional)",
"location": "string (optional)",
"timezone": "string (optional)",
"socials": { "x": "...", "linkedin": "...", "github": "...", "websites": ["..."] },
"notificationPreferences": {
"connectionUpdates": true,
"weeklyNewsletter": false
}
}Response: Same shape as GET /api/auth/me.
Creates a Better Auth-compatible 90-day credential for the CLI browser bridge. The endpoint accepts only the protocol version; callers cannot choose the credential name, metadata, agent binding, or expiry.
Auth: SessionOnlyGuard (project JWT required; API keys and the temporary v1 API-key Bearer fallback are rejected)
Request body:
{ "protocolVersion": 2 }protocolVersion must be the number 1 or 2, and unknown fields are rejected.
Response:
{
"key": "raw-key-returned-once",
"id": "better-auth-api-key-row-id",
"expiresAt": "2026-10-16T12:00:00.000Z"
}The persisted metadata is fixed to { "client": "cli", "protocolVersion": 1|2 } and never includes agentId. A server-only permission marker distinguishes these rows from generic Better Auth keys even if user-editable metadata is forged.
Revokes one exact server-issued CLI credential. This route intentionally accepts only an actual x-api-key CLI caller; JWT, query-token, legacy-v1 Bearer, missing-header, agent-bound, disabled, expired, cross-user, and generic API-key callers fail closed.
Auth: RateLimit('write'), then AuthGuard; the request must carry only x-api-key authentication.
Request body (strict; unknown fields rejected):
{
"keyId": "exact-target-row-id",
"targetKey": "exact-raw-target-secret"
}The caller secret is independently re-resolved from its authoritative hash as an enabled, unexpired, unbound v1/v2 CLI row owned by the authenticated user. The target must match both keyId and hash(targetKey), have the same aligned owner, retain the server-issued CLI shape, and have no agent binding. This supports self-revocation and replacement-key cleanup of a prior credential without permitting arbitrary same-user key deletion. Secrets are never logged.
Success response (only after deletion):
{ "success": true }Malformed bodies return 400; authenticated but ineligible caller/target proof returns the stable 403 body { "error": "CLI credential revocation denied" } (transport-shape rejection uses a distinct typed 403).
Soft-deletes the authenticated user's account.
Auth: SessionOnlyGuard (API keys rejected with 403)
Response:
{ "success": true }Controller prefix: /chat
Dual-auth SSE endpoint for chat messages with context support. There is no default persona. Session-authenticated callers are classified as the web surface from authenticated credential provenance and receive the same Signal policy (or typed refusal) as the dedicated route, preventing a browser from bypassing the policy by selecting this endpoint. API-key principals are classified as the agent surface and must name a persona explicitly; signal stays web-only, so negotiator is the one they can start. Omitting the persona returns HTTP 409 with code: "CHAT_PERSONA_REQUIRED". The main web composer uses /api/chat/web/stream below.
Auth: AuthGuard
Request body (Zod-validated):
{
"message": "string | null (optional)",
"sessionId": "string | null (optional — creates new session if omitted)",
"useCheckpointer": "boolean (optional, default: true)",
"fileIds": ["string (optional — file IDs to attach)"],
"scopeType": "network | intent | null (optional — mutually-exclusive focused scope)",
"scopeId": "string | null (required when scopeType is provided)",
"networkId": "string | null (deprecated alias for scopeType=network)",
"recipientUserId": "string | null (optional — DM recipient)",
"persona": "signal | negotiator | null (optional persona assertion; stored session persona is authoritative)",
"prefillMessages": [
{ "role": "assistant | user", "content": "string (max 10000 chars)" }
]
}Response: SSE stream (Content-Type: text/event-stream)
SSE event types:
status— Processing status updatesrouting— Which subgraph was selected and whysubgraph_result— Results from subgraph executiondebug_meta— Graph execution metadata (graph name, iterations, tools)done— Final event withsessionId, full response text,messageId,title, andsuggestionserror— Error event with message and codeSTREAM_ERROR
Response headers:
X-Session-Id— The session ID for this chatX-Chat-Persona— The authoritative persisted persona used for the turn
API-key Signal assertions are rejected on this compatibility route. A session-authenticated caller is governed by the web policy and may continue an authoritative persisted Signal session, but main-web clients should always use the dedicated route.
Main-web SSE endpoint. It accepts the same request and returns the same SSE events/headers as /api/chat/stream, but is protected by SessionOnlyGuard.
A new ordinary web chat must explicitly request persona: "signal"; omitting it returns HTTP 409 with code: "WEB_SIGNAL_PERSONA_REQUIRED". Signal follow-ups may omit the assertion and inherit the persisted persona. Retired orchestrator sessions remain readable through POST /api/chat/session, but a new turn returns HTTP 409 with code: "WEB_SIGNAL_SESSION_REQUIRED" and action: { "type": "start_signal_session", "href": "/" }. Explicit persona mismatch and unknown persisted personas also fail closed.
Session-only onboarding exception using the same SSE request/response shape. The controller authoritatively reloads the user and returns 403 once onboarding.completedAt is set. Sessions are server-selected and persisted as persona="onboarding" unconditionally; follow-ups inherit that stored persona, while spoofed/mismatched/unknown personas fail closed. The restricted persona exposes only approved self-profile context, the shared guided first-signal intake, proposal-only creation with current-membership validation, and completion. It excludes imports, discovery/opportunities, negotiation, community selection or membership mutation, and administration.
Auth: SessionOnlyGuard
Compatibility history for the authenticated user. Every request is clamped to the retired orchestrator persona, whose sessions stay readable; the persona query parameter is inert, since the persona=negotiator lookup it once served existed only for the removed unscoped Personal Agent DM. Negotiator sessions are reached through their pinned intent, and Signal sessions are never returned here.
Auth: AuthGuard
Response:
{
"sessions": [...]
}Session-only main-web history. Returns signal sessions plus the read-only rows (retired orchestrator, telegram notification transcripts), and excludes the pinned negotiator conversation.
Auth: SessionOnlyGuard
Response: same shape as GET /api/chat/sessions.
Resolve or create a stable selected-intent chat session. Session-authenticated callers are classified as web and receive Signal policy or a typed refusal before scope validation/session creation. API-key callers are the agent surface and must name a persona; without one the call returns HTTP 409 with code: "CHAT_PERSONA_REQUIRED". Repeated calls by the same user, intent, and persona return the same session. The main web composer uses /api/chat/web/session/resolve.
Auth: AuthGuard
Request body:
{
"scopeType": "intent",
"scopeId": "intent UUID"
}Response:
{
"session": {
"id": "...",
"scopeType": "intent",
"scopeId": "...",
"title": "..."
},
"created": false
}Session-only main-web variant of /api/chat/session/resolve. Add persona: "signal"; the returned stable intent-scoped session uses the Signal persona-distinct registry key (signal-intent), so it never rewrites or reuses retired orchestrator history.
{
"scopeType": "intent",
"scopeId": "intent UUID",
"persona": "signal"
}Compatibility detail for a specific retired-orchestrator session with its messages (including assistant metadata). Every other persona returns 404, so legacy clients cannot retrieve web-only history by UUID.
Auth: AuthGuard
Request body:
{
"sessionId": "string (required)"
}Response:
{
"session": {
"id": "...",
"title": "...",
"networkId": "... (legacy network alias, nullable)",
"scopeType": "network | intent | null",
"scopeId": "... (nullable)"
},
"messages": [
{
"id": "...",
"role": "user | assistant",
"content": "...",
"traceEvents": "... (assistant messages only)",
"debugMeta": "... (assistant messages only)",
"createdAt": "..."
}
]
}Session-only main-web detail endpoint. It permits the readable web persona (signal) and the read-only rows (orchestrator, telegram) plus the pinned negotiator conversation, and fails closed for unknown personas.
Both chat detail endpoints now hydrate one durable timeline session only. The first request sends { "sessionId": "..." }; to reveal exactly one older section, send { "sessionId": "...", "beforeSessionId": "<oldest loaded durable session id>" }. Responses retain { session, messages } and add sessionId (the loaded durable session), hasPreviousSession, and previousSessionCursor. The cursor is opaque; callers must not infer ordering from IDs.
Auth: SessionOnlyGuard
Delete a chat session. Delete, title, share, and unshare mutations all load the owned session first and enforce its persisted persona: API-key callers may mutate only negotiator sessions; session-authenticated Signal sessions follow the web policy; retired orchestrator sessions are read-only and return the typed separate-session action.
Auth: AuthGuard
Request body:
{
"sessionId": "string (required)"
}Response:
{ "success": true }Update a chat session title.
Auth: AuthGuard
Request body:
{
"sessionId": "string (required)",
"title": "string (required, non-empty)"
}Response:
{ "success": true, "title": "..." }Generate a share token for a chat session.
Auth: AuthGuard
Request body:
{
"sessionId": "string (required)"
}Response:
{ "shareToken": "..." }Remove the share token from a chat session.
Auth: AuthGuard
Request body:
{
"sessionId": "string (required)"
}Response:
{ "success": true }Update message metadata with frontend trace events (called after streaming completes).
Auth: AuthGuard
Path params:
id— Message ID
Request body:
{
"traceEvents": ["array of trace event objects (max 2000)"]
}Response:
{ "success": true }Get a shared chat session (read-only, public access).
Auth: None (public)
Path params:
token— Share token
Response:
{
"session": {
"id": "...",
"title": "...",
"createdAt": "..."
},
"messages": [
{
"id": "...",
"role": "...",
"content": "...",
"createdAt": "..."
}
]
}Controller prefix: /agents
Agent read routes and the agent-poller endpoints (negotiations pickup/respond, test messages, opportunity pickup/delivery) use AuthGuard (JWT or API key). Agent management writes — create/update/delete agent, tokens, permissions, transports — use SessionOnlyGuard: API keys get 403, so a leaked key cannot mint successor credentials or reshape its own permissions.
List the agents the current user owns or has been authorized to use.
Response:
{
"agents": [
{
"id": "...",
"ownerId": "...",
"name": "...",
"description": "...",
"type": "personal",
"status": "active",
"metadata": {},
"transports": [],
"permissions": [],
"createdAt": "...",
"updatedAt": "..."
}
]
}Create a personal agent owned by the current user.
Request body:
{
"name": "My Claude Agent",
"description": "Handles partner negotiations"
}Response:
{
"agent": {
"id": "...",
"name": "My Claude Agent",
"type": "personal",
"status": "active",
"transports": [],
"permissions": []
}
}Resolve and return the agent bound to the calling API key (x-api-key header). The key's metadata.agentId is read from the database and the matching agent is returned in the same shape as GET /api/agents/:id. Returns 400 if called with a JWT or with a key that has no agent binding. Used by personal-agent runtimes (e.g. the OpenClaw plugin setup wizard) to bootstrap their agentId from a single pasted API key, avoiding a separate agent-id input.
Fetch one agent by ID if the current user owns it or has a permission grant on it.
Update mutable fields on a personal agent.
Request body:
{
"name": "Updated Agent Name",
"description": "optional or null",
"status": "inactive"
}Notes:
- System agents return
403for mutation attempts. - Empty patch bodies return
400.
Soft-delete a personal agent and deactivate its transports.
Response: 204 No Content
Add a transport to an owned personal agent. The only supported channel is mcp — the agent authenticates with an API key (see POST /api/agents/:id/tokens) and pulls work from the Index Network MCP server and the negotiation pickup endpoint below. Transports are MCP-only.
Request body (mcp channel):
{
"channel": "mcp",
"config": {},
"priority": 0
}priority— integer ordering hint when multiple transports on the same agent are eligible for the same event (higher priority first).
Response:
{
"transport": {
"id": "...",
"agentId": "...",
"channel": "mcp",
"active": true,
"failureCount": 0
}
}Remove a transport from an owned personal agent.
Response: 204 No Content
Grant the current user a permission set on an agent.
Request body:
{
"actions": ["manage:intents", "manage:negotiations"],
"scope": "global",
"scopeId": "optional-for-node-or-network"
}Response:
{
"permission": {
"id": "...",
"agentId": "...",
"userId": "...",
"scope": "global",
"scopeId": null,
"actions": ["manage:intents", "manage:negotiations"],
"createdAt": "..."
}
}Revoke a permission from an agent.
Response: 204 No Content
List API keys bound to an owned personal agent. Raw key values are never returned — only stored metadata (id, name, creation timestamp).
Response:
{
"tokens": [
{ "id": "...", "name": "My Claude Agent API Key", "createdAt": "..." }
]
}Create an API key bound to an owned personal agent. The backend issues the key through Better Auth and stores metadata.agentId automatically.
Request body:
{
"name": "My Claude Agent API Key"
}Response:
{
"token": {
"id": "...",
"key": "idx_live_...",
"name": "My Claude Agent API Key",
"createdAt": "..."
}
}Notes:
- The raw
keyvalue is only returned once. - System agents return
403.
Revoke an API key bound to an owned personal agent.
Response: 204 No Content
Errors:
404if the token does not exist or is not bound to the route agent
Claim the next pending negotiation turn for an owned personal agent. Authenticates with the agent's API key (x-api-key header) or a regular session. Legacy credentials are idempotent for an existing claim. A dedicated hermes-negotiator credential must also send the native x-index-hermes-run-id header; one process run can bind only one task, so a second pickup with that run returns 409.
The backend atomically transitions the oldest tasks.state = 'waiting_for_agent' row where the caller's user is a participant to state = 'claimed'. The exact park-generation timeout is cancelled and a claim-generation timeout is enqueued with the remaining park-window budget (a single shared budget of AMBIENT_PARK_WINDOW_MS = 5 minutes from park start — park and claim timers never stack). Repeating pickup for the preserved exact claim idempotently repairs both queue operations without extending the original deadline; an elapsed deadline is repaired as an immediate fallback. Timeout workers validate the job generation and turn count in one transaction, so redelivery from an older re-park/reclaim cannot mutate the current task or continuation fence. If the agent does not respond before the budget expires — or the turn is never claimed at all — the system Index Negotiator takes the turn as a fallback (seat-scoped under v2); an expired claim is not re-parked for another pickup attempt.
Request body: empty.
Response (nothing to claim): 204 No Content.
Response (claimed):
{
"negotiationId": "...",
"taskId": "...",
"opportunity": {
"id": "...",
"reasoning": "Why the evaluator flagged this match",
"actors": [ /* opportunity actor records */ ],
"status": "negotiating"
},
"turn": {
"number": 3,
"deadline": "2026-04-14T12:00:00.000Z",
"counterpartyAction": "counter",
"history": [
{ "turnNumber": 0, "agent": "source", "action": "propose", "message": "..." },
{ "turnNumber": 1, "agent": "candidate", "action": "counter", "message": "..." },
{ "turnNumber": 2, "agent": "source", "action": "counter", "message": "..." }
]
},
"context": {
"ownUser": { /* UserNegotiationContext for the claiming user */ },
"otherUser": { /* UserNegotiationContext for the counterparty */ },
"indexContext": { "networkId": "...", "prompt": "..." },
"seedAssessment": { "score": 82, "reasoning": "...", "valencyRole": "..." },
"isDiscoverer": true,
"discoveryQuery": "optional — only set when the negotiation originated from a discovery query"
},
"seat": "initiator",
"protocolVersion": "v2",
"allowedActions": ["outreach", "counter", "question", "withdraw"],
"canConsultOwner": true
}turn.deadline— ISO-8601 timestamp; park start + the park-window budget (AMBIENT_PARK_WINDOW_MS, 5 minutes). The claim shares this same budget — it is not extended by picking up.turn.counterpartyAction— action from the preceding turn, or"none"if this is the first turn.seat/protocolVersion/allowedActions— the claiming user's seat under the task's protocol version and the exact actions that seat may submit this turn (propose | accept | reject | counter | questionon v1 tasks; seat-scopedoutreach | counter | question | withdrawvsaccept | decline | counter | questionon v2). Final turns expose only their final-turn vocabulary.canConsultOwner—trueonly when this exact v2, non-opening, non-final claim can enter the server's Questioner-backed owner-consultation continuation. The server derives policy eligibility from persisted action, role, claim, and lifecycle data; clients cannot request or advertise consultation on a final turn.context.ownUser/context.otherUser— the persisted absolute source/candidate context projected into the claiming user's perspective. May benullonly for legacy tasks created before turn-context persistence landed.negotiatorMemory— optional array of the claiming user's own negotiator-memory entries (present only whenNEGOTIATOR_MEMORY_INJECTis on and the user's agent has relevant memories — never contains the counterparty's).opportunity—nullwhen the task has no linked opportunity.
A dedicated Hermes response is a different privacy-minimal projection: opportunity is limited to id/status; history omits message; context, negotiatorMemory, privateConsultation, owner selections/free text, evaluator reasoning, actor prose, and all shared-message prose are absent. Its allowedActions contains only accept | decline | request_time | continue, ownerDirective is the fixed server-derived protect_private_context, and runCapability is returned only for the native plugin to retain outside model-visible arguments.
Errors:
403unless the request uses the exact currently selected agent-bound credential (including the current Hermes setup generation when explicitly Hermes-audience), and dedicated Hermes pickup includes its native run header.409when the dedicated process run already picked up a task.
Pause an exact externally claimed v2 turn to consult the represented owner through the existing private Questioner lifecycle. The endpoint requires the exact currently selected agent-bound credential. Dedicated Hermes requests also require their native run ID and opaque capability headers. It accepts only a closed server-owned consultation reason; unknown fields and all free-form disclosure/question text are rejected. Questioner renders fixed copy for the category and receives no agent-authored instructions. The exact task/credential/setup-generation/run capability is atomically consumed with the pause.
Request body (strict):
{
"reason": "consequential_disclosure_permission"
}Response:
{
"success": true,
"status": "input_required",
"settlementId": "negotiation-question-settlement-v1-<task-id>"
}The server arms an attempt-specific expiry for the configured answer window (24 hours by default) before atomically writing one server-authored ask_user turn, its exact material binding, and input_required. Answer, dismissal, or expiry uses the existing exact-task continuation pipeline and resumes at most one successor. A failed Questioner enqueue remains recoverable through the durable expiry.
Errors:
400for an unknown/mismatched reason, any extra/free-form field, v1/opening/final turns, prior same-seat consultation, missing lifecycle wiring, or policy-ineligible admission.403unless the request uses the exact currently selected agent-bound credential and its current setup generation.404for a missing, non-negotiation, or wrong-owner task.409when the exact claim, claimant, continuation fence, or material binding lost a race.
Every rejected consultation preserves the original claimed state and claim deadline. Duplicate losers cancel only their own server-only attempt expiry; they cannot cancel or settle the winning consultation.
Submit a response for a negotiation turn previously claimed via pickup. Authenticates with the agent's API key or a session. The submitted action is validated against the caller's seat + the task's protocol version before any state change. Runtime deselection, disconnect, credential rotation, and revocation serialize behind the same owner fence until message/task/artifact/opportunity/continuation effects and timeout rearming finish. Any committed rearm outbox stores an absolute deadlineAt; retries enqueue only the remaining max(0, deadlineAt - now) delay and never grant a fresh window after an outage.
Request body:
{
"action": "counter",
"message": "optional free-form text shown to the other side",
"assessment": {
"reasoning": "Why the agent chose this action",
"suggestedRoles": {
"ownUser": "agent",
"otherUser": "patient"
}
}
}action— must be within the seat'sallowedActionsreturned bypickup: v1 tasks acceptpropose | accept | reject | counter | question; v2 tasks are seat-scoped (initiatoroutreach | counter | question | withdraw, counterpartyaccept | decline | counter | question).ask_useris never accepted here; use the dedicated consultation endpoint whencanConsultOwneris true.message— optional string ornull.assessment.suggestedRoles.ownUser/.otherUser— each one ofagent,patient,peer.
For a dedicated hermes-negotiator credential, the body is instead strict and closed:
{
"action": "accept | decline | request_time | continue",
"roleAlignment": "peers | owner_leads | counterparty_leads"
}The native x-index-hermes-run-id and x-index-hermes-run-capability headers are required but are not model arguments. The server maps the directive to an allowed protocol action and fixed shared-message/assessment templates. Any message, reasoning, assessment, run/capability body field, or other prose is rejected. The capability is consumed atomically at the claimed-to-working CAS; the owner runtime fence remains held through final durable effects and the completion receipt, making exact retries idempotent and cross-task/generation/credential replay invalid.
Response:
{ "success": true }Errors:
400if the action is outside the caller's seat's allowed set for the task's protocol version (the claim stays intact for a retry).403unless the request uses the exact currently selected agent-bound credential (including the current Hermes setup generation when explicitly Hermes-audience).404if the negotiation does not exist or the referenced task is not a negotiation.409if the task is not inclaimedstate or is claimed by a different agent.
Fetch all undelivered eligible opportunities for an owned personal agent as a batch. Authenticates with the agent's API key (x-api-key header) or a session. Read-only: the response does not reserve or mutate the delivery ledger, so callers are expected to decide which candidates to surface and then commit each selection via the confirm_opportunity_delivery MCP tool.
Uses the same getOpportunitiesForUser database adapter as the feed graph. Eligibility filters: status latent, pending, or draft, the caller's user listed in actors, agent has notify_on_opportunity = true, canUserSeeOpportunity + isActionableForViewer JS filters (mirroring the feed graph), no committed delivery row exists. In practice isActionableForViewer excludes drafts (only latent and pending are actionable). Latent opportunities only surface for the introducer when approved=false. Results are capped at 20 by default; pass ?limit=N (1..20) to request fewer. Results are ordered oldest-first, with rendered card fields suitable for direct interpolation into a delivery prompt.
Query parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
limit |
number | no | Maximum number of opportunities to return. Server clamps to [1, 20] and truncates fractional values. Out-of-range values (0, negatives, >20) are normalized rather than rejected. Defaults to 20 when omitted or empty. |
Request body: empty.
Response:
{
"opportunities": [
{
"opportunityId": "...",
"counterpartUserId": "... | null",
"feedCategory": "connection | connector-flow",
"rendered": {
"headline": "...",
"personalizedSummary": "...",
"suggestedAction": "...",
"narratorRemark": "..."
}
}
],
"totalPending": 5
}feedCategory—'connection'for direct matches,'connector-flow'when the viewer is the introducer.totalPending— count of all eligible opportunities after filters but before the limit is applied. Enables overflow messaging ("N more conversations waiting").- Returns
{ "opportunities": [], "totalPending": 0 }when nothing is pending (not204). - Each poll also bumps
agents.last_seen_at.
Errors:
400iflimitis present but does not parse to a finite number (e.g.abc,Infinity,NaN) —{"error":"limit must be a finite number"}.403if the agent is not owned by the authenticated user. Legacy agent-bound keys retain this historical ownership behavior; the dedicated Hermes negotiator audience is denied this route.
Fetch accepted opportunities where the authenticated user is the counterparty (not the accepter, not an introducer) and no delivery record with deliveredAtStatus = 'accepted' exists yet. Used by personal agent accepted-opportunity pollers.
Auth: AuthGuard (session or API key).
Path params:
id— Agent ID.
Query params:
| Parameter | Type | Required | Description |
|---|---|---|---|
limit |
number | no | Maximum number of opportunities to return. Server clamps to [1, 20]. Defaults to 10. |
Response 200:
{
"opportunities": [
{
"opportunityId": "...",
"accepterUserId": "...",
"accepterName": "Alice",
"conversationUrl": "https://index.network/conversations/...",
"telegramHandle": "alice_tg",
"rendered": {
"headline": "...",
"personalizedSummary": "..."
}
}
]
}telegramHandleisnullwhen the accepter has nouser_socialsentry withlabel = 'telegram'or when the stored value is not a valid Telegram username.conversationUrlfalls back to the frontend base URL if no DM exists.- Returns
{ "opportunities": [] }when no undelivered accepted opportunities exist.
Errors:
400iflimitis present but does not parse to a finite number.403if the agent is not owned by the authenticated user. Legacy agent-bound keys retain this historical ownership behavior; the dedicated Hermes negotiator audience is denied this route.
Return committed delivery counts for an owned personal agent since a given timestamp, grouped by trigger type.
Auth: AuthGuard (session or API key).
Path params:
id— Agent ID.
Query params:
| Parameter | Type | Required | Description |
|---|---|---|---|
since |
string | yes | ISO 8601 timestamp; counts deliveries with delivered_at >= since. |
Response 200:
{ "ambient": 2, "digest": 1 }ambient— number of committed deliveries withtrigger = "ambient"since the given timestamp.digest— number of committed deliveries withtrigger = "digest"since the given timestamp.
Response 400: { "error": "..." } when since is missing or cannot be parsed as a valid ISO 8601 date.
Errors:
403if the agent is not owned by the authenticated user. Legacy agent-bound keys retain this historical ownership behavior; the dedicated Hermes negotiator audience is denied this route.
Used by: the OpenClaw plugin's ambient discovery poller, which calls this endpoint before each cycle to feed today's committed delivery count into the agent's prompt for soft self-restraint against a ≤3/day target.
Atomically reserve and return one pending opportunity for the agent to process. Returns 204 if no opportunities are pending. Also updates the agent's lastSeenAt heartbeat.
Auth: AuthGuard (session or API key).
Path params:
id— Agent ID. The authenticated user must own this agent.
Response 200:
{
"opportunityId": "uuid",
"reservationToken": "token-string",
"reservationExpiresAt": "ISO-8601-timestamp",
"rendered": { ... }
}Response 204: No pending opportunities.
Errors:
404if the agent is not owned by the authenticated user or does not exist (returns 404 regardless to prevent existence disclosure).
Confirm that the agent has successfully delivered an opportunity. Must be called with the reservationToken issued by the preceding pickup call.
Auth: AuthGuard (session or API key).
Path params:
id— Agent ID. The authenticated user must own this agent.opportunityId— Opportunity ID.
Request body:
{ "reservationToken": "token-string" }Response 200:
{ "ok": true }Errors:
404— Invalid or expired reservation token; the message has already been confirmed or the token is wrong.404if the agent is not owned by the authenticated user or does not exist.
Enqueue a test message for the agent. Owner-only. Used to verify that a personal agent's delivery pipeline is working correctly.
Auth: AuthGuard (session only).
Path params:
id— Agent ID. The authenticated user must own this agent.
Request body:
{ "content": "Hello from test" }Response 201: The enqueued test message record.
Errors:
404if the agent is not owned by the authenticated user or does not exist.
Atomically reserve and return one pending test message. Returns 204 if no messages are pending. Also updates the agent's lastSeenAt heartbeat.
Auth: AuthGuard (session or API key).
Path params:
id— Agent ID. The authenticated user must own this agent.
Response 200: The reserved test message with a reservationToken.
Response 204: No pending test messages.
Errors:
404if the agent is not owned by the authenticated user or does not exist.
Confirm delivery of a test message. Must be called with the reservationToken issued by the preceding test-messages/pickup call.
Auth: AuthGuard (session or API key).
Path params:
id— Agent ID.messageId— Test message ID.
Request body:
{ "reservationToken": "token-string" }Response 200:
{ "ok": true }Errors:
404— Invalid or expired reservation token.
Controller prefix: /conversations
List all conversations for the authenticated user.
Auth: AuthGuard
Response:
{
"conversations": [...]
}List A2A agent-to-agent negotiation conversations for the authenticated user.
Auth: AuthGuard
Response:
{
"conversations": [...]
}Each conversation may carry a negotiation lifecycle object. Its optional
screenDecision field (IND-610) is owner-only: it is projected solely when
the authenticated viewer is the negotiation's initiator, and is null for every
other viewer, so a counterparty never learns that an outreach gate ran or what
it concluded. The ownership check is applied inside the projection itself
(services/api/src/adapters/negotiation-lifecycle.projection.ts), independently
of the separate listing rule that hides screened_out rows from non-initiators.
Only named fields are projected; the underlying tasks.metadata blob is never
returned.
{
"screenDecision": {
"source": "screen",
"decision": "pass",
"reasoning": "...",
"counterpartyPremiseFit": "... | null",
"intentAlignment": "... | null",
"screenedAt": "2026-07-24T11:00:00.000Z | null"
}
}source records where the decision came from, because two different refusals
collapse into the same screened_out outcome:
screen— the outreach gate declined before any contact (tasks.metadata.screenDecision); structured evidence fields are present.outcome— the agent refused on its opening turn, so no screen record blocked and only the negotiation-outcomereasoningexists; the evidence fields arenull.
Reasoning is only ever taken from a screened_out outcome. Ordinary declines
and turn-cap stalls do not populate this field.
Return persisted agent-to-agent negotiation activity for one intent owned by the authenticated user.
Auth: AuthGuard
Query: intentId (required UUID)
The response groups activity by stable correspondent identity. Each group
contains exactly the latest three messages in chronological order, with
sender: "yours" | "theirs", parts, createdAt, and opportunityId.
Reads are scoped through the authenticated user, exact intent actor,
opportunity, and negotiation task, so turns cannot bleed across intents or
shared correspondent conversations. Invalid IDs return 400; unknown or
non-owned intents return 404.
{
"groups": [
{
"correspondentUserId": "uuid",
"correspondentLabel": "Ada's agent",
"correspondentAvatar": null,
"messages": []
}
]
}Create a new conversation with participants.
Auth: AuthGuard
Request body:
{
"participants": [
{ "participantId": "string", "participantType": "user | agent" }
]
}The authenticated user must be included in the participants array.
Response (201):
{
"conversation": { ... }
}Get messages for a conversation. Existing limit, before, and taskId behavior remains compatible; message cursors use the stable (createdAt, id) key.
Set sessionHistory=true to receive only the latest durable timeline session and { messages, sessionId, hasPreviousSession, previousSessionCursor }. Add beforeSessionId=<cursor> to retrieve exactly one earlier session. A taskId session-history read remains constrained to that A2A task segment.
Auth: AuthGuard
Path params:
id— Conversation ID
Query params:
limit— Max messages to return (optional)before— Cursor for pagination, return messages before this ID (optional)taskId— Filter messages by task ID (optional)
Response:
{
"messages": [...]
}Send a message in a conversation.
Auth: AuthGuard
Path params:
id— Conversation ID
Request body:
{
"parts": ["array of message parts (required, A2A-compatible)"],
"taskId": "string (optional)",
"metadata": { "key": "value (optional)" }
}Response (201):
{
"message": { ... }
}Get or create a DM conversation with a peer user.
Auth: AuthGuard
Request body:
{
"peerUserId": "string (required)"
}Response:
{
"conversation": { ... }
}Update metadata for a conversation.
Auth: AuthGuard
Path params:
id— Conversation ID
Request body:
{
"metadata": { "key": "value (required)" }
}Response:
{ "success": true }Hide a conversation for the authenticated user (soft-hide via hiddenAt).
Auth: AuthGuard
Path params:
id— Conversation ID
Response:
{ "success": true }List all tasks for a conversation.
Auth: AuthGuard
Path params:
id— Conversation ID
Response:
{
"tasks": [...]
}Get a single task within a conversation.
Auth: AuthGuard
Path params:
id— Conversation IDtaskId— Task ID
Response:
{
"task": { ... }
}Get artifacts for a task within a conversation.
Auth: AuthGuard
Path params:
id— Conversation IDtaskId— Task ID
Response:
{
"artifacts": [...]
}SSE endpoint for real-time conversation events. Streams new messages and conversation updates to the authenticated user.
Auth: AuthGuard
Response: SSE stream (Content-Type: text/event-stream)
- Initial event:
{ "type": "connected" } - Subsequent events: conversation-scoped data pushed in real time
- Keepalive comments sent every 15 seconds
Controller prefix: /debug
All debug endpoints require both DebugGuard (dev/staging only) and AuthGuard.
Returns a full diagnostic snapshot for a single intent, including the intent record, HyDE document stats, index assignments, related opportunities, and a pipeline-health diagnosis.
Auth: DebugGuard + AuthGuard
Path params:
id— Intent ID
Response:
{
"exportedAt": "...",
"intent": {
"id": "...",
"text": "...",
"summary": "...",
"status": "active | archived",
"semanticEntropy": 0.15,
"referentialAnchor": "...",
"intentMode": "...",
"speechActType": "...",
"felicityAuthority": 8,
"felicitySincerity": 9,
"felicityClarity": 7,
"sourceType": "...",
"hasEmbedding": true,
"createdAt": "...",
"updatedAt": "..."
},
"hydeDocuments": {
"count": 3,
"oldestGeneratedAt": "...",
"newestGeneratedAt": "..."
},
"indexAssignments": [
{
"networkId": "...",
"networkTitle": "...",
"indexPrompt": "...",
"relevancyScore": 0.84,
"finalScore": 0.84,
"promptPresence": "both",
"rawScores": { "indexScore": 0.9, "memberScore": 0.75 },
"isDeterministicNoPromptAssignment": false
}
],
"opportunities": {
"total": 5,
"byStatus": { "pending": 2, "accepted": 3 },
"items": [
{
"opportunityId": "...",
"counterpartUserId": "...",
"confidence": 0.9,
"status": "accepted",
"createdAt": "...",
"indexId": "..."
}
]
},
"diagnosis": {
"hasEmbedding": true,
"hasHydeDocuments": true,
"isInAtLeastOneIndex": true,
"verificationAnalysis": { "status": "complete", "missingFields": [] },
"missingVerificationAnalysis": false,
"missingAssignment": false,
"missingHyde": false,
"hasOpportunities": true,
"allOpportunitiesFilteredFromHome": false,
"filterReasons": []
}
}semanticEntropy is the stored entropy value, not a confidence score; no
combined verifier score is exposed. verificationAnalysis.status is
complete, default_only, partial, or missing, so default schema values
cannot be mistaken for completed verification. Assignment finalScore and
promptPresence are null for legacy rows without persisted assignment metadata;
rawScores is omitted when the assignment policy did not call a model. A
promptless automatic assignment therefore reports promptPresence: "none",
finalScore: 1, and isDeterministicNoPromptAssignment: true without raw scores.
The three missing* diagnosis fields independently identify absent verification,
assignment, and HyDE artifacts.
Returns a radar-level diagnostic snapshot for the authenticated user, including intent stats, network memberships, opportunity aggregates, simulated radar-view filtering, and a pipeline-health diagnosis.
Auth: DebugGuard + AuthGuard
Response:
{
"exportedAt": "...",
"userId": "...",
"intents": {
"total": 10,
"byStatus": { "active": 8, "archived": 2 },
"withEmbeddings": 8,
"withHydeDocuments": 6,
"inAtLeastOneIndex": 7,
"orphaned": 1
},
"indexes": [
{ "indexId": "...", "title": "...", "userIntentsAssigned": 3 }
],
"opportunities": {
"total": 15,
"byStatus": { "pending": 5, "accepted": 10 },
"actionable": 4
},
"radarView": {
"cardsReturned": 4,
"filteredOut": {
"notActionable": 3,
"duplicateCounterpart": 2,
"notVisible": 6
}
},
"diagnosis": {
"hasActiveIntents": true,
"intentsHaveEmbeddings": true,
"intentsHaveHydeDocuments": true,
"intentsAreIndexed": true,
"hasOpportunities": true,
"opportunitiesReachRadar": true,
"bottleneck": null
}
}Returns a debug-friendly view of a chat session, including messages and per-turn debug metadata (graph, iterations, tools).
Auth: DebugGuard + AuthGuard
Path params:
id— Session (conversation) ID
Response:
{
"sessionId": "...",
"exportedAt": "...",
"title": "...",
"indexId": "...",
"messages": [
{ "role": "user | assistant", "content": "..." }
],
"turns": [
{
"messageIndex": 1,
"graph": "chat",
"iterations": 3,
"tools": [
{
"name": "...",
"args": { ... },
"resultSummary": "...",
"success": true,
"durationMs": 1234,
"steps": [...],
"graphs": [
{ "name": "...", "durationMs": 500, "agents": [...] }
]
}
]
}
],
"sessionMetadata": { ... }
}Controller prefix: /networks
Network object: Network responses include id, title, key, prompt, imageUrl, metadata, permissions, isPersonal, hasMasterKey, createdAt, updatedAt, owner user, and _count.members. hasMasterKey is true when master-key signup has been enabled on the network (only the key hash is stored server-side).
List networks the authenticated user is a member of, including their personal network.
Auth: AuthGuard
Response:
{
"networks": [...]
}Create a new index.
Auth: AuthGuard
Request body:
{
"title": "string (required)",
"prompt": "string (optional)",
"imageUrl": "string | null (optional)",
"joinPolicy": "anyone | invite_only (optional)"
}Response:
{
"index": { ... }
}Search users by name/email, optionally excluding existing members of an index.
Auth: AuthGuard
Query params:
q— Search query stringindexId— Exclude members of this network (optional)
Response:
{
"users": [...]
}Get all members of every network the signed-in user is a member of (deduplicated). Used for @mentions in chat.
Auth: AuthGuard
Response:
{
"members": [...]
}Get public networks the user has not joined.
Auth: AuthGuard
Response:
{
"networks": [...]
}Get an index by its invitation share code. Used for invitation page preview.
Auth: None (public)
Path params:
code— Invitation share code
Response:
{
"index": { ... }
}Get a public network by ID. Only works for indexes with joinPolicy: 'anyone'.
Auth: None (public)
Path params:
id— Network ID
Response:
{
"index": { ... }
}Get non-personal networks shared between the authenticated user and a target user.
Auth: AuthGuard
Path params:
userId— Target user ID
Response:
{
"networks": [...]
}Accept an invitation to join an index using the invitation code.
Auth: AuthGuard
Path params:
code— Invitation code
Response: JSON with accepted index details.
Update a network's human-readable key. Owner only.
Auth: AuthGuard
Path params:
id— Network ID
Request body:
{
"key": "string (required)"
}Key must match /^[a-z0-9][a-z0-9-]*[a-z0-9]$/, be 3–64 characters, and not collide with an existing key.
Response: JSON with updated network or 400/409 validation errors.
Get a single network by ID with owner info and member count. Members only.
Auth: AuthGuard
Path params:
id— Network ID
Response:
{
"index": { ... }
}Update an index (title, prompt, image, join policy). Owner only.
Auth: AuthGuard
Path params:
id— Network ID
Request body:
{
"title": "string (optional)",
"prompt": "string | null (optional)",
"imageUrl": "string | null (optional)",
"joinPolicy": "anyone | invite_only (optional)"
}Response:
{
"index": { ... }
}Soft-delete a network. Owner only.
Auth: AuthGuard
Path params:
id— Network ID
Response:
{ "success": true }Enable master-key signup on a network. Owner only, any network. Generates a master key, stores only its hash, and returns the plaintext exactly once — the caller must store it. Enabling forces joinPolicy: 'invite_only' on the network so key-provisioned networks are not openly joinable; owners can change the permissions afterwards.
Auth: AuthGuard (session or API key)
Path params:
id— Network ID
Request body: none
Response 201:
{
"masterKey": "<plaintext-64-chars>"
}Errors:
403/404— Caller is not an owner of the network, or the network does not exist.
Rotate the master key on any network with a master key enabled. Owner only. The plaintext is returned exactly once; the previous key stops working immediately. Every owner of the network also receives the new key by email.
Auth: AuthGuard (session or API key)
Path params:
id— Network ID
Request body: none
Response:
{
"masterKey": "<plaintext-64-chars>"
}Errors:
403/404— Caller is not an owner of the network, or the network does not exist.
Get members of an index. Owner only.
Auth: AuthGuard
Path params:
id— Network ID
Response:
{
"members": [...],
"metadataKeys": [],
"pagination": { "page": 1, "limit": 10, "total": 10, "totalPages": 1 }
}Add a member to an index. Owner only.
Auth: AuthGuard
Path params:
id— Network ID
Request body:
{
"userId": "string (required)",
"permissions": ["owner"] | ["member"] (optional, defaults to ["member"])
}Response:
{
"member": { ... },
"message": "Member added | Already a member"
}Errors: 400 — permissions provided but not exactly ['owner'] or ['member'], or not an array. 403 — requester is not an owner.
Change a member's role (promote to owner or demote to member). Owner only. Cannot change your own role. Cannot change contacts.
Auth: AuthGuard
Path params:
id— Network IDmemberId— User ID of the member to update
Request body:
{
"permissions": ["owner"] | ["member"]
}Response:
{
"member": { ... },
"message": "Role updated"
}Errors: 400 — permissions not exactly ['owner'] or ['member']. 403 — requester is not an owner, or attempting to demote the last owner. 404 — target user is not a member.
Remove a member from an index. Owner only. Cannot remove yourself.
Auth: AuthGuard
Path params:
id— Network IDmemberId— User ID to remove
Response:
{ "success": true }Update index permissions (join policy). Owner only.
Auth: AuthGuard
Path params:
id— Network ID
Request body:
{
"joinPolicy": "anyone | invite_only (optional)"
}Response:
{
"index": { ... }
}Get current user's member settings (permissions and ownership status).
Auth: AuthGuard
Path params:
id— Network ID
Response: JSON with member settings.
Get current user's intents in an index. Members only.
Auth: AuthGuard
Path params:
id— Network ID
Response:
{
"intents": [...]
}Join a public network.
Auth: AuthGuard
Path params:
id— Network ID
Response:
{
"index": { ... }
}Errors:
404— Index not found403— Index not public
Leave an index. Members (non-owners) can leave.
Auth: AuthGuard
Path params:
id— Network ID
Response:
{ "success": true }Errors:
404— Not found or not a member400— Cannot leave (owner)
Headless master-key signup. Provisions or re-provisions a user account and returns an API key bound to a network-scoped personal agent. Never sends email. Optional rich profile fields (name, bio, location, socials) are applied to the account immediately, and automatic enrichment may run while the user remains a current network member. The same imported fields are retained as provenance seeds so onboarding preview and confirmation can explain and refine the active profile; network-scoped seed reads never fall back across networks.
Auth: MasterKeyGuard — x-api-key header containing the network's master key (issued once when master-key signup is enabled via POST /api/networks/:id/master-key, stored by the caller).
Path params:
id— Network ID (must have a master key enabled).
Request body (email required; all other fields optional):
{
"email": "attendee@example.com",
"name": "Alice Example",
"bio": "Independent researcher.",
"location": "Healdsburg, CA",
"socials": [
{ "label": "telegram", "value": "@alice" }
]
}Validation caps: name 200 chars, bio 2000 chars, location 200 chars, socials ≤ 32 entries, each label 64 chars, each value 256 chars. socials labels are open vocabulary.
Response 201 (new user created):
{
"user": { "id": "uuid", "email": "attendee@example.com" },
"apiKey": "ix_...",
"mcpServer": {
"name": "index",
"url": "https://protocol.index.network/mcp",
"headers": { "x-api-key": "ix_..." }
}
}Response 200 (existing user): Same shape. A fresh API key is always returned; previously issued keys keep working — prior keys are deliberately not revoked, because signup may be retried by portals/installers and invalidating a just-installed key creates a setup race.
Idempotency: Same email = same user. Each call mints an additional key on the same network-scoped agent — store the latest returned apiKey. No orphan agent records: repeated calls reuse the same scoped agent.
Errors:
400— Missing/invalid email; oversized field; malformedsocialsarray.401— Missingx-api-keyheader.403— Master key invalid; network has no master key enabled; network deleted.
Read-only sibling of /signup. Verifies, without side effects, that a given email is fully provisioned for this network — user is live, member of the network, and has a network-scoped personal agent. Use this to check provisioning state without minting an additional API key (which is what /signup does on every call).
Auth: MasterKeyGuard — x-api-key header containing the network's master key.
Path params:
id— Network ID (must have a master key enabled).
Request body:
{ "email": "attendee@example.com" }Only email is read; any other fields in the body are ignored. Email is normalized (lowercased + trimmed) before lookup.
Response 200 (fully provisioned):
{ "user": { "id": "uuid", "email": "attendee@example.com" } }The response does not include an API key or an MCP server config — the integrator is presumed to hold the key from its original /signup call. If the key has been lost, call /signup to mint a fresh one (previously issued keys remain valid).
Response 409 — User is not in a fully-provisioned state. A single canned message is returned for every "no" path (email unknown, user soft-deleted, no membership, membership soft-deleted, no scoped agent, scoped agent soft-deleted). The integrator's recovery is the same in all cases: call /signup proper.
{ "error": "User has not completed signup for this network" }Idempotency: 100% read-only. Safe to call from retry loops, dashboards, or health probes. Calling 1× or N× has identical effect.
Errors:
400— Missing or malformed email; unparseable body.401— Missingx-api-keyheader.403— Master key invalid; network has no master key enabled; network deleted.
Example (curl):
curl -X POST https://protocol.index.network/api/networks/<NETWORK_ID>/signup/lookup \
-H 'x-api-key: <MASTER_KEY>' \
-H 'content-type: application/json' \
-d '{ "email": "attendee@example.com" }'Parse a CSV file and validate rows before committing an import. Owner-only, any network. Intended for large files (> 500 rows) where client-side parsing is skipped.
Auth: AuthGuard; caller must own the network.
Path params:
id— Network ID.
Request: Multipart form data with a file field containing the CSV.
Response 200:
{
"valid": [{ "email": "a@example.com", "name": "Alice" }],
"invalid": [{ "row": { "email": "" }, "reason": "Missing email" }]
}Errors:
400— No file supplied or CSV is unparseable.403— Not the network owner or scope violation.
Import validated rows (from /import/parse) into the network. Owner-only, any network. CSV rows provision users, scoped agents, and memberships immediately and apply optional profile columns (name, bio, location, socials) to the active account. Automatic enrichment may run while each user remains a current network member. The same imported fields are retained as provenance seeds so onboarding preview and confirmation can explain and refine the active profile; network-scoped seed reads never fall back across networks.
Auth: AuthGuard; caller must own the network.
Path params:
id— Network ID.
Request body:
{ "members": [{ "email": "a@example.com", "name": "Alice" }] }Response 200:
{ "imported": 42, "skipped": 3, "ownersNotified": 1 }imported— Number of accounts provisioned and added as members. Rich profile fields are applied immediately and retained as provenance seeds.skipped— Number of rows that were skipped (errors).ownersNotified— Number of network owners who received a credentials summary email. The email contains an inline CSV with every minted API key (email,name,api_key). Per-user invitation emails are not sent for bulk imports — the owner distributes keys out-of-band.
Errors:
400—membersarray is missing or empty.403— Not the network owner or scope violation.
Invite a single member to a network by email. Owner-only, any network. Idempotent on the (user, network) pair: re-inviting a user who already has a network-scoped agent is a no-op (no key minted, no email re-sent). A user who exists but lacks a scoped agent for this network is provisioned and emailed the same way a brand-new user is.
Auth: AuthGuard; caller must own the network.
Path params:
id— Network ID.
Request body:
{ "email": "attendee@example.com", "name": "Optional Name" }Response 201 (user newly created): A network-scoped personal agent and API key are provisioned, and an invitation email containing the connect command is sent.
{
"user": { "id": "user-uuid", "email": "attendee@example.com" },
"created": true,
"alreadyMember": false,
"agentProvisioned": true
}Response 200 (user already exists): Status code is 200 regardless of whether a scoped agent had to be provisioned. Examples:
- Pre-existing user without a scoped agent — agent + key minted, invitation email sent:
{ "user": { "id": "user-uuid", "email": "attendee@example.com" }, "created": false, "alreadyMember": false, "agentProvisioned": true } - Pre-existing user already provisioned and already a member — pure no-op:
{ "user": { "id": "user-uuid", "email": "attendee@example.com" }, "created": false, "alreadyMember": true, "agentProvisioned": false }
The raw API key is delivered only via the invitation email and is never returned in this response. Use POST /api/networks/:id/signup (master-key auth) for headless flows that need the key in-band.
Errors:
400— Missing or malformed email.403— Not the network owner or scope violation.409— Email belongs to a soft-deleted account and cannot be invited.500— Provisioning failed.
Resend the invitation email to an existing network member. Owner-only, any network. Used when a member did not receive their initial invitation email or requests a refreshed API key. If the member already has a network-scoped agent, its API key is rotated (previous keys revoked, a fresh one minted); if the member has no scoped agent yet (e.g. they joined via another path), a fresh agent and key are provisioned instead.
Auth: AuthGuard; caller must own the network.
Path params:
id— Network ID.memberId— User ID of the network member to resend the invite to.
Request body:
{}Response 200: Invitation email resent with a newly minted API key. rotated is false when the member had no network-scoped agent yet — a fresh agent and key were provisioned, and no prior key existed.
{
"rotated": false,
"email": "attendee@example.com"
}Response 200 with key rotation: The member's existing network-scoped agent keys were revoked and a new one was minted before sending the email.
{
"rotated": true,
"email": "attendee@example.com"
}When rotated: true, the member's previous API keys are no longer valid and the new key is delivered only via the resent invitation email.
Errors:
403— Not the network owner or scope violation.404— Member not found or not a member of this network.500— Provisioning or email delivery failed.
Controller prefix: /integrations
Supported toolkits: gmail, slack, telegram
Telegram is a bot-based notification connection (not a Composio OAuth toolkit). It doesn't use
/linkor/import; connection is established via a deep link returned byPOST /connect/telegram, and disconnection is viaDELETE /:idwithid = telegram:<userId>.
List connected accounts for the authenticated user.
Auth: AuthGuard
Query params:
indexId— Filter to connections linked to this network (optional)
Response:
{
"connections": [...]
}Start OAuth flow to connect a toolkit.
Auth: AuthGuard
Path params:
toolkit—gmail,slack, ortelegram
Response:
- For
gmail/slack: OAuth redirect URL from the integration adapter. - For
telegram:{ "deepLink": "https://t.me/<bot_username>?start=<token>" }where<token>is a short-lived one-time token (15 min TTL). Opening the link prompts Telegram to message the bot with/start <token>, which completes the connection. If Telegram includesmessage.from.username, the gateway also upserts a publicuser_socialsrow withlabel = 'telegram'while preserving other socials.
Link a toolkit connection to an index.
Auth: AuthGuard
Path params:
toolkit—gmailorslack
Request body:
{
"indexId": "string (required)"
}Response:
{ "success": true }Unlink a toolkit from an index. Does not revoke the OAuth connection.
Auth: AuthGuard
Path params:
toolkit—gmailorslack
Query params:
indexId— Network to unlink from (required)
Response:
{ "success": true }Disconnect (delete) a connected account.
Auth: AuthGuard
Path params:
id— Connection ID (ortelegram:<userId>for Telegram)
Behavior:
- Composio connections (
gmail/slack): disconnects the OAuth account and removes all index integration links. - Telegram (
telegram:<userId>): clears the stored chatId and notification prefs. The deep-link token is unchanged; reconnect viaPOST /connect/telegram.
Response: Disconnect result.
Controller prefix: /webhooks
Inbound endpoint for Telegram Bot API updates. Called by Telegram when the bot receives a message (text or /start <token> deep-link callback).
Auth: Header X-Telegram-Bot-Api-Secret-Token must match TELEGRAM_WEBHOOK_SECRET. Otherwise responds 401.
Body: Telegram Update object (JSON). The handler inspects message.chat.id, message.text, and optional message.from.username. When a valid username is present, it is stored as the user's public Telegram social handle without clearing other socials.
Response: Always 200 OK. Inbound handling is fire-and-forget so the endpoint never blocks Telegram's delivery pipeline.
Half-configured gateway: inbound is gated by TELEGRAM_WEBHOOK_SECRET and outbound by TELEGRAM_BOT_TOKEN, so a deployment with only the secret set authenticates updates it can never answer. When the bot token is missing the update is acknowledged and dropped without running the inbound path — the chat agent does not run, no chat session or message rows are written, and one warning is logged per process. The response stays 200 deliberately: a non-2xx makes Telegram retry the same update indefinitely. To disable the gateway cleanly, unset both variables.
Registered automatically at backend startup via
setWebhookwhenTELEGRAM_BOT_TOKENandTELEGRAM_WEBHOOK_SECRETare configured.
Controller prefix: /intents
List intents with pagination and filters.
Auth: AuthGuard
Request body:
{
"page": "number (optional)",
"limit": "number (optional)",
"archived": "boolean (optional)",
"sourceType": "string (optional)"
}Response:
{
"intents": [
{
"id": "...",
"payload": "...",
"summary": "...",
"createdAt": "...",
"updatedAt": "...",
"archivedAt": "... | null",
"waitingOpportunityCount": "number"
}
],
"totalWaitingOpportunities": "number",
"pagination": { ... }
}waitingOpportunityCount includes only distinct, still-pending opportunities
awaiting the authenticated user that are attributed to that signal. The top-level
totalWaitingOpportunities deduplicates rows that are attributed to more than one
listed signal.
Confirm a proposed intent from chat. The proposal ID resolves a durable, owner-scoped record created by the verifier tool. That record binds the exact normalized description, optional network, complete verifier output, and a 24-hour expiry. Caller fields are matching assertions only; verifier analysis is never accepted from the client.
The server locks and rechecks the proposal plus any required current membership in the
same transaction that inserts the intent, writes the exact entropy/anchor/mode/speech
act/felicity columns, creates the optional network assignment, and consumes the
proposal. Concurrent/retried confirmation replays the winning intent without duplicate
question or event side effects. The transaction winner obtains indexing-queue
acknowledgement before those effects; if admission fails after commit, the API returns
retryable 503 intent_admission_enqueue_failed, and an exact consumed-proposal retry
re-attempts the idempotent scoped admission without creating another intent.
Missing/foreign proposals return 404 proposal_not_found, expired proposals return 410 proposal_expired, and
consumed/rejected, mismatched, or invalid-analysis proposals return a typed 409.
Missing or soft-deleted membership returns HTTP 403
network_membership_required with no intent persisted.
Auth: AuthGuard
Request body (Zod-validated):
{
"proposalId": "UUID (required)",
"description": "string (required)",
"networkId": "UUID (optional)"
}Response:
{
"success": true,
"proposalId": "...",
"intentId": "..."
}Reject a pending, unexpired proposal owned by the authenticated user. Rejection durably consumes the approval opportunity so it cannot later be confirmed.
Auth: AuthGuard
Request body (Zod-validated):
{
"proposalId": "string (required)"
}Response:
{
"success": true,
"proposalId": "..."
}Batch-check proposal statuses. Returns which proposal IDs have been confirmed.
Auth: AuthGuard
Request body (Zod-validated):
{
"proposalIds": ["string"]
}Response:
{
"statuses": { ... }
}Get a single intent by ID.
Auth: AuthGuard
Path params:
id— Intent ID
Response:
{
"intent": {
"id": "...",
"payload": "...",
"summary": "...",
"status": "ACTIVE | PAUSED | FULFILLED | EXPIRED",
"createdAt": "...",
"updatedAt": "...",
"archivedAt": "... | null"
}
}Explicitly records that the owner mounted the intent page. This endpoint is session-only: API keys are rejected with 403. The timestamp is monotonic, does not modify intent.updatedAt, and is used only to suppress proactive pool-question delivery when the visit is later than the question. GET /api/intents/:id never stamps a visit.
Auth: SessionOnlyGuard; owner-only
Response: { "success": true, "lastVisitedAt": "<ISO-8601>" } (200), or 404 for missing/foreign intents.
Pause or resume an intent. The transition is idempotent.
Auth: AuthGuard. The intent must belong to the authenticated user. A network-scoped agent may update it only when the intent is assigned to the agent's bound network; scope violations return 403.
Path params:
id— Intent UUID or unambiguous short prefix
Request body (Zod-validated):
{ "status": "PAUSED" }Use PAUSED to pause or ACTIVE to resume. No other lifecycle status is accepted. Archived intents and terminal FULFILLED or EXPIRED intents return 409.
Response 200:
{
"success": true,
"intent": {
"id": "...",
"status": "ACTIVE | PAUSED",
"lifecycleVersionMs": 1784102400000
},
"changed": true
}changed is false when the requested status is already effective. A null legacy status is normalized to ACTIVE. On resume, the service immediately enqueues a from-intent discovery job deduplicated by the intent lifecycle version; the response waits for the queue's enqueue acknowledgement before returning success. If enqueue fails after this request changed PAUSED to ACTIVE, the service compare-and-sets that exact lifecycle version back to PAUSED without overwriting concurrent lifecycle changes. An idempotent ACTIVE request is never paused by compensation.
Pause is non-destructive: existing opportunities and Radar cards, pending questions, conversations, intent-network assignments, and HyDE documents remain available. It blocks admission of not-yet-started intent-driven discovery, candidate matching against the intent, new pool mining/questions, and answer-triggered Tier-1 reruns. Work already admitted may finish. Existing pending questions remain answerable, and their deterministic Tier-0 re-ranking can still apply. After resume, ordinary pool mining and question generation follow the newly enqueued discovery run.
Errors:
400— invalid request body or unsupported status403— network-scoped agent is not allowed to act on the intent404— intent not found or not owned by the authenticated user409— ambiguous short prefix, archived intent, or terminal intent503— resume enqueue was not acknowledged; returns{ "error": "Failed to enqueue intent resume", "code": "enqueue_failed", "retryable": true, "intent": { "id": "...", "status": "ACTIVE | PAUSED" } }.PAUSEDmeans compensation succeeded;ACTIVEis the authoritative status when compensation did not apply or the request was idempotent.
Archive an intent.
Auth: AuthGuard
Path params:
id— Intent ID
Response:
{ "success": true }Controller prefix: /intents/intake
Deterministic fast-intake funnel for /i/new (dark-shipped behind FAST_SIGNAL_INTAKE=true). Round 1 is served from a per-user pack generated offline (no model call); follow-up questions are planned in one structured gemini-2.5-flash call whose total interview length (total, round 1 included) is then locked; synthesis is one more structured call. Two server-side knobs shape the interview: SIGNAL_INTAKE_MAX_QUESTIONS (total budget including round 1, integer 1–10, default 2) and SIGNAL_INTAKE_QUESTION_MODE (singular = one follow-up per /question turn, default; plural = the whole remaining batch in one turn). The where/community round is resolved entirely client-side and only travels as whereText/networkId on /proposal. Every route below is gated by FastSignalIntakeEnabledGuard, which runs before AuthGuard and throws when the flag is off, so a flag-off deployment returns 404 { "error": "Not found" } even to unauthenticated callers (mirrors ContactsEnabledGuard). /prepare and /revise use the intake_synthesis rate-limit class (see Rate Limiting in the development reference) instead of write, since each call launches a background LLM synthesis plus a full intent-graph run and persists a durable proposal row.
A shared IntakePackQuestion shape ({ title, prompt, options: [{ label, description }], multiSelect }) is returned for every generated question. An answer is { selectedOptions: string[], freeText?: string } with at least one of selectedOptions/freeText required, and an answered round is IntakeRound = { prompt: string (1–400 chars), answer }. The server holds no funnel state: every route below takes the ordered rounds list (round 1 first, 1–10 entries), and /question continuation calls echo the locked plannedTotal (integer 1–10), which the server re-clamps to the configured budget.
Shared error responses (in addition to per-route errors below):
404—{ "error": "run_not_found", "code": "run_not_found" }whenrunIddoes not resolve to a run owned by the caller.422—{ "error": "verification_rejected", "code": "verification_rejected", "clarification": IntakePackQuestion }when synthesis produced nothing specific enough to verify; the client rendersclarificationas a recovery round and retries.403—{ "error": "forbidden", "code": "network_membership_required", "networkId": "..." }when the caller is not a member of the suppliednetworkId.400—{ "error": "Validation failed", "details": { ... } }(flattened Zod error) for malformed bodies.500—{ "error": "Failed to process intake request" }for unexpected failures.
Round 1: read the user's precomputed intake pack, or generate one synchronously on a cold miss (no request body).
Auth: RateLimit('write'), FastSignalIntakeEnabledGuard, AuthGuard
Response:
{ "question": { "title": "...", "prompt": "...", "options": [{ "label": "...", "description": "..." }], "multiSelect": true } }Follow-ups: one structured planning call grounded by the pack brief and every answered round. In singular mode the response carries one question per call; in plural mode it carries the whole remaining planned batch. Either way total is the locked interview length (round 1 included); the client stops asking and advances to /prepare once rounds.length reaches total, or when questions is empty.
Auth: RateLimit('write'), FastSignalIntakeEnabledGuard, AuthGuard
Request body (Zod-validated):
{
"rounds": [{ "prompt": "...", "answer": { "selectedOptions": ["..."], "freeText": "string (optional)" } }],
"plannedTotal": "integer (optional, 1-10; echo of the locked total on continuation calls)"
}Response:
{
"questions": [{ "title": "...", "prompt": "...", "options": [{ "label": "...", "description": "..." }], "multiSelect": true }],
"total": 2
}Claim a run and start speculative synthesis (signal synthesis plus the intent graph) without awaiting it, so it can overlap the client's where/community picker.
Auth: RateLimit('intake_synthesis'), FastSignalIntakeEnabledGuard, AuthGuard
Request body (Zod-validated):
{
"rounds": [{ "prompt": "...", "answer": { "selectedOptions": ["..."], "freeText": "string (optional)" } }]
}Response 202:
{ "runId": "<uuid>" }Resolve the proposal for a run: awaits the speculative synthesis started by /prepare when it is still in flight, reuses it when ready and unconstrained, or re-synthesizes when a whereText constraint is supplied or the prior speculation failed.
Auth: RateLimit('write'), FastSignalIntakeEnabledGuard, AuthGuard
Request body (Zod-validated):
{
"runId": "UUID (required)",
"rounds": [{ "prompt": "...", "answer": { "selectedOptions": ["..."], "freeText": "string (optional)" } }],
"networkId": "UUID (optional)",
"whereText": "string (optional, max 280)"
}networkId is the community the user picked in the where round; it is re-verified server-side against the caller's actual membership (see network_membership_required above) and attached to the proposal row so POST /api/intents/confirm later rejects any mismatched networkId.
Response:
{
"proposalId": "...",
"description": "...",
"lookingFor": "...",
"youBring": "..."
}Replace the visible draft from user feedback: writes a brand-new proposal row (the prior one is left untouched) and re-attaches the run's already-picked networkId, if any.
Auth: RateLimit('intake_synthesis'), FastSignalIntakeEnabledGuard, AuthGuard
Request body (Zod-validated):
{
"runId": "UUID (required)",
"feedback": "string (required, 1-600 chars)",
"rounds": [{ "prompt": "...", "answer": { "selectedOptions": ["..."], "freeText": "string (optional)" } }],
"networkId": "UUID (optional)"
}Response:
{
"proposalId": "...",
"description": "...",
"lookingFor": "...",
"youBring": "..."
}Controller prefix: /opportunities
List opportunities for the authenticated user.
Auth: AuthGuard
Query params:
status— Filter by status:pending,stalled,accepted,rejected,expired(optional)networkId— Filter by network (optional)scopeType— Optional selected scope type. Useintentfor selected-intent scope.scopeId— Required whenscopeType=intent; viewer-owned selected intent UUID. Composes withnetworkId; it never broadens network visibility. Rows are returned only when every participant retains an active anchor in the intent's currentintent_networks ∩ viewer membershipsscope (paused-intent history is allowed).intentId— Deprecated/convenience alias forscopeType=intent&scopeId=<intentId>.limit— Max results (optional)offset— Pagination offset (optional)
Response:
{
"opportunities": [...]
}interpretation.reasoning in this user-facing list is safety-normalized. Unsupported attendance, network/community membership, residence, acquaintance, shared-session, and same-place/time claims are removed rather than returned as raw evaluator text.
Get shared accepted opportunities between the authenticated user and a peer, used as chat context.
Auth: AuthGuard
Query params:
peerUserId— Peer user ID (required)
Response: JSON with opportunity cards for chat context.
Radar view: a flat list of opportunity cards with presenter text, optionally scoped to one intent. Clients bucket by lifecycle status themselves.
Auth: AuthGuard
Query params:
networkId— Scope to a specific network (optional)scopeType— Optional selected scope type. Useintentfor selected-intent scope.scopeId— Required whenscopeType=intent; viewer-owned selected intent UUID. Applied before radar visibility filtering, sorting, and counterpart dedupe. Pool-answer factors and deprioritization reasons apply only when theirrecipientUserId + intentIdprovenance exactly matches this viewer and selected intent; global Radar and legacy unscoped adjustments ignore them.intentId— Deprecated/convenience alias forscopeType=intent&scopeId=<intentId>.limit— Max results (optional)noCache— Bypass radar cache whentrueor1(optional)
Response: JSON with a flat items array of presenter cards plus meta. Presenter output and deterministic fallbacks reject unsupported attendance/membership/residence/shared-presence claims. Presentation caches are versioned and fallback output is not persisted.
Get one opportunity with presentation for the viewer. If the requested opportunity was expired because it was superseded by an enriched opportunity, the endpoint returns the newest visible replacement using the existing detection.enrichedFrom link.
Auth: AuthGuard
Path params:
id— Opportunity ID
Response: JSON with opportunity details and presentation. When a replacement was returned, id is the replacement opportunity ID and resolvedFromOpportunityId contains the originally requested ID.
Update opportunity status.
Auth: AuthGuard
Path params:
id— Opportunity ID
Request body:
{
"status": "latent | draft | negotiating | pending | stalled | accepted | rejected | expired",
"scopeType": "intent (optional)",
"scopeId": "selected intent UUID when scopeType=intent (optional)",
"intentId": "deprecated/convenience alias for scopeType=intent&scopeId=<intentId> (optional)",
"acknowledgedUptakeQuestionIds": ["question UUIDs from the latest advisory (optional)"]
}When selected-intent scope is supplied, an accepted update affects only this opportunity row and does not accept same-counterpart sibling opportunities from other intents. Unscoped behavior preserves existing sibling acceptance.
Response: JSON with updated opportunity.
Error responses:
403— Caller is not an actor on the opportunity404— Opportunity not found409— Self-accept blocked. Caller's actor already hasactedAtset (they advanced the opportunity earlier) and is attempting to accept it. The other party must accept. Seedocs/domain/opportunities.md#bilateral-acceptance.409— Uptake soft interlock. When the feature is enabled and unresolved preparatory questions exist, the response containsadvisory.code = "unresolved_uptake_questions", public question payloads, andacknowledgedUptakeQuestionIds. No DM, status, sibling, or contact mutation has occurred. Answer/dismiss the questions and retry normally, or retry with the complete current ID list to continue anyway.
Atomically accept a pending or draft opportunity and resolve the h2h conversation for the actor pair. Backs the Start Chat button on both ambient (pending) and chat-discovered (draft) opportunity cards so the frontend can navigate directly to /chat/:conversationId in a single round-trip.
Runs the same side effects as PATCH .../status with status=accepted (sibling acceptance, contact membership upsert), plus getOrCreateDM(userA, userB) to resolve/create the DM conversation. Does not insert a seed system message — the accepted opportunity itself renders inline in the chat timeline (per IND-237).
Auth: AuthGuard
Path params:
id— Opportunity ID (full UUID or short prefix; resolved server-side)
Request body: empty, or an optional selected-intent scope body:
{
"scopeType": "intent",
"scopeId": "selected intent UUID",
"intentId": "deprecated/convenience alias for scopeType=intent&scopeId=<intentId>",
"acknowledgedUptakeQuestionIds": ["question UUIDs from the latest advisory"]
}When selected-intent scope is supplied, sibling acceptance is skipped. Unscoped behavior preserves existing same-counterpart sibling acceptance.
Response:
{
"conversationId": "string",
"counterpartUserId": "string",
"opportunity": { "id": "string", "status": "accepted", "...": "..." }
}Error responses:
400— Opportunity is not inpendingordraftstatus403— Caller is not an actor on the opportunity404— Opportunity not found409— Self-accept blocked. Caller's actor already hasactedAtset. Seedocs/domain/opportunities.md#bilateral-acceptance.409— Uptake soft interlock with the same structured advisory and no side effects as the status endpoint. Every caller reaches this endpoint authenticated, so the advisory is the single interlock contract; the unauthenticated connect-link continuation flow that used to re-render it no longer exists.500— Status update or DM resolution failed
Issue a single-use owner-approval proof for a pending MCP-agent interaction challenge (IND-593). Agents calling update_opportunity (send/accept/reject) over MCP without a proof receive an owner_approval_required denial carrying a fresh interactionId challenge; the owner explicitly approves that exact interaction here, and the returned proof is relayed to the agent for one retry.
The proof binding (opportunity, action, owner principal, acting agent, interaction) comes entirely from the server-side challenge store — the request only names the challenge; caller-supplied binding fields are never accepted. Proofs are HMAC-signed, expire with the challenge (10 minutes), are minted at most once per challenge (atomic one-shot issuance), and are atomically single-use on consumption. Challenge state lives in a shared store (Redis-backed in production) keyed by opaque hashes; store or configuration failures fail closed.
Auth: AuthGuard + authenticated owner session (session auth only — API-key/agent callers cannot self-issue owner authorization)
Path params:
id— Opportunity ID (full UUID or short prefix; resolved server-side). Must equal the challenge's opportunity.
Request body:
{
"interactionId": "interaction challenge UUID from the agent's owner_approval_required denial"
}Response:
{
"proof": "opaque single-use approval token",
"expiresAt": "ISO timestamp",
"approval": {
"interactionId": "string",
"opportunityId": "string",
"action": "send | accept | reject",
"agentId": "string"
}
}Error responses:
403— Not an authenticated owner session, or the session principal is not the challenge's owner404— Unknown approval interaction, or the challenge belongs to a different opportunity (opaque — no existence oracle; a mismatched route never mints a proof and never consumes the challenge's one-shot issuance)409— An approval proof was already issued for this interaction (issuance is one-shot)410— Approval interaction has expired (the agent must retry to obtain a fresh challenge)503— Approval service unavailable (store/configuration failure — fails closed)
Controller prefix: /networks (separate controller registered alongside NetworkController)
List opportunities for an index. Requires membership.
Auth: AuthGuard
Path params:
indexId— Network ID
Query params:
status— Filter by status (optional)limit— Max results (optional)offset— Pagination offset (optional)
Response:
{
"opportunities": [...]
}User-facing interpretation.reasoning is safety-normalized using the same deterministic affiliation/presence guard as the per-user list.
Create a manual opportunity (curator). Requires owner or member permission.
Auth: AuthGuard
Path params:
indexId— Network ID
Request body:
{
"parties": [
{ "userId": "string", "intentId": "string (optional)" }
],
"reasoning": "string (required)",
"category": "string (optional)",
"confidence": "number (optional)"
}parties must contain at least 2 entries.
Response (201): JSON with the created opportunity. The response reasoning is safety-normalized; persistence-boundary validation rejects unsupported attendance/membership/residence/shared-presence claims.
Controller prefix: /enrichment
Trigger enrichment sync/generation for the authenticated user. Runs the enrichment graph.
Auth: AuthGuard
Response: JSON with enrichment result.
Controller prefix: /storage
Upload a library file to S3.
Auth: AuthGuard
Content-Type: multipart/form-data
Form field: file — The file to upload
Response:
{
"message": "File uploaded successfully",
"file": {
"id": "...",
"name": "...",
"size": "...",
"type": "...",
"createdAt": "...",
"url": "..."
}
}List library files for the authenticated user.
Auth: AuthGuard
Query params:
page— Page number (default: 1)limit— Items per page (default: 100, max: 100)
Response:
{
"files": [...],
"pagination": { ... }
}Download a library file (streams content from S3).
Auth: AuthGuard
Path params:
id— File ID
Response: Binary file content with Content-Disposition: attachment.
Soft-delete a library file.
Auth: AuthGuard
Path params:
id— File ID
Response:
{ "success": true }Upload an avatar image to S3.
Auth: AuthGuard
Content-Type: multipart/form-data
Form field: avatar — The image file
Response:
{
"message": "Avatar uploaded successfully",
"avatarUrl": "..."
}Serve an avatar image (public, streamed from S3).
Auth: None (public)
Path params:
userId— User IDfilename— Avatar filename
Response: Image binary with Cache-Control: public, max-age=31536000, immutable.
Upload an index/network image to S3.
Auth: AuthGuard
Content-Type: multipart/form-data
Form field: image — The image file
Response:
{
"message": "Index image uploaded successfully",
"imageUrl": "..."
}Serve an index image (public, streamed from S3).
Auth: None (public)
Path params:
userId— User IDfilename— Image filename
Response: Image binary with Cache-Control: public, max-age=31536000, immutable.
Controller prefix: /subscribe
Subscribe to newsletter or waitlist via Loops.so.
Auth: None (public)
Request body:
{
"email": "string (required)",
"type": "newsletter | waitlist (optional, default: newsletter)",
"name": "string (optional)",
"whatYouDo": "string (optional)",
"whoToMeet": "string (optional)"
}Response:
{ "success": true }Controller prefix: /users
Batch-fetch users by IDs (max 100).
Auth: AuthGuard
Query params:
ids— Comma-separated user IDs
Response:
{
"users": [
{
"id": "...",
"name": "...",
"intro": "...",
"avatar": "...",
"location": "...",
"socials": { ... },
"createdAt": "...",
"updatedAt": "..."
}
]
}Remove a contact from the authenticated user's personal network (soft delete of the 'contact' membership).
Auth: AuthGuard
Response: { "success": true } on success, 404 if the contact is not a member.
Trigger a discovery negotiation between the authenticated viewer and the target user. Responds with 400 if the viewer targets themselves, 404 if the target does not exist, 409 if a negotiation between the two parties is already in flight.
Auth: AuthGuard
Response (201):
{
"negotiation": {
"id": "...",
"segments": 1,
"state": "completed",
"statusMessage": null,
"statusTimestamp": "...",
"counterparty": { "id": "...", "name": "...", "avatar": null },
"outcome": {
"hasOpportunity": true,
"role": "agent",
"turnCount": 4,
"reason": "accepted"
},
"turns": [
{ "speaker": { "id": "...", "name": "...", "avatar": null }, "action": "propose", "reasoning": "...", "suggestedRoles": null, "createdAt": "..." }
],
"createdAt": "...",
"updatedAt": "..."
}
}List past negotiation threads for a user. Tasks sharing metadata.opportunityId are stitched into one thread; tasks without an opportunity ID remain independent. Messages are merged chronologically across all segments, while the newest segment supplies the response ID, state, status, outcome, and timestamps. When the viewer differs from the profile owner, only mutual negotiations are returned.
Auth: AuthGuard
Path params:
userId— User ID
Query params:
limit— Max threads (default: 20, max: 50)offset— Thread offset (default: 0)result— Filter by result:has_opportunity,no_opportunity,in_progress(optional)
Response:
{
"negotiations": [
{
"id": "...",
"segments": 2,
"state": "completed",
"statusMessage": null,
"statusTimestamp": "...",
"counterparty": { "id": "...", "name": "...", "avatar": "..." },
"outcome": {
"hasOpportunity": true,
"role": "...",
"turnCount": 3,
"reason": "..."
},
"turns": [
{
"speaker": { "id": "...", "name": "...", "avatar": "..." },
"action": "...",
"reasoning": "...",
"suggestedRoles": { ... },
"createdAt": "..."
}
],
"createdAt": "...",
"updatedAt": "..."
}
]
}Update the authenticated user's human-readable key.
Auth: AuthGuard
Request body:
{
"key": "string (required)"
}Key must match /^[a-z0-9][a-z0-9-]*[a-z0-9]$/, be 3–64 characters, and not collide with an existing key. Reserved words (me, new, edit, delete, settings, admin) are rejected.
Response: JSON with updated user or 400/409 validation errors.
Generate an aggregated AI insight summary of the user's negotiations. Self-only: only the authenticated user can view their own insights.
Auth: AuthGuard
Path params:
userId— User ID (must equal the authenticated user's ID)
Response:
{
"insights": {
"summary": "...",
"stats": {
"totalCount": 10,
"opportunityCount": 6,
"noOpportunityCount": 3,
"inProgressCount": 1,
"avgScore": 0.72,
"roleDistribution": { "Helper": 3, "Seeker": 2, "Peer": 1 },
"topCounterparties": [{ "id": "...", "name": "...", "avatar": "...", "count": 2 }]
}
}
}Returns { "insights": null } when no negotiations exist.
Errors:
403— Viewer is not the profile owner
Get a user by ID.
Auth: AuthGuard
Path params:
userId— User ID
Response:
{
"user": {
"id": "...",
"name": "...",
"intro": "...",
"avatar": "...",
"location": "...",
"socials": { ... },
"createdAt": "...",
"updatedAt": "..."
}
}Controller prefix: /tools
The Tool API exposes the same handlers used by the ChatAgent as direct HTTP endpoints. This enables external clients (CLI, plugins, third-party integrations) to invoke protocol tools without going through the LLM chat loop.
List all available tools with their names, descriptions, and input schemas.
Auth: AuthGuard
Response:
{
"tools": [
{
"name": "read_intents",
"description": "Read user's intents with optional filters.",
"schema": { "type": "object", "properties": { ... } }
}
]
}Invoke a tool by name with a JSON query body.
Auth: AuthGuard
Path params:
Request body:
{
"query": { ... }
}The query object is validated against the tool's Zod schema. If omitted or unparsable, defaults to {}.
Response (success): Tool-specific JSON result with 200 status.
Error responses:
400— Invalid request body or query validation failure401— Missing or invalid auth token403— User not found or deactivated404— Tool not found (Tool "xyz" not found. Available tools: ...)500— Internal error during tool execution
Tools are organized by domain. Each tool has its own input schema (see GET /api/tools for full schemas).
| Tool | Domain | Description |
|---|---|---|
read_user_contexts |
Profile | Read user identity and context (own or by query) |
preview_user_context |
Profile | Generate a non-persisted onboarding profile draft from allowed sources |
confirm_user_context |
Profile | Save an approved profile draft or explicit correction text and stamp profileConfirmedAt |
create_user_context |
Profile | Create or regenerate profile identity and context from social links or bio |
update_user_context |
Profile | Update profile details or merge reachable social handles |
get_enrichment_run |
Profile | Read the status and result of an asynchronous profile preview or update run |
cancel_enrichment_run |
Profile | Request cancellation of a queued or running profile preview or update run |
complete_onboarding |
Profile | Validate a durable profile-approval timestamp plus an active first signal created at or after it; optional intentId pins the exact eligible signal and records the completion handoff |
read_intents |
Intent | List user's intents with optional filters |
create_intent |
Intent | Create a new intent from natural language |
update_intent |
Intent | Update an intent (runs full graph pipeline) |
delete_intent |
Intent | Archive/delete an intent |
create_intent_index |
Intent | Link an intent to an index |
read_intent_indexes |
Intent | List networks linked to an intent |
delete_intent_index |
Intent | Unlink an intent from an index |
read_networks |
Network | List user's networks |
read_network_memberships |
Network | List members of a network |
update_network |
Network | Update network settings (title, prompt) |
create_network |
Network | Create a new network |
delete_network |
Network | Delete a network |
create_network_membership |
Network | Add a member to a network |
delete_network_membership |
Network | Remove a member from a network |
list_opportunities |
Opportunity | List user's opportunities with optional networkId and selected-intent scopeType: 'intent', scopeId filters |
update_opportunity |
Opportunity | Accept or reject an opportunity. Optional selected-intent scopeType/scopeId narrows mutation before graph execution. With the uptake guard enabled, a first accept can return success:false plus advisory.code="unresolved_uptake_questions" without mutation; retry with the current acknowledgedUptakeQuestionIds only after explicit user approval to continue anyway. Successful acceptance returns a conversationId. |
list_contacts |
Contact | List user's contacts |
remove_contact |
Contact | Remove a contact |
scrape_url |
Utility | Scrape and extract content from a URL |
read_docs |
Utility | Read protocol documentation |
Structured question delivery and lifecycle. Questions are generated asynchronously by the QuestionerAgent (behind QUESTIONER_ENABLED=true) and served to clients for user interaction.
Auth: Required (session or API key)
List pending questions for the authenticated user.
Query params:
| Param | Type | Default | Description |
|---|---|---|---|
status |
pending | answered | dismissed |
pending |
pending and answered are supported; dismissed is rejected. |
mode |
discovery | intent | enrichment | negotiation | negotiation_inflight | chat | pool_discovery |
— | Filter by generation mode (chat = in-chat ask_user_question questions; discovery rows are historical — the inline generator was removed) |
sourceType |
string | — | Filter by source type (e.g. discovery) |
sourceId |
string | — | Filter by source entity ID |
scopeType |
intent |
— | Selected scope type. Use with scopeId to restrict to a selected intent. |
scopeId |
UUID | — | Required when scopeType=intent. Non-negotiation modes retain their established scope rules. Negotiation-family rows require valid versioned exact-recipient provenance whose stamped intent equals this ID and whose current intent/network/opportunity/task state still validates; legacy or drifted rows fail closed. |
intentId |
UUID | — | Deprecated/convenience alias for scopeType=intent&scopeId=<intentId>. |
conversationId |
string | — | Filter to questions linked to a specific chat session |
noConversation |
true |
— | Exclude questions that have a conversationId (sidebar badge use) |
passive |
true |
— | Exact-intent refetch only. Requires scopeType=intent; suppresses visit-time pool-mining enqueue. Used by mounted-workspace invalidation, not initial active visitation. |
Unscoped/global reads always exclude pool_discovery; those rows are available only with an explicit intent scope. Negotiation-family pending rows are freshness-validated even without an explicit intent scope by using their stamped recipient intent; missing-provenance, crossed mode/purpose, drifted, or unsafe legacy rows fail closed. Answered inflight history retains the exact exchange after its own continuation changes task/opportunity state, but still requires current recipient ownership/fingerprint/network/opportunity actor binding plus the exact terminal settlement. Public rows strip internal pool snapshots, assignments, embeddings, push claims/status, cycle keys, the authoritative pushedAt ledger, negotiation provenance/purpose/task/network/fingerprint/lifecycle metadata, server session bindings, and actor network IDs. A messageId anchor survives an intent-scoped read only when its exact assistant message/session/conversation belongs to the authenticated user's exact negotiator-intent scope.
Response: { questions: PersistedQuestion[] }
Returns the canonical count split used by the two allowed surfaces. Counts require pending, unexpired, conversation-unbound rows. They are independent of the current push flag, so a delivered row is not hidden if the flag later turns off.
Auth: Session only (API keys are rejected)
{
"globalPending": 2,
"pushedPoolPending": 1,
"personalAgentPending": 3
}globalPending excludes every pool_discovery row and remains the Questions-page count. pushedPoolPending includes only pool_discovery rows with a successful internal pushedAt stamp. personalAgentPending is their sum and drives the Personal Agent badge. All three values are derived from the same freshness-validated rows as the global inbox, so stale negotiation provenance cannot remain in a badge after disappearing from a scoped read.
Auth: Required (session or API key)
Submit an answer for a pending question. For non-negotiation modes, existing actor/pending semantics apply. Negotiation-family answers additionally take the exact cohort lock, revalidate actor/provenance, owned ACTIVE fingerprint-equal intent, assignment/membership, opportunity actor visibility/state, and exact task state before any effect. When an inflight consultation was authorized by the default-off NEGOTIATION_CONSULTATION_POLICY_MODE, it has the identical private exact-seat/task contract; policy category is server-only telemetry and is never returned by this API. Stale rows fail closed/system-void without shared mutation or user-answer events. Uptake remains private; ordinary follow-up uses established shared metadata; inflight atomically closes only its stamped input_required task and writes a deterministic durable continuation request. If post-commit Redis delivery fails, the endpoint may fail after recording the answer; retrying the same answer is idempotent and reconciles the same settlement, while the armed expiry job is the process-boundary fallback.
Body:
{
"selectedOptions": ["Option A"],
"freeText": "optional free-text elaboration"
}Response: { success: true, resumed: boolean } (200) or { error: "Question not found" } (404)
resumed is true when a live chat turn was blocked on this question (the persona's ask_user_question tool) and now continues streaming with the answer. Clients should feed the answer back as a new chat message only when resumed is false and the question's mode is chat.
Auth: Required (session or API key)
Dismiss a pending question. Negotiation-family rows use the same exact cohort-first locked revalidation and durable exact-task continuation protocol as answers. Inflight dismissal applies the conservative no-disclosure default and dismisses only the exact stamped task cohort. Timeout uses that protocol even when no question row exists. All delivery/recovery attempts carry the deterministic settlement ID and never select a newer/latest opportunity task.
Response: { success: true } (200) or { error: "Question not found" } (404)
GET /dev/queues/
Auth: None (only available when NODE_ENV !== 'production')
Serves the Bull Board UI for monitoring BullMQ job queues. Monitors the following queues:
- notification
- intent
- opportunity
- profile
- questioner (when
QUESTIONER_ENABLED=true)
Accessible at http://localhost:3001/dev/queues/ when the protocol server is running in development mode.
Native clients authenticate with ordinary Better Auth API keys.
- The Hermes plugin uses an API key supplied through the
INDEX_API_KEYenvironment variable, sent asx-api-key. The dashboard's "log in with browser" runs the same web/cli-authstate-bound handshake as the CLI and Mac app, then persists the minted 90-day key to~/.hermes/.env; sign-out best-effort revokes it viaPOST /api/auth/cli-credential/revoke. SettingINDEX_API_KEYmanually remains a supported override; revocation, expiry, and scoping come from theapikeystable. - The Index macOS app signs in through the web
/cli-authpage (the same state-bound handshake the CLI uses) and stores the resulting 90-day API key in the Keychain. Logout revokes the exact key viaPOST /api/auth/cli-credential/revoke. hermes-negotiatoris a scheduled-negotiation API-key audience: it has onlyGET /agents/meplus exact pickup/respond/consult routes and is represented in Hermes by four handlers (index_agent_me,index_pickup_negotiation,index_respond_negotiation,index_consult_owner). Its hidden run ID/capability are never browser or model arguments. Expired or stale negotiation authority falls back to Index.
These routes require a Better Auth browser session (SessionOnlyGuard); API keys are rejected.
GET /api/connected-agents/hermesreturns{ connections }. Each nonsecret connection is{ installationId, installationName, agentId, actions, activationState, selected, lastHeartbeatAt|null, expiresAt, health, indexCovering }, where health is exactlyactive|stale|never_seen|expired|revoked.POST /api/connected-agents/hermes/:installationId/pauserequires an empty body. It deselects Hermes immediately but preserves the credential and returns the refreshed connection view.DELETE /api/connected-agents/hermes/:installationIdidempotently selects Index, removes target authority, deletes installation keys, and returns{ revoked:true }.
Unknown or cross-owner installation IDs are opaque 404 { "error":"connected_agent_not_found" }; malformed IDs/bodies are 400.
Runtime routes require OwnerControlGuard: a Better Auth browser session. Agent-bound credentials are rejected. The server owns the selection, fallback, and generation authority.
GET /api/agent-runtime?installationId=<uuid>returns the owner-scoped runtime state for that installation.POST /api/agent-runtime/hermes/prepareaccepts{ installationId, setupAttemptId }and creates a disabled generation; preparation grants no active runtime authority.PUT /api/agent-runtimeaccepts either{ "runtime":"index" }or{ "runtime":"hermes", "installationId", "executorId", "setupAttemptId" }and validates the exact generation.POST /api/agent-runtime/reconcile-indexaccepts{ agentId, installationId, setupAttemptId }; it compare-and-selects Index only if that exact binding remains current, otherwise returnspreservedand cannot deselect a successor.POST /api/agent-runtime/rollbackaccepts{ setupAttemptId }and compare-clears only that generation.DELETE /api/agent-runtime/hermes/:installationIdselects Index, revokes installation authority, and marks it inactive. It has no defined request body; callers must omit one (the current server does not separately reject a supplied DELETE body).
Body-bearing runtime routes use exact schemas. Runtime domain errors retain { error, detail }; stale/mismatched compare-and-set operations preserve the authoritative newer binding. Pickup/respond/consult re-read selected executor, global manage:negotiations authority, credential row/audience/expiry, generation, expected speaker, and one-shot capability under the same owner advisory-lock transaction as the task mutation. Exact retries return their durable receipt; generation/credential/run replay across a task is denied.