Skip to content

Portal API

The Portal API is the end-user surface that the shipped User Portal uses and that you build custom portals and chat clients against. It lives on the Core Proxy (:8100) — not the Control Plane. All endpoints are tenant-scoped.

Base URL http://localhost:8100/v1/portal
Auth Portal JWT from POST /v1/portal/auth/login — send as Authorization: Bearer <jwt> or X-Gateway-Authorization: Bearer <jwt> (portal endpoints auto-promote AuthorizationX-Gateway-Authorization)
Tenant X-Tenant-ID: <tenant> header on every call
Group scoping X-Resource-Group: <group_id> header → user’s primary_group_id → first membership
Errors {"error": "<message>"} — 400 validation, 401 unauth (WWW-Authenticate: Bearer), 403 forbidden (inactive user / no group access), 404, 409 conflict, 429 rate limit, 5xx

Chat itself does not go through /v1/portal/* — it goes to the LLM Proxy API with the same portal JWT. Walkthrough: Build a Custom Portal.

The portal JWT is minted by the Core Proxy at login, HS256-signed with JWT_SECRET, lifetime JWT_EXPIRES_IN seconds.

Claim Type Description
user_id string End-user id
tenant_id string Tenant
email string? User email
username string? Username
user_type string Always "end_user" for portal tokens
permissions array? Optional permission list
admin_type string? Set for admin-typed tokens
exp / iat int Expiry / issued-at (Unix)
jti string Token id

List endpoints that paginate take page and page_size (max 200) query params and return {..., total, page, page_size}.

Method Path Auth Purpose
POST /v1/portal/auth/register none Self-register an end user
POST /v1/portal/auth/login none Login → JWT (or 2FA challenge)
POST /v1/portal/auth/login/2fa temp token Complete 2FA login
POST /v1/portal/auth/validate JWT Validate an existing JWT
POST /v1/portal/auth/change-password JWT Change password
POST /v1/portal/auth/logout JWT Logout (client-side invalidation)
GET /v1/portal/auth/2fa/status JWT TOTP enrollment status
POST /v1/portal/auth/2fa/setup JWT Begin TOTP enrollment
POST /v1/portal/auth/2fa/verify JWT Confirm TOTP enrollment
POST /v1/portal/auth/2fa/disable JWT Disable TOTP

TOTP: SHA-1, 6 digits, 30-second window; 10 bcrypt-hashed backup codes are issued at setup.

POST /v1/portal/auth/register

{
"username": "jane",
"email": "jane@example.com",
"password": "s3cret-pass",
"display_name": "Jane Doe",
"avatar_url": null,
"photo_base64": null,
"photo_mime_type": null
}

username 1–100 chars (required), password 8+ chars (required); photo_mime_type one of image/jpeg|png|webp|gif. Response 201:

{ "id": "usr_...", "username": "jane", "email": "jane@example.com", "display_name": "Jane Doe", "created_at": "2026-07-02T10:00:00Z" }

POST /v1/portal/auth/login

Terminal window
curl http://localhost:8100/v1/portal/auth/login \
-H "Content-Type: application/json" \
-H "X-Tenant-ID: default" \
-d '{"username": "trial", "password": "Trial123!"}'

Response 200:

{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer",
"expires_in": 3600,
"user": { "id": "usr_...", "username": "trial", "email": "trial@example.com", "display_name": "Trial User", "is_verified": true, "metadata": {} }
}

If 2FA is enabled the response is a challenge instead:

{ "requires_2fa": true, "temp_token": "tmp_...", "user": { "id": "usr_...", "username": "trial" } }

The temp_token is valid 5 minutes. Complete with:

POST /v1/portal/auth/login/2fa

{ "temp_token": "tmp_...", "code": "123456" }

code is a 6-digit TOTP or a backup code; the response is the normal login response.

Method Path Purpose
GET /v1/portal/auth/profile Current user’s profile
PATCH /v1/portal/auth/profile Update {email?, display_name?, metadata?}
POST /v1/portal/auth/profile/photo Multipart field file, max 5 MB → {success, avatar_url}

GET /v1/portal/auth/profile response:

{
"id": "usr_...",
"username": "trial",
"email": "trial@example.com",
"display_name": "Trial User",
"is_verified": true,
"created_at": "2026-06-01T09:00:00Z",
"last_login": "2026-07-02T08:30:00Z",
"metadata": {},
"avatar_url": "/v1/assets/users/end-user/usr_.../photo?h=...",
"totp_enabled": false,
"primary_group_id": "rg_..."
}

POST /v1/portal/auth/change-password body: {"current_password": "...", "new_password": "..."}.

Method Path Purpose
GET /v1/portal/auth/groups Groups the user belongs to (direct + inherited via SAML/SCIM end-user-group memberships)
PATCH /v1/portal/auth/primary-group Switch active workspace

GET /v1/portal/auth/groups response:

{
"groups": [
{ "id": "rg_eng", "name": "engineering", "display_name": "Engineering", "description": "Eng workspace", "is_primary": true }
],
"primary_group_id": "rg_eng"
}

PATCH /v1/portal/auth/primary-group body {"group_id": "rg_sales"}{"primary_group_id": "rg_sales", "previous_primary_group_id": "rg_eng"}.

Method Path Purpose
GET /v1/portal/models Models available to the user’s active group (with inheritance)
GET /v1/portal/models/{model_id} Single model detail

Query params: group_id, enabled_only=true, healthy_only, provider, modes=chat,responses,completion.

Response:

{
"models": [
{
"id": "3f9c2d1e-...",
"model_id": "gpt-4o",
"name": "GPT-4o",
"provider": "openai",
"enabled": true,
"is_healthy": true,
"input_cost_per_million_tokens": 2.5,
"output_cost_per_million_tokens": 10.0,
"context_window": 128000,
"max_output_tokens": 16384,
"modes": ["chat"],
"supports_vision": true,
"supports_tools": true,
"supports_file_uploads": true,
"supports_streaming": true,
"icon_url": "/v1/assets/llm-providers/openai/icon",
"description": "..."
}
],
"total": 1
}
Method Path Purpose
GET /v1/portal/servers MCP server configs available to the active group (?group_id&enabled_only)
GET /v1/portal/servers/{config_id} Single server config
GET /v1/portal/servers/by-group/{group_id} Servers for a specific group (?include_inherited)

Response:

{
"servers": [
{
"id": "cfg_...",
"name": "hubspot",
"display_name": "HubSpot CRM",
"description": "...",
"display_description": "...",
"enabled": true,
"priority": 1,
"icon_url": "/v1/assets/mcp-servers/hubspot/icon",
"is_default": false,
"mcp_server": { "id": "hubspot-server", "name": "hubspot", "enabled": true, "auth_type": "oauth", "timeout_seconds": 30, "capabilities": ["tools"] },
"oauth_client": { "client_id": "...", "scopes": ["crm.objects.contacts.read"] },
"created_at": "2026-06-01T09:00:00Z",
"updated_at": "2026-06-20T09:00:00Z"
}
],
"total": 1
}

Tool calls against these servers go to the MCP gateway at /v1/proxy/mcp/{server_id}.

Method Path Purpose
GET /v1/portal/knowledgebases KB collections available to the active group (?group_id&enabled_only)
GET /v1/portal/knowledgebases/{collection_id} Single collection detail

Response:

{
"collections": [
{
"id": "kb_...",
"name": "product-docs",
"description": "Product documentation",
"enabled": true,
"top_k": 5,
"similarity_threshold": 0.7,
"max_context_tokens": 4000,
"document_count": 128,
"ready_count": 128,
"created_at": "2026-06-01T09:00:00Z",
"updated_at": "2026-06-20T09:00:00Z"
}
],
"total": 1
}

Per-user identities for source-system permission mapping on connector-synced knowledgebases.

Method Path Purpose
GET /v1/portal/kb-identities/connectors Available connector types
GET /v1/portal/kb-identities The user’s identity mappings
POST /v1/portal/kb-identities Returns 501 — self-linking is admin-managed for now
DELETE /v1/portal/kb-identities/{mapping_id} Remove one of the user’s mappings
Method Path Purpose
GET /v1/portal/notifications List (?notification_type&unread_only&page&page_size — page_size ≤ 200)
GET /v1/portal/notifications/unread-count {"unread_count": N}
POST /v1/portal/notifications/{id}/read Mark one read
POST /v1/portal/notifications/mark-all-read Mark all read

List response:

{
"notifications": [
{
"id": "ntf_...",
"notification_type": "approval",
"title": "Approval requested",
"body": "Tool call get_contacts awaits approval",
"metadata": {},
"is_read": false,
"read_at": null,
"created_at": "2026-07-02T10:00:00Z",
"expires_at": null
}
],
"total": 1,
"page": 1,
"page_size": 50
}

Human-in-the-loop approvals for governed tool calls. Concepts: Approvals.

Method Path Purpose
GET /v1/portal/approvals List (?status=pending|approved|rejected|expired|all&page&page_size)
GET /v1/portal/approvals/pending-count {"pending_count": N}
GET /v1/portal/approvals/{id} Approval detail
GET /v1/portal/approvals/{id}/poll Non-blocking poll for agents (also accepts API-key auth)
POST /v1/portal/approvals/{id}/approve Approve — body {note?}
POST /v1/portal/approvals/{id}/reject Reject — body {note?}

List item shape:

{
"id": "apr_...",
"server_config_id": "cfg_...",
"server_name": "hubspot",
"capability_type": "tool",
"capability_name": "delete_contact",
"requester_type": "agent",
"requester_display": "revenue-sentinel",
"requester_group_id": "rg_sales",
"call_arguments": { "contact_id": "123" },
"request_metadata": {},
"status": "pending",
"timeout_seconds": 300,
"expires_at": "2026-07-02T10:05:00Z",
"resolved_by": null,
"resolved_at": null,
"resolution_note": null,
"created_at": "2026-07-02T10:00:00Z"
}

Poll response (approval_token present only once approved):

{
"id": "apr_...",
"status": "approved",
"approval_token": "apt_...",
"expires_at": "2026-07-02T10:05:00Z"
}

Approve response (reject is identical minus approval_token):

{
"id": "apr_...",
"status": "approved",
"resolved_by": "42",
"resolved_at": "2026-07-02T10:02:31Z",
"approval_token": "apt_...",
"message": "Tool call 'delete_contact' approved"
}
  1. A tool call is blocked → the response indicates approval is required (an approval request is created).
  2. The client polls GET /v1/portal/approvals/{id}/poll, or the user approves it in the portal inbox.
  3. Approving returns approval_token → retry the original request with the X-Approval-Token header.
Method Path Purpose
GET /v1/portal/alerts Alerts visible to the user
POST /v1/portal/alerts/{alert_id}/acknowledge Acknowledge
GET /v1/portal/alerts/group/{group_id} Alerts for one group

List response:

{
"alerts": [
{
"id": "alr_...",
"alert_type": "budget",
"severity": "warning",
"title": "80% of daily budget used",
"message": "...",
"group_id": "rg_eng",
"threshold_value": 0.8,
"current_value": 0.82,
"resolved": false,
"created_at": "2026-07-02T10:00:00Z",
"resolved_at": null
}
],
"total": 1
}
Method Path Purpose
GET /v1/portal/mcp-apps/{server_id}/resource?uri=ui://... Proxied MCP resources/read — fetches iframe app bundles for MCP-Apps UI

Response: { "uri": "ui://...", "content": "...", "mime_type": "text/html", "size_bytes": 12345 }.

Chat uses the LLM proxy with the same portal JWT:

Terminal window
curl http://localhost:8100/v1/proxy/llm/chat/completions \
-H "Authorization: Bearer <portal JWT>" \
-H "X-Tenant-ID: default" \
-H "Content-Type: application/json" \
-d '{
"model_id": "3f9c2d1e-...",
"messages": [{"role": "user", "content": "Hello"}],
"stream": true
}'

model_id is the UUID from GET /v1/portal/models. Streaming is SSE data: {chunk} frames ending with data: [DONE] — see SSE frame format. Embeddings and the Responses API work the same way with the portal JWT.

Header Meaning
X-Usage-Warning Set when approaching a configured limit, e.g. Daily budget at 85%: $4.25 / $5.00