About rebelle
rebelle is a skill for agents automating artwork in Rebelle. It supports live WebSocket control and Motion IO batch rendering, with guidance for validating output and handling operational risks.
Key features
- 01Send painting events over WebSocket
- 02Render batches with Motion IO
- 03Build stroke and animation-frame events
- 04Export canvases and inspect PNG output
Use cases
- 01Automate painting in an open Rebelle canvas
- 02Generate reproducible artwork in batches
- 03Create frame-by-frame animation sequences
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ jablonkai/ skills --skill rebelleAfter installing, type/rebellein Claude Code to confirm it loaded.
Bundled files
References and scripts installed along with SKILL.md.
references/assets.mdbatch.mdjson-events.mdwebsocket.mdscripts/rebelle_events.pyrebelle_ws.pyrebelle-batch.shDetails
From the project READMERebelle Control
Rebelle simulates real paint — water flows, pigments granulate, impasto catches light — and both of its scriptable builds take the same JSON event vocabulary:
| Build | How you drive it | Best for |
|---|---|---|
/Applications/Rebelle 8.app (Pro) | WebSocket server, live | painting into the app the user is watching, exploring, one-off artwork — and animation when Motion IO is absent |
/Applications/Rebelle 8 Motion IO.app | -batch-json file, headless-ish | reproducible renders, animation frame sequences, data-layer in/out |
Check which are installed first (ls -d /Applications/Rebelle*) — often only Pro is.
The Pro binary accepts -batch-json and then silently ignores it (blank artwork, no
frames, verified 8.3.4), so Path B is Motion IO only.
Start live unless the task needs data layers, 16-bit output or a headless render: the round-trip is seconds instead of a minute, you can look at the canvas whenever you want, and animations work live too (see Path B's last paragraph).
- references/json-events.md — the full event vocabulary (both paths). Read before writing anything beyond simple strokes.
- references/websocket.md — live protocol, the undocumented
cmdAPI, what is not supported live. - references/batch.md — Motion IO CLI, data layers, frames→video, licensing.
- references/assets.md — resolving brush preset and paper names (they fail silently if wrong).
- scripts/rebelle_ws.py — dependency-free WebSocket client (library + CLI).
- scripts/rebelle_events.py — builds event files; encodes the batch frame rules and the stroke geometry helpers.
- scripts/rebelle-batch.sh — runs a batch and returns when it is actually done.
Path A — live, over the WebSocket
Rebelle only listens when it was launched with the flag, and it does not have to be restarted for each task — check first, ask second:
python3 scripts/rebelle_ws.py --ping # prints e.g. "Rebelle 8 Pro here"
If nothing answers, ask the user before launching: a fresh instance is a new blank document, and painting goes into whatever artwork is open, with no undo grouping.
"/Applications/Rebelle 8.app/Contents/MacOS/Rebelle 8" \
-websocket-server-enable \
-websocket-allowed-ip-addresses "::ffff:127.0.0.1,127.0.0.1" &
The allowlist is matched against the socket's literal peer address, and a local client
arrives as the IPv6-mapped ::ffff:127.0.0.1 — listing plain 127.0.0.1 alone refuses
your own connection (... refused in Rebelle's output).
Then drive it from Python — the helpers are plain modules in this skill's scripts/,
so put that directory on the path (or run from it):
import sys; sys.path.insert(0, "<this skill>/scripts")
from rebelle_ws import Rebelle
from rebelle_events import catmull_rom, taper
with Rebelle() as r:
r.event({"event_type": "NEW_ARTWORK", "width": 1200, "height": 800, "units": "px"})
r.event({"event_type": "SET_BRUSH", "tool": "WATERCOLOR", "preset": "Watercolor/Round",
"size": 55, "water": 70, "opacity": 90, "paint_type": "PAINT",
"color": {"r": 30, "g": 90, "b": 200}})
r.stroke(catmull_rom([(150, 450), (400, 250), (800, 600), (1050, 320)]),
pressure=taper(0.95))
r.event({"event_type": "SIMULATION", "repeats": 20}) # let the water spread
r.sync() # wait until Rebelle is really finished
r.export("/tmp/check.png") # then Read the PNG
sync() sends a BOOKMARK and waits for the echo. Nothing else acknowledges anything,
so without it you are exporting a canvas that is still mid-stroke.
NEW_ARTWORK is the one event you must not race: while the canvas re-initialises,
Rebelle drops the next message — typically your SET_BRUSH — and a stroke without a
brush crashes a freshly launched app (bad_optional_access, 8.3.4). Rebelle.event()
blocks after NEW_ARTWORK until a bookmark comes back (ready()), so go through it
rather than raw send_text(); a hand-rolled client has to poll the same way.
SAVE/LOAD events are rejected live — export() (cmd: export_canvas) is the only
way out, and it writes the composited canvas exactly as the user sees it.
Path B — batch rendering with Motion IO
python3 build_painting.py # writes events.json via rebelle_events.Doc
bash scripts/rebelle-batch.sh events.json out/
Motion IO never exits by itself on macOS and reports progress only as
batch frame end: i/n on stdout, so always go through rebelle-batch.sh — it waits for
i == n, surfaces the ERROR: lines, then stops the app.
One frame = one output image + one implicit fluid-simulation step. That makes animation natural: paint a bit, close the frame, repeat.
from rebelle_events import Doc, catmull_rom, taper
d = Doc(1200, 800, paper={"preset": "Handmade/HM01 Handmade", "deckled_edges": True})
d.set_brush("WATERCOLOR", "Watercolor/Round", size=55, water=70, opacity=90,
paint_type="PAINT", color=(30, 90, 200))
d.stroke(catmull_rom([(150, 450), (400, 250), (800, 600), (1050, 320)]), pressure=taper())
d.frame() # one animation frame ends here
d.simulation(15, frames=20) # 20 frames of the paint spreading and drying
d.write("events.json")
The frame rules that make batch work
Doc applies these already; hand-written JSON must too, or the run wedges forever or
loses work silently. Motion IO is still finishing its own startup while it reads the
first frames, and these are the shapes that survive it:
- Frame 0 is a warm-up that must load a brush. A
SET_BRUSHthere is enough real work to let startup complete. A frame with only aBOOKMARK— or an empty frame — deadlocks before a single frame is processed. NEW_ARTWORKgoes in frame 1, never frame 0. In frame 0 it is dropped without a word and you silently get Rebelle's default A4 canvas (2339×1654 at 200 dpi).- Something must follow
NEW_ARTWORKin its frame (anotherSET_BRUSHdoes). A frame that ends right afterNEW_ARTWORKdeadlocks. - Don't paint in the
NEW_ARTWORKframe — the canvas re-init wipes it. Start painting in the next frame (Doc.first_content_frameis where real content begins; pass it toffmpeg -start_number).
If you must skip NEW_ARTWORK entirely, -input artwork.reb opens an existing artwork
and is the sturdiest way to fix a canvas size — but the user has to have saved one.
No Motion IO? Render the same Doc live. rebelle_ws.py --send events.json --frames-out out/ replays it frame by frame over the WebSocket and exports
frame_0000.png… with batch numbering, so first_content_frame and the ffmpeg recipe
in batch.md apply unchanged. Live, the fluid
simulation runs in real time rather than one step per frame, so give wet passages
explicit SIMULATION repeats instead of relying on frame count.
Painting that looks painted
- Coordinates are canvas pixels, y down from the top-left, and may go outside the canvas — start and end strokes off-canvas for edge-to-edge washes.
- A
POINTER_MOVEdraws the previous segment, so a stroke must end with a release at the last move's position.stroke()/stroke_events()handle it; hand-written events routinely lose their final segment to this. - Calibrate brush width per preset. Neither
size(the UI slider) norsize_pxpredicts the painted width: on 8.3.4,size_px200 gave ~150 px with Round, ~50 px with Mop and ~200 px with Flat, andsize_px50 on Round only ~6 px. Paint one test stroke per preset, export, measure, then scale — before committing to a composition. - Sample paths densely. Rebelle interpolates between points, so four points give a
smooth-but-generic curve;
catmull_rom()at ~16 points per segment lets the brush texture, spacing and pressure actually show. - Vary pressure. A constant 0.9 reads as machine-drawn.
taper()thins both ends,ramp()builds up — that alone is most of the difference between a plot and a stroke. - Wet media need simulation time. Water only spreads on simulation steps: paint,
then spend frames (or
{"event_type":"SIMULATION","repeats":N}) letting it bloom, andDRY/FAST_DRYbefore painting a layer that should not bleed into the one below. - Build in layers of colour, as in real painting: pale washes first,
DRY, then darker glazes over them.SET_ENGINE_PARAMScontrols absorbency, drips, granulation. - Pick brushes by simulation, not by name:
WATERCOLORfor washes and bleeds,INK_PENfor line work that can be re-wetted,OIL_AND_ACRYLICfor impasto and colour mixing on the canvas,PENCIL/PASTELfor dry texture,AIRBRUSHfor soft gradients.
Always look at the result
Rebelle answers almost nothing, and a wrong paper or preset produces a plausible,
completely wrong image. Export and Read the PNG after each meaningful step, the same
way you would glance at the canvas: live via r.export(path), in batch by reading the
last rendered frame. Judge it as a picture — coverage, colour, whether the water did
anything — and iterate. Then, when the user wants the finished artwork, export at full
size (rgba_canvas, or SAVE with scaling for a NanoPixel-scale export).
Gotchas worth knowing before you hit them
- Wrong preset name → loud error; wrong paper name → silence. Resolve both against
the filesystem first (assets.md); the official quickstart's
"Default/HM01 Handmade"is itself wrong for Rebelle 8. SET_BRUSHwithoutpresetpaints nothing and logsPreset for brush was not set.- Only one Rebelle at a time. Running the GUI build and Motion IO together is asking for trouble; stop one before starting the other.
- Force-stopping the app leaves a
Data/Sessions/<pid>/folder behind in~/Library/Application Support/Escape Motions/Rebelle 8/. Harmless, but they pile up at tens of MB each — worth mentioning to the user rather than deleting behind their back. - Colours are 8-bit RGB objects (
{"r":…,"g":…,"b":…}) even though the engine mixes in 16-bit; 16-bit output only exists in Motion IO.exrexports. - The docs run ahead of the build. The 8.3 docs promise
SAVE/LOADover WebSockets; the shipped 8.3.x builds (checked 8.3.0 and 8.3.4) reject them. Trust an export you have looked at over any documented behaviour. - Animation licensing has strings attached — see the note at the end of batch.md before helping with a commercial production.
Security
Path B opens nothing — Motion IO reads a file and renders it. Path A does: launching Rebelle
with -websocket-server-enable turns it into a code-execution server for its event
vocabulary. Say so before asking the user to relaunch.
- The server listens on
127.0.0.1:8265(REBELLE_WS_PORT) and applies any event JSON it receives to the artwork the user has open, andexport()writes files with their privileges. There is no authentication: every local process, and every other user on a shared machine, can paint into their canvas. -websocket-allowed-ip-addressesis the only access control, and it matches the socket's peer address. Keep it to loopback (::ffff:127.0.0.1,127.0.0.1) — never add a LAN address, which would expose the canvas to the network. Note that a web page in the user's own browser is also a loopback peer, and WebSocket connections aren't subject to CORS, so don't count on the allowlist to exclude one.- To stop it, quit Rebelle and relaunch without the flag. The flag is per-launch, so a normally started Rebelle listens on nothing.