A BLE driver and pattern engine for the Kink K250-4S 4-channel e-stim power box — drive it from a computer, and write your own patterns.
It does two things the stock companion app doesn't: it talks to the box directly over Bluetooth
LE, and it generates patterns — timed compositions of power and frequency — rather than only
replaying the handful the box ships with. Everything here was measured against real hardware
(firmware v1.08--18c4987-250114-01hGMT), one pattern at a time, with the box's own LCD as the
witness.
Built so a human or an AI agent can drive the hardware without being able to exceed limits the wearer agreed to. Install is next; the full safety contract is under Safety & limits, and the reverse-engineering detail after that.
At your own risk. High-power e-stim device — this makes it controllable, not safe. Start low and build. Percentages don't transfer between pad placements: outer skin is far less sensitive than others, so the same figure is a different sensation and a different risk.
The hard stops — every session, no exceptions:
- Never a pad path across the chest or through the heart.
- Never on broken, irritated or numb skin — or anywhere they can't feel it.
- Never while plugged into mains power.
- Never on someone who can't speak or signal, or who can't be seen by whoever is in the room.
- Keep loops and pads loose — circulation at the electrode is the one thing here that can cause real injury.
- If it burns rather than stings, or the skin doesn't settle back to normal, stop — and don't use that site again that night.
- If they feel nothing, turn the power down before you touch anything else, then check the loop: pads properly seated, cable attached, connection complete. A pad that's loose or half-attached concentrates the current into a smaller area — and that is what burns people. Feeling nothing is a fault, not an invitation to push harder.
Two ways in.
Hand it to your AI harness. This repo is written for that: it ships a portable operator skill
(agent-skill/SKILL.md) and the sections below are worded to be followed literally. Point your harness
at the folder — or the clone — and tell it to install; it will run the right steps for your OS and read
limits.json before it drives anything. That's the zero-effort path, and it's the one the agent handoff
block further down spells out.
Or do it yourself — one OS at a time, below. Both get you the same thing.
Run it from a folder you own — your home directory is fine. A system directory (/, /usr,
C:\WINDOWS\system32) is not writable by your user, and git clone fails there first.
Every platform needs: Python 3 with pip and venv, and working Bluetooth LE on the host.
That's it — no app, no server, no build step. bleak is the only dependency, and it speaks to each
platform's native Bluetooth stack.
git clone https://github.com/vybesforge/k250-forge.git
cd k250-forge
./install.shinstall.sh builds a venv, installs bleak, symlinks k250-play / k250-scene / k250-stop / k250-status
into ~/.local/bin. Your ceilings live in limits.json — edit it there (or at
limits-form.html) before your first run.
- Needs BlueZ running (
systemctl status bluetooth).install.shwill offer to put~/.local/binon yourPATH; say yes, or add it yourself later. - If scanning finds nothing:
rfkill list, then say the word — Bluetooth turned off is the usual cause. - Don't run as root. Root often can't reach BlueZ on a desktop session; your normal user can.
- The venv is not optional, and not a style choice. Debian, Ubuntu and Fedora block
pip installinto the system Python (PEP 668,externally-managed-environment).install.shbuilds its own venv, so don'tpip install bleaksystem-wide first — if you already tried, that error is the reason. install.shis bash and needs bash 3.2 or newer (any distro's default; on Alpine:apk add bash python3 python3-venv). The tools themselves are the same on any of them.- Headless is fine. There is no GUI and no desktop session anywhere in this: the tools speak BLE
over BlueZ and print text. (The only desktop-ish thing in it is
limits-form.html, a plain file you open wherever you like.)
git clone https://github.com/vybesforge/k250-forge.git && cd k250-forge
./install.shmacOS needs no extra packages — bleak uses CoreBluetooth. The stock python3 is often Xcode's old
3.9, but there is no version floor to clear: pip just resolves the newest bleak that runs on it.
If you'd rather run the current bleak (or a newer Python 3), brew install python@3.12, then either
put it first (export PATH="$(brew --prefix)/bin:$PATH") or run the installer as
PATH="$(brew --prefix)/bin:$PATH" ./install.sh.
- Grant Bluetooth permission to whatever app runs the commands (Terminal, iTerm, your editor). The prompt appears once on first scan; if it was dismissed, re-enable it in System Settings → Privacy & Security → Bluetooth.
- Everything runs on macOS's stock
/bin/bash(3.2) — no Homebrew bash needed. That is checked intests/test_shell_compat.py, because bash 3.2 has sharp edges newer bash does not. install.shlinks into~/.local/bin, which macOS doesn't put onPATH. The installer will offer to add it to your shell rc (.bash_profile/.zprofile) — say yes, or add it yourself withexport PATH="$HOME/.local/bin:$PATH". Or skipPATHentirely and call the tools as./venv/bin/python k250_play.py ….
One command. In PowerShell, from a folder you own:
cd $HOME # C:\Users\<you> -- anywhere you own
git clone https://github.com/vybesforge/k250-forge.git
cd k250-forge
.\install.cmdinstall.cmd is a two-line launcher for install.ps1: Windows' default execution policy refuses to run
a .ps1 file at all, so the launcher bypasses that policy for that one process and nothing else —
no policy is set, and nothing outlives the window.
install.ps1 builds the venv, installs bleak, and makes the
engine answer --limits-show before it hands over. Nothing needs admin, and it does not touch your PATH
or the registry. If the folder is one you cannot write to, it says so and stops there rather than leaving
you three unrelated errors to work out.
-
install.ps1 cannot be loaded because running scripts is disabled on this system— Windows' default execution policy. Useinstall.cmdinstead, which bypasses it for one process and changes nothing on your machine, or say it out loud yourself:powershell -ExecutionPolicy Bypass -File .\install.ps1
-
Doing it by hand instead — the script is a convenience, not a requirement:
py -3 -m venv venv .\venv\Scripts\pip install bleak
-
py : The term 'py' is not recognized— thepylauncher is not on this machine. It arrives with the python.org installer (which also has the Add python.exe to PATH box on its first screen), but the Microsoft Store build of Python does not add it. Ifpyis missing, ask for the interpreter directly:python -m venv venv .\venv\Scripts\pip install bleakpython3works on some installs too. If none of the three resolve, install Python 3 from python.org, open a new PowerShell window, and checkpython --versionbefore going on. -
The term '.\venv\Scripts\pip' is not recognized— the venv step didn't run, so there is no venv to call. It is the step above failing, not a second problem. Fix that one and this one works. -
bleakuses the WinRT Bluetooth stack — no extra drivers. -
Check which Python
py -3means. It picks your newest Python 3; pip then resolves the newest bleak that runs on it, so there's no floor to chase. With several installed, name the one you want:py -3.12 -m venv venv(python -3.12if you are on thepythonspelling). -
Turn Bluetooth on and make sure the box is awake before scanning.
-
install.shis a bash script and won't run natively. Call the engine directly — it readslimits.jsonitself, so the ceiling and the session budget apply here exactly as they do through the wrapper (a command-line--hardcapcan only lower the file's ceiling):
.\venv\Scripts\python k250_play.py --list # the 35 patterns
.\venv\Scripts\python k250_play.py --limits-show # the ceilings that apply here
.\venv\Scripts\python k250_play.py speed_sweep --base 5 --secs 60
.\venv\Scripts\python k250_stop.py
.\venv\Scripts\python k250_status.pyIt looks for limits.json next to the script — or wherever --limits PATH /
the K250_LIMITS environment variable points.
- What else works natively:
k250_show.py(setlists) andk250_status.py/k250_stop.py, as above.k250_stop.pykills a running pattern with PowerShell'sGet-CimInstance Win32_Processbefore zeroing the pads — and if it cannot list processes it says so, rather than reporting a clean stop it did not achieve. - What doesn't: the shell wrappers in
bin/(they're bash) andk250_ctl.py(it drives a FIFO, which Windows doesn't have — it tells you that and exits, rather than throwing). Neither is needed: the engine and the stop tool are the whole contract, and both enforce limits themselves. - Verified on Windows on real hardware (Sep 2026): the install, the engine, the limits contract, and driving the box over BLE. The one branch not exercised there is the stop tool's process-kill line.
- Git Bash / WSL works too, and
install.shwill run there.
- One BLE connection at a time. Don't pair the box in the OS Bluetooth settings — let the tool scan and connect. If the phone app is connected, nothing here can reach it.
- The box only advertises when it's awake and on the right screen — see below.
Do this before your first scan, and any time the box has gone quiet:
- Press any knob for about a second to power it on — the side LED glows red.
- Tap the gear / settings icon in the top-left corner to open the Options screen.
- Tap the remote-control icon on the right-hand side.
- That's it — the box is now advertising and
k250-statuswill find it.
Wake it the same way if it's gone to sleep mid-session; the screen going off stops the radio.
Both shipped firmwares are supported. On either one, set the pattern on the box by hand before a
session — on v2.00.08b choose Intense (on that firmware it is the closest thing to the old
Manual slot); on v1.08 choose Manual.
- The pattern is the one control this repo cannot set. Power, frequency and channel selection all
drive over BLE on both firmwares. The pattern slot does not:
v1.08accepted a pattern write, andv2.00.08brefuses it outright — every name tried, including the one the box's own screen shows. - Why it matters. Left on one of the box's own generators (
Rhythmor any other built-in), the box runs its own waveform and the level you set is laid on top of it, rather than being the whole signal — so the number on the page is not the number you feel. Put it on the manual slot and the level you ask for is the level you get. - The two firmwares also put different scales on the wire:
v2.00.08brescaled both output axes (frequency to 0-100, power to a plain percentage). The engine reads the firmware the box reports and maps its axes accordingly, so both work without a flag;v1.08behaviour is unchanged.
The full contract: how the ceiling is enforced, and what else the code assumes.
The hard stops are at the top of this file. Two rules that keep them working:
- Decide ceilings calm, before a session, and write them in
limits.json. Don't renegotiate them mid-scene — that's what the file is for. The wearer's stop word and the hardware kill switch both override everything, including the software, including whoever is driving. - Skin check after every session. Not optional, and it's the one thing software can't see. Skin that settles back to normal is fine; pinched or marked is a stop.
The hardware kill switch is the real backstop: hold any knob on the box for 2 seconds and it powers off. Give that to whoever is physically in the room. It needs no software, no network, and no AI in the loop.
An AI driving an e-stim box is a genuinely new failure mode. Not malice — drift. A model that gets a "more" from one turn will happily carry it into the next, and a percentage that crept up while nobody was watching is how people get hurt.
So the ceiling is not in a prompt, it is in the code:
k250-scene speed_sweep --base 40 --hardcap 90
NOTE: --hardcap 90 exceeds the agreed cap 50% — clamped to 50%.
power.max_percent in limits.json is the contract. A command-line --hardcap can only lower it.
limits-form.html generates the file. A driver that can edit its own config is a driver that can
raise its own ceiling, so keep the limits file owned by the human, not the agent.
The session budget is enforced the same way: k250_session.py keeps a ledger and refuses to start
once session.max_duration_s is spent, whoever is asking. Default 30 min, up to 4 h; a fresh session
begins after 15 idle minutes, or with k250-scene --reset-session. K250_IGNORE_SESSION=1 is the
deliberate override.
| control | what it is | limit in limits.json |
|---|---|---|
| Power | the level | power.max_percent (enforced in code) |
| Frequency | the character — fast buzz → slow heavy thump (the box calls it Multi Adjust) | frequency.max |
| Slew | how fast the power dial may move — smooth glides vs snappy/chop | slew.max_percent_per_second |
All three can also be set per channel (channels.per_channel) — different channels sit on
different skin, so one global ceiling is the wrong shape. null means "use the global".
Naming note: the companion app calls the second control speed internally, which is misleading —
it changes character, not how fast anything moves. This project calls it Frequency and reserves
Slew for the rate of change. (The third physical slider, SO / Smooth Operator, is not in the
BLE protocol at all, so it can only be set by hand.)
Battery is not a limit — a reporting rule. The pack idles in the 20–30 % band during bench work,
so a charge figure above the threshold says nothing useful. Mention it at or below
battery.report_below_percent (default 10 %), or if the box drops off BLE and low charge is the
plausible cause. It has gone flat mid-scene before.
You are not a competent judge of how much current is fine, and you cannot see the person. Hold the agreed ceiling, prefer longer over harder. If they feel nothing, cut the power and check the loop — never escalate into silence.
Two rules any skill built from this repo must carry, in the skill itself:
- The stop word ends everything instantly, with no discussion and no check-in afterwards.
- No sensation means stop and check the loop — never more power.
Neither needs software in the loop: the stop word is absolute, and so is the hardware kill switch.
Both are already written that way in agent-skill/SKILL.md — see
For an AI agent.
| Advertised name | Kx250-4S — note it is not "k250". Match on this. |
| Service UUID | 086e0000-7935-0d3a-ca91-bfb0c8c34043 (advertised — the most reliable filter) |
| Characteristic | 086e0001-7935-0d3a-ca91-bfb0c8c34043 (read / write / notify) |
| Address | a random static LE address (it looks like any MAC, e.g. AA:BB:CC:11:22:33) — it changes on power-cycle. Never hardcode it; find() matches address or service UUID or name, and the UUID is the durable one. |
| Signal | roughly −50 dBm within a couple of metres. If you see it at −90, you're too far. |
The box only advertises when it's awake and sitting on Options → "Remote App Control" — the button the manual says to press when pairing with the companion app. The sequence is under Make the box discoverable. On any other screen, asleep, or with the screen off, it is invisible: a scan that finds nothing is almost always this, not a code problem. It does not enumerate as a USB device either — all control is BLE (USB-C is charge-only on Linux).
What it looks like: a small black handheld box, four knobs along the front, a colour touchscreen,
four output jacks on the side for the channel cables, USB-C for charging. Radio is an nRF52840 in
a Raytac MDBT50Q (FCC ID SH6MDBT50).
JSON text, compact, on the characteristic above. Not VESC. Fields:
| key | meaning | value |
|---|---|---|
PW |
power | firmware-dependent — 1.08: 0..10000 (1% = 100) · 2.x: 0..100 (the percent itself) |
MA |
Multi Adjust — frequency/character | 1.08: 0..10000 · 2.x: 0..100 |
PA |
pattern, one per channel | ["Waves","UNPLUG'D",...] |
AC |
selected channel — PW applies to this one |
0..3 |
MP |
max power level (system cap) | 5..100 → LCD L-05..L-100 |
CA |
per-channel plugged status | ["Active","Unplugged",...] |
FV / BC / SB / CS |
firmware / battery % / read-only / unknown | — |
Read-all (app sync):
{"AC":"","PW":"","MA":"","GP":"","PA":"","CA":"","MP":"","SB":"","BC":"","CS":"","FV":"","ER":"0"}.
Notifications are pretty JSON, numbers sometimes unquoted, and may split across packets — strip
\n, \r, \x00 and debounce ~60 ms before parsing.
- The value scales are FIRMWARE-DEPENDENT, and a wrong scale looks exactly like dead commands.
On
v1.08bothPWandMAare0..10000and the app maps its 0–100 slider by ×100 — the proof is that crankingMAto maximum by hand makes the box reportMA: 10000. Onv2.00.08bboth axes are plain0..100:PW=5is 5 %, andMAtops out at 100, with anything above echoed back as100. The engine reads the firmware the box reports and maps its axes to match, so there is no flag to pass; but if you write frames yourself, getting this wrong makes the box clamp silently and the axis look dead. - The box holds
PWandMA— and the ONLY thing that zeroes them is a PATTERN CHANGE. Set 40 % and it sits there indefinitely; there is no dead-man timer, and writingMAdoes not disturbPW. But switch the wave pattern — Manual → Waves, Waves → Climb, anything — and that channel's power and frequency both drop to zero. So: re-send power after anyPAwrite. Observed repeatedly on the box's own screen. PWis never reported in a read-all. It is only echoed when written. There is no software way to confirm power is flowing — the person wearing it is the only instrument. What you can detect is load:CAreports per-channelActive/Unplugged, the box's own continuity sense. Current, impedance and actual delivery are not answerable from here.- A channel with no pattern refuses power.
CAcan sayActivewhile the channel'sPAslot is blank, and every power write comes back as{"PW": 0}. See multi-channel below. - The box's own screen wigs out while you drive it — tab jumping, top bar smearing, the green active indicator flickering. It redraws its UI on every command it receives: measured on the real box, at ~7 BLE frames per second the top bar visibly jumps on essentially every write. Cosmetic and harmless — and a useful confirmation that your writes are landing at all. It is firmware behaviour, not something the driver does wrong. The only way to reduce it is to send fewer frames, which trades display calm for pattern resolution.
Why the engine still re-sends power anyway: because the pattern-change reset means a driver must
write after any PA change, and always writing means never having to special-case it. It's also what
the official app does. Cost is nothing; belt-and-braces, not a requirement of the box.
Also: the box gates channel selection on plug detection (AC writes to an unplugged channel are
silently refused), and the Reverse Polarity Switch is box-only — it does not exist in the BLE
protocol.
The engine drives every channel the box reports as live (CA), and this works on two channels
simultaneously.
- A channel with a blank pattern refuses power — see the blank-pattern note above. Give it a pattern first.
- Set every channel to
Manualbefore driving it. In a patterned mode the box runs its own generator and your power writes go into that; in Manual nothing competes, so what you write is what happens. You own the pattern, not the box. - Rotate channels in windows, don't flip-flop. Hold one channel for ~0.4 s, then move to the next. Rapid per-tick switching halves each channel's update rate and fragments the power stream.
Still untested: different patterns per channel. PA is a per-channel array, but the live channels
have only ever been driven with one pattern between them.
Power is level. MA is character: 0 = fast buzz, 25 = slow heavy thump, and the period in
seconds ≈ value ÷ 10000. It is not a garnish — power can sit dead still while MA moves and the
sensation changes completely. That's the trick this box has that most e-stim rigs don't.
What testing settled, in the order it turned out to matter:
- A flat level with the speed axis sweeping 0↔25 over ~13 s is the motion this box does best. Sweep periods of 8–20 s sit well; 4 s is too brisk.
- The hard drop is the money moment: speed
25 → 0in one step, not a glide. Sit at the top for a few seconds first; the landing lands harder. - Slow beats need more power to feel equal — compensate upward as
MArises. - Cooldown =
MA=0at ~⅓ of base power: a low hum that just sits there. It's a landing, not a gap. - These are starting points, not transferable numbers — see the placement note at the top and in
limits.json. - Compositions beat single motions:
arc= sweep ×2 → climb/sit/hard-drop → cooldown hum → sweep hotter.
k250-play <key> [--level N --secs N --limits FILE --address ADDR]
k250-play --list # EVERY key in one list: engine patterns + imported stims
k250-scene <pattern> [--base N --peak N --secs N --hardcap N --ma-top N --sweep-period N]
k250-scene --list # all patterns (same as: python k250_play.py --list)
k250-scene --limits-show # the active ceiling
k250-stop # INSTANT STOP: kills the pattern, zeroes every live channel
k250-status # read-only: battery, LIVE CHANNELS, pattern/speed per channel
k250-launcher # the local bridge + the limits page (http://127.0.0.1:6969/)
limits-form.html is the whole surface — you set the contract on it and drive from it — and
k250_launcher.py is a tiny loopback-only server that both serves it and lets it act.
Press Apply and it writes limits.json (merging, never replacing — your notes and
pattern_notes survive, and a timestamped backup is taken first).
Start it and open the page it serves:
k250-launcher # or: python3 k250_launcher.py · Windows: py -3 k250_launcher.py
# then open http://127.0.0.1:6969/It binds 127.0.0.1 only — nothing on your network can reach it — and it answers a
file:// page too, so opening limits-form.html directly still works.
Two modes, and only one is ever visible.
- Manual — you drive.
limits.jsonis out of the loop for these runs: the ceiling does not clamp them. You are the limit. - AI — the contract applies. The limits are enforced, and a level above the ceiling is refused rather than quietly overridden.
Section 2 (AI limits) and section 3 (Manual drive) never show together, so the two cannot be confused for one another.
Click to play — there is no Run button. Clicking a pattern or a stim plays it immediately at the level and Duration set above, and it loops until the Duration is up. Clicking another stops the run in progress and starts the new one; Stop is always one click. The hard-stops acknowledgement has to be ticked first, and a level of 0 plays nothing at all.
A BLE device picker chooses the target. The Devices card scans the air and lists only the e-stim devices it recognises; whichever you pick is where a run goes — there is no manual target switch. It is read-only: it finds, it does not connect. The run holds the radio (one connection at a time, as always).
Imported stims sit alongside the patterns. Section 3 carries a stim catalogue in the same
categories as the built-in patterns, de-duplicated and given readable names. Each one is translated
onto the box's two axes — its frequency + intensity timeline becomes MA + PW — or written to a
Coyote V3 as that device's native frames.
Three things about the bridge are deliberate and worth knowing:
- One control sets the ceiling. The page has a single AI power ceiling slider; the per-channel rows sit under it and can only lower a channel. (It used to take the lowest of the four channel sliders, so leaving an unused channel low silently dragged the ceiling down.)
- Manual mode is the override, and it says so out loud. Rather than a hidden raise, choosing Manual states plainly that the contract is out of the loop for those runs. In AI mode the ceiling is absolute: a level above it is refused, and nothing a script or an AI driver does can raise it. The limits file itself is yours to edit — nothing else writes it except Apply, which backs it up first.
- It zeroes after every run — the engine already ends at
PW=0, and the bridge then runs the stop tool once more, and says so loudly if it could not reach the box.
Patterns (k250_play.py) cover sweeps, hard drops, denial loops (trap, ration, dread), climbs
(climb, switchback), chaos (stutter, dice) and compositions (arc, signature).
k250_show.py --set <setlist> plays a whole scene in one connection.
k250_ctl.py holds a persistent link driven from a FIFO — the tool for exploring the protocol
rather than performing.
k250_ble.py / k250_codec.py one-shot control + the wire codec
k250_play.py pattern engine (the 64 patterns)
k250_show.py setlists / whole scenes
k250_ctl.py persistent FIFO-driven controller
k250_status.py read-only status
k250_stop.py panic stop (kills patterns, zeroes all channels)
k250_session.py session ledger -- the timer (no cooldown, no refusal)
limits.json THE CONTRACT — power ceiling, stop word, safety toggles
limits-form.html the limits page: set the contract, and drive from it
k250_launcher.py loopback bridge (127.0.0.1:6969) that serves that page
k250_stim_play.py play an imported stim on the box (loops to the deadline)
coyote_play.py play an imported stim on a Coyote V3 (native frames) — UNTESTED
stim_translate.py stim -> (frequency, intensity) timeline -> box axes / Coyote frames
stim_catalog.py merges the patterns + imported stims into one grouped catalogue
ble_scan.py BLE scan, flags the e-stim devices it recognises (read-only)
limits.manual.json the empty contract Manual mode runs against
web/ brand assets the page serves: fonts/ + images/logo-400.jpg
stim_library/ the imported stim catalogue: data, presets, format notes
install.sh installer for Linux and macOS (bash)
install.ps1 installer for Windows (PowerShell)
install.cmd launcher for install.ps1 — bypasses the script policy once
FINDINGS.md full reverse-engineering log, verdicts, dead ends
LICENSE Apache-2.0
agent-skill/SKILL.md portable operator skill — two hard rules, drop-in for an agent
tests/ nine suites, all runnable from any clone:
test_pattern_change.py regression: a PA change must clear the frequency cache
and seed slew to zero
test_limits_enforcement.py the limits file beats the command line, on every entry point
test_write_policy.py redundant power writes are skipped, but never unsafely
test_portability.py nothing shipped points at the author's machine; POSIX-only
calls are guarded; the shipped skill still has both rules;
install.ps1 keeps its promises (no PATH/registry edits, no
fixed Python floor, refuses an unwritable folder) and parses
under PowerShell when one is on PATH
test_wrapper_cli.py the wrapper tools work from any cwd, and fail loudly
test_shell_compat.py no bash-4-only syntax; arrays guarded for macOS's bash 3.2
test_limits_form.py limits-form.html still generates a contract the engine reads
(needs node; skips cleanly without it)
test_stop_parsers.py k250-stop finds the right processes on POSIX and Windows
test_install.py the installer's PATH handling: K250_ADD_PATH writes the rc line,
non-interactive runs write nothing, later runs do not duplicate
Run them all: for t in tests/test_*.py; do venv/bin/python "$t"; done
Everything lives in one folder — the modules are flat because they import each other by name, and
bin/ holds the shell tools that get symlinked onto your PATH.
Do these in order, the first time, with the wearer connected and someone's hand near the box.
- Wake the box and enable remote control (see Make the box discoverable).
It advertises as
Kx250-4S. - Check it's reachable —
k250-statusreads the battery, the live channels and the current pattern. - Read your limits before you drive anything. Open
limits.json, or build one withlimits-form.html. Know three things: the power ceiling, the stop word, and the session budget. - Lowest useful first run. With the wearer able to speak, and the physical kill switch in the room:
5 % is the shipped default start. It is meant to be barely anything — the point is to prove the path works, not to be a scene.
k250-scene speed_sweep --base 5 --secs 30
- Prove the stop works, both ways. Say the stop word out loud, run
k250-stop, and show everyone the hardware kill switch. Do this before the first real scene. - Then build — up only on the wearer's spoken word, never in silence. See the hard stops at the top.
Everything an agent needs is in the repo, as plain text. A skill built from this should be able to connect, drive, and stop the box without guessing:
| to do this | read this |
|---|---|
| Understand the device, and connect to it | Finding the box above, and FINDINGS.md — the full log, including the dead ends |
| Talk to it correctly (frames, keys, the firmware-dependent value scales) | The protocol section, and k250_codec.py |
| Know what it must never do | limits.json → safety.hard_stops, plus Safety & limits |
| Drive it | k250-play <key> --level N — any pattern or imported stim, by name or by the label the page shows. k250-scene <pattern> --base N --secs N works too. On Windows, where the wrapper can't run, the engine clamps to the same ceiling itself |
| Stop it | k250-stop — the correct response to a stop word, and to "I feel nothing" |
| See what's connected | k250-status |
| See what patterns exist | k250_play.py --list — works on every platform, no box needed |
Put the safety in the tool, not in the prompt. The engine clamps to limits.json no matter what a
driver asks for, so an agent cannot exceed the wearer's agreed ceiling even if it tries. An agent does
not need to be trusted, because the ceiling is not in its hands — that is the point of shipping a limits
file rather than a paragraph of good intentions. The two rules any skill must carry are in
If you're an AI reading this.
Point it at the folder — or the clone — and hand it this:
Read README.md in this repo. Follow the Install section for this OS, then the First steps
section. Read limits.json before driving anything. Use k250-status to check the box is
awake, k250-scene to run a pattern, and k250-stop to stop. The stop word is "red".
Never exceed the ceiling in limits.json. It is enforced in the code — do not work around it.
If I say I feel nothing, stop and check the electrodes; never add power. One BLE connection
at a time.
The skill is in the repo, not just described here.
agent-skill/SKILL.md is that skill written out — the two hard rules, the limits
contract, the tool table and the protocol traps. Copy the folder into whatever your agent auto-loads, or
paste it into a system prompt.
Things worth building next, in rough order of usefulness:
- A browser UI for the box — one page, no build step: pick a pattern, set base/peak/seconds, hit
go, with a big STOP button. The engine already speaks plain arguments, so it's a thin wrapper over
k250-scene. Pair it withlimits-form.html(already done) and a stranger can drive the box safely without reading a line of Python. - A pattern composer, and the surface for it. This project rests on the finding that power and
frequency are two axes, and that the interesting feelings come from how they move relative to each
other. A composer — "power: flat / climb / drop, frequency: sweep / hold / step, over N seconds" —
would generate far more patterns than anyone wants to hand-write, and the vocabulary already exists
in
k250_play.py. The engine is the hard part; the UI over it is small. - A session log. Timestamped record of what was run and at what numbers — partly for reproducibility, mostly because "what did we do last time" is the hardest question to answer afterwards.
- A pre-flight checklist the driver must answer before the first write: pads on where, loops loose, no mains, stop word understood, who's in the room. Cheap, and it's the step people skip.
Shipped since v1.0: multi-channel verified on two channels · per-channel limits · the three enforced controls (power / frequency / slew) · the enforced session budget · the limits page · the pattern-change fix and its regression test.
SB/CSsemantics unknown.- Firmware
v1.08's DFU container is encrypted; no plaintext recovered. - The whole repo is verified on
v1.08;v2.00.08bis untested and expected to need work. - Coyote V3 output (
coyote_play.py) is written but has never been run against real hardware. The frame format is derived from the device's own client and round-trips in our encoder, but that is not the same as hardware accepting it. Treat it as untested until someone has tried it.
Apache-2.0 — see LICENSE. Copyright 2026 vybesforge.
Why that one: this is a safety tool for a device that can hurt people, and it is deliberately written
to be read, copied and adapted — a limits.json you can rewrite for your own body, an operator skill
you can drop into your own agent. Apache-2.0 keeps that open (it is permissive, and it can be used in
closed projects) while adding an explicit patent grant and a requirement that modified files be marked
as changed. If you fork it and change how it clamps power, say so — in the file header, and in a note
to whoever is wearing the pads.
Nothing here is a medical device, and the licence says what every licence says: no warranty. The safety
that matters is in the code (limits.json is enforced, not requested) and in the hard stops above.
If this saved you a fortnight of reverse-engineering — the protocol, the pattern vocabulary, the fact that "I feel nothing" means less power — there's a tip cup at ko-fi.com/vybesforge. Nothing here is paywalled, nothing will be, and the ceiling clamping works identically whether or not anyone ever tips.
main is always the current release, and every earlier release is kept as a git tag — v1.0,
v2.0, v3.0, v3.1, v3.2, v3.2.1 … — so an old version is never lost and never in your way:
git fetch --tags # get the tags
git tag -l # list the releases
git checkout v1.0 # go back to a specific one, if you ever need to
git checkout main # and back to currentWhat changed, and when: CHANGELOG.md.
That is all version maintenance is here: tag a release, write down what changed, keep main
releasable. Nothing gets deleted, and nothing gets hidden inside the repo.