🐾 Handlr ?

Guide β€Ί Self-hosting

The chat bot (Matrix)

A small program beside your box that reminds you in a Matrix room of your own, relays the two things the box says may leave, and takes a handful of commands from you β€” only what its own token allows, and never in a room anybody else could read.

The chat bot is you, relayed: a small program that runs beside your box, reads what is due today, reminds you in a Matrix room, tells you what somebody else did to your clock, and takes a few commands β€” !status, !today, !pause β€” without opening the app. It lives in the repository at tools/matrixbot/handlr_matrixbot.py. It is one file and Python's standard library only: no Matrix SDK, nothing to install, and it is not in the container.

Important

Whoever runs the homeserver can read everything the bot posts β€” and could send commands as you. The bot cannot encrypt, so its room is plaintext on that server (and in its backups). Use a homeserver you run or trust, and keep the room to you and the bot. That is also why the box β€” not the bot β€” decides what the bot may read, and why recite, log and hygiene are off until you switch them on.

What you need

  1. A bot token from your box: /consent β†’ The chat bot. It starts ltk_, is shown once, and reaches only the bot's own routes. Never put your own API token in the bot β€” it refuses anything that is not an ltk_ token.
  2. A Matrix account for the bot β€” its own, not yours (the bot refuses to run as its owner: it would obey its own posts). Log it in once and keep its access token.
  3. A room with you and the bot in it, set up so the bot will talk there: - join rule invite (or knock) β€” never public or "restricted"; - history not world readable; - not encrypted β€” the bot has no end-to-end encryption, and it will not post plaintext into a room you meant to be private; - no more members than you allow (max_members, 2 for you and the bot).

The config file

A JSON file that only you can read (chmod 600 β€” the bot refuses anything group- or world-readable, before it reads a byte, because the file holds two tokens):

{
  "latch_url": "http://127.0.0.1:8770",
  "latch_token": "ltk_…",
  "hs_url": "https://matrix.example",
  "hs_token": "…the bot account's access token…",
  "room": "!abcdefghijk:matrix.example",
  "owner": "@you:matrix.example",
  "max_members": 2,
  "poll_seconds": 30,
  "state_path": "/home/you/.local/state/latch-matrixbot.json"
}
Key What it is
latch_url your box β€” this machine (127.0.0.1 or localhost); see below for another one
latch_token the ltk_… bot token from /consent
hs_url your homeserver's base address β€” no path, no query; https unless it is this machine (plain http elsewhere carries the bot's token and every message in the clear β€” it warns)
hs_token the bot account's access token
room the room's ID (!…:server) β€” not an alias, which can be pointed somewhere else later
owner your Matrix ID β€” the only one the bot takes commands from
max_members the most people the room may hold (2–100) before the bot stops
poll_seconds how long each wait for news lasts (5–300)
state_path where it keeps its place (created 0600); the folder must exist

Every key is required, and an unknown key is refused (it is usually a typo).

Another machine. By default latch_url must be this machine: the bot's token then never crosses a network, and a box that is not the one you meant cannot answer it. To run the bot elsewhere, start it with --allow-remote-handlr β€” and use https, or its token travels in the clear (it warns).

Running it

chmod 600 ~/.config/latch/matrixbot.json
python3 tools/matrixbot/handlr_matrixbot.py --config ~/.config/latch/matrixbot.json

--once runs one round and exits (handy for a first test), -v logs every request's status line. The log never carries a message body, a text from your box or a token.

The first run is history. The first time it starts with a new state file it answers nothing already in the room β€” a command you typed last week is not acted on today β€” and it starts the box's news at its end rather than posting your whole log. From then on it keeps its place in state_path; if it cannot save its place there (a full disk, a read-only folder), it acts on nothing until it can.

It exits with 0 when stopped, 2 when it refuses (the config, a room rule, a token the box or the homeserver no longer accepts), and 1 when a --once round could not finish β€” the box or the homeserver was away, or what it had to say could not all be sent. Running on, it waits at most poll_seconds for news each round and starts a round at most once a second. Under a supervisor, restart on failure but not on 2 β€” a 2 needs you to look. For systemd:

[Service]
ExecStart=/usr/bin/python3 /path/to/handlr/tools/matrixbot/handlr_matrixbot.py --config %h/.config/latch/matrixbot.json
Restart=on-failure
RestartPreventExitStatus=2

The room is checked all the time

At startup and on every sync the bot checks that the room is still invite or knock, not world readable, not encrypted, no bigger than max_members, that you and it are both still in it, that the homeserver still accepts its token, and that your box still answers its token on /api/v1/bot/today.

  • While it runs, it checks every change one at a time, in the order it happened, so a rule broken and put back between two syncs β€” a stranger who joins, reads and leaves; history world readable for a minute β€” still counts. When more happened than one sync carries (a busy room, a homeserver that was away), the homeserver sends only the net result, where a visitor who came and went does not show; the bot then reads back what it skipped and checks that too. If the homeserver will not let it, it stops.
  • When it starts, it checks the room as it is then. It does not replay what happened while it was stopped β€” a new room's own making, or a stranger who came and went while it was off β€” it cannot have posted anything then, and the room as it stands is what it would post into.

The moment a check fails it stops: it answers nothing in that sync β€” not even a command sent before the change, and not after a restart either β€” posts nothing it had waiting, and exits 2. Fix the room and start it again; a room that is still broken is refused at startup.

Commands

Only from you, only as plain text starting with !, only fresh β€” a command more than fifteen minutes old, sent while the bot was away, is not acted on (it says how many it skipped) β€” and each at most once.

Anything else you say is never acted on, but it is not met with silence either: status, /today or Pause? gets Did you mean !status?, and other small talk gets the way to !help β€” at most once a quarter of an hour, and not right after it answered you (a thanks is not a question). It never repeats or logs what you wrote.

Command What it does
!status the lock in one line: locked or not, hidden, paused, collared, open training, an event's day
!today what is due β€” reflection, mantra, check-in β€” today's question, and your AI's line
!remind on Β· !remind off the bot's own reminders here (on to start with)
!pause the safeword: every grant suspended at once. Always there. Resuming is only in the app, on Consent
!recite recite today's mantra, as now β€” only if you switched on Recite and log for you
!log <habit> [minutes] log a habit by its short name, as now β€” only if you switched on Recite and log for you
!ask <anything> talk to your AI Handler β€” only if you switched on Talk to your AI Handler here (below)
!hygiene Β· !hygiene emergency out for hygiene, as now β€” maintenance inside a scheduled slot, an emergency window otherwise (emergency skips the slot). Stops the clock. Only if you switched on Hygiene from the room (below)
!relocked locked back up, as now β€” only if you switched on Hygiene from the room
!accept Β· !decline answer today's proposed workout β€” only if you switched on Recite and log for you
!help the list β€” also the answer to anything else

There is no !unlock, and there never will be: the key is yours, in the app. Whether recite and log work is decided by your box, on the bot's token β€” switched off, the box refuses them and the bot answers with the help and how to switch them on β€” whatever else was wrong with the command (it asks the box first, without writing anything). The time on the clock is only in !status if you switched on Show the clock, and never while a lock is hidden.

Hygiene from the room

!hygiene and !relocked are the dashboard's πŸ› card, said in the room. They move your clock β€” out stops it, back in starts it β€” so they have their own switch, Hygiene from the room on Consent β†’ The chat bot, off until you turn it on: Recite and log for you does not open them.

  • Only ever now. The room cannot backdate a window or a relock. Locked back up earlier than you said? Say so in the app, where the πŸ” button takes a when.
  • The emergency door is never refused, here or anywhere. Outside a maintenance slot, !hygiene is an emergency window; !hygiene emergency asks for one inside a slot too.
  • Nothing about your lock's time. The answer names the window's length (20 min), and !relocked how long you were out and how far past the window β€” never time left, never on a hidden lock.
  • Locked back up? When a window runs out and you have not said you are back in, the bot asks, once for that window, as a message that rings (while reminders are on). !status and !today say you are out for hygiene while you are. A window run well past its time is a πŸ› Long soak mark.

Talking to your AI Handler

!ask <anything> is a turn of your AI Handler's chat β€” the same conversation as the AI page, seeing everything you granted it (your reflections, your kit, the clock) β€” and its answer comes back here, as a message that rings. It is off until you switch on Talk to your AI Handler here on Consent β†’ The chat bot; switched off, !ask says so and asks nothing.

  • One question at a time. The model can take a minute; ask again before it answers and the bot says it is still on the last one. Meanwhile it keeps reading the room: a !pause behind a question is acted on at once, never queued behind the model.
  • The safeword still wins. An answer written knowing something a pause takes back is not posted if you paused while it was thinking β€” it waits on the AI page. The bot checks again just before it posts.
  • A hidden lock's time never reaches the room: chat is never shown it, and an answer that talks about time while one runs is posted masked.
  • If the model is away, the bot says so with the rules line, as the AI page does; if the AI is switched off or the day's calls are used up, it says that. A question the bot was still waiting on when it stopped is not asked again β€” its turn is on the AI page.

The bot logs that it ran !ask, never what you asked or what came back.

Exactly what it relays

The bot says its own sentences, filled in from what your box's two bot routes give it β€” codes, counts and flags β€” plus the only two kinds of text those routes carry:

  • today's reflection question β€” your library's or a playset's; one somebody else set for today follows their rules (your AI's own question never travels);
  • a line your AI said that it marked safe to repeat, while Relay your AI Handler's lines is on β€” and a Handler's line only if you switch Relay a Handler's lines on, labelled with the kind its signer declared, a person or an AI. Either line leaves the room the moment its voice may no longer speak: the safeword, its Say a line revoked, the AI switched off, a pack's collar taken off. A Handler who can see your hidden clock never has a line repeated while that lock stays hidden.

What it posts on its own:

  • a reminder, once a day each, when your reflection, check-in or mantra is due (worked out as if the health module did not exist β€” a hard day never reaches the room). Reminders are ordinary messages, so your client notifies you; everything else is a notice, which most clients show quietly. The reflection is due from midnight, but its reminder waits until four hours before the hour it is due by (Reflection due by on the Handler's settings β€” 22:00 unless you changed it, so from 18:00), on your box's clock;
  • a challenge, the moment your Handler asks for proof (if you switched challenges on): πŸ“Έ Proof, now: write OTTER 47 where the camera can see it… β€” an ordinary message, so it rings, and it follows !remind like the reminders do. One that was answered or ran out before the bot saw it is never posted late; !today says πŸ“Έ Proof asked for while one is open;
  • news from your box: time added, a freeze or training set by somebody else (who β€” your AI, your Handler β€” never the training's title or their reason); a badge (its title only when it was earned by your own game, otherwise a badge β€” and only when the room is shown the number it states anyway: First Handlr and a count of locks always; Long Haul, A Year Under and a playset's locked on N days only with Show the clock; a count of training, reflections, check-ins or spins β€” Good Dog, Said Out Loud, Still Here, Let the Wheel Decide β€” and the streak and photo ones never, because the room gets today's duties as done or not and the clock as time left, never those counts; a playset's joined and granted say no number and are named; its finished says how many days you were locked, so only with Show the clock, and never when it also says how many days you reflected); being collared β€” with the kind your Handler signed they are, said as their word, never as a fact the bot checked β€” or joining a pack (never a name); a pause, a lock, an unlock. A burst becomes a few lines and …and N more.

Withheld means never. A line the box would not relay when the bot read it β€” its relay switch off, a hidden clock in it, a piece of your kit named β€” is not relayed later, whatever changes: switching the relay on, renaming the kit, the lock ending. While you are paused no voice's line reaches the room at all, not even one said just before. And anything that has to wait β€” the two seconds between messages, the hourly cap, a homeserver's bad minute β€” is checked again, by your box, just before it goes: a line you switched off, paused or named in the meantime (even while the bot is still sending) is dropped for good. An answer to !status or !today is built when it is sent, so it is as of then β€” !today reads today's line under the switches as they are at that moment.

It never carries your words, anybody's name, a training title, your kit, your health or a hidden lock's clock, and a refusal never reaches the room β€” except your AI Handler's answers to !ask, which carry whatever you let your AI see, because you switched that on. It posts at most one message every two seconds and thirty an hour.

What it does not do

No end-to-end encryption (so its room must not be encrypted), no photos or voice, no resume, no unlock, no back-dating (the box refuses a time from the bot), and no second copy of your data: everything it knows it reads from your box, when it needs it.

When it stops

It says What to do
config_mode: … chmod 600 it the config is readable by others: chmod 600 it
latch_url: … is not this machine run it beside the box, or pass --allow-remote-handlr
latch_probe: … answered 401 the bot token was revoked β€” make a new one on /consent
latch_probe: … answered 403 that is not a bot token (your own API token?)
latch_probe: … answered 404 your box has no bot routes β€” update Handlr
latch_probe: … answered 503 your box is a hosted instance: its API is off, and a bot cannot run there
whoami: … the homeserver no longer accepts the bot's token, or it is logged in as you
not_in_room: … the bot's account is not in room, or the homeserver will not let it post there
history_visibility, join_rules, encrypted, members, owner_absent, bot_left a room rule broke β€” fix the room, then start it again
gap: … more happened between two syncs than the bot could read back (or the homeserver would not let it) β€” look at the room, then start it again
sync: … answered 4xx the homeserver refused the bot's very first sync β€” check hs_url
the homeserver refused the bot's sync position (a warning) nothing to do: it starts over as history (after the bot moved to another homeserver, or the homeserver was restored)
I could not reach your box just now the box was away for that command; say it again
could not save the state file its folder is full or read-only; it acts on nothing until it can save its place again

After restoring your box from a backup, stop the bot and delete its state_path before you start it again: the restored box counts its news from an earlier point, and a bot that kept its place would say nothing until the box caught up with it. The next run is history.

This page is docs/bot.md in the repo. Spotted a mistake? Tell us how to report it β†’