@@ -57,6 +57,8 @@ import type {
5757 WebhookVerifierArtifact ,
5858 WebhookSecretProvisioning ,
5959 WebhookHmacConfig ,
60+ AnyWebhookSource ,
61+ InferWebhookEvent ,
6062} from "@trigger.dev/core/v3" ;
6163import { chatEvent , normalizeKeyString } from "./webhooks.js" ;
6264// Runtime VALUES go through the ESM/CJS shim so the CJS build can `require`
@@ -4596,8 +4598,35 @@ export type ChatResumeEvent<TClientData = unknown, TUIM extends UIMessage = UIMe
45964598// A connector registers an agent-scoped endpoint that delivers each verified event as a channel
45974599// message; the run applies inbound() to the raw event to get the turn's message. The loop guard is the
45984600// server-side `filter` (an ignored event never becomes a delivery), so inbound is a pure mapper.
4601+ // The reply round-trips (egress) in-run: outbound() maps the turn's reply to a channel message, send()
4602+ // posts/edits it. delivery "final" posts an ack at turn start then edits it to the answer at turn end.
45994603export type ChannelMessageInput = string | UIMessage ;
46004604
4605+ // The channel message a provider posts. Framework requires `text`; providers extend (Slack adds blocks).
4606+ export type ChannelMessage = { text : string ; [ key : string ] : unknown } ;
4607+
4608+ // What outbound() receives: enough to decide what (or whether, null) to post. For "stream", `text` is
4609+ // the accumulated text so far.
4610+ export type ChannelReply = {
4611+ text : string ;
4612+ message : UIMessage ;
4613+ final : boolean ;
4614+ stopped : boolean ;
4615+ error ?: unknown ;
4616+ } ;
4617+
4618+ export type ChannelSendCtx < TEvent = unknown > = {
4619+ event : TEvent ;
4620+ deliveryId : string ;
4621+ previousRef ?: string ;
4622+ mode : "final" | "stream" ;
4623+ final : boolean ;
4624+ } ;
4625+
4626+ // Reserved for 2c: resolve a provider credential keyed by the incoming event's installation (team_id),
4627+ // not the connector (one connector serves many Slack workspaces). The in-run send() calls it at post time.
4628+ export type ResolveChannelToken < TEvent = unknown > = ( event : TEvent ) => Promise < string > ;
4629+
46014630declare const channelEventPhantom : unique symbol ;
46024631
46034632export interface ChannelConnector < TEvent = unknown > {
@@ -4608,7 +4637,11 @@ export interface ChannelConnector<TEvent = unknown> {
46084637 secretProvisioning ?: WebhookSecretProvisioning ;
46094638 filter ?: string ;
46104639 inbound : ( event : TEvent ) => ChannelMessageInput ;
4611- delivery : "stream" | "final" ; // egress mode (used by the reply round-trip; a later phase)
4640+ // Egress (optional; a channel can be inbound-only). Fires only when `send` is set.
4641+ outbound ?: ( reply : ChannelReply ) => ChannelMessage | null ;
4642+ ack ?: ( event : TEvent ) => ChannelMessage | null ; // placeholder posted at turn start ("final" mode)
4643+ send ?: ( message : ChannelMessage , ctx : ChannelSendCtx < TEvent > ) => Promise < { ref ?: string } > ;
4644+ delivery : "final" | "stream" ; // "final" (ack + edit) is v1; "stream" (debounced edits) is a fast-follow
46124645 readonly [ channelEventPhantom ] ?: TEvent ;
46134646}
46144647export type AnyChannelConnector = ChannelConnector < any > ;
@@ -4656,11 +4689,14 @@ export function slackChannel<
46564689 id : string ;
46574690 key : ValidatedWebhookKey < TEvent , TKey > ;
46584691 inbound : ( event : TEvent ) => ChannelMessageInput ;
4692+ outbound ?: ( reply : ChannelReply ) => ChannelMessage | null ;
46594693 filter ?: TFilter & ValidateWebhookFilter < TEvent , TFilter > ;
4660- delivery ?: "stream " | "final " ;
4694+ delivery ?: "final " | "stream " ;
46614695} ) : ChannelConnector < TEvent > {
4662- const { id, key, inbound, filter, delivery } = options ;
4696+ const { id, key, inbound, outbound , filter, delivery } = options ;
46634697 resourceCatalog . registerDeclaredSessionWebhook ( id ) ;
4698+ // `send` is intentionally absent here: the real Slack sender (bot token + chat.postMessage/update)
4699+ // is the @trigger .dev/slack provider package. Until then a Slack channel is inbound-only.
46644700 return {
46654701 id,
46664702 source : "slack" ,
@@ -4669,13 +4705,56 @@ export function slackChannel<
46694705 secretProvisioning : "integrator" ,
46704706 filter,
46714707 inbound,
4672- delivery : delivery ?? "stream" ,
4708+ outbound,
4709+ delivery : delivery ?? "final" ,
46734710 } as ChannelConnector < TEvent > ;
46744711}
46754712
4713+ /**
4714+ * A generic chat-frontend channel over any verified source. Unlike `slack()`, you supply the egress
4715+ * `send` yourself (post/edit the reply back to your surface), so the whole round-trip is under your
4716+ * control. Use for providers without a preset, or to test the channel round-trip end to end.
4717+ */
4718+ export function chatChannelCustom <
4719+ TSource extends AnyWebhookSource ,
4720+ const TKey extends string = string ,
4721+ const TFilter extends string = string ,
4722+ > ( options : {
4723+ id : string ;
4724+ source : TSource ;
4725+ key : ValidatedWebhookKey < InferWebhookEvent < TSource > , TKey > ;
4726+ inbound : ( event : InferWebhookEvent < TSource > ) => ChannelMessageInput ;
4727+ outbound ?: ( reply : ChannelReply ) => ChannelMessage | null ;
4728+ ack ?: ( event : InferWebhookEvent < TSource > ) => ChannelMessage | null ;
4729+ send ?: (
4730+ message : ChannelMessage ,
4731+ ctx : ChannelSendCtx < InferWebhookEvent < TSource > >
4732+ ) => Promise < { ref ?: string } > ;
4733+ filter ?: TFilter & ValidateWebhookFilter < InferWebhookEvent < TSource > , TFilter > ;
4734+ delivery ?: "final" | "stream" ;
4735+ } ) : ChannelConnector < InferWebhookEvent < TSource > > {
4736+ const { id, source, key, inbound, outbound, ack, send, filter, delivery } = options ;
4737+ resourceCatalog . registerDeclaredSessionWebhook ( id ) ;
4738+ return {
4739+ id,
4740+ source : source . provider ,
4741+ key : normalizeKeyString ( key as string ) ,
4742+ verifierArtifact : source . verifier ,
4743+ secretProvisioning : source . secretProvisioning ,
4744+ filter,
4745+ inbound,
4746+ outbound,
4747+ ack,
4748+ send,
4749+ delivery : delivery ?? "final" ,
4750+ } as ChannelConnector < InferWebhookEvent < TSource > > ;
4751+ }
4752+
46764753const chatChannels = {
46774754 /** Slack as a chat frontend. See {@link slackChannel}. */
46784755 slack : slackChannel ,
4756+ /** A generic chat-frontend channel over any source, with your own egress. See {@link chatChannelCustom}. */
4757+ custom : chatChannelCustom ,
46794758} ;
46804759
46814760// Turn a channel connector's inbound() result into a user UIMessage for the turn.
@@ -4684,6 +4763,18 @@ function toUserUIMessage(input: ChannelMessageInput, messageId: string): UIMessa
46844763 return { id : messageId , role : "user" , parts : [ { type : "text" , text : input } ] } as UIMessage ;
46854764}
46864765
4766+ // Sum the text parts of a UIMessage (the reply text egress posts).
4767+ function uiMessageText ( message : UIMessage ) : string {
4768+ return ( message . parts ?? [ ] )
4769+ . map ( ( p ) => ( p && typeof p === "object" && "text" in p ? String ( ( p as { text : unknown } ) . text ) : "" ) )
4770+ . join ( "" ) ;
4771+ }
4772+
4773+ // Default outbound: post the reply text, or nothing when empty (a tool-only / empty turn).
4774+ function defaultChannelOutbound ( reply : ChannelReply ) : ChannelMessage | null {
4775+ return reply . text ? { text : reply . text } : null ;
4776+ }
4777+
46874778// The `action` type onAction receives: the actionSchema output (when set) unioned with the action
46884779// envelopes of any listed chat.event descriptors. ChatEventActions<[]> is `never`, so it collapses
46894780// cleanly when no events are listed; `unknown` is kept only when neither source is present.
@@ -6511,15 +6602,37 @@ function chatAgent<
65116602 void _hsm ;
65126603 // Channel-delivered turn: map the raw provider event to the turn's message via the
65136604 // connector's inbound(), resolved by connectorId. The loop guard is the connector's
6514- // server-side filter, so inbound is a pure mapper here.
6605+ // server-side filter, so inbound is a pure mapper here. `channelConn` + `channelAckRef`
6606+ // are read again at turn-complete to egress the reply.
65156607 let effectiveIncomingMessage = incomingMessage ;
6608+ let channelConn : AnyChannelConnector | undefined ;
6609+ let channelAckRef : string | undefined ;
65166610 if ( wireChannelEvent ) {
6517- const connector = channels ?. find ( ( c ) => c . id === wireChannelEvent . connectorId ) ;
6518- if ( connector ) {
6611+ channelConn = channels ?. find ( ( c ) => c . id === wireChannelEvent . connectorId ) ;
6612+ if ( channelConn ) {
65196613 effectiveIncomingMessage = toUserUIMessage (
6520- connector . inbound ( wireChannelEvent . event ) ,
6614+ channelConn . inbound ( wireChannelEvent . event ) ,
65216615 currentWirePayload . messageId ?? wireChannelEvent . deliveryId
65226616 ) as typeof incomingMessage ;
6617+ // "final" egress: post an ack placeholder now so a message ref exists to edit at turn end.
6618+ if ( channelConn . send && channelConn . ack ) {
6619+ const ackMessage = channelConn . ack ( wireChannelEvent . event ) ;
6620+ if ( ackMessage ) {
6621+ try {
6622+ const ackResult = await channelConn . send ( ackMessage , {
6623+ event : wireChannelEvent . event ,
6624+ deliveryId : wireChannelEvent . deliveryId ,
6625+ mode : channelConn . delivery ,
6626+ final : false ,
6627+ } ) ;
6628+ channelAckRef = ackResult ?. ref ;
6629+ } catch ( ackError ) {
6630+ logger . warn ( "chat.agent: channel ack post failed; continuing" , {
6631+ error : ackError ,
6632+ } ) ;
6633+ }
6634+ }
6635+ }
65236636 }
65246637 }
65256638 const incomingMessages : TUIMessage [ ] = effectiveIncomingMessage
@@ -7744,6 +7857,32 @@ function chatAgent<
77447857 turnAccessToken
77457858 ) ;
77467859
7860+ // Channel egress ("final"): map the turn's reply via outbound() and send it, editing
7861+ // the ack placeholder posted at turn start (previousRef). Best-effort: an egress failure
7862+ // is logged, not fatal. A manual-pipe turn has no responseMessage, so it opts out.
7863+ if ( wireChannelEvent && channelConn ?. send && turnCompleteEvent . responseMessage ) {
7864+ const outbound = channelConn . outbound ?? defaultChannelOutbound ;
7865+ const channelMessage = outbound ( {
7866+ text : uiMessageText ( turnCompleteEvent . responseMessage ) ,
7867+ message : turnCompleteEvent . responseMessage ,
7868+ final : true ,
7869+ stopped : turnCompleteEvent . stopped ,
7870+ } ) ;
7871+ if ( channelMessage ) {
7872+ try {
7873+ await channelConn . send ( channelMessage , {
7874+ event : wireChannelEvent . event ,
7875+ deliveryId : wireChannelEvent . deliveryId ,
7876+ previousRef : channelAckRef ,
7877+ mode : channelConn . delivery ,
7878+ final : true ,
7879+ } ) ;
7880+ } catch ( egressError ) {
7881+ logger . warn ( "chat.agent: channel egress send failed" , { error : egressError } ) ;
7882+ }
7883+ }
7884+ }
7885+
77477886 // Fire onTurnComplete — stream is closed, use for persistence.
77487887 if ( onTurnComplete ) {
77497888 await tracer . startActiveSpan (
0 commit comments