Feelers¶
A channel is three things, kept apart on purpose:
- A Chromo translates. Platform payloads in, core schemas out, and markdown or segments back into platform payloads. It sends nothing.
- An Ink transports. It holds the platform client, sends, edits, uploads, downloads and opens direct messages. It decides nothing about content.
- Feelers render. They decide how a run is drawn on this platform and how the cards a person answers look and read back.
If you want the MVC analogy: the model is the run's event stream, the segments, the
ledger and the persisted actions; the feelers are the view; and the controller is
the channel's siphon on the way out and the channel's ingest on the way in.
The chromo is a codec, not a controller.
The seven feelers¶
Every channel instance carries one of each:
| Feeler | Draws |
|---|---|
timeline |
A streaming run: thinking, tool calls, answer text, todos, subagents, mid-run notices |
markdown |
A finished reply, chunked and sent |
segments |
A reply made of segments: text, mentions, images, files, cards |
artifacts |
A saved artifact's title, provider and viewer link |
approvals |
Approval cards for a batch |
ask_questions |
Question cards for a batch |
oauth |
Authorisation and profile-link messages, always to a private surface |
A channel gets working defaults for all seven: a timeline that accumulates and sends one message at the end, plain-text approval and question cards, a plain-text OAuth message and an artifact overview. Slack, Lark and Discord replace nearly all of them.
Feelers present events and build none. What a channel shows comes from what the graph publishes on the nerve: a run's stream, or what happens around a run — a move, a send, an authorization, an artifact, a failed turn.
The OAuth feeler's present is concrete on the base class and resolves the private
address itself, redirecting a request made on a shared surface to the person's DM
and raising when the platform has nowhere private. A subclass fills in send and
never gets to pick where it sends.
The siphon¶
The graph never calls a channel to draw. It publishes what a kick does on the nerve, and each channel holds one subscription to everything at its own addresses, its siphon, opened when the channel starts and before any message is taken in. The siphon hands each event to the feeler that draws it:
- A run, from its start to its end, goes to the timeline when the channel streams. When it does not, the run's sends, cards and authorizations are drawn as they come and its reply once, at the end.
- A move's line, a send outside a run, an authorization and a failed turn go to their own feelers as they arrive.
- Artifacts have their own feeler inside and outside runs. Their delivery is recorded separately from the final reply.
Each thread is drawn in order in a lane of its own, so a slow platform call on one thread holds up no other. A rendering that fails is logged and the lane carries on; the graph never hears of it, and the reply is already in the ledger. The graph records a row before it publishes it, and the event carries the row's id, so the siphon marks the row with the platform id it was drawn as.
Trunkline's siphon subscribes to nothing of its own. Each console socket holds the subscription of the one thread it watches, whichever channel that thread is on.
The timeline pipeline¶
feelers/output.py is the shared machinery every streaming channel builds on:
- Chunking. A
MarkdownChunkersplits at the best boundary under a platform's limit: paragraph, line, sentence, whitespace. Discord instantiates it at 2000. - Batching. A
TextStreamBatcherbuffers token deltas per block and decides when to flush from the channel'sstreamsettings: always on the first piece, atmax_chars, never undermin_chars, otherwise onflush_interval. - Flushing. A
StreamFlusherruns one background task so the drive loop never blocks on a slow platform call; deltas arriving mid-flush coalesce. - Dispatch.
TimelineState.driveis onematchover the run stream, mapping each event to a hook:thinking_start,thinking_delta,thinking_end,answer_*,tool_start,tool_end,todo,message_sent,oauth_authorization,artifact_available,present_actions. The base hooks delegate shared presentation; a channel overrides the ones it can draw. Draining the whole stream is mandatory, a render failure is logged and the loop keeps draining, and a final output that never streamed is rendered once as a fallback. - Notices. A rule every override honours: a hook that renders reply text marks
the surface as noticed, and every hook that opens a new entry (a thinking block,
a tool call, a todo, never a tool result) calls
begin_entryfirst, which rotates the surface once if a notice streamed in between. That is what lets a mid-run message land between two tool cards instead of inside one. - Subagents. The run reports each accomplice's start and finish, and the timeline draws it as a child timeline of its own.
Tools the timeline never draws: ask_questions, send, commission, whisper,
teleport and the output tool. They are plumbing, not work.
Cards and how they come back¶
The suspender persists the batch and puts it on the run's stream as one event, which the siphon presents wherever it draws the run. Presenting it shows approvals then questions and records each card's platform message id against its action, so the card and the row stay linked. A batch either answers a live request or resumes a suspended run; feelers present both the same way and do not decide which it is. Answering is per channel, and two designs coexist:
- Slack and Lark carry the batch's state in the button payload: ids, the actions, the page, and the answers so far. A press validates the payload and delivers a response when the batch is complete.
- Discord carries ids only, in the button's custom id, and reloads the batch from the database under a per-batch lock. Approvals survive a restart; answers typed but not submitted do not.
Either way the channel builds one DeferredActionBatchResponse and kicks. A batch
already resolved is refused, never resumed twice. The answer is published too, so
a console still showing the cards marks them answered; an IM channel's own cards
for a batch answered somewhere else are not updated yet.
Where a reply lands¶
Both the markdown feeler and the timeline compute the destination from the address: the platform thread when there is one, the chat otherwise, with a reply target when a reply segment led the message. Each ink then maps that onto its platform's own idea of a thread, which is where the per-platform differences on Threads and chat rooms come from.