Latch

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:

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

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:

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:

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:

  1. Get Contents of URL โ€” POST https://<your latch>/api/v1/habits/log, headers Authorization: Bearer <token> and X-Latch-Actor: shortcut, JSON body {"slug": "guitar", "minutes": 30}.
  2. 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.