Skip to content

Portals: administration API and reference ​

Everything on the portal screens is backed by two sets of routes:

  • /api/portals is 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:

json
{
  "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})
WhoA 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).
CredentialThe 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 typeJSON.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.

bash
curl -s https://console.example.com/api/portals \
  -H "Cookie: token=$SESSION"

Administration API ​

Method and pathPurpose
GET /api/portalsList portals (each with its 7-day conversation count and last-used time).
POST /api/portalsCreate a portal.
GET /api/portals/agent-optionsPublished, active agents of every project, for the agent picker.
POST /api/portals/title-modelsLanguage models that can write titles for given agents' projects.
POST /api/portals/network-checkIs this address inside these ranges? Also returns your own address as Console sees it.
POST /api/portals/importRead a settings file: a dry run by default, or apply it to a new portal or an existing one.
GET /api/portals/:idOne portal in full. ?view=summary returns only id, key, name and status.
PATCH /api/portals/:idChange a portal. Partial; any subset of fields.
DELETE /api/portals/:idDelete 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/historyChange history (?section=a,b&limit=&skip=).
GET /api/portals/:id/history/exportHistory as a file (?format=csv|json&section=).
GET /api/portals/:id/termsTerms acceptances and counts (?version=&limit=&skip=).
GET /api/portals/:id/terms/exportAcceptances as CSV (?version=).
POST /api/portals/:id/duplicateA disabled copy with the same settings.
GET /api/portals/:id/exportThe settings as a portable JSON file.
POST /api/portals/:id/view-asWhat a person or group would meet here (read-only).
POST /api/portals/:id/test-sessionOpen the portal as a test (sets the test cookie).
PUT /api/portals/:id/assets/:kindUpload favicon, appleTouchIcon or socialImage.
POST /api/portals/:id/assets/imageUpload a picture for the welcome text.
DELETE /api/portals/:id/assets/:kindRemove 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-frameThe 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.

json
{
  "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
FieldTypeDefaultNotes
namestring—Required, up to 120 characters.
keystringgenerated3 to 48 characters: ^[a-z0-9][a-z0-9-]{2,47}$. Case is folded to lower. 409 KEY_TAKEN when used.
descriptionstring—Up to 500 characters.
welcomeTextstring—Markdown, up to 4,000 characters; pictures only as ![](asset:image/{id}).
agentsarray[]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.
accessobject{ mode: "selected", groupIds: [], userIds: [] } (nobody){ mode: "all" | "selected", groupIds, userIds, networks?, embedded? }.
status"enabled" | "disabled""enabled"
theme, brandingobjectdefaultsPartial; see Appearance.
showToolArgumentsbooleantrue
locale"en" | "tr""en"
experienceobjectthe recommended setApplied on top of the recommended defaults.
compliance, announcements, limits, meta, embedobject / arraynoneSee the settings reference.
reasonstring—Kept in the change history, not on the portal. Up to 300 characters.
bash
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/:id

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

FieldBehaviour
name, description, welcomeText, status, showToolArguments, locale, keyReplaced.
agentsThe 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.
accessmode, 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, embedPartial merge. null clears a clearable value (accentColor, logo, a legal link, a limit, a meta field).
announcementsThe whole list is replaced.
reasonWritten to the change history.
bash
# 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:

json
{ "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=Decommissioned

Response 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=30

days is 7, 30 or 90 (anything else is read as 7).

json
{
  "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=0

limit is 1 to 200 (default 25). Sections: general, key, status, access, agents, appearance, experience, compliance, announcements, limits, embed, secrets, import, created, deleted.

json
{
  "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
json
{
  "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/export

Downloads portal-{address}-{YYYY-MM-DD}.json (shortened):

json
{
  "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
FieldNotes
dataThe parsed file. A file larger than 12 MB is not a portal export.
dryRunDefaults to true. Send false to apply.
targetIdReplace the settings of this portal. Omit it to create a new, disabled portal.
name, keyFor a new portal.
reasonWritten to the history (event imported).
json
{
  "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.

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

FieldTypeNewLegacyNotes
messageActions.copy / .regenerate / .editbooleantruefalse
history.rename / .pin / .bulkDeletebooleantruefalse
titlesstring"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 / .commentsbooleantrue / truefalse
export.enabled / .includeToolsbooleantrue / falsefalse
notifications.tabBadge / .soundbooleantrue / falsefalse
notifications.desktop"off" | "opt_in""off""off"
deepLinkAutoSendbooleanfalsefalse

A wrong value answers 400 invalid_experience with the field.

Welcome and starters ​

FieldLimit
welcomeText (portal) and agents[].welcome4,000 characters, Markdown; variables {{user.name}}, {{user.firstName}}, {{portal.name}}, {{agent.name}}; pictures only ![](asset:image/{id}) (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 ​

FieldDefaultLimit
terms.enabledfalseNeeds 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.version1Read-only; never taken from the request.
terms.reacceptAfterDaysnullWhole days from 1 to 3,650, or null.
disclosure.aiLabelfalse
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).

FieldDefaultLimit
idgenerated[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, endsAtnoneISO 8601; endsAt after startsAt.
dismissibletrueboolean.

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

FieldDefault and maximumEnvironment variable that sets the maximum
messagesPerMinute30PORTAL_MAX_MESSAGES_PER_MINUTE
uploadsPerMinute20PORTAL_MAX_UPLOADS_PER_MINUTE
concurrentStreams5PORTAL_MAX_STREAMS_PER_USER
maxConversationsPerPerson200PORTAL_MAX_CONVERSATIONS_PER_USER

The portal object returns limits (what you set) and limitCeilings (the maximum of each).

Appearance ​

FieldDefaultLimit
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.accentColornone#RRGGBB; null or "" clears.
branding.titlethe portal name80 characters.
branding.logononeA 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.disclaimerdefault notice300 characters.
meta.appNamethe header title60 characters.
meta.description—300 characters.

Errors are 400 VALIDATION_ERROR for theme and branding and 400 invalid_meta for meta.

Images and file limits ​

KindTypesLimit
faviconimage/png, image/svg+xml, image/x-icon, image/vnd.microsoft.icon256 KB
appleTouchIconimage/png256 KB
socialImageimage/png, image/jpeg1 MB
image (welcome picture)image/png, image/jpeg, image/webp, image/gif512 KB each, at most 8 per portal
Settings file (import)JSON12 MB

Access ​

FieldNotes
access.mode"all" or "selected". Anything else reads as selected.
access.groupIds, access.userIdsUp 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}/info

The first call the page makes. For a person who may open the portal it returns 200 (shortened):

json
{
  "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):

json
{
  "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 ​

RoutePurpose
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-sessionLeave a test session (clears the cookie).
POST /api/portal/{address}/conversations/{id}/continueCarry 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:

RoutePurpose
GET /agentsThe 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}/messagesSend a message { "message", "fileIds"?, "stream"? }. Streams server-sent events when asked.
POST /conversations/{id}/messages/{messageId}/regenerateRun 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=1Download 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}/cancelStop 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}/contentUpload (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 ​

LimitValueConfigurable
Messages, regenerations, edits, answers to requests, continues30 per minute per person per portalYes, lower it (limits.messagesPerMinute)
File uploads20 per minuteYes (limits.uploadsPerMinute)
Open event streams5 per person per portal per nodeYes (limits.concurrentStreams)
Conversations kept200 per person per portalYes (limits.maxConversationsPerPerson)
Opening conversations20 per minuteNo
Starting event streams30 per minuteNo
Exporting conversations10 per minuteNo
Every other route300 per minuteNo
One message16,000 charactersNo
Request body (all routes but the file upload)64 KBNo
Files per message50 (the agent's own count limit applies per conversation)No
File sizeFILE_UPLOAD_MAX_MB ceiling (default 50 MB), then the agent's own limitBy the operator and the agent
Agents per portal50No
Network ranges per portal32No
Test session4 hoursNo

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 ​

StatusCodeMeaning
400VALIDATION_ERRORA field is missing or wrong (name, key, agents, locale, status, theme, branding…); field says which.
400invalid_experience, invalid_compliance, invalid_announcements, invalid_limits, invalid_meta, invalid_agentsThe setting of that part was refused.
400invalid_networksA network range is not valid; issues lists them.
400invalid_importThe file is not a portal export, or was exported by a newer version.
400invalid_embed, invalid_origin, invalid_signing, invalid_client_tool, secret_limit, secret_not_found, group_not_found, privileged_groupEmbed settings problems.
401UnauthorizedNo session.
402—No active enterprise license (module: "portals").
403FORBIDDENThe caller is not a workspace owner or admin.
404NOT_FOUNDPortal, image or audience not found.
409KEY_TAKENThe address is used by another portal.

Runtime API errors ​

StatusCodeMeaning
401UNAUTHENTICATED, UNAUTHORIZEDNot signed in (UNAUTHORIZED is the refusal of a file upload without a session).
402LICENCE_REQUIREDNo active enterprise license.
403PORTAL_DISABLEDThe portal is switched off.
403PORTAL_ACCESS_DENIED (page shell), PORTAL_NOT_ALLOWED (chat)The person may not open the portal. Carries a reference code.
403ASSISTANT_NOT_ALLOWEDThe person may not run this agent. Carries a reference code.
403APPROVAL_NOT_ALLOWEDThe person may not answer this approval. Carries a reference code.
403terms_requiredThe terms must be accepted first.
403action_disabled, feedback_disabled, export_disabledThe feature is off for this portal.
403CROSS_SITE_REQUESTA state-changing request that is not same-origin.
404PORTAL_NOT_FOUNDNo such portal in the person's workspace.
409terms_changedThe terms changed since they were shown.
409invalid_message_target, action_pending, action_not_pending, action_cancelled, too_many_filesThe request does not fit the conversation's state.
410action_expiredThe approval or question expired.
413PAYLOAD_TOO_LARGEThe body is over the limit.
415UNSUPPORTED_MEDIA_TYPEA body that is not JSON (and not a same-origin file upload).
429RATE_LIMITED, TOO_MANY_CONVERSATIONSA limit was reached.
501export_format_unsupportedformat=pdf is not rendered here; use the printable page.
503AUTHORIZATION_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 ​

WhereEvents
Console audit log, service portalsportal.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 centerDenied portal.use, agent.invoke and agent.approve decisions, each with its reference code (IAM center → Access check → Look up a denied request).
Portal conversation auditconversation.export with the format, deletions, bulk deletes, pins, regenerations, edits and ratings.

Studio · Pulse · Console · Agent SDK and more — the Cognipeer documentation hub