# 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

```text
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:

```text
(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:

```text
~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.** `~workflow` lists options and stores the selection;
  replying `3` resolves 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 both `channel_id` and `name` —
  which is what lets `~support what's my order status` switch channel *and* send a message in
  one line.
- **Approvals are unconditional.** `~~y` approves 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:

```text
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 per `channelId` and persisted in `localStorage`, so the
  same app in the same browser always resumes the same conversation across reloads.
- The `sessionId` maps to the backend's `external_chat_id`, which combined with
  `(workspace_id, app_id)` uniquely determines a conversation.
- Resetting the session mints a new `sessionId` and 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](/docs/config-presets) attach structured
routing data to a single turn without changing the channel's binding.

## Next steps

- [Messaging Channels](/docs/chat-messaging-channels) — the adapters that feed this.
- [Gateway Model: Apps, Channels, Sessions](/docs/sdk-gateway-model) — the SDK's view in depth.
- [Channel Problems](/docs/fix-channel-issues) — when messages arrive but nothing runs.
