CommunityImagegithub.com

younglark/photoshop-psjs

Use when driving Adobe Photoshop with standalone UXP scripts (.psjs): write, launch, poll, and verify document create/edit/export from an agent. Covers executeAsModal, batchPlay, file tokens, doc-id safety, and psjs vs UXP plugins vs ExtendScript/JSX.

What is photoshop-psjs?

This skill provides a comprehensive, battle-tested guide for AI agents (like Claude Code, Cursor, or Codex) to control Adobe Photoshop programmatically using standalone UXP scripts (.psjs). It's designed for tasks that require Photoshop's rendering capabilities — such as creating, modifying, and exporting documents with layer styles, slicing, or batchPlay operations — and offers a complete agent workflow: write the script, launch Photoshop (via command line or shell), poll for a result file, and verify outputs with pixel-level comparisons. The guide clearly distinguishes when to use .psjs (one-shot, agent-driven automation) versus UXP plugins (persistent panels, networking, IPC) or legacy ExtendScript/JSX (older Photoshop versions, COM/AppleScript). It includes critical, empirically-tested details for Windows and macOS (Photoshop 2026) around file system access via session tokens, the necessity of `dialogOptions: "silent"` to avoid blocking dialogs, document ID safety to protect unintended edits, and the use of `hostControl.suspendHistory` for undo-friendly batch operations. The skill also provides a specialized slicing workflow for generating game-ready 1x PNG sprites and an engine-agnostic layout JSON, complete with offline preparation via psd-tools, agent-authored manifests, and a review gate before final export.

Works with~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/younglark/photoshop-skills/tree/HEAD/skills/photoshop-psjs

Installed? Explore more Image skills: steipete/songsee, affaan-m/frontend-design, mattpocock/improve-codebase-architecture · View all 6 →

Ask in your favorite AI

Open a new chat with this agent skill pre-loaded.

Documentation

Driving Photoshop with UXP Scripts (.psjs)

An executable recipe for agents: create, modify, and export documents in Photoshop. Facts come in two categories:

  • Official: from Adobe UXP documentation, linked at the end.
  • Tested: experience from running .psjs on Windows and macOS with Adobe Photoshop 2026. Unverified items are marked as unverified — do not treat them as official API.

Success criteria: The script writes a result JSON (containing done / ok / fatal / errs); it only modifies the target document specified by the task, leaving other open documents untouched; when pixel alignment is needed, it exports PNG and iterates based on ΔE / pixel difference against a golden image.

Stop first: The task hasn't specified which document to modify, or it asks to overwrite-save a document with unsaved user changes without explicit permission.


1. When to Use / When Not to Use

Use .psjs when:

  • You need Photoshop for tasks: rendering pixels with layer styles, exporting per-layer slices, using batchPlay to create shapes/text/effects, or saving results as PSD/PNG.
  • Inferring a slicing manifest from a PSD/PSB, reviewing, and exporting 1x PNG with an engine-agnostic layout JSON: see Section 9.
  • The agent needs to run the task in one shot (write script → launch → poll logs → copy back results), without requiring a persistent panel.

Do not start Photoshop when:

  • You only need read-only access to the layer tree, names, IDs, text, or approximate bounds: use psd-tools or ag-psd for offline parsing. Tested: psd-tools's layer_id matches UXP layer.id, so offline structures can be used for location, reserving Photoshop for rendering/export. Note that psd-tools' bbox does not include layer effects; final visual bounds still need to be computed in Photoshop.

Use a UXP plugin instead when:

  • You need persistence, bidirectional communication (e.g., an MCP bridge, panel, WebSocket). Official: scripts have no manifest/panel; Network, LaunchProcess, WebView, IPC are all unavailable. These capabilities require a plugin with declared manifest permissions.
  • Official: you cannot call another .psjs from a plugin; but UXP code from a script can generally be moved into a plugin.

Use ExtendScript / JSX instead when:

  • Your target is Photoshop 23.5 or earlier, or you can only launch via Windows COM DoJavaScript / macOS AppleScript do javascript / CEP evalScript. JSX is ES3, synchronous API, in maintenance mode. Do not treat psx.jsx (xbytor's ExtendScript helper library) as a runtime.

2. Official Key Points (Memorize These First)

  • Standalone UXP scripts have been available since Photoshop 23.5, extension .psjs. Debugging is possible with UXP Developer Tool ≥ 1.6, but running scripts does not require UDT.
  • Official launch methods: File > Scripts > Browse...; drag-and-drop .psjs onto the Dock icon (Mac) or into the Photoshop window area that is not an open document (Mac / Win); play via an Action. Double-clicking to open was previously marked as "Coming soon."
  • Scripts are modern JavaScript, supporting top-level await. Official examples and testing rely on it; do not write a script that defines async main but never calls main() and then exits immediately.
  • Any operation that modifies the document or application state must be wrapped in require("photoshop").core.executeAsModal(fn, { commandName }). The commandName is displayed on the progress bar (which appears if the script runs for more than about 2–3 seconds and can be canceled by the user).
  • When the DOM is insufficient, use require("photoshop").action.batchPlay(descriptors, options). Descriptors are actionJSON. Official guidance: prefer the DOM first. Use Copy As JavaScript from the Actions panel in Developer Mode, or Plugins > Development > Record Action Commands... to record descriptors; do not invent key names.
  • dialogOptions is written in the _options of each descriptor, with values "silent" (default, no dialogs even on error), "dontDisplay" (generally no dialogs, but may show on missing parameters or errors), "display". Tested (PS 2026): agents must use "silent". "dontDisplay" can still show blocking error dialogs when a command fails (e.g., Command "Get" is currently not available.); the script will wait until someone clicks OK; after clicking, it returns {_obj:"error", message:"", result:-128} (-128 is the official user cancel code). "silent" does not show dialogs, returns in ~1–5ms with {_obj:"error", message:"Script name: Command '…' is currently not available.", result:-25920}. Passing { dialogOptions: "dontDisplay" } as the second parameter of batchPlay did not cause an error in tests, but the official options table does not have this key, and using "display" to verify its effect is unsafe. New code should place dialogOptions in the descriptor's _options and use "silent".
  • Invalid batchPlay parameters will reject; runtime failures like a non-existent target will resolve, returning {_obj:"error", message, result}. result === -128 indicates user cancellation. Always check the returned array.
  • File system: scripts have no manifest; permissions are built into Photoshop. The official scripting section still states: use file choosers and getTemporaryFolder(). getEntryWithUrl / createSessionToken are part of the official FileSystemProvider API (see Section 4 for tested details).
  • require("uxp").script provides executionContext, args, setResult(result). Official FAQ (initial release) stated: the first version does not support passing arguments to scripts. Do not rely on command-line arguments or script.args to pass task configuration; write paths and parameters into the script, or have the script read a neighboring JSON file. Tested (Windows / PS 2026, Photoshop.exe script.psjs): script.args is []. If additional CLI arguments follow the script path, the script does not execute (no result file within 3 minutes); the launcher still returns immediately with exit code 0, and stdout has no script output. Do not pass extra CLI arguments. setResult can be called, returns undefined, and does not appear in Photoshop.exe's exit code or stdout. Always use a result file.
  • Only one script can run at a time, and scripts cannot call another script.
  • Official executionContext: isCancelled / onCancel for responding to user cancellation, reportProgress({ value, commandName }) to update the progress bar, hostControl.suspendHistory({ documentID, name }) + resumeHistory(id, commit) to group a batch of modifications into a single history step, allowing designers to undo everything at once. Tested: isCancelled is a boolean; reportProgress can be called, returns undefined. suspendHistory({documentID, name}) returns {historySuspensionID}; resumeHistory(id) groups subsequent modifications into one step, with the name appearing in doc.historyStates. The enumerable keys of hostControl may be empty; use Object.getOwnPropertyNames. Read history via the DOM (doc.historyStates / doc.activeHistoryState), not with batchPlay get historyStatesCount (that command is unavailable, see Section 7).

3. Agent Launch and Polling (Tested on Windows)

The official documentation does not present passing a .psjs to Photoshop.exe via command line as a formal interface. Tested (Windows / Photoshop 2026):

& "C:\Program Files\Adobe\Adobe Photoshop 2026\Photoshop.exe" "D:\work\ps-job\run_job.psjs"
  • No Creative Cloud desktop app, UDT, or developer mode is required.
  • If Photoshop is already open, the script runs in the existing instance.
  • The launcher returns immediately, and the script continues running asynchronously inside PS. You must poll for the result JSON that the script writes itself (recommended keys: done, ok, fatal, errs); do not use the process exit code to determine success or failure.
  • Tested: an export script exporting a .psb to 28 slices took about 18 seconds (with file opening taking about 10 seconds) and wrote an export_log.json.
  • Tested (Windows / PS 2026): File association exists (.psjs=Photoshop.PSJSFileType.200, ftype is Photoshop.exe "%1"). Start-Process script.psjs will actually execute the script and write the result file. Agents are still advised to explicitly pass Photoshop.exe + script path and rely on the result file.
  • Before launching, ensure PS does not have a modal dialog open (crashed "Recover Documents" prompt, save dialogs, etc.). With a dialog present, the script will not execute as expected, and polling will time out; after a timeout, take a screenshot of the PS interface instead of blindly retrying.

Remote agent workflow: Write the .psjs on the agent side → copy it to the machine with Photoshop (use only user-authorized directories) → launch via shell and poll logs → copy back PNG/PSD/logs → perform pixel comparison when needed.

Polling example (PowerShell):

$ps = "C:\Program Files\Adobe\Adobe Photoshop 2026\Photoshop.exe"
$script = "D:\work\ps-job\run_job.psjs"
$log = "D:\work\ps-job\job_log.json"
if (Test-Path $log) { Remove-Item $log }
& $ps $script
$deadline = (Get-Date).AddMinutes(10)
do {
  Start-Sleep -Seconds 2
  if (Test-Path $log) {
    $j = Get-Content $log -Raw | ConvertFrom-Json
    if ($j.done) {
      $j | ConvertTo-Json -Depth 8
      if (-not $j.ok) { exit 1 }
      break
    }
  }
  if ((Get-Date) -gt $deadline) { throw "timeout waiting for $log" }
} while ($true)

macOS (tested, Photoshop 2026, Mac mini): Official docs only mention drag-and-drop to Dock/window; the following command-line launch is not documented officially but has been tested and works:

open -a "Adobe Photoshop 2026" /path/to/run_job.psjs
# Also works with file association: open /path/to/run_job.psjs
  • Cold start: When PS is not running, open -a returns in ~0.3 seconds (exit 0); PS starts, automatically executes the script, and writes the result file in about 8 seconds.
  • Warm start: When PS is already running, the script executes in the existing instance and writes the result within seconds.
  • Using open xxx.psjs without -a (via file association) also executes the script.
  • getEntryWithUrl("file:/Users/<user>/...") can write files without a chooser.
  • Same as Windows: the launcher returns immediately; you must poll for the result file; do not add extra command-line arguments.

Polling example (zsh):

log=~/ps-job/job_log.json; rm -f "$log"
open -a "Adobe Photoshop 2026" ~/ps-job/run_job.psjs
for i in {1..300}; do [ -f "$log" ] && grep -Eq '"done": ?true' "$log" && break; sleep 2; done
cat "$log"

Skeleton script: see examples/run_job.psjs (top-level await, executeAsModal, document ID guard, result file, try/catch). Tested: this skeleton has been verified to: write done/ok, export preview.png, close its own created documents, and leave the ID set of other open documents unchanged.


4. File Access

Official FileSystemProvider: getFileForOpening / getFileForSaving / getFolder, getTemporaryFolder, getEntryWithUrl(url), createEntryWithUrl, createSessionToken(entry).

For file paths given to batchPlay, they must be session tokens, not bare system paths. Official note: when using Photoshop DOM methods (e.g., app.open(file), doc.saveAs.psd(file, ...)), you can pass File/Folder entries directly; the host automatically converts them to tokens.

Tested (PS 2026 script, without showing a file chooser):

const fs = require("uxp").storage.localFileSystem;
const root = await fs.getEntryWithUrl("file:/D:/work/ps-job"); // directory must exist
const out = await root.createFile("job_log.json", { overwrite: true });
await out.write(JSON.stringify(log, null, 2));

URLs like file:/D:/... work on Windows. The scripting section still only guarantees file choosers + temporary directory; treat "arbitrary path, no chooser" as a PS 2026 tested behavior. When changing versions, perform a smoke test write first. Do not use networking modules — they are prohibited in the official scripting environment.


5. Document Scope and Safety (Essential)

Principle: the script only touches the target document(s) specified by the task. The target can be a document created by the script or an existing PSD named by the task (iterating on an existing design is a common scenario). All other open documents must remain unmodified, unsaved, and unclosed.

Determining the targets set:

  • New documents: the id returned by app.createDocument() is added to targets.
  • Iterating on an existing PSD: locate it by the path given in the task. First check if it's already open in app.documents by doc.path; if it is, reuse that instance (do not open a second copy). If not open, use app.open(entry). Matching by name alone is unreliable (duplicate names, "-recovered" copies); prefer paths, then doc IDs from the task. Tested: matching by path reuses the same instance (same doc ID, does not open a second copy); after unsaved edits, doc.saved === false; saveAs(..., true) saves a new version, leaving the original file's mtime and size unchanged, and the document remains open, still pointing to the original path. On Windows, doc.path looks like D:\work\file.psd. Recovered documents (e.g., "xxx-recovered") may have an empty path and saved === false; they cannot be found by path and must be located by the doc ID from the task.
  • Temporary documents (e.g., slices from "New Layer from Slice") are added to targets and closed with closeWithoutSaving() when done.

Guard: Before every batchPlay, save, or close, check that app.activeDocument.id is in targets. This check only covers descriptors that implicitly target the active document; for descriptors that explicitly specify { _ref: "document", _id }, that ID must also be checked. Log app.documents.map(d => ({ name: d.name, id: d.id, path: d.path })) both before and after the run. Tested: When creating a layer with make, including a non-active document's { _ref: "document", _id }, the layer is created on that document, and activeDocument does not change. Therefore, explicit IDs must also be checked. However, set against a non-active document fails ("Command 'Set' is currently not available."); to modify properties, first set app.activeDocument = target then set.

When modifying an existing document:

  • If the target document is already open and has unsaved changes (official doc.saved === false), it means the user is actively editing it. It's generally acceptable to continue editing (that's the point of iterative tasks), but do not overwrite-save unless the task explicitly requests it; first note in the log that the document was dirty before the run.
  • Use hostControl.suspendHistory to group the script's modifications into a single history step, allowing the user to undo everything with one Ctrl+Z.
  • Saving strategy depends on the task: default to saving a new version (e.g., xxx_v14.psd), preserving the original; only use doc.save() when the task explicitly says "update in place."
  • At the end of the script, do not close target documents that were originally opened by the user; only close documents that the script itself opened or created and that the task does not require to be kept.

Automatic cleanup: Official executionContext.hostControl.registerAutoCloseDocument(docID) can automatically close temporary documents when the user cancels or the modal ends. Only use this for documents created by the script. For documents the designer will continue working on, first call unregisterAutoCloseDocument. Do not register auto-close on documents that were already open.


6. Verification Loop

  1. The script exports a PNG (and optionally a PSD).
  2. On the agent side, compare it to a golden image (CIEDE2000 ΔE, pixel difference, local magnification).
  3. Based on the differences, modify the script (paths, gradients, layer styles); do not manually edit the user's open document and expect reproducibility.
  4. Re-run, re-export, re-compare until standards are met or the remaining gap is explained.

7. Tested Pitfalls

  • dialogOptions: "dontDisplay" can still show blocking error dialogs on failure. Every descriptor run by an agent should include _options: { dialogOptions: "silent" }. When polling times out, first check if PS has an error dialog open; do not blindly retry. Do not launch the next script while a dialog is up.
  • Read history via the DOM, not via batchPlay get historyStatesCount. The latter throws "Command 'Get' is currently not available." (dontDisplay shows a dialog, silent returns result: -25920). Use doc.historyStates / doc.activeHistoryState.
  • set on a non-active document fails ("Command 'Set' is currently not available."). Set the target as app.activeDocument first, then set. However, make with a layer can target a non-active document without switching the active document — the document _id in the descriptor must be guarded.
  • Multiple strokes require frameFXMulti (a single stroke can use frameFX). For gradients that follow the shape's contour outward, use gradientType: "shapeburst".
  • innerShadow as a single key is silently ignored; use innerShadowMulti: [{ _obj: "innerShadow", ... }]. After modifying, use get to read back layerEffects to confirm the key is present.
  • Custom paths: first set the workPath, then make contentLayer. Shapes created this way usually do not have a keyOriginType and are not live shapes with editable corner radii in the properties panel. For editable rounded corners, use make contentLayer with shape._obj = "rectangle" / "ellipse" and include unitValueQuadVersion: 1 with four corner radii.
  • Font assignment uses the PostScript name (e.g., textItem.characterStyle.font = "MuseoSansRounded-1000"), and the font must be installed; user-level installation is sufficient — it does not need to be machine-wide.
  • In RGB descriptors, the green channel is called grain, not green. Do not "correct" this name.
  • executeAsModal must be awaited. If omitted, subsequent code continues before the modal is acquired.
  • batchPlay will throw an exception on user cancellation; do not catch and silently discard the cancellation, as the script will not stop.

Layer effects and shape actionJSON: see references/batchplay.md.


8. Sources

Official:

Tested empirical additions are found in the sections above, based on work using .psjs to batch-create UI components and compare them pixel-by-pixel against a golden image; these are not Adobe standards.


9. Agent Slicing Workflow

Slice a UI into game-importable 1x PNGs and produce an engine-agnostic intermediate layout JSON organized by component semantics. Details, schemas, naming, identification methods, and tested results are in references/slicing.md. First, follow the decision tree at the top of that document: open materials (including layer effects on groups, and choose an effect strategy for each affected part) → identify components → separate shared vs. state-dependent parts → distinguish variants from old drafts → only mark missing parts for parts that actually vary → determine if repeated elements are reusable → stop at the review gate. Script and Python helpers: examples/export_slices.psjs, examples/slicing_helper.py, examples/naming_rules.example.json, examples/export_config.example.json.

Python environment: Run the helper from the project venv; do not install the package into system Python. Requirements: psd-tools[composite], Pillow, numpy. For large documents, use the verified call PSDImage.open(path, parse_limits=ParseLimits(max_objects=None)) (do not guess parameters or set the ParseLimits.max_objects class attribute). See slicing.md "Python Environment" for details.

Division of labor: The helper only prepares materials (a full image with default state, per-layer crops with IDs, a layer tree including hidden layers, nine-slice candidates). The agent running this skill simultaneously reads these materials and composes a manifest: components, parts, canonical names, types, states, and hierarchy. --rules is an optional project naming convention. infer can still produce a keyword draft (draft_manifest.json), but this does not replace the agent's manifest.

Workflow (do not export before review):

  1. Prepare Materials (offline): prepare. Optional --artboard <artboardNameOrId>; if omitted, processes all visible top-level artboards; if no artboards exist, processes the entire canvas. The tree includes hidden layers (with a visible flag). crops/ mainly contains exportable leaves; groups with layer effects are written into group_effects.json and crops_index.json (with group crops if composited), and also include effect_params read by psd-tools (type, blend mode, color, opacity, angle, distance, size, spread/choke, stroke position). Look up IDs from the index; do not guess filenames. If a group ID has no crop image, do not immediately resort to its inner leaves — first check if that group has effects. For groups without effects, or for empty layers or items outside artboards, consult tree.json; missing crops do not imply a missing state.
  2. Agent Writes Manifest: Identify interactive components like tab groups, bottom bars, buttons, list rows, etc. Split bases, highlights, icons, and text into separate parts by state; do not bake states together. First, determine which parts are shared across states and which change: shared parts go into shared (filename without state suffix), changing parts go into individual states. A hidden layer is only considered an alternate state if the main body/content is unchanged and the differences are only in state styles (brightness, stroke, glow, color scheme, etc.). If a slot has different content or badges, treat it as an old draft and ignore it; do not use it to fill in for the actual shared resource, and do not mark shared parts as missing. Hidden layers that fall in the current selection slot, with only glow/highlight style changes, should be added as that state's highlight (the default selected state must also be included); layers that glow or bubble up over a visible selected base, even if their shape is different, are additive highlights — not replacements. Do not put them in uncertain_hidden; only write uncertain_hidden when the content or motif does not match (including both side's crops). For missing parts, use the most complete item in the same group as a template (e.g., selected state has base + highlight + label); list every sibling that is missing any of these parts, including the highlight. A single list row with a special style is just one instance of that row; do not infer missing states for other rows, and do not infer a missing normal state for this row unless the design has a complete set of variants for every row. When selecting an export strategy, check if the nearest parent group (or the group representing that element) has a layer effect (e.g., drop shadow). If the effect is on the group and the inner leaves do not have it, choose an effect_mode for that part (see slicing.md "Group Effects: Bake or Engine Draw"): baked (default) — layer_id / select_ids use the group ID, the effect is baked into the image, the visible bounds include the effect, the logical bounds exclude it; engine — select_ids use the child layers, copy the group's effect_params into engine_effects, to be drawn by the engine, with no effect pixels in the image. Each such part requires an effect_reason statement. Follow project rules or user specifications if provided. The only forbidden action is selecting only child layers without recording the effect (silently losing effects). Do not fabricate. For repeated elements, judge reusability based on UI purpose, game UI common sense (list row bases, avatar frames, same-style buttons, rank medals, currency icons, progress bars are typically reused; avatar images are runtime content, separate them from the frame and mark placeholder: true; names/scores/numbers are dynamic text) and evidence from reuse_candidates.json. If reusable, export one image; each instance writes reuse_of: <sharedName> (shared name without instance number). If the visual output doesn't match the effect, assume the judgment was wrong, and only split the inconsistent instances apart. If logical evidence is strong but doesn't match, or if an avatar and its frame are merged in the PSD, write the decision into reuse_decisions (evidence, problem, comparison images, options) and let the user decide; by default, export them separately. Naming convention: module_type_purpose_state (lowercase; omit the state segment for shared parts). Do not use raw PSD layer names. Types: image | nine_slice | dynamic_text | art_text.
  3. Review Gate: flatten + preview produce annotated previews and JSON (tags with fx:baked / fx:engine; engine effects draw estimated bounds so the user can change the mode during review; flatten lists parts that would lose effects in effect_issues). The user modifies the JSON for confirmation; status remains pending_review until confirmed. preview will skip inverted or empty bounding boxes with a warning but will not abort.
  4. Export (.psjs): The script hardcodes a single CONFIG_URL and reads paths from the job JSON. Export 1x PNG by layer_id / select_ids: baked selects the group, baking the effect into the image; engine selects and merges child layers, with no group effects in the image. Instances with reuse_of export only one shared image; other instances record only their bounds. dynamic_text does not generate an image; only text properties are exported. art_text generates an image.

…

[truncated for translation]

Related Skills