Type a brief and get a finished PNG and an editable design file from a design agent on Mastra, in around 3 minutes, depending on the model and how hard the brief is.
To have a coding agent, such as Claude Code or Cursor, set up this kit for you, give it this prompt:
Open https://img.ly/docs/cesdk/codesign/starter-kits/quickstart-mastra-cdsqms.mdand set up this starter kit for me.What It Does#
You run one command with a brief, such as “A square Instagram post for a spring sale on ceramic mugs”. An AI model designs it, looks at what it made, and improves it, then the kit writes two files:
out/design.png, a picture of the design.out/design.imgly, the design itself with its images and fonts, which you can open and edit in CE.SDK’s web editor ( Your Editor). CE.SDK is IMG.LY’s design engine and editor.
Start from this kit if your product runs a design agent on its servers: a chat, a job queue, or an API. Two things run on your machine:
- The executor, a Docker container from the image
imgly/codesign-executor, which IMG.LY publishes. It holds CE.SDK’s engine and runs the short JavaScript programs the model writes to change the design, in its own container, apart from your keys and your data. You write no code for it. - The agent runner,
design.mjs, the kit’s code and later yours. It gets the design tools from the CoDesign SDK,@imgly/codesign-sdk, and hands them to an agent on Mastra, a TypeScript agent framework. A tool is a function the model can ask to call, such as one that changes the design or renders a preview. The agent runs the agent loop: it calls the model, runs each tool the model asks for, and sends the result back, until the model is done. Then the script exports the design.
The model runs through IMG.LY’s AI gateway, a service that forwards model calls to the model provider and charges them to your IMG.LY account, so you need no account with a model provider.
Pre-requisites#
- Docker, to run the executor – Download
- Node.js v22+ with npm, pnpm, or yarn – Download
- A CoDesign license key. Contact sales to get a CoDesign license key. That one key starts the design engine, pays for each exported file, and pays for the model through the gateway.
Get Started#
Step 1: Clone the Repository#
git clone https://github.com/imgly/starterkit-codesign-quickstart-mastra-js-node.gitgit clone https://github.com/imgly/starterkit-codesign-quickstart-mastra-js-node.gitnpx degit imgly/starterkit-codesign-quickstart-mastra-js-node starterkit-codesign-quickstart-mastra-js-nodenpx degit imgly/starterkit-codesign-quickstart-mastra-js-node starterkit-codesign-quickstart-mastra-js-nodeWhat is inside:
starterkit-codesign-quickstart-mastra-js-node/├── design.mjs # The agent runner: prepares the tools, hands them to a Mastra agent, exports the design├── .env.template # The variables to copy into .env├── README.md # How to run the kit├── LICENSE.md # IMG.LY's license, the same as the CoDesign SDK's├── package.json # The SDK and Mastra's core, at exact versions; `npm run executor` starts the executor├── tsconfig.json # `npm run check` type-checks design.mjs└── tsconfig.base.json # The compiler settings tsconfig.json extendsRead design.mjs after your first run; How It Works below explains it.
Step 2: Install Dependencies#
cd starterkit-codesign-quickstart-mastra-js-node
npm installcd starterkit-codesign-quickstart-mastra-js-node
npm installcd starterkit-codesign-quickstart-mastra-js-node
pnpm installcd starterkit-codesign-quickstart-mastra-js-node
pnpm installcd starterkit-codesign-quickstart-mastra-js-node
yarncd starterkit-codesign-quickstart-mastra-js-node
yarnThe kit has no lock file, so use the package manager you prefer:
package.json pins each direct dependency to an exact version. It pins
@imgly/codesign-sdk to the same version as the executor
image’s tag in its executor script. Keep the two equal, because the SDK
and the executor must come from the same build
( Versions and Upgrades).
Step 3: Set Your Keys#
Copy .env.template to .env and fill in one variable: IMGLY_CESDK_LICENSE
holds your CoDesign license key (the variable keeps CE.SDK’s name).
EXECUTOR_TOKEN, the token your runner and the executor share, is already
filled in:
IMGLY_CESDK_LICENSE=<your CoDesign license key>EXECUTOR_TOKEN=codesign-quickstart-tokenThe executor script passes .env to the executor, and the start script
to design.mjs, so the two always have the same token. Two optional
variables are described in .env.template:
| Variable | What it is |
|---|---|
MODEL |
The gateway’s model id. Default @openrouter/anthropic/claude-opus-5. Any model OpenRouter serves works as @openrouter/ and its OpenRouter id, such as @openrouter/anthropic/claude-haiku-5.5. The gateway lists its own ids with your key: curl -H "Authorization: Bearer $IMGLY_CESDK_LICENSE" https://gateway.img.ly/v1/models. |
EXECUTOR_URL |
The executor’s address. Default http://localhost:8080. |
Step 4: Start the Executor#
npm run executor
curl http://localhost:8080/healthnpm run executor
curl http://localhost:8080/healthpnpm run executor
curl http://localhost:8080/healthpnpm run executor
curl http://localhost:8080/healthyarn executor
curl http://localhost:8080/healthyarn executor
curl http://localhost:8080/healthThe executor script runs IMG.LY’s image in Docker with the variables from
.env, on port 8080 of your machine only. The first start downloads the
image, about 600 MB on disk, and shows no progress meanwhile; run
docker pull imgly/codesign-executor:1.84.0 first to see it. curl prints a line like this one:
{"result":{"sessions":0,"accepting":true,"bound":false,...}}If /health does not answer, run docker logs codesign-executor-mastra to see why.
The executor stops at start when the license key is missing or cannot be
activated, or when it cannot reach api.img.ly.
After you change .env, run the executor script again: it replaces the
container, which reads .env only when it starts.
Step 5: Design Something#
npm start – “A square Instagram post for a 20% spring sale on ceramic mugs. Calm, premium, lots of white space.”npm start – “A square Instagram post for a 20% spring sale on ceramic mugs. Calm, premium, lots of white space.”pnpm start “A square Instagram post for a 20% spring sale on ceramic mugs. Calm, premium, lots of white space.”pnpm start “A square Instagram post for a 20% spring sale on ceramic mugs. Calm, premium, lots of white space.”yarn start “A square Instagram post for a 20% spring sale on ceramic mugs. Calm, premium, lots of white space.”yarn start “A square Instagram post for a 20% spring sale on ceramic mugs. Calm, premium, lots of white space.”A run takes around 3 minutes, depending on the model and how hard the brief is. You see a line for each tool call as it happens, a ✓ and the tool’s name, as the model reads IMG.LY’s design rules, changes the design, and looks at a render, a picture of the design; a ✗ marks a call that failed. Then you see the model’s one-sentence summary and a line with the number of steps, the seconds, and the tokens used. The last line is:
Wrote out/design.png and out/design.imglyOpen out/design.png to see the design. out/design.imgly opens in CE.SDK’s
web editor, which you embed in a page of your product
( Your Editor). Use exactly the CE.SDK version the
executor runs, which the SDK exports as CESDK_VERSION: an older or newer
editor that saves the file drops what the other version wrote
( Versions and Upgrades).
To stop and remove the executor, run npm run executor:stop.
Run It Again#
The executor serves one conversation: your id for one chat or job, which
design.mjs sets to quickstart on every run. At the end of a run, the
script calls release(), which closes the design on the executor. Half a
minute later the executor exits, and Docker starts it again, ready for your
next run.
A run that starts in the few seconds while the container restarts fails with
executor_unreachable. Wait until curl http://localhost:8080/health answers
again.
Before you run the executor anywhere but your machine, follow Host the Executor.
What It Costs#
Each run’s PNG export and the model’s tokens are charged to the IMG.LY account
behind your key; the .imgly file, the edits, and the previews are free
( What It Costs).
How It Works#
design.mjs does five things. The excerpts below are from it, and the whole
file follows them.
- It calls the model through IMG.LY’s gateway. The gateway at
https://gateway.img.ly/v1speaks OpenAI’s Chat Completions, the request format most model providers accept. Your license key is its API key. Mastra reaches any such service from the agent’smodelsetting: aproviderIdof your choice, the gateway’smodelId, its address asurl, and the key asapiKey. The kit needs no model provider package. - It prepares the tools with
prepareCoDesign.conversationis your id for the work. Every design of a conversation opens on one executor that serves no other conversation, because the model’s code runs there; see Security.executors: remoteExecutors({ acquire, token })says where a conversation’s executor is.acquireis a function you write. Here it always answers the container on port 8080. In production, it answers a new container for each new conversation, and the same one while the conversation lasts.pluginslists the CE.SDK plugins the engine applies. A plugin adds asset sources: searchable lists of what a design can use, such as photos, typefaces, and colors.googleFonts()is IMG.LY’s Google Fonts catalog, the smallest useful list.designis the id of the design the tools work on. With nodesignsstore, the design lives in the runner’s memory for the run.gateway: falseleaves out image generation. The model still runs through the gateway. The option is required, so you decide.
const codesign = prepareCoDesign({ // Your id for this job. Every design of a conversation opens on one executor // that serves no other conversation. Every run here is yours, so all share one. conversation: 'quickstart', executors: remoteExecutors({ // The executor for a conversation. Here there is one; a product starts a // new one for each conversation. acquire: () => ({ url: process.env.EXECUTOR_URL || 'http://localhost:8080' }), token: process.env.EXECUTOR_TOKEN }), // The CE.SDK plugins the engine applies: everything the agent can search and // place. Google Fonts is the smallest useful list; your own plugins go here. plugins: [googleFonts()], design: `quickstart-${Date.now()}`, // any id; with no `designs` store, the design lives in memory gateway: false // no image generation; the model still runs through the gateway});- It hands the tools to Mastra. Each SDK tool is
{ name, description, inputSchema, call }, and Mastra’screateToolturns each one into a Mastra tool, keyed by its name.createTooltakes the input’s JSON Schema as it is, so the kit needs no schema library such as zod, which Mastra’s own examples use. A failed call is thrown, so Mastra reports it to the model as a failed call, and the model can try again.toModelOutputmarks the renders as images, not as text.
// The SDK's tools in Mastra's shape. Mastra takes their JSON Schema as it is; no zod.const tools = Object.fromEntries( codesign.tools.map((t) => [ t.name, createTool({ id: t.name, description: t.description ?? '', inputSchema: t.inputSchema, execute: async (args, { abortSignal }) => { const result = await t .call(args, { signal: abortSignal }) .catch((err) => { console.log(`✗ ${t.name} (${err.kind ?? err.name})`); // rejected: the executor or your store failed throw err; }); console.log(`${result.isError ? '✗' : '✓'} ${t.name}`); if (result.isError) throw new Error(textOf(result.content)); // the model reads it as a failed call return result.content; }, // Renders go to the model as images, not as text. A call whose arguments // fail the schema never runs: Mastra hands over its { error, message }. toModelOutput: (output) => ({ type: 'content', value: Array.isArray(output) ? /** @type {import('@imgly/codesign-sdk').ToolResult['content']} */ ( output ).map((p) => p.type === 'text' ? { type: 'text', text: p.text } : { type: 'media', mediaType: p.mimeType, data: p.data } ) : [ { type: 'text', text: /** @type {{ message?: string }} */ (output).message ?? JSON.stringify(output) } ] }) }) ]));- It lets the model see its renders. Chat Completions allows no images
inside a tool result, and Mastra sends a render there as base64 text, a
long encoded string the model cannot look at. The kit’s
imagesAfterToolResultsis a Mastra processor, a function that edits the messages before each model call (itsprocessLLMRequesthook). It moves each render into a user message after the tool result. It changes only what goes to the model on that call. Without that step nothing fails, but the model designs without seeing its work.
// Chat Completions has no images in a tool result: move each render into a// user message after it, so the model sees its work. Left out, nothing fails;// the model designs blind. Mastra runs this before every model call./** @type {import('@mastra/core/processors').InputProcessor} */const imagesAfterToolResults = { id: 'images-after-tool-results', processLLMRequest: ({ prompt }) => { const out = []; let images = []; const flush = () => { if (images.length === 0) return; out.push({ role: 'user', content: [ { type: /** @type {const} */ ('text'), text: 'Attached image(s) from tool result:' }, ...images ] }); images = []; }; for (const message of prompt) { if (message.role !== 'tool') { flush(); out.push(message); continue; } out.push({ ...message, content: message.content.map((part) => { if (part.type !== 'tool-result' || part.output.type !== 'content') return part; const kept = []; for (const item of part.output.value) { if (item.type === 'media' && item.mediaType.startsWith('image/')) images.push({ type: /** @type {const} */ ('file'), mediaType: item.mediaType, data: item.data }); else kept.push(item); } if (kept.length === part.output.value.length) return part; return { ...part, output: { type: /** @type {const} */ ('content'), value: kept.length > 0 ? kept : [ { type: /** @type {const} */ ('text'), text: '(see attached image)' } ] } }; }) }); } flush(); return { prompt: out }; }};- It runs the agent.
new Agenttakes anid, aname, the model, the tools, the processor, and the system prompt asinstructions, withcodesign.instructionsafter your own: they tell the model how to use the tools, so your prompt names none of them.agent.generate(task, { maxSteps: 60 })lets the model take up to 60 steps; Mastra’s default of 5 ends a design run long before the design is done. When the model is done, the script exports the PNG and the.imglyfile and callsrelease().
const designer = new Agent({ id: 'designer', name: 'Designer', instructions: `You design marketing visuals. When you are done, say in one sentence what you made.\n\n${codesign.instructions}`, // IMG.LY's AI gateway speaks OpenAI's Chat Completions; your CoDesign license key is its API key. model: { providerId: 'imgly', modelId: process.env.MODEL || '@openrouter/anthropic/claude-opus-5', url: 'https://gateway.img.ly/v1', apiKey: process.env.IMGLY_CESDK_LICENSE }, tools, inputProcessors: [imagesAfterToolResults]});// logger: false, or Mastra logs a failed model call as a 50-line object; the run's one-line cause below says enough.const mastra = new Mastra({ agents: { designer }, logger: false });const agent = mastra.getAgent('designer');The whole file
// A brief in, a finished design out: Mastra runs the model through IMG.LY's gateway, the engine runs in one local executor.import { mkdir, writeFile } from 'node:fs/promises';
import { googleFonts, prepareCoDesign, remoteExecutors} from '@imgly/codesign-sdk';import { Agent } from '@mastra/core/agent';import { Mastra } from '@mastra/core/mastra';import { createTool } from '@mastra/core/tools';
const brief = process.argv[2];const task = `${brief}\n\nDesign this and reply once it is done. No one can answer questions during this run.`;const missing = [ ...(brief ? [] : ['a brief (the first argument)']), ...['IMGLY_CESDK_LICENSE', 'EXECUTOR_TOKEN'].filter((n) => !process.env[n])];if (missing.length > 0) { console.error( `Missing ${missing.join(', ')}.\nUsage: npm start -- "<brief>", with the variables of .env.template set in .env` + (process.env.IMGLY_CESDK_LICENSE ? '' : '\nContact sales (https://img.ly/forms/contact-sales/) to get a CoDesign license key.') ); process.exit(1);}
const codesign = prepareCoDesign({ // Your id for this job. Every design of a conversation opens on one executor // that serves no other conversation. Every run here is yours, so all share one. conversation: 'quickstart', executors: remoteExecutors({ // The executor for a conversation. Here there is one; a product starts a // new one for each conversation. acquire: () => ({ url: process.env.EXECUTOR_URL || 'http://localhost:8080' }), token: process.env.EXECUTOR_TOKEN }), // The CE.SDK plugins the engine applies: everything the agent can search and // place. Google Fonts is the smallest useful list; your own plugins go here. plugins: [googleFonts()], design: `quickstart-${Date.now()}`, // any id; with no `designs` store, the design lives in memory gateway: false // no image generation; the model still runs through the gateway});
const textOf = (parts) => parts .map((p) => (p.type === 'text' ? p.text : '')) .join('\n') .trim();
// The SDK's tools in Mastra's shape. Mastra takes their JSON Schema as it is; no zod.const tools = Object.fromEntries( codesign.tools.map((t) => [ t.name, createTool({ id: t.name, description: t.description ?? '', inputSchema: t.inputSchema, execute: async (args, { abortSignal }) => { const result = await t .call(args, { signal: abortSignal }) .catch((err) => { console.log(`✗ ${t.name} (${err.kind ?? err.name})`); // rejected: the executor or your store failed throw err; }); console.log(`${result.isError ? '✗' : '✓'} ${t.name}`); if (result.isError) throw new Error(textOf(result.content)); // the model reads it as a failed call return result.content; }, // Renders go to the model as images, not as text. A call whose arguments // fail the schema never runs: Mastra hands over its { error, message }. toModelOutput: (output) => ({ type: 'content', value: Array.isArray(output) ? /** @type {import('@imgly/codesign-sdk').ToolResult['content']} */ ( output ).map((p) => p.type === 'text' ? { type: 'text', text: p.text } : { type: 'media', mediaType: p.mimeType, data: p.data } ) : [ { type: 'text', text: /** @type {{ message?: string }} */ (output).message ?? JSON.stringify(output) } ] }) }) ]));
// Chat Completions has no images in a tool result: move each render into a// user message after it, so the model sees its work. Left out, nothing fails;// the model designs blind. Mastra runs this before every model call./** @type {import('@mastra/core/processors').InputProcessor} */const imagesAfterToolResults = { id: 'images-after-tool-results', processLLMRequest: ({ prompt }) => { const out = []; let images = []; const flush = () => { if (images.length === 0) return; out.push({ role: 'user', content: [ { type: /** @type {const} */ ('text'), text: 'Attached image(s) from tool result:' }, ...images ] }); images = []; }; for (const message of prompt) { if (message.role !== 'tool') { flush(); out.push(message); continue; } out.push({ ...message, content: message.content.map((part) => { if (part.type !== 'tool-result' || part.output.type !== 'content') return part; const kept = []; for (const item of part.output.value) { if (item.type === 'media' && item.mediaType.startsWith('image/')) images.push({ type: /** @type {const} */ ('file'), mediaType: item.mediaType, data: item.data }); else kept.push(item); } if (kept.length === part.output.value.length) return part; return { ...part, output: { type: /** @type {const} */ ('content'), value: kept.length > 0 ? kept : [ { type: /** @type {const} */ ('text'), text: '(see attached image)' } ] } }; }) }); } flush(); return { prompt: out }; }};
const designer = new Agent({ id: 'designer', name: 'Designer', instructions: `You design marketing visuals. When you are done, say in one sentence what you made.\n\n${codesign.instructions}`, // IMG.LY's AI gateway speaks OpenAI's Chat Completions; your CoDesign license key is its API key. model: { providerId: 'imgly', modelId: process.env.MODEL || '@openrouter/anthropic/claude-opus-5', url: 'https://gateway.img.ly/v1', apiKey: process.env.IMGLY_CESDK_LICENSE }, tools, inputProcessors: [imagesAfterToolResults]});// logger: false, or Mastra logs a failed model call as a 50-line object; the run's one-line cause below says enough.const mastra = new Mastra({ agents: { designer }, logger: false });const agent = mastra.getAgent('designer');
/** A failure in one line: the SDK error's kind and the first line of its message, not a stack or a request body. */const cause = (err) => `${err?.kind ? `${err.kind}: ` : ''}${String(err instanceof Error ? err.message : err).split('\n')[0]}`;
/** How many blocks the pages hold, read with a free edit: none when the model only asked a question. */const blocksOnPages = async () => ( await codesign.edit(`let n = 0;for (const page of await engine.design.findByType('page')) n += (await engine.design.getChildren(page)).length;return n;`) ).value;
// The free .imgly first: when the billed PNG export fails, or the run ends early, the design the model made is kept.async function save(withPNG) { let file = 'out/design.imgly'; try { await mkdir('out', { recursive: true }); await writeFile(file, await codesign.export('imgly')); if (!withPNG) return console.error(`Wrote ${file}, the design so far.`); if ((await blocksOnPages()) === 0) { console.error( 'The page has no blocks, so no PNG was exported (each export is charged). If the model asked a question, put the answer into the brief, and run again.' ); process.exitCode = 1; return; } file = 'out/design.png'; await writeFile(file, await codesign.export('png')); console.log('Wrote out/design.png and out/design.imgly'); } catch (err) { console.error(`Could not write ${file}: ${cause(err)}`); process.exitCode = 1; }}
const started = Date.now();try { const run = await agent.generate(task, { maxSteps: 60 }); console.log(`\n${run.steps.at(-1)?.text ?? run.text}`); // run.text joins every step's text const { inputTokens, outputTokens } = run.totalUsage ?? {}; console.log( `${run.steps.length} steps, ${Math.round((Date.now() - started) / 1000)} s, ` + `${inputTokens ?? '?'} input / ${outputTokens ?? '?'} output tokens` ); if (run.finishReason !== 'stop') { console.error(`The model stopped early (${run.finishReason}).`); process.exitCode = 1; } await save(run.finishReason === 'stop');} catch (err) { console.error(`The run failed: ${cause(err)}`); process.exitCode = 1; await save(false);} finally { await codesign.release(); // ends the design's session now; the engine closes about 30 s later}What to Replace#
| Piece | In the kit | In your product |
|---|---|---|
| The agent runner | design.mjs, run once from a terminal with a brief |
your server or job, with any agent framework, on the gateway with your license key |
| The tools | codesign.tools |
the same |
| The executor | one container from npm run executor, shared by every run |
IMG.LY’s executor image, a new container for each conversation, answered by acquire |
| The design | the runner’s memory, then two files in out/ |
your database, through designs ( Connect Your Storage) |
| Fonts and media | googleFonts() |
your own CE.SDK plugin in plugins ( Bring Your Brand) |
To grow the script into your product:
- To start from a customer’s file, such as a Photoshop file (PSD), a PDF, or
an
.imglyfile, callcodesign.load(bytes)before the model runs. - To let the model hand files to a person, pass
uploadExport: a function that stores an exported file and answers where the person gets it. - To let the model generate images, pass
gateway: { bearer }, a function that returns your key.
For each of these options, see the API Reference.
Troubleshooting#
The Executor Does Not Start#
docker: open .env: no such file or directory. Copy.env.templateto.envin the kit’s folder and set your key.The container name "/codesign-executor-mastra" is already in use, or the container keeps restarting anddocker logs codesign-executor-mastrashowsno CoDesign license key. The container still has the.envit was started with. Runnpm run executoragain: it replaces the container./healthdoes not answer. Rundocker logs codesign-executor-mastra. The executor stops at start when the license key is missing or cannot be activated, or when it cannot reachapi.img.ly.
The Run Fails#
-
node: .env: not found. Copy.env.templateto.envin the kit’s folder. -
Missing a brief (the first argument). Pass the brief after the command, in quotes, as in step 5. -
Missing IMGLY_CESDK_LICENSE, EXECUTOR_TOKEN. Set both in.env. -
The script uses another key or token than the one in
.env.npm startreads.envwith Node’s--env-file, which never overrides a variable already exported in your shell. Unset it there, for exampleunset IMGLY_CESDK_LICENSE. -
The page has no blocks, so no PNG was exported. Usually the model asked a question instead of designing, and nobody answers during a run. Put the answer into the brief, and run again. No export was charged. -
missing or wrong executor token. The executor answered 401: it was started with anotherEXECUTOR_TOKEN. After you change the token, runnpm run executoragain, so the executor gets it. -
executor_unreachable. The executor is not running, or the run started while it restarts after the last run. Wait untilcurl http://localhost:8080/healthanswers. -
The gateway answers 402
insufficient_credits. The account behind the key is out of credits. The script stops with the error. -
The model stopped early. The model reached the 60-step limit or stopped for another reason. No PNG was exported; the design so far is inout/design.imgly. -
Could not write out/design.pngwith the reason. The model’s run finished, the PNG export failed, and the design is kept inout/design.imgly. For the reasons an export fails, see Troubleshooting.
For more failures and their fixes, see Troubleshooting.
Next Steps#
- Quickstart — the kit for another agent framework
- Generate Designs — turn the script into an HTTP API on your server
- Add a Design Chat — let people design in a chat in your product
- Host the Executor — run the executor in production, with a new container for each conversation