# Ableton Live Agent Skills

Producer Pal ships a portable Agent Skill that lets coding agents control
Ableton Live through Producer Pal's [REST API](/guide/rest-api), with no MCP
client required.

::: warning 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**](/guide/customizing-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:

| Tool                                                              | Global skills location             |
| ----------------------------------------------------------------- | ---------------------------------- |
| [Claude Code](https://docs.claude.com/en/docs/claude-code/skills) | `~/.claude/skills/<name>/SKILL.md` |
| [Codex CLI](https://developers.openai.com/codex/skills/)          | `~/.codex/skills/<name>/SKILL.md`  |
| [Gemini CLI](https://geminicli.com/docs/cli/skills/)              | `~/.gemini/skills/<name>/SKILL.md` |

::: info 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](/guide/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](/installation/claude-desktop), the [Chat UI](/guide/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](/guide/rest-api) directly, it can pick its own
[notation](/features/midi-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](/guide/rest-api) directly.

:::

## Install

Download
<a href="/downloads/producer-pal-skill.zip" download>producer-pal-skill.zip</a>
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](/features/midi-notation#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](/guide/remote-script) installed and selected in Live,
and the device installed in your User Library's Max MIDI Effect folder (see
[installing](/installation#install-the-device)).

### Notation and small-model mode

Producer Pal encodes MIDI notes in one of three
[notations](/features/midi-notation):
[`bar|beat`](/features/midi-notation#bar-beat) (the default, compact
human-readable text), [`midi-json`](/features/midi-notation#midi-json) (notes as
a JSON array), and [`stark`](/features/midi-notation#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](/features#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](/guide/optimizing)
has the numbers.

### Direct Live API (advanced)

The `ppal-live-api` tool gives direct, low-level access to the
[Live Object Model](https://docs.cycling74.com/apiref/lom/) 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.

::: tip 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;
  }
}
```
::: tip 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](/guide/rest-api#python). 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](/guide/remote-script). macOS only; opening needs no device.

<a href="/downloads/producer-pal-all-skills.zip" download>producer-pal-all-skills.zip</a>
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.

::: warning 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](https://github.com/adamjmurray/producer-pal/tree/main/examples/skills)
for the full list.

## Source

- Skill folder:
  [`examples/skills/producer-pal/`](https://github.com/adamjmurray/producer-pal/tree/main/examples/skills/producer-pal)
- REST API reference: [REST API guide](/guide/rest-api)
