Studio Baton
Scripted operations for a one-to-one teaching studio: learner records, session documents, lesson summaries, messaging, video, and calendar — driven by one command-line tool with a stable exit code contract.
Baton exists because these workflows were being run by an AI agent reading prose instructions, assembling API calls by hand. That works when the model is strong and fails quietly when it is not. Everything that can be a script here is a script, so the model is left with the one job only a model can do — writing the summary — and even that is submitted as JSON validated against a schema.
Status: usable, not yet 1.0. Every pipeline is ported and tested; the configuration format is versioned and will not change shape without saying so. See Roadmap.
Where it came from
Studio Baton is the third generation of one workflow used in a real music studio:
Class Summarize scripts → PLAM voice assistant → OpenClaw skills → Studio Baton
The first scripts proved that lesson video could move from Google Drive through transcription, music-vocabulary correction, an LLM summary, and Notion automatically. PLAM put those pipelines behind a Thai voice interface and added scheduling, searchable memory, and video. Rebuilding the work as OpenClaw skills made orchestration more reliable, but an agent still had to read long instructions and assemble API calls.
Baton makes that last layer executable. Rules become commands, model output becomes data validated against a schema, and risky actions become gates with machine-readable outcomes. The real studio's profile and data are not part of this repository; this package is the reusable mechanism.
The working cycle
The command groups follow the work around one lesson rather than an abstract software taxonomy:
- Book — resolve an exact learner, update the next session document, then create the calendar event.
- Teach and record — upload short lesson clips to that learner's source folder.
- Process video — collect, encode, upload, and link the recording back to the session.
- Write the lesson — stage a note, give a model contracted context, validate, preview, and publish.
- Review and send — deliver the short report only after its required data passes the gate.
Speech recognition is deliberately outside Baton. A harness may accept typed text or provide its own speech layer, but the operational CLI is not coupled to one ASR model.
What it does
| Command | Job |
|---|---|
baton init |
Create a profile that already runs |
baton doctor |
Check config, credentials, and drivers before anything runs |
baton config |
Show the configuration the tool actually resolved |
baton job |
Run long work detached, then check on it, wait, or stop it |
baton learner |
Enrol and look up learners, sessions, pieces, and past work |
baton song |
List, search, add, edit, and remove pieces in the shared catalogue |
baton lesson |
Stage a lesson, validate a model-written summary, publish it |
baton send |
Send a lesson summary or a recorded work's links (Drive/YouTube), refusing to send anything incomplete |
baton video |
Collect recordings → encode → publish → link back, resumable |
baton calendar |
Book lessons, keeping documents and calendar in step |
baton notes |
Push a note or a Markdown file to a documents page |
Install
pip install studio-baton # core
pip install "studio-baton[google]" # plus Drive, YouTube, Calendar
Supported platforms
Linux and macOS, both covered by CI on every push. Windows is not supported.
Detached jobs are built on POSIX signals and file locking, so baton job and
everything that runs behind it does not work there — this is a real limit, not
an untested guess: the Windows suite was run once, in the open, and failed.
Quickstart
baton init ~/my-studio --sample-data
export BATON_PROFILE=~/my-studio
cd ~/my-studio && cp .env.example .env && $EDITOR .env
baton doctor
baton learner list
init asks a handful of questions — your language, timezone, where records
live, how messages are sent, what you call a student and a session — and writes
a config, an .env.example listing exactly the variables that profile needs,
and a database with the schema already in it. Pass every answer as a flag with
--yes to run it unattended.
Baton reads the profile's .env when it loads the profile, so filling that
file in is enough — nothing needs exporting. A variable already set in the
environment wins over the file, which is how a container or a secret store
injects credentials without the file being present at all.
baton doctor reports every problem at once rather than one per re-run, and
exits 2 while anything is unresolved. It checks the schema mapping too — a
column named in baton.yaml that does not exist is caught here rather than at
2am inside a pipeline. Add --offline to skip the checks that need a network.
Already have a database? Do not run the migration. Point db.tables and
db.fields at your own names and let baton doctor confirm the mapping — see
Adopting a database you already have.
baton schema postgres prints the reference SQL if you want to compare.
Design
One profile directory holds everything installation-specific. A profile is a
baton.yaml plus whatever private material you keep beside it. The code never
reaches outside it, which is what lets a private deployment be config-only
while the code stays a shared dependency.
Configuration names credentials; it never contains them. Settings ending in
_env name an environment variable. A profile is safe to keep in a private
repository and safe to paste into a bug report.
Nothing about the domain is hardcoded. Table names, column names, document
property names, status values, recipient aliases, and the words for
"student"/"week"/"piece" all live in baton.yaml. Adopting an existing Notion
database or Postgres schema is a config change, not a migration.
Exit codes are the interface.
| Code | Meaning |
|---|---|
0 |
Success |
1 |
Bad invocation |
2 |
Configuration or environment is incomplete — nothing was attempted |
3 |
Ambiguous input; a person must choose. A candidates list is returned |
4 |
Submitted content failed schema validation; nothing was written |
5 |
A fail-closed gate blocked the action. No override exists for missing data; a refusal for already sent is the one case a person can override with --again |
6 |
Upstream service failed after retries |
7 |
Local job state is inconsistent and needs an audit |
8 |
A background job is still running, or another run holds the lock |
9 |
Baton itself crashed. The payload carries the traceback — a bug report, not a retry |
130 / 143 |
Interrupted (Ctrl-C) or killed. Under a harness, 143 is a time limit expiring |
One documented exception: job wait exits with the code of the command it
supervised, which can be anything a wrapped program returns.
With --json, every command — success or failure — prints one JSON document on
stdout and nothing else. Progress goes to stderr. An agent reads the code and
the document; it never parses prose. That holds for a crash and for a kill too,
so an empty stdout means the process never got to run at all.
baton --version --json answers the question a harness has to ask first: the
version, and every command that exists in it. A skill written against a newer
Baton is then a mismatch someone can see, instead of an "unknown command" an
agent cannot tell from its own typo.
Gates fail closed. A lesson message is not sent when a required field is missing. There is deliberately no way to force it: the fix is to supply the data. This is the behaviour that made the original system trustworthy and it is preserved exactly:
$ baton send lesson "Ada Whitfield" --to me
✗ Refusing to send the lesson message for Ada Whitfield: missing doc_link.
missing:
- doc_link: `doc_link` is empty
Nothing was sent. Supply the missing items, then re-run. There is no flag to bypass this check.
exit=5
What counts as required is configuration (gates.send_lesson_required), so a
studio sets its own standard of completeness; the block itself is not
negotiable. What is sent is what was published — the message comes from the
record stored at publish time, and the links are Baton's own, which is why
links are forbidden inside the summary a model writes.
Several learners go through one invocation. A refusal for one does not abandon the rest, and the exit code plus the report say exactly which did not go:
baton send batch --to me --learner "Ada" --learner "Bruno" --learner "Clara"
The same stance applies to names. A typed name resolves only on an exact match
or a configured alias — a partial match never resolves, even when it is the
only one, because the second person with that name is exactly the case that
would go wrong silently. Ambiguity exits 3 and returns the candidates:
{"error": "needs_human",
"message": "“Nam” is not an exact match for any student.",
"details": {"candidates": [{"id": "4", "name": "Namo (guitar)"},
{"id": "5", "name": "Namo (drums)"}]}}
The model returns data, never prose. A summary is the one thing Baton cannot script, so it is the one thing a model writes — as JSON against a schema, which Baton then renders itself. The loop is three commands:
baton lesson contract "Ada Whitfield" # schema + context, in one document
baton lesson ingest "Ada Whitfield" --file s.json # validated, or exit 4
baton lesson render "Ada Whitfield" # deterministic preview
baton lesson publish "Ada Whitfield"
Each of these takes the learner positionally or as --learner "<name>".
publish --session N does not choose a lesson — a learner has one draft at a
time — it refuses if the staged draft is for a different one.
Rules a JSON Schema cannot express are checked in code, because they are exactly the ones a small model ignores when they are written as prose — no emoji in the parent's message, no links, one line per field, and every theory callout referenced by an id that exists. A rejection is total: nothing is stored, and every violation comes back at once with a pointer to it.
One studio voice still has to meet a six-year-old and an exam candidate
differently, so a learner's tone, their instrument, and whether they own one
at home are turned into wording and notation guidance and handed to the model
for that learner alone — three columns that had existed since the first
migration with nothing reading them. The lesson before is handed over in full
rather than as the message a parent was sent about it, since that is what the
progress section has to be measured against.
The same layer keeps the sections from collapsing into each other. Each answers
a different question — how the session went, what changed since last time, what
was worked on, what is still hard, what to practise — so a fact stated in more
than two of them is rejected, a rating where an observation belongs ("did very
well") is rejected by name, a word about the child rather than the playing ("a
weak point") is sent back with a different correction, and so is a practice goal
nobody can practise at home. progress is required once a learner has a previous session to
compare with, and is written as a change rather than a verdict:
## Progress
- Needed the count called out → Counts through the piece unaided
$ baton lesson ingest "Ada Whitfield" --file summary.json
exit 4 | contract | The lesson summary does not match the required structure (3 problems).
/short_summary/covered contains emoji, which this profile does not allow in messages
/short_summary/progress contains a link
/callouts/0 `tremolo-picking` is not in this studio's theory notes
Callout text comes from the studio's own theory.json; the model supplies only
the id. It cannot write theory content into a document at all.
The same reasoning covers notes. The skill this replaces handed a model the
API shape and a curl invocation and asked it to build the block JSON, split
it at the store's 100-child ceiling, and retry — all mechanical, and all
invisible when done wrong, because a note that lost a line just looks shorter
than you remembered. It is a parser now:
baton notes preview --file today.md # what it becomes, touching nothing
baton notes push --file today.md
The conversion is total: every line produces exactly one block, and anything unrecognised becomes a paragraph rather than being dropped.
A rewrite cannot destroy what it did not write. Updating a summary replaces
only the blocks the docs.preserve policy does not protect, so uploaded
recordings, sheet-music embeds and practice-track callouts survive. Blocks are
appended before the old ones are deleted: a failure halfway then leaves a
duplicated section, which is recoverable, rather than an empty page with the
recordings gone. The policy is an allowlist expressed as data:
docs:
preserve:
- {type: video}
- {type: embed}
- {type: callout, icon: "🎧"}
A publish also puts back a recording the page is missing. The video
pipeline records an upload the moment YouTube returns the id, so a run that
dies afterwards leaves the recording published and the page with no link to
it — and the send gate then refuses a lesson whose video exists. lesson publish looks for such an upload and appends the block itself, reporting it
as recording, so the repair is not a hand-written Notion block.
Booking happens in an order that cannot leave two records disagreeing.
A lesson is marked in progress on its document first; the calendar event is
created only if that succeeded. Creating the event first and then failing on
the document leaves a lesson the sessions know nothing about — the teacher
trusts the calendar, the pipeline trusts the documents, and they drift apart
until someone reconciles them by hand. Cancelling runs the chain backwards for
the same reason, and refuses to reach further back than
calendar.rollback_window_days, because rewriting last week's records is
usually a mistake rather than an intention.
Date arithmetic is code, not a model's job. An off-by-one books a lesson on the wrong day and nobody finds out until a family arrives to an empty room:
$ baton calendar date พน # shorthand tokens are configuration
2026-08-17
$ baton calendar date วันศุกร์ # a weekday means its next occurrence, never today
2026-08-21
$ baton calendar date "next tuesday"
✗ `next tuesday` is not a date Baton understands.
Use YYYY-MM-DD, a signed offset like +2, or one of: today, tomorrow, yesterday, พน, วันจันทร์, วันอังคาร, …
Weekday names (calendar.weekdays) and day-first 12/8/2026
(calendar.accept_dmy, off by default) are configuration too. Times carry
their own vocabulary (calendar.time_words): 6 โมงเย็น books 18:00, 9 โมง
reads the number literally as 09:00, 3 ทุ่ม is 21:00, ตี 3 is 03:00, and a
time past 23 hours is refused rather than wrapped around. A whole range shows
at once, empty days included — a gap is information:
baton calendar list --from 2026-08-14 --to 2026-08-20
A whole day is booked from the list a teacher actually writes. A slot ends when the next begins, and a free period is skipped but still bounds the slot before it — without that, the lesson before an hour off silently doubles:
baton calendar schedule tomorrow --text "17:00 Ada Whitfield
18:00 -
19:00 Bruno Castell"
Reads fall over; writes never do. With db.fallback set, a read served
during an outage comes from the secondary store. A write does not: a write that
lands only in a replica is a permanent divergence that nothing reconciles, so
it fails loudly instead.
"Latest" means the newest session that happened, never the highest number.
Sessions get skipped — illness, cancellations, pages created in advance — so
session 12 existing says nothing about whether session 12 took place. And the
next free session is where a new lesson may land: a page in progress is the
target while it is fresh — the studio's flow books a lesson, the page turns
In progress, and the summary is written onto that page — and only a page still
in progress more than learner.next_stale_days past its date is passed over
as abandoned, so one missed week cannot hold every later week hostage. A page
marked "not started" that already has blocks on it is someone's work in
progress, and handing it back as free is how a summary overwrites a draft.
baton learner latest "Ada Whitfield" # newest done, by document date
baton learner next "Ada Whitfield" # where the next lesson lands
baton learner in-progress # who still owes a summary (calendar window)
Enrolment writes nothing until every input has resolved. learner add
refuses an exact-name duplicate outright (a near-miss is only ever reported
alongside a success, never blocking one), checks learner.instruments and
learner.tones when the profile restricts them, and rejects a page URL it
cannot read a Notion page id from before the learner is even created. A
studio-specific column named on the command line — --prompt-level,
--master-link — with no db.fields entry to write it to is a configuration
error raised up front, the same as any other unmapped field:
baton learner add "Elin Frost" --instrument guitar --tone child \
--page-urls https://notion.site/1-16cf38e8e88b830f8167819ac35a6428 \
https://notion.site/2-27df49f9f99c941f9278920bd46b7539
A status the profile does not describe — a studio adds "Cancelled" — maps to unknown rather than being filed as one of the three. Unknown is never offered as the next free session.
The piece catalogue is a shared table, not a learner's property.
baton song lists, searches, adds, edits, and removes it; learner assign
is what points a learner at one. Removing a piece a learner is still assigned
to is refused (exit 5, naming who) rather than orphaning the assignment or
racing the database's own foreign key. song update only changes the fields
given — an empty value clears one, leaving a flag out leaves it alone:
baton song add "Nocturne No. 2" --sheet-link https://example.invalid/nocturne.pdf
baton song update 3 --practice-track "" # clears the link, leaves everything else
Long jobs resume. Video processing and publishing record each completed step atomically, so a crash mid-run is re-runnable without re-uploading a video or duplicating a page block.
Three properties hold the video pipeline together, each because its absence loses or duplicates a recording:
- Nothing is deleted until everything else succeeded. Source clips are trashed last, after the upload and the link. Until then they are the only copy, and a crash before the upload would lose the lesson permanently.
- A completed upload is never repeated. The video id is recorded the moment the platform returns it, so a resume cannot publish a second copy of a child's lesson with no way to tell which link was sent.
- One learner's failure does not stop the others. A corrupt clip from one phone must not mean nobody's recording goes out that night.
baton video run --dry-run # what is waiting
baton video run --detach # background, survives the session
baton video status # per-learner progress through the steps
baton video resume # continue whatever did not finish
✗ Ada Whitfield ##..... failed
ffmpeg failed: Invalid data found when processing input
next step: combined
steps: downloaded → combined → session_resolved → uploaded → doc_linked → cleaned → source_trashed
Clips arrive from Google Drive or a watched local directory
(media.source.driver), so the pipeline can be tried without a Google
account. A source folder resolves to a learner by exact name only — the same
stance as everywhere else, because uploading one child's lesson onto another
child's page is not worth the convenience.
Long jobs also detach. Encoding and uploading run for tens of minutes — longer than an agent session, an SSH connection, or anyone's patience. Any command can be handed to a supervisor that outlives the shell that started it:
baton job spawn --name nightly -- baton video run # returns at once
baton job list # what is running
baton job list --all # including old finished ones
baton job wait <id> --timeout 90 # exits 8 if still going
baton job logs <id> --tail 50
baton job stop <id> # SIGTERM, then SIGKILL
job wait exits with the job's own exit code, so waiting on a detached run
and running it in the foreground are indistinguishable to a caller. Keep
--timeout below whatever your harness allows one command to take — Claude
Code's default is two minutes — because a wait that gets killed tells you
nothing, while the job it was waiting on carries on regardless.
Two properties make this safe to point an agent at. A job whose supervisor died
without recording an outcome reads as orphaned (exit 7) rather than silently
"running forever" — liveness is checked, not assumed. And the writing commands
take a whole-run lock held by an open file handle, so a second run cannot
collide with a first; the OS drops the lock however the holder dies, which means
there is no stale lockfile to clear by hand:
✗ Another run already holds video.lock.
Wait for it to finish (`baton job list`), or stop it (`baton job stop <id>`), then re-run.
There is one lock per workflow — video, lesson, calendar, send — so an
evening of encoding does not stop the day's messages. Read-only commands and
--dry-run take nothing. This matters most where two agents share one profile,
as a Claude Code session and an OpenClaw container do: neither knows the other
exists, and exit 8 is how they find out.
A message is not sent twice. Every delivery leaves a receipt — a digest, not
the message — and an identical send inside the next 12 hours is refused with
exit 5 naming the time of the first. This is aimed squarely at what a harness
does to a correct program: kill the call in the gap between the platform
accepting a message and Baton printing that it did, and the agent, reasoning
correctly from what it can see, sends again. --again overrides it, and belongs
to a person who has confirmed the first message never arrived.
Configuration
src/baton/defaults.yaml is the documented, complete default. A profile is
deep-merged over it, then BATON__SECTION__KEY environment variables are merged
over that. The profile's .env is loaded into the environment first, so it can
carry a BATON__… override as well as a credential — and an exported variable
still beats the file either way. To see the result:
baton config show # whole tree
baton config show docs.properties # one branch
Driving it from an agent
skills/ holds a wrapper per pipeline for harnesses that load skill files
(Claude Code, OpenClaw, and anything with the same convention):
ln -s "$PWD/skills/"* ~/.claude/skills/
Each is a decision table — a trigger, the exact command, and what to do about
each exit code — not a manual. None of them contains an API call, a JSON
payload, or a python3 -c: everything a model would otherwise assemble by hand
is a subcommand instead, which is what the CLI underneath is for.
The live harness adds zeroskim above this layer: SHA-256 evidence with a 15-minute gate requires an agent to read the relevant skill before work. That gate reduces forgotten instructions; Baton's own name, schema, state, and completeness checks still run afterwards and limit the effect when a model gets the instruction wrong anyway.
tests/test_skills.py keeps them honest. It fails if a raw API call reappears,
if a skill stops documenting its exit codes, or if one grows past 120 lines —
the original ran to 400 lines of prose, which is how its rules stopped being
followed. It also checks that the commands a skill names exist and parse, but
only for lines that begin with baton — a command written inside a markdown
table is not checked today, which is most of student-lookup.
Roadmap
The port from the original skills runs in phases; each lands behind tests and is diffed against the legacy scripts before the old path is retired.
- P0 Package skeleton, configuration, state layer, exit contract, CI
- P0.5 Detached jobs (
baton job), run locking, orphan detection - P1 Storage and document adapters (SQLite, Supabase/PostgREST, Notion), the name-resolution gate, migrations, and in-memory fakes
- P2
baton learner— lookups joined across both stores - P3
baton lesson— the JSON summary contract and safe publishing - P4
baton send— the fail-closed gate; LINE, Telegram, and webhook drivers - P5
baton video, with--detachwired tobaton job - P6
baton calendar - P7
baton notes - P8 Agent skill definitions (
skills/) - P9
baton init, migrations, docs - P10 The parity harness (
tools/parity.py)
Every phase has landed, and the read paths have now been exercised against a
real studio: doctor passes against live Supabase, Notion, and LINE, one real
LINE message has been delivered, and a student-lookup skill now runs inside
the live harness so production lookups go through Baton's read path while the
write paths stay on the legacy system by design. The parity harness below
agreed with the scripts being replaced on all 54 cases of its latest round
(18 August 2026). What has not run against
anything real is the write-heavy end — video encoding and upload, and booking
or cancelling a real calendar event. Treat the parity run — and a first send to
yourself, never to a family — as the gate before any of this is trusted with a
real lesson.
Replacing something that already works
A rewrite is trustworthy when it gives the same answers as the thing it replaces, on that studio's own data — not when its own tests pass. Tests were written from the same understanding as the code, so they share its blind spots. The old script does not.
tools/parity.py --spec parity.yaml
It runs both sides of each case and diffs the fields that matter. Read-only by design: lookups, never a send, a publish, or an upload. When one side cannot run it is reported as a difference, never as agreement — a harness that scores silence as a pass would give the go-ahead to retire a working system. Run it daily until the answers have agreed for long enough to trust, retiring the read-only paths first and the ones that message families last — a wrong lookup is noticed, a wrong message to a parent is not recoverable.
parity.yaml is not in this repository, and no example of it could be: it
names the paths of your legacy scripts and the fields of your schema. Write
your own — the format, with a worked case, is documented at the top of
tools/parity.py.
Three things about running the old system will look like disagreements when they are really setup problems. All three cost a run to find:
- Imports resolve against the legacy workspace, not yours. Set
PYTHONPATHto the directory the old scripts assume they live under, or every case fails identically on an import. - A read gate can expire mid-run. If the legacy side is behind a gate with a window (fifteen minutes, in the system this replaces), a run longer than the window turns every remaining learner into a difference. Widen the window to longer than the run takes, or re-seed it before starting. The harness is right to report those as differences rather than agreement — that property is pinned by its own tests — so the fix belongs in the setup, not the harness.
- The old side may need dependencies Baton does not have.
httpxandsupabaseare not Baton's, and the legacy scripts will not start without them.
Documentation
- Setting up Notion — including the sharing step that makes every request 404 until you do it
- Adopting a database you already have
- Running a private deployment — one public codebase, a config-only private overlay
Development
uv venv && uv pip install -e ".[dev]"
.venv/bin/python -m pytest
.venv/bin/python -m ruff check .
.venv/bin/python -m mypy
License
MIT. See LICENSE.
Release files for studio-baton 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| studio_baton-0.5.0.tar.gz | 400.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| studio_baton-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 686.1 kB
Release files / studio_baton-0.5.0.tar.gz
| Download URL | studio_baton-0.5.0.tar.gz |
|---|---|
| Size | 400.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
08a670fee7ab9676f3f698331991684accdcbdbc5e5a8d15d176a7fc091444a0
|
|
BLAKE2b-256 checksum How to use checksums |
194eb4dfa928a336a8b9c8fa0a3ffc4405239d0a8ae9d0280ed99b2e52749535
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 28, 2026.
Transparency logRelease files / studio_baton-0.5.0-py3-none-any.whl
| Download URL | studio_baton-0.5.0-py3-none-any.whl |
|---|---|
| Size | 285.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f09fbc59c3b3bd94f163fcd99907395bbe34b265516ecec67b69711c0fd2acb7
|
|
BLAKE2b-256 checksum How to use checksums |
10cf8493fa4dd6577e72dec9f1fd2c5ff1aa78eb09adf158fc0f5ba50d83c0c0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 28, 2026.
Transparency log