Skip to content

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.

client.ts — the tiny base you'll reuse
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();
}
  1. 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 code
    const 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/2fa takes {"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 as Authorization: Bearer <jwt> (portal endpoints auto-promote it) plus X-Tenant-ID. Lifetime is the deployment’s JWT_EXPIRES_IN (default 3600s) — on 401, drop to the login screen.

  2. 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/groups response (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-group response:

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

  3. const { models, total } = await api(
    "/v1/portal/models?enabled_only=true&modes=chat,responses"
    );

    GET /v1/portal/models response (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. Use id (the UUID) as model_id in chat requests — it’s unambiguous. GET /v1/portal/models/{model_id} returns one model.

  4. Chat goes to the LLM proxy with the same portal JWT. Send model_id (the UUID from step 3) and stream: 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 with usage: {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.

  5. 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/servers response:

    {
    "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/knowledgebases response:

    {
    "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
    }
  6. Poll the unread count for the bell badge; fetch the list when opened:

    // badge — poll every 30s or so
    const { unread_count } = await api("/v1/portal/notifications/unread-count");
    // panel
    const { notifications, total, page, page_size } = await api(
    "/v1/portal/notifications?unread_only=true&page=1&page_size=20"
    );
    // mark as read
    await 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) via metadata.approval_request_id.

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

    // badge
    const { pending_count } = await api("/v1/portal/approvals/pending-count");
    // list
    const { approvals } = await api("/v1/portal/approvals?status=pending&page=1&page_size=20");
    // act
    const 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 the call_arguments the 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 202 with approval_request_id and is polling GET /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 Required with the same body shape. If the approver is the current user (self-approval within their group), take the approval_token from the approve response and retry the original JSON-RPC call with X-Approval-Token.

    The token authorises exactly one submission of that capability, then dies.

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.

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