Gateway Model: Apps, Channels, Sessions

Applies to: ChengOS v0.1.0+ | Last verified: 2026-08-12 | Source: chengflow-sdk/README.md, chengflow-sdk/src/sdk/types.ts

The gateway is the layer between external applications and ChengOS workflows. Its whole job is mapping: which app a message came from, which workflow should handle it, and which conversation it belongs to. Getting this model right is what keeps a multi-app deployment from becoming guesswork.

Note the naming: the gateway is called chengflow-app / cheng-app in deployment configs, and its source lives in chengflow-sdk/.

Four identifiers

Everything rests on keeping these separate:

IdentifierAnswersNotes
workspaceIdWhich tenant?Always explicit, so a multi-workspace deployment cannot mis-route
channelIdWhere did this message come from?A workspace-unique slug, e.g. weather-app. Not a workflow identifier.
boundWorkflowIdWhat should run?A workflow UUID, bound when the channel is configured
sessionIdWhich ongoing dialogue?Maps to a durable conversation

The rule that matters

channelId identifies origin. boundWorkflowId selects execution. They are never the same thing, and the backend must never infer one from the other.

Earlier versions used channelId to look up the workflow, which conflated three separate concerns: app-origin identity, workflow selection, and adapter-level authentication. The consequence was that you could not point two apps at one workflow, or move an app to a different workflow, without renaming things that other parts of the system depended on.

The current model binds the workflow explicitly at channel-configuration time. channelId then does only what its name says: it tells the gateway which app to route the result back to.

Authentication

The browser gateway authenticates with a JWT and nothing else. It does not use adapter keys.

That is a deliberate boundary. chk_-prefixed channel keys exist for server-side channel adapters — a Telegram or Slack integration calling in from outside — and they only work against /api/v1/channel/*. A browser front end reusing an adapter key would be embedding a long-lived shared secret in client-side code. See authentication.

Message flow

External app
  ↓
Gateway accepts the connection and pairs the app
  ↓
Gateway resolves channelId → app mapping, and boundWorkflowId → execution target
  ↓
Gateway calls the backend with the user's JWT
  ↓
Backend executes the workflow
  ↓
Backend returns the result / pushes execution events
  ↓
Gateway routes the result back to the right app via channelId + session mapping

The division of labour: the gateway owns pairing, mapping, and routing; the backend owns execution. Neither guesses at the other's job.

Channel configuration

A channel record carries:

FieldMeaning
idThe record's own UUID (primary key)
channelIdThe business slug, unique within the workspace
boundWorkflowIdThe workflow UUID to execute
name, descriptionHuman-facing labels
appTypeWhich kind of app
connectionConfigPer-adapter settings
enabledWhether it accepts traffic
connectionStateLifecycle state
webhookUrlWhere external platforms deliver messages

Note id and channelId are both present and are not interchangeable: id is the database key, channelId is the slug you use in application code.

Manage channels under /api/v1/workspaces/:workspace_id/channels, and their keys under /channel-keys.

Sessions and conversations

A session has an id (the session_id sent to the backend), a display label, a createdAt, an optional conversationId, and a pinned flag. It also carries a derived executionStatus — pending, running, waiting_for_review, completed_unread, or failed_unread.

The *_unread states are a UI concern, not a backend one: they let the gateway show that a session finished while the user was looking elsewhere. They are derived by the execution coordinator and never persisted by the session manager, so treat them as view state.

Sessions map onto durable conversations. The conversation is the source of truth for history; the session is the client's handle on it.

Two REST calls per turn

The browser path is:

  1. POST /api/v1/workspaces/:workspace_id/conversations/resolve — find or create the conversation.
  2. POST /api/v1/conversations/:id/messages — post the message and trigger execution.

The response gives conversation_id, workflow_id, and optionally execution_id. Subscribe to that execution_id over WebSocket for streaming output.

Next steps