Self-hosting
API, Shortcuts and bots
A bearer-token JSON API for phone Shortcuts and local bots โ your own token off until you set it, a bot's only once you make one, every write stamped with who did it, and deliberately unable to pull your journal in bulk.
On this page
/api/v1 is for things that are not a browser: an iPhone Shortcut or a script of yours that
logs a photo from the share sheet, on your own token, and a chat bot on a scoped token of its
own. Both are off by default: yours until you set it, a bot's until you make one.
Enabling
Set LATCH_API_TOKEN in .env and recreate the container. Empty turns off your own
(owner) token: every route it would reach answers 503 โ it fails closed, never open. A
bot token you made on /consent is separate and still reaches
its own few routes with LATCH_API_TOKEN empty; revoke it there to close those too
(Settings โ System says how many are live). A header that is there but is not a well-formed
ASCII Bearer โฆ is a 401 either way. Send the token as a bearer:
Authorization: Bearer <token>
X-Latch-Actor: shortcut
X-Latch-Actor is one of user, api, shortcut (left out, it is api). It is written on
every event, so the log says who did what. ai, bot, system and pack:โฆ are refused with
403: the token is your own hand, and those voices act only through what you grant on
Consent, never by borrowing it. Anything else is a 400 โ a typo is an error,
not a line in your log with the wrong name on it.
That token is the owner token. There is a second kind, for one machine at a time: a
scoped bot token, minted on /consent, which reaches only the
chat bot's own routes.
The token can read your journal
/api/v1/handler/entries returns reflection and check-in bodies by design. Treat the token
as a LAN-only secret: a password manager, a Shortcut on your own phone, a bot on your own
box. Never anything that leaves the house.
Timing: at
Every write takes an optional at โ epoch seconds โ for something that already happened. The
same rules as the web's when field apply (the retroactive log):
never the future, never before the lock it lands in, marked logged later if it is more than
two minutes old.
Routes
| Method | Route | What |
|---|---|---|
| GET | /state |
the clock (status, remaining, end_at, frozen, hiddenโฆ) and lifetime totals. The session note is omitted; a running hidden lock's end time is too (see below). |
| POST | /session/lock |
{seconds โฅ 60, note?, hidden?, at?} |
| POST | /session/adjust |
{seconds (ยฑ), reason?, at?} |
| POST | /session/freeze |
{minutes? (0 = until unfrozen), reason?, at?} |
| POST | /session/unfreeze |
{at?} |
| GET | /hygiene |
the open hygiene window (start, until, kind, overrun), the maintenance slot open now or the next one, the slot labels, today's counts. Never the clock |
| POST | /hygiene/open |
{emergency?, at?} โ out for hygiene: maintenance inside a scheduled slot, emergency otherwise (always allowed). Stops the clock. Owner token, or a bot token with Hygiene from the room on (as now) |
| POST | /hygiene/relock |
{at?} โ locked back up; at for when it really happened (owner token only โ the bot relocks as now) |
| POST | /session/unlock |
{force?} โ force: true is the emergency key |
| GET | /session/undo/candidate |
what undo would hit and, if it cannot, why โ the same text the UI shows |
| POST | /session/undo |
{reason?} |
| POST | /note |
{text, at?} |
| POST | /media/photo |
multipart photo, caption?, at? โ a photo check-in. Returns ids and the clock, never the caption |
| GET | /events |
?limit=&module= โ whitelisted payloads (no prose) |
| POST | /wheel/spin |
spin as the calling actor |
| GET | /wheel/segments |
the wheel |
| GET | /tasks |
{open, library} |
| POST | /tasks/assign |
{task_id?, due_hours?} โ omit task_id for random |
| POST | /tasks/{id}/complete |
multipart photo?, note?, at? |
| POST | /tasks/{id}/fail |
form reason?, at? |
| GET | /handler/today |
what is due โ facts only, plus reason: the one code for what matters now (reflect_due, checkin_due, โฆ; see the Handler), quest: the running chapter's goal โ its numbers and verdict, and on your own token its wording โ and, while one is open, challenge: {id, word, issued_at, due_at, minutes, left} |
| POST | /handler/challenge |
multipart photo โ answer the open challenge. No at: an answer is stamped when it arrives |
| POST | /handler/challenge/decline |
"I can't right now" โ the miss, said by you: the penalty applies, the lighter mark |
| GET | /handler/entries |
?kind=&limit= โ bodies included, by design |
| POST | /handler/reflect |
{body, prompt_id?, at?} |
| GET | /habits |
every habit with today's count, this week against its target, streak, week seconds, and the running timer if any โ facts only, never a log's note |
| POST | /habits/log |
{slug, minutes?, at?} โ minutes blank is a check-off. at is when it ended; more than two days back counts and earns no XP |
| POST | /habits/start |
{slug} โ start the timer (one at a time) |
| POST | /habits/stop |
{discard?} โ stop it and log the elapsed minutes; discard: true writes nothing |
| GET | /leaveit |
every Leave it bad habit โ its limits, today's and yesterday's quantity and verdict state, this week's total and dry days, the kept streak โ plus the ledger open now and passes used. Numbers and states only |
| POST | /leaveit/ledger |
{day?, declared?} โ "that's everything": closes the ledger open now (or day, back two weeks), with optional counts {slug: n}. The higher of your count and the logged one counts |
| POST | /leaveit/tally |
{slug, amount?, at?} โ +1 (or an amount) on a bad habit tapped in Latch |
| POST | /leaveit/pass |
{slug, day, allow} โ a planned exception, declared before the day begins |
| GET | /kit |
the inventory (items: id, name, kind, category, status, on) and what is on right now (on: bouts with item_ids) โ the one call that returns a name |
| POST | /kit/bout |
{item_ids, kind?: play\|wear, at?} โ put things on / start play. Answers ids, never names |
| POST | /kit/bout/{use_id}/end |
{outcome?, scores?, at?} โ take them off; at is when it ended |
| POST | /kit/{item_id}/consume |
{qty, at?} โ log a squirt of lube without opening a page; answers the level left |
| POST | /handler/reflect/voice |
multipart audio, duration_seconds, prompt_id?, note?, at? |
| POST | /handler/checkin |
{scores: {headspace, obedience, mood, denial, energy}, note?, prompt_id?, at?} |
| POST | /handler/recite |
{kind: mantra\|affirmation, prompt_id, at?} |
| POST | /handler/meditation |
{minutes, prompt_id?, note?, at?} |
| GET | /health/today |
the latest day's readings by metric (null = no reading), whether the data is stale, the recovery flag with its reason keys, and each rule's key and state โ never the Handler's lines. See Health |
| POST | /health/daily |
{readings: [{day, metric, value, unit?, source?, observed_at?}], apply?} โ the sync script's door; a reading that could not be taken is left out, never sent as 0. See Health |
| POST | /pause |
๐ the safeword โ {paused: true, event_id}. Saying it twice is the same pause. There is no resume on the API: that is yours, on /consent |
| GET | /bot/today |
bot token only โ see below |
| GET | /bot/feed |
?after=&limit= (โค 50) โ bot token only |
| POST | /bot/ask |
{text} โ bot token only, and only with Talk to your AI Handler here on: one turn of your AI Handler's conversation, said in the room. See below |
| GET | /bot/mantra |
bot token only, and only with Post today's mantra audio on: today's wake mantra track, audio/mpeg. 404 when there is none that may go โ not rendered yet, rendered before today's mantra changed, or paused. See Trance |
That table is the whole surface. A test in the repository holds /api/v1 to exactly this
allowlist in both directions โ a new route fails the suite until it is written down.
Errors come back with a 4xx in one of two shapes:
{"error": "โฆ"}โ the game said no, in the same sentences the web UI shows (Time isn't up, No such line., โฆ).{"detail": โฆ}โ the door said no, before the game was asked.
A client (the chat bot above all) can key on the door's exact strings:
| Status | detail |
|---|---|
| 401 | Bad or missing bearer token |
| 503 | API disabled: LATCH_API_TOKEN is not set ยท The JSON API is off on a hosted instance. |
| 403 | not in this token's scope |
| 403 | This token speaks only as the bot. ยท That voice is not yours to use on this token โ it acts only through what you grant. |
| 400 | Unknown X-Latch-Actor โ use user, api or shortcut. |
| 403 | a bot token only: The bot writes only now โ leave out `at`. ยท The bot recites only today's mantra โ its id is on /bot/today. |
| 422 | a body or query that does not parse โ here detail is a list, one entry per field |
A hidden lock stays hidden
While a hidden lock is running, nothing on the API tells you when it ends. /state and
every write that echoes the clock (the wheel spin included) return end_at, remaining, base_seconds,
adjust_seconds and frozen_seconds as null; the lock event on /events and on
/session/undo/candidate comes without its base_seconds; and a refused unlock says "Time
isn't up" instead of how long is left. started_at, status and hidden are still there.
Once the lock ends, everything comes back.
What is deliberately not on the API
- Export. Every
/export/*route is behind the reverse proxy's login, not the token. - Prose on the event feed. Excerpts, scores, notes, reasons and captions are absent from
/eventsbecause they are not in the whitelist. - A Swagger page or
/openapi.json. Both would beacon to a CDN on every visit and publish the route list without a token. This page is the documentation.
Examples
Lock for four hours:
curl -s -X POST https://latch.your.lan/api/v1/session/lock \
-H "Authorization: Bearer $LATCH_API_TOKEN" -H "X-Latch-Actor: shortcut" \
-H "Content-Type: application/json" -d '{"seconds": 14400}'
A photo check-in from an iPhone Shortcut (Receive images from Share Sheet โ Convert Image to
JPEG โ Get Contents of URL, method POST, form body, field photo = the image):
POST /api/v1/media/photo (multipart) photo=<jpeg> caption=""
A spoken reflection from Record Audio โ same pattern with field audio and
duration_seconds โ the server refuses a recording whose length it cannot establish.
Ask the Handler what is due before nagging yourself:
curl -s https://latch.your.lan/api/v1/handler/today -H "Authorization: Bearer $LATCH_API_TOKEN"
Kit
Kit on the API exists for the phone with your hands otherwise occupied: hood on, plug
out, lube. The names of what you own are the most identifying thing in this database,
so they ride out on exactly one call โ GET /kit, the inventory read a Shortcut asks
for once to learn the ids โ and never on the event feed: a bout's events carry item_ids,
not names, so an automation that only ever POSTs never learns what "Rex" is. The bout's
note, an item's notes, dimensions, price and photos are not on the API at all;
/export/kit.csv and /export/bouts.csv stay behind the reverse proxy's login.
A Shortcut for hood on: POST /kit/bout with {"item_ids": [4], "kind": "wear"}; for
hood off: POST /kit/bout/<use_id>/end. GET /kit tells you which use_id is open.
Every write records X-Latch-Actor, so a Shortcut-logged bout is distinguishable from a
hand-logged one in the log.
Kit is the owner token's alone: a bot token gets 403
not in this token's scope on all four routes, Recite and log for you or not, and โ as on
every route โ X-Latch-Actor: ai (or bot, or pack:โฆ) is refused. While a hidden lock runs,
the state a bout's end echoes is as blank as /state's.
Scoped tokens: the chat bot
A chat bot relays into a room, and whoever runs that room's server can read everything it
posts โ so it does not get your owner token. On /consent โ The chat bot you make it a
token of its own:
- It starts
ltk_โฆand is shown once, on the page that made it. Only its sha256 is stored; lose it and make another. Revoke it there with one tap; the page shows when each token was last used (to within ten minutes). - It works whether or not
LATCH_API_TOKENis set, and it speaks only asbotโ one of your own voices, which counts toward your streak, but not one that turns a key: it can never lock, unlock, write your words, undo or take time off. - It reaches only
GET /bot/today,GET /bot/feedandPOST /pause. Every other route answers 403 not in this token's scope. Two more open only when you switch on Recite and log for you:POST /handler/reciteโ today's mantra only, the id/bot/todaygives it (it gets ids back, never the mantra's words) โ andPOST /habits/log. Neither takes anatfrom the bot (403): the bot writes only now, so a room cannot back-date a week of practice into your log. - Everything it receives passes through one filter on the way out:
end_at,remaining,base_seconds,adjust_secondsandfrozen_secondsare removed unless you switched on Show the clock โ and even then never while a hidden lock runs. A hidden lock whose time is up still readslockedto the bot, with nocan_unlock: the moment it turneddonewould tell the room when it ended. - One more opens only when you switch on Talk to your AI Handler here:
POST /bot/ask(below). - One more opens only when you switch on Post today's mantra audio:
GET /bot/mantra, the wake track the bot posts in the room once a day at wake time. A post is not a recite. - Two more open only when you switch on Hygiene from the room:
POST /hygiene/openandPOST /hygiene/relockโ they stop and restart the clock, so no other switch opens them. Like recite and log, neither takes anatfrom the bot (403). - The owner token cannot read
/bot/today,/bot/feedor/bot/mantra, or call/bot/ask(403): those are the bot's, built for a room, and your own clients have the richer routes above (you talk to your AI on its page).
What the bot reads
GET /bot/today is codes, ids, counts and flags, plus exactly two kinds of text: today's
reflection prompt, and a line your AI Handler said that is safe to repeat (a Handler's line
too, only if you switch that on, labelled with the kind its signer declared):
{"now": 1790000000.0, "day": "2026-09-21", "hour": 19, "status": "locked", "hidden": true, "paused": false,
"reason": "checkin_due",
"clock": null,
"collar": {"state": "stray", "by_kind": null, "ai_local": false},
"duties": {"reflected": false, "recited": false, "checkin_due": true,
"checkin_overdue": false, "due_hour": 22, "challenge": false},
"prompts": {"reflection": {"id": 7, "text": "โฆ", "by_kind": null}, "mantra": {"id": 3}, "checkin": {"id": 9}},
"lines": {"ai": null, "handler": null},
"ask": false,
"hygiene": null,
"training": {"open": 1},
"event": {"day": 12, "days": 31}}
day is your box's local date โ the day the duties are counted in, whatever timezone the bot
runs in โ and hour the hour there (0โ23), so a reminder can wait for a sensible one. reason is
the outbound one โ worked out as if the health module did not exist, so a
hard day never reaches the room โ and so is a habit counted from health data, which is left
out of the habits entirely. A line is safe to repeat when it names none of your kit and no
training title in your box and, while a hidden lock runs, says nothing about time. No person's or pack's name ever appears, and
no training title; event is the day and the length only, never the event's name or banner
(a playset's own badge titles on the feed are its author's words, and may mention its event).
The reflection prompt carries its words when it is from your own library or a playset's. One
somebody else wrote for today is theirs: by_kind says who (ai, or the kind a Handler's
signer declared), the AI's own question never carries its words (the AI marks a line safe to
repeat; a prompt has no such mark), and a Handler's only with Relay a Handler's lines on and
under the same rule as their lines.
While you are paused, lines.ai and lines.handler are null โ paused: true says why,
so a client can tell "paused" from "nothing said today". ask is a flag: whether Talk to your
AI Handler here is on right now. hygiene is null unless Hygiene from the room is on; then
{"out", "overrun", "window"} โ two flags (out for hygiene now; its window has run out) and the
id of the event that opened it, never the window's length or the lock's time. mantra_audio is
null unless Post today's mantra audio is on; then {"ready", "due", "seconds"} โ whether
today's wake track is there (and is of today's mantra, and you are not paused), whether it is
wake time yet on the box's clock, and the track's length. Never the words.
GET /bot/feed?after=<id> is what happened since, oldest first: {id, ts, kind} plus, by
kind โ by (your AI, your Handler, your AI Handler) on time added, a freeze, an
unfreeze or training assigned by somebody else (never its title or their reason), plus
minutes on time added and a freeze; text on a relayable AI line; text and by_kind (the
kind its signer declared, human or ai) on a relayable Handler line; a badge's title โ only
a playset's badge earned by your own game or one of the app's own achievements, and only when
the room is shown the number it states anyway: a count of locks always (First Latch), a lock's
time only while Show the clock is on and no lock is hidden (Long Haul, A Year Under, a
playset's locked on N days, and its finished when that is all it states), a badge that states
no number (a playset's joined, granted) always โ and a badge for anything else (a
finished that also says how many days you reflected among them): a count of training, reflections,
check-ins or spins (Good Dog, Said Out Loud, Still Here, Let the Wheel Decide โ the room gets
today's duties as flags and the clock as time left, never those counts), the streak and photo
ones, kit, your health, a refusal, a badge whose origin is unknown, or one from a module the
room has not been told about; by_kind on a collar; and on a verification
challenge, its word (from the app's own list โ a code,
not anybody's text), due_at and minutes โ only while it can still be answered: one already
answered, missed or past its deadline is withheld. Locks,
unlocks, pauses and pack comings and goings are the kind alone. A refusal never appears.
Every answer carries X-Latch-Feed-Next: how far the scan got โ past the items it returned
and the ones it withheld. Pass it as the next after. A withheld item then stays withheld (a
line said while its relay was off is not relayed when you switch it on, nor one that named kit
you have since renamed); a client that goes only to the last id it got would have every
withheld item after it judged again on each read, and relayed the moment its reason lapsed.
To ask whether ONE item may still be relayed โ before posting one that had to wait โ
read ?after=<its id โ 1>&limit=1: it comes back only if it still may (the chat bot does this).
Talking to your AI Handler from the room
POST /bot/ask {"text": "โฆ"} is the one bot route that is not a projection. It is out of the
bot's scope until you switch on Talk to your AI Handler here (off by default), and then it
is your AI Handler's chat โ the same conversation as the AI page, with
the same history and everything you granted it: your reflections under Read your
reflections, your kit, the clock. Its answer comes back as the model wrote it, for the room:
{"ok": true, "code": "ok", "reply": "Good pup. Kneel and breathe.", "away": null, "knew_reads": true}
That is your choice to make: the model is your own, and so is letting its words into a room whoever runs the homeserver can read. What still holds is the game:
- A hidden lock's time: chat is never shown a hidden lock's clock, and an answer that talks about time while one runs comes back masked, as the AI page shows it.
- The safeword: an answer written knowing something a pause takes back โ
knew_reads: trueโ is not handed over if you paused while the model was thinking (code: "paused"; it waits on the AI page). The chat bot checks the flag again just before it posts. - The switch, read again once the model has answered: turned off meanwhile,
code: "switched_off"and no reply.
With no answer, code says why: away (the model did not answer โ away then carries Your
Handler is away from the phone. The rules still stand: โฆ), or one of invalid (empty, or over
1000 characters), inert, hosted, off, moving, low_disk, budget (the AI's calls a day
are shared with the AI page and its own thinking), switched_off, paused. The call holds the
request while the model thinks โ up to about a minute and a half.
Bots and the AI Handler
The API is the door every bot uses: the chat bot (reminders and commands in a Matrix room of your own, on the scoped token above) is one. Whatever you build, build it as a client of this API rather than a second writer to the database โ that is what keeps the clock derived and the log honest.
Your AI Handler is not an API client: it is a module on your box, and the owner token
cannot speak as ai (a header claiming it is refused with 403). It acts as ai only through
the grants you tick on Consent, the same ladder a pack's Handler walks.
Habits
The habits routes exist for one thing: logging from wherever you are without opening the app. An iPhone Shortcut on the Home Screen โ Guitar, 30 min โ is two actions:
- Get Contents of URL โ
POST https://<your latch>/api/v1/habits/log, headersAuthorization: Bearer <token>andX-Latch-Actor: shortcut, JSON body{"slug": "guitar", "minutes": 30}. - Show Result โ or nothing.
Ask for the minutes with Ask for Input if you want one Shortcut per habit rather than per
length. An Automation can fire it when a practice app closes, or when you leave a location.
The at field takes epoch seconds for something that already happened.
If the app you practise with already keeps a calendar, you may not need a Shortcut at all โ see Habits โ Reading another app's calendar.
This page also ships inside the app, at /guide โ so your own instance always serves the guide for the version you are running, with the internet unplugged. Get Latch ยท Something wrong here? Tell me.