Skip to content

Latest commit

 

History

History
3684 lines (2713 loc) · 125 KB

File metadata and controls

3684 lines (2713 loc) · 125 KB
title Protocol API Reference
type spec
tags
api
controllers
endpoints
rest
protocol
authentication
sse
created 2026-03-26
updated 2026-08-09

Protocol API Reference

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.

Table of Contents


Authentication Patterns

AuthGuard

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:
    • 401Access token or API key required (no credential provided)
    • 401Invalid or expired access token (JWT verification failed)
    • 401Invalid 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.

SessionOnlyGuard

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 as AuthGuard
  • API key: rejected — 403This endpoint requires a session token; API keys are not accepted
  • No credential: 401Access token required

Session-only endpoints:

  • DELETE /api/auth/account
  • POST /api/agents, PATCH /api/agents/:id, DELETE /api/agents/:id
  • POST /api/agents/:id/tokens, DELETE /api/agents/:id/tokens/:tokenId
  • POST /api/agents/:id/permissions, DELETE /api/agents/:id/permissions/:permissionId
  • POST /api/agents/:id/transports, DELETE /api/agents/:id/transports/:transportId

DebugGuard

Debug endpoints additionally require the DebugGuard, which gates access based on environment:

  • Enabled when: NODE_ENV === 'development' or ENABLE_DEBUG_API === 'true'
  • Error: 404Not found (when disabled)

Debug endpoints apply both guards: DebugGuard first, then AuthGuard.

Public Routes

Some routes have no guard at all:

  • GET /api/auth/providers
  • GET /api/chat/shared/:token
  • GET /api/networks/share/:code
  • GET /api/networks/public/:id
  • POST /api/subscribe/
  • GET /api/unsubscribe/:token
  • GET /api/storage/avatars/:userId/:filename
  • GET /api/storage/index-images/:userId/:filename

Error Response Format

All error responses follow a consistent JSON format:

{ "error": "Error message description" }

Non-Controller Routes

These routes are handled directly in main.ts before the controller routing loop.

Health Check

GET /health

Auth: None

Response:

{
  "status": "ok",
  "timestamp": "2026-03-26T00:00:00.000Z",
  "service": "protocol-v2"
}

Better Auth Routes

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 request headers: Telegram identity binding

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 telegram social 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_socials under label = '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.

Universal links and deep-link landing pages

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.

MCP runtime limits and error envelopes

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_BYTES rejects oversized HTTP request bodies before JSON-RPC handling. Default: 1000000 bytes.
  • MCP_TOOL_MAX_OUTPUT_BYTES caps encoded tool output. Default: 1000000 bytes.
  • MCP_TOOL_MAX_OUTPUT_<TOOL_NAME>_BYTES overrides one tool, for example MCP_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.

Performance Stats (Dev Only)

GET /dev/performance

Auth: None (only available when NODE_ENV !== 'production')

Response: JSON object with performance statistics.


Auth

Controller prefix: /auth

GET /api/auth/providers

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)
  • emailPasswordtrue when NODE_ENV !== 'production'

GET /api/auth/me

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.

PATCH /api/auth/profile/update

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.

POST /api/auth/cli-credential

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.

POST /api/auth/cli-credential/revoke

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).

DELETE /api/auth/account

Soft-deletes the authenticated user's account.

Auth: SessionOnlyGuard (API keys rejected with 403)

Response:

{ "success": true }

Chat

Controller prefix: /chat

POST /api/chat/stream

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 updates
  • routing — Which subgraph was selected and why
  • subgraph_result — Results from subgraph execution
  • debug_meta — Graph execution metadata (graph name, iterations, tools)
  • done — Final event with sessionId, full response text, messageId, title, and suggestions
  • error — Error event with message and code STREAM_ERROR

Response headers:

  • X-Session-Id — The session ID for this chat
  • X-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.

POST /api/chat/web/stream

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.

POST /api/chat/onboarding/stream

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

GET /api/chat/sessions

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": [...]
}

GET /api/chat/web/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.

POST /api/chat/session/resolve

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
}

POST /api/chat/web/session/resolve

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"
}

POST /api/chat/session

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": "..."
    }
  ]
}

POST /api/chat/web/session

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

POST /api/chat/session/delete

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 }

POST /api/chat/session/title

Update a chat session title.

Auth: AuthGuard

Request body:

{
  "sessionId": "string (required)",
  "title": "string (required, non-empty)"
}

Response:

{ "success": true, "title": "..." }

POST /api/chat/session/share

Generate a share token for a chat session.

Auth: AuthGuard

Request body:

{
  "sessionId": "string (required)"
}

Response:

{ "shareToken": "..." }

POST /api/chat/session/unshare

Remove the share token from a chat session.

Auth: AuthGuard

Request body:

{
  "sessionId": "string (required)"
}

Response:

{ "success": true }

POST /api/chat/message/:id/metadata

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 /api/chat/shared/:token

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": "..."
    }
  ]
}

Agents

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.

GET /api/agents

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": "..."
    }
  ]
}

POST /api/agents

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": []
  }
}

GET /api/agents/me

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.

GET /api/agents/:id

Fetch one agent by ID if the current user owns it or has a permission grant on it.

PATCH /api/agents/:id

Update mutable fields on a personal agent.

Request body:

{
  "name": "Updated Agent Name",
  "description": "optional or null",
  "status": "inactive"
}

Notes:

  • System agents return 403 for mutation attempts.
  • Empty patch bodies return 400.

DELETE /api/agents/:id

Soft-delete a personal agent and deactivate its transports.

Response: 204 No Content

POST /api/agents/:id/transports

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
  }
}

DELETE /api/agents/:id/transports/:transportId

Remove a transport from an owned personal agent.

Response: 204 No Content

POST /api/agents/:id/permissions

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": "..."
  }
}

DELETE /api/agents/:id/permissions/:permissionId

Revoke a permission from an agent.

Response: 204 No Content

GET /api/agents/:id/tokens

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": "..." }
  ]
}

POST /api/agents/:id/tokens

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 key value is only returned once.
  • System agents return 403.

DELETE /api/agents/:id/tokens/:tokenId

Revoke an API key bound to an owned personal agent.

Response: 204 No Content

Errors:

  • 404 if the token does not exist or is not bound to the route agent

POST /api/agents/:id/negotiations/pickup

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 | question on v1 tasks; seat-scoped outreach | counter | question | withdraw vs accept | decline | counter | question on v2). Final turns expose only their final-turn vocabulary.
  • canConsultOwnertrue only 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 be null only for legacy tasks created before turn-context persistence landed.
  • negotiatorMemory — optional array of the claiming user's own negotiator-memory entries (present only when NEGOTIATOR_MEMORY_INJECT is on and the user's agent has relevant memories — never contains the counterparty's).
  • opportunitynull when 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:

  • 403 unless 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.
  • 409 when the dedicated process run already picked up a task.

POST /api/agents/:id/negotiations/:negotiationId/consult

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:

  • 400 for an unknown/mismatched reason, any extra/free-form field, v1/opening/final turns, prior same-seat consultation, missing lifecycle wiring, or policy-ineligible admission.
  • 403 unless the request uses the exact currently selected agent-bound credential and its current setup generation.
  • 404 for a missing, non-negotiation, or wrong-owner task.
  • 409 when 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.

POST /api/agents/:id/negotiations/:negotiationId/respond

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's allowedActions returned by pickup: v1 tasks accept propose | accept | reject | counter | question; v2 tasks are seat-scoped (initiator outreach | counter | question | withdraw, counterparty accept | decline | counter | question). ask_user is never accepted here; use the dedicated consultation endpoint when canConsultOwner is true.
  • message — optional string or null.
  • assessment.suggestedRoles.ownUser / .otherUser — each one of agent, 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:

  • 400 if the action is outside the caller's seat's allowed set for the task's protocol version (the claim stays intact for a retry).
  • 403 unless the request uses the exact currently selected agent-bound credential (including the current Hermes setup generation when explicitly Hermes-audience).
  • 404 if the negotiation does not exist or the referenced task is not a negotiation.
  • 409 if the task is not in claimed state or is claimed by a different agent.

GET /api/agents/:id/opportunities/pending

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 (not 204).
  • Each poll also bumps agents.last_seen_at.

Errors:

  • 400 if limit is present but does not parse to a finite number (e.g. abc, Infinity, NaN) — {"error":"limit must be a finite number"}.
  • 403 if 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.

GET /api/agents/:id/opportunities/accepted

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": "..."
      }
    }
  ]
}
  • telegramHandle is null when the accepter has no user_socials entry with label = 'telegram' or when the stored value is not a valid Telegram username.
  • conversationUrl falls back to the frontend base URL if no DM exists.
  • Returns { "opportunities": [] } when no undelivered accepted opportunities exist.

Errors:

  • 400 if limit is present but does not parse to a finite number.
  • 403 if 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.

GET /api/agents/:id/opportunities/delivery-stats

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 with trigger = "ambient" since the given timestamp.
  • digest — number of committed deliveries with trigger = "digest" since the given timestamp.

Response 400: { "error": "..." } when since is missing or cannot be parsed as a valid ISO 8601 date.

Errors:

  • 403 if 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.


POST /api/agents/:id/opportunities/pickup

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:

  • 404 if the agent is not owned by the authenticated user or does not exist (returns 404 regardless to prevent existence disclosure).

POST /api/agents/:id/opportunities/:opportunityId/delivered

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.
  • 404 if the agent is not owned by the authenticated user or does not exist.

POST /api/agents/:id/test-messages

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:

  • 404 if the agent is not owned by the authenticated user or does not exist.

POST /api/agents/:id/test-messages/pickup

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:

  • 404 if the agent is not owned by the authenticated user or does not exist.

POST /api/agents/:id/test-messages/:messageId/delivered

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.

Conversation

Controller prefix: /conversations

GET /api/conversations

List all conversations for the authenticated user.

Auth: AuthGuard

Response:

{
  "conversations": [...]
}

GET /api/conversations/negotiations

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-outcome reasoning exists; the evidence fields are null.

Reasoning is only ever taken from a screened_out outcome. Ordinary declines and turn-cap stalls do not populate this field.

GET /api/conversations/negotiations/activity

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": []
    }
  ]
}

POST /api/conversations

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 /api/conversations/:id/messages

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": [...]
}

POST /api/conversations/:id/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": { ... }
}

POST /api/conversations/dm

Get or create a DM conversation with a peer user.

Auth: AuthGuard

Request body:

{
  "peerUserId": "string (required)"
}

Response:

{
  "conversation": { ... }
}

PATCH /api/conversations/:id/metadata

Update metadata for a conversation.

Auth: AuthGuard

Path params:

  • id — Conversation ID

Request body:

{
  "metadata": { "key": "value (required)" }
}

Response:

{ "success": true }

DELETE /api/conversations/:id

Hide a conversation for the authenticated user (soft-hide via hiddenAt).

Auth: AuthGuard

Path params:

  • id — Conversation ID

Response:

{ "success": true }

GET /api/conversations/:id/tasks

List all tasks for a conversation.

Auth: AuthGuard

Path params:

  • id — Conversation ID

Response:

{
  "tasks": [...]
}

GET /api/conversations/:id/tasks/:taskId

Get a single task within a conversation.

Auth: AuthGuard

Path params:

  • id — Conversation ID
  • taskId — Task ID

Response:

{
  "task": { ... }
}

GET /api/conversations/:id/tasks/:taskId/artifacts

Get artifacts for a task within a conversation.

Auth: AuthGuard

Path params:

  • id — Conversation ID
  • taskId — Task ID

Response:

{
  "artifacts": [...]
}

GET /api/conversations/stream

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

Debug

Controller prefix: /debug

All debug endpoints require both DebugGuard (dev/staging only) and AuthGuard.

GET /api/debug/intents/:id

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.

GET /api/debug/radar

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
  }
}

GET /api/debug/chat/:id

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": { ... }
}

Network

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).

GET /api/networks

List networks the authenticated user is a member of, including their personal network.

Auth: AuthGuard

Response:

{
  "networks": [...]
}

POST /api/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": { ... }
}

GET /api/networks/search-users

Search users by name/email, optionally excluding existing members of an index.

Auth: AuthGuard

Query params:

  • q — Search query string
  • indexId — Exclude members of this network (optional)

Response:

{
  "users": [...]
}

GET /api/networks/my-members

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 /api/networks/discovery/public

Get public networks the user has not joined.

Auth: AuthGuard

Response:

{
  "networks": [...]
}

GET /api/networks/share/:code

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 /api/networks/public/:id

Get a public network by ID. Only works for indexes with joinPolicy: 'anyone'.

Auth: None (public)

Path params:

  • id — Network ID

Response:

{
  "index": { ... }
}

GET /api/networks/shared/:userId

Get non-personal networks shared between the authenticated user and a target user.

Auth: AuthGuard

Path params:

  • userId — Target user ID

Response:

{
  "networks": [...]
}

POST /api/networks/invitation/:code/accept

Accept an invitation to join an index using the invitation code.

Auth: AuthGuard

Path params:

  • code — Invitation code

Response: JSON with accepted index details.

PUT /api/networks/:id/key

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 /api/networks/:id

Get a single network by ID with owner info and member count. Members only.

Auth: AuthGuard

Path params:

  • id — Network ID

Response:

{
  "index": { ... }
}

PUT /api/networks/:id

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": { ... }
}

DELETE /api/networks/:id

Soft-delete a network. Owner only.

Auth: AuthGuard

Path params:

  • id — Network ID

Response:

{ "success": true }

POST /api/networks/:id/master-key

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.

POST /api/networks/:id/rotate-master-key

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 /api/networks/:id/members

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 }
}

POST /api/networks/:id/members

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.

PATCH /api/networks/:id/members/:memberId

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 ID
  • memberId — 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.

DELETE /api/networks/:id/members/:memberId

Remove a member from an index. Owner only. Cannot remove yourself.

Auth: AuthGuard

Path params:

  • id — Network ID
  • memberId — User ID to remove

Response:

{ "success": true }

PATCH /api/networks/:id/permissions

Update index permissions (join policy). Owner only.

Auth: AuthGuard

Path params:

  • id — Network ID

Request body:

{
  "joinPolicy": "anyone | invite_only (optional)"
}

Response:

{
  "index": { ... }
}

GET /api/networks/:id/member-settings

Get current user's member settings (permissions and ownership status).

Auth: AuthGuard

Path params:

  • id — Network ID

Response: JSON with member settings.

GET /api/networks/:id/my-intents

Get current user's intents in an index. Members only.

Auth: AuthGuard

Path params:

  • id — Network ID

Response:

{
  "intents": [...]
}

POST /api/networks/:id/join

Join a public network.

Auth: AuthGuard

Path params:

  • id — Network ID

Response:

{
  "index": { ... }
}

Errors:

  • 404 — Index not found
  • 403 — Index not public

POST /api/networks/:id/leave

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 member
  • 400 — Cannot leave (owner)

POST /api/networks/:id/signup

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: MasterKeyGuardx-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; malformed socials array.
  • 401 — Missing x-api-key header.
  • 403 — Master key invalid; network has no master key enabled; network deleted.

POST /api/networks/:id/signup/lookup

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: MasterKeyGuardx-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 — Missing x-api-key header.
  • 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" }'

POST /api/networks/:id/members/import/parse

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.

POST /api/networks/:id/members/import

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:

  • 400members array is missing or empty.
  • 403 — Not the network owner or scope violation.

POST /api/networks/:id/members/invite

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.

POST /api/networks/:id/members/:memberId/resend-invite

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.

Integration

Controller prefix: /integrations

Supported toolkits: gmail, slack, telegram

Telegram is a bot-based notification connection (not a Composio OAuth toolkit). It doesn't use /link or /import; connection is established via a deep link returned by POST /connect/telegram, and disconnection is via DELETE /:id with id = telegram:<userId>.

GET /api/integrations

List connected accounts for the authenticated user.

Auth: AuthGuard

Query params:

  • indexId — Filter to connections linked to this network (optional)

Response:

{
  "connections": [...]
}

POST /api/integrations/connect/:toolkit

Start OAuth flow to connect a toolkit.

Auth: AuthGuard

Path params:

  • toolkitgmail, slack, or telegram

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 includes message.from.username, the gateway also upserts a public user_socials row with label = 'telegram' while preserving other socials.

POST /api/integrations/:toolkit/link

Link a toolkit connection to an index.

Auth: AuthGuard

Path params:

  • toolkitgmail or slack

Request body:

{
  "indexId": "string (required)"
}

Response:

{ "success": true }

DELETE /api/integrations/:toolkit/link

Unlink a toolkit from an index. Does not revoke the OAuth connection.

Auth: AuthGuard

Path params:

  • toolkitgmail or slack

Query params:

  • indexId — Network to unlink from (required)

Response:

{ "success": true }

DELETE /api/integrations/:id

Disconnect (delete) a connected account.

Auth: AuthGuard

Path params:

  • id — Connection ID (or telegram:<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 via POST /connect/telegram.

Response: Disconnect result.


Webhooks

Controller prefix: /webhooks

POST /api/webhooks/telegram

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 setWebhook when TELEGRAM_BOT_TOKEN and TELEGRAM_WEBHOOK_SECRET are configured.


Intent

Controller prefix: /intents

POST /api/intents/list

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.

POST /api/intents/confirm

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": "..."
}

POST /api/intents/reject

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": "..."
}

POST /api/intents/proposals/status

Batch-check proposal statuses. Returns which proposal IDs have been confirmed.

Auth: AuthGuard

Request body (Zod-validated):

{
  "proposalIds": ["string"]
}

Response:

{
  "statuses": { ... }
}

GET /api/intents/:id

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"
  }
}

POST /api/intents/:id/visit

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.

PATCH /api/intents/:id/status

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 status
  • 403 — network-scoped agent is not allowed to act on the intent
  • 404 — intent not found or not owned by the authenticated user
  • 409 — ambiguous short prefix, archived intent, or terminal intent
  • 503 — resume enqueue was not acknowledged; returns { "error": "Failed to enqueue intent resume", "code": "enqueue_failed", "retryable": true, "intent": { "id": "...", "status": "ACTIVE | PAUSED" } }. PAUSED means compensation succeeded; ACTIVE is the authoritative status when compensation did not apply or the request was idempotent.

PATCH /api/intents/:id/archive

Archive an intent.

Auth: AuthGuard

Path params:

  • id — Intent ID

Response:

{ "success": true }

Fast Signal Intake

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" } when runId does 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 renders clarification as a recovery round and retries.
  • 403{ "error": "forbidden", "code": "network_membership_required", "networkId": "..." } when the caller is not a member of the supplied networkId.
  • 400{ "error": "Validation failed", "details": { ... } } (flattened Zod error) for malformed bodies.
  • 500{ "error": "Failed to process intake request" } for unexpected failures.

POST /api/intents/intake/start

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 } }

POST /api/intents/intake/question

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
}

POST /api/intents/intake/prepare

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>" }

POST /api/intents/intake/proposal

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": "..."
}

POST /api/intents/intake/revise

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": "..."
}

Opportunity

Controller prefix: /opportunities

GET /api/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. Use intent for selected-intent scope.
  • scopeId — Required when scopeType=intent; viewer-owned selected intent UUID. Composes with networkId; it never broadens network visibility. Rows are returned only when every participant retains an active anchor in the intent's current intent_networks ∩ viewer memberships scope (paused-intent history is allowed).
  • intentId — Deprecated/convenience alias for scopeType=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 /api/opportunities/chat-context

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.

GET /api/opportunities/radar

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. Use intent for selected-intent scope.
  • scopeId — Required when scopeType=intent; viewer-owned selected intent UUID. Applied before radar visibility filtering, sorting, and counterpart dedupe. Pool-answer factors and deprioritization reasons apply only when their recipientUserId + intentId provenance exactly matches this viewer and selected intent; global Radar and legacy unscoped adjustments ignore them.
  • intentId — Deprecated/convenience alias for scopeType=intent&scopeId=<intentId>.
  • limit — Max results (optional)
  • noCache — Bypass radar cache when true or 1 (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 /api/opportunities/:id

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.

PATCH /api/opportunities/:id/status

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 opportunity
  • 404 — Opportunity not found
  • 409 — Self-accept blocked. Caller's actor already has actedAt set (they advanced the opportunity earlier) and is attempting to accept it. The other party must accept. See docs/domain/opportunities.md#bilateral-acceptance.
  • 409 — Uptake soft interlock. When the feature is enabled and unresolved preparatory questions exist, the response contains advisory.code = "unresolved_uptake_questions", public question payloads, and acknowledgedUptakeQuestionIds. 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.

POST /api/opportunities/:id/start-chat

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 in pending or draft status
  • 403 — Caller is not an actor on the opportunity
  • 404 — Opportunity not found
  • 409 — Self-accept blocked. Caller's actor already has actedAt set. See docs/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

POST /api/opportunities/:id/owner-approvals

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 owner
  • 404 — 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)

Network Opportunity

Controller prefix: /networks (separate controller registered alongside NetworkController)

GET /api/networks/:indexId/opportunities

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.

POST /api/networks/:indexId/opportunities

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.


Enrichment

Controller prefix: /enrichment

POST /api/enrichment/sync

Trigger enrichment sync/generation for the authenticated user. Runs the enrichment graph.

Auth: AuthGuard

Response: JSON with enrichment result.


Storage

Controller prefix: /storage

POST /api/storage/files

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": "..."
  }
}

GET /api/storage/files

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": { ... }
}

GET /api/storage/files/:id

Download a library file (streams content from S3).

Auth: AuthGuard

Path params:

  • id — File ID

Response: Binary file content with Content-Disposition: attachment.

DELETE /api/storage/files/:id

Soft-delete a library file.

Auth: AuthGuard

Path params:

  • id — File ID

Response:

{ "success": true }

POST /api/storage/avatars

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": "..."
}

GET /api/storage/avatars/:userId/:filename

Serve an avatar image (public, streamed from S3).

Auth: None (public)

Path params:

  • userId — User ID
  • filename — Avatar filename

Response: Image binary with Cache-Control: public, max-age=31536000, immutable.

POST /api/storage/index-images

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": "..."
}

GET /api/storage/index-images/:userId/:filename

Serve an index image (public, streamed from S3).

Auth: None (public)

Path params:

  • userId — User ID
  • filename — Image filename

Response: Image binary with Cache-Control: public, max-age=31536000, immutable.


Subscribe

Controller prefix: /subscribe

POST /api/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 }

User

Controller prefix: /users

GET /api/users/batch

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": "..."
    }
  ]
}

DELETE /api/users/contacts/:contactId

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.

POST /api/users/:userId/negotiations

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": "..."
  }
}

GET /api/users/:userId/negotiations

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": "..."
    }
  ]
}

PUT /api/users/me/key

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.

GET /api/users/:userId/negotiations/insights

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 /api/users/:userId

Get a user by ID.

Auth: AuthGuard

Path params:

  • userId — User ID

Response:

{
  "user": {
    "id": "...",
    "name": "...",
    "intro": "...",
    "avatar": "...",
    "location": "...",
    "socials": { ... },
    "createdAt": "...",
    "updatedAt": "..."
  }
}

Tools

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.

GET /api/tools

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": { ... } }
    }
  ]
}

POST /api/tools/:toolName

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 failure
  • 401 — Missing or invalid auth token
  • 403 — User not found or deactivated
  • 404 — Tool not found (Tool "xyz" not found. Available tools: ...)
  • 500 — Internal error during tool execution

Available Tools

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

Questions

Structured question delivery and lifecycle. Questions are generated asynchronously by the QuestionerAgent (behind QUESTIONER_ENABLED=true) and served to clients for user interaction.

GET /api/questions

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[] }

GET /api/questions/counts

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.

POST /api/questions/:id/answer

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.

POST /api/questions/:id/dismiss

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)


Queue Monitoring (Dev Only)

Bull Board UI

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
  • email
  • questioner (when QUESTIONER_ENABLED=true)

Accessible at http://localhost:3001/dev/queues/ when the protocol server is running in development mode.

Standalone Hermes and native-client authentication

Native clients authenticate with ordinary Better Auth API keys.

Credentials

  • The Hermes plugin uses an API key supplied through the INDEX_API_KEY environment variable, sent as x-api-key. The dashboard's "log in with browser" runs the same web /cli-auth state-bound handshake as the CLI and Mac app, then persists the minted 90-day key to ~/.hermes/.env; sign-out best-effort revokes it via POST /api/auth/cli-credential/revoke. Setting INDEX_API_KEY manually remains a supported override; revocation, expiry, and scoping come from the apikeys table.
  • The Index macOS app signs in through the web /cli-auth page (the same state-bound handshake the CLI uses) and stores the resulting 90-day API key in the Keychain. Logout revokes the exact key via POST /api/auth/cli-credential/revoke.
  • hermes-negotiator is a scheduled-negotiation API-key audience: it has only GET /agents/me plus 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.

Connected Hermes owner controls

These routes require a Better Auth browser session (SessionOnlyGuard); API keys are rejected.

  • GET /api/connected-agents/hermes returns { connections }. Each nonsecret connection is { installationId, installationName, agentId, actions, activationState, selected, lastHeartbeatAt|null, expiresAt, health, indexCovering }, where health is exactly active|stale|never_seen|expired|revoked.
  • POST /api/connected-agents/hermes/:installationId/pause requires an empty body. It deselects Hermes immediately but preserves the credential and returns the refreshed connection view.
  • DELETE /api/connected-agents/hermes/:installationId idempotently 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.

Agent negotiation runtime owner control

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/prepare accepts { installationId, setupAttemptId } and creates a disabled generation; preparation grants no active runtime authority.
  • PUT /api/agent-runtime accepts either { "runtime":"index" } or { "runtime":"hermes", "installationId", "executorId", "setupAttemptId" } and validates the exact generation.
  • POST /api/agent-runtime/reconcile-index accepts { agentId, installationId, setupAttemptId }; it compare-and-selects Index only if that exact binding remains current, otherwise returns preserved and cannot deselect a successor.
  • POST /api/agent-runtime/rollback accepts { setupAttemptId } and compare-clears only that generation.
  • DELETE /api/agent-runtime/hermes/:installationId selects 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.