Skip to article
Browse chapters

Channels, the gateway and schedules

Put a session in a chat thread, keep one resident bingo per data directory, and defer or repeat a turn.

Channels

A chat platform is a surface like any other (ADR-0016). bingo-channels holds one deliverer and a set of adapters; each adapter hands over the mechanisms it actually implements — editing a message, buttons, a typing indicator, threads — so a capability can never be advertised by an adapter whose renderer does not draw it.

Two adapters exist today: loopback, an in-process adapter with configurable limits that is the contract every behaviour is proven against, and feishu, for a self-built (企业自建) Feishu app. The long connection Feishu offers is not available to marketplace apps.

Listening

bingo channels                   # listen on the configured channels and nothing else
bingo --channels loopback        # listen beside an ordinary run
bingo --channels loopback=127.0.0.1:7777

--channels is repeatable, and the channels setting says the same thing.

Configuring one

The app’s name is settings; what it signs with is not.

{
  "channels": {
    "feishu": { "appId": "cli_xxx" }
  }
}

The secret comes from BINGO_FEISHU_APP_SECRET (with BINGO_FEISHU_APP_ID for the id), or from the credential store for a gateway that inherits no shell. Two commands write them:

bingo channels add feishu      # asks for the app id and the secret together
bingo channels secret feishu   # pastes a secret on its own, for rotation

What a conversation becomes

A private chat is a session. A group engages only when the bot is mentioned. A thread continues its chat’s session. Session keys are <adapter>/<chat>[/<thread>], minted by this plugin and nobody else, and Origin.principal is the platform’s own user id, stamped by the adapter and never read out of the message text.

A streaming answer is redrawn with the whole accumulated text rather than a delta, coalesced on a sentence boundary, then on a count of new characters, then on a timer — with one pending snapshot per conversation, so a stale redraw can never overwrite the final text.

A permission question renders as native buttons where the adapter has them, and otherwise as a numbered list you answer by replying with the number or the label. When the question is resolved anywhere — in the terminal, on the wire, by anyone — the delivered message is edited: the buttons are stripped and the outcome is appended (approved in the TUI). No live button outlives its question.

One process per app

Feishu’s long connection is cluster-mode and Telegram’s polling mutually evicts, so a second bingo against the same credentials would be a silent half-outage. The adapter takes a lock file per credential under the data directory and refuses loudly rather than degrading quietly.

The gateway

An inbound door has no grace period: people write at any hour, and the Feishu connection has no replay. bingo channels runs that door in the foreground and dies with the terminal, so bingo gateway runs one resident bingo per data directory and manages it like a service (ADR-0020).

bingo gateway start        # spawn it detached, wait until it is up
bingo gateway status       # whether one is running here, as what, and since when
bingo gateway logs -n 100  # the log, and where it is
bingo gateway stop         # ask it to stop, wait for its locks to come back
bingo gateway restart
bingo gateway doctor       # read settings, credentials and every lock; say what to do
bingo gateway doctor --fix # remove exactly the locks whose process is gone
bingo gateway run          # the resident process itself, in the foreground

The gateway is a whole host, not a bridge: it assembles the ordinary plugin host with the channels surface listening, and sessions, transcripts, schedules and locks are the normal ones in the normal places. Nothing is proxied.

pathwhat
~/.bingo/data/gateway/gateway.pidpid, binary version and start time
~/.bingo/data/gateway/gateway.logstdout, stderr, and the tracing sink

install keeps it alive across logins:

bingo gateway install
bingo gateway uninstall

On macOS that writes ~/Library/LaunchAgents/com.bingo.gateway.plist and loads it with launchctl; on Linux, ~/.config/systemd/user/bingo-gateway.service enabled with systemctl --user. The unit runs <current exe> gateway run and carries no secrets. While a service is installed the verbs delegate to the supervisor, so launchd and a hand-spawned process never fight over one pidfile; status and doctor name which mode is in force.

install, start and restart preflight the channels first — unparseable settings, no configured channel, or a channel that cannot sign is refused with the doctor’s own lines. A configuration that cannot run is never handed to a supervisor to crash-loop under.

A Linux user unit stops at logout unless lingering is on; doctor has a row for that and prints the loginctl enable-linger command rather than running it. Windows is out of scope.

Schedules

bingo-schedule defers and repeats turns (ADR-0019). An entry is one JSON file under ~/.bingo/data/schedules/<id>.json, hand-editable, one entry per file.

The spec is a small grammar rather than cron:

every 45s · every 30m · every 2h
daily at 09:00
once at 2026-09-01T09:00:00-07:00

Days are not a unit in every: a day is a civil thing that daylight saving makes longer or shorter, which is what daily at is for.

A fire is a turn on the schedule’s own session, keyed schedule/<id>, at the entry’s working directory. The transcript is the record — results, failures and costs land where every other turn’s do, and --resume reads them. A once at entry disables itself after firing.

The model creates and forgets them with ScheduleCreate, ScheduleList and ScheduleForget; a person reads the table with /schedule. An unattended run uses the entry’s own permission mode, and a question nobody answers is declined the way a headless run declines them — the turn continues or fails honestly.

Two honest limits. Schedules fire only while a bingo process runs — the terminal surface, bingo serve, bingo channels or the gateway. There is no daemon of their own, and /schedule says as much on its last row — schedules: held by this process, or schedules: dormant — held by pid 42. And one runner per store: the first process to take the lock file holds them, and a second runs with schedules dormant and one notice saying who has them. On start, an entry that is overdue fires once, never once per missed interval.