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 or the zidane-daemon program (daemon). 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). 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.
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 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) |
| zidane-daemon | The same two names in zidane's config folder: ~/.zidane on macOS, ~/.config/zidane on a fresh Linux install (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(alsotrue,yes,on) in the host's environment.--no-channelsonzidane-server startor onzidane-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:
{
"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:
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. |
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) | |
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. Every key, the Slack and GitHub ones included, is in the 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#
Create the app
Create a Slack app from a manifest. The full Socket Mode manifest, and the Events API variant, are in CHANNELS.md. The Events API needs zidane-server: Slack posts to
https://<your server>/channels/<name>/eventsand/channels/<name>/interactions.Get the tokens
Install the app to the workspace and copy the bot token (
xoxb-…). For Socket Mode, create an app-level token with theconnections:writescope (xapp-…). For the Events API, copy the signing secret.Find the ids
You need the workspace id (
T…), each person's member id (U…) and each channel id (C…, orG…for a private one). Invite the bot to each bound channel (/invite @zidane).Start the host
Write the config, set the variables, run
zidane-server channels check, then start or reload the host.zidane-server channels statusshows the channelconnected.
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.
Create a GitHub App
Set the webhook URL to
https://<your server>/channels/<name>/webhookand 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.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.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.
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 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 for the full config, the security model, known limitations and the smoke checklist
zidane/channelsfor the API- Server security for tokens and the audit log