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
| Identity | Answers | Where it is decided |
|---|---|---|
channelId | Which app / connection did this come from | The channel record |
workflowId | What should run | Bound at channel-config time |
session / external_chat_id | Which conversation is this | Per 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:
- Retire the current conversation by suffixing its
external_chat_idwith__retired_<ts>, so it stops matching future lookups. - 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:
- Numbered replies are stateful.
~workflowlists options and stores the selection; replying3resolves against that stored list. An explicit~workflow <n>only resolves against a stored selection, so a bare number without a prior listing does nothing. ~<channel-name>matches by prefix. The parser tries the whole text first, then progressively shorter whitespace-delimited prefixes, against bothchannel_idandname— which is what lets~support what's my order statusswitch channel and send a message in one line.- Approvals are unconditional.
~~yapproves and~~n [reason]rejects; conditional approvals are explicitly unsupported and rejected with a message rather than half-honoured.
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:
- A
sessionId(UUID v4) is generated perchannelIdand persisted inlocalStorage, so the same app in the same browser always resumes the same conversation across reloads. - The
sessionIdmaps to the backend'sexternal_chat_id, which combined with(workspace_id, app_id)uniquely determines a conversation. - Resetting the session mints a new
sessionIdand clears the associated conversation id — the browser equivalent of~new. - Multiple sessions per channel are supported, so one app can host several parallel conversations.
Choosing a routing shape
| You want | Do this |
|---|---|
| One app, one behaviour | Bind the workflow on the channel; done |
| One app, several behaviours | Bind a router workflow that branches on message content |
| Several apps, one behaviour | Several channels, all bound to the same workflow |
| Per-turn routing control | Shortcuts (~~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
- Messaging Channels — the adapters that feed this.
- Gateway Model: Apps, Channels, Sessions — the SDK's view in depth.
- Channel Problems — when messages arrive but nothing runs.