| title | Protocol API Reference | |||||||
|---|---|---|---|---|---|---|---|---|
| type | spec | |||||||
| tags |
|
|||||||
| created | 2026-03-26 | |||||||
| updated | 2026-04-08 |
Complete reference for all HTTP endpoints exposed by the protocol server. All routes are prefixed with /api (global prefix). The server runs on port 3001 by default.
- Authentication Patterns
- Non-Controller Routes
- Auth
- Agents
- Chat
- Conversation
- Debug
- Network
- Integration
- Intent
- Link
- Opportunity
- Network Opportunity
- Profile
- Storage
- Subscribe
- Unsubscribe
- Tools
- User
- Queue Monitoring (Dev Only)
Most endpoints require the AuthGuard, which verifies JWT tokens statelessly via the local JWKS endpoint.
- Header:
Authorization: Bearer <jwt> - Fallback:
?token=<jwt>query parameter - Errors:
401—Access token required(no token provided)401—Invalid or expired access token(verification 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.
Debug endpoints additionally require the DebugGuard, which gates access based on environment:
- Enabled when:
NODE_ENV === 'development'orENABLE_DEBUG_API === 'true' - Error:
404—Not found(when disabled)
Debug endpoints apply both guards: DebugGuard first, then AuthGuard.
Some routes have no guard at all:
GET /api/auth/providersGET /api/chat/shared/:tokenGET /api/networks/share/:codeGET /api/networks/public/:idPOST /api/subscribe/GET /api/unsubscribe/:tokenGET /api/storage/avatars/:userId/:filenameGET /api/storage/index-images/:userId/:filename
All error responses follow a consistent JSON format:
{ "error": "Error message description" }These routes are handled directly in main.ts before the controller routing loop.
GET /health
Auth: None
Response:
{
"status": "ok",
"timestamp": "2026-03-26T00:00:00.000Z",
"service": "protocol-v2"
}The following paths are delegated to Better Auth and are not handled by controllers:
/api/auth/sign-in/api/auth/sign-up/api/auth/sign-out/api/auth/session/api/auth/callback/api/auth/error/api/auth/get-session/api/auth/forget-password/api/auth/magic-link/api/auth/reset-password/api/auth/verify-email/api/auth/change-password/api/auth/change-email/api/auth/delete-user/api/auth/list-sessions/api/auth/revoke-session/api/auth/revoke-other-sessions/api/auth/update-user/api/auth/token/api/auth/jwks/api/auth/api-key/create/api/auth/api-key/list/api/auth/api-key/delete
Refer to the Better Auth documentation for details on these endpoints.
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.
GET /dev/performance
Auth: None (only available when NODE_ENV !== 'production')
Response: JSON object with performance statistics.
Controller prefix: /auth
Returns the list of configured social auth providers.
Auth: None (public)
Response:
{
"providers": ["google"],
"emailPassword": true
}providers— array of enabled social providers (currently only"google"if configured)emailPassword—truewhenNODE_ENV !== 'production'
Returns the current authenticated user with their full profile.
Auth: AuthGuard
Response:
{
"user": {
"id": "...",
"name": "...",
"email": "...",
"intro": "...",
"avatar": "...",
"location": "...",
"timezone": "...",
"socials": { ... },
"isGhost": false,
"notificationPreferences": { ... },
"createdAt": "...",
"updatedAt": "..."
}
}Side effect: If the user has a name and at least one social link but no profile, a background profile sync is triggered automatically.
Updates the authenticated user's profile fields and/or notification preferences.
Auth: AuthGuard
Request body:
{
"name": "string (optional)",
"intro": "string (optional)",
"avatar": "string (optional)",
"location": "string (optional)",
"timezone": "string (optional)",
"socials": { "x": "...", "linkedin": "...", "github": "...", "websites": ["..."] },
"notificationPreferences": {
"connectionUpdates": true,
"weeklyNewsletter": false
}
}Response: Same shape as GET /api/auth/me.
Soft-deletes the authenticated user's account.
Auth: AuthGuard
Response:
{ "success": true }Controller prefix: /chat
Send a message to the chat graph for synchronous processing.
Auth: AuthGuard
Request body:
{
"message": "string (required)"
}Response:
{
"response": "...",
"error": "... (if any)"
}SSE streaming endpoint for chat messages with context support. Streams graph events and LLM tokens in real-time.
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)"],
"indexId": "string | null (optional — scope to a specific index)",
"recipientUserId": "string | null (optional — DM recipient for ghost invites)",
"prefillMessages": [
{ "role": "assistant | user", "content": "string (max 10000 chars)" }
]
}Response: SSE stream (Content-Type: text/event-stream)
SSE event types:
status— Processing status updatesrouting— Which subgraph was selected and whysubgraph_result— Results from subgraph executiondebug_meta— Graph execution metadata (graph name, iterations, tools)done— Final event withsessionId, full response text,messageId,title, andsuggestionserror— Error event with message and codeSTREAM_ERROR
Response headers:
X-Session-Id— The session ID for this chat
List all chat sessions for the authenticated user.
Auth: AuthGuard
Response:
{
"sessions": [...]
}Get a specific session with its messages (including assistant metadata).
Auth: AuthGuard
Request body:
{
"sessionId": "string (required)"
}Response:
{
"session": { ... },
"messages": [
{
"id": "...",
"role": "user | assistant",
"content": "...",
"traceEvents": "... (assistant messages only)",
"debugMeta": "... (assistant messages only)",
"createdAt": "..."
}
]
}Delete a chat session.
Auth: AuthGuard
Request body:
{
"sessionId": "string (required)"
}Response:
{ "success": true }Update a chat session title.
Auth: AuthGuard
Request body:
{
"sessionId": "string (required)",
"title": "string (required, non-empty)"
}Response:
{ "success": true, "title": "..." }Generate a share token for a chat session.
Auth: AuthGuard
Request body:
{
"sessionId": "string (required)"
}Response:
{ "shareToken": "..." }Remove the share token from a chat session.
Auth: AuthGuard
Request body:
{
"sessionId": "string (required)"
}Response:
{ "success": true }Update message metadata with frontend trace events (called after streaming completes).
Auth: AuthGuard
Path params:
id— Message ID
Request body:
{
"traceEvents": ["array of trace event objects (max 2000)"]
}Response:
{ "success": true }Get a shared chat session (read-only, public access).
Auth: None (public)
Path params:
token— Share token
Response:
{
"session": {
"id": "...",
"title": "...",
"createdAt": "..."
},
"messages": [
{
"id": "...",
"role": "...",
"content": "...",
"createdAt": "..."
}
]
}Controller prefix: /agents
All agent routes use AuthGuard.
List the agents the current user owns or has been authorized to use.
Response:
{
"agents": [
{
"id": "...",
"ownerId": "...",
"name": "...",
"description": "...",
"type": "personal",
"status": "active",
"metadata": {},
"transports": [],
"permissions": [],
"createdAt": "...",
"updatedAt": "..."
}
]
}Create a personal agent owned by the current user.
Request body:
{
"name": "My Claude Agent",
"description": "Handles partner negotiations"
}Response:
{
"agent": {
"id": "...",
"name": "My Claude Agent",
"type": "personal",
"status": "active",
"transports": [],
"permissions": []
}
}Resolve and return the agent bound to the calling API key (x-api-key header). The key's metadata.agentId is read from the database and the matching agent is returned in the same shape as GET /api/agents/:id. Returns 400 if called with a JWT or with a key that has no agent binding. Used by personal-agent runtimes (e.g. the OpenClaw plugin setup wizard) to bootstrap their agentId from a single pasted API key, avoiding a separate agent-id input.
Fetch one agent by ID if the current user owns it or has a permission grant on it.
Update mutable fields on a personal agent.
Request body:
{
"name": "Updated Agent Name",
"description": "optional or null",
"status": "inactive"
}Notes:
- System agents return
403for mutation attempts. - Empty patch bodies return
400.
Soft-delete a personal agent and deactivate its transports.
Response: 204 No Content
Add a transport to an owned personal agent. The only supported channel is mcp — the agent authenticates with an API key (see POST /api/agents/:id/tokens) and pulls work from the Index Network MCP server and the negotiation pickup endpoint below. Transports are MCP-only.
Request body (mcp channel):
{
"channel": "mcp",
"config": {},
"priority": 0
}priority— integer ordering hint when multiple transports on the same agent are eligible for the same event (higher priority first).
Response:
{
"transport": {
"id": "...",
"agentId": "...",
"channel": "mcp",
"active": true,
"failureCount": 0
}
}Remove a transport from an owned personal agent.
Response: 204 No Content
Grant the current user a permission set on an agent.
Request body:
{
"actions": ["manage:intents", "manage:negotiations"],
"scope": "global",
"scopeId": "optional-for-node-or-network"
}Response:
{
"permission": {
"id": "...",
"agentId": "...",
"userId": "...",
"scope": "global",
"scopeId": null,
"actions": ["manage:intents", "manage:negotiations"],
"createdAt": "..."
}
}Revoke a permission from an agent.
Response: 204 No Content
List API keys bound to an owned personal agent. Raw key values are never returned — only stored metadata (id, name, creation timestamp).
Response:
{
"tokens": [
{ "id": "...", "name": "My Claude Agent API Key", "createdAt": "..." }
]
}Create an API key bound to an owned personal agent. The backend issues the key through Better Auth and stores metadata.agentId automatically.
Request body:
{
"name": "My Claude Agent API Key"
}Response:
{
"token": {
"id": "...",
"key": "idx_live_...",
"name": "My Claude Agent API Key",
"createdAt": "..."
}
}Notes:
- The raw
keyvalue is only returned once. - System agents return
403.
Revoke an API key bound to an owned personal agent.
Response: 204 No Content
Errors:
404if the token does not exist or is not bound to the route agent
Claim the next pending negotiation turn for an owned personal agent. Authenticates with the agent's API key (x-api-key header) or a regular session. Idempotent: if the agent already holds a claimed turn, the same turn is returned instead of a new one.
The backend atomically transitions the oldest tasks.state = 'waiting_for_agent' row where the caller's user is a participant to state = 'claimed'. A 6-hour claim timeout is enqueued; if the agent does not submit a response in that window the turn is released back to waiting_for_agent for another claim attempt, and an unclaimed turn eventually falls through to the system Index Negotiator after 24 hours.
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"
}
}turn.deadline— ISO-8601 timestamp; the claim expires atclaimedAt + 6h.turn.counterpartyAction— action from the preceding turn (propose,counter,question,accept,reject), or"none"if this is the first turn.context.ownUser/context.otherUser— the persisted absolute source/candidate context projected into the claiming user's perspective. May benullonly for legacy tasks created before turn-context persistence landed.opportunity—nullwhen the task has no linked opportunity.
Errors:
403if the agent is not owned by the authenticated user.
Submit a response for a negotiation turn previously claimed via pickup. Authenticates with the agent's API key or a session. The backend atomically CAS's the task from claimed (scoped to this agentId) to working, persists the turn, then either finalizes the negotiation (on accept, reject, or when the turn cap is reached) or returns it to waiting_for_agent for the counterparty.
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— one ofpropose,accept,reject,counter,question.message— optional string ornull.assessment.suggestedRoles.ownUser/.otherUser— each one ofagent,patient,peer.
Response:
{ "success": true }Errors:
403if the agent is not owned by the authenticated user.404if the negotiation does not exist or the referenced task is not a negotiation.409if the task is not inclaimedstate or is claimed by a different agent.
Fetch all undelivered eligible opportunities for an owned personal agent as a batch. Authenticates with the agent's API key (x-api-key header) or a session. Read-only: the response does not reserve or mutate the delivery ledger, so callers are expected to decide which candidates to surface and then commit each selection via the confirm_opportunity_delivery MCP tool.
Eligibility filters match the pre-batch pickup flow: status pending or draft, the caller's user listed in actors, draft exclusion when createdBy == user, agent has notify_on_opportunity = true, no committed delivery row exists, and canUserSeeOpportunity passes. 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": "...",
"rendered": {
"headline": "...",
"personalizedSummary": "...",
"suggestedAction": "...",
"narratorRemark": "..."
}
}
]
}- Returns
{ "opportunities": [] }when nothing is pending (not204). - Each poll also bumps
agents.last_seen_at.
Errors:
400iflimitis present but does not parse to a finite number (e.g.abc,Infinity,NaN) —{"error":"limit must be a finite number"}.403if the agent is not owned by the authenticated user.
Return committed delivery counts for an owned personal agent since a given timestamp, grouped by trigger type.
Auth: AuthOrApiKeyGuard (session or API key).
Path params:
id— Agent ID.
Query params:
| Parameter | Type | Required | Description |
|---|---|---|---|
since |
string | yes | ISO 8601 timestamp; counts deliveries with delivered_at >= since. |
Response 200:
{ "ambient": 2, "digest": 1 }ambient— number of committed deliveries withtrigger = "ambient"since the given timestamp.digest— number of committed deliveries withtrigger = "digest"since the given timestamp.
Response 400: { "error": "..." } when since is missing or cannot be parsed as a valid ISO 8601 date.
Errors:
403if the agent is not owned by the authenticated user.
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.
Controller prefix: /conversations
List all conversations for the authenticated user.
Auth: AuthGuard
Response:
{
"conversations": [...]
}List A2A agent-to-agent negotiation conversations for the authenticated user.
Auth: AuthGuard
Response:
{
"conversations": [...]
}Create a new conversation with participants.
Auth: AuthGuard
Request body:
{
"participants": [
{ "participantId": "string", "participantType": "user | agent" }
]
}The authenticated user must be included in the participants array.
Response (201):
{
"conversation": { ... }
}Get messages for a conversation.
Auth: AuthGuard
Path params:
id— Conversation ID
Query params:
limit— Max messages to return (optional)before— Cursor for pagination, return messages before this ID (optional)taskId— Filter messages by task ID (optional)
Response:
{
"messages": [...]
}Send a message in a conversation.
Auth: AuthGuard
Path params:
id— Conversation ID
Request body:
{
"parts": ["array of message parts (required, A2A-compatible)"],
"taskId": "string (optional)",
"metadata": { "key": "value (optional)" }
}Response (201):
{
"message": { ... }
}Get or create a DM conversation with a peer user.
Auth: AuthGuard
Request body:
{
"peerUserId": "string (required)"
}Response:
{
"conversation": { ... }
}Update metadata for a conversation.
Auth: AuthGuard
Path params:
id— Conversation ID
Request body:
{
"metadata": { "key": "value (required)" }
}Response:
{ "success": true }Hide a conversation for the authenticated user (soft-hide via hiddenAt).
Auth: AuthGuard
Path params:
id— Conversation ID
Response:
{ "success": true }List all tasks for a conversation.
Auth: AuthGuard
Path params:
id— Conversation ID
Response:
{
"tasks": [...]
}Get a single task within a conversation.
Auth: AuthGuard
Path params:
id— Conversation IDtaskId— Task ID
Response:
{
"task": { ... }
}Get artifacts for a task within a conversation.
Auth: AuthGuard
Path params:
id— Conversation IDtaskId— Task ID
Response:
{
"artifacts": [...]
}SSE endpoint for real-time conversation events. Streams new messages and conversation updates to the authenticated user.
Auth: AuthGuard
Response: SSE stream (Content-Type: text/event-stream)
- Initial event:
{ "type": "connected" } - Subsequent events: conversation-scoped data pushed in real time
- Keepalive comments sent every 15 seconds
Controller prefix: /debug
All debug endpoints require both DebugGuard (dev/staging only) and AuthGuard.
Returns a full diagnostic snapshot for a single intent, including the intent record, HyDE document stats, index assignments, related opportunities, and a pipeline-health diagnosis.
Auth: DebugGuard + AuthGuard
Path params:
id— Intent ID
Response:
{
"exportedAt": "...",
"intent": {
"id": "...",
"text": "...",
"summary": "...",
"status": "active | archived",
"confidence": 0.85,
"inferenceType": "...",
"sourceType": "...",
"hasEmbedding": true,
"createdAt": "...",
"updatedAt": "..."
},
"hydeDocuments": {
"count": 3,
"oldestGeneratedAt": "...",
"newestGeneratedAt": "..."
},
"indexAssignments": [
{ "indexId": "...", "indexTitle": "...", "indexPrompt": "..." }
],
"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,
"hasOpportunities": true,
"allOpportunitiesFilteredFromHome": false,
"filterReasons": []
}
}Returns a home-level diagnostic snapshot for the authenticated user, including intent stats, index memberships, opportunity aggregates, simulated home-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
},
"homeView": {
"cardsReturned": 4,
"filteredOut": {
"notActionable": 3,
"duplicateCounterpart": 2,
"notVisible": 6
}
},
"diagnosis": {
"hasActiveIntents": true,
"intentsHaveEmbeddings": true,
"intentsHaveHydeDocuments": true,
"intentsAreIndexed": true,
"hasOpportunities": true,
"opportunitiesReachHome": true,
"bottleneck": null
}
}Runs the opportunity discovery pipeline for a specific intent and returns the full graph trace. WARNING: This persists results (creates/reactivates opportunities).
Auth: DebugGuard + AuthGuard
Path params:
id— Intent ID
Response:
{
"exportedAt": "...",
"preflight": { ... },
"result": { ... }
}Returns diagnosis string instead of result if there are no candidates or graph execution fails.
Returns a debug-friendly view of a chat session, including messages and per-turn debug metadata (graph, iterations, tools).
Auth: DebugGuard + AuthGuard
Path params:
id— Session (conversation) ID
Response:
{
"sessionId": "...",
"exportedAt": "...",
"title": "...",
"indexId": "...",
"messages": [
{ "role": "user | assistant", "content": "..." }
],
"turns": [
{
"messageIndex": 1,
"graph": "chat",
"iterations": 3,
"tools": [
{
"name": "...",
"args": { ... },
"resultSummary": "...",
"success": true,
"durationMs": 1234,
"steps": [...],
"graphs": [
{ "name": "...", "durationMs": 500, "agents": [...] }
]
}
]
}
],
"sessionMetadata": { ... }
}Controller prefix: /networks
List indexes the authenticated user is a member of, including their personal index.
Auth: AuthGuard
Response:
{
"networks": [...]
}Create a new index.
Auth: AuthGuard
Request body:
{
"title": "string (required)",
"prompt": "string (optional)",
"imageUrl": "string | null (optional)",
"joinPolicy": "anyone | invite_only (optional)",
"allowGuestVibeCheck": "boolean (optional)"
}Response:
{
"index": { ... }
}Search users by name/email, optionally excluding existing members of an index.
Auth: AuthGuard
Query params:
q— Search query stringindexId— Exclude members of this network (optional)
Response:
{
"users": [...]
}Get all members of every index the signed-in user is a member of (deduplicated). Used for @mentions in chat.
Auth: AuthGuard
Response:
{
"members": [...]
}Get public indexes the user has not joined.
Auth: AuthGuard
Response:
{
"networks": [...]
}Get an index by its invitation share code. Used for invitation page preview.
Auth: None (public)
Path params:
code— Invitation share code
Response:
{
"index": { ... }
}Get a public index by ID. Only works for indexes with joinPolicy: 'anyone'.
Auth: None (public)
Path params:
id— Network ID
Response:
{
"index": { ... }
}Get non-personal indexes shared between the authenticated user and a target user.
Auth: AuthGuard
Path params:
userId— Target user ID
Response:
{
"networks": [...]
}Accept an invitation to join an index using the invitation code.
Auth: AuthGuard
Path params:
code— Invitation code
Response: JSON with accepted index details.
Update a network's human-readable key. Owner only.
Auth: AuthGuard
Path params:
id— Network ID
Request body:
{
"key": "string (required)"
}Key must match /^[a-z0-9][a-z0-9-]*[a-z0-9]$/, be 3–64 characters, and not collide with an existing key.
Response: JSON with updated network or 400/409 validation errors.
Get a single index by ID with owner info and member count. Members only.
Auth: AuthGuard
Path params:
id— Network ID
Response:
{
"index": { ... }
}Update an index (title, prompt, image, join policy). Owner only.
Auth: AuthGuard
Path params:
id— Network ID
Request body:
{
"title": "string (optional)",
"prompt": "string | null (optional)",
"imageUrl": "string | null (optional)",
"joinPolicy": "anyone | invite_only (optional)",
"allowGuestVibeCheck": "boolean (optional)"
}Response:
{
"index": { ... }
}Soft-delete an index. Owner only.
Auth: AuthGuard
Path params:
id— Network ID
Response:
{ "success": true }Get members of an index. Owner only.
Auth: AuthGuard
Path params:
id— Network ID
Response:
{
"members": [...],
"metadataKeys": [],
"pagination": { "page": 1, "limit": 10, "total": 10, "totalPages": 1 }
}Add a member to an index. Owner/admin only.
Auth: AuthGuard
Path params:
id— Network ID
Request body:
{
"userId": "string (required)",
"permissions": ["string (optional — include 'admin' for admin role)"]
}Response:
{
"member": { ... },
"message": "Member added | Already a member"
}Remove a member from an index. Owner only. Cannot remove yourself.
Auth: AuthGuard
Path params:
id— Network IDmemberId— User ID to remove
Response:
{ "success": true }Update index permissions (join policy, guest vibe check). Owner only.
Auth: AuthGuard
Path params:
id— Network ID
Request body:
{
"joinPolicy": "anyone | invite_only (optional)",
"allowGuestVibeCheck": "boolean (optional)"
}Response:
{
"index": { ... }
}Get current user's member settings (permissions and ownership status).
Auth: AuthGuard
Path params:
id— Network ID
Response: JSON with member settings.
Get current user's intents in an index. Members only.
Auth: AuthGuard
Path params:
id— Network ID
Response:
{
"intents": [...]
}Join a public index.
Auth: AuthGuard
Path params:
id— Network ID
Response:
{
"index": { ... }
}Errors:
404— Index not found403— Index not public
Leave an index. Members (non-owners) can leave.
Auth: AuthGuard
Path params:
id— Network ID
Response:
{ "success": true }Errors:
404— Not found or not a member400— Cannot leave (owner)
Controller prefix: /integrations
Supported toolkits: gmail, slack, telegram
Telegram is a bot-based orchestrator connection (not a Composio OAuth toolkit). It doesn't use
/linkor/import; connection is established via a deep link returned byPOST /connect/telegram, and disconnection is viaDELETE /:idwithid = telegram:<userId>.
List connected accounts for the authenticated user.
Auth: AuthGuard
Query params:
indexId— Filter to connections linked to this network (optional)
Response:
{
"connections": [...]
}Start OAuth flow to connect a toolkit.
Auth: AuthGuard
Path params:
toolkit—gmail,slack, ortelegram
Response:
- For
gmail/slack: OAuth redirect URL from the integration adapter. - For
telegram:{ "deepLink": "https://t.me/<bot_username>?start=<token>" }where<token>is a short-lived one-time token (15 min TTL). Opening the link prompts Telegram to message the bot with/start <token>, which completes the connection.
Link a toolkit connection to an index.
Auth: AuthGuard
Path params:
toolkit—gmailorslack
Request body:
{
"indexId": "string (required)"
}Response:
{ "success": true }Unlink a toolkit from an index. Does not revoke the OAuth connection.
Auth: AuthGuard
Path params:
toolkit—gmailorslack
Query params:
indexId— Network to unlink from (required)
Response:
{ "success": true }Import contacts from a connected toolkit into an index.
Auth: AuthGuard
Path params:
toolkit—gmailorslack
Request body:
{
"indexId": "string (optional — defaults to personal index)"
}Response: Import result with counts.
Disconnect (delete) a connected account.
Auth: AuthGuard
Path params:
id— Connection ID (ortelegram:<userId>for Telegram)
Behavior:
- Composio connections (
gmail/slack): disconnects the OAuth account and removes all index integration links. - Telegram (
telegram:<userId>): clears the stored chatId and notification prefs. The deep-link token is unchanged; reconnect viaPOST /connect/telegram.
Response: Disconnect result.
Controller prefix: /webhooks
Inbound endpoint for Telegram Bot API updates. Called by Telegram when the bot receives a message (text or /start <token> deep-link callback).
Auth: Header X-Telegram-Bot-Api-Secret-Token must match TELEGRAM_WEBHOOK_SECRET. Otherwise responds 401.
Body: Telegram Update object (JSON). The handler only inspects message.chat.id and message.text.
Response: Always 200 OK. Inbound handling is fire-and-forget so the endpoint never blocks Telegram's delivery pipeline.
Registered automatically at backend startup via
setWebhookwhenTELEGRAM_BOT_TOKENandTELEGRAM_WEBHOOK_SECRETare configured.
Controller prefix: /intents
List intents with pagination and filters.
Auth: AuthGuard
Request body:
{
"page": "number (optional)",
"limit": "number (optional)",
"archived": "boolean (optional)",
"sourceType": "string (optional)"
}Response:
{
"intents": [
{
"id": "...",
"payload": "...",
"summary": "...",
"createdAt": "...",
"updatedAt": "...",
"archivedAt": "... | null"
}
],
"pagination": { ... }
}Confirm a proposed intent from chat. Persists the pre-verified intent directly.
Auth: AuthGuard
Request body (Zod-validated):
{
"proposalId": "string (required)",
"description": "string (required)",
"indexId": "string (optional)"
}Response:
{
"success": true,
"proposalId": "...",
"intentId": "..."
}Reject a proposed intent from chat. Logs the rejection for analytics.
Auth: AuthGuard
Request body (Zod-validated):
{
"proposalId": "string (required)"
}Response:
{
"success": true,
"proposalId": "..."
}Batch-check proposal statuses. Returns which proposal IDs have been confirmed.
Auth: AuthGuard
Request body (Zod-validated):
{
"proposalIds": ["string"]
}Response:
{
"statuses": { ... }
}Get a single intent by ID.
Auth: AuthGuard
Path params:
id— Intent ID
Response:
{
"intent": {
"id": "...",
"payload": "...",
"summary": "...",
"createdAt": "...",
"updatedAt": "...",
"archivedAt": "... | null"
}
}Archive an intent.
Auth: AuthGuard
Path params:
id— Intent ID
Response:
{ "success": true }Controller prefix: /links
List all links for the authenticated user.
Auth: AuthGuard
Response:
{
"links": [
{
"id": "...",
"url": "...",
"createdAt": "...",
"lastSyncAt": "... | null"
}
]
}Create a new link.
Auth: AuthGuard
Request body:
{
"url": "string (required)"
}Response:
{
"link": { ... }
}Delete a link.
Auth: AuthGuard
Path params:
id— Link ID
Response:
{ "success": true }Get link content/metadata.
Auth: AuthGuard
Path params:
id— Link ID
Response:
{
"url": "...",
"lastSyncAt": "... | null",
"lastStatus": "...",
"pending": true
}Controller prefix: /opportunities
List opportunities for the authenticated user.
Auth: AuthGuard
Query params:
status— Filter by status:pending,stalled,accepted,rejected,expired(optional)networkId— Filter by network (optional)limit— Max results (optional)offset— Pagination offset (optional)
Response:
{
"opportunities": [...]
}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.
Home view with dynamic sections including LLM-categorized opportunities, presenter text, and Lucide icons.
Auth: AuthGuard
Query params:
indexId— Scope to a specific network (optional)limit— Max results (optional)
Response: JSON with categorized home sections.
Discover opportunities via HyDE graph.
Auth: AuthGuard
Request body (Zod-validated):
{
"query": "string (required, min 1 char)",
"limit": "number (optional, default: 5)"
}Response: JSON with discovered opportunities.
Get one opportunity with presentation for the viewer.
Auth: AuthGuard
Path params:
id— Opportunity ID
Response: JSON with opportunity details and presentation.
Generate an invite message for a ghost counterpart on an opportunity.
Auth: AuthGuard
Path params:
id— Opportunity ID
Response: JSON with generated invite message.
Update opportunity status.
Auth: AuthGuard
Path params:
id— Opportunity ID
Request body:
{
"status": "latent | draft | negotiating | pending | stalled | accepted | rejected | expired"
}Response: JSON with updated opportunity.
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 orchestrator (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
Response:
{
"conversationId": "string",
"counterpartUserId": "string",
"opportunity": { "id": "string", "status": "accepted", "...": "..." }
}Error responses:
400— Opportunity is not inpendingordraftstatus403— Caller is not an actor on the opportunity404— Opportunity not found500— Status update or DM resolution failed
Controller prefix: /networks (separate controller registered alongside NetworkController)
List opportunities for an index. Requires membership.
Auth: AuthGuard
Path params:
indexId— Network ID
Query params:
status— Filter by status (optional)limit— Max results (optional)offset— Pagination offset (optional)
Response:
{
"opportunities": [...]
}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 created opportunity.
Controller prefix: /profiles
Trigger profile sync/generation for the authenticated user. Runs the profile graph.
Auth: AuthGuard
Response: JSON with profile generation result.
Controller prefix: /storage
Upload a library file to S3.
Auth: AuthGuard
Content-Type: multipart/form-data
Form field: file — The file to upload
Response:
{
"message": "File uploaded successfully",
"file": {
"id": "...",
"name": "...",
"size": "...",
"type": "...",
"createdAt": "...",
"url": "..."
}
}List library files for the authenticated user.
Auth: AuthGuard
Query params:
page— Page number (default: 1)limit— Items per page (default: 100, max: 100)
Response:
{
"files": [...],
"pagination": { ... }
}Download a library file (streams content from S3).
Auth: AuthGuard
Path params:
id— File ID
Response: Binary file content with Content-Disposition: attachment.
Soft-delete a library file.
Auth: AuthGuard
Path params:
id— File ID
Response:
{ "success": true }Upload an avatar image to S3.
Auth: AuthGuard
Content-Type: multipart/form-data
Form field: avatar — The image file
Response:
{
"message": "Avatar uploaded successfully",
"avatarUrl": "..."
}Serve an avatar image (public, streamed from S3).
Auth: None (public)
Path params:
userId— User IDfilename— Avatar filename
Response: Image binary with Cache-Control: public, max-age=31536000, immutable.
Upload an index/network image to S3.
Auth: AuthGuard
Content-Type: multipart/form-data
Form field: image — The image file
Response:
{
"message": "Index image uploaded successfully",
"imageUrl": "..."
}Serve an index image (public, streamed from S3).
Auth: None (public)
Path params:
userId— User IDfilename— Image filename
Response: Image binary with Cache-Control: public, max-age=31536000, immutable.
Controller prefix: /subscribe
Subscribe to newsletter or waitlist via Loops.so.
Auth: None (public)
Request body:
{
"email": "string (required)",
"type": "newsletter | waitlist (optional, default: newsletter)",
"name": "string (optional)",
"whatYouDo": "string (optional)",
"whoToMeet": "string (optional)"
}Response:
{ "success": true }Controller prefix: /unsubscribe
Soft-delete a ghost user to opt out of emails. Returns an HTML response.
Auth: None (public)
Path params:
token— Unsubscribe token fromuserNotificationSettings
Response: HTML page confirming unsubscribe or indicating the link is no longer valid.
Controller prefix: /users
Batch-fetch users by IDs (max 100).
Auth: AuthGuard
Query params:
ids— Comma-separated user IDs
Response:
{
"users": [
{
"id": "...",
"name": "...",
"intro": "...",
"avatar": "...",
"location": "...",
"socials": { ... },
"isGhost": false,
"createdAt": "...",
"updatedAt": "..."
}
]
}Manually add a contact by email (creates ghost user if not registered).
Auth: AuthGuard
Request body (Zod-validated):
{
"email": "string (required, valid email)",
"name": "string (optional)"
}Response:
{
"result": { ... }
}Remove a contact from the authenticated user's personal network (soft delete of the 'contact' membership).
Auth: AuthGuard
Response: { "success": true } on success, 404 if the contact is not a member.
Trigger a discovery negotiation between the authenticated viewer and the target user. Responds with 400 if the viewer targets themselves, 404 if the target does not exist, 409 if a negotiation between the two parties is already in flight.
Auth: AuthGuard
Response (201):
{
"negotiation": {
"id": "...",
"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": "..."
}
}List past negotiations for a user. When the viewer differs from the profile owner, only mutual negotiations are returned.
Auth: AuthGuard
Path params:
userId— User ID
Query params:
limit— Max results (default: 20, max: 50)offset— Pagination offset (default: 0)result— Filter by result:has_opportunity,no_opportunity,in_progress(optional)
Response:
{
"negotiations": [
{
"id": "...",
"counterparty": { "id": "...", "name": "...", "avatar": "..." },
"outcome": {
"hasOpportunity": true,
"finalScore": 0.85,
"role": "...",
"turnCount": 3,
"reason": "..."
},
"turns": [
{
"speaker": { "id": "...", "name": "...", "avatar": "..." },
"action": "...",
"fitScore": 0.8,
"reasoning": "...",
"suggestedRoles": { ... },
"createdAt": "..."
}
],
"createdAt": "..."
}
]
}Update the authenticated user's human-readable key.
Auth: AuthGuard
Request body:
{
"key": "string (required)"
}Key must match /^[a-z0-9][a-z0-9-]*[a-z0-9]$/, be 3–64 characters, and not collide with an existing key. Reserved words (me, new, edit, delete, settings, admin) are rejected.
Response: JSON with updated user or 400/409 validation errors.
Generate an aggregated AI insight summary of the user's negotiations. Self-only: only the authenticated user can view their own insights.
Auth: AuthGuard
Path params:
userId— User ID (must equal the authenticated user's ID)
Response:
{
"insights": {
"summary": "...",
"stats": {
"totalCount": 10,
"opportunityCount": 6,
"noOpportunityCount": 3,
"inProgressCount": 1,
"avgScore": 0.72,
"roleDistribution": { "Helper": 3, "Seeker": 2, "Peer": 1 },
"topCounterparties": [{ "id": "...", "name": "...", "avatar": "...", "count": 2 }]
}
}
}Returns { "insights": null } when no negotiations exist.
Errors:
403— Viewer is not the profile owner
Get a user by ID.
Auth: AuthGuard
Path params:
userId— User ID
Response:
{
"user": {
"id": "...",
"name": "...",
"intro": "...",
"avatar": "...",
"location": "...",
"socials": { ... },
"isGhost": false,
"createdAt": "...",
"updatedAt": "..."
}
}Controller prefix: /tools
The Tool API exposes the same handlers used by the ChatAgent as direct HTTP endpoints. This enables external clients (CLI, plugins, third-party integrations) to invoke protocol tools without going through the LLM chat loop.
List all available tools with their names, descriptions, and input schemas.
Auth: AuthGuard
Response:
{
"tools": [
{
"name": "read_intents",
"description": "Read user's intents with optional filters.",
"schema": { "type": "object", "properties": { ... } }
}
]
}Invoke a tool by name with a JSON query body.
Auth: AuthGuard
Path params:
toolName— Name of the tool to invoke (e.g.read_intents,create_opportunities)
Request body:
{
"query": { ... }
}The query object is validated against the tool's Zod schema. If omitted or unparsable, defaults to {}.
Response (success): Tool-specific JSON result with 200 status.
Error responses:
400— Invalid request body or query validation failure401— Missing or invalid auth token403— User not found or deactivated404— Tool not found (Tool "xyz" not found. Available tools: ...)500— Internal error during tool execution
Tools are organized by domain. Each tool has its own input schema (see GET /api/tools for full schemas).
| Tool | Domain | Description |
|---|---|---|
read_user_profiles |
Profile | Read user profiles (own or by query) |
create_user_profile |
Profile | Generate profile from social links or bio |
update_user_profile |
Profile | Update profile details |
complete_onboarding |
Profile | Mark onboarding complete |
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 indexes linked to an intent |
delete_intent_index |
Intent | Unlink an intent from an index |
read_indexes |
Index | List user's indexes |
read_index_memberships |
Index | List members of an index |
update_index |
Index | Update index settings (title, prompt) |
create_index |
Index | Create a new index |
delete_index |
Index | Delete an index |
create_index_membership |
Index | Add a member to an index |
delete_index_membership |
Index | Remove a member from an index |
create_opportunities |
Opportunity | Discover opportunities (search, target, introduce) |
list_opportunities |
Opportunity | List user's opportunities with filters |
update_opportunity |
Opportunity | Accept or reject an opportunity. Accepting returns a conversationId (opens a DM between both parties) |
list_contacts |
Contact | List user's contacts |
add_contact |
Contact | Add a contact by email |
remove_contact |
Contact | Remove a contact |
import_contacts |
Contact | Import contacts from file/integration |
import_gmail_contacts |
Integration | Import contacts from Gmail via Composio |
scrape_url |
Utility | Scrape and extract content from a URL |
read_docs |
Utility | Read protocol documentation |
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
Accessible at http://localhost:3001/dev/queues/ when the protocol server is running in development mode.