# Flue channels

> Discover channel modules, mount verified webhook routes under `/channels/:name`, and dispatch inbound events to agents.

- Repository: withastro/flue-with-vercel-eve
- GitHub: https://github.com/withastro/flue
- Human docs: https://www.grok-wiki.com/public/docs/withastro-flue-with-vercel-eve-f4b79875fff6
- Complete Markdown: https://www.grok-wiki.com/public/docs/withastro-flue-with-vercel-eve-f4b79875fff6/llms-full.txt

## Source Files

- `withastro-flue:examples/slack-channel/src/channels/slack.ts`
- `withastro-flue:examples/github-channel/src/channels/github.ts`
- `withastro-flue:packages/slack/src/index.ts`
- `withastro-flue:packages/github/src/index.ts`
- `withastro-flue:packages/runtime/src/runtime/flue-app.ts`
- `withastro-flue:blueprints/channel.md`
- `withastro-flue:packages/cli/bin/flue.ts`

---

---
title: "Flue channels"
description: "Discover channel modules, mount verified webhook routes under `/channels/:name`, and dispatch inbound events to agents."
---

Flue discovers `channels/<name>.ts` modules at build time, registers each module's declared HTTP handlers under `/channels/<name><suffix>`, and routes verified provider webhooks through application callbacks that can call `dispatch(...)` to deliver JSON input into continuing agent sessions.

## Discovery and routing

During `flue build` and `flue dev`, the CLI scans `<source-root>/channels/` for `*.ts`, `*.mts`, `*.js`, or `*.mjs` files. Each basename becomes an immutable channel namespace. Channel names must be non-empty and cannot contain `:`.

:::files
<source-root>/
├── agents/
├── workflows/
└── channels/
    ├── slack.ts      → /channels/slack/*
    └── github.ts     → /channels/github/*
:::

The source root resolves from `flue.config.*` (see [Flue project layout](/flue-project-layout)): prefer `<project>/.flue/`, then `<project>/src/`, then `<project>/`.

At runtime, `flue()` mounts channel traffic on two patterns:

| Pattern | Behavior |
| --- | --- |
| `* /channels/:name/:suffix` | Match a discovered handler by HTTP method and route suffix |
| `* /channels/:name` | Always `404` — the namespace itself is not an endpoint |

The generated entry normalizes each module's named `channel` export into `channelHandlers[name]`, keyed as `"<METHOD> <suffix>"` (for example `"POST /events"`). Handler lookup is exact: wrong suffix → `404`; wrong method with a matching suffix → `405` with an `Allow` header derived from declared routes.

<Note>
If `app.ts` mounts `flue()` under a prefix, channel URLs receive the same prefix as agents and workflows. Prefixing applies to all Flue routes; one channel cannot be relocated independently.
</Note>

```ts title="src/app.ts"
import { flue } from '@flue/runtime/routing';
import { Hono } from 'hono';

const app = new Hono();
app.route('/api', flue());
export default app;
```

With this composition, a Slack Events API handler declared at `/events` is published at `/api/channels/slack/events`.

## Channel module contract

Every discovered channel file must export a named `channel` binding with a non-empty `routes` array:

```ts title="Minimal custom channel"
import type { Handler } from 'hono';

const webhook: Handler = async (c) => {
  const rawBody = await c.req.text();
  // Verify signature against rawBody before parsing.
  return c.body(null, 200);
};

export const channel = {
  routes: [{ method: 'POST', path: '/webhook', handler: webhook }],
};
```

Route validation rules enforced at build time:

| Rule | Constraint |
| --- | --- |
| `method` | Uppercase ASCII letters only (`POST`, `GET`, …) |
| `path` | Absolute suffix starting with `/`, no query or fragment, no `.` or `..` segments |
| `handler` | Callable Hono handler returning a `Response` |
| Duplicates | Same `method + path` within one channel is rejected |

Filename maps to URL namespace:

```txt
channels/acme.ts  +  /webhook  →  POST /channels/acme/webhook
channels/slack.ts +  /events   →  POST /channels/slack/events
```

Add a path comment immediately above each application handler documenting the published URL.

First-party packages (`@flue/slack`, `@flue/github`, and others) return a `channel` object that satisfies this contract. Optional protocol surfaces publish routes only when their callback is provided — omitting `events` omits `/events` entirely.

## Scaffold with the CLI

Use `flue add channel` to fetch a blueprint for a coding agent:

<CodeGroup>
```sh title="First-party channel"
flue add channel slack --print | codex
```

```sh title="Custom provider"
flue add channel https://developers.notion.com/reference/webhooks --print | codex
```
</CodeGroup>

Named channels resolve to provider-specific blueprints. A URL selects the generic channel blueprint, which guides signature verification, provider SDK setup, and route declaration without assuming a maintained `@flue/<provider>` package.

Refresh an existing implementation with `flue update channel <name|url>`.

## Ownership boundary

| Concern | Owner |
| --- | --- |
| Request authentication and signature verification | Channel package or authored handler |
| Provider handshakes (URL verification, `ping`, etc.) | Channel package or authored handler |
| Body limits, parsing, typed provider payloads | Channel package or authored handler |
| Routes beneath `/channels/<name>/...` | Flue runtime |
| Provider SDK client and outbound credentials | Application |
| OAuth, installation, token storage | Application |
| Agent tools and authorization policy | Application |
| Delivery deduplication and business persistence | Application |

Channels are inbound HTTP ingress only. Outbound API calls use the provider's established SDK, exported from the same module as application-owned code.

## Verified ingress

First-party channel constructors verify the exact unconsumed request body before invoking application callbacks.

**Slack** (`createSlackChannel`):

<ParamField body="signingSecret" type="string" required>
Secret used to verify exact request bytes. Signed timestamps must be within five minutes of the server clock.
</ParamField>

<ParamField body="bodyLimit" type="number">
Maximum request-body size in bytes. Default: 1 MiB.
</ParamField>

Optional surfaces: `events` → `POST /events`, `interactions` → `POST /interactions`, `commands` → `POST /commands`. URL verification is handled internally; authenticated Events API deliveries forward with Slack's native field names. The channel does not deduplicate Events API retries.

**GitHub** (`createGitHubChannel`):

<ParamField body="webhookSecret" type="string" required>
Secret configured on the GitHub webhook. Verified with `X-Hub-Signature-256` against exact delivered bytes.
</ParamField>

<ParamField body="bodyLimit" type="number">
Maximum request-body size in bytes. Default: 25 MiB.
</ParamField>

Single surface: `webhook` → `POST /webhook`. `ping` deliveries are answered internally. The channel is stateless and does not deduplicate `deliveryId` values — GitHub expects a `2xx` within ten seconds and does not auto-retry.

```mermaid
sequenceDiagram
    participant Provider as Provider (Slack/GitHub/…)
    participant Flue as flue() /channels/:name
    participant Pkg as Channel package
    participant App as channels/<name>.ts handler
    participant Agent as Agent session

    Provider->>Flue: POST /channels/<name>/<suffix>
    Flue->>Pkg: Route to verified handler
    Pkg->>Pkg: Verify signature, enforce limits
    alt Handshake (url_verification / ping)
        Pkg-->>Provider: Protocol response
    else Application event
        Pkg->>App: Typed callback (payload / delivery)
        App->>Agent: dispatch(agent, { id, input })
        App-->>Pkg: Response / empty 200
        Pkg-->>Provider: HTTP response
    end
```

## Handle verified events

Callbacks run only after verification and protocol handling complete. Each receives an object with the Hono context `c` plus provider-specific typed data.

```ts title="src/channels/slack.ts"
import { dispatch } from '@flue/runtime';
import { createSlackChannel } from '@flue/slack';
import assistant from '../agents/assistant.ts';

export const channel = createSlackChannel({
  signingSecret: process.env.SLACK_SIGNING_SECRET!,

  // Path: /channels/slack/events
  async events({ payload }) {
    if (payload.type !== 'event_callback') return;

    switch (payload.event.type) {
      case 'app_mention': {
        const event = payload.event;
        await dispatch(assistant, {
          id: channel.conversationKey({
            teamId: payload.team_id,
            channelId: event.channel,
            threadTs: event.thread_ts ?? event.ts,
          }),
          input: {
            type: 'slack.app_mention',
            eventId: payload.event_id,
            text: event.text,
          },
        });
        return;
      }
      default:
        return;
    }
  },
});
```

GitHub follows the same pattern with a single `webhook` callback receiving a discriminated `delivery` (`name` narrows `payload`):

```ts title="src/channels/github.ts (abridged)"
async webhook({ delivery }) {
  if (delivery.name === 'issue_comment' && delivery.payload.action === 'created') {
    const { repository, issue, comment } = delivery.payload;
    await dispatch(assistant, {
      id: channel.conversationKey({
        owner: repository.owner.login,
        repo: repository.name,
        issueNumber: issue.number,
      }),
      input: {
        type: 'github.issue_comment.created',
        deliveryId: delivery.deliveryId,
        comment: { id: comment.id, body: comment.body },
      },
    });
  }
}
```

### Return values

Channel callbacks use ordinary Hono and Fetch responses:

- Return nothing → empty `200` acknowledgement
- Return `c.json(...)`, `c.text(...)`, or a `Response` → explicit status, headers, body
- Return a JSON-compatible value (where supported) → serialized as response body

Handlers must return a `Response`; otherwise the runtime throws a `TypeError`.

## Dispatch to agents

`dispatch(...)` admits asynchronous input to a continuing agent session. It resolves after admission, not after model processing.

**Created-agent overload** — pass a default-exported `createAgent(...)` value:

```ts
await dispatch(assistant, {
  id: channel.conversationKey(thread),
  input: { type: 'slack.app_mention', eventId, text },
});
```

**Named-agent overload** — target by discovered module name:

```ts
await dispatch({
  agent: 'assistant',
  id: channel.conversationKey(thread),
  input: { type: 'slack.app_mention', eventId, text },
});
```

<ParamField body="id" type="string" required>
Target agent instance id. Often a `conversationKey(...)` for the provider thread, issue, or conversation.
</ParamField>

<ParamField body="input" type="unknown" required>
JSON-serializable payload delivered to the session. Use `null` for an intentional empty payload.
</ParamField>

<ResponseField name="dispatchId" type="string">
Generated delivery identifier. This is not a workflow `runId`.
</ResponseField>

<ResponseField name="acceptedAt" type="string">
ISO timestamp assigned when dispatch admission begins.
</ResponseField>

<Warning>
Dispatched input is an agent-session operation, not a workflow run. Do not confuse `dispatchId` with `runId` from `POST /workflows/:name`.
</Warning>

On Node, dispatch admission is durable via SQL; exact replays return the original receipt, conflicting replays throw. On Cloudflare, admission is durable to the agent Durable Object and processing may be at-least-once — design external side effects to be idempotent.

## Conversation keys

First-party channels expose `conversationKey(ref)` and `parseConversationKey(id)` for canonical, namespaced instance identifiers:

| Provider | Key format | Example ref |
| --- | --- | --- |
| Slack | `slack:v1:<teamId>:<channelId>:<threadTs>` | `{ teamId, channelId, threadTs }` |
| GitHub | `github:v1:owner:<owner>:repo:<repo>:issue:<n>` | `{ owner, repo, issueNumber }` |

Conversation keys identify destinations; they are not authorization capabilities. Bind outbound tools to parsed refs in trusted agent initialization:

```ts title="src/agents/assistant.ts"
import { createAgent } from '@flue/runtime';
import { channel, replyInThread } from '../channels/slack.ts';

export default createAgent(({ id }) => ({
  model: 'anthropic/claude-haiku-4-5',
  instructions: 'Reply in the bound Slack thread when appropriate.',
  tools: [replyInThread(channel.parseConversationKey(id))],
}));
```

Direct `POST /agents/:name/:id` routes must authorize caller-selected instance ids before deriving provider destinations from them.

## Application-owned tools

Define narrow `defineTool(...)` helpers that call the exported provider SDK client. Bind credentials and destinations in application code; expose only intentionally variable fields (message text, comment body) to the model.

```ts title="Outbound tool pattern"
import { defineTool } from '@flue/runtime';
import { WebClient } from '@slack/web-api';

export const client = new WebClient(process.env.SLACK_BOT_TOKEN);

export function replyInThread(ref: { channelId: string; threadTs: string }) {
  return defineTool({
    name: 'reply_in_slack_thread',
    description: 'Reply in the Slack thread bound to this agent.',
    parameters: {
      type: 'object',
      properties: { text: { type: 'string', minLength: 1 } },
      required: ['text'],
      additionalProperties: false,
    },
    async execute({ text }) {
      const result = await client.chat.postMessage({
        channel: ref.channelId,
        thread_ts: ref.threadTs,
        text,
      });
      return JSON.stringify({ channel: result.channel, ts: result.ts });
    },
  });
}
```

Keep credentials, raw bodies, webhook response URLs, interaction tokens, and other short-lived capabilities out of dispatched `input`, model context, logs, and durable session history.

## HTTP API surface

:::endpoint POST /channels/:name/:suffix
Serve a discovered channel handler after method and suffix lookup.

**Path parameters**

| Name | Description |
| --- | --- |
| `name` | Channel basename from `channels/<name>.ts` |
| `suffix` | Route suffix declared on `channel.routes[].path` (e.g. `/events`, `/webhook`) |

**Responses**

| Status | When |
| --- | --- |
| `200` | Handler succeeds; empty body when callback returns nothing |
| `400` | Malformed provider payload or failed parsing (provider-specific) |
| `401` | Signature verification failed |
| `404` | Unknown channel, missing suffix, or unregistered route |
| `405` | Wrong HTTP method for an otherwise matching suffix |
| `413` | Body exceeds configured limit |
| `415` | Unsupported `Content-Type` (provider-specific) |

Channel routes are always mounted when a module is discovered; they do not require an HTTP `route` export like agents and workflows.
:::

List channel endpoints alongside agents and workflows at `GET /openapi.json` when mounted through `flue()`. See [Flue HTTP API reference](/flue-http-api-reference) for the full route inventory and error envelopes.

## Retries and idempotency

Channel packages are stateless and do not deduplicate provider deliveries. Providers may retry, duplicate, or reorder events.

Preserve provider delivery or event ids in `input` when useful for tracing. When duplicate admission is unacceptable, claim that id in application-owned durable storage before dispatching or performing external effects.

Handlers await application work such as `dispatch(...)` before acknowledging. Some providers impose response deadlines; a timed-out operation may still complete later, so timeouts do not replace idempotency.

## Node and Cloudflare

First-party channel packages use Fetch and Web Crypto and are tested on Node and workerd. Cloudflare builds enable `nodejs_compat`.

The outbound SDK remains application-owned. Validate that SDK operations your application depends on work on the configured target; provider blueprints select cross-runtime clients where possible.

Long-lived sockets, polling loops, and provider-managed background transports are outside the current channel model. Use verified HTTP delivery or keep those integrations in application-owned infrastructure.

## Verify locally

<Steps>
<Step title="Build the configured target">

```sh
flue build --target node
# or
flue build --target cloudflare
```

</Step>

<Step title="Start the dev server">

```sh
flue dev --target node
```

Confirm discovered channels appear in the build manifest and respond at the expected `/channels/<name>/<suffix>` paths.

</Step>

<Step title="Exercise webhook signatures">

Create representative payloads with valid and invalid signatures. Confirm:

- Correct route returns expected status and body
- Invalid signature returns `401` (or provider-equivalent rejection)
- Wrong HTTP method returns `405` with allowed methods
- Protocol handshakes (Slack URL verification, GitHub `ping`) succeed without reaching application handlers

</Step>

<Step title="Trace dispatch admission">

Dispatch from a handler and confirm the target agent session receives input. On Node, inspect the returned `dispatchId` and agent event stream coordinates from `POST /agents/:name/:id` or `flue connect`.

</Step>
</Steps>

<Warning>
Avoid contacting a live provider during routine verification unless explicitly testing an integration end-to-end.
</Warning>

## Troubleshooting

| Symptom | Likely cause |
| --- | --- |
| `404` on `/channels/<name>` | Namespace-only URL — append the route suffix (`/events`, `/webhook`, …) |
| `404` on `/channels/<name>/events` | Optional callback omitted — enable `events` (or equivalent) in the channel constructor |
| `405` on correct path | HTTP method mismatch — check `channel.routes[].method` |
| `[flue] dispatch() called before runtime was configured` | `dispatch()` invoked outside a Flue-built server entry |
| `[flue] dispatch() target agent "…" is not registered` | Agent module missing, or target name does not match discovered basename |
| `[flue] Channel "…" must export…` | Missing or malformed named `channel` export — rebuild after fixing `routes` |
| Signature failures in production | Clock skew (Slack five-minute window), wrong secret, or body consumed before verification |

## Related pages

<CardGroup>
<Card title="Flue project layout" href="/flue-project-layout">
Source-root resolution, `channels/` discovery boundaries, and `app.ts` composition.
</Card>
<Card title="Build Flue agents" href="/build-flue-agents">
Author agents that receive dispatched channel input and bind provider tools.
</Card>
<Card title="Flue HTTP API reference" href="/flue-http-api-reference">
Full mounted route inventory, admission semantics, and error envelopes.
</Card>
<Card title="Flue examples" href="/flue-examples">
Copy-pasteable channel ingress fixtures for Slack, GitHub, and other providers.
</Card>
<Card title="Runtime models" href="/runtime-models">
How dispatch receipts differ from workflow runs and Eve session turns.
</Card>
<Card title="Flue CLI reference" href="/flue-cli-reference">
`flue add channel`, `flue update channel`, and dev-server flags.
</Card>
</CardGroup>
