Producer Pal is an AI music composition tool that integrates with Ableton Live through a Max for Live device using the Model Context Protocol (MCP). Written entirely in TypeScript.
This applies to everything you write: code comments, docs, commit messages, and your replies.
- Be brief. Say it once, in the fewest words that stay accurate. Leave out the history, the alternatives you rejected, and the measurements — unless a reader needs them to make a decision.
- Use plain language. Write for a human in a hurry. Prefer an ordinary word over a technical one, a short sentence over a clause pile. Don't write to prove you understood the details.
- Shorten long comments in code you're already touching. If a comment is longer than the code it explains, rewrite it smaller and plainer. Don't go hunting through the codebase for comments to fix — only fix what you're editing anyway.
- Never cut the load-bearing part. An assumption that causes a bug if it's wrong stays. So does a "don't do the obvious thing here, because X" warning. Trim the story around the fact, not the fact.
- A code comment stands on its own. Say the reason in the comment; don't
send the reader to an ADR for it. ADRs get rewritten, merged and renumbered,
and a pointer to a numbered rule inside one is the first thing a rewrite
breaks. A bare
(ADR-00NN)after a complete thought is fine — it attributes without the comment depending on it. - Tool descriptions and results are the tightest of all — the Producer Pal
Skills,
.def.tsdescriptions, and tool results all spend the user's context window. Keep them short, clear, and limited to what the model needs.
npm run build:debug # dev build — always use this for development/testing
npm run fix # auto-fix formatting and lint
npm run check # lint + typecheck + format + tests
# npm run lint / typecheck / format / test also run individually
npm run ui:build # chat UI production build
npm run ui:test # stubbed webui Playwright suite (no Ableton or API keys)
npm run docs:dev # docs site (VitePress, producer-pal.org)Portal script → Max for Live Device (MCP Server) → Live API
Key entry points:
- MCP Server:
src/mcp-server/mcp-server.ts - Max V8 code:
src/live-api-adapter/live-api-adapter.ts - Portal:
src/portal/producer-pal-portal.ts - Chat UI:
webui/src/main.tsx - Claude Desktop extension:
claude-desktop-extension/manifest.template.json - Tools:
src/tools/**/*.ts - Chat CLI:
evals/chat/index.ts - Evaluation scenarios:
evals/scenarios/index.ts
See dev/Architecture.md for system design and dev/Chat-UI.md for the web UI.
-
License headers: every source file starts with this block (after any shebang).
examples/**is exempt — those get copied into user projects.// Producer Pal // Copyright (C) <year> <author> // AI assistance: <AI tool> (<company>) // SPDX-License-Identifier: GPL-3.0-or-later
Editing an existing file: append yourself to the end of the
Copyrightlist and the AI tool to the end ofAI assistance. Both read oldest-first, so never reorder them. -
File naming: React components are PascalCase (
ChatHeader.tsx); everything else is kebab-case (use-chat.ts). -
Function organization: the first exported function is the main one, named after the file (
updateClip()inupdate-clip.ts). Helpers go below it. -
No barrel files: no
index.tsor other pure re-export files. -
Imports:
src/imports need.tsextensions;webui/never uses extensions. Use the#src/,#webui/,#evals/aliases to cross between top-level modules — a relative import must stay inside its own module (src/notation,src/tools, …), andwebui/bans..entirely. Enforced bysrc/test/meta/import-restrictions.test.ts. -
Null checks: prefer
== nullover=== nullor=== undefined. -
Live API: use the
src/live-api-adapter/live-api-extensions.tsinterface, not raw.get("property")?.[0]. Build paths withlivePathfromsrc/shared/live-api-path-builders.ts— never hardcode a path string. On a runtimeLiveAPI, reach child objects withapi.child("name")(chainable), never by concatenating ontoapi.path. Never callnew LiveAPI()— onlyLiveAPI.from()tracks the object for release, and an untracked one leaves a Live path listener armed for the life of the device. -
Never hold a
LiveAPIacross requests — not in module state, not in a cache, not in a callback that outlives the call. Objects are released when the request ends and reused by the next one, so a stale reference silently points at a different Live object. Build them where you use them. Seesrc/live-api-adapter/live-api-release.ts. -
Tool design follows
dev/Principles.md— addressing, multi-target, relocation, partial completion, observability, warnings, destruction, vocabulary, spelling, efficiency. Read it before changing a tool's inputs, outputs, or failure behavior. Anything about a target goes in that target's result entry; a warning is only for what no result can carry, and is appended to the response as aWARNING:block the model reads. See ADR-0035 for the calls that are refused up front instead. Never cite a principle by number outside that file — numbering shifts as principles merge and split; state the idea instead. -
A warning belongs to the request that raised it. V8 buffers warnings per-request and appends them to that request's own response, and it has no async context to do that automatically. So: adding an
awaittohandleRequestneeds a matchingresumeWarningCapture()on every path back out,catchincluded; any new promise V8 can suspend on needssuspendWarningCapture(); and avoid-ed async call is a suspension point too, so it needsdetachWarningCapture(). Miss any of them and warnings silently land on another request's response. Warnings raised with no request in flight go to the Max console instead — don't try to route them into a response. Seesrc/shared/max/v8-warning-capture.ts. -
Tool schemas: use
z.coerce.string()for ID params andz.coerce.number()for numeric ones — models send both strings and numbers, and the MCP SDK validates before our handler runs. For choosing a param's shape and writing per-mode descriptions, seedev/Tool-Schemas.md. -
String length caps of 2000+: never let them reach the JSON Schema as
maxLength— llama.cpp-based clients compile it into a grammar repetition and then reject every tool call, for every tool. UseboundedString()and state the limit in the param description. See ADR-0021. -
The filesystem is Node-side only: the V8 runtime (
src/live-api-adapter/) has no filesystem, and shippedsrc/**can't shell out. Allnode:fswork lives insrc/mcp-server/. User-content features (~/.producer-paloverrides, global context, custom system prompt) are MCP/REST concerns that never touch the Live API. Seedev/Architecture.md→ Runtime Boundary. -
Generated parsers:
generated-*-parser.jsfiles are gitignored and built from the.peggygrammars. Never commit them; regenerate (npm run parser:build) after editing a grammar. -
Note-value grammar is duplicated on purpose across both
.peggygrammars and the regexes insrc/notation/barbeat/time/barbeat-time.ts— don't extract a shared fragment. Parity tests hold the sites in step; adding a parse site means updating every site and the parity test. Same deal for Stark'sDrumPitchName. See ADR-0003. -
Exact dependency versions: no
^/~/ranges anywhere in package.json. -
The issue tracker is not durable storage — assume any tracker (Linear, GitHub issues) will be deleted someday, and that not every contributor can read it. A ticket is a to-do, not a record. Anything worth keeping goes in the repo: code comments for local reasoning,
dev/docs for how a system works,dev/decisions/ADRs for why a settled choice went that way, and user-facing docs when it changes what users see. A commit or PR that only points at a ticket has lost the information. -
No Linear ticket references anywhere in the repo — this is a public repo with private ticket numbers. Never write
AJM-NNNin a tracked file or a commit message; explain the reasoning instead.npm run checkscans both tracked files and your commits on this branch, but only locally, so run it before pushing. PR titles and bodies are fine. -
GitHub issues go in the commit message, not the release PR body — put
Resolves #NNNNin the commit that fixes it.dev -> mainmerges onto the default branch, so the issue closes when the release lands. One keyword per issue: extraRefs #NNNNon supporting commits just add permanent timeline events to a public issue. Don't name an issue you aren't fixing. -
Keep the Skills and specs current: the Producer Pal Skills (
src/skills/fragments/) need updating whenever notation or tool behavior changes under them. The grammar specs indev/specs/have no test guarding them, so update them by hand when you change grammar syntax. -
File size limits (blank and comment lines don't count): 325 lines per source file, 650 for a whole test suite; 115 lines per function;
max-depth4;complexity20. When a file gets close, extract cohesive helpers into{feature}-helpers.tsbeside it — don't compress code to squeak under the limit. Once a directory has 2+ helper files, move them intohelpers/. Split test files as{feature}-{area}.test.ts, and give a feature its owntests/directory once it has 3+ test files. -
Write lint suppressions with the
eslint-prefix, notoxlint-. Both work, but the rule requiring a-- reasonon every directive only sees theeslint-spelling. Seedev/Linting.md. -
DRY: no duplicate function bodies (oxlint catches them), keep shared constants in one place, and treat repeated patterns as a missing abstraction.
src/, scripts/, evals/, and webui/ are all type-checked. Prefer explicit
return types on exported functions. Every exported function declaration needs a
JSDoc block with @param/@returns descriptions — no types, since TypeScript
already has them.
TypeScript 6 and 7 are installed side-by-side. TypeScript 7 ships no
programmatic API (it's the Go port; a new API is expected in 7.1), but oxlint's
jsPlugins bridge needs one, so package.json follows the upstream-recommended
aliasing:
"@typescript/native": "npm:typescript@7.0.2",
"typescript": "npm:@typescript/typescript6@6.0.2"The practical consequences:
tscis TypeScript 7 — this is whatnpm run typecheckruns.tsc6is the 6.0.2 compiler, kept only so the bridge resolves; don't typecheck with it.import ts from "typescript"gets the 6.0.2 API, which is whyscripts/stats/loc.tsandsrc/test/helpers/vi-mock-scan-test-helpers.tsstill use the compiler API normally.- TS 7 reports overload-mismatch errors on the failing argument, not the
call expression, so a
@ts-expect-errorfor one goes directly above the offending argument (seeduplicate-mocks-test-helpers.ts). That placement is TS-7-only —tsc6will call it unused. - Version pins may be npm aliases;
src/test/package-json-versions.test.tsacceptsnpm:<name>@<exact>but still rejects ranges.
- Run
npm run checkafter any code change. Before claiming done:npm run fix, thennpm run check, thennpm run check:build. If you touchedwebui/**, also runnpm run ui:test—checkdoesn't include it. npm run build:debugis the dev build. It force-enables the Direct Live API tool, which a release build gates off — harmless for evals and e2e, since it stays off untilPOST /config { liveApiEnabled }turns it on. Thecodeparam and the work-in-progress warp params are NOT on by default: they go straight into the clip tool schemas with no runtime gate, so a model would reach for a feature no release build ships. Opt in per build when you're working on one (ENABLE_CODE_EXEC=true npm run build:debug), and rebuild plain afterwards.npm run check:buildoverwrites the device with a release build, so re-runbuild:debugafterwards or the device in Live is the wrong one.- Debugging: import
consolefromsrc/shared/max/v8-max-console.tsand useconsole.warn()— it shows up in the CLI and in the live MCP response.console.log()andconsole.error()don't. - Coverage gaps:
npm run checkprints totals only; the per-file breakdown is incoverage/coverage-summary.txt. Function coverage must be 100%; mark a genuinely untestable function with/* v8 ignore start -- reason */. Before ignoring or deleting a branch as unreachable, try to write the test — reading the code is not enough to prove it, and the attempt is what tells you whether the guard is dead or you just hadn't found the input. - See
dev/Testing.mdfor what counts as a test file, webui test gotchas, and the mock registry. CLI tools and test Live Sets are indev/Development-Tools.md.
E2E tests live in e2e/mcp/ and drive a real Ableton Live. See
e2e/mcp/README.md.
Always ask before running them — they open a Live Set without saving the current one, which can destroy work in progress.
Always run a single file. The full suite takes several minutes.
npm run e2e:mcp -- ppal-update-clip-arrangement-splittingA new track is not always empty. Live applies the user's default track
preset (User Library → Defaults/Creating Tracks/) to every track it creates,
and that preset varies per machine — one dev's new MIDI track arrives bare,
another's already has a Channel EQ and a Utility on it. So never hardcode a
device index for a device the test just created: use createTestDeviceAt(),
which returns the path the device actually landed at. A test that assumes d1
passes for whoever wrote it and fails for everyone else. The same goes for any
other per-machine Live preference a test might lean on.
Save test Live Sets from MIN_LIVE_VERSION, not from whatever Live you have
installed. Live can't open a Set saved by a newer version, so a Set authored
above the minimum makes every test that opens it unrunnable on the oldest Live
we support. src/test/meta/versions/live-set-versions.test.ts catches it.
These encode standards the project is held to — don't relax or rewrite any of them without asking:
dev/Principles.md— the first principles for tool design. It states the intended future state, so editing one to match today's code retires a goal silently.src/test/lint-suppression-limits.test.ts— per-tree caps on lint-disable,@ts-expect-error, and v8-ignore comments.src/test/helpers/comment-limits.ts— per-tree caps on comment lines and comment-block length, enforced bysrc/test/comment-limits.test.ts.vitest.config.ts(thresholds) — coverage.config/.jscpd*.json(threshold) — code duplication.
Internal docs live in dev/ — the filenames are descriptive, so ls dev/ to
find one. The main ones: dev/Principles.md (first principles for tool design —
read first), dev/Architecture.md (system design), dev/Coding-Standards.md
(full style guide + Live API reference), dev/Testing.md,
dev/Tool-Schemas.md, dev/Linting.md, dev/specs/ (bar|beat and transform
grammars), dev/Development-Tools.md, and dev/decisions/ (ADRs — why settled
choices went the way they did, especially the rejections).
DEVELOPERS.md covers dev setup; CONTRIBUTING.md covers contributing.
Keep a doc small enough to read whole. Past ~20 KB, split it: an index with
the concepts, plus one file per lookup-table chunk in a sibling directory
(dev/mutation-baselines/, dev/specialized-devices/). Catalogs and per-scope
results are the parts to move out; the reasoning stays in the index.
This repo is big — 900+ source files and ~1.3 MB of dev/ docs. Reading whole
files is the main way a session runs out of room.
- Search docs, don't read them.
grep -n -A15for the term you need. Read adev/doc end-to-end only if it's under ~15 KB. - Read tests and long sources in ranges.
sed -n '120,190p'the block you care about; test files here run to 800+ lines. - Coverage:
grepthe file you touched out ofcoverage/coverage-summary.txt— don't print the whole thing.