Skip to content

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.

HostSlack (Socket Mode)Slack (Events API)GitHubMembers
zidane-serverYesYesYesYes
zidane-daemonYesNoNoNo, 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.

HostConfig file
zidane-serverchannels.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-daemonThe 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 (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:

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:

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.

KeyDefaultMeaning
providerrequired'slack' or 'github'
ownersrequiredSlack member ids (U… or W…) or GitHub logins. Owners approve, steer and use commands.
membersnonePeople who can talk to the agent with member access. A layer must give them a level.
accessAccess by role: { owner?, member? } (access levels)
limits2 runs at once, 10 runs per member per hourconcurrentRuns and runsPerMemberPerHour. Owners are never held.
bindingsrequiredWhere 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.
enabledtruefalse 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#

LevelApprovalsBudget per runAgent
fullthe host's approval modenonethe binding's
containedan owner answers$2, 15 minthe binding's
readonlyan owner answers$1, 10 minplan (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. 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. The Events API needs zidane-server: Slack posts to https://<your server>/channels/<name>/events and /channels/<name>/interactions.

  2. 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. 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. 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. 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. 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. 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. 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.

CommandDoes
/zidane stopStops the conversation's live run
/zidane compactCompacts the session
/zidane forkForks the session at its last turn
/zidane model <id>Sets the model (provider/model, or a bare model id that one provider offers)
/zidane approveAccepts the session's oldest pending approval, once
/zidane denyDenies 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.

CommandDoes
zidane-server channels checkChecks the config offline, secrets included, and prints what each role gets. Exits 1 on errors.
zidane-server channels statusLists the running server's channels: name, provider, state, since, detail
zidane-server channels reloadMakes the running server read its config again
zidane-server start --no-channelsNever 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#

TopicWhat happens
Intake storechannels/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.
RestartsThe 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.
LimitsA 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 loadOn zidane-server, more than 300 requests a minute under /channels/ from one client address get 429
AuditThe 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
ShutdownThe runner stops taking messages first, then the host stops its runs

Next#