Gatekeeper triage for a voice-to-task courier.
Demo video (3 min): https://youtu.be/dl9dS00GEFY
Walkthrough (2.5 min, screen capture of the courier running): https://youtu.be/RGEm62mWekI
Development roadmap: planned capabilities, completion criteria, and the public tracking board: ROADMAP.md and the Project.
Interactive sandbox (no sign-in, no install): recorded Decider results, simulated integrations: https://dan-lee-odinson.github.io/ratatoskr-demo/
A conversation ends (on a Pocket wearable
recorder, or typed to a Telegram bot with /demo); each follow-up in it lands in Todoist and as a
three-line message on a CircuitMess Artemis smartwatch or any phone with Telegram:
research · high · 0.92
Email Priya the preprint
commit yes 0.91 · sens clear 0.82 · prio waiting
Strands Decider 2B, running on this machine's CPU, is the gate. First it reads the conversation
itself and holds it for review if anything in it is sensitive, or if it is unsure, before any
cloud model sees a word. Only then does an open-weight model on Amazon Bedrock
(openai.gpt-oss-20b, through the bedrock-mantle Chat Completions endpoint) extract the
commitments, and Decider makes six typed judgments about each task in one read: which project it
belongs to, whether a specific person is waiting on it, whether a stated deadline makes it late after
a date, whether it is worth following up
soon, whether it is a real commitment, and whether it is sensitive. Code turns those into a
priority. The transcript is never persisted, and a code-level tripwire holds any conversation that
names a forbidden client before any model, Decider included, sees it, even when the transcriber
misspells the name or splits it in two ("Vargrime", "var grime"): it matches listed spellings and
the name's sound (Double Metaphone).
Built for the AWS Builder Agent Experience Hackathon: Strands Decider. Tags:
#strands-decider-2b #policy.
AI-use disclosure. Dan Lee-Odinson directed this build and dispositioned its decisions. Claude Code (Anthropic's Claude Opus 5.5) wrote the code, the tests and this documentation from Dan's written handoff. It is a public spin-off of Dan's private courier, with the personal taxonomy and the deployment removed.
Triage before action, as a folder another builder can copy: a code tripwire the model cannot
override; a gate on the source text, where there is one; six typed questions answered by Strands
Decider in one read, with a confidence floor per question and the priority set by a rule in code;
and a hold whenever the answer to "is this sensitive?" is yes or unsure. It ships a
standard-library Decider client (gate_source_text() and triage()), a Strands Agents
BeforeToolCallEvent hook that blocks a pending tool call, the questions as TOML, and a
twenty-line example. See patterns/gatekeeper_triage/README.md.
A second recipe, patterns/photo_scene/, decides with one read of a Strands
Decider vision server whether a picture is a commitment or reference material, hands an unsure one to the owner, and
wires the answer so that reference never reaches the extraction step.
| what | its role | |
|---|---|---|
| required | a computer (a laptop is enough) | runs the courier from the command line |
| required | an Amazon Bedrock API key | openai.gpt-oss-20b extracts the commitments |
| required | a Telegram bot and its chat | /demo <text> in, /status, and every notice out |
| required | a Todoist account | where the tasks land, in one project |
| required | Strands Decider (local; 16 GB minimum host RAM, see System requirements) | the source-text gate and the routing ([route] backend = "decider") |
| optional | a Pocket recorder and its API key | conversations in, recorded instead of typed (inputs = ["pocket", ...]) |
| optional | an Artemis watch, or any phone with Telegram | where the three-line message is read |
| optional | photos now; smart glasses later, through the mock kit | the photo-folder input (desktop-fed pictures through ledger/inbox/photos/) works and is measured; the video path and native glasses capture through Meta's toolkit remain a stub |
| optional | a TypeSafe API key | the bench's Jev column only |
The minimum install is the five required items, [devices] inputs = ["text"] and
[route] backend = "decider". A secret is asked for only when the adapter that uses it
is enabled: no Pocket key without the Pocket input, no TypeSafe key without Jev or the bench.
Decider 2B runs on the host, so the host is the one real requirement. Ratatoskr works on a laptop CPU
without a GPU; an optional GPU configuration gives the measured improvement in bench/RESULTS.md on
the same workload. Every other number this project measured, in this README and in the article, was
measured on a Windows 11 laptop with 16 logical cores, Decider held to four of them, and no GPU.
| Host | Minimum | Measured on the reference machine |
|---|---|---|
| CPU | 4 threads free for Decider while it reads; no other CPU-heavy job on the machine | 4 intra-op threads (and 1 inter-op), laptop CPU with 16 logical cores |
| Memory | The text path: 16 GB minimum host RAM; the server commits about 10 GB, and the memory guard stops it at the --max-commit-gb value (off unless given; the start command below sets 16). With photos enabled, the vision server runs beside it (measured, right) |
The text path: 9.9 to 10.4 GB committed over 70 reads per server. With photos enabled: about 10.1 GB private for the text server plus 12.19 GB for the vision server (bench/IMAGE_EVAL_2026-10-22_final.md) |
| GPU | None needed | None on the default path (CPU-only torch build; kernels: reference PyTorch implementation, causal_conv1d and flash-linear-attention not installed). Optional, measured: NVIDIA GeForce RTX 5050 Laptop GPU (8 GB, driver 610.74), torch 2.14.1+cu130, bfloat16 weights, the same reference kernels: 0.44 s per six-question read warmed, against 11.01 s on the CPU in the same session (see "Running on a GPU") |
| Disk | Decider weights (4.4 GB in the Hugging Face cache: the 4.3 GB Qwen3.5-2B base model and the 88 MB Decider checkpoint) plus two virtual environments | .venv-decider (server), .venv (courier) |
| Python | 3.12 or later (requires-python = ">=3.12") |
3.12.10 |
| OS | Windows 11 with PowerShell is what the run commands assume; the courier and the pattern have no Windows-only code (the courier needs httpx and Metaphone from PyPI; the pattern's client is standard-library Python) |
Windows 11 |
| Network | Outbound HTTPS to Bedrock, Telegram, and whichever of Todoist and Pocket are enabled; Decider listens on 127.0.0.1:8000 only and must never be exposed |
Same |
| Accounts | AWS with Bedrock model access to openai.gpt-oss-20b; Telegram bot token and chat id; Todoist token and Pocket API key only for the adapters you enable |
All four |
Measured latency: median 11.1 s (p90 11.3 s) per six-question read in the fresh evaluation of 21 Oct
2026 (bench/FRESH_EVAL_2026-10-22.md), and 10.8 s in the bench pass of 19 Oct, both with the laptop otherwise idle. The overnight pass of 17–18 Oct, also with the laptop otherwise idle, measured 30.2 s;
what changed between them is not established. With another job keeping every core busy, reads
did not finish at all: 0 of 44 answered on 17 Oct.
Recommended settings:
- Start the server with a pid file and the memory guard:
.\.venv-decider\Scripts\python tools\serve_decider.py --pid-file ledger\decider.pid --max-commit-gb 16 - Keep the courier poll interval at its default of 30 seconds (the config refuses anything lower). A read must finish well inside one interval, so do not lower it on a CPU-only host.
- The spend ledger's hard stop is $5.00, and it is a ceiling:
[spend] hard_stop_usdin the config can lower it, and the config refuses anything aboveSPEND_CEILING_USD($5.00, inratatoskr_demo/config.py). - Write a one-line description for every project in
[project.criteria]ofprompts/route.toml(the sort reads these;prompts/context.mdonly words the extraction). On the bench, the descriptions carried most of Decider's confidence on the project sort (the 19 Oct bench in RESULTS.md: 18 of 30 sure with them, 4 of 30 without). - Give Decider the machine while it runs. With another job keeping every core busy, reads did not finish (above): the courier then makes recordings wait rather than lose them, and the bench can hang. The bench timeout is 600 s for that reason; the courier's own read timeout is 180 s.
Every input and output passes through one small record, the device envelope
(ratatoskr_demo/devices/): Envelope(device_id, kind, ts, payload), with a payload of
TranscriptRef, Text, Button, TagRequest, AudioRef or VideoRef, validated on
construction and published as JSON Schema. Every input adapter has the same three methods
(list_new, source, fetch_text), and the cycle talks only to adapters: the courier runs the
same with Pocket, with the /demo text alone, or with the glasses stub.
flowchart LR
subgraph inputs["inputs (device adapters, any of them)"]
T["Telegram /demo <text><br/>text_adapter"]
P["Pocket recorder<br/>pocket_adapter"]
G["Photos, glasses_adapter<br/>the photo-folder input (desktop-fed pictures<br/>through ledger/inbox/photos/) works and is measured"]
V["Smart glasses, glasses_adapter<br/>the video path and native glasses capture<br/>through Meta's toolkit remain a stub"]
end
E(["device envelope<br/>Text · TranscriptRef · ImageRef · VideoRef ..."])
T --> E
P --> E
G --> E
V -. "slot only" .-> E
E --> TR{"triage<br/>keep · already · not ready ·<br/>hold tag · ledger hold ·<br/>stop-list tripwire"}
TR -- "names a forbidden client" --> H1["HOLD<br/>no model is called"]
TR -- process --> SG{"source-text gate<br/>Strands Decider on 127.0.0.1<br/>reads the transcript, ~300-word windows"}
SG -- "sensitive, or unsure" --> H0["HOLD for review<br/>no cloud model is called"]
SG -- clear --> X["extraction<br/>openai.gpt-oss-20b on Bedrock<br/>(spend ledger, identity check)"]
X --> L{"C9 leak guard<br/>8-word spans of the transcript"}
L -- "quoted twice" --> H2["HOLD: leak-guard"]
L -- clean --> D["Strands Decider 2B, local CPU<br/>one read, six judgments per task<br/>project · waiting · deadline · soon · commitment · sensitive"]
D -- "sensitive, or unsure" --> H3["HOLD for review"]
D -- clear --> W["Todoist project<br/>labels, priority by rule, [maybe]"]
W --> R["wrist_adapter<br/>Telegram message"]
R --> A["Artemis watch, or a phone"]
Priority by a rule. Decider answers three yes/no questions: is a specific person (named, or
known by relationship or role) expecting a reply or a deliverable from the owner, not merely the
owner waiting on someone else (someone_waiting); is there a date, time or event set by someone
else, or agreed with them, after which it is late (hard_deadline; a date the owner picked for
themself is not one); and is it worth following up in the next few weeks (worth_researching_soon).
Code decides, in order: waiting → high; else a deadline within 30 days of the recording → high (a
later one does not count); else worth it → medium; else low. If the step that would decide rests
on an answer Decider is unsure of, the project's default applies. The due date is read by code
(ratatoskr_demo/due.py), counted from the recording's own date, never today's. The wrist line says
which step set it: prio waiting, deadline, soon, none, or default:waiting (and so on: the
unsure question).
When a privacy check is unsure, the recording is held, never passed through. The only outcome of
an uncertain answer is an id-only notice back to the owner (the recording's id and the kind of hold;
no title, no words from the conversation); the recording is released by a tag (ok in Pocket).
The project sort is not a privacy question: it judges a task whose conversation has already been cleared, and when it is unsure the task is filed "unsorted", not held.
Privacy is a feature of this design, and these four refusals are how it is built. With Decider
configured, which the quickstart ("Run it") requires, nothing in the config loosens them; the two exceptions
are the urgent tag while Decider is unreachable, described below, and the demo-only
backend = "none" setting, which runs the courier with no sensitivity gate (the stop-list
tripwire still runs; see "Running without a gate (demo only)").
The tripwire. Before any model call, Strands Decider included, a check in code holds any conversation that names a stop-listed client, even when the transcriber misspells the name or splits it in two ("Vargrime", "var grime"): it matches listed spellings and the name's sound (Double Metaphone). The hold notice gives the recording id only, never its title, because Pocket builds titles from the conversation.
The source-text gate. An extraction can drop the very detail that made a conversation
sensitive ("cover Mara's talk", without "she starts medical leave"), so Decider first reads the
transcript itself: split on speaker turns into windows of about 300 words (Decider reads 4,096
tokens), one sensitive question per window, and the conversation is held if any window is
sensitive or Decider is unsure of one. Decider is the only model that may read transcript text,
because it is bound to 127.0.0.1 (the config refuses any other host) and persists nothing; a test
fails if transcript text is ever sent anywhere else but the extraction itself. A window takes a
few seconds on four laptop CPU threads (median 2.1 s, p90 4.1 s in the fresh evaluation of 21
Oct; 1.7–3.3 s in the 19 Oct bench), which is fine for the
demo's short recordings. With the bench-only "jev" backend the source gate is skipped, and the
post-extraction check on each task (below) is the only sensitivity check.
Task-text sensitivity. After the extraction, one of Decider's six judgments about each task is
whether a person should review it first: sensitive yes, or unsure (below its floor, or an
answer the courier cannot read), holds the whole recording for review, and nothing of it is
written.
Nothing written while Decider is down. If Decider is down, a read times out, or Decider
answers in a shape the courier cannot use (an HTTP error, a non-JSON body, no answers), nothing is
written: the recording waits and is retried, and is never sent to Bedrock ungated; after three
cycles without Decider, one notice says how many recordings are waiting. The one exception, for
Decider unreachable and never for an unusable reply: a
recording tagged urgent in Pocket is, while Decider is unreachable, extracted and written
unrouted with priority high and the label unrouted ("urgent · unrouted" on the wrist); the
tripwire still runs first, and with Decider up urgent changes nothing.
Apart from the source gate, the transcript exists only in memory during one cycle: the envelope carries the recording id, the cycle fetches the text at extraction time, and nothing writes it anywhere. For routing, Decider is shown only the guarded task, its notes and due date, and the contact line, never the transcript or the recording's title.
Ratatoskr writes to places other people can see. These are the rules that keep a wrong answer from becoming a visible mistake, and what you are expected to do on your side of them.
What the courier will never do
- Store a transcript. The transcript lives in memory for one cycle and is gone when the cycle ends. A test checks that no eight-word span from any fixture transcript appears in any output file.
- Post text from a held recording. A hold notice carries the recording id, the kind of hold, and the release instruction, and never a word of the recording.
- Pass an unsure privacy answer. A sensitivity answer below its floor holds the recording, at the gate and at routing, and a malformed sensitivity answer at routing holds it too. No answer at all, or a reply the courier cannot use, makes the recording wait for a healthy server; a wait is never read as a yes or a no. An explicit, recorded owner release (the Release button on a photo held as unsure) authorizes continuation past a gate's unsure hold for that unchanged photo, and the ledger records it as the owner's; a model clear and an owner release are never confused. The gate runs before extraction, so a recording the gate holds or waits on has not left this machine.
- Bypass a Decider verdict. The
urgenttag skips the gate only while Decider is unreachable; a verdict, once given, stands. - Write anything new while Decider is down. If the health check fails or a read times out, nothing
is written until a later cycle sees a healthy server; a recording already extracted is not
written either, and its read is not asked again until then. The one exception is a recording you
tagged
urgentyourself, which is written unrouted with the tripwire still run first. - Spend past the ledger. Bedrock calls stop at the configured cap ($5 by default).
- Talk to a model it did not expect. The model-identity check refuses an endpoint that answers as a different model.
What runs before any model sees a word
The tripwire is a list of protected names and their known misspellings, matched phonetically and across adjacent words. A hit holds the recording with no model call. Put every client, patient, or person you must never post about on that list before the first live run, including the ways a transcriber is likely to mangle the name.
Secrets
Tokens live in .env, which is git-ignored. The HTTP log filter redacts tokens from log lines. The
publish step scans tracked files for secrets and for the names on the forbidden-names list, which
lives outside the repo. If a token ever appears in a log, a screenshot, or a commit, rotate it at
the provider before doing anything else.
Decider stays local
The server listens on 127.0.0.1:8000. Do not bind it to another interface, put it behind a reverse
proxy, or run it on a machine other people can reach. The whole privacy design rests on the
sensitivity judgment being made on the machine that holds the transcript.
What you must do
- Tell people you are recording. Laws on recording conversations differ by place, and some require every party's consent. The courier does nothing to establish consent; that is yours.
- Read the held items. A hold is a question for you, with a one-line notice and no text. Release with a tag or leave it; the courier will not decide for you.
- Keep the project criteria current. The project sort's confidence comes mostly from the one-line
criteria under
[project.criteria]inprompts/route.toml; a stale list means more tasks arrive unsorted. - Give Decider the machine while it runs. Starved of CPU, reads overrun their timeout, the health check makes recordings wait, and a bench can hang (see System requirements).
Where the judgments are weak
In the fresh evaluation of 21 Oct (bench/FRESH_EVAL_2026-10-22.md: 24 new conversations, labelled before any model
was asked), Decider's project sort misfiled nothing: it was sure of 8 of the 26 labelled ordinary
tasks, right on all 8, and left the other 18 unsorted. In the 19 Oct bench (RESULTS.md) it was sure
on 18 of 30 fixture tasks, right on all 18, and on the five live tasks sure of none. The
hard-deadline question was wrong on 4 of the 13 answers it was sure about in the fresh evaluation
(on the 19 Oct bench, 12 of 21 against the labels as first written, 5 of 21 as corrected on 20 Oct,
Dan's ruling 40). The priority rule takes
a sure "deadline: yes" at face value and reads the date in code only to decide whether it falls
within thirty days, so a wrong yes costs a priority level and never a task. Priority and project are
filing decisions and fail safe (the task lands on the list unsorted, and the wrist says so).
Sensitivity decisions fail closed (hold). Use the courier for lists where a misfiled task costs you
a minute, and keep it away from anything where a misfiled task could cost someone else more than
that.
- It runs from the command line on one machine. There is no web interface, no database and no
queue; Strands Decider is served on
127.0.0.1and nothing else may reach it. - Glasses: the photo-folder input (desktop-fed pictures through
ledger/inbox/photos/) works and is measured; the video path and native glasses capture through Meta's toolkit remain a stub. The stub accepts an envelope carrying aVideoRefand returns it unchanged, so the seam has the slot. Nothing reads video. - It never deletes a recording, and it sends messages only to the one Telegram chat configured.
- Tasks go to one Todoist project (
Ratatoskr Demo) and nowhere else. - Strands Decider on a laptop CPU is not fast (see "How fast Decider is here" below): a recording
with three commitments takes about half a minute to a minute to route. It costs nothing per call and never
leaves the machine. The demo is filmed with one-commitment recordings and
/demo. - Extraction quality is that of a 20-billion-parameter open-weight model at low reasoning effort: it misses some commitments and due dates. The leak guard, not the prompt, is what keeps the transcript's wording out of the output.
| where | what one read covers | time | |
|---|---|---|---|
| this build (the fresh evaluation, 21 Oct 2026) | a laptop CPU, 4 threads, full precision (fp32), the reference PyTorch kernels (no causal_conv1d, no flash-linear-attention), below-normal priority, strands-decider 0.1.0, v21 |
all the questions about one task in one read, about 960–980 tokens | about 11 s per task (fresh evaluation median 11.1 s, p90 11.3 s; 19 Oct bench median 10.8 s; 10.5–18.1 s in the 18 Oct live run) |
| Strands Decider's README | an NVIDIA RTX 3090 under WSL2, v19 | one JevBench question | median 115 ms (95th percentile 299 ms) |
The second row is the project's own GPU figure, for an earlier checkpoint and one question at a time; the first row is the one that applies to this build. Up to the overnight bench of 17–18 Oct the same reads took 20–40 s (median 22–30 s); from the first live run on 18 Oct they took 10–18 s. What changed is not established. The demo machine shares its CPU with another long-running job, so Decider is held to four threads throughout. Decider's prefix cache stays on: without it a read took 46–94 s.
| Safeguard | Where |
|---|---|
No transcript persistence (C1); the /demo text is a transcript too |
cycle.py, tests in test_leak.py, test_devices.py |
| Stop-list tripwire before any model call, Decider included; it matches listed spellings and sound-alikes ("Vargrime", "var grime") | triage.py, tests in test_triage.py |
| Source-text gate before any Bedrock call; transcript text only ever to Decider on loopback, or to the extraction | source_gate.py, test_source_gate.py |
| Leak guard: any 8-word transcript span in model output is rejected, retried once, then held | guard.py |
| Hold on sensitive or unsure | cycle.py, judge.py |
| Notices carry the recording id only, never its Pocket title (Pocket builds titles from the conversation) | cycle.py, commands.py, tests in test_notices_and_short.py, test_leak.py |
| A transcript under 8 words (an accidental press) is skipped before any model: no read, no notice | cycle.py, test_notices_and_short.py |
| Bedrock spend ledger: worst case reserved before every call, hard stop at $5.00 | spend.py |
Model identity: every response must name openai.gpt-oss-20b |
bedrock.py |
Model record: the model's GET /v1/models entry must match config/model_baseline.json |
bedrock.py |
| Lost network vs Bedrock error: a lost network is never counted; a request that went out stays at worst case | bedrock.py |
| The key only in the Authorization header; no secret in any log, ledger or exception | bedrock.py, test_leak.py |
| Idempotent writes: deterministic Todoist command ids | todoist.py |
The nine hard constraints are in CLAUDE.md.
Python 3.12. Two virtual environments: one for the courier, one for Strands Decider (torch lives there).
python -m venv .venv
.venv/Scripts/python -m pip install -e ".[dev]"
python -m venv .venv-decider
.venv-decider/Scripts/python -m pip install strands-decider==0.1.0Copy .env.example to .env and fill in the keys the enabled adapters need (see "What you need":
at least the Bedrock key, the Telegram bot token and chat id, and the Todoist token). Then:
.venv-decider/Scripts/python tools/serve_decider.pystarts Strands Decider on 127.0.0.1:8000, on the CPU, capped at four threads (the first run
downloads the Qwen3.5-2B base model, about 4.5 GB). Leave [route] backend = "decider" in
config.toml, as shipped. In another terminal:
.venv/Scripts/ratatoskr-demo --decider-check
.venv/Scripts/ratatoskr-demo --once --dry-run
.venv/Scripts/ratatoskr-demo --loop--loop polls Pocket every 30 seconds and answers /status and /demo <text> on Telegram, from
the configured chat only. Other commands: --spend (the Bedrock ledger), --rotate-options N (the
option-order test), --inspect-shape <recording_id> (key names and types of one real recording,
never values), --scrub-list. For the bench's real-task set, --once --dry-run --save-tasks
(optionally with --demo-text "<text>" and --option-descriptions on|off) saves the guarded
extractions, never the transcripts, to the git-ignored bench/raw/.
Every command above runs Decider on the CPU, and that stays the default. On a machine with an NVIDIA card, a second Decider venv holding the CUDA 13.0 build of the same torch serves it on the card, with the weights in bfloat16 (the measured venv held the CPU venv's other packages at the same versions):
python -m venv .venv-decider-cuda.venv-decider-cuda/Scripts/python -m pip install "torch==2.14.1" --index-url https://download.pytorch.org/whl/cu130.venv-decider-cuda/Scripts/python -m pip install strands-decider==0.1.0.venv-decider-cuda/Scripts/python tools/serve_decider.py --device cuda --pid-file ledger/decider.pid --max-commit-gb 16Measured on an NVIDIA GeForce RTX 5050 Laptop GPU (8 GB, driver 610.74), in one session against the
CPU on the same workload (bench/RESULTS.md, "Server variants"): 0.44 s per routing read and 0.11 s
per gate window warmed, against 11.01 s and 2.23 s on the CPU; a cold start of 12.0 s; 5.0 GB of card
memory at peak. One answer at a floor moved from sure to unsure (bfloat16 on the card against float32
on the CPU), and nothing the courier does changed. Without a CUDA device, --device cuda stops at once
with a message; it never falls back to the CPU.
With [route] backend = "none" and no Decider, no sensitivity check runs and nothing is held for
sensitivity (the stop-list tripwire still runs); the courier logs
backend=none: no sensitivity gate; nothing is held for sensitivity (the stop-list tripwire still runs)
at startup, and every task it writes carries the label ungated, which the wrist shows in place of
the sensitivity field.
When Decider is unsure of a task's project, the task is written to Todoist with no project label, and its
Telegram message carries one button per project, in the order of Decider's own probabilities for that
task, and a last button, Leave unsorted. A tap updates the existing Todoist task with that project's
proj: label and rewrites the message's first line to say the owner filed it; the model is not asked
again. The choice is recorded in the ledger as the owner's (project_by: owner), and a second tap changes
nothing. Taps are handled by the loop's Telegram receiver: they take effect at once while --loop runs.
A twelve-second clip of the tap: https://youtu.be/-QixQrUyyws
The same filing from the companion mobile web prototype in app/, through the courier's owner service, in a 38-second recording: https://youtu.be/BLX68APO6LM
A second input: photos (PNG, JPEG, WebP or GIF) dropped in ledger/inbox/photos/, with "glasses" in
[devices] inputs. A phone photograph is read upright (its EXIF orientation honoured) and at most 2000 px on its
long side, from a copy in memory, the file untouched; a file over 12 MB, or a HEIC file, is held as unreadable
before any model. Each passes three checks on the laptop before any cloud call: a file that is missing,
unreadable or blank is held before any model is asked; a second Strands Decider server, started with
--vision, answers "Is this picture of something that must never leave this machine?" and holds the photo on
yes or unsure; then RapidOCR, on the laptop, reads the photo's text, which meets the stop-list tripwire and the
text gate like any transcript before the extraction on Bedrock. The image gate uses the same checkpoint, which
was not trained on images, at the text gate's floor, a starting point rather than a tuned value. A photo held as
sensitive stays in the folder, with no way to release it. One held as unsure by the image gate or the text gate
gets a Release button on its notice; the owner's tap sends nothing anywhere and lets the photo continue on the next
cycle from the check after the one that held it, every later check running as usual. A recorded owner release
authorizes continuation past that gate's unsure hold for that unchanged file (the ledger keeps the file's sha256; a
changed file is a new picture); it is distinct from a model clear and is recorded as the owner's. The 8-word floor that closes
an accidental press on a recording does not apply to a photo: a note with four words on it goes on, and a photo whose
text has no words at all is held as unreadable. After the text gate and before
anything is extracted, the vision server is asked what the picture represents, from the picture and its text, at
the routing read's floor for a choice question (also a starting point): a commitment, a promise in either
direction, or reference material. A commitment goes on as before; reference material (a menu, a poster, a catalog
page) is kept as a note in ledger/reference/ with nothing written to Todoist; and when the answer is unsure,
nothing is sent anywhere and the owner is asked on Telegram, with two buttons, which it is. Reference material never
leaves the laptop. Who owes whom is read by the extraction and by the owner, not decided from the picture: the
image evaluation showed the checkpoint could not tell the direction of a promise from a picture and its text. For the evaluation
(bench/IMAGE_EVAL_2026-10-21.md) the folder was fed from the desktop with rendered pictures. A second evaluation
(bench/IMAGE_EVAL_2026-10-22_camera.md) fed it twelve phone photographs of cards made by hand for the run: none of
the four sensitive cards reached Bedrock or Todoist, all four reference cards were kept on the laptop with no owner
action, and none of the four commitments was written as its task, the OCR having missed a key word on every
handwritten card it read (8 of 8). The vision server runs from its own venv, beside the published one:
python -m venv .venv-decider-vision.venv-decider-vision/Scripts/python -m pip install "strands-decider[vision]==0.2.0".venv-decider-vision/Scripts/python tools/serve_vision.py --pid-file ledger/vision.pid --max-commit-gb 16and the courier needs the OCR: pip install -e ".[photos]".
An 85-second clip of the photo path, four outcomes and two owner taps: https://youtu.be/pDflGWOV-xY
After the install above. Everything here is a file you edit; nothing needs code.
-
Where the project list lives. Two files, packaged in
ratatoskr_demo/prompts/. Copy both to aprompts/folder at the repository root and edit the copies there: a copy in that folder ([paths] prompts_dirinconfig.toml) wins over the packaged one.route.toml, section[project], is the sort. Each project is one line in[project.criteria], a short name and a one-line description in your own words, and one line in[project.defaults], the priority ("low","medium"or"high") that project's tasks get when the priority step that decides is unsure. The name is one word: it becomes the Todoist labelproj:<name>. Every project needs a default, and a startup error names any that is missing. Keepother: the question tells Decider to choose it when nothing fits.context.mdgives the extraction the same areas in plain sentences, one- Name: descriptionline each, so tasks come out worded sensibly.
One entry as it is today, in
route.toml:[project.criteria] research = "Papers, preprints, reviewers, data and methods." [project.defaults] research = "medium"
and in
context.md:- Research: papers, preprints, reviewers, data and methods. -
Write descriptions the sort can use. Decider judges each task (its text, its notes, and the contact's organisation and role) against these lines. Make each one concrete and one line long, naming the kinds of task and the people that belong there. Four to six projects that do not overlap sort better than seven that do.
- Good (today's
school):"Coursework, assignments, instructors, registration, study partners."It names things and people, and no other project claims them. - Weak:
"Stuff for my studies and other important things."It names no kind of task, and "important things" overlaps every other project.
What the 19 Oct measurement showed: the descriptions carry most of the sort's confidence. With the project names alone, Decider was sure of a project for 4 of 30 test tasks instead of 18. On real conversations the sort often stays unsure: of the five tasks from the first live run it was sure of none, with or without the descriptions, and the courier files each one unsorted.
- Good (today's
-
The stop list. In
config.toml,[triage] stop_listholds one entry per protected name, the name astermand every misspelling a transcriber has produced asspellings:stop_list = [{ term = "vargrheim", spellings = ["vargrime"] }]
Add a name as another
{ term = "...", spellings = ["...", "..."] }in the list (spellingsmay be left out); the list may not be empty. A recording is held if the term or a listed spelling appears in its title or transcript, in any case, or if a word there, or two neighbouring words written together, sounds like the term (Double Metaphone). This is code (ratatoskr_demo/triage.py) that runs before any model sees the conversation, Strands Decider included; no model takes part in it, and none can override it. The hold notice gives the recording's id only; tag the recordingokin Pocket to release it. -
Where tasks land, and "unsorted". In
config.toml,[todoist] inbox_project_name = "Ratatoskr Demo"is the one Todoist project every task goes to (created if it is missing), andlabel = "pocket"goes on every task. A sorted task also getsproj:<project>(for exampleproj:research); a task written under theurgenttag while Decider was unreachable getsunrouted. The wrist's first line is<project> · <priority> · <confidence>. "unsorted" in the project's place means Decider's project answer was below its floor (min_confidence = 0.70under[project]): the task is still written, to the same Todoist project, with thepocketlabel and noproj:label, and the confidence shown is that unsure answer's. If its priority also rested on an unsure answer, it has none, since there is no project default to fall back on. -
Check the setup before the first run. With Strands Decider serving (under "Run it"):
.venv/Scripts/ratatoskr-demo --decider-check
It asks Decider about one invented test task and prints the answers beside the expected ones. A good result looks like this (the numbers vary):
decider-check: 1 canonical fixture task (rec_001) -> strands-decider-2B-hobson-v21 (<n> input / <n> output tokens, <n> ms) task: <the test task> decider: project research <c> · priority high by waiting · waiting p=<p> <c> · deadline p=<p> <c> · soon p=<p> <c> · commit p=<p> <c> · sensitive p=<p> <c> expected: project research, priority high, commitment True, sensitive FalseEach yes/no question shows Decider's probability of yes (
p=) and its confidence; an answer under its floor is marked(below floor).decider-check: Strands Decider not usable at http://127.0.0.1:8000: ...means Decider is not running or answered in a shape the courier refuses. Then:.venv/Scripts/ratatoskr-demo --once --dry-run
It runs one poll against your real inputs and writes nothing: no task, no notice, no ledger entry. The log lines of a good run look like these, from the 19 Oct dry run (titles replaced):
INFO ratatoskr_demo.bedrock: bedrock: model record for openai.gpt-oss-20b matches the baseline INFO ratatoskr_demo.decider: route: Strands Decider up (StrandsAgents/strands-decider-2B-hobson-v21, device cpu) INFO ratatoskr_demo.cycle: network up INFO ratatoskr_demo.source_gate: source gate: 1 of 1 window(s) asked, max p=0.22 -> clear INFO ratatoskr_demo.decider: route: project=None priority=None (default:waiting) real_commitment=True sensitivity=clear <recording id> '<title>': Process would create: <task> labels=['pocket'] priority=- ok: 7 listed, 7 examined; today 0 processed, 0 held, 0 errorsGood means: the model record matches, Decider is up, an ordinary conversation clears the gate, every recording has a triage line (
Process, orHold/StillHeldwhere you expect a hold), and the last line starts withok:. A stop-listed recording showsHold (stoplist:<term>)with no model line for it.project=None, andlabels=['pocket']with noproj:label, mean the sort was unsure: that task would be filed unsorted.
bench/run_bench.py asks the same six questions of Strands Decider on the CPU, of
openai.gpt-oss-20b on Bedrock (as a JSON-answer prompt with the same question text) and of
TypeSafe's Jev, over every task extracted from the fixtures and over the 30 canonical tasks as
written, and scores each against the fixtures' expected answers; it also runs the source-text
gate on every transcript first. The generated report is bench/RESULTS.md.
With --option-descriptions both it asks every task twice, with each project option's one-line
description and with the names alone, side by side; --real-tasks adds tasks saved from real
recordings (reported as counts, never their text). With --fresh it runs the fresh evaluation
instead: each conversation in bench/fixtures/fresh/ through the courier's own cycle.
The fresh evaluation (bench/FRESH_EVAL_2026-10-22.md, run on 21 Oct 2026 on the frozen configuration, tag
eval-freeze-2026-10-22): 24 new fictional conversations, 12 with something private in them and 12
ordinary, their expected outcomes written and committed (bench/fixtures/fresh/labels.json) before
any model was asked, each through the courier's own cycle with Strands Decider on the laptop CPU.
Decider only; the outcomes come from the courier's own records:
| fresh evaluation, 21 Oct 2026 | result |
|---|---|
| sensitive conversations written anyway | 0 of 12 (all 12 held by the source gate, before any Bedrock call) |
| ordinary conversations held anyway | 4 of 12: 3 by the source gate as unsure, 1 by the task check after extraction |
| Bedrock extraction calls / task writes for held conversations | 1 / 0 (the one call: the ordinary conversation the task check held) |
| project, 26 labelled ordinary tasks: right / wrong / left unsorted | 8 / 0 / 18 |
| priority right (ruling 7's rule), 26 labelled ordinary tasks | 11 of 26 |
| real commitment, right when sure | 23 of 24 |
| sensitive (the task text), right when sure | 24 of 24 |
| median / p90 per read | 11.1 s / 11.3 s (six questions); 2.1 s / 4.1 s (one gate window) |
The bench comparison (bench/RESULTS.md, the pass of 19 Oct 2026): on the 30
canonical tasks, the laptop otherwise idle, with each project option's one-line description, as the
courier asks, against the labels as first written on 18 Oct (RESULTS.md also counts them as
corrected on 20 Oct; only the priority row moves, to 50% / 70% / 63%):
| Strands Decider (laptop CPU, 4 threads) | gpt-oss-20b on Bedrock | Jev | |
|---|---|---|---|
| hold decision (sensitive or unsure) right | 30/30 | 27/29 | 29/30 |
| project right / sure / right when sure | 80% / 60% / 100% | 80% / 93% / 86% | 90% / 87% / 96% |
| priority right (set by the rule) | 53% | 73% | 67% |
| real commitment right when sure | 100% | 97% | 100% |
| sensitive right when sure | 100% | 96% | 100% |
| median latency per task | 10.8 s | 1.7 s | 0.15 s |
| cost per task | $0 | about $0.00013 | about $0.00005 |
The option descriptions (RESULTS.md, 19 Oct). Asked again with the project options as their names alone, Decider was sure of the project for 4 of 30 tasks instead of 18 (right 22 of 30 instead of 24), and on the 16 extracted tasks for 2 instead of 10 (right 10 instead of 16). Bedrock and Jev lost agreement too (each 22 of 30, from 24 and 27). The one-line descriptions carry most of what makes Decider sure of a project.
The real tasks (RESULTS.md, 19 Oct). The first live run's five tasks, extracted again from Dan's own recordings,
are two commitments in different words, so this is a very small sample. Decider was not sure of
the project for any of them, with or without the descriptions (with them it leaned mostly to
other, never above p 0.30): in the courier all five go to "unsorted", as the courier's own dry
runs showed. With the descriptions, Jev put all five in research (4 sure) and Bedrock four (the
fifth in community), all sure; without them, the three backends moved most of them to work or
writing.
What the numbers say. In the fresh evaluation, Decider's floors did what they are for on the
project and the sensitivity: when it was sure of a project it was right (8 of 8), and the other 18
tasks went to "unsorted" instead of the wrong project; it was sure of a task's sensitivity 24 times
and right every time; of its 24 sure commitment answers, one was wrong. That does not hold for
every question: the deadline question was wrong on 4 of its 13 sure answers and the waiting
question on 5 of 13 (the fresh report's question table), and priority, which the rule builds from
them, was right for 11 of the 26 tasks. The source-text gate held every sensitive conversation (12
of 12 in the fresh evaluation, 2 of 2 in the 19 Oct bench) before any Bedrock call; in the fresh
evaluation it also held three ordinary conversations it was unsure of (a garage's call about a
car's "diagnosis", a thesis advisor's corridor chat, a storyboard review while a client's lawyers
read the contract), and the task check held a fourth, a hospital volunteer's orientation, after
its extraction. In the 19 Oct bench (RESULTS.md): gpt-oss-20b called itself sure of nearly every
answer, including the wrong ones; Decider's priority agreed 16 of 30 against 22 and 20, against the
labels as first written, and 15, 21 and 19 of 30 after Dan's ruling 40 corrected twelve labels on
20 Oct; on the real tasks it was never sure of the project at all. Decider is the slowest of the
three on a laptop CPU; it is also the only one that costs nothing and never leaves the machine.
Rotating the order of the project options seven ways (RESULTS.md, 19 Oct) changed the answer for 2
of 30 tasks, each between work and other: the same two with the same counts as on 18 Oct (3 of
30 on 17 Oct, with an older wording). The first-listed option was picked no more often than chance
(13.8% against 14.3%). The conversations and the expected answers are invented, in both runs, so
these are small-sample numbers.
pytest runs without a network or a .env: Bedrock, Strands Decider, Pocket, Todoist, Telegram
and TypeSafe are all mocked, and a guard makes any real connection fail. python tools/check_tests_bite.py breaks each safeguard on purpose and checks that its tests go red.
Contributions are welcome. Read CONTRIBUTING.md for how to set up, test, and send a change, and CODE_OF_CONDUCT.md for how we treat each other here.
Ratatoskr is a personal project published under the Apache License 2.0. The license is the agreement; this section says in plain words what that means for someone who runs it.
- As is. The software is provided without warranty of any kind, as the license states. It is a demonstration of a pattern, directed and measured by one person on one laptop. Nothing in the README, the article, or the bench results is a promise about how it will behave on your machine, with your recordings, or with your accounts.
- Your accounts, your spend. You run it with your own AWS, Telegram, Todoist, and Pocket credentials, under those providers' terms. The spend ledger is a guard the courier enforces on its own calls; it does not cap what your AWS account can be billed for by anything else, and you are responsible for the bill.
- Your recordings, your consent. You are responsible for having the right to record and process every conversation you feed it, under the laws where the recording happens and under the terms of the recording device.
- The privacy checks are filters, not guarantees. The tripwire matches a list you maintain. The sensitivity gate is a model judgment with a floor. The tripwire holds on a match; the gate holds when unsure; either can still be wrong. Review what they hold, and do not route anything through the courier that would cause harm if a single line reached a shared list.
- What it talks to. The courier sends traffic only to the services you configure. The
Decider server contacts the Hugging Face Hub to download its 4.4 GB of weights on first start
and to check for updates on later starts; set
HF_HUB_OFFLINE=1after the first start if you want no contact at all. Nothing is sent to the project's author. - Fixtures are fictional. Every name, company, and conversation in
tests/fixtures/andbench/fixtures/is invented. Any resemblance to a real person or organization is coincidental. - Contributions. By submitting a pull request you agree that your contribution is licensed under Apache-2.0, as section 5 of the license provides.
- Trademarks. Pocket, Todoist, Telegram, CircuitMess, Amazon Bedrock, and Strands are their owners' marks. This project is not affiliated with or endorsed by any of them.
- AI-use disclosure. The code was written by Claude Code under the author's direction; the author dispositioned every design question. The animation, presenter restyling, and music in the demo video were generated under the author's direction.
This section is a plain-language summary and is not legal advice; the license text governs.
Copyright 2026 Dan Lee-Odinson. Licensed under the Apache License 2.0; see LICENSE and NOTICE.

