Channel Routing & Session Mapping

Applies to: ChengOS v0.1.0+ | Last verified: 2026-08-12 | Source: chengflow-sdk/README.md, chengflow-sdk/src/sdk/session-manager.ts, crates/cheng-core/src/domain/channel/mod.rs, crates/cheng-core/src/ports/conversation_repo.rs, crates/cheng-api/src/services/conversation_command.rs

An inbound message has to answer two separate questions: which conversation is this part of? and which workflow should run? Conflating them is the single most common modelling mistake in a gateway, and ChengOS deliberately keeps them apart.

The three identities

IdentityAnswersWhere it is decided
channelIdWhich app / connection did this come fromThe channel record
workflowIdWhat should runBound at channel-config time
session / external_chat_idWhich conversation is thisPer external chat

channelId is not a workflow identifier. It says which app sent the message and which connection owns the session, and the gateway uses it to route the result back to the right app. The workflow is chosen by an explicit binding — bound_workflow_id on the channel record — never inferred from channelId.

That separation was learned the hard way: before the gateway was refactored, channelId was mapped straight to a workflow while the browser frontend also reused adapter-style keys against /channel/*. Three concerns — app-source identity, workflow selection, and adapter-level authentication — ended up entangled in one value. They are now modelled separately.

The channel record

Channel
  ├── channel_id          ← external identifier, unique per workspace,
  │                         and the URL path segment for webhooks
  ├── app_type            ← the ROUTING KEY: raw string, never an enum
  ├── bound_workflow_id   ← what runs when a message arrives
  ├── connection_config   ← opaque to the framework
  ├── connection_state
  ├── setup_data
  └── enabled

app_type selects the adapter; bound_workflow_id selects the workflow. Both are explicit fields, so neither is guessed from the other.

Conversation resolution

An inbound message is mapped to a conversation by the triple:

(workspace_id, app_id, external_chat_id)  ──►  Conversation

find_or_create_by_channel is idempotent: repeated calls with the same triple return the same conversation. That is what makes a webhook retry — or a platform redelivering a message — harmless.

The conversation also stores external_user_id, so a group chat is one conversation with attributed senders rather than one conversation per participant.

Rotation: starting over

~new, or switching workflow, must not leave two conversations claiming the same external chat. Rotation therefore does two things in one transaction:

  1. Retire the current conversation by suffixing its external_chat_id with __retired_<ts>, so it stops matching future lookups.
  2. Create a fresh conversation owning the canonical mapping, bound to the new workflow.

There is never a window in which two active conversations own the same external mapping. The in-memory implementation used by tests does the two steps sequentially; the worst interleaving re-creates the conversation on the next message rather than duplicating the mapping.

Commands from an external channel

Users on Telegram or WeCom have no toolbar, so routing controls are exposed as text commands:

~new                        start a new conversation
~workflow                   list workflows (reply with a number to switch)
~workflow <name> [message]  switch workflow, optionally sending a message
~channel                    list channels
~<channel-name> [message]   switch channel
~status                     show current workspace / workflow
~cancel                     cancel the running execution or an open selection
~~shortcuts                 list route shortcuts (reply with a number to toggle)
~~presets                   list node presets (reply with a number to apply)
~~y                         approve a pending request
~~n [reason]                reject a pending request
~help                       this help

Two details worth knowing:

These commands live in the conversation command service, not in the ingest pipeline, so external channels, the CLI, and tests share exactly one implementation.

The gateway SDK model

The chengflow-app gateway (which lives in chengflow-sdk/) is a frontend gateway layer, not a second editor. Its responsibilities:

external app
  ↓
gateway pairs and connects the app
  ↓
gateway resolves channelId → app mapping, and workflowId → execution target
  ↓
gateway calls the backend with the user's JWT
  ↓
backend runs the workflow
  ↓
backend returns results / pushes execution events
  ↓
gateway routes the result back to the originating app by channelId + session mapping

The browser gateway authenticates with a JWT only. It does not use chk_ adapter keys — those belong to server-side platform services. This is a hard boundary: a browser frontend holding an adapter-level key would be handing out a credential that can act for a whole channel.

Sessions in the browser

The SDK's session manager owns the client half of the mapping:

Choosing a routing shape

You wantDo this
One app, one behaviourBind the workflow on the channel; done
One app, several behavioursBind a router workflow that branches on message content
Several apps, one behaviourSeveral channels, all bound to the same workflow
Per-turn routing controlShortcuts (~~shortcuts, or the editor toolbar)

The fourth row is the one people miss: shortcuts attach structured routing data to a single turn without changing the channel's binding.

Next steps