A client-review export can leave you with two jobs: generating the JPEGs and checking that the delivery contains every selected photograph. Saved settings handle the image recipe. A custom export plug-in can add a record of which files were produced and which need attention.
With the Lightroom Classic Lua SDK, you can build that behavior into the Export workflow. Codex can help write and revise the plug-in files, while Lightroom Classic renders the photographs. Loading the plug-in and inspecting its output are essential parts of development.
This guide builds a small Client Delivery Export plug-in around three test photographs. The reference implementation produces a manifest around Lightroom’s JPEG export process; application-level validation remains part of the walkthrough. You will work through registration, export handling, error reporting, and a repeat run.
On Vagon Cloud Computer, you can edit the provider with Codex, reload it in Lightroom Classic, and inspect its delivery folder on the same Windows desktop. That short feedback loop keeps each code revision close to the export it produces.
Define the Client Delivery Export Before Writing Lua
Start with a delivery you can inspect in a few minutes. Select three test photographs: one landscape, one portrait, and one whose long edge is already below 2400 pixels. The smaller image will help you check that the export does not enlarge it.
Use this review recipe:
JPEG files in sRGB.
Quality set to 80 in the Export dialog.
Long edge limited to 2400 pixels, with enlargement disabled.
A new destination folder for each export run.
A CSV manifest recording source identity, actual output filename, processing status, and any error message.
These are example delivery requirements. Adjust them later to match your client's specifications, after the basic workflow behaves consistently.
Decide what the plug-in adds
Lightroom Classic already supports export presets and After Export actions. A preset is sufficient when you only need to reuse image settings. An After Export action can launch a separate tool to process exported files.
Our plug-in will add a job label and record outcomes as Lightroom processes the selected photographs. Keep the built-in image settings visible so you can inspect the recipe before exporting. The custom behavior belongs around delivery tracking.
For a successful test, the resulting package should resemble this:
A successful delivery requires readable JPEGs and a manifest that agrees with them. A failed rendition needs an error record. Cancellation must leave the run identifiable as incomplete, and a manifest write failure must be reported even if the images exist.
Keep source photographs and catalog edits unchanged. A second export should use a fresh folder, preserving the first delivery for comparison.
Prepare Lightroom Classic, Codex, and the SDK on Vagon
Use a Windows Vagon computer for this workflow. Before installing Lightroom Classic, compare its Windows version and hardware with Adobe's requirements for your Lightroom release. Install and activate Lightroom Classic through your Adobe account, then launch it successfully before adding development tools. Adobe licensing is separate from the cloud computer.
Create a test catalog and import copies of the three photographs. Confirm that the original image files are available on the Vagon desktop and that Lightroom can open them without missing-file warnings. Export one image manually using the review recipe. This establishes that ordinary rendering and destination-folder access work before you introduce custom code.
Keep each delivery folder inside exports. The plug-in source stays in ClientDelivery.lrdevplugin, where Lightroom will load it during development. This layout lets you inspect a code change, reload the plug-in, and open its exported files within the same desktop session.
Download the SDK through the Adobe Lightroom Classic developer page. Keep its API reference and export-service sample available under sdk-reference. Record the SDK version and installed Lightroom Classic version in the README so Codex can work against an identified reference.
Install the CLI using the Windows instructions in the official Codex documentation, then open PowerShell inside the Vagon desktop. Change to your project directory and launch it:
Complete the available sign-in flow and ask Codex to list the project files and identify the SDK references. Keep its file access focused on this working folder.
Codex will edit the Lua source here. You will load and exercise that source in Lightroom Classic. This file-based development loop needs no MCP server; the next step is to define a plug-in Lightroom can recognize.
Give Codex the SDK Contract and Build the Plug-in Skeleton
Use two Lua files inside ClientDelivery.lrdevplugin: Info.lua and ExportServiceProvider.lua. Keep both in that folder, directly beside each other. The working project must also contain a sibling exports folder, as shown above.
The reference below declares SDK 13.0 as its development baseline. Its registration and export model follow Adobe's v13 guide; supplemental object and file-operation contracts were checked against Adobe-authored API documentation preserved in a 14.3 SDK mirror. This is a documented reference implementation, not a claim that it has passed Lightroom Classic 13 or the current release. The automated checks used Lua 5.1 and SDK test doubles; no Lightroom application test has been performed for this example.
Download the SDK available through Adobe's developer route and compare its reference with the declared baseline before adapting the example. Record your installed Lightroom version separately. Changing a version number in Info.lua does not demonstrate compatibility.
LrPluginName names the plug-in in Plug-In Manager. The provider's title names the destination in Export To, and file identifies the Lua script Lightroom loads. Keep the toolkit identifier stable while revising this project. Replace the tutorial's com.example identifier with your own reverse-domain identifier before distribution.
After saving the complete provider in the next section, use File > Plug-In Manager > Add to select ClientDelivery.lrdevplugin. If registration fails, inspect the diagnostic and check the filename before changing the handler. Do not run an export until both files are saved.
Inspect Info.luaand ExportServiceProvider.luaagainst sdk-reference.
Reportthe reference version and any API mismatch before changing code.
Explaindestination validation,rendition handling,and checkpoint writes.
Keepthe CSV schema,source identifiers,and no-overwrite behavior intact.
Showthe diff and run the supplied Lua 5.1harness after any revision.
Donot describe a harness passas a successful Lightroom application test
Inspect Info.luaand ExportServiceProvider.luaagainst sdk-reference.
Reportthe reference version and any API mismatch before changing code.
Explaindestination validation,rendition handling,and checkpoint writes.
Keepthe CSV schema,source identifiers,and no-overwrite behavior intact.
Showthe diff and run the supplied Lua 5.1harness after any revision.
Donot describe a harness passas a successful Lightroom application test
Inspect Info.luaand ExportServiceProvider.luaagainst sdk-reference.
Reportthe reference version and any API mismatch before changing code.
Explaindestination validation,rendition handling,and checkpoint writes.
Keepthe CSV schema,source identifiers,and no-overwrite behavior intact.
Showthe diff and run the supplied Lua 5.1harness after any revision.
Donot describe a harness passas a successful Lightroom application test
A specific request, such as adding a delivery-note field, is easier to evaluate against this working source than regenerating the entire plug-in. Keep the original version so you can compare behavior after the change.
Implement the Export Dialog and Rendition Handler
Save the following complete file as ExportServiceProvider.lua. It includes the Delivery field, validation, rendering loop, and manifest writer. The next section explains the writer's recovery behavior.
The Job label is descriptive metadata. The destination comes from Export Location and must be a direct child of this project's exports folder. Create that delivery folder before exporting. For example, choose Documents\ClientDelivery\exports\client-review-001 and turn off Put in Subfolder. The final execution guard requires an empty folder.
This Windows tutorial deliberately uses ASCII-only destination paths because its manifest writer uses Lua io.open. If your Windows profile path contains non-ASCII characters, use an ASCII-only writable project location and adjust the earlier PowerShell command accordingly. Supporting other path encodings requires a separately verified file-writing route.
The provider adds one bound field through sectionsForTopOfDialog and retains Lightroom's built-in export sections. exportPresetFields remembers the Job label; the validation observer updates the reason shown beside a disabled Export button.
Set JPEG and sRGB in File Settings, and choose the option that creates a new name for existing files. The internal collision setting is rename. Enter quality 80 and the agreed sizing recipe manually. If you later initialize quality through code, LR_jpeg_quality uses 0.8 for that setting. The provider checks format, color space, destination and collision handling; it does not enforce every review-image preference.
Adobe's export-service model gives processRenderedPhotos an export context inside a cooperative task. The handler inventories catalog photo identifiers before requesting renditions, configures progress, and waits for each rendered result. A skipped rendition is handled separately because a successful wait alone does not guarantee a file was produced.
The code checks that each accepted file is nonempty, is inside the delivery folder, and has not already been assigned to another row. Catalog identifiers distinguish virtual copies that share a source filename. They are local to that catalog, so the manifest is a delivery record rather than a global asset identifier.
localFiles = import"LrFileUtils"localPaths = import"LrPathUtils"localTasks = import"LrTasks"localDialogs = import"LrDialogs"localView = import"LrView"localprovider, active = {}, {}
localexportRoot = Paths.child(Paths.parent(_PLUGIN.path), "exports")
localfunctionrequireOK(ok, message)
ifnotokthenerror(tostring(messageor"File operation failed"), 0) endend-- Windows tutorial paths only; ASCII avoids Lua io path-encoding issues.localfunctionnormalized(path)
returnPaths.standardizePath(path):gsub("\\", "/"):gsub("/+$", ""):lower()
endlocalfunctiondestination(p)
ifp.LR_export_destinationType ~= "specificFolder"orp.LR_export_useSubfolderthenreturnnil, "Choose Specific Folder and turn off Put in Subfolder."endlocalfolder = p.LR_export_destinationPathPrefixor""iffolder == ""orfolder:find("[^ -~]") thenreturnnil, "Choose a folder with an ASCII-only full path."endifnormalized(Paths.parent(folder) or"") ~= normalized(exportRoot) thenreturnnil, "Choose a direct child folder of this project's exports folder."endifnot (p.deliveryJobLabelor""):find("%S") thenreturnnil, "Enter a job label."endifp.LR_format ~= "JPEG"orp.LR_export_colorSpace ~= "sRGB"orp.LR_collisionHandling ~= "rename"thenreturnnil, "Use JPEG, sRGB, and Existing Files: Choose a new name."endreturnfolderendlocalfunctionvalidate(p)
local_, reason = destination(p)
p.LR_cantExportBecause = reasonendprovider.exportPresetFields = {
{ key = "deliveryJobLabel", default = "client-review-001" },
}
functionprovider.startDialog(p)
localkeys = { "deliveryJobLabel", "LR_export_destinationType",
"LR_export_destinationPathPrefix", "LR_export_useSubfolder",
"LR_format", "LR_export_colorSpace", "LR_collisionHandling" }
for_, keyinipairs(keys) dop:addObserver(key, function() validate(p) end)
endvalidate(p)
endfunctionprovider.sectionsForTopOfDialog(f, p)
return {{ title = "Delivery",
f:row {
f:static_text { title = "Job label" },
f:edit_field { value = View.bind"deliveryJobLabel",
width_in_chars = 28 },
},
}}
endlocalfunctioncsv(value)
return'"' .. tostring(valueor""):gsub('"', '""') .. '"'endlocalfunctiondocument(job, runStatus, rows)
locallines = { "job_label,run_status,source_id,source_name,output_file,status,error" }
for_, rowinipairs(rows) dolocalfields = { job, runStatus, row.id, row.name,
row.output, row.status, row.message }
fori = 1, 7dofields[i] = csv(fields[i]) endlines[#lines + 1] = table.concat(fields, ",")
endreturntable.concat(lines, "\r\n") .. "\r\n"end-- Never truncate the current checkpoint. Check every write, close and move.localfunctioncheckpoint(folder, contents, final)
localnextFile = Paths.child(folder, "manifest.next.csv")
localcurrent = Paths.child(folder, "manifest.partial.csv")
localprevious = Paths.child(folder, "manifest.previous.csv")
localfinished = Paths.child(folder, "manifest.csv")
requireOK(notFiles.exists(nextFile), "Unresolved manifest.next.csv; stop and inspect.")
requireOK(notFiles.exists(finished), "A final manifest already exists.")
localhandle, openError = io.open(nextFile, "wb")
requireOK(handle, openError)
localwritten, writeError = handle:write(contents)
localclosed, closeError = handle:close()
requireOK(written, writeError)
requireOK(closed, closeError)
ifFiles.exists(current) thenifFiles.exists(previous) thenrequireOK(Files.delete(previous)) endrequireOK(Files.move(current, previous))
endrequireOK(Files.move(nextFile, current))
iffinalthenrequireOK(Files.move(current, finished))
ifFiles.exists(previous) thenrequireOK(Files.delete(previous)) endendendfunctionprovider.processRenderedPhotos(_, context)
localfolder, folderKey, ownsLocklocalprogresslocalok, failure = Tasks.pcall(function()
localreasonfolder, reason = destination(context.propertyTable)
requireOK(folder, reason)
folderKey = normalized(folder)
requireOK(notactive[folderKey], "This destination is already in use.")
active[folderKey], ownsLock = true, truerequireOK(Files.exists(folder) == "directory", "Create the empty delivery folder first.")
requireOK(Files.isEmptyDirectory(folder), "Destination is not empty; choose a new folder.")
localrows, byId = {}, {}
forphotoincontext.exportSession:photosToExport() dolocalid = tostring(photo.localIdentifier)
requireOK(notbyId[id], "Duplicate source identifier in this export session.")
localrow = { id = id,
name = Paths.leafName(photo:getRawMetadata("path")),
output = "", status = "pending", message = "" }
rows[#rows + 1], byId[id] = row, rowendrequireOK(#rows > 0, "No photographs in this export session.")
localjob = context.propertyTable.deliveryJobLabelcheckpoint(folder, document(job, "running", rows), false)
progress = context:configureProgress { title = "Client Delivery Export" }
progress:setCancelable(true)
localseenPaths = {}
for_, renditionincontext:renditions { progressScope = progress, stopIfCanceled = true } doifprogress:isCanceled() thenbreakendlocalrow = byId[tostring(rendition.photo.localIdentifier)]
requireOK(row, "Rendition has no inventory row.")
requireOK(row.status == "pending", "Duplicate rendition for a source photograph.")
localsuccess, pathOrMessage = rendition:waitForRender()
ifnotsuccessthenrow.status, row.message = "render_failed", tostring(pathOrMessage)
elseifrendition.wasSkippedthenrow.status, row.message = "skipped", "The rendition was skipped."elselocalpath = pathOrMessagelocalkey = normalized(path)
localsize = Files.fileAttributes(path).fileSizeor0ifnormalized(Paths.parent(path) or"") ~= folderKeyorFiles.exists(path) ~= "file"orsize <= 0orseenPaths[key] thenrow.status = "delivery_failed"row.message = "Output missing, empty, outside destination, or reused."elserow.status, row.output = "exported", Paths.leafName(path)
seenPaths[key] = trueendendifrow.status ~= "exported"thenrendition:uploadFailed(row.message) endcheckpoint(folder, document(job, "running", rows), false)
endlocalrunStatus = progress:isCanceled() and"canceled"or"complete"ifrunStatus == "complete"thenfor_, rowinipairs(rows) doifrow.status ~= "exported"thenrunStatus = "partial"endendendcheckpoint(folder, document(job, runStatus, rows), true)
Dialogs.message("Client Delivery Export", "Run: " .. runStatus .. "\n" .. folder,
runStatus == "complete"and"info"or"warning")
end)
ifprogressthenprogress:done() endifownsLockthenactive[folderKey] = nilendifnotokthenDialogs.message("Delivery incomplete", tostring(failure)
.. "\nInspect the destination and its manifest checkpoint files.", "critical")
error(tostring(failure), 0)
endendreturnprovider
localFiles = import"LrFileUtils"localPaths = import"LrPathUtils"localTasks = import"LrTasks"localDialogs = import"LrDialogs"localView = import"LrView"localprovider, active = {}, {}
localexportRoot = Paths.child(Paths.parent(_PLUGIN.path), "exports")
localfunctionrequireOK(ok, message)
ifnotokthenerror(tostring(messageor"File operation failed"), 0) endend-- Windows tutorial paths only; ASCII avoids Lua io path-encoding issues.localfunctionnormalized(path)
returnPaths.standardizePath(path):gsub("\\", "/"):gsub("/+$", ""):lower()
endlocalfunctiondestination(p)
ifp.LR_export_destinationType ~= "specificFolder"orp.LR_export_useSubfolderthenreturnnil, "Choose Specific Folder and turn off Put in Subfolder."endlocalfolder = p.LR_export_destinationPathPrefixor""iffolder == ""orfolder:find("[^ -~]") thenreturnnil, "Choose a folder with an ASCII-only full path."endifnormalized(Paths.parent(folder) or"") ~= normalized(exportRoot) thenreturnnil, "Choose a direct child folder of this project's exports folder."endifnot (p.deliveryJobLabelor""):find("%S") thenreturnnil, "Enter a job label."endifp.LR_format ~= "JPEG"orp.LR_export_colorSpace ~= "sRGB"orp.LR_collisionHandling ~= "rename"thenreturnnil, "Use JPEG, sRGB, and Existing Files: Choose a new name."endreturnfolderendlocalfunctionvalidate(p)
local_, reason = destination(p)
p.LR_cantExportBecause = reasonendprovider.exportPresetFields = {
{ key = "deliveryJobLabel", default = "client-review-001" },
}
functionprovider.startDialog(p)
localkeys = { "deliveryJobLabel", "LR_export_destinationType",
"LR_export_destinationPathPrefix", "LR_export_useSubfolder",
"LR_format", "LR_export_colorSpace", "LR_collisionHandling" }
for_, keyinipairs(keys) dop:addObserver(key, function() validate(p) end)
endvalidate(p)
endfunctionprovider.sectionsForTopOfDialog(f, p)
return {{ title = "Delivery",
f:row {
f:static_text { title = "Job label" },
f:edit_field { value = View.bind"deliveryJobLabel",
width_in_chars = 28 },
},
}}
endlocalfunctioncsv(value)
return'"' .. tostring(valueor""):gsub('"', '""') .. '"'endlocalfunctiondocument(job, runStatus, rows)
locallines = { "job_label,run_status,source_id,source_name,output_file,status,error" }
for_, rowinipairs(rows) dolocalfields = { job, runStatus, row.id, row.name,
row.output, row.status, row.message }
fori = 1, 7dofields[i] = csv(fields[i]) endlines[#lines + 1] = table.concat(fields, ",")
endreturntable.concat(lines, "\r\n") .. "\r\n"end-- Never truncate the current checkpoint. Check every write, close and move.localfunctioncheckpoint(folder, contents, final)
localnextFile = Paths.child(folder, "manifest.next.csv")
localcurrent = Paths.child(folder, "manifest.partial.csv")
localprevious = Paths.child(folder, "manifest.previous.csv")
localfinished = Paths.child(folder, "manifest.csv")
requireOK(notFiles.exists(nextFile), "Unresolved manifest.next.csv; stop and inspect.")
requireOK(notFiles.exists(finished), "A final manifest already exists.")
localhandle, openError = io.open(nextFile, "wb")
requireOK(handle, openError)
localwritten, writeError = handle:write(contents)
localclosed, closeError = handle:close()
requireOK(written, writeError)
requireOK(closed, closeError)
ifFiles.exists(current) thenifFiles.exists(previous) thenrequireOK(Files.delete(previous)) endrequireOK(Files.move(current, previous))
endrequireOK(Files.move(nextFile, current))
iffinalthenrequireOK(Files.move(current, finished))
ifFiles.exists(previous) thenrequireOK(Files.delete(previous)) endendendfunctionprovider.processRenderedPhotos(_, context)
localfolder, folderKey, ownsLocklocalprogresslocalok, failure = Tasks.pcall(function()
localreasonfolder, reason = destination(context.propertyTable)
requireOK(folder, reason)
folderKey = normalized(folder)
requireOK(notactive[folderKey], "This destination is already in use.")
active[folderKey], ownsLock = true, truerequireOK(Files.exists(folder) == "directory", "Create the empty delivery folder first.")
requireOK(Files.isEmptyDirectory(folder), "Destination is not empty; choose a new folder.")
localrows, byId = {}, {}
forphotoincontext.exportSession:photosToExport() dolocalid = tostring(photo.localIdentifier)
requireOK(notbyId[id], "Duplicate source identifier in this export session.")
localrow = { id = id,
name = Paths.leafName(photo:getRawMetadata("path")),
output = "", status = "pending", message = "" }
rows[#rows + 1], byId[id] = row, rowendrequireOK(#rows > 0, "No photographs in this export session.")
localjob = context.propertyTable.deliveryJobLabelcheckpoint(folder, document(job, "running", rows), false)
progress = context:configureProgress { title = "Client Delivery Export" }
progress:setCancelable(true)
localseenPaths = {}
for_, renditionincontext:renditions { progressScope = progress, stopIfCanceled = true } doifprogress:isCanceled() thenbreakendlocalrow = byId[tostring(rendition.photo.localIdentifier)]
requireOK(row, "Rendition has no inventory row.")
requireOK(row.status == "pending", "Duplicate rendition for a source photograph.")
localsuccess, pathOrMessage = rendition:waitForRender()
ifnotsuccessthenrow.status, row.message = "render_failed", tostring(pathOrMessage)
elseifrendition.wasSkippedthenrow.status, row.message = "skipped", "The rendition was skipped."elselocalpath = pathOrMessagelocalkey = normalized(path)
localsize = Files.fileAttributes(path).fileSizeor0ifnormalized(Paths.parent(path) or"") ~= folderKeyorFiles.exists(path) ~= "file"orsize <= 0orseenPaths[key] thenrow.status = "delivery_failed"row.message = "Output missing, empty, outside destination, or reused."elserow.status, row.output = "exported", Paths.leafName(path)
seenPaths[key] = trueendendifrow.status ~= "exported"thenrendition:uploadFailed(row.message) endcheckpoint(folder, document(job, "running", rows), false)
endlocalrunStatus = progress:isCanceled() and"canceled"or"complete"ifrunStatus == "complete"thenfor_, rowinipairs(rows) doifrow.status ~= "exported"thenrunStatus = "partial"endendendcheckpoint(folder, document(job, runStatus, rows), true)
Dialogs.message("Client Delivery Export", "Run: " .. runStatus .. "\n" .. folder,
runStatus == "complete"and"info"or"warning")
end)
ifprogressthenprogress:done() endifownsLockthenactive[folderKey] = nilendifnotokthenDialogs.message("Delivery incomplete", tostring(failure)
.. "\nInspect the destination and its manifest checkpoint files.", "critical")
error(tostring(failure), 0)
endendreturnprovider
localFiles = import"LrFileUtils"localPaths = import"LrPathUtils"localTasks = import"LrTasks"localDialogs = import"LrDialogs"localView = import"LrView"localprovider, active = {}, {}
localexportRoot = Paths.child(Paths.parent(_PLUGIN.path), "exports")
localfunctionrequireOK(ok, message)
ifnotokthenerror(tostring(messageor"File operation failed"), 0) endend-- Windows tutorial paths only; ASCII avoids Lua io path-encoding issues.localfunctionnormalized(path)
returnPaths.standardizePath(path):gsub("\\", "/"):gsub("/+$", ""):lower()
endlocalfunctiondestination(p)
ifp.LR_export_destinationType ~= "specificFolder"orp.LR_export_useSubfolderthenreturnnil, "Choose Specific Folder and turn off Put in Subfolder."endlocalfolder = p.LR_export_destinationPathPrefixor""iffolder == ""orfolder:find("[^ -~]") thenreturnnil, "Choose a folder with an ASCII-only full path."endifnormalized(Paths.parent(folder) or"") ~= normalized(exportRoot) thenreturnnil, "Choose a direct child folder of this project's exports folder."endifnot (p.deliveryJobLabelor""):find("%S") thenreturnnil, "Enter a job label."endifp.LR_format ~= "JPEG"orp.LR_export_colorSpace ~= "sRGB"orp.LR_collisionHandling ~= "rename"thenreturnnil, "Use JPEG, sRGB, and Existing Files: Choose a new name."endreturnfolderendlocalfunctionvalidate(p)
local_, reason = destination(p)
p.LR_cantExportBecause = reasonendprovider.exportPresetFields = {
{ key = "deliveryJobLabel", default = "client-review-001" },
}
functionprovider.startDialog(p)
localkeys = { "deliveryJobLabel", "LR_export_destinationType",
"LR_export_destinationPathPrefix", "LR_export_useSubfolder",
"LR_format", "LR_export_colorSpace", "LR_collisionHandling" }
for_, keyinipairs(keys) dop:addObserver(key, function() validate(p) end)
endvalidate(p)
endfunctionprovider.sectionsForTopOfDialog(f, p)
return {{ title = "Delivery",
f:row {
f:static_text { title = "Job label" },
f:edit_field { value = View.bind"deliveryJobLabel",
width_in_chars = 28 },
},
}}
endlocalfunctioncsv(value)
return'"' .. tostring(valueor""):gsub('"', '""') .. '"'endlocalfunctiondocument(job, runStatus, rows)
locallines = { "job_label,run_status,source_id,source_name,output_file,status,error" }
for_, rowinipairs(rows) dolocalfields = { job, runStatus, row.id, row.name,
row.output, row.status, row.message }
fori = 1, 7dofields[i] = csv(fields[i]) endlines[#lines + 1] = table.concat(fields, ",")
endreturntable.concat(lines, "\r\n") .. "\r\n"end-- Never truncate the current checkpoint. Check every write, close and move.localfunctioncheckpoint(folder, contents, final)
localnextFile = Paths.child(folder, "manifest.next.csv")
localcurrent = Paths.child(folder, "manifest.partial.csv")
localprevious = Paths.child(folder, "manifest.previous.csv")
localfinished = Paths.child(folder, "manifest.csv")
requireOK(notFiles.exists(nextFile), "Unresolved manifest.next.csv; stop and inspect.")
requireOK(notFiles.exists(finished), "A final manifest already exists.")
localhandle, openError = io.open(nextFile, "wb")
requireOK(handle, openError)
localwritten, writeError = handle:write(contents)
localclosed, closeError = handle:close()
requireOK(written, writeError)
requireOK(closed, closeError)
ifFiles.exists(current) thenifFiles.exists(previous) thenrequireOK(Files.delete(previous)) endrequireOK(Files.move(current, previous))
endrequireOK(Files.move(nextFile, current))
iffinalthenrequireOK(Files.move(current, finished))
ifFiles.exists(previous) thenrequireOK(Files.delete(previous)) endendendfunctionprovider.processRenderedPhotos(_, context)
localfolder, folderKey, ownsLocklocalprogresslocalok, failure = Tasks.pcall(function()
localreasonfolder, reason = destination(context.propertyTable)
requireOK(folder, reason)
folderKey = normalized(folder)
requireOK(notactive[folderKey], "This destination is already in use.")
active[folderKey], ownsLock = true, truerequireOK(Files.exists(folder) == "directory", "Create the empty delivery folder first.")
requireOK(Files.isEmptyDirectory(folder), "Destination is not empty; choose a new folder.")
localrows, byId = {}, {}
forphotoincontext.exportSession:photosToExport() dolocalid = tostring(photo.localIdentifier)
requireOK(notbyId[id], "Duplicate source identifier in this export session.")
localrow = { id = id,
name = Paths.leafName(photo:getRawMetadata("path")),
output = "", status = "pending", message = "" }
rows[#rows + 1], byId[id] = row, rowendrequireOK(#rows > 0, "No photographs in this export session.")
localjob = context.propertyTable.deliveryJobLabelcheckpoint(folder, document(job, "running", rows), false)
progress = context:configureProgress { title = "Client Delivery Export" }
progress:setCancelable(true)
localseenPaths = {}
for_, renditionincontext:renditions { progressScope = progress, stopIfCanceled = true } doifprogress:isCanceled() thenbreakendlocalrow = byId[tostring(rendition.photo.localIdentifier)]
requireOK(row, "Rendition has no inventory row.")
requireOK(row.status == "pending", "Duplicate rendition for a source photograph.")
localsuccess, pathOrMessage = rendition:waitForRender()
ifnotsuccessthenrow.status, row.message = "render_failed", tostring(pathOrMessage)
elseifrendition.wasSkippedthenrow.status, row.message = "skipped", "The rendition was skipped."elselocalpath = pathOrMessagelocalkey = normalized(path)
localsize = Files.fileAttributes(path).fileSizeor0ifnormalized(Paths.parent(path) or"") ~= folderKeyorFiles.exists(path) ~= "file"orsize <= 0orseenPaths[key] thenrow.status = "delivery_failed"row.message = "Output missing, empty, outside destination, or reused."elserow.status, row.output = "exported", Paths.leafName(path)
seenPaths[key] = trueendendifrow.status ~= "exported"thenrendition:uploadFailed(row.message) endcheckpoint(folder, document(job, "running", rows), false)
endlocalrunStatus = progress:isCanceled() and"canceled"or"complete"ifrunStatus == "complete"thenfor_, rowinipairs(rows) doifrow.status ~= "exported"thenrunStatus = "partial"endendendcheckpoint(folder, document(job, runStatus, rows), true)
Dialogs.message("Client Delivery Export", "Run: " .. runStatus .. "\n" .. folder,
runStatus == "complete"and"info"or"warning")
end)
ifprogressthenprogress:done() endifownsLockthenactive[folderKey] = nilendifnotokthenDialogs.message("Delivery incomplete", tostring(failure)
.. "\nInspect the destination and its manifest checkpoint files.", "critical")
error(tostring(failure), 0)
endendreturnprovider
An ordinary rendering error is recorded per photograph and signaled back to Lightroom. A manifest or destination failure raises an error for the run. A canceled run keeps pending rows for photographs whose output was not verified. Run one export at a time: the in-memory guard applies to this loaded provider, not every possible Lightroom process or external writer.
Write a Manifest That Matches the Exported Files
The writer uses the same fixed schema for every checkpoint. A successful run might contain the following records; identifiers and filenames here are illustrative:
Every expected photograph starts with a pending row. The rendition loop changes it to exported, render_failed, delivery_failed, or skipped. The run remains running during processing, then becomes complete, partial, or canceled. A normal loop exit is insufficient for complete: every row must have a verified output.
Replace checkpoints without truncating the current record
Each update writes a fresh manifest.next.csv and checks both write and close results. The writer then moves the current manifest.partial.csv to manifest.previous.csv before promoting the new file. An earlier backup is removed only after the new candidate has been written and closed successfully.
If promotion fails, stop and inspect the retained files. The previous checkpoint can remain under the backup name; a leftover next file may be incomplete. Do not automatically trust whichever file has the newest timestamp.
On finalization, the current checkpoint becomes manifest.csv, then the backup is removed. A cleanup failure is reported even if the final manifest already exists. A handled cancellation can have a finalized manifest with canceled status; an unexpected interruption leaves checkpoint files to inspect. This protocol avoids deliberately truncating the current record, but does not promise power-loss durability on every filesystem.
The CSV function quotes every field and doubles embedded quotes. Local Lua 5.1 harness checks covered commas, quotes, embedded line breaks, rendering failures, cancellation and injected file-operation failures. Those checks validate the simulated control flow, not Lightroom's runtime or the JPEGs it renders.
Load the Plug-in and Verify a Three-Photo Export
Save the integrated source, then reload Client Delivery Export through Plug-In Manager's Plug-in Author Tools. Confirm that Lightroom reports the plug-in as running. Close and reopen the Export dialog so you inspect the newly loaded version.
Select exactly the three test photographs in Grid view. Open File > Export and choose Client Delivery Export. For this first test, use a single export configuration with the preset checkboxes cleared. Adobe documents that selecting presets for multi-batch export can hide custom plug-in sections, which would complicate this initial export check.
Set the Job label to client-review-001 and choose a fresh, empty folder at exports\client-review-001. Verify the resolved destination, including any subfolder setting. Check the review recipe again and leave HDR Output disabled for this ordinary sRGB JPEG test. In Export Location, also choose the existing-file option that creates a new name. The provider validates those settings before processing; its execution guard rejects a destination containing any files.
Run the export and wait for processing to finish. Open the delivery folder in File Explorer, then open every JPEG in an image viewer. Compare each image with its source in Lightroom, checking that the intended crop, orientation, and visible Develop adjustments carried through.
Use ExifTool's read-only tag extraction to inspect the saved files. Install the Windows executable from the official ExifTool distribution and make exiftool available in PowerShell. From the project folder, run:
Expect JPEG file type, the intended dimensions, and sRGB color-space/profile information. Read EXIF and ICC fields together: absent or conflicting fields need investigation rather than an automatic pass. This metadata command does not fully decode the image; keep the visual opening check. It also does not prove which quality-slider setting encoded the file.
For uncropped, correctly oriented test images, the expected dimensions are straightforward:
Test image dimensions
Expected exported dimensions
Landscape: 6000 × 4000
2400 × 1600
Portrait: 4000 × 6000
1600 × 2400
Small image: 1200 × 800
1200 × 800
Adapt the calculation to your actual photographs and crops. Confirm that the files decode as JPEGs and carry an sRGB profile or color-space indication. A filename extension and a plausible preview alone do not establish those properties.
Reconcile the delivery record
Open manifest.csv with a CSV-aware reader. There should be one row for each selected photograph, with distinct source identities, matching output filenames, and no unexplained errors. Every row marked exported must resolve to the image you just inspected. A clean three-image run should have three JPEGs and a complete manifest, with no leftover partial manifest.
Record the observed result, Lightroom version, and plug-in revision in the README. If anything differs, keep the files and diagnostic message for debugging before increasing the batch size.
Fix Load Errors, Failed Renders, and Repeat-Run Problems
Give Codex the exact error, the plug-in revision, and the stage that failed. Include the relevant export settings and manifest rows. Ask for a correction confined to that failure, then reload and repeat the same test before adding features.
Use the earliest failed checkpoint to narrow the investigation:
Symptom
First check
Plug-in fails to load
Inspect Plug-In Manager diagnostics, Lua syntax, and the registered provider filename.
Export stays disabled
Read the validation reason; check the label, destination, format, color space, and collision option.
A rendition fails
Preserve the SDK error and try a manual export of that photograph with the same image settings.
JPEGs exist but the manifest is missing
Inspect manifest creation, write, close, and finalization errors separately from rendering.
Edits do not appear
Confirm which plug-in folder Lightroom loaded, reload it, and reopen Export.
If the manual export also fails, investigate the source photograph or Lightroom settings before changing the manifest code. If it succeeds, compare the provider's settings and rendition handling with that working export.
Exercise cancellation and failure reporting
Cancel a disposable export while it is processing. If three images finish too quickly, use a larger test selection. Check that the saved record identifies the canceled or incomplete run and leaves unprocessed photographs distinguishable from successful outputs.
Run test_provider.py from the accompanying reference package after changes to the writer. It injects failed opens, partial writes, close errors, checkpoint moves, finalization, and backup deletion, and checks that errors are surfaced. These are simulated SDK operations, not actual Lightroom disk failures.
Repeat without replacing the first delivery
Export the original selection into exports\client-review-002. Compare its images and manifest with the first run. Filenames should resolve correctly in each folder, and the earlier delivery should remain unchanged.
Also test two copied photographs from different source folders that share a filename. Both need distinct output paths and accurate manifest rows. Finally, attempt to reuse the first delivery folder: validation or the execution guard should refuse before overwriting its contents. Record these outcomes before moving to client work.
Package the Plug-in for the Next Delivery
Once the checks pass, preserve a versioned copy of the source. Package the Lua files in ClientDelivery.lrplugin for distribution, keeping Info.lua at the folder root. Retain the .lrdevplugin working copy separately.
Include a README with installation steps, the review recipe, Lightroom and SDK versions, and the tests you actually completed. Keep client photographs and catalogs outside the distributed package.
Load the packaged copy with the development copy disabled, then repeat the small export to check that packaging introduced no missing files. Keep a known working release before further changes. The README should distinguish automated harness checks from exports you actually inspected in Lightroom.
FAQs
Does this plug-in work in Lightroom or only Lightroom Classic?
This workflow targets Lightroom Classic's desktop Lua SDK. Treat the cloud-focused Lightroom application as a separate product; the Classic plug-in folder described here is not a transferable extension for it.
Can Codex write the plug-in in Python instead of Lua?
The Lightroom Classic plug-in SDK uses Lua. Codex can help develop that Lua code. Python could support separate file-processing tools, but it does not replace the SDK entry points Lightroom loads from this plug-in.
Do I need an MCP server to build it?
No. This example uses local file edits and Plug-In Manager. A bridge for live application access would be a separate integration with its own compatibility requirements.
Does running this on Vagon provide a Lightroom Classic license?
Plan for your own Adobe entitlement and activation. The cloud computer and the application license are separate parts of the setup. Confirm access to Lightroom Classic through your Adobe account before preparing the development environment.
When should I build a Publish Service instead?
Use the export workflow for individual delivery batches. Consider a Publish Service when you need an ongoing relationship between catalog photographs and published items, including subsequent updates. That requires additional SDK behavior; adding a manifest to an export provider does not implement synchronization.
Vagon Computer
Run it on a cloud computer
Open it from any browser, Mac, iPad or Chromebook. No local hardware needed.
Mert is a philosophy student with a strong interest in economics, motorsports, and basketball. He enjoys thinking about how the world works, both in theory and in real life. In his free time, you can often find him watching a race, following a basketball game, or reading about economic ideas.