Let a person finish by hand what the agent started: open the agent’s designs in CE.SDK’s web editor with the same brand, fonts, and images the agent used, and save them back without losing either side’s work.
No example product runs the code on this page; it is checked against the SDK’s
types, and each function that throws 'yours to write' is yours to write over
your own API.
Before You Start#
This guide assumes the design store and the plugin module from
Connect Your Storage: read and write over your
designs, and the plugin file, sent as { module, options }, that registers
your asset sources (searchable lists of your images, fonts, and colors).
Your agent runner, the backend process that calls the model and
prepareCoDesign, passes both; the executor, the container from IMG.LY’s
image, runs the agent’s CE.SDK engine. read answers a design’s content, its
version (a value that changes on every save), and the CE.SDK version that
saved it, and write stores only while the design is still at the version it
was given. Your editor’s routes call the same two store functions, and your page gets the same module text with a bundler’s
raw-text import (Vite’s ?raw, say) or from your API. It gets this person’s
options, a short-lived token among them, from your API too.
CE.SDK, IMG.LY’s design engine, comes as a web editor you embed in your page
(@cesdk/cesdk-js). The SDK does not ship an editor; its
@imgly/codesign-sdk/cesdk-utils entry is a few helpers for your editor’s
engine that do the parts that fail silently when done by hand. Your save
route and your editor’s UI stay yours. Install the SDK next to
@cesdk/cesdk-js at exactly the CE.SDK version the SDK reports as
CESDK_VERSION (import { CESDK_VERSION } from '@imgly/codesign-sdk'):
npm install --save-exact @imgly/codesign-sdk@1.83.0 @cesdk/cesdk-js@1.83.0npm ls @cesdk/cesdk-js then shows 1.83.0, the same version as
CESDK_VERSION. The code below adds only these helpers to an editor you
already set up as CE.SDK’s own web guides show; the panel ids in it (the ids
of CE.SDK’s asset panels) are that editor’s defaults.
Why the Editor Must Match the Agent#
Three things must be the same in your editor as on the agent’s executor, or a design looks or saves differently:
- The same plugins. The executor applies the list your runner passes in
plugins. Your editor applies the same list, with this person’s options, so its panels show the same collections and its images load from the same places. - The same CE.SDK version. A CE.SDK that saves a design another version
wrote can drop settings with no error, so your editor runs exactly the SDK’s
CESDK_VERSION, andloadDesignrefuses to open a design in any other. See Versions and Upgrades for why and how to upgrade. - The same save rules. The agent saves only over the version it read. A person’s save must too, or it overwrites what the agent did while the editor was open.
Open a Design#
export async function openEditor(container: HTMLDivElement, designId: string) { const design = await readDesign(designId);
const cesdk = await CreativeEditorSDK.create(container, { license: CESDK_WEB_LICENSE }); // The same list your runner passes to prepareCoDesign, with this person's options. await addPluginModules(cesdk.engine, [ await yourPluginModule(), googleFonts() ]); // After the plugins: a design loaded first fetches its images before their resolver exists. // Refuses, before the person works on it, an editor on another CE.SDK than the // executor's, or a design a newer CE.SDK saved. Remembered for saveDesign. await loadDesign(cesdk.engine, design);The whole file, editor.ts
import CreativeEditorSDK, { type AssetDefinition } from '@cesdk/cesdk-js';import { googleFonts, imageSizeOf, type EnginePluginModule, type ImageSize, type StoredDesign} from '@imgly/codesign-sdk';import { addPluginModules, loadDesign, saveDesign, unloadDesign} from '@imgly/codesign-sdk/cesdk-utils';
export async function openEditor(container: HTMLDivElement, designId: string) { const design = await readDesign(designId);
const cesdk = await CreativeEditorSDK.create(container, { license: CESDK_WEB_LICENSE }); // The same list your runner passes to prepareCoDesign, with this person's options. await addPluginModules(cesdk.engine, [ await yourPluginModule(), googleFonts() ]); // After the plugins: a design loaded first fetches its images before their resolver exists. // Refuses, before the person works on it, an editor on another CE.SDK than the // executor's, or a design a newer CE.SDK saved. Remembered for saveDesign. await loadDesign(cesdk.engine, design);
// Which panel shows which of your sources is your editor's configuration. The ids // are the sources your plugin module registers (here, Bring Your Brand's). cesdk.ui.updateAssetLibraryEntry('ly.img.typefaces', { sourceIds: ['acme.brand.typefaces'] }); cesdk.ui.updateAssetLibraryEntry('ly.img.colors', { sourceIds: ({ currentIds }) => ['acme.brand.colors', ...currentIds] }); // A person's upload goes where the agent's go: your route, answering the stored asset. // An image is kept with its size in pixels, as the SDK tells your sources for the agent's. cesdk.actions.register('uploadFile', async (file: File) => { const size = imageSizeOf( new Uint8Array(await file.arrayBuffer()), file.type ); return uploadToYourRoute(file, size); });
let version = design.version; // what your read route answered: changes on every save
async function save(): Promise<void> { // An archive stays an archive; a scene keeps only addresses that can be fetched again. // cesdkVersion is this editor's CE.SDK, for the route's checks and your store. const { content, cesdkVersion } = await saveDesign(cesdk.engine); const archive = content.archive !== undefined; const response = await fetch(`/api/designs/${designId}`, { method: 'PUT', headers: { 'If-Match': version, 'X-CESDK-Version': cesdkVersion, 'Content-Type': archive ? 'application/zip' : 'text/plain' }, body: archive ? content.archive : content.scene }); if (response.status === 412) return keepMineOrTheirs(); // someone saved first: below if (response.status === 409) return askToReload(await response.text()); // another CE.SDK version if (!response.ok) throw new Error(`save failed: ${response.status}`); version = response.headers.get('ETag') ?? version; }
async function keepMineOrTheirs(): Promise<void> { const latest = await readDesign(designId); // the newer design and its version if (await personWantsTheirs()) { await loadDesign(cesdk.engine, latest); // the person's unsaved change is dropped version = latest.version; } else { version = latest.version; // the next save lands over the newer design await save(); } }
function close(): void { unloadDesign(cesdk.engine); // frees the archive's temporary blob: URL cesdk.dispose(); }
return { cesdk, save, close };}
// Yours: your CE.SDK web license for this page's domain.const CESDK_WEB_LICENSE = '<your CE.SDK license>';
// Yours: your read route. It answers the design's content, its version (the// ETag) and the CE.SDK that saved it (null: unknown), as your design store's// `read` returns them.async function readDesign( _id: string): Promise<Pick<StoredDesign, 'content' | 'version' | 'cesdkVersion'>> { throw new Error('yours to write');}
// Yours: the plugin module your runner sends, as text, with this person's// options (an API address, a short-lived token from your API).async function yourPluginModule(): Promise<EnginePluginModule> { throw new Error('yours to write');}
// Yours: stores a file through your upload route, with an image's size (undefined// for a file that is no image), and answers the asset your source lists, with// the size as `meta.width` and `meta.height`.async function uploadToYourRoute( _file: File, _size: ImageSize | undefined): Promise<AssetDefinition> { throw new Error('yours to write');}
// Yours: a dialog. true: load the newer design; false: keep this one.async function personWantsTheirs(): Promise<boolean> { throw new Error('yours to write');}
// Yours: tells the person to reload the page.function askToReload(_message: string): void { throw new Error('yours to write');}addPluginModules(engine, plugins) does three things CE.SDK’s own addPlugin
does not:
- It takes the list as the runner sends it. Each entry is
{ module, options }, the shapeprepareCoDesign({ plugins })takes. It loads each module, builds its plugin with the options, and initializes it on the engine, in order, with the executor’s own code and checks. A module that does not load, a default export that is not a function returning a plugin, aninitializethat throws, or a source that fails the checks throws aCoDesignError, the SDK’s error, whosekindisinvalid_options, naming the plugin. - It installs one URI resolver over your sources’
resolve. A URI resolver is the engine’s hook that turns an image’s address, its URI, into a URL it can fetch. If your images use an address scheme of your own (acme://…) that a source turns into a fetchable URL, CE.SDK itself never callsresolve: without the resolver such an image shows as nothing, with no error; see Private Buckets. The resolver takes the engine’s only resolver slot, so answer your schemes in a source’sresolverather than setting a resolver of your own. - It resolves the thumbnails (
thumbUri,previewUri) your sources return, so the editor’s panels show them. An image with nothumbUrishows its ownurias the thumbnail; a video or audio file without one shows none.
Call it once per engine, before loadDesign. It loads the modules from data:
URLs, so a Content Security Policy (CSP) on your page needs script-src data:.
Your sources then run in the person’s browser too, not only on the executor:
the images and fonts they answer, and the API a module calls, are fetched from
your editor’s page, so those hosts must send cross-origin resource sharing
(CORS) headers for its origin, or the browser blocks the requests.
loadDesign(engine, { content, cesdkVersion }) takes the design as your
store’s read returned it: a scene string, CE.SDK’s text form of a design;
an .imgly archive, a design with its images packed in; or an address
({ uri }) your page fetches first, so it must allow your page’s origin with
CORS headers. It remembers which form it loaded, for the save. Content CE.SDK
cannot load fails with kind load_failed, and an address that does not answer
with fetch_failed. Before it loads,
loadDesign refuses what this editor could damage on save, so the person
never works on a design they cannot keep:
- an editor whose CE.SDK is not exactly
CESDK_VERSION, the executor’s: aCoDesignErrorof kindinvalid_optionsthat names both versions; - a design a newer CE.SDK saved, from
cesdkVersion: kindolder_saver.
When it works, the editor shows the agent’s design with its brand fonts and images, the panels list your sources, and an image in your own scheme shows the image, not an empty box.
unloadDesign(engine) frees the archive’s blob: URL. Call it when the editor
closes, or a page that opens many editors keeps every archive’s bytes:
function close(): void { unloadDesign(cesdk.engine); // frees the archive's temporary blob: URL cesdk.dispose();}Save It#
let version = design.version; // what your read route answered: changes on every save
async function save(): Promise<void> { // An archive stays an archive; a scene keeps only addresses that can be fetched again. // cesdkVersion is this editor's CE.SDK, for the route's checks and your store. const { content, cesdkVersion } = await saveDesign(cesdk.engine); const archive = content.archive !== undefined; const response = await fetch(`/api/designs/${designId}`, { method: 'PUT', headers: { 'If-Match': version, 'X-CESDK-Version': cesdkVersion, 'Content-Type': archive ? 'application/zip' : 'text/plain' }, body: archive ? content.archive : content.scene }); if (response.status === 412) return keepMineOrTheirs(); // someone saved first: below if (response.status === 409) return askToReload(await response.text()); // another CE.SDK version if (!response.ok) throw new Error(`save failed: ${response.status}`); version = response.headers.get('ETag') ?? version;}saveDesign(engine) returns { content, cesdkVersion }: the design ready for
your store, and the CE.SDK that saved it, for your save route and
designs.write. The content is chosen as the executor’s saves choose:
- an archive, when
loadDesignloaded one, so the images packed in it are kept; - a scene string, otherwise, keeping addresses in
http,https,bundle, and any scheme of your own the engine names; - an archive after all, when the scene holds images only this tab has, such
as a pasted image at a
blob:address, which would dangle after a reload.
It waits for images still loading, and stores and sends nothing. Your page
sends the save with two headers: If-Match, the version your read route
answered (then the one each save answers), and X-CESDK-Version, the
cesdkVersion that saveDesign returned. When CE.SDK
can save the design neither as a scene string nor as an archive, it rejects
with a CoDesignError of kind archive_failed.
Your save route, on your server, makes the checks the SDK makes before it calls
your designs.write. The browser belongs to the person, who can change what
it sends, so the route is where these checks hold (see
Security):
import { assertCanSave, CESDK_VERSION, isCoDesignError, type DesignContent, type DesignStore} from '@imgly/codesign-sdk';
// Your editor's save: PUT /api/designs/:id. Any web framework works the same way.export async function saveDesignRoute( request: Request, id: string): Promise<Response> { const saver = request.headers.get('X-CESDK-Version'); // the CE.SDK the editor runs const ifMatch = request.headers.get('If-Match'); // the version the editor opened if (saver === null || ifMatch === null) { return new Response('send X-CESDK-Version and If-Match', { status: 428 }); } // Only the CE.SDK you deploy saves: a tab opened before a release still runs the old one. if (saver !== CESDK_VERSION) { return new Response(`Reload the page: this editor runs CE.SDK ${saver}.`, { status: 409 }); }
// The same store your runner passes to prepareCoDesign, for this person's tenant. const designs = designStoreFor(await tenantOf(request)); const stored = await designs.read(id, { signal: request.signal }); if (stored === undefined) return new Response(null, { status: 404 }); try { // Throws kind older_saver when a newer CE.SDK saved the stored design than // this editor's, and invalid_options for a version it cannot read. assertCanSave({ saver, recorded: stored.cesdkVersion }); } catch (err) { if (!isCoDesignError(err)) throw err; return new Response(err.message, { status: err.kind === 'older_saver' ? 409 : 400 // the message names both versions }); }
const bytes = new Uint8Array(await request.arrayBuffer()); const content: DesignContent<ArrayBuffer> = request.headers.get('Content-Type') === 'application/zip' ? { archive: bytes } : { scene: new TextDecoder().decode(bytes) }; // Stored only while the design is still at the version the editor opened; // no editKey: a person's save is no agent edit. const version = await designs.write(id, content, { ifMatch, cesdkVersion: saver }); if (version === undefined) { return new Response('changed', { status: 412 }); // the agent or another tab saved first } return new Response(null, { status: 200, headers: { ETag: version } });}
// Yours: the tenant of the signed-in person, from your session cookie.async function tenantOf(_request: Request): Promise<string> { throw new Error('yours to write');}
// Yours: your design store (see Connect Your Storage).function designStoreFor(_tenantId: string): DesignStore { throw new Error('yours to write');}- Both headers are required. A save without
X-CESDK-VersionorIf-Matchis answered428: an editor that sends neither is one the route cannot check. - The CE.SDK check refuses an editor of another CE.SDK version with
409, typically a tab left open across your release. assertCanSaverefuses when a newer CE.SDK saved the stored design, for instance executors you already moved forward. It throws aCoDesignErrorof kindolder_saverwhose message names both versions (itssaverandrecordedfields hold them), which the route answers409, or of kindinvalid_optionsfor a version it cannot read, answered400. Any other error is not a refusal, so the route rethrows it.If-Matchmakes the write conditional:designs.writestores only while the design is still at the version the editor opened, and answersundefinedotherwise. The route then answers412.
When Someone Saved First#
A 412 means the stored design changed since the editor opened it, most likely
because the agent edited it. Your editor decides with the person: load the
newer design (their unsaved change is lost, or they redo it), or keep theirs by
saving again with the new version, over the agent’s work. Either way, the
agent’s next edit is refused because the design changed since the agent last
read it, and the model looks before it builds on what the person did. For
versions and parallel edits in depth, see
Concurrent Edits.
async function keepMineOrTheirs(): Promise<void> { const latest = await readDesign(designId); // the newer design and its version if (await personWantsTheirs()) { await loadDesign(cesdk.engine, latest); // the person's unsaved change is dropped version = latest.version; } else { version = latest.version; // the next save lands over the newer design await save(); }}Panels and Uploads#
addPluginModules registers your sources. Which panel shows which source is
your editor’s configuration, with CE.SDK’s own calls:
// Which panel shows which of your sources is your editor's configuration. The ids// are the sources your plugin module registers (here, Bring Your Brand's).cesdk.ui.updateAssetLibraryEntry('ly.img.typefaces', { sourceIds: ['acme.brand.typefaces']});cesdk.ui.updateAssetLibraryEntry('ly.img.colors', { sourceIds: ({ currentIds }) => ['acme.brand.colors', ...currentIds]});// A person's upload goes where the agent's go: your route, answering the stored asset.// An image is kept with its size in pixels, as the SDK tells your sources for the agent's.cesdk.actions.register('uploadFile', async (file: File) => { const size = imageSizeOf( new Uint8Array(await file.arrayBuffer()), file.type ); return uploadToYourRoute(file, size);});A person’s upload is kept with its size in pixels, as the agent’s uploads are.
Every image asset your sources list carries meta.width and meta.height:
CE.SDK sizes a placed image from them, and the agent frames a logo at its
aspect only when it knows it. The SDK tells your sources the size of every
image the agent stores, but an upload in your editor goes through CE.SDK’s
uploadFile action to your route, and the SDK never sees it. So read the size
where you have the bytes: imageSizeOf(bytes, file.type) from
@imgly/codesign-sdk reads it from the file’s header, with no image decoder,
in the browser before the upload as above or in your route on the server. A
search that meets an image without a size fails with invalid_asset (see
Image Sizes in the API
Reference).
The Designs Panel#
The source that lists your designs (the id you gave as listSourceId in your
design store)
is a plain CE.SDK source, and CE.SDK applies one of its assets as it applies any
asset, which places a design as an empty block. If your designs panel should
open a design instead, give that source an applyAsset (CE.SDK’s own member)
that does what your product does.
What Fails Silently#
| Mistake | What the person sees | Test |
|---|---|---|
No resolver for your own scheme (plugins added with CE.SDK’s addPlugin alone) |
Images show as nothing; no error | Open a design with an image in your scheme and check that engine.block.getState(id).type is not 'Error' ('FileFetch' names a fetch that failed) |
A design loaded before addPluginModules |
The same | The same |
| An archive saved as a scene string | Images an import packed into the design are gone at the next open | Import a Photoshop file (PSD) with an image no source keeps, edit it in the editor, save, reopen |
| A pasted image saved in a scene string | It dangles after a reload | Paste an image, save, reload |
| An editor on another CE.SDK version | The design loses settings at the next save, with no error | Open a design in an editor of another version, expect loadDesign to throw invalid_options; save from one, expect 409 |
| An unconditional save | The agent’s edit made while the editor was open is overwritten; nobody is told | Open a design, let the agent edit it, save in the editor, expect 412 |
| An upload route that keeps no image size | Nothing in the editor; the agent’s next search fails with invalid_asset, naming the source and the upload |
Upload an image in the editor, then run testAssetSource on the source it landed in |
Next Steps#
- Connect Your Storage — build the design store and the plugin module your editor shares with the agent.
- Concurrent Edits — handle versions and parallel calls, and work on a copy for approval.
- Versions and Upgrades — move your editor and your executors to a new CE.SDK together.
- API Reference —
look up every option of
@imgly/codesign-sdk/cesdk-utils.