← Learn
M4LJavaScriptLive API

Max for Live,
in JavaScript

From a CS background to building, validating, and extending Max for Live devices in JavaScript. The language is the easy part. What's new is the runtime: a message-passing host, a live object tree you steer through a single API, and an engine from a different decade.

orange — note green — tip yellow — heads up
The gotchas

Why M4L coding feels off, even with a CS degree

1

You receive messages, you don't get called

The patch sends bare values into numbered inlets. Max routes each to a magic-named handler (msg_int, bang, anything…). The value's type and this.inlet are your whole interface, with no signature you design.

2

There is no return value

Handlers return nothing to the patch. Push results out through numbered outlets with outlet(n, …). Think wires, not call stacks.

3

One global interpreter, on the main thread

State lives in module globals. The script runs on Live's main thread, so a long loop freezes the UI, not audio. Heavy work gets deferred. There's no async runtime.

4

Live is only visible through one keyhole

Everything goes through LiveAPI and the Live Object Model. You navigate a tree, read/write properties, call functions. Object identities are temporary, and figuring out what's stable is half the work.

Live Object Model

The Live Object Model

Everything a device does goes through LiveAPI calls against the Live Object Model. Reference: LOM class list and Live API Overview.

The four root paths

live_setthe open Set (the Song): tracks, scenes, master, transport.
live_appthe application itself, including its browser.
this_devicethe M4L device the script is running inside.
control_surfacesconnected controllers.

Explore it

Click any row to expand. The bar at the bottom shows the exact path you'd pass to LiveAPI.

live object model · tree
live_setThe open Live Set — pick a node above.

Three ways to point at an object

// 1. By PATH — positional, readable, but shifts if tracks move
new LiveAPI(null, "live_set tracks 0 devices 0");

// 2. By ID — a runtime handle Live hands you; stable *within* a session
new LiveAPI(null, "id", 42);

// 3. By a VIEW path — "what the user has selected right now"
new LiveAPI(null, "live_set view selected_track");
Note
First argument is an observation callback. Pass null to read/write once; pass a function and set .property to be notified on every change. LiveAPI reference.
Heads up
No LiveAPI in global code: only inside a function, after Live has loaded.
Tip
live_set view selected_track is the workhorse pattern: acts on whatever the user clicked without hard-coding any index.

The four verbs

.get("prop")Returns an array, even for a single value: ["Drums"], not "Drums". This trips everyone up once.
.set("prop", v)write a property.
.call("fn", …)invoke a function on the object.
.info / .id / .pathmetadata. .info dumps every property and function the object exposes in your Live version.
Heads up
The LOM changes between Live versions. A property that doesn't exist fails silently. Before trusting any name (including from this page), log obj.info and read the real list.

What's stable, what's not

idRuntime-only. Yesterday's id 42 is someone else today. Never save across sessions.
pathPositional — shifts when you drag tracks.
stable handleSave a name, tag, or browser uri. Re-resolve to an id at load time.
Inlets & outlets

Inlets, outlets, and the magic handlers

inlets  = 1;     // how many inlets the object box shows
outlets = 2;     // and how many outlets

// Handlers are matched by the *type* of the incoming message:
function msg_int(n)    { /* an integer arrived */ }
function msg_float(f)  { /* a float arrived     */ }
function bang()        { /* a bang arrived      */ }
function msg_symbol(s) { /* a single word       */ }
function list(a, b)    { /* a space-separated list */ }
function anything(sel) { /* anything else; sel = first word */ }

// Which inlet did it come in on?  ->  this.inlet  (0-based)
Note
You don't register handlers: you just define them. Max routes by message type. anything is the catch-all; its first argument is the leading symbol.
Tip
this.inlet lets one handler serve multiple inlets. Branch on it to give each inlet a distinct meaning.

The reload / load burst

Saving a JS file and opening a Set both fire a rapid burst of messages into your script. Guard destructive actions with a time gap so a replay can't double-fire.

let lastFire = 0;
function msg_bang_action() {
    const now = Date.now();
    if (now - lastFire < 50) return;   // swallow burst replays
    lastFire = now;
    doTheDestructiveThing();
}
Tip
Only guard actions that cause harm on replay. Re-reading state on reload is fine. Don't blanket-debounce everything.
Heads up
50 ms is a heuristic. A fast legitimate double-tap can be eaten; a slow machine can leak a burst. Revisit if input feels laggy.
Two JS engines

Two engines, two decades of JavaScript

the js objectthe v8 object
engineMozilla SpiderMonkey, ~2011 vintageGoogle V8 (Chrome/Node)
languageroughly ES5: var only, no arrows, no class, no Map, no template literalsup to ES2022: modern everything, plus real JSON
ships inMax 8 / Live 11 and earlierMax 9 / Live 12.2+ (mid-2025)
reachruns everywhereLive 12.2+ users only
Note
The Max API surface is identical across both. Porting is mechanical: swap js file.js for v8 file.js, modernize syntax at leisure.
Heads up
Ship a v8 device and every Live 11 user can't run it. Target jsfor reach, or label the device "requires Live 12.2+".
Tip
For learning, start on v8. Modern syntax + JSON + Map make LOM-wrangling far less painful. The example below is v8 but works under js too.
Full device walkthrough

A whole device, side by side

Reads the selected track's volume, lets you set it from a float, and updates live when you move the fader or switch tracks.

4.1 — Setup & state

// trackvol.js — a minimal Max for Live device.
// Shows and controls the volume of the *currently selected*
// track, and updates live when you pick a different track or
// move the fader.
// Runs in a v8 object (Max 9 / Live 12.2+); the legacy js
// object works too.
autowatch = 1;   // reload on save while developing

inlets  = 1;     // inlet 0: a float 0.0–1.0 to set the volume
outlets = 2;     // outlet 0: track name  |  outlet 1: volume

// You CANNOT create a LiveAPI in global code — only inside a
// function, after Live has opened the Set.
let volumeObserver = null;
let volumePath     = null;
Heads up
You can't construct a LiveAPI in global code: only inside a function, after Live has loaded. Observers are declared empty here and built later inside init.

4.2 — Init

// A `live.thisdevice` object bangs once, when the device is
// fully loaded. Wire its bang to this script.
function init() {
    const view = new LiveAPI(onTrackChange, "live_set view");
    view.property = "selected_track";
    onTrackChange();   // prime it for whatever's selected now
}
Note
Wire a live.thisdevice bang to this script. It fires once the device and Set are fully loaded, which is the safe moment to first touch the API.
Tip
Passing a callback + setting .property registers a live observer. Calling onTrackChange() immediately primes the display for the current selection.

4.3 — React to the selected track changing

function onTrackChange() {
    const t = new LiveAPI(null,
        "live_set view selected_track");
    if (t.id === 0) return;   // id 0 = "no object"

    // .get() ALWAYS returns an array: ["Drums"]
    outlet(0, "set", t.get("name")[0]);

    volumePath =
        "live_set view selected_track mixer_device volume";
    if (!volumeObserver)
        volumeObserver = new LiveAPI(onVolumeChange);
    volumeObserver.path     = volumePath;
    volumeObserver.property = "value";
}
Note
Every .get returns an array. [0] is the single most common fix.
Heads up
There's no clean unregister. Spawning a fresh observer on every track change accumulates them and slows Live. One observer, repointed, is the pattern.

4.4 — React to the volume value changing

// Fires whenever the volume changes — whether we set it,
// the mouse moved the fader, or automation did.
// args looks like ["value", 0.85].
function onVolumeChange(args) {
    if (args[0] === "value") outlet(1, args[1]);
}
Note
The callback args are [propertyName, value]. Checking args[0] future-proofs it if you later observe multiple properties with one callback.
Tip
Fires for your set, mouse moves, and automation. You don't poll.

4.5 — Write back, and a manual refresh

// A float arriving on inlet 0 sets the current track's volume.
function msg_float(v) {
    if (this.inlet !== 0 || !volumePath) return;
    const vol = new LiveAPI(null, volumePath);
    vol.set("value", Math.max(0, Math.min(1, v)));
}

// A bang re-reads everything — handy behind a "refresh" button.
function bang() { onTrackChange(); }
Note
Volume is 0–1 normalized. Out-of-range writes fail silently, so clamp everything.
Heads up
Setting value fires onVolumeChange. Fine here, but an observer reacting to its own write can spiral in richer devices. Gate writes when needed.
Validating a device

Validating a device

  1. 1
    Open the Max Console. Every post() lands here. If it's empty when you act, the messages aren't reaching the script. Check patch wiring before the JS.
  2. 2
    Confirm init ran. post a line at the end of init. If you never see it, the live.thisdevice bang isn't wired and every API call afterward is operating on nothing.
  3. 3
    Verify property names with .info. Log the owning object's .info and confirm the name exists in your Live version. Silent no-ops from a misremembered property are the most common failure.
  4. 4
    Exercise the read path. Select different tracks; name and volume outputs should update. If not, log t.id: 0 means the path resolved to nothing.
  5. 5
    Exercise the write path. Send floats in; the fader should move. Send 2.0 and −1.0 to confirm your clamp holds.
  6. 6
    Confirm the observer fires from outside. Move the fader with the mouse and with automation. Both should update the output. You're observing, not polling.
  7. 7
    Save, close, reopen the Set. The device should re-init cleanly. This catches anything that depended on stale ids.
  8. 8
    Watch the thread. Big traversals go behind deferlow. Long work on the main thread freezes Live's UI.
  9. 9
    Mind async writes. Loading a device isn't synchronous, so the object appears a tick later. Defer the follow-up read.
Patterns

Patterns worth keeping

Reusable building blocks

Observe the selection

live_set view selected_track generalizes to any selection-driven tool: device, clip, scene.

Read & set parameters

Walk to a DeviceParameter and use value, min, max, display_value. Covers macros, randomizers, most parameter tools.

Persist a stable handle

Never save an id. Save a name, tag, or browser uri and re-resolve on load. Prefer getvalueof/setvalueof over the outlet/inlet dance.

Load from the browser

Reach live_app browser, walk a category, and call("load_item", …) to drop a device on the selected track.

Reuse observers

There's no clean unregister. Spawning one per event accumulates them and slows Live. One observer, repointed.

Defer heavy work

Big tree walks behind deferlow. Reads after an async call get deferred a tick.

Resources

Official resources

Pair these with your own .info dumps. Never trust one source alone.

Quick reference

One-screen cheat sheet

Address an object

new LiveAPI(null, "live_set view selected_track")

Read (returns an array!)

let name = api.get("name")[0];

Write

api.set("value", 0.8);

Call a function

browser.call("load_item", "id", itemId);

Observe a property

var a = new LiveAPI(cb, path);
a.property = "value";

Self-verify the API

post(api.info);  // the real property list

Decode a message by type

msg_int / msg_float / bang / msg_symbol / anything
this.inlet  // which inlet fired

Two hard rules

No LiveAPI in global code. Never persist an id: store a stable handle and re-resolve.

Don't block

Main thread. Heavy walks → deferlow. Reads after async calls → defer a tick.

← All ArticlesRead alongside the official docs & your own .info dumps.