Skip to content

Ableton Live Agent Skills ​

Producer Pal ships a portable Agent Skill that lets coding agents control Ableton Live through Producer Pal's REST API, with no MCP client required.

Not the same as Producer Pal Skills

Two different things are called "skills" around here. An Agent Skill, the subject of this page, is an integration package that teaches a coding agent how to connect to Producer Pal. The Producer Pal Skills are instructions the model receives after connecting, whichever route it took.

Agent Skills are a small, open convention shared across the major coding-agent CLIs: a folder containing a SKILL.md (with frontmatter describing when to use it) plus optional scripts and resources. The folder is loaded lazily when the agent decides the skill is relevant. The same folder works across all three:

ToolGlobal skills location
Claude Code~/.claude/skills/<name>/SKILL.md
Codex CLI~/.codex/skills/<name>/SKILL.md
Gemini CLI~/.gemini/skills/<name>/SKILL.md

When to use this vs MCP or REST

Both agent paths give the AI the same thing. The skill's bootstrap discovers the tools and their schemas (--list-tools) and calls ppal-connect, which returns the same Producer Pal Skills and context an MCP client gets from its own ppal-connect call, so neither route is "more automatic" than the other.

Use MCP when your client speaks it and isn't a coding agent: chat apps like Claude Desktop, the Chat UI, and web clients. Tools appear natively in the client and nothing has to shell out.

Use the skill with coding agents that can run commands. Because it drives the REST API directly, it can pick its own notation, toolset, and small-model mode per request and re-read the schemas mid-session, without moving your Chat UI or any other client off theirs. MCP clients bake the tool descriptions at the start of a conversation, so the same switch means starting a new one.

For scripts and pipelines with no agent involved, skip both and use the REST API directly.

Install ​

Download producer-pal-skill.zip and unzip it into your agent's skills directory:

bash
curl -L https://producer-pal.org/downloads/producer-pal-skill.zip -o /tmp/ppal-skill.zip
unzip -o /tmp/ppal-skill.zip -d ~/.claude/skills/
# or ~/.codex/skills/, or ~/.gemini/skills/

The zip holds one folder, producer-pal/, so it lands in the right place with no reshuffling. It tracks the repo's main branch, same as this site.

Those paths install it globally, for every project. Most agents also read a skills folder inside a project (for Claude Code that's .claude/skills/ in the project root), which keeps the skill to that one project. Consult your coding agent's docs for its exact project-local path.

To take it from a git checkout instead:

bash
git clone --depth 1 https://github.com/adamjmurray/producer-pal.git
cp -r producer-pal/examples/skills/producer-pal ~/.claude/skills/

The skill folder contains a SKILL.md (frontmatter + instructions for the agent) and a ppal.mjs (the Node CLI it shells out to).

How it works ​

When the user asks the agent something Producer-Pal-shaped ("set tempo to 120", "what's in track 2", "make a 4-bar drum loop"), the agent loads SKILL.md and follows its bootstrap:

  1. List tools. node ppal.mjs --list-tools --notation midi-json returns the full tool catalog with input schemas, so the agent knows what's available without baking it into the skill. The notation is baked into every tool and argument description, so it rides on this first call already. Coding agents get midi-json (MIDI notes as a JSON array) because they can generate and parse it programmatically.
  2. Call ppal-connect. Its response includes the up-to-date Producer Pal Skills (the note syntax for the notation it asked for, transforms, conventions), the same instructions an MCP client gets from its own ppal-connect call. The skill stays small; the heavy guidance comes from Producer Pal itself.
  3. Use the other tools per those instructions, via node ppal.mjs <tool> [json-args] --notation midi-json.

--notation applies to the one request that carries it, so the skill passes it every time.

Because the skill is just a thin pointer + bootstrap, it stays correct as Producer Pal evolves: new tools, schema changes, and skill updates land in ppal-connect's response automatically.

Adding Producer Pal to a Live Set ​

If the open Live Set has no Producer Pal device, the agent can add it, on macOS or Windows:

bash
node ppal.mjs --add-to-live-set

It adds a MIDI track with the device, and does nothing if Producer Pal is already running. The agent asks first unless you asked for Producer Pal. This needs the remote script installed and selected in Live, and the device installed in your User Library's Max MIDI Effect folder (see installing).

Notation and small-model mode ​

Producer Pal encodes MIDI notes in one of three notations: bar|beat (the default, compact human-readable text), midi-json (notes as a JSON array), and stark (a literal type: content format with event-based drum hits). The choice changes the note syntax in every tool/argument description and in the ppal-connect Skills.

The skill asks for midi-json per request, with --notation, rather than writing the device setting, so your Chat UI and any connected MCP clients keep whatever notation they were using while the agent works in its own.

The skill also assumes small-model mode is off. Coding agents generally run capable models that handle the full tool descriptions, so it's left at the default. If you drive Producer Pal from a local coding agent on a smaller model, edit SKILL.md to pass small-model mode (which trims the tool descriptions) and consider stark instead of midi-json:

bash
node ppal.mjs --list-tools --notation stark --small-model-mode

Narrowing the toolset ​

--disable-tools <names> withholds tools from a request. They vanish from --list-tools, and ppal-connect comes back without the parts of the Skills that teach them, so the agent stops paying for guidance it will never use. See Choosing a Toolset.

bash
node ppal.mjs ppal-connect --disable-tools ppal-library,ppal-create-device

SKILL.md tells the agent to decide once and pass the same list on every call, since the header applies per request, same as --notation, and equally invisible to the Chat UI and your MCP clients. Optimizing has the numbers.

Direct Live API (advanced) ​

The ppal-live-api tool gives direct, low-level access to the Live Object Model for reads and writes the higher-level tools don't cover. It's off by default (absent from --list-tools). An agent can turn it on itself, though the setting is global to the device:

bash
node ppal.mjs --set-config '{"liveApiEnabled":true}'

It's not the default for a reason: the specialized tools are tuned for reliable results, while the raw Live API is easy to misuse. Reach for it for custom integrations, scripting, or debugging directly against the Live API when the standard tools aren't enough.

Saving tokens: compact responses

The REST API returns JSON by default (a parsed result plus a separate warnings list), which is what you want when the agent generates or parses MIDI data in scripts. If you're mostly reading state or having a conversational back-and-forth (not processing the data programmatically), you can save tokens by requesting the compact format instead: pass ?format=compact on tool calls (a one-line change in ppal.mjs's callTool: params.set("format", "compact")). The trade-off is that result then comes back as a raw string rather than parsed JSON, and warnings fold into it.

The bundled script ​

ppal.mjs is a zero-dependency Node 18+ script that wraps Producer Pal's REST API. It's both the CLI the skill shells out to and a small library you can import in your own code:

js
#!/usr/bin/env node

// Producer Pal REST API client (Node 18+, no dependencies).
//
// CLI:
//   node ppal.mjs --add-to-live-set
//   node ppal.mjs --set-config '<json>'
//   node ppal.mjs --list-tools
//   node ppal.mjs <tool> [json-args] [options]
//
// Options:
//   --url <baseUrl>          override Producer Pal URL (default http://localhost:3350)
//   --timeout-ms <ms>        per-request timeout (1–55000)
//   --set-config <json>      update device settings, e.g. '{"liveApiEnabled":true}'
//   --notation <name>        barbeat | midi-json | stark, for this request only
//   --disable-tools <names>  withhold tools from this request (comma-separated)
//   --small-model-mode       shrink tool schemas and Skills for this request
//
// Examples:
//   node ppal.mjs --list-tools --notation midi-json
//   node ppal.mjs ppal-read-live-set
//   node ppal.mjs ppal-read-track '{"path": "t0"}'
//   node ppal.mjs ppal-create-clip '{...}' --timeout-ms 10000
//   node ppal.mjs ppal-connect --disable-tools ppal-library,ppal-create-device
//
// Library:
//   import { listTools, callTool, setConfig, addToLiveSet } from "./ppal.mjs";
//   const { result, warnings } = await callTool("ppal-read-live-set");

import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";

const DEFAULT_BASE_URL = "http://localhost:3350";

// Three of the per-request headers. Unlike --set-config these change nothing on
// the device: each applies to the one request that carries it, so it can't move the
// chat UI or another client off its own notation or toolset. Absent ⇒ that
// client keeps the device's global setting.
const DISABLED_TOOLS_HEADER = "x-producer-pal-disabled-tools";
const NOTATION_HEADER = "x-producer-pal-notation";
const SMALL_MODEL_MODE_HEADER = "x-producer-pal-small-model-mode";

/**
 * Request headers for this call's profile, omitting whichever options are
 * absent. `disabledTools` is a string[] or a comma-separated string; `notation`
 * is "barbeat" | "midi-json" | "stark"; `smallModelMode` is a boolean.
 *
 * Nothing is remembered between requests, so pass the same options on every
 * call in a session — including the `listTools` call, so the schemas you read
 * match the notation you'll write.
 */
function profileHeaders(options = {}) {
  const names = (
    Array.isArray(options.disabledTools)
      ? options.disabledTools.join(",")
      : (options.disabledTools ?? "")
  ).trim();
  return {
    ...(names ? { [DISABLED_TOOLS_HEADER]: names } : {}),
    ...(options.notation ? { [NOTATION_HEADER]: options.notation } : {}),
    ...(options.smallModelMode != null
      ? { [SMALL_MODEL_MODE_HEADER]: String(Boolean(options.smallModelMode)) }
      : {}),
  };
}

/**
 * GET /api/tools — returns the full envelope `{tools: [...]}` as a parsed
 * object. The tool list endpoint always returns JSON; it has no `?format`
 * toggle. The profile options (see profileHeaders) shape the catalog: withheld
 * tools are omitted, and the descriptions and schemas resolve against this
 * request's notation and small-model mode.
 */
export async function listTools(baseUrl = DEFAULT_BASE_URL, options = {}) {
  const res = await fetch(`${baseUrl}/api/tools`, {
    headers: profileHeaders(options),
  });
  if (!res.ok) {
    throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  }
  return res.json();
}

/**
 * Call a Producer Pal tool by name. The REST API defaults to `format=json`, so
 * `result` is a parsed value (object/array/etc.) and warnings are surfaced as a
 * separate `warnings: string[]` field.
 *
 * The profile options (see profileHeaders) apply to this request: a withheld
 * tool 404s, `ppal-connect` returns a Skills blob matching this request's
 * notation and toolset, and `notation` also decides how notes in the arguments
 * are parsed and how notes in the result are formatted.
 */
export async function callTool(name, args = {}, options = {}) {
  const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
  const params = new URLSearchParams();
  if (options.timeoutMs != null) {
    params.set("timeoutMs", String(options.timeoutMs));
  }

  const query = params.toString();
  const url = query
    ? `${baseUrl}/api/tools/${name}?${query}`
    : `${baseUrl}/api/tools/${name}`;
  const res = await fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      ...profileHeaders(options),
    },
    body: JSON.stringify(args),
  });
  if (!res.ok) {
    throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  }
  return res.json();
}

/**
 * POST /config — update device settings remotely and return the full updated
 * config. `patch` is a partial object; the server ignores unrecognized keys and
 * also silently ignores an invalid value for a known field (an unknown
 * `notation` is dropped, keeping the current setting; booleans are coerced). The
 * only field that rejects with a 400 is an invalid `tools` list. Because bad
 * values are dropped rather than reported, read the returned config to confirm a
 * setting actually took effect.
 *
 * Every setting here is GLOBAL to the device — it also moves the chat UI and any
 * connected MCP clients. Prefer the per-request `notation`, `disabledTools`, and
 * `smallModelMode` options for anything they cover. What's left that only lives
 * here: `{ liveApiEnabled: true }` to turn on the advanced `ppal-live-api` tool.
 */
export async function setConfig(patch, options = {}) {
  const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
  const res = await fetch(`${baseUrl}/config`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(patch),
  });
  if (!res.ok) {
    throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  }
  return res.json();
}

const REMOTE_SCRIPT_URL = `http://127.0.0.1:${process.env.PPAL_REMOTE_SCRIPT_PORT ?? 3349}`;
const REMOTE_SCRIPT_REPO =
  "https://github.com/adamjmurray/producer-pal/tree/main/remote-script";
const ADD_WAIT_MS = 30_000;
// Live can re-create the device right after a load, so one answer can be the
// old device's last. Two in a row counts as up.
const READY_STREAK = 2;

/**
 * Add the Producer Pal device to the open Live Set, on a new MIDI track,
 * through the Producer Pal remote script (which must be installed and selected
 * as a Control Surface in Live). Then wait for Producer Pal to answer. Does
 * nothing if it already answers. This changes the user's Set, so only do it
 * when they asked for Producer Pal.
 *
 * Resolves to `{ producerPal: true }`, plus `addedProducerPal: { trackIndex,
 * trackName }` when it added the device.
 */
export async function addToLiveSet(options = {}) {
  const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
  if (await answers(`${baseUrl}/config`)) {
    return { producerPal: true };
  }

  let res;
  try {
    res = await fetch(`${REMOTE_SCRIPT_URL}/load`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ type: "mfl-device", name: "Producer_Pal" }),
      signal: AbortSignal.timeout(35_000), // the script waits up to 30s for Live
    });
  } catch (err) {
    throw new Error(
      err.cause?.code === "ECONNREFUSED"
        ? `The Producer Pal remote script isn't answering on ${REMOTE_SCRIPT_URL}. Is Live running? Install the script and select it as a Control Surface (Live Settings → Tempo & MIDI): ${REMOTE_SCRIPT_REPO}. Set PPAL_REMOTE_SCRIPT_PORT if it uses another port.`
        : `The Producer Pal remote script didn't reply: ${err.message}`,
      { cause: err },
    );
  }
  const body = await res.json().catch(() => ({}));
  if (!res.ok) {
    throw new Error(
      `Couldn't add Producer Pal: ${loadFailure(res.status, body, baseUrl)}`,
    );
  }

  const track = { trackIndex: body.track?.index, trackName: body.track?.name };
  if (!(await waitUntilUp(`${baseUrl}/config`, Date.now() + ADD_WAIT_MS))) {
    throw new Error(
      `Added the Producer_Pal device to track ${track.trackIndex} ("${track.trackName}"), but it didn't answer on ${baseUrl} within ${ADD_WAIT_MS / 1000}s. Check the device in Live.`,
    );
  }
  return { producerPal: true, addedProducerPal: track };
}

/** Explain a failed remote script /load. */
function loadFailure(status, body, baseUrl) {
  if (status === 404) {
    return "Live's browser has no Producer_Pal device. Install Producer_Pal.amxd in the User Library's Presets/MIDI Effects/Max MIDI Effect folder: https://producer-pal.org/installation#install-the-device";
  }
  if (status === 409 && body.candidates != null) {
    return `Live's browser has more than one Producer_Pal device: ${body.candidates.join(", ")}. Keep only one device named Producer_Pal there.`;
  }
  // A 409 without candidates: the Set already has one, but it didn't answer.
  if (status === 409 && body.error != null) {
    return `${body.error}. It isn't answering on ${baseUrl}: check that device.`;
  }
  return `the remote script answered ${status}: ${body.error ?? "(no error text)"}`;
}

/** Wait for `url` to answer READY_STREAK times in a row, until `deadline`. */
async function waitUntilUp(url, deadline) {
  let streak = 0;
  for (;;) {
    streak = (await answers(url)) ? streak + 1 : 0;
    if (streak >= READY_STREAK) {
      return true;
    }
    if (Date.now() >= deadline) {
      return false;
    }
    await new Promise((r) => setTimeout(r, 250));
  }
}

/** Whether a GET to `url` succeeds. */
async function answers(url) {
  try {
    const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
    return res.ok;
  } catch {
    return false;
  }
}

// --- CLI ---

function parseArgs(argv) {
  const opts = { baseUrl: DEFAULT_BASE_URL, listTools: false };
  const positional = [];
  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i];
    if (arg === "--url") {
      opts.baseUrl = argv[++i];
    } else if (arg === "--timeout-ms") {
      opts.timeoutMs = Number(argv[++i]);
    } else if (arg === "--list-tools") {
      opts.listTools = true;
    } else if (arg === "--add-to-live-set") {
      opts.addToLiveSet = true;
    } else if (arg === "--set-config") {
      opts.setConfig = argv[++i];
    } else if (arg === "--disable-tools") {
      opts.disabledTools = argv[++i];
    } else if (arg === "--notation") {
      opts.notation = argv[++i];
    } else if (arg === "--small-model-mode") {
      opts.smallModelMode = true;
    } else if (arg === "--help" || arg === "-h") {
      opts.help = true;
    } else {
      positional.push(arg);
    }
  }
  return { opts, positional };
}

const HELP = `Producer Pal REST API client

Usage:
  node ppal.mjs --add-to-live-set
  node ppal.mjs --set-config '<json>'
  node ppal.mjs --list-tools
  node ppal.mjs <tool> [json-args] [options]

Options:
  --url <baseUrl>          override Producer Pal URL (default ${DEFAULT_BASE_URL})
  --timeout-ms <ms>        per-request timeout (1–55000)
  --add-to-live-set        add the Producer Pal device to the open Live Set if it
                           isn't running (needs the Producer Pal remote script)
  --set-config <json>      update device settings, e.g. '{"liveApiEnabled":true}'
                           Global to the device — it moves every other client too.
  --notation <name>        barbeat | midi-json | stark
  --disable-tools <names>  withhold tools from this request (comma-separated
                           tool names)
  --small-model-mode       shrink tool schemas and Skills
  --help, -h               show this help

--notation, --disable-tools, and --small-model-mode apply to the ONE request
that carries them. Nothing is remembered between calls, so pass them every
time, --list-tools included.

Examples:
  node ppal.mjs --list-tools --notation midi-json
  node ppal.mjs ppal-read-live-set
  node ppal.mjs ppal-read-track '{"path": "t0"}'
  node ppal.mjs ppal-create-clip '{...}' --notation midi-json
  node ppal.mjs ppal-connect --disable-tools ppal-library,ppal-create-device
`;

async function main(argv) {
  const { opts, positional } = parseArgs(argv);
  if (opts.help) {
    console.log(HELP);
    return;
  }

  if (opts.addToLiveSet) {
    console.log(JSON.stringify(await addToLiveSet(opts), null, 2));
    return;
  }

  if (opts.listTools) {
    const result = await listTools(opts.baseUrl, opts);
    console.log(JSON.stringify(result, null, 2));
    return;
  }

  if (opts.setConfig != null) {
    let patch;
    try {
      patch = JSON.parse(opts.setConfig);
    } catch (err) {
      console.error(`Invalid JSON for --set-config: ${err.message}`);
      process.exit(1);
    }
    const updated = await setConfig(patch, opts);
    console.log(JSON.stringify(updated, null, 2));
    return;
  }

  const [toolName, argsJson = "{}"] = positional;
  if (!toolName) {
    console.error(
      "Missing tool name. Use --list-tools to discover tools, or pass a tool name as the first argument.",
    );
    process.exit(1);
  }

  let args;
  try {
    args = JSON.parse(argsJson);
  } catch (err) {
    console.error(`Invalid JSON for tool args: ${err.message}`);
    process.exit(1);
  }

  const response = await callTool(toolName, args, opts);
  if (response.isError) {
    console.error(`API error: ${response.result}`);
    process.exit(1);
  }
  console.log(JSON.stringify(response, null, 2));
}

// Run main() when invoked as CLI (not when imported as a library)
if (isEntryPoint()) {
  try {
    await main(process.argv.slice(2));
  } catch (err) {
    if (err.cause?.code === "ECONNREFUSED") {
      console.error(
        "Could not connect to Producer Pal. Is Ableton Live running with the Producer Pal device?",
      );
    } else {
      console.error(err.message ?? err);
    }
    process.exit(1);
  }
}

// Compare real paths: argv[1] may go through a symlink, and a file URL is
// percent-encoded (and /C:/... on Windows).
function isEntryPoint() {
  try {
    return (
      realpathSync(process.argv[1]) ===
      realpathSync(fileURLToPath(import.meta.url))
    );
  } catch {
    return false;
  }
}

Prefer Python?

The skill ships with the Node script because nearly every agent runtime has Node available, but a zero-dependency Python equivalent is also maintained; see the Python sample script. To use it, swap the node ppal.mjs commands in SKILL.md for python ppal.py and drop ppal.py into the skill folder.

Companion skills ​

The producer-pal skill is the connection. Five more skills come with it:

  • ableton-audio-generator: synthesize audio from scratch with plain Node.js DSP and place it in Live: drum kits and Drum Racks, samples for Simpler, wavetables, reverb impulse responses, and open-ended clips like drones and textures. The agent writes the DSP for what you asked for; a shared library handles WAV encoding so custom algorithms are cheap to try.
  • ableton-export-audio: get audio back out of Live. Bounce the mix, a single track, or one Session clip to a file. macOS only, no API key needed.
  • ableton-analyze-audio: listen to any audio file with Google's Gemini API for feedback on timbre, mix, and arrangement: any platform, no Ableton involved, needs a GEMINI_API_KEY. It uses the export skill to get the file out of Live first. The analysis is one short script against one HTTP endpoint, so swapping in a different audio-capable model or service is a small edit.
  • ableton-read-als: read Live Set files straight from disk, without opening Live or needing the device in the Set. Tracks, device chains with racks and macros, parameters, clips, mixer, routing, scenes. Point it at a whole projects folder to compare many Sets at once, like "what master chain do I usually use?". Live 11 and 12 files, any platform, read-only.
  • ableton-open-live-set: open a Live Set by path, or create a new one, and wait until it has loaded, answering the dialogs in the way. It never throws away unsaved changes or crash-recovered work unless you agree. It can also add Producer Pal to that Set, or to the one open now, through the Producer Pal remote script. macOS only; opening needs no device.

producer-pal-all-skills.zip has all six. Unzip it the same way:

bash
curl -L https://producer-pal.org/downloads/producer-pal-all-skills.zip -o /tmp/ppal-skills.zip
unzip -o /tmp/ppal-skills.zip -d ~/.claude/skills/

Same global-vs-project choice as above: unzip into a project's own skills folder (.claude/skills/) to scope them all to that project.

Two skills need macOS

ableton-export-audio and ableton-open-live-set drive Live's dialogs with AppleScript: export because Live has no render API, open because opening a Set means answering Live's save and crash-recovery prompts. Everything else in the bundle (audio generation, Gemini analysis, reading .als files) runs anywhere Node does.

See the skills README for the full list.

Source ​

Released under the GPL-3.0 License.