Channel gateway

The only layer that knows both an interactive channel and the platform. One message shape converges inbound, one intent shape diverges outbound, and no model call happens here.

1 min read Updated Aug 30, 2026

Channel gateway

The Channel Gateway connects an interactive conversational channel to AgencyCore. It turns a vendor payload into one platform message. It turns one platform intent into a vendor message.

One message shape in. One intent shape out. No model call, no prompt, and no business rule inside.

Scope

ChannelV1InboundOutbound
Web chatyesWebChannelAdapterSSE streams, not the Gateway
SlacklaterSlackChannelAdapterGateway delivery
Telegramlaterone more adapterGateway delivery
WhatsApplaterone more adapterGateway delivery, window constrained
EmailneverNylas produceremail Tool

Ship web chat first. Remote interactive channels are deferred until the web-first interface slice proves the request, approval and Run progress loop. A later channel is one adapter and one connection type. It is not a new subsystem.

Web chat is inbound only. The browser holds an authenticated SSE connection, so the platform answers on the conversation stream and the Run stream. Only a remote channel needs Gateway delivery. See surfaces.

Email is not a channel here. Nylas owns email transport. Inbound email becomes a Trigger event, and outbound email is a Tool call.

What the web slice builds

⚠️ Most of this page describes a remote channel, and the web slice builds none of it. Web chat is a first-party authenticated client. It sends no vendor payload, so it has no signature to verify, no workspace to resolve, no external user to link, no external thread to map, and no message to deliver back. A component whose only implementation skips its own job is a component the reader must still understand.

So the web slice builds two things from this page, and defers the rest with Slack.

PartWeb sliceWhy
NormalizedMessagebuildThe Front Door contract. It is what makes a later channel one adapter.
InboundAdapter + WebChannelAdapterbuildOne decoder, called by the conversation route.
WebhookRouterdeferThere is no webhook. The route is authenticated, and it enqueues directly.
InboundWorkerdeferIdentity and session are already resolved by the session token.
ChannelIdentityServicedeferThe Supabase user id is the platform user id.
ChannelSessionServicedeferconversation_id is in the path. Nothing maps onto it.
ChannelActionHandlerdeferApprovals resolve in Human Review.
ChannelNotifier, OutboundAdapter, ChannelCapabilities, OutboundIntentdeferWeb chat answers on SSE, so it needs no delivery.
agent.channel_connections, _identities, _sessionsdeferNothing writes them.

Read every section below as the design a remote channel lands against. The web slice inherits the shapes, not the machinery.

Boundary

TEXT
      vendor                                            platform
        │                                                   ▲
        │  webhook                                          │ NormalizedMessage
        ▼                                                   │
   WebhookRouter ─ verify ─ decode ─ claim ─ enqueue ─ ACK   │
                                              │             │
                                              ▼             │
                                        InboundWorker ──────┘
                                              │
                                              └─ ChannelActionHandler ─> ApprovalService

        ▲                                                   │
        │  vendor message                                   │ OutboundIntent
        │                                                   ▼
   adapter.send() <───────────── ChannelNotifier <──── Run events

The adapter knows vendor payloads and vendor limits. It does not know Agents, prompts, Tools, workflows or policy rules.

WebhookRouter and InboundWorker are one seam split by a deadline, not two stages of thought. Every vendor retries a slow answer, and identity plus session resolution is slower than the ACK budget allows. So the router does only what must happen before the ACK, and the worker does the rest. Read them as one component with a durable hand-off in the middle.

Core code

TEXT
src/agentic/entry_control/channels/
  adapters/
    base.py        InboundAdapter, OutboundAdapter, ChannelCapabilities
    web.py         WebChannelAdapter                       <- the web slice
    slack.py       SlackChannelAdapter
  models.py        InboundEvent, NormalizedMessage, OutboundIntent, ChannelSession
  webhooks.py      WebhookRouter: verify, decode, claim, enqueue, ACK
  inbound.py       InboundWorker: identity, session, normalize, dispatch
  identity.py      ChannelIdentityService, link tokens
  sessions.py      ChannelSessionService
  actions.py       ChannelActionHandler
  outbound.py      ChannelNotifier
  repositories.py  connections, identities, sessions

⚠️ The path is src/agentic/, and it is not src/domains/. The import-linter contract in ac-python-api/pyproject.toml binds src.agentic as its source module. A package under src/domains/ sits outside it, so it could import src.domains.chat while lint-imports stayed green, which is the one thing the contract exists to stop. See coexistence, rule 3.

entry_control is a new top-level package under src.agentic, so it joins the source list of the src.agentic.surfaces is the top layer contract, and it takes a contract of its own: services, governance, runtime, shared and capabilities may not import it. inngest_functions is not in that list and cannot be: the registry imports the module that builds each function, exactly as inngest_functions/__init__.py imports runtime/inngest/execute.py for run.execute. inngest_app is not in it either, because the direction is entry_control -> inngest_app.

ComponentJob
InboundAdapterRead the workspace id, verify a request, decode an event
OutboundAdapterSend one intent, within what the channel can render
ChannelCapabilitiesWhat one channel can render, as data. Outbound only
WebhookRouterVerify, claim the delivery id, enqueue durably, then ACK
InboundWorkerResolve identity and session, build one NormalizedMessage, call the Front Door
ChannelIdentityServiceMap an external user to a platform user, and unlink one
ChannelSessionServiceMap an external conversation or thread to a platform conversation
ChannelActionHandlerTurn a button press into one approval decision
ChannelNotifierTurn a Run event into one OutboundIntent and send it

No manager class sits above these parts.

Contracts

Adapters

Inbound and outbound are two protocols, not one. Web chat answers on SSE and never sends through the Gateway, so a single protocol would force WebChannelAdapter to carry a send it must refuse.

PYTHON
class InboundAdapter(Protocol):
    channel: Channel

    def external_workspace_id(self, request: IncomingRequest) -> str:
        """Read the workspace id from the raw payload. Nothing is trusted yet."""

    def verify(self, request: IncomingRequest, connection: ChannelConnection) -> None:
        """Raise on a bad signature, a stale timestamp, or a wrong token."""

    def decode(self, request: IncomingRequest) -> InboundEvent | None:
        """Return None for an event this channel ignores."""


class OutboundAdapter(Protocol):
    channel: Channel
    capabilities: ChannelCapabilities

    async def send(self, session: ChannelSession, intent: OutboundIntent) -> DeliveryResult: ...
AdapterInboundOutbound
WebChannelAdapteryesno. SSE answers, so it needs no capabilities either
SlackChannelAdapteryesyes

ChannelCapabilities sits on the outbound half, because every field it holds describes rendering. A channel we only receive from declares none.

The router owns the order of the inbound calls. Neither adapter reads a database table.

PYTHON
@dataclass(frozen=True)
class ChannelCapabilities:
    edits: bool        # the channel can update a message it already sent
    buttons: bool      # the channel can render actions
    threads: bool
    max_text: int      # characters in one message

Capability belongs to the adapter and the connection. It never belongs to an Agent.

Inbound event

PYTHON
InboundEvent = MessageReceived | ActionReceived | ConnectionRevoked
PYTHON
@dataclass(frozen=True)
class MessageReceived:
    external_event_id: str
    external_user_id: str
    external_conversation_id: str
    external_thread_id: str | None
    text: str
    addressed: bool                    # a DM, a mention, or a reply in a Gateway thread
    from_bot: bool
    attachment_count: int
    occurred_at: datetime

Only a MessageReceived can become conversational input. A button press and an uninstall go to deterministic handlers. They never pretend to be chat.

An edit, a delete and a reaction decode to None in V1. The platform drops them.

Normalized message

PYTHON
@dataclass(frozen=True)
class NormalizedMessage:
    channel: Channel
    message_id: str          # platform id; the Run start key derives from it
    organization_id: UUID
    user_id: UUID            # always a verified person
    conversation_id: UUID
    text: str
    occurred_at: datetime

The Front Door receives no vendor field. There is no channel_context, so a Slack thread id cannot leak into routing logic.

user_id is not optional. An unverified sender produces no NormalizedMessage, so no downstream layer must test an identity state.

Outbound intent

PYTHON
@dataclass(frozen=True)
class OutboundIntent:
    conversation_id: UUID
    kind: Literal['message', 'progress', 'approval', 'error']
    dedupe_key: str
    text: str | None = None
    approval_id: UUID | None = None
    actions: list[Action] = field(default_factory=list)

Meaning is stable. Presentation varies. Slack renders buttons; a weaker channel renders a link to Human Review.

Session

PYTHON
@dataclass(frozen=True)
class ChannelSession:
    id: UUID
    connection_id: UUID
    organization_id: UUID
    conversation_id: UUID
    external_conversation_id: str
    external_thread_key: str   # '' when the channel has no thread
    channel: Channel

Inbound path

TEXT
vendor webhook
   │
   ├─ adapter.external_workspace_id()  -> resolve the connection
   ├─ adapter.verify()                 -> signature and timestamp window
   ├─ adapter.decode()                 -> InboundEvent, or None to drop
   ├─ IdempotencyService.claim(organization_id, 'webhook.<channel>',
   │                           external_event_id, sha256(raw_body))
   ├─ durable enqueue                  -> Inngest, concurrency key = session key
   └─ ACK                              -> under 3 seconds, no model call
                │
                ▼
          InboundWorker
                ├─ MessageReceived   -> identity -> session -> NormalizedMessage -> FrontDoorService
                ├─ ActionReceived    -> ChannelActionHandler
                └─ ConnectionRevoked -> disable the connection, unlink its identities

ACK first, then process. The webhook handler never waits for a model call.

Admission checks, in order

CheckDrop reason
The workspace maps to an enabled connectionunknown_connection, connection_disabled
The signature is validbad_signature
The timestamp is inside 5 minutesreplay
The event decodes to a supported kindunsupported_event
The sender is a person, not a bot or this appbot_sender
The message addresses usnot_addressed
The text is inside the size limittoo_large
The delivery id is newduplicate
The body matches the hash the delivery id was claimed forhash_mismatch

Every drop carries one reason, and the platform counts it. A silent no-op looks the same as a broken install.

hash_mismatch is the one drop a person must look at. The vendor re-sent one delivery id with different content, so the delivery is not a duplicate and the key already answers for another body. See idempotency.

Addressing

A direct message always addresses us. A message in a shared channel addresses us only when it mentions the app, or when it replies inside a thread that the Gateway already answers. Everything else drops as not_addressed.

This rule keeps a busy channel cheap. It also stops the bot loop, together with the from_bot check.

Idempotency and ordering

The vendor delivery id is the one dedupe key. Claim it under the shared webhook.<provider> scope, as webhook.<channel>, and complete the claim only after the enqueue succeeds. A failed enqueue must leave the claim unfinished, so the vendor retry still works.

NormalizedMessage.message_id seeds the Run start key. The Front Door builds StartRunCommand.idempotency_key from it, so one Slack message can never start two Runs.

The queue holds one worker per session key. Two fast messages in one thread therefore run in order, and two Front Door turns never interleave in one conversation.

Attachments

The current V1 slice reads text only, and web chat is not an exception. The worker counts attachments, records the count on the conversation message, and answers once that the platform cannot read a file here. No file passes through an adapter, and no conversation upload exists. The same rule applies when remote channels land.

A file still reaches the platform as an organization knowledge source, through the ordinary upload surface. That path belongs to state and knowledge, and it attaches to an organization, never to one conversation.

Conversation attachments are deferred. They add a per-conversation knowledge scope, a retention rule and an ingestion path, and no V1 product need requires them.

Identity and sessions

An identity row exists, or it does not. There is no state column.

TEXT
row present   -> verified. The person becomes an actor identity.
row absent    -> unknown.  The person starts no Run.
unlink        -> the row is deleted, and the person is unknown again.

The Gateway hands an actor identity to the Front Door. RunManager mints the Principal, because the intersection needs the definition and the Run ID. See runtime execution.

Linking

TEXT
message from an unknown sender
   -> the Gateway answers once with one link
      /channels/link?token=<signed>
   -> the token holds connection_id + external_user_id
   -> it expires in 15 minutes, and it works one time
   -> the person signs in, then confirms
   -> ChannelIdentityService.link() writes the row
   -> the platform does not replay the first message

The platform answers at most one link invitation per external user per hour. A shared channel therefore cannot become a spam source.

The first message is not replayed on purpose. A stale intent is worse than one more sentence from the person.

PYTHON
class ChannelIdentityService:
    async def resolve(self, connection_id: UUID, external_user_id: str) -> ChannelIdentity | None: ...
    async def issue_link_token(self, connection_id: UUID, external_user_id: str) -> str: ...
    async def link(self, token: str, user: AuthenticatedUser) -> ChannelIdentity: ...
    async def unlink(self, identity_id: UUID) -> None: ...

Sessions

PYTHON
class ChannelSessionService:
    async def resolve_or_create(
        self,
        connection: ChannelConnection,
        external_conversation_id: str,
        external_thread_id: str | None = None,
    ) -> ChannelSession: ...

A unique database constraint resolves a race between two concurrent messages. The service inserts, ignores a conflict, then reads the winner. No distributed lock exists.

The thread key uses '' for a channel with no thread. A NULL would make every row distinct, so the constraint would not hold.

A shared thread

A Slack thread can hold two people. The session maps that thread to one conversation, and the conversation is shared. The principal is not shared.

  • The conversation records who created it.
  • Each message records its own sender.
  • Each turn uses the principal of the person who wrote that message.

So user B reads the shared history of the thread, and user B reads no CRM row that user B may not read.

Outbound path

TEXT
durable    Run event        -> ChannelNotifier -> adapter.send()
transient  live event       -> progress relay  -> adapter.send()   (best effort)
IntentSourceDelivery
messagea Front Door answer, or a Run resultdurable, at least once, deduped
approvalan approval row is createddurable, at least once, deduped
errora Run faileddurable, at least once, deduped
progresslive Run eventsbest effort, at most once, throttled
approval updateagent/approval.resolveddurable, at least once, deduped

A durable intent travels as its own Inngest event, so a worker restart cannot lose the answer. A progress update travels on the transient live-event transport, and the platform drops it under load.

Rules

  • Delivery status is not Run status. A Run can succeed while Slack is down.
  • A 429 or a 5xx retries with backoff. A token_revoked or a channel_not_found marks the connection degraded and raises an ops alert.
  • Text longer than capabilities.max_text splits into ordered chunks.
  • A channel without buttons renders a link to Human Review.
  • A channel without edits sends no progress at all.
  • Progress edits one message, at most one time per 5 seconds.
  • A conversation with no session drops the intent. Web chat has its own stream.
  • An approval resolved in Human Review still clears the channel buttons. The notifier reads agent/approval.resolved, so no channel keeps a live button on a decided row.

Channel actions

TEXT
ActionReceived(approval_id, decision)
   -> resolve the identity        unknown -> answer with the link invitation
   -> ApprovalService.resolve(approval_id, decision, actor)
   -> update the original message with the outcome

The Gateway never writes the approval row. It calls the approval domain, and the domain checks TTL, authority and the argument hash. Every presentation resolves the same approval id, so silence is never consent.

A second press returns the current state. It is not an error.

Data model

TableKey columnsConstraint
agent.channel_connectionsorganization_id, channel, external_workspace_id, credentials_ref, capabilities, statusunique(channel, external_workspace_id)
agent.channel_identitiesconnection_id, organization_id, external_user_id, user_id, linked_atunique(connection_id, external_user_id)
agent.channel_sessionsconnection_id, organization_id, conversation_id, external_conversation_id, external_thread_keyunique(connection_id, external_conversation_id, external_thread_key)

⚠️ All three tables are deferred with the remote channels. The web slice creates none of them: it resolves identity from the session token and reads conversation_id from the path. contract.md lists the tables the current slice does create.

status is enabled, degraded or revoked. Credentials live in the vault, and the row holds only a reference.

One workspace maps to one organization. A second organization that installs the same workspace receives a clear error. This keeps identity resolution unambiguous.

Failure handling

FailureBehaviour
Bad signatureReject with 401. Record the attempt. Enqueue nothing.
Stale timestampReject as a replay, even when the signature is valid.
Unknown workspaceReject with 404. Do not create a connection.
Enqueue failsReturn 5xx and leave the claim unfinished, so the vendor retries.
Duplicate deliveryACK with success. Enqueue nothing.
Unknown senderAnswer with one link invitation. Start no Run.
Front Door failsThe turn fails. Send one error intent. Create no Run.
Delivery fails, retryableRetry with backoff inside the notifier. Run state does not change.
Delivery fails, terminalMark the connection degraded. Alert ops. Run state does not change.
Connection revoked mid-RunThe Run continues. The result lands in Run Explorer only.
Person unlinked mid-RunThe Run continues under its frozen principal. The next message needs a new link.

Stress tests

ScenarioWhat the design does
Slack retries the same delivery 3 timesThe claim on external_event_id admits one. The other two ACK and stop.
Two messages arrive 200 ms apart in one threadThe session key limits the queue to one worker, so the turns stay ordered.
The bot answer triggers a new inbound eventThe from_bot check drops it. No loop starts.
A person edits the message during the RunThe edit decodes to None. The Run keeps its original input.
A stranger writes in a shared channelThe message drops as not_addressed, or it receives one link invitation. No Run starts.
Two people talk in one Slack threadOne conversation, two principals. Each turn reads under its own sender.
A Run takes 40 minutesOne throttled progress edit, then one durable result message. Slack rate limits hold.
The answer is 30,000 charactersThe adapter splits it into ordered chunks under max_text.
Two people press Approve at the same timeThe approval row transitions one time. The second press reads the resolved state.
An unlinked person presses ApproveThe action is refused, and the person receives the link invitation.
A person without authority presses ApproveThe approval domain refuses. The Gateway renders the reason.
The workspace uninstalls while a Run waitsDelivery stops, the connection turns revoked, and Human Review still resolves the approval.
The worker dies after the vendor sendThe dedupe key stops a second post on retry.
The same person works in two workspacesTwo identity rows, one per connection. Each carries its own organization.
A vendor sends a message with no thread idThe thread key is '', so the unique constraint still holds.
A person approves in Human Review, not in Slackagent/approval.resolved updates the Slack message, so the buttons do not stay live.
A person uploads a file in SlackThe count is recorded, and the platform answers that it reads text only.

V1 decisions

DecisionV1 choice
ChannelsWeb chat now. Slack next
Identity statesPresent or absent. No state column
Unknown senderOne link invitation. No Run, and no service actor
Message edits, deletes, reactionsDropped
AttachmentsCounted, then dropped, in every channel. Deferred to a later version
Inbound orderingOne worker per session key
Outbound answer, approval, errorDurable Inngest event, deduped
Outbound progressBest effort, one edit per 5 seconds, only when edits is true
Approval buttonsSlack only. Human Review is the floor
Workspace to organizationOne to one

Rules

  • Interactive chat belongs here. Email does not.
  • The Gateway degrades presentation. Agent behaviour stays channel independent.
  • An unverified person never becomes an actor identity, and never starts a Run.
  • No model call, prompt, plan, tool selection or business rule lives in this package.
  • The Front Door receives no vendor field.
  • A durable enqueue must succeed before the claim completes.
  • A database constraint resolves every race. No distributed lock exists.
  • Delivery telemetry is not product Run state.
  • Web chat has no private path around the Gateway inbound, and it needs no Gateway outbound.

Open decisions

  1. Does Slack Socket Mode remove the public webhook endpoint for a self-hosted install, or does the HTTP path stay the only one?
  2. When does a shared thread need its own privacy rule, beyond the per-sender principal?
  3. Which channel earns the third adapter: Telegram for reach, or WhatsApp for the customer request?
  4. Should a link invitation carry the first message, so the person does not repeat it? V1 says no.

Minimum contract tests

  • Every adapter rejects an invalid signature and a stale timestamp.
  • One vendor delivery id enqueues at most one durable message.
  • A failed enqueue leaves the claim unfinished, so a retry still works.
  • Concurrent session creation returns one session row.
  • A message with no thread id and a message in a thread create two sessions.
  • An unknown sender starts no Run, and receives at most one invitation per hour.
  • An unlinked identity cannot regain an actor identity without a fresh link.
  • A message from a bot never reaches the Front Door.
  • An unaddressed channel message never reaches the Front Door.
  • Two ordered messages in one thread produce two ordered turns.
  • One Slack message produces one Run under duplicate delivery.
  • A repeated outbound send with one dedupe key posts one message.
  • A revoked connection stops delivery and fails no Run.
  • Two approval presses produce one state transition.
  • An approval resolved outside the channel clears the channel buttons.
  • No vendor field reaches Front Door decision logic.
  • Email cannot register as a Channel Gateway adapter.