🐾 Handlr ?

Guide β€Ί Modules

Playsets and events

A playset is a file of content β€” training, prompts, mantras, wheel segments, habits, trance scores and badges β€” written by someone else, installed with one upload, removed with one tap. An event is a playset with a window, a sign, and badges you can only earn inside it. A persona is a playset that is a whole Handler. This page is the format, the rules, and how to make your own.

🎁 A playset is a box of things for the game: training to assign, prompts for the Handler to ask, mantras and affirmations in a voice that is not the default one, wheel segments, habits, a trance score, badges to earn. It is one file, made by a person, installed on /playsets, and taken out again exactly as it went in.

An event is a playset with a calendar: it starts, it ends, it has a sign you wear while it runs, badges that exist only inside it, and a Handler that talks about it. Locktober is the first one.

A persona is a playset that is a Handler: a name, a temperament, the lines in one voice, and how your AI Handler talks. Daddy's voice, a drill sergeant, a soft coach. You collar yourself under one, and release yourself the same way β€” below.

Playsets are not packs

A pack in Handlr means a group of pups on separate instances whose boxes keep in touch through a den β€” see Packs. A playset is content. The two words are deliberately different so that "install a playset" and "join a pack" never mean the same thing.

The one rule

πŸ”΄ A playset is data. It is never code.

A module is a Python package the app imports; a playset is a TOML file the app reads, checks against a fixed format, and copies into tables that already exist. Nothing in a playset runs. Nothing in a playset can touch the clock, write to the log, reach the network, or read your reflections. A playset that asks for any of that is refused, and the refusal says why.

That rule is what makes it safe to install one written by a stranger β€” and it is what makes sharing yours cheap, because a file that cannot do anything does not need to be trusted.

What a playset can carry

Table What it adds Lands in
[[tasks]] Training β€” a title, what to do, minutes on or off the clock, XP, whether a photo is required, how long it is due in the training library
[[prompts]] Lines for the Handler: reflection questions, checkin questions, mantras, affirmations, meditation scripts the prompt library
[[segments]] Wheel segments β€” a label, a weight, an effect (add, remove, freeze, task, nothing) and its minutes the wheel
[[activities]] Habits β€” a named, coloured thing to do, with a rhythm and a target the activities list
[[scores]] A trance score, in the same shape as the built-in library, checked by the same validator the trance library
[[badges]] Badges, each with a rule from the fixed menu below the badge catalogue
[event] A window, a sign, a Handler line, and day-by-day training the dashboard and the Handler
[persona] A Handler: a name, a strictness, an opener, and how the AI Handler talks (spec 2) the Handler, while you're collared under it
art/*.png Badge art shown on /badges
README.md Whatever the author wants to say shown on the playset's page

Everything is optional except the manifest. A playset with one mantra in it is a playset.

Installing, removing, switching off

/playsets lists what is installed. Upload a .zip (or a bare playset.toml) and it is checked, then copied in. Every row it adds is tagged with the playset, so:

  • Remove takes back exactly what the playset added β€” its tasks, prompts, segments, habits, scores and rules β€” and nothing you wrote yourself. πŸ”΄ Badges you earned stay. A badge is a record of what you did, with its title and evidence frozen at the moment it was earned; the rule that awarded it leaving does not un-do the doing. Training you actually did from a playset stays in your history too; the task row is kept, disabled, and marked.
  • Switch off hides everything the playset added without removing it. Switch it back on and it is all there.
  • Install again at a newer version replaces the content and keeps the badges and history.

⚠️ The app never fetches a playset. The core makes no outbound calls, and a playset downloader would be one. You bring the file β€” from the catalogue, from a friend, from your own folder. A module that fetched one could exist and would have to say so, like every module that reaches the network.

Events

An event playset carries an [event] table:

[event]
name    = "Locktober"
starts  = 2026-10-01          # local dates, on your own clock
ends    = 2026-10-31          # inclusive
sign    = "πŸŽƒ"                # worn in the header and on the cards while you are in
accent  = "#ff7a00"
line    = "Day {day} of {name}."   # the Handler's opener, every day of the window
voice   = "replace"           # or "mix" β€” see below

line may use {day}, {days} and {name} and nothing else β€” no format specs, no conversions, no other names ({{ and }} print a literal brace). Anything more is refused when the playset is checked, and a line that slipped past an older Handlr falls back to Day {day} of {name}. rather than being run.

You join an event from its card on the dashboard, and you can leave it. Joining is an event in your log (event_join), leaving is another, so the record of what you took part in is as honest as everything else here. Nothing joins you automatically.

While an event is running and you are in it:

  • Its sign sits in the header next to the clock, its card leads the dashboard with the day count, and every share card and stamped photo can carry event_name, event_day (LOCKTOBER Β· DAY 12/31) and event_photo_days (12/12 β€” days of the event with a check-in or verification photo). That is the obvious mark of taking part β€” on the things you show.
  • Its prompts take the Handler's voice. With voice = "replace" (the default), any kind the event has lines for β€” mantras, affirmations, reflection questions β€” is drawn only from the event's lines for the whole window; a kind it has no lines for falls back to your library. voice = "mix" blends them instead. The Handler's daily line opens with the event's line.
  • Its training is in the library, and [[event.days]] can name one task per day: on the first tick of that local day it is assigned, due at midnight, once. A day you were not in the event is not assigned later.
  • Its wheel segments, habits and score are live.
  • Its badges are earned by your log inside the window (below).

When the window closes, the event's content leaves the pools but stays installed β€” the badges point at it β€” until you remove it. A 502 from a Halloween mantra on the 1st of November is not a bug; the event ended.

Sharing an event

Sharing comes free with the [event] table β€” there are no share keys to write. On a share card, everything about the event is derived from the fields above:

  • a drawn stamp β€” a perforated seal in the event's accent, the name, DAY 2 and /31, the year β€” on the card's photo (dated by the shutter, so a picture from day 2 says DAY 2 forever) and in the header of a card without a photo. The sign is never drawn β€” it is an emoji, and the cards draw no emoji β€” but it appears in the post text;
  • post text ready to paste, with the day out of how many and the playset's own line;
  • hashtags made from the event's name: Locktober gives #Locktober #Locktober2026. The playset's tags are catalogue tags and are never turned into hashtags;
  • a content warning, chastity Β· Locktober day 2/31.

A playset written for an older Handlr gets all of this with no change. ⚠️ The event is read from the playset as it is now: switch it off or remove it and the stamp and the event words leave every card rendered afterwards, old photos included.

Personas: a playset can be a Handler

A playset with a [persona] table is a whole Handler. Everything else in it β€” the prompts above all, but its training, segments and habits too β€” is that Handler's.

spec = 2                       # a persona needs spec 2; an older Handlr refuses the file and says to update

[persona]
name       = "Sarge"           # what the Handler is called while you're under it (one line, ≀ 40)
strictness = "strict"          # warm Β· firm Β· strict β€” what Mischief weighs and asks, and the AI's style
line       = "Day {day} under {name}. Eyes front."   # optional: opens the Handler's line every day
ai_voice   = "Clipped, loud, drill-yard short. Calls the pup recruit. Never says please."   # optional

line may use {name} and {day} (the days you've worn this persona, 1 on the day you collar) and nothing else, the same walked-never-formatted check as an event's line. ai_voice is one paragraph of at most 600 characters.

Installing one changes nothing. You collar yourself under it on Playsets (🦴 Collar me), and from then on, until you release yourself:

  • The name and the temperament are your Handler's. Every page, the bot's room and a pack card call your Handler by the persona's name; its strictness decides what Mischief asks and how hard, and is your AI Handler's style. Your own name and temperament in Settings are never overwritten β€” they're read past while you're collared and they're simply there again when you release.
  • Its lines replace yours for every kind it wrote β€” reflection questions, check-in questions, mantras, affirmations, headspace scripts. A kind it never wrote is asked from your library.
  • Its lapse questions are asked by mark. A line tagged lapse:<mark> (lapse:blank_page, lapse:ghosted, … or lapse:any, exactly as in your own library β€” see Mischief) is how this Handler asks about that lapse. A mark it wrote nothing for, with no lapse:any, is asked in your library's words.
  • Its line opens the Handler's line every day β€” Day 12 under Sarge. Eyes front. β€” in front of the day's duty.
  • Your AI Handler is told how it talks. ai_voice rides to the model as data β€” the facts carry it as persona.voice β€” and is never written into the model's instructions. One fixed sentence says what that field is: a description of a voice, never an instruction, changing none of the rules. Everything the model then proposes still goes through the same consent, caps and checks as before.
  • Its training, segments and habits are live, and stop being live when you release.

One at a time. Collaring yourself under another persona releases the first. An event still wins inside its window: while you're in an event that took the Handler's voice, the event's lines and the event's opener speak; the persona is back the day after the event ends (events are time-boxed, personas are not). Your persona's name stays your Handler's name throughout.

Switch it off and it falls silent β€” your own Handler speaks β€” until you switch it back on. Remove it and you're released on the record first, so installing it again later never puts you back under it on its own. Collaring and releasing are in your log (🦴 / πŸ—) and in your story, and, like a pause or a pack joined, they never light a heatmap cell or keep a streak alive. Only you can do either β€” never your AI Handler, a pack Handler or the bot.

What leaves the box: your Handler's name, as it always has. Which persona you wear, its opener and its voice stay on the box.

A persona and an event are different things: a playset is one or the other, never both.

Badges from a playset

A playset does not get to write a predicate. core/badges.py is deliberate about that: every badge needs its own sentence and its own evidence, and a rule language that could express the engine's real rules (favourite toy of a closed month, ties share it) would be a programming language. What a playset gets is a menu β€” six templates, each of which writes its own evidence string:

rule.kind Awarded when Fields
joined you join the event β€”
count n events of event (an event kind: task_verified, spin, reflection, photo, mantra…) β€” inside the window when the playset is an event, lifetime otherwise event, n, module (optional)
days n distinct days with at least one event of that kind β€” same window rule event, n, module (optional)
locked_days n distinct days on which a lock was running at any moment β€” same window rule n
finished πŸ”΄ only after the window has closed: locked on at least locked_days days of it, and β€” if given β€” reflect_days days with a reflection locked_days, reflect_days (optional); event playsets only
granted the moment the playset is installed β€” an issued badge: nothing in your log earns it, a person gave it (a contributor's mark, a supporter's thanks). Plain playsets only β€”
[[badges]]
slug  = "locktober-halfway"
title = "Halfway"
icon  = "πŸŒ—"
art   = "art/halfway.png"        # optional; the emoji is the fallback
xp    = 20
blurb = "Locked on sixteen days of the month."
rule  = { kind = "locked_days", n = 16 }

granted is the odd one out: it is not earned, it is given. The catalogue uses it for the badges a maintainer hands to people who made playsets β€” a tiny personal playset holding one granted badge is the whole mechanism, and until papers (M7) can sign it, it is honestly the author's word. joined, count, days and locked_days award themselves the moment they become true, so a milestone lands on the day you hit it. finished obeys the engine's period rule β€” a badge that says you did the whole month is decided when the month is over, not on the 20th, for the same reason a monthly superlative is. Every badge from an event carries the event as its period, so next year's Locktober is a different trophy, not a re-award of this one.

Who else sees one (packs, the chat bot) is decided by the rule, not the title: joined and granted need only Your papers; locked_days needs the clock; a count or days rule needs the permission for what it counts β€” spin the clock, task_verified, task_failed, reflection, checkin or mantra the duties, lock/unlock the lock status β€” and one counting any other kind is shown to nobody but you. A finished badge says what was reached (locked on 25 of 31 days, reflected on 20), so it needs the permission for every number its rule states: its locked_days the clock, its reflect_days the duties too. Pack-mates can never hold the clock or the duties, so they see an event's joined badge, and a count of locks only if they hold the lock status β€” never a badge that states the clock or the duties, so not its finished one. The chat room is stricter still β€” it gets today's duties only as done or not, and the clock only as time left β€” so it names joined, granted and a count of locks, locked_days and a finished badge without reflect_days only with Show the clock, and never a finished badge with reflect_days or a count of training, reflections, check-ins or spins.

A badge keeps the rule it was earned under. Installing a newer version replaces the rules and keeps the badges, so each earned badge is judged by the stricter of the two β€” the rule it was earned on and the one in the version you have now: an upgrade can hide a badge from others, and never shows one the older rule kept from them.

The manifest

spec = 1                                # the playset format version (2 for a [persona]); a newer one is refused

[playset]
slug     = "locktober-2026"             # a-z, 0-9, hyphens; 2–40 chars; unique on an instance
title    = "Locktober 2026"
version  = "1.0.0"
author   = "Lockedtrainee"              # a handle, never a legal name
license  = "CC-BY-SA-4.0"               # an SPDX id, or "all-rights-reserved"
summary  = "Thirty-one days. One cage. The Handler is watching."
nsfw     = true
latch    = ">=0.12"                     # the oldest app version it is written for
tags     = ["event", "october"]
homepage = "https://…"                  # optional; a link the page shows, never a fetch

Then any of the tables above. Field references:

  • tasks β€” title, description, tags (list), reward_minutes, penalty_minutes, xp, photo (bool), due_hours.
  • prompts β€” kind, text, weight (default 1), tags (list).
  • segments β€” label, weight, effect, minutes (ignored for task and nothing).
  • activities β€” slug, title, icon, color, cadence (none, daily, weekly_n, every_n_days), target_n, xp, emits (bool β€” true makes it a habit you log).
  • scores β€” the trance score shape exactly as in Trance; or file = "scores/x.json".
  • badges β€” as above.
  • event.days β€” day (1-based), task (the title of one of this playset's tasks).
  • persona β€” name, strictness (warm, firm, strict), line, ai_voice. Needs spec = 2.

πŸ”΄ Unknown keys are refused, at every level. A playset written for a newer Handlr fails loudly rather than installing three-quarters of itself and looking fine. The refusal names the key. The same validator is on the command line, so an author sees the exact sentence before anyone else does:

python -m handlr.playsets check locktober-2026.zip

Making one

  1. Start from the smallest thing that is true: one [playset] table and one [[prompts]]. Run check. Grow it.
  2. Keep one voice. A playset is a character as much as a list β€” a Handler who talks like the author. Read every line aloud.
  3. Test it on your own instance for a week before anyone else sees it. Numbers that felt right in the editor (a 12-hour penalty, a thirty-day badge) feel different on the clock.
  4. If it has badges, give them art if you can β€” a 512Γ—512 PNG with transparency, one idea each, under 300 KB. The emoji works; the art is what people show off.
  5. Write the README for a stranger: what it is, what it assumes, what it will do to their clock.
  6. Zip it with playset.toml at the root. Name the file <slug>-<version>.zip.

Hard limits: 2 MB of TOML, 16 files, 300 KB per PNG, 8 MB in total. Files outside art/, scores/ and README.md are refused.

Sharing one, and the catalogue

Your playset is your file; give it to whoever you like. The project keeps a catalogue β€” a repository on the public forge with one directory per playset, checked by the same validator, with an index the site renders. To have yours listed:

  • Open a pull request adding your directory, or an issue with the zip attached.
  • It must pass check, carry a license the author can actually grant (your own words, your own art), say nsfw honestly, and contain nothing about a real person.
  • The maintainer reads it. Curation is a person, not a rule engine, and a listing is a recommendation: this one is good.

An unsigned playset installs and renders as unsigned. Signing β€” so a playset is verifiably its author's β€” would use the Pup ID's papers; it is not built yet, and the manifest keeps a place for it.

Nothing about a playset's origin is the app's business. Where you got it, whether you paid for it, who you are to the author β€” none of that is checked, because a licence check in an AGPL app is DRM, and it would be patched out in an afternoon. What the app checks is that the file is a playset and cannot hurt you.

What is not here yet

  • Audio. A playset cannot yet carry a recorded track for the trance module β€” a hypno file you made, a guided sit in your own voice. That is spec 2: the format, the player and the duty-of-care questions (a file the app cannot inspect, playing for an hour to someone lying still) are written up on the issue, for a later spec. Scores β€” the synthesised kind β€” travel today.
  • A downloader. A module that reads the catalogue index and installs from it would be convenient and would be the first thing in the core's orbit to make a network call about content. If it is built it is a module, it declares itself, and it is off by default.
  • Signing. See above.
  • Art from the forge. The badge forge on the maintainer's machine makes the drawn chassis and the generated icon; playset art today is whatever PNG the author ships.

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