Unofficial documentation and Python tooling for .lightkeyproj files — the project
format used by Lightkey, the macOS DMX lighting control app.
The format is not publicly documented. Everything here was reverse-engineered from real project files across many build-and-test cycles on a live venue rig, so it covers not just the schemas but the silent failure modes — the mistakes that make Lightkey crash on open, render an empty panel, or quietly ignore what you wrote.
Install it into Claude and you can just ask for what you want — "add a colour bank to my Lightkey project" — and it works from hard-won knowledge instead of guessing at an undocumented binary format.
Unofficial and unaffiliated. Not endorsed by Lightkey or Monospace. It reads and writes your project files, so keep backups and never overwrite your only copy.
Two ways. Pick the one that matches how you use Claude.
Run these two commands:
claude plugin marketplace add jimhoggey/Lightkey-Skillclaude plugin install lightkey-patcher@lightkey-formatAlready inside Claude Code? Use /plugin marketplace add jimhoggey/Lightkey-Skill and
/plugin install lightkey-patcher@lightkey-format instead — same thing.
Done. Later on, /plugin marketplace update lightkey-format updates it and
/plugin uninstall lightkey-patcher@lightkey-format removes it.
Upload a ZIP — no terminal needed.
- Download the ZIP: lightkey-patcher-skill.zip
- In Claude, go to Settings → Capabilities → Skills (called Customize → Skills in some versions).
- Click Add, choose the ZIP you just downloaded, and upload it.
- It appears in your skills list with a toggle. Leave it switched on.
You also need code execution enabled in settings, because the skill runs Python. Skills you upload are private to your account.
Optional add-on — Stream Deck buttons for your cues: lightkey-streamdeck-skill.zip. Upload it the same way. Only needed if you run your show from an Elgato Stream Deck. (The Claude Code plugin above already includes it.)
⚠️ Don't use the green "Code → Download ZIP" button on this repo. That archive hasSKILL.mdin the wrong place and Claude will reject it. Use the download link above — it's the same content, packaged the way Claude needs.
Nothing to configure. Claude uses it automatically as soon as you mention Lightkey, a
.lightkeyproj file, or DMX lighting — you never have to invoke it by name.
Other ways to install
As a plain skill, without the plugin system:
git clone https://github.com/jimhoggey/Lightkey-Skill ~/.claude/lightkey-format
mkdir -p ~/.claude/skills
ln -s ~/.claude/lightkey-format/skills/lightkey-patcher ~/.claude/skills/lightkey-patcher
ln -s ~/.claude/lightkey-format/skills/lightkey-streamdeck ~/.claude/skills/lightkey-streamdeck # optionalSKILL.md refers to docs/… and lightkey/… relative to the repo root, which the symlink
preserves. For a project-scoped install use .claude/skills/ inside the project instead.
With Cursor, Copilot or another assistant: nothing here is Claude-specific. Point it at
skills/lightkey-patcher/SKILL.md as the entry point —
it's plain markdown that links onward into docs/. Adding SKILL.md and
docs/pitfalls.md to context is enough to avoid the expensive mistakes.
Just the Python, no AI: see Quick start below.
Claude can't reach your hard drive there, so the loop is upload → ask → download:
- Find your project file — a single
.lightkeyproj, wherever you saved it (often~/Documents). - Duplicate it first. ⌘D in Finder. Work on the copy; never upload your only copy.
- Attach the copy to a new conversation (the 📎 button) and ask for what you want.
- Download the file Claude gives back, open it in Lightkey, and test it on the rig before a service or show.
Just point it at the file — it can read and write your project directly, which makes it the better choice if you're iterating on a rig across many versions.
What's in this Lightkey project? List my fixtures, cues and preset groups.
My reds are coming out blue on the actual lights — what's wrong?
Add a House Lights row with Off / 10 / 25 / 50 / 100%, where pressing one
turns the previous one off.
Build me a colour bank: 8 warm ambient looks, mirrored left-to-right,
and make them all release each other.
Add a section of moving-head positions — stage, ceiling, and a slow sweep.
Build me a MIDI-driven cue bank for a video opener: one-shot hits per zone,
a few full-stage states, strobes, and an EXIT that fades back to the service look.
Make the animated flow exactly match the static look so I can switch between them.
Check this file I generated: is anything broken, and does the panel have
text hidden behind buttons?
Here's my latest save. The warm look drifts to white — keep it warm, and give
the movers two positions aimed at the side walls.
These cues do nothing when I press them, but the same thing works in Design view.
Make me a Stream Deck profile for this show, with the house levels on the home
page, and keys that light up while their cue is on.
Most of the work after version 1 is small changes to a show that runs every week. The skill
follows a staged loop for that (docs/update-workflow.md): it builds on your newest save
(so your own edits in Lightkey survive), diagnoses anything "broken" before changing it, asks
only the questions that are really yours, builds a new numbered version, proves that nothing
you didn't ask about changed, and hands back test steps for the rig and a file to roll back to.
If you run the show from a Stream Deck, the lightkey-streamdeck add-on builds the profile:
one key per cue, sending MIDI notes through the free "MIDI" plugin by Trevliga Spel into
Lightkey, with keys that light while their cue is live. It reads every note, key type and
group from your show file, so the deck and the show can't disagree, and it refuses the
key/trigger combination that crashed Lightkey on a live rig. Export your current profile from
the Stream Deck app, attach it with your show, and ask. Details: docs/streamdeck-midi.md.
A realistic first session:
you [attaches MyVenue.lightkeyproj]
What's in this project, and are any of my colours wrong?
Claude runs tools/inspect_project.py → 32 fixtures, 140 cues, 16 preset groups,
old fpStore schema, 8 mutex groups
runs tools/probe_colour.py → blue-low/red-high confirmed, and flags
"Fire Red" as storing blue
…explains what it found and offers to fix the mis-packed presets
Two habits worth keeping. Ask Claude to inspect before it edits — the tooling is built
around reading your file first, and a change made without that is a guess. And test on the
rig, not just in the app: a project can open perfectly and still have a button wired to
nothing. docs/pitfalls.md exists because all of these failures are silent.
Lightkey stores each colour as a 64-bit integer with blue in the low bits and red in the high bits:
bits 0-15: Blue bits 16-31: Green bits 32-47: Red bits 48-63: Alpha
def pack_color(r16, g16, b16, a16=0):
return ((b16 & 0xFFFF) | ((g16 & 0xFFFF) << 16) |
((r16 & 0xFFFF) << 32) | ((a16 & 0xFFFF) << 48))Get this backwards and every red preset renders blue — while your code, your preset names and your validation all look correct. It survived several release cycles on a real rig because the obvious test colours (white, grey, amber) look identical under either byte order. Test with a saturated primary, or just run the probe:
python3 tools/probe_colour.py MyProject.lightkeyprojIt decodes every named colour preset under both interpretations, tells you which one agrees with what the presets are called, and flags any preset whose stored colour contradicts its own name — those were written by a patcher with the wrong packing.
| Path | What it is |
|---|---|
docs/archive-format.md |
NSKeyedArchiver basics: $objects, $top, UID references |
docs/class-schemas.md |
Field-by-field schemas for LXCue, LXPreset, LXSequence, LXControlPanel, LXCpanButton, LXTextCanvasItem, MIDI/key bindings, fixture profiles & capabilities |
docs/fpstore-format.md |
The inner binary plist each preset carries: umbrellaContainers, colour packing, native effects, moving heads |
docs/patterns.md |
28 working recipes: radio groups, LTP layering, beat-synced sequences, mirrored gradients, collision-checked layout, one-shot cues, MIDI/timeline show blocks, twin flows, strobes, moving-head vocabulary |
docs/pitfalls.md |
32 documented failure modes, each with symptom → cause → fix |
docs/update-workflow.md |
The staged loop for revising a show that already runs: base file, diagnosis table, request → doc routing, validation layers, hand-off |
docs/streamdeck-midi.md |
Stream Deck add-on: how keys, the MIDI plugin and Lightkey's ports connect; key mode ↔ trigger behaviour pairing; latch groups; profile format |
lightkey/resolve.py |
Inspection library: load(), find_instances(), resolve(objs, uid, depth=N) |
lightkey/colour.py |
pack_color / c8 / unpack_rgb8, uniform-brightness palettes |
lightkey/build.py |
Builder plus mk_preset / mk_cue / mk_button / mk_sequence / mk_text_label / mk_cpan_frame and build_fpstore |
lightkey/bindings.py |
MIDI bindings: list them, find free notes, add note triggers, change a trigger's behaviour |
lightkey/validate.py |
Validator — semantic checks on a file you generated, including unchanged_except() and bindings_intact() for revisions |
streamdeck/ |
Stream Deck profile builder and checker (the add-on's library) |
tools/inspect_project.py |
CLI: dump fixtures, cues, panels, groups, schema flavour, MIDI/key bindings (--midi, dead ones flagged) |
tools/probe_colour.py |
CLI: prove the colour byte order against your own project |
tools/export_midi_map.py |
CLI: the show's MIDI map as CSV |
tools/build_streamdeck_profile.py |
CLI: build a Stream Deck profile from a show + layout (+ icons on macOS) |
tools/check_streamdeck_profile.py |
CLI: check any profile against the show; diff against the previous one |
tools/extract_effects.py |
Pull native-effect blobs out of a reference project for cloning |
tools/build_skill_zip.py |
Repackage the repo as claude.ai skill ZIP uploads (one per skill) |
examples/build_dimmer_panel.py |
End-to-end: build a working radio-group dimmer panel |
examples/revision/ |
Templates for a revision build and its validator |
examples/streamdeck_layout.json |
Annotated Stream Deck layout |
skills/lightkey-patcher/SKILL.md |
Entry point when used as a Claude skill or plugin |
skills/lightkey-streamdeck/SKILL.md |
Entry point for the optional Stream Deck add-on |
.claude-plugin/ |
Plugin + marketplace manifests |
git clone https://github.com/jimhoggey/Lightkey-Skill
cd lightkey-format
python3 tools/inspect_project.py ~/Documents/MyProject.lightkeyprojNo dependencies — plistlib is in the standard library. Python 3.9+.
from lightkey.resolve import load, find_instances, classname
data, objects = load('MyProject.lightkeyproj')
for uid in find_instances(objects, 'LXCue'):
print(objects[uid].get('priority'))Read docs/pitfalls.md before you write your first line. Lightkey's
unarchiver is strict and fails silently: a preset whose dict has one extra key, or a name
wrapped in NSMutableString instead of stored as a raw string, is dropped without an error
message. The short version:
- Never overwrite
top.rootPresetGroup— the Live panel resolves buttons through it. namefields are raw plist strings, notNSMutableStringwrappers.- Never hardcode UID literals — UIDs are offsets into
$objectsand move on every save. - Reuse existing class definitions; don't mint duplicates.
- Modify the user's objects in place where you can. Rebuilding a panel from scratch drops bindings and destroys hand-made edits.
- Every reference you write is a
plistlib.UIDinstance — a bare int parses fine and silently empties the panel (and Lightkey re-saves it empty). - One preset/sequence per mutex group per cue; give a show block an EXIT cue.
Then validate what you wrote, not what you meant:
from lightkey.validate import Validator
v = Validator('source.lightkeyproj', 'output.lightkeyproj')
v.structural_parity() # class defs, key sets, raw-string names, root untouched
v.buttons_resolve() # every button -> cue -> preset -> fpStore
v.preserved_buttons() # source buttons kept, cue/behavior/tint unchanged
v.no_overlap(ignore_preexisting=True) # no NEW label/button collisions
v.mutex_intact(['Colour Bank'])
v.one_shot('Hit All', max_hold=1.0) # finite hold, releases itself
v.hues_within('Fire', {'red', 'orange'}) # catches a byte-order regression
v.report()Covers the older fpStore schema (umbrellaContainers), which is what almost every file
in the wild uses. The newer containers/subcontainers schema seen in Lightkey's own
Effects Showcase is only partially mapped — contributions very welcome.
Native effects (WaterFall, Color Cycle, …) keep their parameters in an opaque blob. You can
clone them verbatim onto other fixtures, but you cannot recolour them; build your own
palette as a sequence instead (docs/patterns.md §22).
Different rigs surface different corners of the format. If you find a class this doesn't document, a schema variant, or a new way to make Lightkey fail quietly, please open an issue or PR — see CONTRIBUTING.md. Please don't attach project files containing real venue data; a minimal reproduction is more useful anyway.
MIT — see LICENSE.