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 Authorization → X-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.
JWT claims
Section titled “JWT claims”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 |
Pagination conventions
Section titled “Pagination conventions”List endpoints that paginate take page and page_size (max 200) query params and return {..., total, page, page_size}.
Authentication and 2FA
Section titled “Authentication and 2FA”| 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.
Register
Section titled “Register”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
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.
Profile
Section titled “Profile”| 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": "..."}.
Groups (workspaces)
Section titled “Groups (workspaces)”| 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"}.
Models
Section titled “Models”| 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}MCP servers
Section titled “MCP servers”| 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}.
Knowledgebases
Section titled “Knowledgebases”| 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}KB identities
Section titled “KB identities”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 |
Notifications
Section titled “Notifications”| 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}Tool approvals
Section titled “Tool approvals”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"}Approval-retry flow for chat clients
Section titled “Approval-retry flow for chat clients”- A tool call is blocked → the response indicates approval is required (an approval request is created).
- The client polls
GET /v1/portal/approvals/{id}/poll, or the user approves it in the portal inbox. - Approving returns
approval_token→ retry the original request with theX-Approval-Tokenheader.
Usage and cost alerts
Section titled “Usage and cost alerts”| 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}MCP Apps
Section titled “MCP Apps”| 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 from a custom client
Section titled “Chat from a custom client”Chat uses the LLM proxy with the same portal JWT:
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.
Response headers
Section titled “Response headers”| Header | Meaning |
|---|---|
X-Usage-Warning |
Set when approaching a configured limit, e.g. Daily budget at 85%: $4.25 / $5.00 |
