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¶
/topicslists 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 agendaand 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 7fetches any proposal by number, buttons included, whenever it was opened. /minutessends the write-up as a file;/minutes decisionsreturns the decisions as text;/concludeends 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.