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.
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
| Channel | V1 | Inbound | Outbound |
|---|---|---|---|
| Web chat | yes | WebChannelAdapter | SSE streams, not the Gateway |
| Slack | later | SlackChannelAdapter | Gateway delivery |
| Telegram | later | one more adapter | Gateway delivery |
| later | one more adapter | Gateway delivery, window constrained | |
| never | Nylas producer | email 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.
| Part | Web slice | Why |
|---|---|---|
NormalizedMessage | build | The Front Door contract. It is what makes a later channel one adapter. |
InboundAdapter + WebChannelAdapter | build | One decoder, called by the conversation route. |
WebhookRouter | defer | There is no webhook. The route is authenticated, and it enqueues directly. |
InboundWorker | defer | Identity and session are already resolved by the session token. |
ChannelIdentityService | defer | The Supabase user id is the platform user id. |
ChannelSessionService | defer | conversation_id is in the path. Nothing maps onto it. |
ChannelActionHandler | defer | Approvals resolve in Human Review. |
ChannelNotifier, OutboundAdapter, ChannelCapabilities, OutboundIntent | defer | Web chat answers on SSE, so it needs no delivery. |
agent.channel_connections, _identities, _sessions | defer | Nothing writes them. |
Read every section below as the design a remote channel lands against. The web slice inherits the shapes, not the machinery.
Boundary
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
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.
| Component | Job |
|---|---|
InboundAdapter | Read the workspace id, verify a request, decode an event |
OutboundAdapter | Send one intent, within what the channel can render |
ChannelCapabilities | What one channel can render, as data. Outbound only |
WebhookRouter | Verify, claim the delivery id, enqueue durably, then ACK |
InboundWorker | Resolve identity and session, build one NormalizedMessage, call the Front Door |
ChannelIdentityService | Map an external user to a platform user, and unlink one |
ChannelSessionService | Map an external conversation or thread to a platform conversation |
ChannelActionHandler | Turn a button press into one approval decision |
ChannelNotifier | Turn 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.
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: ...
| Adapter | Inbound | Outbound |
|---|---|---|
WebChannelAdapter | yes | no. SSE answers, so it needs no capabilities either |
SlackChannelAdapter | yes | yes |
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.
@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
InboundEvent = MessageReceived | ActionReceived | ConnectionRevoked
@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
@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
@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
@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
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
| Check | Drop reason |
|---|---|
| The workspace maps to an enabled connection | unknown_connection, connection_disabled |
| The signature is valid | bad_signature |
| The timestamp is inside 5 minutes | replay |
| The event decodes to a supported kind | unsupported_event |
| The sender is a person, not a bot or this app | bot_sender |
| The message addresses us | not_addressed |
| The text is inside the size limit | too_large |
| The delivery id is new | duplicate |
| The body matches the hash the delivery id was claimed for | hash_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.
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
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.
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
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
durable Run event -> ChannelNotifier -> adapter.send()
transient live event -> progress relay -> adapter.send() (best effort)
| Intent | Source | Delivery |
|---|---|---|
message | a Front Door answer, or a Run result | durable, at least once, deduped |
approval | an approval row is created | durable, at least once, deduped |
error | a Run failed | durable, at least once, deduped |
progress | live Run events | best effort, at most once, throttled |
approval update | agent/approval.resolved | durable, 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
429or a5xxretries with backoff. Atoken_revokedor achannel_not_foundmarks the connectiondegradedand raises an ops alert. - Text longer than
capabilities.max_textsplits into ordered chunks. - A channel without
buttonsrenders a link to Human Review. - A channel without
editssends 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
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
| Table | Key columns | Constraint |
|---|---|---|
agent.channel_connections | organization_id, channel, external_workspace_id, credentials_ref, capabilities, status | unique(channel, external_workspace_id) |
agent.channel_identities | connection_id, organization_id, external_user_id, user_id, linked_at | unique(connection_id, external_user_id) |
agent.channel_sessions | connection_id, organization_id, conversation_id, external_conversation_id, external_thread_key | unique(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
| Failure | Behaviour |
|---|---|
| Bad signature | Reject with 401. Record the attempt. Enqueue nothing. |
| Stale timestamp | Reject as a replay, even when the signature is valid. |
| Unknown workspace | Reject with 404. Do not create a connection. |
| Enqueue fails | Return 5xx and leave the claim unfinished, so the vendor retries. |
| Duplicate delivery | ACK with success. Enqueue nothing. |
| Unknown sender | Answer with one link invitation. Start no Run. |
| Front Door fails | The turn fails. Send one error intent. Create no Run. |
| Delivery fails, retryable | Retry with backoff inside the notifier. Run state does not change. |
| Delivery fails, terminal | Mark the connection degraded. Alert ops. Run state does not change. |
| Connection revoked mid-Run | The Run continues. The result lands in Run Explorer only. |
| Person unlinked mid-Run | The Run continues under its frozen principal. The next message needs a new link. |
Stress tests
| Scenario | What the design does |
|---|---|
| Slack retries the same delivery 3 times | The claim on external_event_id admits one. The other two ACK and stop. |
| Two messages arrive 200 ms apart in one thread | The session key limits the queue to one worker, so the turns stay ordered. |
| The bot answer triggers a new inbound event | The from_bot check drops it. No loop starts. |
| A person edits the message during the Run | The edit decodes to None. The Run keeps its original input. |
| A stranger writes in a shared channel | The message drops as not_addressed, or it receives one link invitation. No Run starts. |
| Two people talk in one Slack thread | One conversation, two principals. Each turn reads under its own sender. |
| A Run takes 40 minutes | One throttled progress edit, then one durable result message. Slack rate limits hold. |
| The answer is 30,000 characters | The adapter splits it into ordered chunks under max_text. |
| Two people press Approve at the same time | The approval row transitions one time. The second press reads the resolved state. |
| An unlinked person presses Approve | The action is refused, and the person receives the link invitation. |
| A person without authority presses Approve | The approval domain refuses. The Gateway renders the reason. |
| The workspace uninstalls while a Run waits | Delivery stops, the connection turns revoked, and Human Review still resolves the approval. |
| The worker dies after the vendor send | The dedupe key stops a second post on retry. |
| The same person works in two workspaces | Two identity rows, one per connection. Each carries its own organization. |
| A vendor sends a message with no thread id | The thread key is '', so the unique constraint still holds. |
| A person approves in Human Review, not in Slack | agent/approval.resolved updates the Slack message, so the buttons do not stay live. |
| A person uploads a file in Slack | The count is recorded, and the platform answers that it reads text only. |
V1 decisions
| Decision | V1 choice |
|---|---|
| Channels | Web chat now. Slack next |
| Identity states | Present or absent. No state column |
| Unknown sender | One link invitation. No Run, and no service actor |
| Message edits, deletes, reactions | Dropped |
| Attachments | Counted, then dropped, in every channel. Deferred to a later version |
| Inbound ordering | One worker per session key |
| Outbound answer, approval, error | Durable Inngest event, deduped |
| Outbound progress | Best effort, one edit per 5 seconds, only when edits is true |
| Approval buttons | Slack only. Human Review is the floor |
| Workspace to organization | One 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
- Does Slack Socket Mode remove the public webhook endpoint for a self-hosted install, or does the HTTP path stay the only one?
- When does a shared thread need its own privacy rule, beyond the per-sender principal?
- Which channel earns the third adapter: Telegram for reach, or WhatsApp for the customer request?
- 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.