Channels: where turns come in

A channel is an agent's inbound edge — a signed webhook or fetch handler in agent/channels/*.ts that verifies the platform, resolves who is speaking, runs a turn, and posts the reply back.

The shape#

Each file in agent/channels/*.ts default-exports either a Channel or a factory (env) => Channel. Use the factory on Cloudflare: secrets only exist in env inside an invocation, never at module scope, so the host calls the factory with its env (process.env on native, the worker/DO bindings on edge).

// agent/channels/slack.ts
import { slackChannel } from "@junejs/core/channels";

export default (env: { SLACK_SIGNING_SECRET: string; SLACK_BOT_TOKEN: string }) =>
  slackChannel({
    signingSecret: env.SLACK_SIGNING_SECRET,
    botToken: env.SLACK_BOT_TOKEN,
    stream: true,
    status: "is thinking…",
  });

Channels are pure transport. A webhook channel owns a path (/channels/slack, /channels/crisp by default), verifies the signature, ACKs fast, and runs the turn in the background (kept alive with ctx.waitUntil on the edge). A channel can also contribute tools — they are merged into the agent's tool list, and a duplicate tool name fails assembly.

Identity: from platform sender to ctx.user#

The platform's claimed sender lands on event.user and is untrusted. A resolveIdentity hook maps verified evidence to your own Principal; the result is pinned on event.principal before any observer or turn runs, flows to ToolContext.principal, and reaches a defineAction as ctx.user — the same field a UI POST or /mcp call carries. Tools marked requiresPrincipal stay hidden on anonymous turns.

  • Slack — sender ids arrive inside the signature-verified payload, so there is no extra fetch. The resolver gets { userId, teamId, senderTeamId, channelId, threadId, kind, ts }. teamId is the envelope workspace, not proof of membership (Slack Connect users arrive under it too): before granting staff roles, check an allowlist or users.info.
  • Crisp — webhook user fields are client-writable hints, so the channel pulls the conversation's identity-verification evidence over authenticated REST — one GET per normalized event, and only when resolveIdentity is configured; without it no lookup runs and turns stay anonymous. Only sdk/api email verifications count as verified; a failed lookup arrives as { fetched: false, verified: false }.
slackChannel({
  signingSecret, botToken,
  resolveIdentity: ({ userId }) => (userId && STAFF.has(userId) ? { id: userId, role: "staff" } : null),
});

Both are fail-closed: a throwing resolver is reported to onError and the turn runs anonymous.

httpChannel#

A generic web endpoint: POST to path (default /message) with { message, session? } and get { text } back. Pass mcp (your app's MCP handler) to also serve /mcp from the same channel.

import { httpChannel } from "@junejs/core/channels";

export default httpChannel({ path: "/message" });
curl -X POST https://<host>/message -d '{"message":"hello","session":"s1"}'
# → {"text":"…"}

slackChannel#

Required: signingSecret (verifies v0=HMAC-SHA256 plus a ±5-minute replay window) and botToken (xoxb-…). Point both Event Subscriptions and Interactivity Request URLs at https://<host>/channels/slack; the channel answers url_verification itself.

Rendering. By default the reply is posted once. stream: true streams it into one message via chat.startStream / appendStream / stopStream (falls back to post-once when the host has no runStream). status: "is thinking…" shows Slack's presence line under the composer while the turn runs (needs the Agents & AI Apps feature; fails harmlessly elsewhere).

Pre-tool text. With stream: true, text a model writes before calling a tool ("Let me search the docs…") streams into the answer. Set intermediateText: "status" to render it as progress instead — a task-timeline entry when tasks is on, else the status line — so only the final step's text is the answer. The trade-off: each step is held until it ends, so the answer appears whole rather than token by token.

Which events do what.

slackChannel({
  signingSecret, botToken,
  botUserId: env.SLACK_BOT_USER_ID,         // loop guard for the bot's own reactions
  respondTo: ["app_mention"],               // these kinds run a turn + reply
  on: {                                     // typed per-kind observers, no LLM
    reaction_added: (e, ctx) => record(e),
    reaction_removed: (e, ctx) => record(e),
  },
  onEvent: ({ raw, event }, ctx) => mirror(raw), // every verified event_callback
});
  • events — kinds that normalize: "message" | "app_mention" | "reaction_added" | "reaction_removed". Defaults to ["message", "app_mention"], or is derived from respondTo + on keys when you set those.

  • respondTo — which of events drive a turn (default: all of them).

  • respondWhen(event, raw) — per event, whether a respondTo kind gets a turn; the ones it turns down still reach on / onEvent. It is never called for an event accept rejects or for a redelivery. To answer mentions and DMs without answering every channel message:

    respondTo: ["app_mention", "message"],
    respondWhen: (e) => e.kind === "app_mention" || e.channelType === "im",
    

    event.channelType is "channel" | "group" | "im" | "mpim" | "unknown", from Slack's channel_type. Only message events carry one, so a mention in a channel reads "unknown", never "channel". Any event in a D… channel reads "im". DMs also need the im:history scope and the message.im subscription.

  • mode: "observe" — never run a turn or post; only on / onEvent fire.

  • Reaction turns carry no text: the user text is a synthesized note and the target rides on event.reaction.

  • accept(raw) gates a verified event before any work; onRejected reports deliveries turned away (bad_signature, malformed_body, unrouted_interaction).

Redelivery dedup. Slack retries an event with the same event_id when it doesn't see a 2xx within 3 s. The channel remembers handled ids per mount path (10 minutes, up to 5,000) and ACKs a repeat before anything — observers included — runs. It is per isolate, so a retry landing on another edge isolate isn't caught; dropped repeats show up in diagnose().counters.duplicates.

Approvals (HITL). When a tool calls ctx.requestInput({ id, prompt }), the turn parks and the channel posts the prompt with Approve / Deny buttons. The click arrives on the same endpoint (Interactivity must be on), and the clicker's verified Slack id resumes the turn — checked against the request's answerers. Because Slack attests every event, an approval with no answerers defaults to the user who triggered it; a { policy } answerer is decided by the agent's authorizeAnswer hook. A rejected click leaves the buttons in place and tells the clicker ephemerally. approvalConfirm: true adds Slack's confirmation dialog. Works with and without stream.

Agent tools. Mounting the channel gives the agent four tools. Each defaults its target from the current Slack event, so the model can call them with no arguments:

tool Slack API scope
slack_read_thread conversations.replies channels:history / groups:history
slack_list_reactions reactions.get reactions:read
slack_resolve_user users.info users:read
slack_add_reaction reactions.add reactions:write

Also available: feedback / onFeedback (native 👍/👎 on streamed replies), tasks (tool calls as a task timeline), replaceInFlight (a new message supersedes the thread's running turn), onInteraction (your own buttons on the same endpoint), and diagnose() — checks the token, the granted scopes against the enabled features, and per-kind delivery counters.

crispChannel#

// agent/channels/crisp.ts
import { crispChannel } from "@junejs/core/channels";

export default (env: { CRISP_SIGNATURE_SECRET: string; CRISP_IDENTIFIER: string; CRISP_KEY: string }) =>
  crispChannel({
    signingSecret: env.CRISP_SIGNATURE_SECRET, // plugin hooks; or auth: { type: "urlKey", key } for website hooks
    identifier: env.CRISP_IDENTIFIER,
    key: env.CRISP_KEY,
    replyAs: "note",                           // supervised rollout: replies go to operators only
  });
  • Auth: exactly one of signingSecret (plugin hooks, HMAC over [{ts};{body}] + replay guard) or auth ({ type: "signature", secret } or { type: "urlKey", key, param? } for unsigned website hooks). Neither, or both, throws at construction.
  • tier — "plugin" (default) or "website", sent as X-Crisp-Tier.
  • events / respondTo / on / onEvent / mode / accept work as in Slack, over "message" | "message_changed" | "state_changed" | "rating". Default: visitor text messages only. Which events arrive is set by the hook checkboxes in Crisp, not here.
  • Tools: crisp_read_conversation and crisp_send_note.

Private notes are Crisp messages of type note — only human operators see them. Three ways to write one: the agent calls crisp_send_note mid-turn; replyAs: "note" sends every reply as a note; or your code calls channel.post(target, { text, note: true }). All of it fails closed, so operator-only text never leaks to the visitor by accident: a replyAs other than "message" / "note" throws at construction, a non-boolean note throws on post, and Slack's post rejects note: true outright since Slack has no private notes. Model-supplied website/session ids are encoded as single path segments, and . / .. are rejected.

Custom channels#

defineChannel (from @junejs/core/agent-config) types a hand-rolled channel. Don't re-implement the crypto — the security primitives are exported from @junejs/core/channels: verifySlackSignature, verifyCrispSignature, verifyCrispUrlKey, normalizeSlackEvent, normalizeCrispEvent, tryParseJson, timestampFresh.

// agent/channels/slack-lite.ts
import { defineChannel } from "@junejs/core/agent-config";
import { normalizeSlackEvent, tryParseJson, verifySlackSignature } from "@junejs/core/channels";

type Payload = { type?: string; challenge?: string; team_id?: string; event?: Parameters<typeof normalizeSlackEvent>[0] };

export default (env: { SLACK_SIGNING_SECRET: string }) =>
  defineChannel({
    name: "slack",
    path: "/channels/slack-lite",
    async webhook(req, ctx) {
      const body = await req.text(); // the raw body, exactly as received
      const ok = await verifySlackSignature(
        env.SLACK_SIGNING_SECRET,
        req.headers.get("x-slack-request-timestamp") ?? "",
        body,
        req.headers.get("x-slack-signature") ?? "",
      );
      if (!ok) return new Response("bad signature", { status: 401 });
      const payload = tryParseJson<Payload>(body);
      if (!payload) return new Response("", { status: 200 }); // signed but unparseable: ACK, don't retry
      if (payload.type === "url_verification") return Response.json({ challenge: payload.challenge });
      const norm = normalizeSlackEvent(payload.event ?? {}, ["app_mention"], undefined, payload.team_id);
      if (norm) {
        const work = ctx
          .run(norm.userText, { session: norm.session, event: norm.event })
          .then((reply) => postReply(norm.event, reply)) // your outbound call
          .catch(console.error);
        ctx.waitUntil?.(work); // keep the isolate alive past the ACK on the edge
      }
      return new Response("", { status: 200 });
    },
  });

Per-surface policy#

The channel carries no behavior. How the agent acts when reached over Slack lives at the agent level, keyed by event.source (the channel name — "slack", "crisp"): an instructions.slack.md variant next to instructions.md, plus mechanics in agent.ts:

// agent/agent.ts
export default {
  name: "support",
  surfaces: {
    slack: { mode: "append" },               // or "replace": the variant is the whole system prompt
    crisp: { denyTools: ["gdrive__delete_file"] }, // unlisted AND undispatchable on this surface
  },
};

A mode without a matching instructions.<source>.md throws. The old channels/<source>.md overlay still loads, with a deprecation warning. See the agent directory.

Why it matters#

A channel is the only code that touches the platform, and it does the parts that are easy to get wrong for you: signature and replay checks, fast ACKs, retry dedup, and identity decided from verified evidence rather than payload claims. Everything past the webhook is one agent, one tool registry, and one ctx.user check, whichever platform the turn came from.