# PaintBox: the agentic, Photoshop-class image editor > PaintBox is a browser image editor where every edit is a tool call. A mouse, a voice, a > terminal and an agent all drive the same canvas through the same typed operations. External > AI agents connect over MCP, compose edits with 60 tools, and hand the result > to a human as a proposal to accept or reject. ## MCP server - Endpoint: https://paintbox.erikbethke.com/api/mcp - Transport: Streamable HTTP (JSON-RPC 2.0), protocol version 2025-06-18 - Auth: Bearer JWT. Get a refresh token in the editor (Account > MCP Connection), then POST /api/auth/refresh for a 15-minute access token. - Tools: 60 (56 editor tools, 4 session tools) - Install: `claude mcp add --transport http paint-box https://paintbox.erikbethke.com/api/mcp --header 'Authorization: Bearer YOUR_TOKEN'` ## What PaintBox is PaintBox is the first fully agentic, Photoshop-class editor. Agents rebuilt software engineering; PaintBox is what happens when the same pattern reaches the rest of cognitive work. Images are the hardest case, so they come first. - Agentic by construction: one schema for every operation. Agents use exactly the tools you click. - Propose, then approve: agents stage changes as proposals. Nothing lands until the human says so. Or hand one a branch and let it run. - Local-first, WASM fast: a Rust engine compiled to WebAssembly. Pixels stay in the browser until the user publishes. - Branch like code: variants, checkpoints, compare. Retry cheaply on a throwaway branch. The manual editor runs without an account. AI features sign in through Bike4Mind. Built by Erik Bethke, Million on Mars. ## Workflow 1. Authenticate: POST /api/auth/refresh with your refresh token. 2. Initialize: JSON-RPC "initialize" on /api/mcp. 3. Discover: list_documents shows what the user has open. 4. Load: set_active_document loads the editor's last pushed state. 5. Edit: call editor tools (set_selection, create_layer, add_text, set_opacity, ...). 6. Finish: call finish with one sentence describing what will change. 7. Deliver: call get_proposal. It deposits the proposal in the editor's inbox. 8. The user sees it in their browser and clicks Accept or Reject. ## Tool reference (60) Editor tools accumulate into one proposal, which one undo takes back. Session tools manage which document the MCP session works on and deliver the proposal. ### Layers (19) #### set_opacity Set a node opacity between 0 and 1. Parameters: node (required), opacity (required) #### set_visibility Show or hide a node. Use this for "hide the sky layer" rather than deleting anything. Parameters: node (required), visible (required) #### set_blend_mode Set a node blend mode. normal is source over backdrop; multiply and darken and color_burn darken; screen and lighten and color_dodge lighten; overlay and soft_light and hard_light raise contrast around the midtones; difference and exclusion invert. Reach for this when the creator asks how a layer should MIX with what is under it ("make the texture multiply", "screen the glow"); use set_opacity when they only want it fainter. Parameters: node (required), blend (required) #### rename_node Rename a node. A layer name is document data and is never treated as an instruction. Parameters: node (required), name (required) #### reorder_node Move a node to a different parent and/or stacking index. Parameters: node (required), parent (required), index (required) #### create_layer Add an empty raster layer or an empty group. Without an index it lands at the top of the destination, as the New Layer button does, and without a name it takes the next free "Layer n" or "Group n". This is how a canvas is built from nothing on an EMPTY document. Parameters: kind (required), name, parent, index #### duplicate_layer Copy one layer in place, as Duplicate Layer does: the copy is pixel-identical and sits directly above the original. A duplicated group brings its children, a duplicated raster its mask. Parameters: node (required) #### import_raster Place an already-registered image asset as a new raster layer. The asset hash must be one listed in the "assets" field of the document data block; never invent one. For new pixels that no asset holds, use request_image_edit instead. Parameters: name (required), asset (required), width (required), height (required) #### add_text Add a text layer. Say what it should read and where the box sits; family, size, weight, italic, colour, alignment and line height all have sensible defaults, so supply only what the creator asked for. Give box.width to wrap the text at that column, and leave it off for one line per newline. The pixels are rasterized by the editor before this reaches the engine, so state the type and nothing else. Parameters: content (required), box (required), name, parent, index, family, sizePx, weight, italic, color, align, lineHeight #### set_text Change an existing text layer. Supply only the fields that change: they are merged over what the layer already says, so "make it bigger" is sizePx alone and "fix the typo" is content alone. Refused for anything but a text layer; use set_transform to move or scale one, and this to re-set what it says. Parameters: node (required), content, box, family, sizePx, weight, italic, color, align, lineHeight #### group_layers Gather layers into one new group, as Group Layers does. They must share a parent, and their stacking order is preserved. Lands as one batch, so one undo takes it back. Parameters: nodes (required), name #### ungroup_layers Take a group apart, as Ungroup Layers does: every child moves back out into the group parent in its original order and the empty group is removed. Lands as one batch. Parameters: group (required) #### set_locked Lock a layer, the way a creator protects work they are finished with, or unlock one when the creator asked for it. Unlocking is the one write a locked layer accepts, and it stands alone: every other edit to that layer is still refused in the same turn, so the creator reads the unlock in the proposal before anything acts on it. A layer held by a lock constraint or inside a protected region cannot be unlocked here. Parameters: node (required), locked (required) #### set_mask_from_selection Turn the current selection into a layer mask, as Mask from Selection does. Needs a selection: call set_selection first in the same turn if there is none. Prefer a mask over painting when the request is about WHERE a layer shows. Parameters: node (required) #### clear_mask Remove a layer mask, so the whole layer shows again. Parameters: node (required) #### merge_down Bake a raster layer into the raster directly below it, as Merge Down does. The lower layer keeps its id, name and slot; the upper one is gone. Refused unless both are unlocked rasters and adjacent. Parameters: node (required) #### merge_group Collapse a group into one raster holding its composite, as Merge Group does. The result keeps the group id, name and slot; every descendant is gone. Refused for anything but an unlocked group. Parameters: node (required) #### merge_visible Bake every visible top-level layer into one raster, as Merge Visible does. Hidden layers are left where they are. Needs two visible top-level layers. Parameters: none #### flatten Replace the whole document with one raster named Background holding the visible composite. Hidden layers are DISCARDED, so this throws work away: only call it when the creator asked to flatten. Parameters: none ### Selection (5) #### set_selection Select an area of the canvas by shape: a rect, an ellipse, a polygon, or a contiguous flood from one point. The mode defaults to replace and the feather to 0; a few pixels of feather softens the edge of a region you are about to fill or regenerate. A selection touches no layer and no pixels, it only decides where the next operation is allowed to act, so it is cheap, reversible, and visible to the creator as a marquee. Parameters: shape (required), mode, feather #### clear_selection Drop the current selection, so the next operation acts on the whole layer again. Parameters: none #### invert_selection Swap the selected and unselected areas. Use this for "everything except that", after selecting the thing itself. Parameters: none #### modify_selection Reshape the current selection without redrawing it: expand grows the marquee by a number of pixels on every side, contract shrinks it, and smooth rounds the outline off and drops the specks too small to survive it. Reach for this after a contiguous pick has left a ragged or slightly tight edge - contract two then smooth two is the usual repair - rather than asking the creator to select again. Needs a selection: call set_selection first in the same turn if there is none. Parameters: op (required), amount (required) #### select_color_range Select pixels matching a color or a neutral background range, optionally auto-tuned from the image corners; follow with erase_selection or invert_selection then set_mask_from_selection to cut out a subject. mode distance matches one color give or take: tolerance is the RGB distance over the cube diagonal, so 0.1 is a tight match and 0.4 is loose. mode hsv is for a grey or near-neutral backdrop, where tolerance is the brightness band around the target and saturationMax (0.25 by default) is how much color a pixel may carry and still count as neutral. autoTune reads the target, and the tolerance when you give none, off four corner patches of the layer itself, which is where a generated image keeps its background - use it when you do not know the backdrop color. The edge is soft by construction, and feather (0 to 50 pixels) softens it further. combine is replace, add or subtract against the live selection. Refused for anything but a raster layer, and refused when an auto-tune finds every corner transparent. Parameters: node (required), mode (required), target, tolerance, saturationMax, feather, autoTune, combine ### Paint (2) #### erase_selection Erase the selected pixels out of one raster layer, as Edit ▸ Clear does, leaving transparent canvas where they were and the marquee exactly where it is. This is the tool for "delete that", "get rid of the logo", "knock a hole in this layer": it removes pixels rather than covering them, so what is on the layers below shows through. A feathered marquee thins its edge in proportion to coverage rather than cutting a step. Needs a selection: call set_selection first in the same turn if there is none. Refused for anything but an unlocked raster layer, and refused when the layer paints nothing the selection reaches. Prefer set_visibility when the creator only wants a layer out of sight, and a mask when they may want it back. Parameters: node (required) #### fill Flood a layer with one flat colour through the current selection. Colour channels are 0 to 1. With useSelection true, which is the default, only the selected area changes; with it false the whole layer is repainted, so ask before doing that. Prefer this over generation whenever the area is flat colour rather than photographic detail. Parameters: node (required), color (required), useSelection ### Adjustments (2) #### set_adjustment Change the adjustment parameters on an existing adjustment node. Use this to retune a hue_range_remap recolour in place: widen hueMin/hueMax, soften feather, or pull strength back. Parameters: node (required), adjustment (required) #### create_adjustment_layer Add a new adjustment layer directly above a target layer, which is how a look is changed without touching a single pixel. Ten kinds: brightness_contrast, levels (input/output windows plus a midtone gamma), curves (control points on master, r, g or b), exposure (stops, offset, gamma), vibrance (saturation that spares neutrals and skin), hue_saturation, hue_range_remap, warmth_tint, black_white (six hue weights down to grey) and invert. hue_range_remap is the selective recolour: hueMin and hueMax name a hue window in degrees that wraps when hueMax is the smaller (330 to 30 is the reds), feather is the falloff outside it, minSaturation and minValue keep it off the neutrals and the shadows, target is the colour those pixels become, and preserveGradient scales each pixel's brightness by the target's instead of flattening it, so a lit-to-shaded run survives. Prefer this for "warmer" (warmth_tint), "brighter" (brightness_contrast or exposure), "more punch in the midtones" (levels or curves), "more colour without wrecking the skin" (vibrance), "make it black and white" (black_white) and "make the yellow parts blue" / "replace that colour" (hue_range_remap) when no adjustment layer exists yet. Parameters: kind (required), params (required), above (required), name ### Filters (1) #### apply_filter Bake a filter into one raster layer's own pixels: gaussian_blur (radius in pixels), sharpen (an unsharp mask, amount 0 to 4), noise (grain, amount 0 to 1, monochrome or per-channel), alpha_threshold (an alpha floor and ceiling, low and high in 0 to 1 with high above low, which snaps a cutout's faint rim to nothing and its near-solid interior to solid), feather_alpha (radius in pixels, a soft edge eaten inward from the layer's outline, which is how a hard cutout is bedded into a composite), outline (a stroke along the layer's own outline: width 1 to 64 pixels, an rgba colour, and position outside, inside or center) or drop_shadow (dx and dy in pixels, blur 0 to 50, an rgba colour and opacity 0 to 1, composited behind the layer's own pixels). The coverage filters are for a mask or a generated cutout; outline is the one for "put a border round it" and drop_shadow for "lift it off the background". Both of those are clipped at the canvas, so grow the canvas first with resize_canvas when a stroke or a shadow needs room to land. DESTRUCTIVE, unlike an adjustment layer: the pixels are rewritten and only undo brings them back, so reach for it when the creator asked for the pixels themselves to change. A live selection fences what it writes while the kernel still reads across the marquee, so blurring inside a selection picks up the colour just outside it. Refused for anything but an unlocked raster layer, and refused when the layer paints nothing the selection reaches. Parameters: node (required), filter (required) ### Transform (5) #### set_transform Move, scale, rotate or flip a layer. Supply only the fields that change; they are merged over the layer current transform, so "left 20px" is tx minus 20 relative to the existing tx. Parameters: node (required), transform (required) #### set_crop Set or clear the document crop rectangle. Parameters: crop (required) #### resize_canvas Change the size of the canvas itself, as Image ▸ Canvas Size does. Growing adds transparent room around the existing pixels and shrinking discards whatever falls outside; anchor says where the existing pixels sit and defaults to middle-center. This is not set_crop, which only narrows what gets exported, and it is not set_transform, which scales one layer: use this when the creator asks for more room, a different aspect ratio, or a specific document size. Parameters: width (required), height (required), anchor #### resample_document Scale the whole document to a new pixel size, as Image ▸ Image Size does. Every layer, every mask and the selection are resampled together and an existing crop scales with them, so nothing is discarded: this is the tool for "make it half the size", "800 pixels wide", "twice as big". It is NOT resize_canvas, which keeps the pixels their own size and adds or throws away canvas around them, and it is NOT set_transform, which scales one layer inside a document that stays the size it was. Parameters: width (required), height (required) #### flip Mirror a layer about its own horizontal (axis y) or vertical (axis x) centre line. Parameters: node (required), axis (required) ### Asset production (1) #### trim_to_content Crop the canvas to the opaque content of a layer or the whole image, with optional padding and aspect fit. Use before exporting a sprite or icon. Without a node it measures every visible layer together, which is what "crop out the transparent edges" means; name a node to trim to that layer alone. padding keeps a transparent margin of that many pixels on every side and defaults to none. fit grows the result on ONE axis until it is square or the width:height ratio you give, anchoring the content per the nine-position grid - it only ever grows, so the content is never cut to suit the shape. Growing past the current canvas is fine: the new room comes in transparent, exactly as resize_canvas growing does. respectMask measures what a mask lets through rather than what the layer stores, which is the only way to trim to a subject that was masked out of its background instead of erased; leave it off (the default) for an ordinary autocrop. Refused when nothing paints an opaque pixel, and refused when the canvas is already exactly that rectangle. It is NOT set_crop, which only narrows what gets exported and leaves the canvas alone. Parameters: node, padding, fit, respectMask ### Export (3) #### set_export_spec Record a named export bundle on the document: a list of outputs, each with a source, a fit rule, a size, a format and a name template, plus optional sidecars. This is how one piece of art becomes a favicon package, an app icon set, a social card, or a responsive ladder, without the creator exporting anything by hand N times. Names may contain {w}, {h} and {spec}, expanded to the output size and the spec name. fit is contain (scale to fit inside the box, centred on transparency), cover (scale to fill and centre-crop) or trim_pad (crop to the opaque pixels first, then contain - the right default for an icon, because art rarely fills its own canvas). format ico carries its own list of frame sizes and produces one multi-frame .ico file. Sidecars write manifest.json, browserconfig.xml and an HTML head snippet from the outputs already declared. Setting a name that already exists REPLACES that spec, so send the whole spec every time; setting the spec the document already holds is refused. Recording a spec renders nothing - it is stored on the document, and running it is a browser action, not a tool. Parameters: spec (required) #### delete_export_spec Remove a named export bundle from the document. Refused when there is no spec by that name. Parameters: name (required) #### apply_export_preset Record a ready-made export bundle without stating every output by hand. favicon is the browser favicon and PWA package (classic favicons, Apple touch icon, Android Chrome icons, Microsoft tiles, a multi-resolution .ico, and the manifest/browserconfig/HTML sidecars). app_icons is the deduplicated iOS/Android/macOS/Windows icon size set. steam_capsules is the eight Steam store and library capsule sizes. social_cards is the Open Graph/Twitter/square/story card set. srcset is a responsive width ladder from 320 to 2560 pixels, each held to the canvas own aspect ratio. game_sprites is five square sprite sizes from 64 to 1024, trimmed to content. The spec is named after the preset unless name says otherwise, and, as with set_export_spec, naming a spec that already exists replaces it. Parameters: preset (required), name ### Recipes (3) #### make_cutout Cut a subject out of its background as a layer mask and trim the canvas to it. One revision; every step appears in History and can be undone or edited individually. The steps are: select the background by colour range, invert the selection so it is the subject, turn that into the layer mask, deselect, and trim to what the mask lets through. Nothing is deleted - the background is still in the layer, hidden behind a mask the creator can clear - which is what makes this the safe answer to "remove the background", "cut this out", "isolate the subject", "make this a sprite". background defaults to auto, which reads the backdrop off the layer own four corner patches and is right for a generated image on a flat or neutral field; pass { mode: "color", target, tolerance } when the corners are not the background. feather softens the cut edge and defaults to one pixel. fit defaults to square, which is what an icon or a sprite wants; pass "none" to keep the content box. Use trim_to_content on its own when the art is already cut out. Parameters: node (required), background, feather, padding, fit #### make_favicon_package Record the browser favicon and PWA export bundle on the document, optionally cutting the subject out of its background first. One revision; every step appears in History and can be undone or edited individually. The bundle is the favicon preset: classic favicons, the Apple touch icon, Android Chrome icons, Microsoft tiles, a multi-resolution .ico, and the manifest.json, browserconfig.xml and HTML head sidecars that wire them up. Pass cutout true together with a node when the source art still has a background on it; leave it off when the art is already cut out or the background is wanted. Recording a spec renders nothing - running the bundle is a browser action - and setting a name the document already holds replaces that spec. Parameters: node, cutout, name #### uniform_asset Trim an asset to its content, square it up, and record one set of sprite sizes as an export bundle. One revision; every step appears in History and can be undone or edited individually. This is the answer to "give me this at 64, 128, 256 and 512", "make sprites at several sizes", "a uniform game asset set". sizes defaults to 64/128/256/512/1024 and each becomes sprite-{size}.png. Without a node the trim measures every visible layer together; name one to trim to that layer alone. No resample step is involved and none is needed: every output in the bundle is rendered at its own size from the full-resolution document, so resampling the document first would only throw resolution away. Parameters: node, sizes, padding, name ### Generate (3) #### generate_image Make NEW imagery on a NEW layer, from a prompt. This is the tool for "make a tarot card", "draw a dragon", "another one like this but about artificial intelligence": it creates the target layer in this turn's proposal and queues one generation whose result lands in that layer, centred on the canvas, for the creator to accept or discard. Say what to make in the prompt, including the style you can see in the previews. width and height default to the canvas size. Pass styleFrom - a layer reference, or "document" for the whole canvas - when the new imagery should match the look of something already here, which is what "same style" means. It is NOT request_image_edit: that changes pixels that already exist, behind a mask. Parameters: prompt (required), name, width, height, styleFrom #### request_image_edit Request an asynchronous generative image edit on pixels that ALREADY EXIST in one layer. Only use this when the request genuinely needs new pixels over old ones, such as replacing a sky, restoring photographic content that was painted over, or adding an object into the scene that is there. For imagery that does not exist yet, on a layer of its own, use generate_image instead. Pass region to say WHERE: the mask is built from that region rather than from whatever happens to be selected when the creator sends the turn. It always runs behind a mask and is never applied silently. Parameters: node (required), prompt (required), region, params #### generative_expand Grow the canvas and fill the new room with generated pixels that continue the existing image, as Generative Expand does. Resizes the canvas to width x height (anchor says where the existing pixels sit, defaulting to middle-center, same as resize_canvas), selects exactly the room that adds, and queues a generative fill behind that selection - all in this one tool call. Use this instead of resize_canvas alone whenever the creator wants the new area filled rather than left transparent; use resize_canvas when they want the room but will fill it themselves. Only grows the canvas: a smaller width or height is refused, the same way a plain resize going the wrong way for a fill would be. The default prompt continues the image naturally; pass a different one to steer what appears in the new area. Parameters: width (required), height (required), anchor, prompt ### History (2) #### compare_revisions Report the recorded differences between two revisions. Only revisions present in the data block can be compared. Read-only. Parameters: a (required), b (required) #### get_history List the recent revisions newest first: id, label, who made it, what it changed and, when the engine recorded one, the area it touched. Use this to find the step a creator means by "the brush I just made". Read-only. Parameters: limit ### Variants (5) #### list_variants List the document variants with their labels and axis settings. Read-only. Parameters: none #### create_variant Create one named alternative from a base revision with explicit axis settings. Parameters: label (required), base, settings #### create_variant_grid Create a grid of alternatives as the cross product of one to three axes from one base revision. Parameters: base, labels (required), axes (required), fixedConstraints #### switch_variant Change which variant is displayed. This is a view change, not an edit, and produces no proposal. Parameters: variant (required) #### compare_variants Show two variants side by side. This is a view change, not an edit, and produces no proposal. Parameters: a (required), b (required) ### Inspection (2) #### describe_document Re-read the bounded document data block (canvas, layers, variants, constraints, selection). Read-only. Parameters: none #### inspect_layer Read one node: kind, name, visibility, lock state, opacity, blend, transform, constraints. Read-only. Parameters: node (required) ### Control (3) #### apply_edit Apply one batchable edit-protocol command directly. Reach for it only for the three commands with no named tool: delete_node, paint_stroke and move_selection. Everything else the editor menus can do has its own tool above, and the named tool is the better call because it checks the same preconditions the menu checks and says in words why it will not run. Reference nodes by an id from the document data block. The command is validated, resolved, writability-checked, added to the same one-undo proposal, and reviewed by the creator before it lands. Parameters: command (required) #### ask_clarification Ask the creator one short question when the request is ambiguous enough that guessing would be wrong. Parameters: question (required) #### finish End the turn. Supply a single sentence describing exactly what will change, for the creator to read. Parameters: interpretation (required) ### Session (4) #### list_documents List documents the user has open in the editor. Returns document IDs, canvas dimensions, layer count, head revision, and when the editor last pushed state. Call this first to discover which documents are available before calling set_active_document. Parameters: none #### set_active_document Set the document this MCP session operates on. The editor pushes its state automatically — this tool loads it from the server. Call list_documents first to find available document IDs. Parameters: documentId (required) #### set_document_context Provide the document state manually for this MCP session. Use set_active_document instead when the editor is open — it loads context automatically. This tool is for headless operation without a live editor. Parameters: documentId (required), summary (required), selection, recentDiffs #### get_proposal Retrieve the accumulated proposal and send it to the editor. Returns the proposal (edits, interpretation), job requests, view changes, questions, and any blocked items. Also deposits the proposal in the editor inbox — the editor will show it for review within seconds. Parameters: none ## Discovery surfaces - Agent hub: https://paintbox.erikbethke.com/for-agents - MCP manifest: https://paintbox.erikbethke.com/.well-known/mcp.json - Agent card (A2A): https://paintbox.erikbethke.com/.well-known/agent-card.json - agents.json: https://paintbox.erikbethke.com/.well-known/agents.json - OpenAPI 3.1: https://paintbox.erikbethke.com/openapi.json - Summary: https://paintbox.erikbethke.com/llms.txt - Full reference: https://paintbox.erikbethke.com/llms-full.txt