Portals: administration API and reference
Everything on the portal screens is backed by two sets of routes:
/api/portalsis the administration API the dashboard uses: create, change and delete portals, read history, statistics and terms acceptances, duplicate, export and import, open as test, "View as", and upload images./api/portal/{address}is the runtime API the portal page and an embedded chat use: the page shell (/info), accepting terms, the portal's images, and the chat protocol served by@cognipeer/agent-server.
This page lists both with request and response examples, then every setting with its default and limits, the limits and the error codes.
Enterprise module
Portals are part of the enterprise edition. Both route sets answer 402 when the workspace has no active enterprise license:
{
"error": "Payment Required",
"message": "Portals require an active ENTERPRISE license.",
"module": "portals",
"requiresEnterprise": true
}Authentication and conventions
Administration API (/api/portals) | Runtime API (/api/portal/{address}) | |
|---|---|---|
| Who | A signed-in workspace owner or admin. The role is re-read from the user record on every request; custom RBAC roles are not accepted. | Any signed-in person. The policy engine decides, per request, whether this person may open the portal (portal.use) and run an agent in it (agent.invoke). |
| Credential | The dashboard session cookie (named token). These are the dashboard's own routes, not part of the token-based client API. | The session cookie, or for a person in an embedded frame a bearer session (Authorization: Bearer cp_es_…). |
| Content type | JSON. | JSON, except a file upload, which sends the file's own bytes. |
| Errors | { "error": "…", "code": "…", "field"?: "…", "issues"?: [...] } | The same on the page-shell calls; { "error": { "code", "message", "details"? } } on the chat routes. |
Replace https://console.example.com in the examples with your Console address, and $SESSION with the value of your session cookie.
curl -s https://console.example.com/api/portals \
-H "Cookie: token=$SESSION"Administration API
| Method and path | Purpose |
|---|---|
GET /api/portals | List portals (each with its 7-day conversation count and last-used time). |
POST /api/portals | Create a portal. |
GET /api/portals/agent-options | Published, active agents of every project, for the agent picker. |
POST /api/portals/title-models | Language models that can write titles for given agents' projects. |
POST /api/portals/network-check | Is this address inside these ranges? Also returns your own address as Console sees it. |
POST /api/portals/import | Read a settings file: a dry run by default, or apply it to a new portal or an existing one. |
GET /api/portals/:id | One portal in full. ?view=summary returns only id, key, name and status. |
PATCH /api/portals/:id | Change a portal. Partial; any subset of fields. |
DELETE /api/portals/:id | Delete a portal. ?reason= is kept in the audit log. |
GET /api/portals/:id/stats?days= | Statistics for 7, 30 or 90 days. |
GET /api/portals/:id/history | Change history (?section=a,b&limit=&skip=). |
GET /api/portals/:id/history/export | History as a file (?format=csv|json§ion=). |
GET /api/portals/:id/terms | Terms acceptances and counts (?version=&limit=&skip=). |
GET /api/portals/:id/terms/export | Acceptances as CSV (?version=). |
POST /api/portals/:id/duplicate | A disabled copy with the same settings. |
GET /api/portals/:id/export | The settings as a portable JSON file. |
POST /api/portals/:id/view-as | What a person or group would meet here (read-only). |
POST /api/portals/:id/test-session | Open the portal as a test (sets the test cookie). |
PUT /api/portals/:id/assets/:kind | Upload favicon, appleTouchIcon or socialImage. |
POST /api/portals/:id/assets/image | Upload a picture for the welcome text. |
DELETE /api/portals/:id/assets/:kind | Remove an image (?id= for a welcome picture). |
POST /api/portals/:id/embed/secrets, DELETE …/embed/secrets/:secretId, GET …/embed/status, POST …/embed/sessions/revoke, POST …/embed/test-token, POST …/embed/test-frame | The Embed tab's calls: shared secrets, live sessions, sign everyone out and the test page. See Embedding a portal. |
The portal object
Create, read and update answer with the portal in this shape (shortened). Defaults are filled in for records that predate a setting, and secrets appear as ids and dates only, never as values.
{
"portal": {
"id": "6710a5c2e1b3a4f0c8d9e012",
"key": "hr-desk",
"name": "HR Desk",
"description": "Leave, payroll and benefits",
"welcomeText": "## Hi {{user.firstName}}",
"agents": [
{
"agentKey": "leave-bot", "projectId": "670fe0…", "order": 0,
"name": "Leave bot", "projectName": "HR", "publishedVersion": 3,
"missing": false, "unpublished": false, "available": true
}
],
"access": {
"mode": "selected",
"groupIds": ["6701ab…"],
"userIds": [],
"networks": { "mode": "allow_list", "cidrs": ["203.0.113.0/24"], "deniedMessage": "Company network only." }
},
"status": "enabled",
"theme": { "colorScheme": "system", "lightPreset": "chatgpt-light", "darkPreset": "midnight" },
"branding": { "title": "HR Desk", "hasLogo": true },
"showToolArguments": true,
"locale": "en",
"experience": { "messageActions": { "copy": true, "regenerate": true, "edit": true }, "…": "…" },
"experienceConfigured": true,
"compliance": { "terms": { "enabled": false, "body": "", "version": 1, "reacceptAfterDays": null }, "disclosure": { "aiLabel": false, "links": {} } },
"announcements": [],
"limits": { "messagesPerMinute": 10 },
"limitCeilings": { "messagesPerMinute": 30, "uploadsPerMinute": 20, "concurrentStreams": 5, "maxConversationsPerPerson": 200 },
"meta": { "appName": "HR Desk" },
"assets": {},
"embed": { "enabled": false, "…": "…" },
"path": "/p/hr-desk",
"url": "https://console.example.com/p/hr-desk",
"createdBy": "66f1…", "createdAt": "2026-10-12T08:00:00.000Z", "updatedAt": "2026-10-12T09:30:00.000Z",
"conversations7d": 21,
"lastUsedAt": "2026-10-12T09:12:44.000Z"
}
}The url is built from the configured canonical Console address (APP_URL), never from request headers. experienceConfigured: false marks a legacy portal. The detail view (GET /api/portals/:id) adds the 7-day figures as kpi7d, the creator and updater names, the number of people who accepted the current terms (termsAcceptedCount) and what the Embed tab needs (embedInfo).
Create a portal
POST /api/portals| Field | Type | Default | Notes |
|---|---|---|---|
name | string | — | Required, up to 120 characters. |
key | string | generated | 3 to 48 characters: ^[a-z0-9][a-z0-9-]{2,47}$. Case is folded to lower. 409 KEY_TAKEN when used. |
description | string | — | Up to 500 characters. |
welcomeText | string | — | Markdown, up to 4,000 characters; pictures only as . |
agents | array | [] | Up to 50 entries { projectId, agentKey, order?, starters?, welcome? }. Each must exist, be active and have a published version; an agent key may appear once. |
access | object | { mode: "selected", groupIds: [], userIds: [] } (nobody) | { mode: "all" | "selected", groupIds, userIds, networks?, embedded? }. |
status | "enabled" | "disabled" | "enabled" | |
theme, branding | object | defaults | Partial; see Appearance. |
showToolArguments | boolean | true | |
locale | "en" | "tr" | "en" | |
experience | object | the recommended set | Applied on top of the recommended defaults. |
compliance, announcements, limits, meta, embed | object / array | none | See the settings reference. |
reason | string | — | Kept in the change history, not on the portal. Up to 300 characters. |
curl -s -X POST https://console.example.com/api/portals \
-H "Cookie: token=$SESSION" -H "Content-Type: application/json" \
-d '{
"name": "HR Desk",
"key": "hr-desk",
"agents": [{ "projectId": "670fe0a1c2d3e4f5a6b7c8d9", "agentKey": "leave-bot" }],
"access": { "mode": "selected", "groupIds": ["6701ab12c3d4e5f6a7b8c9d0"], "userIds": [] },
"locale": "en",
"reason": "Pilot for the HR team"
}'Response 201: { "portal": { … } }. Validation failures answer 400 with code and the refused field.
Update a portal
PATCH /api/portals/:idSend only what changes. Settings objects are merged onto what is stored and validated field by field; each section is written as a whole, so clearing a value sticks.
| Field | Behaviour |
|---|---|
name, description, welcomeText, status, showToolArguments, locale, key | Replaced. |
agents | The whole list is replaced; the given order decides the order and is renumbered 0..n-1. starters / welcome omitted on an entry keep their stored value; starters: null clears them. Entries already in the portal are not re-checked, so an agent unpublished since does not block an unrelated edit. |
access | mode, groupIds, userIds are replaced as given (missing lists become empty). networks and embedded are kept unless named; networks: null or { "mode": "any" } removes the restriction. |
theme, branding, experience, compliance, limits, meta, embed | Partial merge. null clears a clearable value (accentColor, logo, a legal link, a limit, a meta field). |
announcements | The whole list is replaced. |
reason | Written to the change history. |
# Switch on terms and a legal link, lower the message rate, and say why
curl -s -X PATCH https://console.example.com/api/portals/6710a5c2e1b3a4f0c8d9e012 \
-H "Cookie: token=$SESSION" -H "Content-Type: application/json" \
-d '{
"compliance": {
"terms": { "enabled": true, "title": "Terms of use", "body": "## House rules\n\nDo not paste secrets.", "reacceptAfterDays": 180 },
"disclosure": { "aiLabel": true, "links": { "privacy": "https://example.com/privacy", "support": "mailto:help@example.com" } }
},
"limits": { "messagesPerMinute": 10 },
"reason": "Ticket 1234"
}'Restrict the portal to a network:
{ "access": { "mode": "selected", "groupIds": ["6701ab12c3d4e5f6a7b8c9d0"], "userIds": [],
"networks": { "mode": "allow_list", "cidrs": ["203.0.113.0/24", "2001:db8::/32"], "deniedMessage": "Company network only." } } }Delete a portal
DELETE /api/portals/:id?reason=DecommissionedResponse 200: { "success": true }, or 404 NOT_FOUND. The portal's history rows are removed with it; the deletion remains in the console audit log.
Statistics
GET /api/portals/:id/stats?days=30days is 7, 30 or 90 (anything else is read as 7).
{
"stats": {
"range": { "from": "2026-09-13T00:00:00.000Z", "to": "2026-10-12T09:30:00.000Z", "days": 30 },
"totals": {
"conversations": 112, "messages": 640, "activeUsers": 37,
"inputTokens": 410220, "outputTokens": 96810, "totalTokens": 507030,
"costUsd": 4.2931, "costComplete": true
},
"byAgent": [
{ "agentKey": "leave-bot", "projectId": "670fe0…", "name": "Leave bot", "conversations": 90, "messages": 520, "activeUsers": 33, "totalTokens": 430110, "costUsd": 3.61 }
],
"daily": [
{ "date": "2026-09-13", "conversations": 3, "messages": 14, "activeUsers": 3, "totalTokens": 9800, "costUsd": 0.08 }
],
"truncated": false
}
}Days are UTC and every day of the window is present. costComplete: false means some turn reported tokens without a price (cost is a lower bound). truncated: true means more than 5,000 conversations were active in the window (figures are a lower bound). Test sessions are not counted.
Change history
GET /api/portals/:id/history?section=access,limits&limit=25&skip=0limit is 1 to 200 (default 25). Sections: general, key, status, access, agents, appearance, experience, compliance, announcements, limits, embed, secrets, import, created, deleted.
{
"entries": [
{
"id": "6710b0…", "portalId": "6710a5…", "portalKey": "hr-desk",
"section": "limits", "event": "updated",
"actorUserId": "66f1…", "actorName": "Anna Admin", "actorEmail": "anna@example.com",
"ip": "203.0.113.9", "userAgent": "Mozilla/5.0 …", "requestId": "req_…",
"reason": "Ticket 1234",
"changes": [ { "path": "limits.messagesPerMinute", "before": 30, "after": 10 } ],
"createdAt": "2026-10-12T09:30:01.000Z"
}
],
"total": 14
}A change is { path, before?, after? }, or { path, added?, removed? } for a list. Secrets are "[hidden]", images are "[image 12 KB]", text over 500 characters is cut, lists over 20 entries are cut.
GET …/history/export?format=csv returns time, section, event, actor, actor_email, ip, reason, path, before, after, added, removed (cells that start with =, +, - or @ are prefixed with ' so a spreadsheet does not run them); format=json returns { portal: { id, key }, truncated, entries }. An export holds up to 10,000 rows, newest first, and sends X-Export-Truncated: true when it was cut.
Terms acceptances
GET /api/portals/:id/terms?version=2&limit=50&skip=0{
"rows": [
{ "userId": "66f2…", "email": "sam@example.com", "name": "Sam", "version": 2, "acceptedAt": "2026-10-12T08:41:00.000Z", "ip": "198.51.100.4", "userAgent": "Mozilla/5.0 …" }
],
"total": 37, "acceptedCurrent": 35, "currentVersion": 2
}limit is 1 to 200 (default 50). GET …/terms/export downloads portal-{address}-terms-acceptances.csv (accepted_at, version, user_id, email, name, ip, user_agent), up to 50,000 rows, with X-Export-Truncated when cut. Acceptances from test sessions are not listed or counted.
Duplicate
POST /api/portals/:id/duplicate
{ "name": "HR Desk (staging)", "key": "hr-desk-staging", "reason": "Rehearsal" }name defaults to {name} (copy), key to a generated one. Response 201: { "portal": { … } }. The copy is disabled, has no secrets, a terms version of 1 and no acceptances, conversations or statistics.
Export and import
GET /api/portals/:id/exportDownloads portal-{address}-{YYYY-MM-DD}.json (shortened):
{
"kind": "cognipeer.portal",
"schemaVersion": 2,
"exportedAt": "2026-10-12T09:40:00.000Z",
"portal": {
"name": "HR Desk",
"status": "enabled",
"locale": "en",
"agents": [ { "agentKey": "leave-bot", "projectKey": "hr", "projectName": "HR", "order": 0 } ],
"access": { "mode": "selected", "groups": [ { "name": "HR team" } ], "users": [ { "email": "sam@example.com" } ], "networks": { "mode": "allow_list", "cidrs": ["203.0.113.0/24"] } },
"theme": { "colorScheme": "system", "lightPreset": "chatgpt-light", "darkPreset": "midnight" },
"experience": { "…": "…" },
"compliance": { "terms": { "enabled": true, "body": "…", "reacceptAfterDays": 180 }, "disclosure": { "…": "…" } },
"announcements": [],
"limits": { "messagesPerMinute": 10 },
"assets": { "favicon": { "contentType": "image/png", "data": "iVBORw0KGgo…" } }
}
}schemaVersion 1 files are still read; a newer one is refused. Signing secrets, tokens, terms acceptances, conversations, statistics and history are never in the file. Pictures are included while their total stays within 4 MB.
POST /api/portals/import| Field | Notes |
|---|---|
data | The parsed file. A file larger than 12 MB is not a portal export. |
dryRun | Defaults to true. Send false to apply. |
targetId | Replace the settings of this portal. Omit it to create a new, disabled portal. |
name, key | For a new portal. |
reason | Written to the history (event imported). |
{
"report": {
"ok": true,
"target": { "mode": "overwrite", "portalId": "6710a5…", "name": "HR Desk" },
"counts": { "agents": 4, "groups": 0, "users": 2, "announcements": 5, "images": 3 },
"unresolved": { "agents": ["ghost-agent-xyz (hr)"], "groups": ["Ghost Group"], "users": ["ghost.person@example.com"] },
"problems": [],
"ignored": ["embed.allowedOrigins"]
}
}report.ok: false lists problems ({ code, message, status, field? }); nothing is applied then. unresolved entries are left out, never guessed. ignored names what the file carries that an existing portal keeps as it is (the trust settings of an embed). Applying answers 201 with { report, portal }.
View as
POST /api/portals/:id/view-as
{ "audience": { "type": "user", "id": "66f2…" }, "ip": "198.51.100.4", "embedded": false }audience.type is user or group; ip and embedded are optional. Nothing runs and nobody is signed in.
{
"view": {
"portal": { "id": "6710a5…", "key": "hr-desk", "name": "HR Desk", "status": "enabled", "networks": { "mode": "allow_list", "count": 1 }, "embedded": "access_list" },
"audience": { "type": "user", "id": "66f2…", "label": "Sam", "disabled": false },
"canOpen": { "allowed": true, "defaultDeny": false, "reasons": [] },
"agents": [
{ "agentId": "670f…", "key": "leave-bot", "name": "Leave bot", "allowed": true, "defaultDeny": false, "reasons": [] }
],
"runnableAgentIds": ["670f…"],
"truncated": false
}
}defaultDeny: true means nothing allowed it (no rule matched), as opposed to a rule that forbade it; reasons explains the policies involved. At most the first 50 agents are decided (truncated). A portal or audience that does not exist answers 404.
Network check
POST /api/portals/network-check
{ "networks": { "mode": "allow_list", "cidrs": ["203.0.113.0/24"] }, "ip": "203.0.113.9" }{
"yourAddress": "198.51.100.4",
"address": "203.0.113.9",
"verdict": { "allowed": true, "reason": "listed", "matched": "203.0.113.0/24" },
"networks": { "mode": "allow_list", "cidrs": ["203.0.113.0/24"] }
}verdict.reason is any (no restriction), listed (with matched), not_listed or unknown_address. Omit ip to check the address the engine sees for you. A bad range answers 400 invalid_networks with issues such as { code: "invalid_cidr", index, value, message }; other issue codes are too_many, empty_allow_list, invalid_mode, not_an_object and cidrs_not_a_list.
Test session
POST /api/portals/:id/test-session{ "url": "/p/hr-desk", "key": "hr-desk", "expiresInSeconds": 14400 }The response sets the signed HttpOnly cookie cp_portal_test (SameSite=Lax, Secure in production, 4 hours), bound to you, the workspace and this portal. Open url to use it. DELETE /api/portal/{address}/test-session clears it.
Images
PUT /api/portals/:id/assets/favicon
{ "contentType": "image/png", "data": "<base64>" }{ "asset": { "kind": "favicon", "contentType": "image/png", "size": 4821, "etag": "9f1c2d3e4a5b6c7d", "updatedAt": "2026-10-12T09:50:00.000Z", "url": "/api/portal/hr-desk/assets/favicon?v=9f1c2d3e4a5b6c7d" } }POST …/assets/image uploads a welcome picture, answers 201 and adds id and token (asset:image/{id}) to put in the welcome text. DELETE …/assets/{kind} removes one (?id= for a welcome picture). The bytes must really be the declared type; limits are in the settings reference. The request body limit for these routes is 2 MB.
Settings reference
Experience (chat features)
experience on create or update. A legacy portal reads as everything off; a portal created now starts from the New column.
| Field | Type | New | Legacy | Notes |
|---|---|---|---|---|
messageActions.copy / .regenerate / .edit | boolean | true | false | |
history.rename / .pin / .bulkDelete | boolean | true | false | |
titles | string | "agent_model" | "off" | "off", "agent_model", or a model key (letters, digits and ._:/@-, up to 120 characters; must be an llm model in the project of one of the portal's agents). |
feedback.enabled / .comments | boolean | true / true | false | |
export.enabled / .includeTools | boolean | true / false | false | |
notifications.tabBadge / .sound | boolean | true / false | false | |
notifications.desktop | "off" | "opt_in" | "off" | "off" | |
deepLinkAutoSend | boolean | false | false |
A wrong value answers 400 invalid_experience with the field.
Welcome and starters
| Field | Limit |
|---|---|
welcomeText (portal) and agents[].welcome | 4,000 characters, Markdown; variables {{user.name}}, {{user.firstName}}, {{portal.name}}, {{agent.name}}; pictures only  (anything else: 400). |
agents[].starters[] | At most 6; text required, up to 500 characters; label up to 60; autoSend boolean (default false). A portal's starters replace the agent's own. |
Compliance
| Field | Default | Limit |
|---|---|---|
terms.enabled | false | Needs a non-empty body. |
terms.title | — | 120 characters; null clears. |
terms.body | "" | 20,000 characters, Markdown. A change after terms existed raises version by 1. |
terms.version | 1 | Read-only; never taken from the request. |
terms.reacceptAfterDays | null | Whole days from 1 to 3,650, or null. |
disclosure.aiLabel | false | |
disclosure.labelText | — | 60 characters; null clears. |
disclosure.links.privacy / .terms / .support / .aboutAi | — | Full https address up to 500 characters with no spaces or credentials; support may be mailto:. null or "" clears. |
Errors are 400 invalid_compliance.
Announcements
announcements is the whole list (a request replaces what is stored).
| Field | Default | Limit |
|---|---|---|
id | generated | [A-Za-z0-9_-], up to 64. Keeping an id with changed text or severity gives the announcement a new id (people who dismissed the old one see it again). |
severity | "info" | info, warning, critical. |
text | — | Required, 500 characters, short Markdown. |
startsAt, endsAt | none | ISO 8601; endsAt after startsAt. |
dismissible | true | boolean. |
At most 20 stored and at most 5 active at any one moment. Errors are 400 invalid_announcements. At run time the active ones are returned critical first, then warning, then information.
Limits (per portal)
limits takes whole numbers from 1 to the platform maximum; null returns a limit to the default. Errors are 400 invalid_limits (… cannot be above 30).
| Field | Default and maximum | Environment variable that sets the maximum |
|---|---|---|
messagesPerMinute | 30 | PORTAL_MAX_MESSAGES_PER_MINUTE |
uploadsPerMinute | 20 | PORTAL_MAX_UPLOADS_PER_MINUTE |
concurrentStreams | 5 | PORTAL_MAX_STREAMS_PER_USER |
maxConversationsPerPerson | 200 | PORTAL_MAX_CONVERSATIONS_PER_USER |
The portal object returns limits (what you set) and limitCeilings (the maximum of each).
Appearance
| Field | Default | Limit |
|---|---|---|
theme.colorScheme | "system" | system, light, dark. |
theme.lightPreset | "chatgpt-light" | chatgpt-light, snow, arctic, cloud, pearl, sky, sandstone, rose-garden, mint, lavender. |
theme.darkPreset | "midnight" | midnight, ocean, purple-haze, sunset, emerald, onyx, nord. |
theme.accentColor | none | #RRGGBB; null or "" clears. |
branding.title | the portal name | 80 characters. |
branding.logo | none | A data:image/(png|jpeg|svg+xml|webp);base64,… URI, up to 256 KB decoded. The content must match the type; an SVG with scripts, handlers, foreign content, a DOCTYPE or external references is rejected. null clears. |
branding.disclaimer | default notice | 300 characters. |
meta.appName | the header title | 60 characters. |
meta.description | — | 300 characters. |
Errors are 400 VALIDATION_ERROR for theme and branding and 400 invalid_meta for meta.
Images and file limits
| Kind | Types | Limit |
|---|---|---|
favicon | image/png, image/svg+xml, image/x-icon, image/vnd.microsoft.icon | 256 KB |
appleTouchIcon | image/png | 256 KB |
socialImage | image/png, image/jpeg | 1 MB |
image (welcome picture) | image/png, image/jpeg, image/webp, image/gif | 512 KB each, at most 8 per portal |
| Settings file (import) | JSON | 12 MB |
Access
| Field | Notes |
|---|---|
access.mode | "all" or "selected". Anything else reads as selected. |
access.groupIds, access.userIds | Up to 1,000 ids each; ignored when the mode is all. |
access.networks | { mode: "any" | "allow_list", cidrs, deniedMessage? }. Up to 32 ranges (IPv4, IPv6 or a single address; IPv4-mapped IPv6 is refused); deniedMessage up to 300 characters. 400 invalid_networks otherwise. |
access.embedded | "access_list" (default) or "any_verified": whether people who arrive through the embed must be on the list. |
Runtime API
All runtime routes live under /api/portal/{address}. The tenant is not in the URL: it comes from the signed-in session.
Page shell
GET /api/portal/{address}/infoThe first call the page makes. For a person who may open the portal it returns 200 (shortened):
{
"state": "ok",
"user": { "id": "66f2…", "email": "sam@example.com", "displayName": "Sam" },
"portal": { "id": "6710a5…", "key": "hr-desk", "name": "HR Desk", "welcomeText": "## Hi {{user.firstName}}" },
"theme": { "colorScheme": "system", "lightPreset": "chatgpt-light", "darkPreset": "midnight" },
"branding": { "title": "HR Desk", "logo": "data:image/png;base64,…" },
"locale": "en",
"agents": [ { "id": "leave-bot", "name": "Leave bot", "description": "…" } ],
"chat": {
"configured": true,
"messageActions": { "copy": true, "regenerate": true, "edit": true },
"history": { "rename": true, "pin": true, "bulkDelete": true },
"feedback": { "enabled": true, "comments": true },
"export": { "enabled": true, "includeTools": false },
"notifications": { "tabBadge": true, "sound": false, "desktop": "off" },
"deepLinkAutoSend": false
},
"announcements": [ { "id": "ann_1a2b3c4d5e", "severity": "warning", "text": "Maintenance tonight", "dismissible": true } ],
"compliance": {
"terms": { "required": true, "title": "Terms of use", "body": "## House rules …", "version": 2 },
"disclosure": { "aiLabel": true, "links": { "privacy": "https://example.com/privacy" } }
},
"meta": { "appName": "HR Desk", "favicon": "/api/portal/hr-desk/assets/favicon?v=9f1c2d3e4a5b6c7d" }
}test: true is added for a test session and embedded: true for a person who arrived through the embed. chat.configured: false marks a legacy portal. compliance.terms is null when the portal asks for none. Only the announcements due now are returned.
A person who cannot open the portal gets the refusal below, with a state the page turns into a friendly screen, the user, and for refusals by the policy engine a decisionId (also in the X-Decision-Id header):
{
"error": "You do not have access to this portal",
"message": "You do not have access to this portal",
"code": "PORTAL_ACCESS_DENIED",
"state": "forbidden",
"user": { "id": "66f2…", "email": "sam@example.com" },
"decisionId": "2d895bb3-abec-4c60-bb0e-ee61ef1dc817"
}For a refusal by the network restriction the answer also carries "reason": "network" and, when the portal chose its own words, "deniedMessage".
Accept the terms
POST /api/portal/{address}/terms/accept
{ "version": 2 }Response 200: { "accepted": true, "version": 2 }. 409 terms_changed when the version is no longer current, 400 invalid_version when it is missing. A portal without terms answers 200 and records nothing. While terms are open, every chat route answers 403 terms_required.
Images, test session and continue
| Route | Purpose |
|---|---|
GET /api/portal/{address}/assets/{favicon|appleTouchIcon|socialImage} and …/assets/image/{id} | The portal's images, for people who may open the portal. ETag and a fingerprint query make them cacheable; 304 on a match. |
DELETE /api/portal/{address}/test-session | Leave a test session (clears the cookie). |
POST /api/portal/{address}/conversations/{id}/continue | Carry a conversation on after its usage limit was raised, for an agent that lets clients do it. JSON, or { "stream": true } for SSE. 403 budget_forbidden otherwise. |
The chat protocol
Everything else under /api/portal/{address} is the open @cognipeer/agent-server protocol, wired to Console. The ones the portal features rely on:
| Route | Purpose |
|---|---|
GET /agents | The agents the person can use in this portal. |
GET /conversations, POST /conversations, GET/PATCH/DELETE /conversations/{id} | Conversations of the signed-in person only. PATCH takes title (up to 200 characters; "" restores the generated one), pinned and a usage budget. |
POST /conversations/bulk-delete | { "ids": [...] }, at most 100. Answers { "deleted": n, "results": [{ "id", "status": "deleted" | "not_found" | "error" }] }. Someone else's conversation is not_found, like one that does not exist; with bulk delete off each result is error. |
POST /conversations/{id}/messages | Send a message { "message", "fileIds"?, "stream"? }. Streams server-sent events when asked. |
POST /conversations/{id}/messages/{messageId}/regenerate | Run the newest answer's question again. 409 invalid_message_target (reason: not_newest) for any other message. |
POST /conversations/{id}/messages/{messageId}/edit | { "content" }: replace one of the person's own messages and everything after it, then answer again. 400 for empty content; 409 invalid_message_target for an answer (only the person's own messages can be edited). |
POST /conversations/{id}/messages/{messageId}/feedback | { "rating": "up" | "down" | null, "reasons"?, "comment"? }. null withdraws. Reasons: inaccurate, incomplete, off_topic, too_long, unsafe, other; comment up to 1,000 characters (400 when comments are off). One rating per person per message, replaceable. |
GET /conversations/{id}/export?format=md|json|html&tools=0|1&print=1 | Download a conversation. format=pdf answers 501 export_format_unsupported; use format=html&print=1 for a printable page. tools=1 needs Include tool calls on. Each export is audited as conversation.export. |
POST /conversations/{id}/actions/{actionId} | Answer an approval (allow or deny, an optional comment, and rememberForSession) or a question (the answers, or cancel). The turn then continues and can stream like a message. The body follows the agent-server protocol. |
POST /conversations/{id}/cancel | Stop a running answer. 409 action_pending when the conversation is only waiting on a request. |
GET /conversations/{id}/events/stream?after={seq} | Reattach to a running turn (reload, second tab). |
POST /files?name=&conversationId=, GET /conversations/{id}/files, GET /files/{id}/content | Upload (the file's own bytes with its Content-Type, or base64 JSON), list and download attachments. |
Features that are off answer 403 with action_disabled (rename, pin, regenerate, edit), feedback_disabled or export_disabled.
Limits
| Limit | Value | Configurable |
|---|---|---|
| Messages, regenerations, edits, answers to requests, continues | 30 per minute per person per portal | Yes, lower it (limits.messagesPerMinute) |
| File uploads | 20 per minute | Yes (limits.uploadsPerMinute) |
| Open event streams | 5 per person per portal per node | Yes (limits.concurrentStreams) |
| Conversations kept | 200 per person per portal | Yes (limits.maxConversationsPerPerson) |
| Opening conversations | 20 per minute | No |
| Starting event streams | 30 per minute | No |
| Exporting conversations | 10 per minute | No |
| Every other route | 300 per minute | No |
| One message | 16,000 characters | No |
| Request body (all routes but the file upload) | 64 KB | No |
| Files per message | 50 (the agent's own count limit applies per conversation) | No |
| File size | FILE_UPLOAD_MAX_MB ceiling (default 50 MB), then the agent's own limit | By the operator and the agent |
| Agents per portal | 50 | No |
| Network ranges per portal | 32 | No |
| Test session | 4 hours | No |
Over a rate limit the response is 429 RATE_LIMITED with a Retry-After header; over the conversation cap it is 429 TOO_MANY_CONVERSATIONS. Limits are counted per signed-in person and portal.
Error codes
Administration API errors
| Status | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | A field is missing or wrong (name, key, agents, locale, status, theme, branding…); field says which. |
| 400 | invalid_experience, invalid_compliance, invalid_announcements, invalid_limits, invalid_meta, invalid_agents | The setting of that part was refused. |
| 400 | invalid_networks | A network range is not valid; issues lists them. |
| 400 | invalid_import | The file is not a portal export, or was exported by a newer version. |
| 400 | invalid_embed, invalid_origin, invalid_signing, invalid_client_tool, secret_limit, secret_not_found, group_not_found, privileged_group | Embed settings problems. |
| 401 | Unauthorized | No session. |
| 402 | — | No active enterprise license (module: "portals"). |
| 403 | FORBIDDEN | The caller is not a workspace owner or admin. |
| 404 | NOT_FOUND | Portal, image or audience not found. |
| 409 | KEY_TAKEN | The address is used by another portal. |
Runtime API errors
| Status | Code | Meaning |
|---|---|---|
| 401 | UNAUTHENTICATED, UNAUTHORIZED | Not signed in (UNAUTHORIZED is the refusal of a file upload without a session). |
| 402 | LICENCE_REQUIRED | No active enterprise license. |
| 403 | PORTAL_DISABLED | The portal is switched off. |
| 403 | PORTAL_ACCESS_DENIED (page shell), PORTAL_NOT_ALLOWED (chat) | The person may not open the portal. Carries a reference code. |
| 403 | ASSISTANT_NOT_ALLOWED | The person may not run this agent. Carries a reference code. |
| 403 | APPROVAL_NOT_ALLOWED | The person may not answer this approval. Carries a reference code. |
| 403 | terms_required | The terms must be accepted first. |
| 403 | action_disabled, feedback_disabled, export_disabled | The feature is off for this portal. |
| 403 | CROSS_SITE_REQUEST | A state-changing request that is not same-origin. |
| 404 | PORTAL_NOT_FOUND | No such portal in the person's workspace. |
| 409 | terms_changed | The terms changed since they were shown. |
| 409 | invalid_message_target, action_pending, action_not_pending, action_cancelled, too_many_files | The request does not fit the conversation's state. |
| 410 | action_expired | The approval or question expired. |
| 413 | PAYLOAD_TOO_LARGE | The body is over the limit. |
| 415 | UNSUPPORTED_MEDIA_TYPE | A body that is not JSON (and not a same-origin file upload). |
| 429 | RATE_LIMITED, TOO_MANY_CONVERSATIONS | A limit was reached. |
| 501 | export_format_unsupported | format=pdf is not rendered here; use the printable page. |
| 503 | AUTHORIZATION_UNAVAILABLE (page shell), PERMISSIONS_UNAVAILABLE (chat) | The policy engine could not decide. The portal fails closed and never reads this as "no access". |
The refusals that carry a reference code use codes the chat component has no text for (PORTAL_NOT_ALLOWED, ASSISTANT_NOT_ALLOWED, APPROVAL_NOT_ALLOWED, PERMISSIONS_UNAVAILABLE), so the person reads the message, including the code, as written.
Audit events
| Where | Events |
|---|---|
Console audit log, service portals | portal.config.created, .updated, .deleted, .imported, .duplicated, each with the portal key, the sections changed and the redacted changes. |
Console audit log, service portals (embed) | portal.embed.exchange, portal.embed.refused, portal.embed.logout. |
Console audit log, service portals (test sessions) | portal.test_session, at most once a minute per person and portal. |
| Authorization center | Denied portal.use, agent.invoke and agent.approve decisions, each with its reference code (IAM center → Access check → Look up a denied request). |
| Portal conversation audit | conversation.export with the format, deletions, bulk deletes, pins, regenerations, edits and ratings. |

