Build a Custom Portal or Chat Client
The shipped User Portal is built entirely on public APIs of the Core Proxy — which means you can build your own. A branded chat client, a Slack-style internal tool, a mobile app: anything that can hold a JWT and parse SSE can be a Brutor portal, and every user action stays governed and audited exactly like the stock portal.
Two API families, one base URL (http://localhost:8100):
- Portal API —
/v1/portal/*: auth, profile, workspaces, model/server/KB discovery, notifications, approvals - LLM proxy —
/v1/proxy/llm/*: the actual chat/embeddings calls, made with the same portal JWT
All examples below are plain fetch (TypeScript), with request and response bodies shown. Every endpoint is tenant-scoped; send X-Tenant-ID on every call.
const BASE = "http://localhost:8100";const TENANT = "default";
let jwt: string | null = null;
async function api(path: string, init: RequestInit = {}) { const res = await fetch(`${BASE}${path}`, { ...init, headers: { "Content-Type": "application/json", "X-Tenant-ID": TENANT, ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), ...(init.headers ?? {}), }, }); if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { res }); return res.json();}-
Log in (with the 2FA branch)
Section titled “Log in (with the 2FA branch)”async function login(username: string, password: string) {const data = await api("/v1/portal/auth/login", {method: "POST",body: JSON.stringify({ username, password }),});if (data.requires_2fa) {// temp_token is valid for 5 minutes — prompt for the TOTP codeconst code = await promptUserForTotp();const final = await api("/v1/portal/auth/login/2fa", {method: "POST",body: JSON.stringify({ temp_token: data.temp_token, code }),});jwt = final.access_token;return final.user;}jwt = data.access_token;return data.user;}POST /v1/portal/auth/login— request:{"username": "trial", "password": "Trial123!"}Response (no 2FA):
{"access_token": "eyJhbGciOiJIUzI1NiIs...","token_type": "bearer","expires_in": 3600,"user": {"id": "usr-01JX...","username": "trial","email": "trial@example.com","display_name": "Trial User","is_verified": true,"metadata": {}}}Response when the account has TOTP enabled:
{"requires_2fa": true,"temp_token": "tmp-...","user": {"id": "usr-01JX...", "username": "trial"}}POST /v1/portal/auth/login/2fatakes{"temp_token": "...", "code": "123456"}(6-digit TOTP or a backup code) and returns the normal login response.The JWT is HS256, minted by the Core Proxy, with claims
{user_id, tenant_id, username, user_type: "end_user", exp, iat, jti}. Send it asAuthorization: Bearer <jwt>(portal endpoints auto-promote it) plusX-Tenant-ID. Lifetime is the deployment’sJWT_EXPIRES_IN(default 3600s) — on401, drop to the login screen. -
Workspaces: list groups, set the primary group
Section titled “Workspaces: list groups, set the primary group”Users can belong to several resource groups (“workspaces”), each with its own models, tools, and limits. Build the workspace switcher on:
const { groups, primary_group_id } = await api("/v1/portal/auth/groups");async function switchWorkspace(groupId: string) {return api("/v1/portal/auth/primary-group", {method: "PATCH",body: JSON.stringify({ group_id: groupId }),});}GET /v1/portal/auth/groupsresponse (includes direct and inherited memberships):{"groups": [{"id": "rg-01ABC...", "name": "sales", "display_name": "Sales","description": "Customer-facing sales workspace", "is_primary": true},{"id": "rg-01DEF...", "name": "ops", "display_name": "Ops","description": null, "is_primary": false}],"primary_group_id": "rg-01ABC..."}PATCH /v1/portal/auth/primary-groupresponse:{"primary_group_id": "rg-01DEF...", "previous_primary_group_id": "rg-01ABC..."}The primary group is the default scope for every subsequent request. To scope a single request to another of the user’s groups without switching, send
X-Resource-Group: <group_id>on that request. -
List the models the user may use
Section titled “List the models the user may use”const { models, total } = await api("/v1/portal/models?enabled_only=true&modes=chat,responses");GET /v1/portal/modelsresponse (group-scoped, inheritance applied):{"models": [{"id": "9b1f2a44-6c1e-4a52-8f3d-0e7c1b2a3d45","model_id": "gpt-4o","name": "Ops — GPT-4o mini","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/icons/openai.svg","description": null}],"total": 1}Filters:
group_id,enabled_only,healthy_only,provider,modes=chat,responses,completion. Useid(the UUID) asmodel_idin chat requests — it’s unambiguous.GET /v1/portal/models/{model_id}returns one model. -
Streaming chat
Section titled “Streaming chat”Chat goes to the LLM proxy with the same portal JWT. Send
model_id(the UUID from step 3) andstream: true, then parse SSE:async function* streamChat(modelId: string, messages: object[]) {const res = await fetch(`${BASE}/v1/proxy/llm/chat/completions`, {method: "POST",headers: {"Content-Type": "application/json",Authorization: `Bearer ${jwt}`,"X-Tenant-ID": TENANT,},body: JSON.stringify({ model_id: modelId, messages, stream: true }),});if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { res });const reader = res.body!.getReader();const decoder = new TextDecoder();let buf = "";for (;;) {const { done, value } = await reader.read();if (done) return;buf += decoder.decode(value, { stream: true });for (const line of buf.split("\n\n")) {if (!line.startsWith("data: ")) continue;const payload = line.slice(6);if (payload === "[DONE]") return;const chunk = JSON.parse(payload);const delta = chunk.choices?.[0]?.delta?.content;if (delta) yield delta;}buf = buf.slice(buf.lastIndexOf("\n\n") + 2);}}for await (const token of streamChat(model.id, [{ role: "user", content: "Hello" },])) {appendToChatWindow(token);}Request:
{"model_id": "9b1f2a44-6c1e-4a52-8f3d-0e7c1b2a3d45","messages": [{"role": "user", "content": "Hello"}],"stream": true}SSE frames:
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant"},"index":0}]}data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"delta":{"content":"Hi"},"index":0}]}data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"delta":{},"index":0,"finish_reason":"stop"}]}data: [DONE]Non-streaming (
stream: false) returns the standard OpenAI shape withusage: {prompt_tokens, completion_tokens, total_tokens}. Embeddings (POST /v1/proxy/llm/embeddings) and the Responses API (POST /v1/proxy/llm/responses) work with the same JWT — see Call LLMs through the gateway. -
MCP server and knowledge-base pickers
Section titled “MCP server and knowledge-base pickers”Populate the “tools” and “knowledge” pickers from the user’s group-scoped catalogs:
const { servers } = await api("/v1/portal/servers?enabled_only=true");const { collections } = await api("/v1/portal/knowledgebases?enabled_only=true");GET /v1/portal/serversresponse:{"servers": [{"id": "cfg-01JH...","name": "hubspot-sales","display_name": "HubSpot","description": "CRM access","display_description": "Deals, contacts, notes","enabled": true,"priority": 100,"icon_url": "/v1/assets/icons/hubspot.png","is_default": false,"mcp_server": {"id": "mcp-01KS...","name": "HubSpot MCP","enabled": true,"auth_type": "oauth","timeout_seconds": 30,"capabilities": {"tools": ["get_contacts", "get_deals"]}},"oauth_client": {"client_id": "hs-client-...", "scopes": ["crm.objects.read"]},"created_at": "2026-06-01T09:00:00Z","updated_at": "2026-06-20T10:00:00Z"}],"total": 1}Tool calls then go to
POST /v1/proxy/mcp/{mcp_server.id}(JSON-RPC, same JWT) — see MCP clients.GET /v1/portal/knowledgebasesresponse:{"collections": [{"id": "kb-01JC...","name": "Product Docs","description": "Public product documentation","enabled": true,"top_k": 5,"similarity_threshold": 0.7,"max_context_tokens": 4000,"document_count": 132,"ready_count": 132,"created_at": "2026-05-10T08:00:00Z","updated_at": "2026-06-30T16:00:00Z"}],"total": 1} -
Notifications badge
Section titled “Notifications badge”Poll the unread count for the bell badge; fetch the list when opened:
// badge — poll every 30s or soconst { unread_count } = await api("/v1/portal/notifications/unread-count");// panelconst { notifications, total, page, page_size } = await api("/v1/portal/notifications?unread_only=true&page=1&page_size=20");// mark as readawait api(`/v1/portal/notifications/${id}/read`, { method: "POST" });await api("/v1/portal/notifications/mark-all-read", { method: "POST" });List response item:
{"id": "ntf-01JZ...","notification_type": "tool_approval_request","title": "Approval needed: post_message","body": "Tool 'post_message' requested by Revenue Sentinel on server 'slack' requires approval.","metadata": {"approval_request_id": "appr-01JX...","tool_name": "post_message","server_name": "slack","surface": "mcp","requester": "Revenue Sentinel"},"is_read": false,"read_at": null,"created_at": "2026-07-02T10:15:00Z","expires_at": null}notification_type: "tool_approval_request"items should deep-link into your approvals inbox (next step) viametadata.approval_request_id. -
Tool-approval inbox
Section titled “Tool-approval inbox”When a governed tool call needs a human, the gateway creates an approval request visible to members of the requesting group. Build the inbox on:
// badgeconst { pending_count } = await api("/v1/portal/approvals/pending-count");// listconst { approvals } = await api("/v1/portal/approvals?status=pending&page=1&page_size=20");// actconst approved = await api(`/v1/portal/approvals/${id}/approve`, {method: "POST",body: JSON.stringify({ note: "Looks safe — go ahead" }),});await api(`/v1/portal/approvals/${id}/reject`, {method: "POST",body: JSON.stringify({ note: "Not during quarter close" }),});List item — render
capability_name,server_name,requester_display, and thecall_argumentsthe agent wants to send:{"id": "appr-01JX...","server_config_id": "cfg-01JH...","server_name": "slack","capability_type": "tool","capability_name": "post_message","requester_type": "agent","requester_display": "Revenue Sentinel","requester_group_id": "rg-01ABC...","call_arguments": {"channel": "#revenue-alerts", "text": "🔴 CHURN RISK — ..."},"request_metadata": {},"status": "pending","timeout_seconds": 300,"expires_at": "2026-07-02T10:20:00Z","resolved_by": null,"resolved_at": null,"resolution_note": null,"created_at": "2026-07-02T10:15:00Z"}Approve response — note the one-time
approval_token:{"id": "appr-01JX...","status": "approved","resolved_by": "usr-01JX...","resolved_at": "2026-07-02T10:16:30Z","approval_token": "apt_9f2c...","message": "Tool call 'post_message' approved"}The retry contract. Whoever made the original blocked call retries it with the header
X-Approval-Token: apt_9f2c...:- An agent (API-key caller) got a
202withapproval_request_idand is pollingGET /v1/portal/approvals/{id}/poll— the poll returns{"id": "...", "status": "approved", "approval_token": "apt_9f2c..."}once resolved, and the agent retries itself. Your inbox just needs the approve button. - Your own chat UI making an interactive MCP call gets a
428 Precondition Requiredwith the same body shape. If the approver is the current user (self-approval within their group), take theapproval_tokenfrom the approve response and retry the original JSON-RPC call withX-Approval-Token.
The token authorises exactly one submission of that capability, then dies.
- An agent (API-key caller) got a
Error handling
Section titled “Error handling”Every portal endpoint returns errors as:
{"error": "Invalid username or password"}| Status | Meaning | UI behaviour |
|---|---|---|
400 |
Validation error | Show the message inline |
401 |
Missing/expired JWT (WWW-Authenticate: Bearer) |
Clear token, return to login |
403 |
Inactive account or no group access to the resource | Explain, offer workspace switch |
404 |
Resource not found / not visible in scope | Treat as absent |
409 |
Conflict (e.g. duplicate registration) | Show the message |
428 |
Approval required (interactive tool call) | Route into the approval flow |
429 |
Rate/usage limit — honor Retry-After |
Back off, show a friendly limit notice |
5xx |
Server/upstream error | Retry with backoff |
On the chat path, also branch on the structured 403 guardrail body (error.code === "guardrail_blocked") and 429 limit bodies — see error handling in the LLM guide.
Headers cheat sheet
Section titled “Headers cheat sheet”| Header | Direction | Example | Purpose |
|---|---|---|---|
Authorization |
→ | Bearer eyJhbGci... |
Portal JWT (or X-Gateway-Authorization) |
X-Tenant-ID |
→ | default |
Tenant context — send on every call |
X-Resource-Group |
→ | rg-01ABC... |
Scope one request to a non-primary workspace |
X-Approval-Token |
→ | apt_9f2c... |
One-time retry of an approval-gated call |
X-Usage-Warning |
← | Daily budget at 85%: $4.25 / $5.00 |
Soft warning before the hard 429 — show a usage notice |
Retry-After |
← | 27 |
Seconds to wait after a 429 |
Related
Section titled “Related”- Portal API reference — every endpoint and field
- Call LLMs through the gateway — the chat surface in depth
- Approvals — configuring which tools require a human
