Skip to content

Sample: Chat Channels

Shows how a feature posts to a tenant’s Slack channel without ever handling the webhook URL.

Route-only targets: Chat targets belong to the tenant, not to a user. A handler addresses one with ctx.notify(type, { route: { slack: "ops" } }); "ops" is a connection name. The chat channels declare no resolve, so a normal to: userId notification skips them without a log row.

Secrets for the URL: The tenant stores the webhook URL as a secret under channel-slack:webhooks.<connection name> (namespace declared by the channel, written through secrets:write:set). The delivery.send job reads it with an audit entry (channel-slack:send), posts the message and records only the connection name and an outcome code (http_500, timeout, redirect_blocked, host_not_allowed, missing_credentials, invalid_address).

App-level hardening: Each channel only talks to its provider’s hosts, over https, without following redirects. allowedHosts, requireHttps and timeoutMs are options of createChannelSlackFeature(opts). Tenants cannot change them; the test uses them to point at a local stub.

Production boot: runProdApp, runDevApp and runWorkerApp wire ctx.notify and the tenant secrets for you, so no app wiring is needed. Queued channels run through the delivery.render/delivery.send jobs on the worker lane (all-in-one by default); a “send test message” handler can pass immediate: true to ctx.notify to deliver inline and read sent/failed from the returned deliveries.

The same shape works for channel-discord, channel-teams (connection name + webhook secret) and channel-telegram (one botToken secret, the address is the chat id).

Terminal window
bun test --config=bunfig.integration.toml --timeout=15000 samples/recipes/chat-channels

The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):

// Chat Channels Sample
//
// A handler tells the tenant's ops chat about something. Chat targets belong to
// the tenant, not to a user, so the feature addresses one by connection name via
// `route` — it never sees the webhook URL. The tenant stores that URL as a secret
// under `channel-slack:webhooks.<connection name>`.
import {
defineFeature,
defineWriteHandler,
type NotifyFn,
qn,
} from "@cosmicdrift/kumiko-framework/engine";
import * as z from "zod";
export const OPS_ANNOUNCEMENT_TYPE = qn("ops", "notify", "announcement");
export const opsFeature = defineFeature("ops", (r) => {
r.requires("delivery");
r.writeHandler(
defineWriteHandler({
name: "announce",
schema: z.object({
connection: z.string(),
title: z.string().min(1),
body: z.string().optional(),
}),
access: { roles: ["Admin"] },
handler: async (event, ctx) => {
const notify = ctx.notify as NotifyFn;
await notify(OPS_ANNOUNCEMENT_TYPE, {
route: { slack: event.payload.connection },
data: { title: event.payload.title, body: event.payload.body },
});
return { isSuccess: true, data: { announced: true } };
},
}),
);
});

📄 On GitHub: samples/recipes/chat-channels/src/feature.ts