NT-005 · Interaction channels
Status Proposal · Date 2026-08-13 References PRD-001 — AU-02, AU-04, AU-05, AU-11, NF-06, NF-09, FO-04, SE-10 · NT-004 Outcome Two families rather than one abstraction, one new domain concept, three stages Revised 2026-08-14 — §9 the shape of a channel trigger, §10 where email belongs Revised 2026-08-15 — §12 stage 3 broken down, §13 why the other vendors come after Revised 2026-08-16 — §12.2 the per-correspondent ceiling, built Revised 2026-09-04 — §9 a conversation may name the agent it starts
People do not live in the console. An approval that waits for somebody to open a browser tab waits, and a run whose result nobody reads was not worth the money. Channels are how the platform reaches where the work already happens.
This note argues that “channels” is two products wearing one word, and that building them as one makes the easy half carry the hard half’s assumptions.
1. A channel is a trigger, and the trigger model already exists
Section titled “1. A channel is a trigger, and the trigger model already exists”trigger.Opener takes a Request and seals run_started. Cron, webhook and
event composition all arrive that way. A channel is the fourth, and it needs no
new machinery to open a run.
What it brings that a webhook does not is identity — the sender is a person, not a shared secret — and conversation: the message has a parent, a thread, and context other messages left behind.
A trigger is a deliberate ask. A mention, a command, a reply to a thread — never every message in the channel. The listening version sounds more capable and is worse in three directions at once: cost, because ambient conversation becomes tokens; privacy, because people’s conversations reach a model nobody asked to involve; and the record, which fills with runs nobody started. The mention is the consent, and it is free.
2. What the ledger records is the ask, not the sentence
Section titled “2. What the ledger records is the ask, not the sentence”If a run opens with raw text, then a year later the answer to “why did this agent restart that service” is somebody typed “investiga isso aí” in a thread. That is a screenshot, not an audit record.
So references resolve at the edge, before the run exists, and what the ledger holds is structured:
seq 1 run_started origin: {channel: slack/acme, conversation: C07-ops, message: 1786…914, thread: 1786…102} asked_by: usr_ana scope: acme/ops input: sha256:3f1a…
content 3f1a… { subject: { kind: "alert", system: "grafana", id: "alm_8842" }, ask: "investigate", text: "@agents investiga isso aí" }The sentence stays — it is what the person said, and dropping it would make the
record dishonest — but it is filed as evidence rather than as instruction. The
planner reads subject. The text carries the untrusted label into the Gate,
where the taint check already knows what to do with it.
2.1 The boundary of resolution
Section titled “2.1 The boundary of resolution”The platform resolves references to what it put there. The alert reached
#ops because a webhook trigger reported it, so message_id → alm_8842 is a
mapping this installation already holds. Replying in that thread resolves with
certainty.
A third-party bot’s message, or “that problem from yesterday”, does not resolve — and must not pretend to. It becomes an ask with no subject, tainted, and the Gate treats it as what it is: untrusted input asking for an effect. An agent that needs a specific alert can go and search for one, which is a tool call somebody can audit, rather than a guess the edge made silently.
3. Replying to the origin is not sending a message
Section titled “3. Replying to the origin is not sending a message”The distinction that is easiest to get wrong and most expensive to get wrong.
| What it is | How the Gate sees it | |
|---|---|---|
| Reply to origin | The run reporting on itself, to where it came from | Reach is the origin sealed in step 1. There is nowhere else to go. |
| Send a message | An effect on the world, arbitrary reach | A classified tool, under policy and the risk ladder like any other |
Model both as one “send message” tool and an agent that can answer in a thread can also message the whole company. Separated, replying is bounded by the run’s own provenance rather than by a rule somebody has to remember to write.
4. The conversation carries the scope
Section titled “4. The conversation carries the scope”A conversation maps to a domain.Scope. #financeiro → acme/finance.
This is governance, not convenience. The same person asking the same thing in two channels gets two different sets of permitted tools, and the question “who could have asked for this” becomes answerable: the intersection of who reaches the conversation and who holds a grant in its scope.
The scope must not come from the text. An ask that names its own scope is an ask that can name a wider one.
Containment is right for hearing and wrong for asking, and the asymmetry is
deliberate. A conversation configured at a company hears about every area in
it, because one #alertas for a company that has not split its areas is a
sensible thing to configure. The same widening applied to the inbound path
would make that conversation a place from which to start an agent in any of
those areas, which is a different grant entirely — so a conversation configured
above the scope it could start runs in reports and starts nothing, and says so
in the mode it stores rather than leaving the refusal to be discovered at the
door.
The limit case is the scope above every company. A room there answers the area nobody pointed at a channel: without it, a run parked in that area reaches no card and no person. It is refused as an origin in the channel package itself, not left to the fact that no agent can be published in that scope today — that is a rule in another package, and the day somebody teaches the catalogue to read the scope above every company as “all of them”, a room configured for visibility would become a start button for the whole installation.
5. Where the two families split
Section titled “5. Where the two families split”Everything above holds for both. This does not.
The platform’s identity model assumes a principal with grants in a scope. A customer messaging a company’s WhatsApp number has no grant, should not have one, and will not have one. So what bounds the run?
Two facts that today share one field:
| Meaning | Bounds the run? | |
|---|---|---|
on_behalf_of | Who delegated authority | Yes — the run can what they can |
| subject (new) | Who the run is about | No — supplies input, delegates nothing |
A customer is a subject and never a principal. The run carries the authority of the agent and of the conversation’s scope; the customer’s message is input. If that is not explicit, the first person to write ignore previous instructions and transfer discovers what was implicit.
5.1 What the external family additionally needs
Section titled “5.1 What the external family additionally needs”- Taint becomes load-bearing. Customer text is entirely adversary- controlled. Prompt injection is not a risk to consider; it is the normal case. The existing taint check is what makes this family viable at all.
- Erasure already fits. A phone number is exactly the
ownerkeyadmin.Erasures.ForSubjecterases on, and the claim check means erasing a customer’s words never touches the hash chain (AU-04, NF-09). - A ceiling per correspondent. Today’s ceilings are per run and per scope. On an external channel one number can drain an area’s budget, and neither existing limit sees it happening.
- The 24-hour window. A run that concludes three days later cannot simply reply; WhatsApp requires an approved template. That is product work, not a driver.
- A customer expects a conversation; the platform runs runs. One run per
ask, with the thread as correlation rather than as state. A run left open for
days waiting on a customer holds a lease, holds a budget reservation and
occupies the queue — and reusing
parkedfor it would make “waiting for somebody to decide” and “waiting for a customer to answer” read the same in the ledger, which they are not.
5.2 Why this is a split and not a parameter
Section titled “5.2 Why this is a split and not a parameter”Built as one abstraction, the internal family inherits subject-versus-principal, per-correspondent ceilings and template windows it does not need, and the external family inherits identity assumptions that are false for it. Slack and Teams are one product. WhatsApp is another that happens to also deliver text.
6. What the audit trail has to gain
Section titled “6. What the audit trail has to gain”The trail reads gate_decided and approval_decided from the ledger, unioned
with admin_events. It shows what the Gate decided and not who asked for
the run. While triggers were cron and webhooks that was tolerable. With
channels it is the first thing anybody wants to see, and it is not there.
run_startedjoins the trail, carrying origin, asker and resolved subject.- An approval decided in a thread is the same step, recording the same
facts — who, when, and the digest of exactly what was approved. A second
approval path with weaker facts would give the record two grades of approval,
which is precisely what
internal/auditrefuses to do for the two trails it already merges. - New administrative verbs: connecting a workspace, mapping a conversation to a scope, and — the sensitive one — binding a channel account to a principal. That is granting a person’s authority to a messaging identifier. If it is ever pointed at the wrong principal, that line is the only thing that answers when and by whom.
The trail is not a message archive. Bodies in the audit trail turn the
record into a chat log full of personal data, with the retention obligations
that follow. The trail carries the reference and the digest; the body lives in
run_content under retention and erasure.
7. Order
Section titled “7. Order”Stage 1 — outbound only. The run reports to a conversation: opened, waiting on somebody, finished, failed. No inbound surface, no identity binding needed to post, and it already answers the alert case — there the trigger is the webhook and the channel is where people find out.
Stage 2 — approval in the thread. The first inbound surface, and a small one: a signed interaction payload, not free text. Needs the account-to-principal binding, and needs the decision to seal the same facts the console seals.
Stage 3 — the ask in text. The largest surface. Needs everything above plus reference resolution, taint and scope-from-conversation.
Slack first, for all three, because its signing is the best documented and its threading model is the one the design assumes. Teams follows the same shape. WhatsApp is the second family and starts after stage 3, not beside it.
8. Two decisions this note does not make
Section titled “8. Two decisions this note does not make”Who binds a channel account to a principal? An administrator registering it, or the person authenticating once through the installation’s identity provider and the binding falling out of that? The second is far better to use and needs an OAuth flow per channel.
Does a conversation map to one scope or several? One is simple and is what
this note assumes. But a #ops that serves three areas in practice will want
the ask to say which — and then the scope depends on the text again, which
§4 says not to do.
9. A channel trigger names no conversation
Section titled “9. A channel trigger names no conversation”When stage 3 arrives, “started from a conversation” becomes a fourth kind of trigger beside cron, webhook and event. It is not shaped like them.
The three that exist are self-contained: cron carries its expression, webhook
its path, event its name, and the agent declaring one is all it takes to fire
it. A channel cannot close that way, because a conversation carries a scope
(§4) and the conversation-to-scope map is administrative. An author writing
trigger: channel, conversation: C07-ops would be choosing which conversation
may start their agent — and the author is precisely the person who does not
govern which conversations belong to which area. It is the same separation that
makes classifying a tool the Curator’s act and not theirs.
And the question it looks like it answers is already answered without it. Which agent runs when somebody writes in a channel? The mention names it, or the conversation does. What governs whether it may is the intersection of two facts that already exist: the conversation maps to scope X, and the agent lives in scope X.
The conversation naming one is the same administrative act as mapping it to a scope, made in the same place by the same people, and it is still not the author choosing their own reach. It answers a question the mention alone could not: a channel that exists for one agent had no way to say so, so every person typing in it had to repeat what the configuration already knew, and the day they forgot the platform refused them by name. With an agent named, a mention there needs none and starts nothing else; with none named, the message names the agent and the whole scope is addressable, which is what every installation configured before this does.
The mode a conversation is in is a boundary and not a label. A conversation that starts runs only from its configured message sources must not also be startable by anybody who can type in it, and that is enforced where the configuration is read rather than at each door — a door cannot reply, and a channel that answers nothing is indistinguishable from a broken one.
It selects and it does not authorise. A mention still runs as the person whose channel account is bound, exactly as before — the two are separate questions and folding them together is how “an administrator chose an agent here” would quietly become “anybody in this channel may run it”.
So the trigger is a bare declaration with no field:
This agent may be started by an ask in a conversation of its own scope.
The agent declares willingness; the administration declares reach. That is the shape tools already have — the agent asks for one, the Curator decides what it may do — and it exists for the same reason: describing a process must not grant any power.
It earns its place in the other direction too. An agent that does not declare it cannot be started by any message at all, however the conversations are mapped. Being able to say “this one is internal, never startable by text” is a safety property worth declaring.
10. Email is a transport, not a role
Section titled “10. Email is a transport, not a role”The question “is email a channel?” has no single answer, because the word names a transport and this note is about roles. Email turns up in three, and treating them as one is the expensive mistake.
| What is happening | What it is here |
|---|---|
| The platform telling somebody a run is waiting | A channel. Another driver; the conversation is a mailbox. |
| The agent emailing a customer as part of its work | A tool. An effect, through the Gate, under policy. |
| Mail arriving and opening a run | A trigger, of the external family (§5). |
The criterion is who chose the recipient. The platform, from the run’s own provenance or from configuration: a channel. The agent, from its reasoning: a tool, through the Gate, always.
With Slack that line is easy to see, because posting to #ops and a
slack.dm tool look different. With email they look identical — same SMTP,
same body, same server — and that is the trap. Modelling “the agent emails the
customer” as a channel hands an agent unbounded outbound reach that never meets
the Gate. It is the worst mistake available in this design.
10.1 What email has that Slack does not
Section titled “10.1 What email has that Slack does not”- No delivery guarantee. Slack answers
ok:falsewith a reason. Mail is accepted by a relay and may bounce hours later or land in a spam folder for ever. An approval request that vanishes silently is the failure stage 1 exists to prevent, so email is a second notification and never the only one. - No thread identity worth relying on.
Message-IDandIn-Reply-To, mangled by clients. Stage 2 in a mail thread is materially harder. - Irreversible.
crm.retractunsays a reply; mail cannot be recalled. As a tool it classifies as a write with no compensation, and that absence is worth showing: the machinery will report that nothing takes it back. - It is the phishing surface. An agent that can mail an arbitrary address from the company’s domain is a phishing engine with an audit trail. The recipient has to be constrained — to the run’s origin, or by policy on the tool.
10.2 Inbound mail comes last, after WhatsApp
Section titled “10.2 Inbound mail comes last, after WhatsApp”Against the intuition, which says email is older and therefore easier. It is
the least authenticated inbound surface there is: anyone can send to
support@, SPF and DKIM speak about the envelope and not about who typed, and
it is the most exercised prompt-injection vector in the world. Opening it
before taint and identity binding are mature would be starting with the most
hostile case.
11. What is deliberately not here
Section titled “11. What is deliberately not here”Two loops that would look like natural extensions and are not:
- A bot that answers questions about the platform. That is a different product with a different threat model, and it would put a model between an operator and the record they are trying to read.
- Channels as a message bus. PRD N4 rules out building an integration engine. This platform sends its own notifications and receives its own approvals. The moment it relays other systems’ messages it has become the product the PRD said it is not.
12. Stage 3, broken down
Section titled “12. Stage 3, broken down”Added 2026-08-15, after stages 1 and 2 shipped and PRD DE-18…21 made the ask a requirement rather than a proposal.
Half of it is already standing, which is easy to lose sight of when the note reads as though nothing exists.
Built: Slack signature verification, used by the HTTP event path and the
approval button. Slack Socket Mode for installations that cannot expose a
public Request URL. The account-to-principal binding and its administrative
endpoint. trigger.Opener, which takes a Request and seals run_started —
a channel is a fourth caller and needs no new machinery. The taint check, which
is what makes text somebody else wrote survivable at all. And the outbound
half, with delivery recorded per conversation and retried per conversation.
Missing, smallest first:
| Piece | Why it is what it is |
|---|---|
| Conversation → scope | The map exists in one direction only: Configured.For(scope) answers which conversations speak for a scope. Inbound needs the reverse, and it has to be unique — a conversation serving two scopes means the ask picks the scope, which §4 forbids |
| The trigger declaration | A field with no value on the specification (§9), the parser, the editor, and the intersection: the conversation maps to scope X and the agent lives in scope X |
| Which agent | The mention names it, or the conversation does. Parsing the mention, resolving the name, reading the conversation’s own agent |
| The events endpoint | URL verification, signature (reuse), dedup by event_id — and Slack’s three-second acknowledgement, which means opening the run cannot happen on that request. Get it wrong and every ask is delivered several times |
| Reference resolution | §2.1. The Ledger records the structured ask and not the sentence, which means resolving “this thread” to the alert the platform itself posted there, and refusing to resolve what it did not |
The first three are small. The fourth is medium and fiddly, and its difficulty is somebody else’s contract rather than ours. The fifth is the one to spend thought on, and it is not code-shaped: it is deciding what the platform is entitled to claim it knows.
12.1 The inbox is durable before the acknowledgement
Section titled “12.1 The inbox is durable before the acknowledgement”Slack documents a 2xx within three seconds and retries what it does not get, which is why the run cannot open on that request. But moving the work off the request only solves the retry: between acknowledging and opening there is a window, and a process that dies inside it has told Slack the ask arrived and has no record that it did. The sender is satisfied and the question is gone, which is the worst pair available — a failure that reports success.
So the order is: verify the signature, write the event down, commit, then acknowledge. The opener consumes afterwards, and it may be a different process or a later one.
What is written is team, channel and event_id under a unique key, with
the payload and its digest and a status. The unique key is what makes a
redelivery cost nothing: Slack retries, the insert conflicts, the same
acknowledgement goes back. It is the webhook trigger’s Idempotency-Key
(the webhook door already requires one) arriving under somebody else’s name, and the reason is the
same one — without it a redelivery opens a second run, and every sender in
existence redelivers.
This is the one piece of stage 3 that is infrastructure rather than design, and it is the piece most likely to be skipped for looking like plumbing.
12.1.1 HTTP and Socket Mode are two doors into the same inbox
Section titled “12.1.1 HTTP and Socket Mode are two doors into the same inbox”Slack offers two delivery contracts. HTTP is the simpler production shape when the installation can expose a Request URL: Slack calls the door, the signing secret verifies every request, and the response is the acknowledgement. Socket Mode is the internal/on-premise shape: a worker opens an outbound WebSocket using Slack’s app-level token, and Slack sends the same Events API payloads there instead.
Those two paths are not two triggers. Both parse the same Events API payload
and both write channel_inbox before acknowledging. That is the property that
matters: changing the transport does not change dedupe, taint, origin, or the
per-correspondent ceiling.
Socket Mode changes the secret and the operational cost. It removes the public
inbound URL, but it adds a long-lived connection the worker must maintain and
an xapp- token sealed beside the channel’s other credentials. It receives
Events API asks in this platform. Approval buttons remain on the HTTP
interaction path until Socket Mode interactions are built deliberately; a
button shown when no path can receive the answer is worse than no button.
12.1.2 Watching selected messages is configured automation
Section titled “12.1.2 Watching selected messages is configured automation”A watched conversation is not a bot reading the room and deciding what to do. It is a rule an operator wrote: messages in this conversation, from these Slack user/bot/app ids, start this agent, running as this platform principal. The Slack source filters the event; it grants nothing. The text never chooses the agent and never chooses authority.
The message is still untrusted input. It enters the same inbox, carries the same taint, and counts against the same correspondent ceiling, keyed by the Slack source. The only thing the watch rule adds is the part a mention used to provide: which agent to start and who authorised the run.
12.2 An open channel is an open budget
Section titled “12.2 An open channel is an open budget”Left unanswered here, and worth answering before the first installation rather than after: a conversation anybody can mention the bot in is a way for anybody to open runs. Not maliciously — a channel with three hundred people and a helpful agent is enough.
Today the only thing standing there is the scope ceiling, which is shared with everything else in that area: the first busy morning spends the month’s budget and the agents nobody was talking to stop. §5.1 proposes a per-correspondent ceiling and files it under the external family, because that is where it is obviously needed. It is needed here too, and the difference is only how obvious it is.
Built. A correspondent may open a bounded number of runs in a rolling window, counted from what the inbox already records. Three choices in it are worth stating, because each had a plausible alternative:
- Runs, not money. A run’s cost is known when it ends, and by then the next twenty have started. Each run already has a ceiling of its own, so bounding how many bounds the spend — late enough to be useless is worse than approximate.
- The account, not the person. The binding may not resolve, and the flood arrives before it does. One person with two accounts gets two ceilings; that is the price of a question answerable from what arrived.
- Said once. A limit that answers every message it rejects amplifies the flood it exists to stop. The rest are recorded with a reason code and not said, so an operator asking why a conversation went quiet can still count them.
- Taken, not read. The slot is reserved before the run is opened, under a lock held per correspondent. Counting and then opening is a check followed by a decision, and between them is every other worker: two see nobody has spent anything, both open, and a ceiling of one admits as many runs as there are processes sweeping. The reservation goes with the ask’s lease, so a worker that dies frees the slot in a lease rather than in a window — which couples the two numbers: a lease shorter than the time to open a run releases the slot while the run is still opening, and the ceiling admits one more than it should.
It is not yet a setting. A number nobody can change is a poor knob and a real rail, and a setting with no screen would be configuration written straight into the database. The screen comes with the setting.
And §8’s two open questions block the second and fourth. They should be answered before either is built, not during.
13. The other internal channels come after, not beside
Section titled “13. The other internal channels come after, not beside”Teams, Discord and Google Chat are the same family as Slack (§5): a principal with grants, a conversation carrying a scope, a thread. Nothing in the design changes for them, which is exactly why they are tempting to add now.
Do not. Slack has stages 1 and 2 and not 3. Adding a second vendor before the third stage exists produces two channels that are each two-thirds of a product, and the missing third is the one anybody actually asks for. Depth finishes something; breadth multiplies an unfinished thing.
What a second vendor costs, once stage 3 is done, is genuinely small and worth writing down so the estimate is not re-argued: a driver that posts a message and reads an interaction, its signature scheme, its threading model, and its account identifier. The shape is already the product’s — origin, scope, subject-versus-principal, reply-to-origin — and none of it is per vendor.
One caveat that is not uniform, and it is the reason “just another driver” is not quite true: the identity binding differs per vendor, and it is the sensitive part. Binding a channel account to a principal is granting a person’s authority to a messaging identifier, and each vendor’s account identifier has its own semantics for what happens when somebody leaves, is renamed, or is a guest. That question deserves its own answer per vendor rather than one inherited from Slack.