Skip to content

Driving a council from somewhere else

Five ways to reach a council you are not sitting in front of. SSH needs nothing extra; a browser tab, an HTTP API, a Telegram chat and a Discord server all run on top of one board. In every one of them the hard part is never the transport — it is that sign-off is enforced by identity, and a remote caller has none until you give them one.


Choosing

You want Do this
Set up a council from your laptop, on a machine that has the CLIs SSH — works today, see below
Watch a round from a phone on your own network Browser (mooting serve --web), behind an SSH tunnel
Two people in one council, or a client that is not a terminal HTTP + events (mooting serve)
A council that keeps running while nobody watches HTTP + events or Telegram — both own a supervisor
To run a council from your phone, in a group, with other people Telegram — mooting telegram
The same, where your group already talks on Discord Discord — mooting discord

Using it

SSH

The board is a plain SQLite file and nothing needs a daemon, so a shell on the machine is a complete interface:

ssh box "cd project && mooting topic new reno \
    --title 'Renovation in Leuven' --brief 'Renovation in Leuven' \
    --seats Kevin,Sam,Santa,you"
ssh box "cd project && mooting topic agenda reno \
    'what budget; which rooms; heat pump or not'"
ssh box "cd project && mooting run reno"

mooting console is the line-based session for exactly this: mooting tui wants a real terminal, console does not, and both drive the same board through the same dispatch.

What SSH does not give you: a session two people can watch at once, a phone, or anything that survives the connection dropping mid-round.

Do not put the board on OneDrive, Dropbox or an SMB share to "sync" it. SQLite's locking assumes a real filesystem; cloud-sync clients copy files out from under open handles, and the failure mode is a corrupt board, not an error. If two machines must reach one board, they need a process in front of it — see HTTP + events, below.

Browser — mooting serve --web

mooting serve --web

Runs the real session in a browser through textual-serve — the transcript, the seat panel, and the input that decides. Nothing is reimplemented.

Whoever reaches that port is the human sitting there: they can approve plans, grant execute capability, and conclude meetings. textual-serve has no authentication of its own, so bind to 127.0.0.1 and reach it through an SSH tunnel, or put it behind a reverse proxy that authenticates. A non-loopback bind is refused outright unless you also pass --allow-remote.

HTTP + events — mooting serve

pip install 'mooting[serve]'
mooting serve

One process owns the board and the supervisor; a TUI, a web page, a phone, or a script all talk to it the same way.

Every route is under /api. This table is the server's own routing table, not a plan: a path that is not here does not answer.

GET /api/topics, GET /api/topics/{slug} what exists, and its agenda
PATCH /api/topics/{slug} agenda, mode, manager, rounds, effort
POST /api/topics/{slug}/messages say something, or answer a question
POST /api/topics/{slug}/run · /stop drive the council
GET /api/proposals/{id} one proposal in full, with its votes
POST /api/proposals/{id}/decide sign-off — people's seats only
GET /api/stream the event stream, as Server-Sent Events; resumes from Last-Event-ID or ?since=, misses nothing
GET /api/events?since=N the same events by polling, for a client that cannot hold a connection

Not here: opening a topic and fetching minutes have no route. Both exist at the terminal (mooting topic new, mooting minutes) and neither has been needed remotely yet -- said plainly rather than listed as though they answered.

mooting serve --grant <seat> issues a bearer token for a human seat (never send it as a query string — it ends up in logs). Every decision carries the seat whose token made it.

Telegram — mooting telegram

A council in a Telegram chat, with buttons for sign-off and the same commands as the terminal session.

Setting it up

1. Make a bot. In Telegram, message @BotFather and send /newbot. It asks for a display name, then a username ending in bot, and replies with a token like 8123456789:AAF.... Treat it as a password.

2. Install, and seat a council.

pip install 'mooting[telegram]'
cd your-project
mooting setup

3. Start the bot. The token is needed this once; it is remembered after Telegram accepts it.

mooting telegram --token 8123456789:AAF...

Leave it running. It prints a one-time pairing code:

  menu    10 commands registered — type / in the chat to see them
  pair    send  /pair 9f2c1a  to the bot to claim the first seat

4. Pair yourself. Send that line to your bot:

/pair 9f2c1a

It replies with the seat you speak as and this chat's id.

Adding the bot to another room later. The bot answers only in rooms it knows: one named with --chat, or one where somebody has already been approved. An unknown group is ignored, and the terminal says which chat it ignored. To open a new room, print a code and send it from the account that should hold the seat:

mooting claim                 # prints:  /pair a1b2c3

Sending that in the new group binds your chat account to the seat, makes you the host of that room, and makes the room known. It is good once, for fifteen minutes. Reading it requires the machine the board lives on, which is the point: every other way of saying who owns a board -- a name passed to the command, being first to pair, creating the group -- is something anybody can produce.

In a group, turn Group Privacy off first. Telegram gives a new bot privacy mode by default, and a bot in that mode receives only commands and replies to its own messages. Every command still works, and plain talk — which is how you post to the council — never reaches it, silently. In @BotFather: /mybots → your bot → Bot Settings → Group Privacy → Turn off, then remove the bot from the group and add it again, because the setting is read when it joins. A one-to-one chat with the bot is unaffected.

5. Lock it to that room. Stop the bot, start it again with the id — no token, it is remembered:

mooting telegram --chat -1001234567890

Without --chat the bot answers anywhere it is added, which hands that room your subscriptions. The id is remembered too, so from now on it is just mooting telegram.

6. Hold a council. Type / for the commands.

/topic new should we cap webhook retries?
/topic agenda cap the retries; full or partial jitter
/run

Plain messages post as you. @Santa what does the gateway do today? puts a question to one seat and the others wait for the answer.

Once it is running

  • /topics lists every council as buttons, one per row, with the one this chat is on marked. Tap another and the room moves to it. The same list appears if you send anything while the chat is not on a topic yet, so a slug never has to be typed from memory on a phone. /topic new, /topic agenda and the rest still mean what they always did.
  • A proposal arrives with Approve / Reject buttons. The reason is the reply it asks you for, and the button carries the proposal id, so a sign-off cannot land on the wrong one.
  • That happens for proposals opened while the bot is running — the chat picks up from the board's head and never replays. /proposals 7 fetches any proposal by number, buttons included, whenever it was opened.
  • /minutes sends the write-up as a file; /minutes decisions returns the decisions as text; /conclude ends the meeting and delivers both.
  • Send anything to the chat -- a file, a photo, a voice note -- and it is attached to the topic. Text, PDF and Word go into every seat's next prompt; anything else the seats get by name and path only.
  • /topic agenda, /rounds, /effort, /seats — the same commands as the terminal session, because it is the same dispatch behind them.

Adding other people

Once you are paired, approving happens in the chat — no terminal:

them:  /pair
bot:   You are not paired here. Request 2 is waiting for a member to approve it.
you:   /pair approve 2
bot:   A Colleague now speaks as AColleague.

/pair list shows what is waiting; /pair deny <id> refuses. This part goes beyond openclaw, which approves only from the shell.

The seat is derived from their own display name rather than invented, and it is always a human seat: pairing a person onto an agent seat would let them speak as one, and put a non-human name against something only a human may do.

Approval is per chat — being trusted in one council is not being trusted in another — and mooting pair --approve <id> --seat <s> still works from the shell, which is how you recover if you lose access to the chat.

Discord — mooting discord

The same council as the Telegram chat: the same commands, the same pairing, proposals with Approve and Reject buttons. Both run on chat.ChatHost, so a flow that works in one works in the other.

Setting it up

1. Make a bot. At https://discord.com/developers/applications: New Application → Bot → Reset Token, and copy the token. On the same page turn on Message Content Intent; without it the bot cannot read what anybody types in a server.

2. Install, and start it. The token is needed this once.

pip install 'mooting[discord]'
mooting discord --token <token>

When the bot is in no server yet, it prints an invite link. Open it, pick your server, and the commands appear in Discord's own / menu there.

  signed  in as mooting#1234
  menu    22 commands registered in 0 server(s)
  invite  https://discord.com/oauth2/authorize?client_id=...
  pair    send  /pair 9f2c1a  to the bot to claim the first seat

3. Claim the first seat. Send /pair 9f2c1a in the channel you want the council in. Chosen from the / menu, the command's echo is shown only to you, so the code is not left in the channel. To keep the bot to that channel only, restart it with --channel <id>; the id is in the reply.

How it differs from Telegram

Telegram Discord
message length 4096 2000, so a long reply arrives in more pieces
buttons one per row for lists packed five across, 25 at most
giving a reason in your own words a forced reply box reply to the bot's message (long-press → Reply on a phone)
someone added to the room the person who added them decides not seen: they send /pair and the host approves
a refusal, e.g. "only the host can answer that" a pop-up a message only the presser sees

A direct message to the bot is a private room, open to an account that redeemed a code from mooting claim, as on Telegram. A Discord account and a Telegram account are separate identities: redeeming a code on one does not bind the other.


The reasoning behind all of this — why SSE and not a websocket, why HTML and not MarkdownV2, what was deliberately left unbuilt — is in Why it works this way.