run() loop), and the agent’s reply is posted back to the surface. A Slack thread becomes a real conversation with the agent, exactly like the browser chat, just a different frontend.
List channels on a chat.agent alongside (or instead of) events:
run() loop is unchanged: the agent does not know or care that it is talking to Slack. One verified Slack message in a thread is routed to a durable session keyed to that thread, run as a turn, and the reply is posted into the thread.
Slack
slack() from @trigger.dev/slack makes the agent a native Slack agent: the session shows its status, the reply streams into the thread with tool calls as task cards, approvals are buttons and Slack’s stop button stops the turn. Shared images and PDFs reach the model, messages sent mid-reply can be folded into it, and the agent can post its own buttons. See Slack agents for setup and options.
Linear
linear() from @trigger.dev/linear makes the agent a Linear agent: assign it an issue or @mention it and it works the issue inside Linear’s agent session UI, with thoughts, actions, a plan and approvals. See Linear agents.
Approvals and interactive controls
An agent on a channel can pause a turn to get a human decision, approving a refund or confirming a deletion, and resume once someone clicks a button in the thread.slack() renders Approve and Deny buttons for you and collapses them to the decision once clicked; you can replace them, or give a tool its own UI whose click becomes the tool’s result (see Slack agents). See human-in-the-loop.
Any surface: chat.channels.custom
For a surface without a preset, chat.channels.custom is the generic connector. You supply the endpoint it subscribes to, the session key, the inbound map, and the egress send:
send is called to post the reply. Within a turn, ctx.previousRef is the ref you returned from the turn’s earlier send (the acknowledgement, or the message being streamed), so later sends edit that message instead of posting a new one. Each turn starts without a ref: the next turn’s answer is a new message. Return null from outbound to stay silent (a tool-only turn, say).
An event that inbound maps to an empty message (no text, and no file or data parts) runs no turn. A click or system event your surface sends to the same endpoint is dropped rather than answered with an empty prompt.
List the connector in chat.agent({ channels: [mySurface] }), which subscribes the agent to the endpoint with the id agentId:connectorId. Other subscribers on the endpoint keep receiving its deliveries.
Stopping a turn from the channel
A connector can map an event to a control withonControl. Returning { kind: "stop" } while a turn is running aborts it through the run’s stopSignal, exactly like the browser’s stop button: the partial reply posts with stopped: true and the event is consumed instead of running as a turn. linear() and slack() map their stop buttons this way; a custom connector might map a /stop command:
inbound as an ordinary message. Return whenIdle: "drop" to consume it instead: no turn runs, and if the session is waiting on an approval the pending tool calls are resolved as stopped and the connector’s cancelInteraction(cancelled, ctx) takes the controls down (each cancelled call carries the ref its controls were posted with). Set at to when the user asked (epoch milliseconds), and a stop older than the latest turn is consumed without stopping it or cancelling that turn’s approval, so a late or repeated delivery never stops the wrong turn. A stop pressed while the latest turn ran but delivered after it ended is consumed too, rather than handled as a message.
Status and native streaming
Two more hooks let a connector mirror the session into a surface that has its own notion of an agent, the wayslack() does:
lifecycle(state, ctx)reportsworkingwhen a turn starts (and every 50 minutes while it runs),waitingwhen it pauses on a human decision,idlewhen it ends for any reason, andclosedonchat.close().ctx.firstTurnandctx.promptare there to title a new conversation. Calls are serialized, so an older report never lands after a newer one.stream(ctx), withdelivery: "stream", returns a per-turn transport{ text(delta), progress?(event), end(outcome) }. The runtime feeds it the reply’s markdown deltas and tool progress, then callsend({ stopped, pending, errorText? })once. Resolve{ delivered: true }when the reply reached the surface, and the runtime does not post it again. Callctx.opened(ref)once the surface has created the stream message: the ref is saved with the session while the turn runs, and if the worker dies mid-stream the next one calls the connector’sendInterruptedStream(ref, ctx)before it answers, so the old stream does not look like it is still being written.
Bound sessions
The first channel message binds the session to its connector and thread. From then on, any turn on that session posts its answer back through the same connector, including turns no channel delivery started: achat.event action that returns chat.turn(), or a message from a browser client sharing the session via chat.createWatchToken. slack() binds to the team, channel, thread and the user it is answering; a custom connector opts in with bind:
inbound may return { message, externalUserId, externalMessageId } as well as a plain string or UIMessage. The runtime stamps every channel-delivered user message with metadata: { source: "channel", connectorId, provider, deliveryId, externalDeliveryId?, externalUserId?, externalMessageId? } (deliveryId is the delivery’s whd_ id and externalDeliveryId the provider’s), and a transcript storage receives source: "channel" and the connectorId on its save context, so a store you own can attribute and reconcile channel messages.
Files, buttons and steering
Three more hooks cover what a chat surface carries besides text:download(url, { handle })fetches files only the surface’s credentials can read. Haveinboundreturn file parts on the surface’s URLs, and thestreamTextpassed torun()asks each connector’sdownloadfor every file URL in the prompt (return null for URLs it doesn’t own). Downloads are cached for the run and the transcript keeps the URL. A filedownloadthrows on becomes a short note in that turn’s messages, and the turn still runs.toAction(event)maps a click on a control the agent posted to an action. It reachesonAction, validated againstactionSchema, with the click itself aschannel.event. Returningchat.turn(message)answers in the bound thread. Return null for anything else, such as a click answering a pending tool call, whichonInteractionhandles.- With
pendingMessageson the agent, a channel message that arrives while a turn is running goes throughinboundand is offered toshouldInjectlike a browser message (itsmetadata.sourceis"channel"). Stops, clicks and empty messages are never offered. received(ctx)acknowledges a channel message that arrives while a turn is running, as soon as it arrives and whether or notpendingMessagesfolds it in, so its wording should be true either way:slack()reacts with 👀,linear()posts a thought. Stops, clicks and empty messages are not acknowledged.
Channels vs events
Both are inbound surfaces on achat.agent, and an agent can list both:
events(chat.event): the webhook is a signal. Delivered toonAction; the agent acts, no reply is sent back.channels(slack,chat.channels.custom): the webhook is a chat frontend. Delivered as a turn torun(); the reply is posted back.

