execute tool, but the controls live in Slack (or your own surface) instead of a React component. The slack() connector ships Approve and Deny buttons out of the box.
How it works
The building block is a tool with noexecute function. When the model calls it, the turn completes with the tool call still pending instead of resolving it. Over a channel the framework then takes over the round-trip:
Because it is a no-execute pause, the run suspends while it waits rather than holding compute. A human can take minutes or days to decide without burning compute or hitting maxDuration. The mechanics are the same as the browser case, covered in human-in-the-loop.
Slack approvals
Out of the box,slack() posts Approve and Deny buttons for any pending no-execute tool. A click resolves to { approved: boolean } and the buttons collapse to the decision. You define the tool and list the channel on the agent:
trigger/support-agent.ts
requestApproval, the bot posts “Approval needed” with the action and Approve and Deny buttons, and the session’s status is suspended. Clicking Approve resolves the tool to { approved: true }, the buttons collapse to “Approved by @you”, and the agent continues and streams the outcome. Deny resolves { approved: false }. Pressing Slack’s stop button instead cancels the approval: the buttons are replaced with “Stopped” and a later click resumes nothing.
Customizing the controls
Forchat.channels.custom, four hooks own the interaction round-trip:
(pending, ctx) => ChannelMessage | null
Map the pending tool call(s) to the controls you post.
pending is a list of { toolCallId, toolName, input }, and ctx carries the event and deliveryId that started the turn (both absent on a bound turn, such as a chat.turn() action answering into the thread). Return null to skip posting controls.(event) => { toolCallId, output } | null
Map a verified callback event to a resolution. The
output is stitched onto the pending tool call matched by toolCallId and the run resumes. Return null to treat the event as a normal message instead (a new turn).(event, resolution) => void
Collapse the controls after a decision so they cannot be clicked again. Best effort: a failure here is logged and the run still resumes.
(cancelled, ctx) => void
Take the controls down when a stop (
onControl returning { kind: "stop", whenIdle: "drop" }) arrives while the session waits on them. Each cancelled call carries the ref its controls were posted with. The calls are already resolved as stopped. Best effort: a failure is logged.value so onInteraction can resolve the exact call (see Approvals and buttons to customize the buttons or give a tool its own UI), and finalizeInteraction posts to the interaction’s response_url with replace_original to swap the buttons for the outcome. On a custom surface you choose the encoding and how you edit the controls away.
output is whatever your tool expects to receive. The Slack default resolves { approved: boolean }; if your tool needs a richer answer, supply your own onInteraction that returns the shape your tool reads.
The reply after a decision
When the turn resumes, the channel reply shows the agent’s answer, not the text it produced before pausing. The framework posts the assistant text generated after the tool call, so a preamble like “I’ll need approval first” is not concatenated onto the final “done” message. The approval prompt itself already lives in the collapsed controls.Next steps
Channels
Point Slack or any surface at an agent so messages become turns.
Browser human-in-the-loop
The same no-execute pause, rendered in a React frontend.
Sessions
The durable per-conversation state a channel turn runs on.
Filters
Gate which verified deliveries reach the agent.

