# Channels

> Let people use zidane from Slack threads and DMs and from GitHub comments, through zidane-server or the `zidane-daemon` program.

Channels connect zidane to Slack and GitHub. A message in a Slack thread, a Slack DM or a GitHub comment becomes a prompt in a zidane session. The reply and the tool calls come back to the same place, and owners get the approvals.

Channels run inside [zidane-server](https://zidane.si/docs/server.md) or the `zidane-daemon` program ([daemon](https://zidane.si/docs/daemon.md)). `zidane daemon`, which the terminal app starts, does not run them. The desktop app starts `zidane daemon` too when the `zidane` command is installed. To use channels on your machine, start `zidane-daemon` yourself before you open an app ([run it yourself](https://zidane.si/docs/daemon.md#run-it-yourself)). They talk to the host over the same protocol a window uses. Channel sessions are ordinary sessions, so they show in every window's session list.

> **Warning.** Channels are experimental. The config, the `zidane/channels` API and the behavior can change in any release. CI has no live Slack or GitHub tests, so run the smoke checklist in [CHANNELS.md](https://github.com/Tahul/zidane/blob/main/docs/CHANNELS.md#manual-smoke-checklist) before you rely on a setup.

| Host | Slack (Socket Mode) | Slack (Events API) | GitHub | Members |
| - | - | - | - | - |
| zidane-server | Yes | Yes | Yes | Yes |
| zidane-daemon | Yes | No | No | No, owners only |

## Turn channels on

Channels are on when the host's data dir holds a channels config, and off otherwise. There is no setting.

| Host | Config file |
| - | - |
| zidane-server | `channels.json` or `channels.config.ts` in `<dir>/.zidane` when the data dir is `<dir>` (`--data-dir` or `ZIDANE_SERVER_DATA_DIR`, `/data/.zidane` in the image), else zidane's config folder ([data dir](https://zidane.si/docs/server-config.md#data-dir)) |
| zidane-daemon | The same two names in zidane's config folder: `~/.zidane` on macOS, `~/.config/zidane` on a fresh Linux install ([folders](https://zidane.si/docs/files.md#base-folders)) |

Keep one of the two files. When both exist, the config does not load and channels stay off.

Two switches always win over the file:

- `ZIDANE_NO_CHANNELS=1` (also `true`, `yes`, `on`) in the host's environment.
- `--no-channels` on `zidane-server start` or on `zidane-daemon`.

When channels are off, the host does not load the channels code, nothing connects to Slack or GitHub, and nothing is written.

A config that does not load never stops the host. The server prints each error with its key path, writes it to the audit log, and starts without channels. A JSON config is watched, so channels come up once you fix and save it. A TS config needs `SIGHUP` (`kill -HUP <pid>`, or `docker kill --signal HUP <container>`) or a restart. `channels reload` sends nothing while no channel runs.

## Write a config

A JSON config for `zidane-daemon`, which answers your own Slack DMs:

```json title="channels.json"
{
  "channels": {
    "me": {
      "provider": "slack",
      "team": "T0123ABCD",
      "botToken": { "env": "SLACK_BOT_TOKEN" },
      "appToken": { "env": "SLACK_APP_TOKEN" },
      "owners": ["U0123ABCD"],
      "bindings": [
        { "match": { "dm": true }, "folder": "/home/me/code/app" }
      ]
    }
  }
}
```

A TS config for zidane-server, with a Slack channel that has members and a GitHub repository. `defineChannels` only adds types:

```ts title="channels.config.ts"
import { defineChannels } from 'zidane/channels'

export default defineChannels({
  defaults: { limits: { concurrentRuns: 2, runsPerMemberPerHour: 10 } },
  channels: {
    ops: {
      provider: 'slack',
      team: 'T0123ABCD',
      botToken: { env: 'SLACK_BOT_TOKEN' },
      appToken: { env: 'SLACK_APP_TOKEN' },
      owners: ['U0OWNER01'],
      members: ['U0DEV0001', 'U0DEV0002'],
      access: { member: 'contained' },
      bindings: [{ match: { channel: 'C0123ABCD' }, folder: '/data/workspaces/app' }],
    },
    app: {
      provider: 'github',
      appId: 123456,
      privateKey: { env: 'GITHUB_APP_PRIVATE_KEY' },
      webhookSecret: { env: 'GITHUB_WEBHOOK_SECRET' },
      owners: ['octocat'],
      bindings: [{ match: { repo: 'acme/app' }, folder: '/data/workspaces/app' }],
    },
  },
})
```

The loader checks the whole file and reports every problem with its key path (`channels.ops.bindings[0].folder: must be an absolute path`). An unknown key is an error. JSON and TS go through the same checks. Only TS can hold functions.

| Key | Default | Meaning |
| - | - | - |
| `provider` | required | `'slack'` or `'github'` |
| `owners` | required | Slack member ids (`U…` or `W…`) or GitHub logins. Owners approve, steer and use [commands](#owner-commands). |
| `members` | none | People who can talk to the agent with member access. A layer must give them a level. |
| `access` | | Access by role: `{ owner?, member? }` ([access levels](#access-levels)) |
| `limits` | 2 runs at once, 10 runs per member per hour | `concurrentRuns` and `runsPerMemberPerHour`. Owners are never held. |
| `bindings` | required | Where conversations go: `match` (a Slack `channel`, `{ dm: true }`, or a GitHub `repo`) and an absolute `folder` |
| `bindings[].environment` | `'worktree'` | `worktree` gives each conversation its own git worktree on `zidane/<provider>-<8 hex>`. `local` uses the folder itself. |
| `enabled` | `true` | `false` keeps the channel in the file but off |

On zidane-server, a binding's folder must be inside the [workspace roots](https://zidane.si/docs/server-config.md). Every key, the Slack and GitHub ones included, is in the [config reference](https://github.com/Tahul/zidane/blob/main/docs/CHANNELS.md#config-reference).

### Secrets

A secret is written in the file (`"botToken": "xoxb-…"`) or named by an environment variable (`"botToken": { "env": "SLACK_BOT_TOKEN" }`). Prefer `{ env }`. With inline secrets, keep the file mode 0600. `channels check` warns otherwise.

As soon as the file parses, the host removes every variable it names from its own environment, so the agents' shells never inherit them. The host reads them from a copy taken at start, so a changed variable needs a restart. On `zidane-daemon`, the variables must be in the daemon's own environment.

### Access levels

| Level | Approvals | Budget per run | Agent |
| - | - | - | - |
| `full` | the host's approval mode | none | the binding's |
| `contained` | an owner answers | $2, 15 min | the binding's |
| `readonly` | an owner answers | $1, 10 min | `plan` (read-only) |

Owners start from `full`. Members have no level until a layer gives them one. Access folds three layers, outermost first: `defaults.access`, the channel's `access`, then the binding's `access`. Each layer sets `owner` and `member` apart. The most specific layer wins. A policy object (`{ extends: 'contained', approvalTimeout: 30 }`) changes some rules of a level.

Every level hides the host's secrets from the read tools (`read_file`, `list_files`, `glob`, `grep`, `skills_read`). A session keeps the rules it was created with and only ever tightens them.

## Slack setup

## 1. Create the app

Create a Slack app from a manifest. The full Socket Mode manifest, and the Events API variant, are in [CHANNELS.md](https://github.com/Tahul/zidane/blob/main/docs/CHANNELS.md#slack-setup). The Events API needs zidane-server: Slack posts to `https://<your server>/channels/<name>/events` and `/channels/<name>/interactions`.

## 2. Get the tokens

Install the app to the workspace and copy the bot token (`xoxb-…`). For Socket Mode, create an app-level token with the `connections:write` scope (`xapp-…`). For the Events API, copy the signing secret.

## 3. Find the ids

You need the workspace id (`T…`), each person's member id (`U…`) and each channel id (`C…`, or `G…` for a private one). Invite the bot to each bound channel (`/invite @zidane`).

## 4. Start the host

Write the config, set the variables, run `zidane-server channels check`, then start or reload the host. `zidane-server channels status` shows the channel `connected`.

Do not register a `/zidane` slash command. Commands are ordinary messages.

### What people see in Slack

Conversations are threads. In a bound channel, a message that mentions the bot starts one. Replies in its thread continue it. In a DM, every message starts or continues one. Each conversation has its own session.

- An owner's message is a prompt, mention or not. While a run is live, it steers that run.
- A member's message is a prompt when it mentions the bot or comes from the person who started the thread. It waits behind a live run.
- Any other member message in the thread is kept as context for the next run.
- A stranger's message is ignored.

Each run is one streamed reply in the thread, with tool calls and todos as task cards and a summary line at the end. Reactions on the person's message follow its state: `:hourglass_flowing_sand:` queued, `:eyes:` picked up, `:white_check_mark:` done, `:x:` failed, `:octagonal_sign:` stopped.

Approvals go to each owner's DM, with buttons to accept once, accept for the session or deny. Only an owner's click counts. Questions (`ask_user`) are asked in the thread, and an owner or the thread's starter can answer. Slack's stop button stops the run when an owner clicks it.

## GitHub setup

GitHub needs a public webhook, so it runs on zidane-server only.

## 1. Create a GitHub App

Set the webhook URL to `https://<your server>/channels/<name>/webhook` and a webhook secret. Give it read and write on Issues, Pull requests and Contents, and read on Metadata. Subscribe to Issue comment and Pull request review comment.

## 2. Add the key

Generate a private key. Put it in a variable (`GITHUB_APP_PRIVATE_KEY="$(cat app.pem)"`) or inline in a 0600 file.

## 3. Install and clone

Install the App on the repositories. Clone each repository into its binding's folder, inside the server's workspace roots, and keep it fetched.

## 4. Start the server

Write the config, run `zidane-server channels check`, then start or reload the server. At start the channel checks the key with GitHub.

Only a comment that mentions the bot (`@<slug>` or `@<slug>[bot]`) or starts with `/zidane ` reaches zidane. A mention on an issue or pull request starts a session, or continues that issue's session. One progress comment per run is edited in place. When a run ends well with new commits, zidane pushes the branch and opens or updates a pull request. GitHub has no buttons, so an owner answers an approval with a `/zidane approve` or `/zidane deny` comment.

## Owner commands

A message that is exactly one of these is a command, not a prompt. Only owners can use them.

| Command | Does |
| - | - |
| `/zidane stop` | Stops the conversation's live run |
| `/zidane compact` | Compacts the session |
| `/zidane fork` | Forks the session at its last turn |
| `/zidane model <id>` | Sets the model (`provider/model`, or a bare model id that one provider offers) |
| `/zidane approve` | Accepts the session's oldest pending approval, once |
| `/zidane deny` | Denies it |

In Slack, start the message with a space (` /zidane stop`), so Slack sends it as a message and not as a slash command.

## Commands

`zidane server …` and `zidane-server …` are the same program.

| Command | Does |
| - | - |
| `zidane-server channels check` | Checks the config offline, secrets included, and prints what each role gets. Exits 1 on errors. |
| `zidane-server channels status` | Lists the running server's channels: name, provider, state, since, detail |
| `zidane-server channels reload` | Makes the running server read its config again |
| `zidane-server start --no-channels` | Never starts channels, as `ZIDANE_NO_CHANNELS=1` |

`--data-dir <dir>` sets the storage root, as on `start`. The config is then read from `<dir>/.zidane`. `status` and `reload` take `--socket <path>` when the server's socket is not the default.

`reload` sends `SIGHUP` to the server, so run it where the server runs (`docker exec` in a container). On a server that started with channels, `SIGHUP` reloads them and does not stop the server. `reload` sends nothing when no channel runs. A server that started without channels exits on `SIGHUP`, so restart it. If its config did not load, fix it and send `SIGHUP` yourself.

`zidane-daemon` has no channels commands. Send it `SIGHUP` to reload a TS config, or restart it. Its config errors go to stderr. Config warnings and audit records need `--verbose`.

## Operations

| Topic | What happens |
| - | - |
| Intake store | `channels/intake.db` in the same folder as the config records every message received, what became of it, and which session each conversation maps to. Rows are kept for 7 days. A provider's redelivery is dropped. |
| Restarts | The runner sends again each message no run answered, and shows pending approvals and questions again. With [crash recovery](https://zidane.si/docs/durability.md) `on`, a channel run continues after a crash in the same Slack reply. With `resume-ask` (the default), it continues there once someone answers the resume question. |
| Limits | A member's prompt waits while the channel has `concurrentRuns` runs, or when the member started `runsPerMemberPerHour` runs in the last hour. A run waiting on an approval or an answer does not count against `concurrentRuns`. |
| Webhook load | On zidane-server, more than 300 requests a minute under `/channels/` from one client address get `429` |
| Audit | The server's audit log gets `channels.start`, `channels.reload`, `channels.config.error`, `channels.stop`, and one `channel` record per runner event, by provider id, never message text |
| Shutdown | The runner stops taking messages first, then the host stops its runs |

## Next

- [CHANNELS.md](https://github.com/Tahul/zidane/blob/main/docs/CHANNELS.md) for the full config, the security model, known limitations and the smoke checklist
- [`zidane/channels`](https://zidane.si/docs/reference/channels.md) for the API
- [Server security](https://zidane.si/docs/server-security.md) for tokens and the audit log
