Transport Contract
This page describes the runtime contract used by app-defined browser surfaces: the default chat UI, custom app main UI, widget UI, and scene surfaces. The platform owns transport and session routing; the app owns what the UI does with that surface.
KDCube serves app UI surfaces through the same integrations layer that serves app operations and static assets. A client can render a widget or main UI directly, or embed it inside another shell. The KDCube control plane may use iframes for isolation, but "iframe" is not the app surface; @ui_widget(...), @ui_main, and static assets are.
The platform exposes three integration families. Browser chat uses SSE or Socket.IO. App widgets, main views, and admin frontends use the integrations REST routes. MCP-capable clients can call app-served MCP endpoints declared with @mcp(...). The public term is app; literal route examples still use internal compatibility names such as bundle_id and /bundles/....
| Transport | Use case | Peer identifier |
|---|---|---|
| SSE | Standard browser apps. One-way server-to-client stream; chat requests sent via POST /sse/chat. |
Client-provided stream_id query param |
| Socket.IO | Apps that need bidirectional messaging or already use Socket.IO. | Connection sid (assigned by server) |
Integration Surface Matrix
| Surface | Typical client | Route family | Notes |
|---|---|---|---|
| Realtime chat | Browser app, embedded chat UI, custom app frontend | /sse/stream + /sse/chat or Socket.IO |
Streams chat_start, chat_delta, chat_compaction, chat_complete, chat_error, and related service events. |
| App REST UI / API | App widget UI, custom SPA main UI, admin panel, webhook caller | /api/integrations/bundles/{tenant}/{project}/{bundle_id}/widgets/{alias}, /operations/{alias}, /public/{alias}, /static/... |
Standard request/response surface. A request can still trigger targeted streaming events when the caller supplies KDC-Stream-ID. |
| App MCP endpoint | MCP-capable agent runtime, IDE, automation client | /api/integrations/bundles/{tenant}/{project}/{bundle_id}/mcp/{alias} or /public/mcp/{alias} |
Declared with @mcp(...). Current transport is streamable-http. This is not an SSE or Socket.IO channel. |
/operations/{alias} and consume SSE or Socket.IO for live updates. Use the /mcp/{alias} surface only when the caller actually speaks MCP.Runtime-Disabled App Surfaces
App widgets, REST operations, and MCP endpoints can be disabled at runtime through app props. When that happens the route is still part of the app contract, but the processor treats it as unavailable and returns 404.
- Treat
404on an app widget, operation, or MCP endpoint as feature unavailable, not only as transport failure. - This can change without a redeploy, so dynamic clients should refresh their available actions and widgets when app props change.
- If the whole app is disabled, all its inbound surfaces become unavailable together.
SSE Endpoints
| Endpoint | Method | Purpose |
|---|---|---|
/sse/stream | GET | Open the long-lived event stream. Requires stream_id query param. |
/sse/chat | POST | Submit conversation events. Returns a synchronous acknowledgement (processing_started, external_event_accepted, or external_event_recorded). |
/sse/conv_status.get | POST | Request the current conversation status. |
SSE Stream Query Parameters
| Param | Required | Purpose |
|---|---|---|
stream_id | Yes | Unique peer identifier for this connection |
user_session_id | No | Reuse an existing authenticated session |
bearer_token | No | Access token fallback when headers are unavailable |
id_token | No | ID token fallback when headers are unavailable |
tenant | No | Routing hint within the deployment contract; never an authorization override |
project | No | Routing hint within the deployment contract; never an authorization override |
project_events | No | Set to true only for clients that need tenant/project-level service events, such as an operations dashboard |
Shared Chat Send Contract
POST /sse/chat and Socket.IO chat_message carry the same logical chat request. The transport wrapper differs, but the admitted request semantics are shared.
Logical Event-Submission Shape
{
"conversation_id": "conv_123",
"bundle_id": "my.bundle@1-0",
"message_kind": "regular|followup|steer",
"continuation_kind": "regular|followup|steer",
"active_turn_id": "turn_current",
"target_turn_id": "turn_current",
"target": { "agent_id": "default.react.agent" },
"external_events": [{
"type": "event.user.prompt",
"event_source_id": "react.message",
"reactive": true,
"agent_id": "default.react.agent",
"payload": {
"mime": "text/plain",
"event": { "text": "Hello" }
}
}]
}
User prompts, follow-ups, steers, attachments, and app-authored events all use the ordered external_events[] batch. A client-provided turn_id, active_turn_id, or target_turn_id is correlation or routing intent; the acknowledgement reports the authoritative server decision.
Attachments on SSE
When POST /sse/chat includes attachments, it uses multipart form data:
event_submission— JSON string containing the event submission and itsexternal_events[]files— repeated binary parts, in the same order as theevent.user.attachment.*entries in that batch
Without attachments, POST /sse/chat may be plain JSON.
Attachments on Socket.IO
Socket.IO sends the event-submission object directly as the first chat_message argument:
{
"conversation_id": "conv_123",
"external_events": [{
"type": "event.user.attachment.file",
"event_source_id": "chat.attachment",
"payload": {
"mime": "text/plain",
"event": { "filename": "a.txt", "file_index": 0 }
}
}]
}
Binary buffers follow as additional event arguments. A nested {"message": ...} wrapper is rejected.
Synchronous Acknowledgement
Both send paths return an immediate acknowledgement before the turn necessarily starts. Current status values are:
| Status | Meaning |
|---|---|
processing_started | A regular reactive event batch and its bodyless lane wake were admitted atomically. |
external_event_accepted | The event batch was accepted as a continuation for the active conversation owner. |
external_event_recorded | A non-reactive event batch was recorded without creating an immediate wake. |
EventSource and requires no extra client library, but reverse proxies and CDNs must preserve streaming responses and use suitable buffering and timeout settings. Choose Socket.IO when you need bidirectional event delivery from client to server beyond the POST /sse/chat send path.Scene and Context Pins
A scene is a browser host that composes multiple app and platform surfaces: chat, canvas, memory, task views, file previews, or another widget. The scene should stay thin. It tracks registered surfaces, namespace styles, and active context drags; object meaning stays with the namespace provider.
{
"type": "kdcube.context.attach",
"contexts": [
{ "ref": "mem:record:...", "label": "Memory rule", "namespace": "mem" }
]
}
Drop on canvas
The scene asks the canvas surface to pin the canonical ref. The card color and badge come from namespace style config, not from a canvas-only fallback.
Drop on owning widget
The scene calls generic object.action(open). The named-service provider returns a UI event targeting the surface that can open the object.
Click search result
Named-service search can emit UI search-result artifacts. Capable clients render them as clickable and draggable context objects without teaching ReAct about a specific widget.
Strict Conversation and Context Refs
Only the strict form conv:<conversation_id> identifies a conversation that may be loaded. A ref such as conv:fi:conv_123.turn_456.files/report.pdf is a conversation-owned file/context object, not a conversation ID.
sdk.chat.context commands attach context and must never call conversation loading. Only sdk.chat.conversation and sdk.chat.viewer enter the open/load path. Native drag/drop and package surface commands should use the same strict parser instead of deriving behavior from the first namespace token.
Surface Commands and Acknowledgements
Cross-surface actions use declared kdcube.surface.command contracts. For example, a consent card can send connections.hub.open with the backend deep-link payload. The sender waits for an acknowledgement: host ack opens in scene; no ack uses the direct served-widget fallback; no viable route hides the action. Parent-frame existence alone is not capability.
Capabilities and Helper Threads
A chat-originated capabilities.open command carries conversation_id. Picker edits stay as a local draft until Save changes; switching conversations discards unsaved edits, and stale responses must not overwrite another conversation. An independently mounted unscoped capabilities widget edits only the future-conversation baseline.
Subagent streams fold by child conversation identity: prefer the explicit child_conversation_id stamp, otherwise the envelope's conversation ID when it matches an open helper thread. Continuation participant cards use authored_by: "agent", agent_title, and the child's contribution handoff so reload and live rendering attribute the turn to the helper, not to “You.”
See Object Ecosystem & Ontologic Contracts for the full scene and named-service journey.
Chat Stream Event Catalog
The chat stream uses one shared semantic event envelope across SSE and Socket.IO. SSE frames carry an event name and JSON data; Socket.IO delivers the same envelopes as named events. The transport route tells the client which listener fires, and the payload type tells your code what the event means.
Transport Event Names
| Stream event | Payload type | Purpose |
|---|---|---|
ready | — | Stream is open and authenticated. Payload includes session_id, user_type, stream_id. |
chat_start | chat.start | Turn accepted and processing started. |
chat_step | chat.step or custom | Structured step update (progress, tool results, decisions). |
chat_step | chat.files | Files delivered during the turn. Canonical rows carry an object_ref/ref that the client resolves under the user's session. |
chat_step | chat.citations | Links delivered to the Links surface; an item with placement: "chat" also settles in the visible conversation flow. |
chat_step | chat.conversation.title | Conversation auto-named mid-turn. Match on event.step === "conversation_title"; the name is in data.title. |
chat_delta | chat.delta | Streaming text chunks (answer, thinking, artifacts). |
chat_compaction | chat.compaction | ReAct context compaction lifecycle while a long turn is still running. |
chat_complete | chat.complete | Turn completed. Contains data.final_answer and optional data.followups. |
chat_error | chat.error | Turn failed. Contains data.error, optional data.error_type. |
chat_service | chat.service, gateway.*, rate_limit.* | Service-level events: rate limits, gateway rejections, queue status. |
conv_status | conv.status | Conversation state snapshot (idle, in_progress, error). |
server_shutdown | — | Server is draining. Reconnect after a short delay. |
chat_compaction is a progress route, not a completion route. Append it to the in-progress activity timeline or progress card and keep listening for later deltas, steps, completion, or error events. The payload includes event.status plus data.kind, data.compaction_id, and token estimates when available.
Common Envelope Shape
All chat events share this JSON structure:
{
"type": "chat.step",
"timestamp": "2026-02-26T21:14:05.267Z",
"ts": 1700000000000,
"service": {
"request_id": "...", "tenant": "...",
"user": "...", "user_type": "registered"
},
"conversation": {
"session_id": "...", "conversation_id": "...",
"turn_id": "..."
},
"event": {
"agent": "...", "step": "...",
"status": "started|running|completed|error",
"title": "...", "markdown": "..."
},
"data": { },
"delta": { },
"extra": { }
}
Clients should preserve and generically render unknown semantic event types instead of dropping the entire envelope. External conversation events use transport kind external_event; their semantic type remains inside the nested event payload.
Conversation Title
A new conversation is auto-named by the backend while the first turn is still running, not when it finishes. The name is announced as a step event so the client can update the conversation header live. It rides the chat_step route (some deployments also echo it on chat_delta), so match on event.step, not on type:
{
"type": "chat.conversation.title",
"event": { "step": "conversation_title", "status": "completed" },
"data": { "title": "Inspecting a zip and generating an Excel report" }
}
On event.step === "conversation_title", set the conversation title from data.title immediately, and do not render it as a visible timeline entry. Do not wait for chat_complete or for a conversation-list refresh to surface the name — the stored title and the list endpoint are a fallback for clients that reload history, not the live signal. Show a placeholder (for example “Untitled conversation”) until this event arrives.
Delta Markers
Streaming chunks (chat_delta) use a marker field to fan out to different UI channels:
| Marker | Meaning | Typical usage |
|---|---|---|
answer | Assistant response stream | Main answer text rendered in the chat bubble |
thinking | Reasoning stream | Internal analysis, shown in a collapsible panel |
canvas | Artifact stream | App artifacts, rendered HTML/JSON content, and widget previews. Uses extra.artifact_name for grouping. |
timeline_text | Timeline stream | Short status entries for an activity log |
subsystem | Structured JSON payloads | App widgets, tool payloads, and other structured side channels. Routed by extra.sub_type (e.g. code_exec.status, web_search.filtered_results). |
Each delta chunk looks like:
{
"delta": {
"text": "Here is the answer.",
"index": 0,
"marker": "answer",
"completed": false
},
"extra": {
"format": "markdown",
"artifact_name": "...",
"sub_type": "..."
}
}
delta.completed is true, the server has finished sending chunks for that marker/artifact. Close the corresponding UI stream.File and Link Delivery
Incoming user attachments and outgoing result files are different surfaces. User attachments arrive with the chat request. Files produced by tools, rendering, or isolated execution are emitted to authenticated chat clients as chat.files object references.
A canonical in-turn file event uses the normal event envelope and carries file rows under data.items:
{
"type": "chat.files",
"event": {
"step": "files",
"status": "completed",
"title": "Files Ready (1)"
},
"data": {
"count": 1,
"items": [
{
"filename": "report.pdf",
"mime": "application/pdf",
"object_ref": "conv:fi:conv_...turn_...files/report.pdf",
"ref": "conv:fi:conv_...turn_...files/report.pdf",
"meta": { }
}
]
}
}
Resolve object_ref at click time under the current user session, then follow the fresh authorized response. Do not ask the model to copy a signed URL into its answer. Older direct-hosting producers may still emit hosted_uri, rn, or key; treat those as source-specific compatibility metadata, not the canonical in-turn named-service contract. A tool's artifact_type: "files" result is only a declaration: delivery is complete only after hosting/materialization succeeds and chat.files arrives. Surface delivery_failed.file_hosting prominently.
Turn-less transports are intentionally different. If no chat lane is bound, the result is not rewritten and a URL-bearing MCP response remains available to the machine client. Links intended for users travel as chat.citations; render placement: "chat" both in the Links surface and in the conversation.
Usage and Token Counting
After a turn completes, the server emits an accounting.usage event (on the chat_step route) containing a cost breakdown:
{
"type": "accounting.usage",
"data": {
"breakdown": [ ... ],
"cost_total_usd": 0.0042
},
"event": {
"step": "accounting",
"markdown": "Token usage: 1,240 in / 380 out"
}
}
Socket.IO Events
Connection Setup
Connect to the platform namespace and pass authentication fields in the auth payload:
const socket = io(baseUrl, {
auth: {
bearer_token: accessToken,
id_token: idToken,
user_session_id: sessionId, // required on the normal session path
tenant: "my-tenant", // optional routing hint
project: "my-project" // optional routing hint
}
});
On the normal Socket.IO path, user_session_id is required; bearer and ID tokens may upgrade that loaded session. The exception is an app-issued federated_token connection, which creates and verifies its bound session through the federated path. On successful connection, the server assigns a sid that acts as the peer stream identifier for targeted event delivery (equivalent to SSE's stream_id).
Event Names and Payloads
Socket.IO events use the same semantic envelope as the chat stream catalog above. The named events match the shared transport routes:
| Event | Direction | Payload |
|---|---|---|
session_info | Server → Client | Bound session, user, tenant/project, and client-role information |
chat_start | Server → Client | Same envelope as SSE chat_start |
chat_step | Server → Client | Same envelope as SSE chat_step |
chat_delta | Server → Client | Same envelope as SSE chat_delta |
chat_compaction | Server → Client | Same envelope as SSE chat_compaction |
chat_complete | Server → Client | Same envelope as SSE chat_complete |
chat_error | Server → Client | Same envelope as SSE chat_error |
chat_service | Server → Client | Same envelope as SSE chat_service |
conv_status | Server → Client | Same envelope as SSE conv_status |
Namespace and Room Patterns
Events are scoped to the authenticated session. The server manages rooms internally based on session_id. Clients do not join or leave rooms manually. Broadcast events go to all peers in the session room; peer-targeted events go only to the specific sid.
/api/integrations/* and includes the KDC-Stream-ID header with the Socket.IO sid, app-emitted events will target only that peer instead of broadcasting to the entire session.Authentication for Clients
The server resolves credentials in a fixed priority order. The first source that provides a token wins.
1. Explicit Headers (highest priority)
Set on REST, SSE POST, and integration requests:
Authorization: Bearer <token> | Access token |
X-ID-Token | ID token |
User-Session-ID | Reuse an existing session |
2. SSE / Socket.IO Auth Payload
When headers are unavailable (e.g. EventSource does not support custom headers), pass tokens as query params on the SSE stream URL or in the Socket.IO auth object:
bearer_token | Access token |
id_token | ID token |
3. Cookies (lowest priority)
Fallback for cookie-based / proxylogin deployments. The browser sends these automatically:
__Secure-LATC | Access token cookie |
__Secure-LITC | ID token cookie |
Useful Request Headers
| Header | Purpose |
|---|---|
KDC-Stream-ID | Peer identifier for targeted event delivery from REST/integration calls |
X-User-Timezone | User timezone (e.g. America/New_York) for server-formatted messages |
X-User-UTC-Offset | UTC offset in minutes |
MCP auth note: the MCP route family does not by itself choose authentication. Follow the endpoint's declared app-owned, managed delegated-credential, or intentionally public contract. Managed bearer tokens are handles to server-side grant records, not browser login tokens.
Operation CSRF for Cookie-Authenticated POSTs
An app can opt an individual POST operation into CSRF protection with csrf: true. A browser first requests GET .../operations/{operation}/csrf, then sends the returned token as X-KDCube-CSRF-Token on the POST. The token is subject-, tenant-, project-, app-, operation-, and method-bound, expires after ten minutes, and is consumed once through Redis. A covered operation returns 503 if that one-use backend is unavailable. Bearer-authenticated API clients do not need this ambient-cookie defense unless their surface explicitly requires it.
Response Headers to Observe
| Header | Action |
|---|---|
X-Session-ID | Store and reuse to maintain session continuity |
X-User-Type | Resolved user type for the request |
Retry-After | Honor on 429 and 503 responses before retrying |
Token Refresh Pattern
Refresh credentials on 401 or an explicit authentication-expiry response, not on every 403. A 403 may be a valid authorization denial or a structured Connection Hub consent response; route the latter to its grant/connect flow and do not hide it behind token refresh. For expired SSE credentials, close the current EventSource, obtain fresh tokens, and reconnect with the new credentials. Keep stream_id stable across reconnects so the server can associate the new connection with the same peer.
Embedding KDCube in a Host App
A KDCube app surface can render inside a customer-owned origin. The control-plane shell stays DENY by default; app widgets and static docs stay SAMEORIGIN. Framing a KDCube surface inside a third-party origin is opt-in through assembly config, so operators can host the control plane or an app widget inside their own site without hand-patching nginx.
Frame policy: proxy.frame_embedding
assembly.yaml declares the embedding policy. Nginx / OpenResty templates and the CLI installer honor it.
proxy:
frame_embedding:
mode: allowlist # standalone | allowlist
allowed_origins:
- https://app.example.com # browser origins permitted to frame the surface
In standalone mode the shell is not embeddable. In allowlist mode the listed browser origins may frame it; the policy sets the matching Content-Security-Policy: frame-ancestors and X-Frame-Options behavior.
Iframe sizing
Embedded iframes can shrink as well as grow, so the host can size the frame to its content in both directions as an app surface changes height.
Frame View Contract
The host page drives an app widget's expanded and collapsed states through postMessage. The widget reports its preferred view and the host issues expand/collapse commands, so a widget embedded in a host shell can open to a modal-style view and return to inline without the app owning the host layout.
Public anonymous access
An anonymous public static main-view route lets a host expose an app surface before the user signs in. When the user authenticates on the host, a login handoff carries the session into the embedded surface. Frontend cookie names are configurable so the embedded surface and the host origin do not collide.
Application-hosted websites
An app can expose its complete built main-view tree as a website. Direct aliases use /sites/{alias}; a dedicated hostname can preserve the viewer host and rewrite clean paths to /api/integrations/site-root/{path}. HTML receives an injected kdcube-site-context with tenant, project, app ID, alias, public base, and catalog revision, so browser code does not parse an internal static URL to discover identity.
This site mechanism is separate from @public_content, which serves indexed public records, catalogs, metadata, and sitemaps rather than a complete UI file tree.
/assets root collision
When you front KDCube with your own site, the /assets root can collide with the host's own asset path. Serve KDCube under a path prefix, or reconcile the asset roots, so static asset requests resolve to the right origin.
Error Handling & Reconnection
SSE Reconnect Strategy
Use exponential backoff with jitter. The server does not guarantee sticky connections — any replica may serve your reconnect.
delay = min(30s, 2attempt + jitter(0..1s))
| Signal | Meaning | Action |
|---|---|---|
server_shutdown event |
Instance is draining | Close stream immediately. Reconnect after 1–2s + jitter. |
| Connection drop (no event) | Network issue or scaled-down replica | Reconnect with exponential backoff (start 1–2s, cap 30s). |
HTTP 503 with {"status":"draining"} |
Instance is draining (on REST calls) | Retry after 1–3s + jitter. |
Rate Limit Responses
Rate limits arrive as chat_service events and/or HTTP status codes:
| HTTP Status | Meaning | Action |
|---|---|---|
429 | Rate limit exceeded | Back off 2–5s + jitter. Honor Retry-After header. Max 5 retries. |
503 | Backpressure or draining | Back off 1–3s + jitter. Do not retry immediately. |
401 | Authentication missing, invalid, or expired | Refresh credentials or redirect to login. |
403 | Authenticated but denied, or consent is required | Inspect the structured error. Open the indicated Connection Hub flow for consent; otherwise show the denial. Do not blindly retry. |
In-stream rate-limit events (rate_limit.denied, rate_limit.warning) include a data.rate_limit object with retry_after_sec, reset_text, and a ready-to-display user_message. Prefer showing user_message directly.
Backpressure Signals
Gateway-level rejections arrive on chat_service with types such as gateway.backpressure, gateway.rate_limit, and gateway.circuit_breaker. These indicate the ingress is protecting the backend. Back off and retry.
Turn Interruption
If the processing worker dies after a turn has started, you may have already rendered partial chat_delta content. The server signals interruption with:
conv_statuswithdata.completion = "interrupted"chat_errorwithdata.error_type = "turn_interrupted"
Keep partial output visible, mark the turn as failed, and offer the user a manual retry. Do not auto-resubmit.
Multi-Tab Coordination
Leader Election
Use localStorage or BroadcastChannel to elect a single leader tab. Only the leader maintains the SSE connection. Follower tabs read events from shared storage or request on demand.
Burst Control
Coalesce duplicate page-load requests and serialize sends for one conversation unless your client deliberately implements follow-up or steer semantics. Respect Retry-After and prefer pushed status over polling.
Draining / Maintenance Mode
When the platform enters a drain cycle, active SSE streams receive a server_shutdown event with reason: "draining". REST endpoints return 503. This is expected, not fatal. Close connections gracefully and reconnect after a short delay. The load balancer will route you to a healthy replica.
session_id and auth tokens consistent, and the server will associate your requests correctly regardless of which instance handles them.