channels
@alexkroman1/aai/channels — where a run’s output GOES.
One vendor today, one shape: a factory returns a serializable DESCRIPTOR
({ kind, options }) and sendToChannel posts a ChannelMessage
to it. Nothing here opens a socket at import time, and nothing reads a
credential out of the environment — see SlackChannelOptions for why
a channel’s credential is passed in where a provider’s is not.
Example
Section titled “Example”Post a run’s result to Slack
import { slackChannel } from "@alexkroman1/aai/channels";import { sendToChannelOrFail } from "@alexkroman1/aai/step-errors";
export async function postSummary(webhookUrl: string, points: string[]): Promise<string> { return await sendToChannelOrFail(slackChannel({ webhookUrl }), { text: `Weekly summary: ${points.length} items`, heading: "Weekly summary", sections: [{ title: "Highlights", bullets: points }], });}A channel is the OUTBOUND half, deliberately
Section titled “A channel is the OUTBOUND half, deliberately”The word is used elsewhere for a bidirectional edge adapter — vercel/eve’s
defineChannel owns inbound routes, a session address, and delivery back —
and that is a different concept than this one. The narrow reading is the
useful one here because it is what a durable step has: no session to resume,
no route to serve, one message to place somewhere and a verdict to reach
about whether a failed attempt is worth repeating. eve’s own docs decline to
abstract this case and send authors to the provider’s API plus an
application-owned outbox; this SDK’s steps already have the durability half,
so what was left to write is the render-and-classify half.
What each piece is for
Section titled “What each piece is for”- slackChannel — declare a destination. isSlackWebhookUrl guards the value where a PERSON supplies it, which is a security boundary and not only a typo check.
- sendToChannel — post, and throw a ChannelDeliveryError
carrying the retry verdict.
sendToChannelOrFail(@alexkroman1/aai/step-errors) is the same call with the fatal/retryable mapping already applied. - renderChannelPayload — the request that WOULD be sent, pure, so a spec can assert the body without a network.
This subpath names neither zod nor @alexkroman1/aai/step-errors, which is
what lets an agent.ts import isSlackWebhookUrl for a schema
refinement without pulling either into its graph.
Functions
Section titled “Functions”escapeSlackMrkdwn()
Section titled “escapeSlackMrkdwn()”escapeSlackMrkdwn(
text):string
The three characters Slack’s mrkdwn reserves.
Only three, and only these: Slack’s own escaping rules say &, < and >
and nothing else, so escaping more would put backslashes in front of the
apostrophes in every summary. & first, or the ampersands introduced by the
other two get double-escaped.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”string
explainChannelFailure()
Section titled “explainChannelFailure()”explainChannelFailure(
channel,detail):string
The sentence a person can act on for a refusal this channel understands.
Parameters
Section titled “Parameters”channel
Section titled “channel”detail
Section titled “detail”string
Returns
Section titled “Returns”string
Throws
Section titled “Throws”when channel.kind names no known channel.
explainSlackChannelFailure()
Section titled “explainSlackChannelFailure()”explainSlackChannelFailure(
options,detail):string
The sentence a person can act on, chosen from what the URL and the body say.
workflow_not_published is called out by name because it is the one 4xx
with a fix that is not “check your URL” — the URL is fine and the workflow
behind it was never published — and nothing in Slack’s generic message says
so.
Parameters
Section titled “Parameters”options
Section titled “options”detail
Section titled “detail”string
Returns
Section titled “Returns”string
isSlackWebhookUrl()
Section titled “isSlackWebhookUrl()”isSlackWebhookUrl(
value):boolean
Whether a string is a Slack webhook URL at all — an incoming webhook or a workflow trigger, on one of Slack’s two webhook hosts.
A HOST check rather than “is it a URL”, and this is a security boundary as much as a usability one. The value becomes the target of a POST carrying whatever the run summarized, so anything that is not Slack is an exfiltration endpoint somebody typed into a form. Refuse it where the value is accepted — a 400 at the call site — rather than at delivery, which is a failed run after the expensive work has already been paid for.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”boolean
Example
Section titled “Example”Refuse a non-Slack destination at the form’s edge
import { isSlackWebhookUrl } from "@alexkroman1/aai/channels";import { z } from "zod";
export const input = z.object({ webhookUrl: z .string() .trim() .url() .refine(isSlackWebhookUrl, "Enter a Slack webhook URL from hooks.slack.com"),});isSlackWorkflowTriggerUrl()
Section titled “isSlackWorkflowTriggerUrl()”isSlackWorkflowTriggerUrl(
url):boolean
A workflow trigger, which takes flat variables and not Block Kit.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”boolean
registerChannelHandler()
Section titled “registerChannelHandler()”Call Signature
Section titled “Call Signature”registerChannelHandler(
handler):void
Register a channel kind, so sendToChannel can dispatch a descriptor
carrying its tag.
The SDK registers what it ships (Slack today). Call this for a destination
it does not — an internal notifier, a platform with no adapter here — and
the rest of the channel surface works unchanged: slackChannel() has no privileges
a hand-written descriptor factory lacks.
Register before the first send, and remember a descriptor outlives the process. A channel round-trips through a durable run’s journal, so a run resumed in a fresh worker dispatches on a tag whose module that worker may never have imported. Register at module load in the agent’s entry, not lazily beside the first call.
Re-registering a kind REPLACES it, which is what makes a shipped channel overridable — and is why the tag is the identity rather than the value.
A handler typed on its own options (ChannelHandler<MyOptions>) is
registered WITH the function that narrows the raw record into them, which
runs before each render and advice — so both are handed a checked value,
and a journaled descriptor with a bad field fails naming it. The overload
makes the narrowing required: nothing else checks that what a journal hands
back is a MyOptions.
Parameters
Section titled “Parameters”handler
Section titled “handler”Returns
Section titled “Returns”void
Call Signature
Section titled “Call Signature”registerChannelHandler<
O>(handler,options):void
Register a channel kind whose render/advice read their OWN options type,
narrowed from the descriptor’s raw options by options (throwing a sentence
naming the field that is wrong).
Type Parameters
Section titled “Type Parameters”O
Parameters
Section titled “Parameters”handler
Section titled “handler”options
Section titled “options”(raw) => O
Returns
Section titled “Returns”void
registeredChannelKindNames()
Section titled “registeredChannelKindNames()”registeredChannelKindNames(): readonly
string[]
The tags sendToChannel can dispatch, in registration order.
Returns
Section titled “Returns”readonly string[]
renderChannelPayload()
Section titled “renderChannelPayload()”renderChannelPayload(
channel,message):ChannelPayload
The request a channel would send for this message — PURE, so the branch a channel takes over its own options is testable without a network.
That branch is not academic: on Slack it decides between Block Kit and flat workflow variables, and the wrong one is a 400 on the whole payload.
Parameters
Section titled “Parameters”channel
Section titled “channel”message
Section titled “message”Returns
Section titled “Returns”Throws
Section titled “Throws”when channel.kind names no known channel.
renderSlackChannelPayload()
Section titled “renderSlackChannelPayload()”renderSlackChannelPayload(
message,options):ChannelPayload
Block Kit, or flat variables — see the module doc.
Parameters
Section titled “Parameters”message
Section titled “message”options
Section titled “options”Returns
Section titled “Returns”renderSlackPlainText()
Section titled “renderSlackPlainText()”renderSlackPlainText(
message):string
The trigger body: one string, because that is all a variable can hold.
text leads rather than being dropped — on this arm it is the notification
line AND the only place the caller’s own summary of the message survives.
Parameters
Section titled “Parameters”message
Section titled “message”Returns
Section titled “Returns”string
sendToChannel()
Section titled “sendToChannel()”sendToChannel(
channel,message):Promise<string>
Post one message, and classify the failure honestly.
The 4xx/5xx split is the whole reason this is not a one-line stepFetch.
A revoked webhook, an unpublished Slack workflow and a wrong variable name
all answer 4xx and will answer 4xx identically on every retry — retrying
them burns a step’s attempts and delays the real error by minutes. A 5xx is
the platform having a bad minute, which is precisely what retries are for,
and any Retry-After it named is carried on the error.
The ChannelDeliveryError it throws is what toStepError reads, so a step
body hands it straight on and the engine gives up or waits the right amount
— see ChannelDeliveryError, or reach for sendToChannelOrFail
(@alexkroman1/aai/step-errors) to skip the .catch.
Parameters
Section titled “Parameters”channel
Section titled “channel”message
Section titled “message”Returns
Section titled “Returns”Promise<string>
whatever the platform answered with, or "ok" when it sent no body.
Throws
Section titled “Throws”on any non-2xx.
Example
Section titled “Example”import { sendToChannel, slackChannel } from "@alexkroman1/aai/channels";import { throwStepError } from "@alexkroman1/aai/step-errors";
export async function announce(webhookUrl: string): Promise<string> { return await sendToChannel(slackChannel({ webhookUrl }), { text: "Run finished." }).catch( throwStepError, );}slackChannel()
Section titled “slackChannel()”slackChannel(
options):SlackChannel
Declare a Slack destination.
Parameters
Section titled “Parameters”options
Section titled “options”Returns
Section titled “Returns”Example
Section titled “Example”Post a digest to Slack from a step
import { slackChannel } from "@alexkroman1/aai/channels";import { sendToChannelOrFail } from "@alexkroman1/aai/step-errors";
export async function postDigest(webhookUrl: string, summary: string): Promise<string> { return await sendToChannelOrFail(slackChannel({ webhookUrl }), { text: `Daily digest: ${summary}`, heading: "Daily digest", sections: [{ body: summary }], });}Classes
Section titled “Classes”ChannelDeliveryError
Section titled “ChannelDeliveryError”A post the channel refused, carrying the verdict the caller needs.
retryable is the whole point, and it is why this is a class rather than a
thrown Response. A revoked webhook, an unpublished Slack workflow and a
wrong variable name all answer 4xx and will answer 4xx identically on every
attempt — retrying them burns a step’s attempts and delays the real error by
minutes. A 5xx is the platform having a bad minute, which is precisely what
retries are for.
toStepError (@alexkroman1/aai/step-errors) reads both fields, exactly as
it already does for StepGenerateError and TranscribeError — so a step
body hands this straight to it and the DevKit gives up or waits out the
platform’s own Retry-After without the body deciding anything.
Extends
Section titled “Extends”Error
Constructors
Section titled “Constructors”Constructor
Section titled “Constructor”new ChannelDeliveryError(
message,init):ChannelDeliveryError
Parameters
Section titled “Parameters”message
Section titled “message”string
cause?
Section titled “cause?”unknown
channelKind
Section titled “channelKind”string
retryable
Section titled “retryable”boolean
retryAfter?
Section titled “retryAfter?”Date
status?
Section titled “status?”number
Returns
Section titled “Returns”Overrides
Section titled “Overrides”Error.constructor
Properties
Section titled “Properties”channelKind
Section titled “channelKind”
readonlychannelKind:string
The channel that refused it — "slack", and so on.
readonlyname:"ChannelDeliveryError"="ChannelDeliveryError"
Overrides
Section titled “Overrides”Error.name
retryable
Section titled “retryable”
readonlyretryable:boolean
Whether another attempt could plausibly succeed.
retryAfter
Section titled “retryAfter”
readonlyretryAfter:Date|undefined
When the platform named a Retry-After, the moment it asked for.
status
Section titled “status”
readonlystatus:number|undefined
The HTTP status, or undefined when the request never got an answer.
Interfaces
Section titled “Interfaces”ChannelDescriptor
Section titled “ChannelDescriptor”Base shape for a channel descriptor: a kind tag plus an opaque options
payload, so the dispatch table picks the renderer and passes the author’s
options through verbatim.
Type Parameters
Section titled “Type Parameters”Kind extends string
Options
Section titled “Options”Options
Properties
Section titled “Properties”
readonlykind:Kind
options
Section titled “options”
readonlyoptions:Options
ChannelHandler
Section titled “ChannelHandler”Everything one channel kind supplies: how to turn a ChannelMessage into the request body that platform takes, and what to say when the platform refuses one.
A channel is defined as a VALUE of this shape, in the module that owns the
platform, and sendToChannel reaches it through the registry. The generic
send path therefore imports nothing vendor-specific and adding a channel
touches no shared file — which is the whole reason this interface is public
rather than an internal shape inside send.ts, where it started with Slack’s
option-narrowing spelled out beside the dispatch table.
The descriptor’s options arrive RAW, because a descriptor round-trips
through a durable run’s journal and arrives as whatever was written there.
Narrowing them is the kind’s own job and the reason it owns this value: a
cast would fail as POST undefined rather than naming the field that is
missing. O is what that narrowing produces: register a
ChannelHandler<MyOptions> together with the function that narrows the raw
record into MyOptions (see registerChannelHandler) and render/advice
are handed its answer, typed; leave O at its default and they are handed
the raw record to narrow themselves.
Type Parameters
Section titled “Type Parameters”O = Record<string, unknown>
The options render and advice read, as options narrows
them. Defaults to the raw record.
Properties
Section titled “Properties”advice
Section titled “advice”
readonlyadvice: (options,detail) =>string
What to tell an author when the platform refuses a post.
Parameters
Section titled “Parameters”options
Section titled “options”O
detail
Section titled “detail”string
Returns
Section titled “Returns”string
readonlykind:string
The kind tag its descriptors carry, e.g. "slack".
render
Section titled “render”
readonlyrender: (message,options) =>ChannelPayload
Turn a message into this platform’s request.
Parameters
Section titled “Parameters”message
Section titled “message”options
Section titled “options”O
Returns
Section titled “Returns”ChannelMessage
Section titled “ChannelMessage”What gets posted, in terms no single platform owns.
text is not decoration and it is not optional. It is what a push
notification and a screen reader read, and it is the WHOLE message on a
channel that has no rich format — a Slack workflow trigger takes flat string
variables and there is no blocks variable to send. A channel with a rich
format renders heading/sections and uses text as the notification
line; a channel without one renders text and folds the rest into it. Leave
it off and Slack notifies as “[no preview]”; that is the failure this field
exists to prevent.
Properties
Section titled “Properties”heading?
Section titled “heading?”
readonlyoptionalheading?:string
The title above the sections.
sections?
Section titled “sections?”
readonlyoptionalsections?: readonlyChannelSection[]
The body, in blocks.
subtitle?
Section titled “subtitle?”
readonlyoptionalsubtitle?:string
A line under the heading — context for the whole message.
readonlytext:string
The notification line, and the fallback body. Always sent.
ChannelPayload
Section titled “ChannelPayload”A rendered request: where to POST and what to send.
Returned by renderChannelPayload, which is PURE — the branch a channel takes over its own options is testable without a network, and on Slack that branch is the difference between a delivered message and a 400.
Properties
Section titled “Properties”
readonlybody:Record<string,unknown>
The JSON body.
headers?
Section titled “headers?”
readonlyoptionalheaders?:Readonly<Record<string,string>>
Headers beyond Content-Type: application/json.
readonlyurl:string
Absolute URL to POST to.
ChannelSection
Section titled “ChannelSection”One block of a message: a titled chunk, optionally linked, with prose and bullets under it.
Everything is optional because a channel renders what it was given rather
than demanding a shape — a section with only body is a paragraph, one with
only title and url is a link.
Properties
Section titled “Properties”
readonlyoptionalbody?:string
The prose.
bullets?
Section titled “bullets?”
readonlyoptionalbullets?: readonlystring[]
Bullet points under the prose.
subtitle?
Section titled “subtitle?”
readonlyoptionalsubtitle?:string
A line under the title — a source, a byline, a timestamp.
title?
Section titled “title?”
readonlyoptionaltitle?:string
The section’s headline. Rendered as a link when url is set.
readonlyoptionalurl?:string
Where title points.
SlackChannelOptions
Section titled “SlackChannelOptions”What slackChannel takes.
No credential is read from the environment, and that is a deliberate difference from a provider descriptor. A webhook URL IS the credential — anyone holding it can post — and the destination is usually per-run rather than per-deploy: one deployed agent posts to whichever workspace each run names. So it is passed in, and the guard for it (isSlackWebhookUrl) is published so the check can happen at the form’s edge.
Properties
Section titled “Properties”textParam?
Section titled “textParam?”
readonlyoptionaltextParam?:string
The workflow variable the message text is sent as — WORKFLOW TRIGGER URLs only, where it must match a variable that workflow declares. Ignored by an incoming webhook, which takes Block Kit.
Default Value
Section titled “Default Value”"text"
webhookUrl
Section titled “webhookUrl”
readonlywebhookUrl:string
An incoming webhook (hooks.slack.com/services/…) or a workflow trigger
(hooks.slack.com/triggers/…). Validate it with
isSlackWebhookUrl wherever it is accepted from a person.
Type Aliases
Section titled “Type Aliases”Channel
Section titled “Channel”Channel =
ChannelDescriptor<string,Record<string,unknown>> &object
Any channel descriptor — what sendToChannel takes.
The __surface property is a compile-time tag, so a PROVIDER descriptor
cannot be handed to a channel operation and vice versa. It is optional and
never present at runtime, so a plain { kind, options } object parsed off
the wire stays assignable — the same trick ProviderDescriptor’s __stage
plays for the four pipeline stages.
Type Declaration
Section titled “Type Declaration”__surface?
Section titled “__surface?”
readonlyoptional__surface?:"channel"
Compile-time surface tag; never present at runtime.
SlackChannel
Section titled “SlackChannel”SlackChannel =
Channel&object
A Slack channel descriptor, as returned by slackChannel.
Type Declaration
Section titled “Type Declaration”
readonlykind: typeofSLACK_CHANNEL_KIND
options
Section titled “options”
readonlyoptions:SlackChannelOptions&Record<string,unknown>
Variables
Section titled “Variables”CHANNEL_POST_TIMEOUT_MS
Section titled “CHANNEL_POST_TIMEOUT_MS”
constCHANNEL_POST_TIMEOUT_MS:number
A platform is not slow. A post that has not answered in 30s is not going to, and a step holding a socket open past that is a step nobody can cancel.
SLACK_CHANNEL_HANDLER
Section titled “SLACK_CHANNEL_HANDLER”
constSLACK_CHANNEL_HANDLER:ChannelHandler
Slack as a ChannelHandler — what sendToChannel dispatches to for a
"slack" descriptor.
Exported so a host that assembles its own channel set can name it, and so
this module is a complete unit: everything the send path needs to handle
Slack is here, and send.ts imports this one value rather than four
functions and an options type.
SLACK_CHANNEL_KIND
Section titled “SLACK_CHANNEL_KIND”
constSLACK_CHANNEL_KIND:"slack"="slack"
The kind tag on a Slack channel descriptor.