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

Client transport options diagram
Client Transport Options Browser connects via SSE or Socket.IO to platform ingress Browser EventSource / io() GET /sse/stream SSE Transport one-way stream + POST send io.connect() Socket.IO Transport bidirectional events Platform Ingress Chat Relay + Redis Pub/Sub
TransportUse casePeer 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

SurfaceTypical clientRoute familyNotes
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.
i
Browser UI vs MCP: If you are building an app widget or main view, you will usually call /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 404 on 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

EndpointMethodPurpose
/sse/streamGETOpen the long-lived event stream. Requires stream_id query param.
/sse/chatPOSTSubmit conversation events. Returns a synchronous acknowledgement (processing_started, external_event_accepted, or external_event_recorded).
/sse/conv_status.getPOSTRequest the current conversation status.

SSE Stream Query Parameters

ParamRequiredPurpose
stream_idYesUnique peer identifier for this connection
user_session_idNoReuse an existing authenticated session
bearer_tokenNoAccess token fallback when headers are unavailable
id_tokenNoID token fallback when headers are unavailable
tenantNoRouting hint within the deployment contract; never an authorization override
projectNoRouting hint within the deployment contract; never an authorization override
project_eventsNoSet 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 its external_events[]
  • files — repeated binary parts, in the same order as the event.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:

StatusMeaning
processing_startedA regular reactive event batch and its bodyless lane wake were admitted atomically.
external_event_acceptedThe event batch was accepted as a continuation for the active conversation owner.
external_event_recordedA non-reactive event batch was recorded without creating an immediate wake.
i
When to choose SSE: Use SSE for standard browser apps. It uses native 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 eventPayload typePurpose
readyStream is open and authenticated. Payload includes session_id, user_type, stream_id.
chat_startchat.startTurn accepted and processing started.
chat_stepchat.step or customStructured step update (progress, tool results, decisions).
chat_stepchat.filesFiles delivered during the turn. Canonical rows carry an object_ref/ref that the client resolves under the user's session.
chat_stepchat.citationsLinks delivered to the Links surface; an item with placement: "chat" also settles in the visible conversation flow.
chat_stepchat.conversation.titleConversation auto-named mid-turn. Match on event.step === "conversation_title"; the name is in data.title.
chat_deltachat.deltaStreaming text chunks (answer, thinking, artifacts).
chat_compactionchat.compactionReAct context compaction lifecycle while a long turn is still running.
chat_completechat.completeTurn completed. Contains data.final_answer and optional data.followups.
chat_errorchat.errorTurn failed. Contains data.error, optional data.error_type.
chat_servicechat.service, gateway.*, rate_limit.*Service-level events: rate limits, gateway rejections, queue status.
conv_statusconv.statusConversation state snapshot (idle, in_progress, error).
server_shutdownServer 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:

MarkerMeaningTypical usage
answerAssistant response streamMain answer text rendered in the chat bubble
thinkingReasoning streamInternal analysis, shown in a collapsible panel
canvasArtifact streamApp artifacts, rendered HTML/JSON content, and widget previews. Uses extra.artifact_name for grouping.
timeline_textTimeline streamShort status entries for an activity log
subsystemStructured JSON payloadsApp 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": "..."
  }
}
i
Closing a stream channel: When 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:

EventDirectionPayload
session_infoServer → ClientBound session, user, tenant/project, and client-role information
chat_startServer → ClientSame envelope as SSE chat_start
chat_stepServer → ClientSame envelope as SSE chat_step
chat_deltaServer → ClientSame envelope as SSE chat_delta
chat_compactionServer → ClientSame envelope as SSE chat_compaction
chat_completeServer → ClientSame envelope as SSE chat_complete
chat_errorServer → ClientSame envelope as SSE chat_error
chat_serviceServer → ClientSame envelope as SSE chat_service
conv_statusServer → ClientSame 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.

i
Peer targeting from REST: If a widget, custom main view, or other app-owned frontend makes a REST call to /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-TokenID token
User-Session-IDReuse 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_tokenAccess token
id_tokenID token

3. Cookies (lowest priority)

Fallback for cookie-based / proxylogin deployments. The browser sends these automatically:

__Secure-LATCAccess token cookie
__Secure-LITCID token cookie

Useful Request Headers

HeaderPurpose
KDC-Stream-IDPeer identifier for targeted event delivery from REST/integration calls
X-User-TimezoneUser timezone (e.g. America/New_York) for server-formatted messages
X-User-UTC-OffsetUTC 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

HeaderAction
X-Session-IDStore and reuse to maintain session continuity
X-User-TypeResolved user type for the request
Retry-AfterHonor 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))
SignalMeaningAction
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 StatusMeaningAction
429Rate limit exceededBack off 2–5s + jitter. Honor Retry-After header. Max 5 retries.
503Backpressure or drainingBack off 1–3s + jitter. Do not retry immediately.
401Authentication missing, invalid, or expiredRefresh credentials or redirect to login.
403Authenticated but denied, or consent is requiredInspect 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_status with data.completion = "interrupted"
  • chat_error with data.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.

i
No sticky sessions required. Requests can land on any replica. Keep session_id and auth tokens consistent, and the server will associate your requests correctly regardless of which instance handles them.