Run the executor in production, hardened, with a new container for each conversation and outbound traffic limited to the hosts it needs. Exports are charged to your IMG.LY account.
This guide takes the executor from the local container a
quickstart kit starts with npm run executor to
production. The TypeScript on this page
is type-checked against the SDK; the shell commands and Kubernetes manifests
are not run. For what is supported, see
Status.
What the Executor Is#
The executor is an HTTP server that IMG.LY publishes as the Docker image
imgly/codesign-executor:1.83.0. It holds CE.SDK, IMG.LY’s design
engine, and runs the short JavaScript programs the AI model writes to change a
design. It also charges every file it exports to your IMG.LY account
(Billing). Your agent runner is your own code: it calls the
model in a loop, keeps designs in your storage, and calls the executor over
HTTP. Both terms, and the rest of CoDesign’s, are in
Terminology.
You run the image unchanged. You do not build it, add nothing to it, and write no executor of your own. What varies per product or tenant, such as brand fonts, colors, and where your images live, reaches the executor from your runner with each engine session as CE.SDK plugin modules (JavaScript files that add fonts, images, and colors to an engine): the same plugins your browser editor applies ( Bring Your Brand). Your designs never live on the executor: your runner reads and writes them in your own store ( Connect Your Storage), and the engine holds a copy of the one it works on.
Two words this page uses. A conversation is one piece of work your
product gives the agent: one chat, one batch job, one API request. Your runner
names it with an id of its own, prepareCoDesign({ conversation }). An
engine session is the engine working on one design of that conversation.
The first tool call on a design opens it, and later calls on that design
reuse it.
The settings below keep each conversation apart from every other one, from your network, and from your cloud’s credentials. For what the model’s code can reach, and where each key lives, see Security.
From the Quickstart to Production#
The quickstart runs one executor on your machine for development, with
npm run executor: a plain docker run with --restart always. The
container keeps its record of which conversation it serves (its binding,
explained below) in memory, so it comes back unbound after each run. It has
none of the hardened settings, no control of its outbound traffic, and a token
anyone can read in the kit. Its acquire function, the function in your
runner that names a conversation’s executor, always returns the one address.
For production, change these things, in this order:
- Run the image with the hardened settings and a token of your own (Run the Image Hardened).
- Run one executor for each conversation that is open at the same time (Run Enough Executors).
- Give each conversation a new container, and drop the restart policy (A New Container for Each Conversation).
- Replace the quickstart’s
acquirewith one that gives each conversation its own executor, and callcodesign.release()when the conversation is done (Find a Conversation’s Executor). - Let only your runner reach the executor’s port, and block all outbound traffic except the hosts the executor needs (Allow Only the Hosts the Executor Needs).
- Pin the SDK and the image to the same version, and upgrade both together ( Versions and Upgrades).
On Kubernetes, every step has its own form, in Run It on Kubernetes.
Run the Image Hardened#
Pull the image tagged with the same version as the SDK your runner installs, then run one container per conversation, removed when it exits:
docker pull imgly/codesign-executor:1.83.0
export IMGLY_CESDK_LICENSE=<your CoDesign license key>export EXECUTOR_TOKEN=$(openssl rand -hex 32) # a secret the container and the runner share
docker run -d --rm -p 127.0.0.1:8080:8080 \ --read-only --tmpfs /tmp:rw,noexec,nosuid,size=512m \ --cap-drop ALL --security-opt no-new-privileges \ --pids-limit 256 --memory 2g --cpus 1 \ -e IMGLY_CESDK_LICENSE -e EXECUTOR_TOKEN \ imgly/codesign-executor:1.83.0
curl http://localhost:8080/health/health answers with a JSON status once the executor is up (its fields are
in A New Container for Each Conversation). A container that cannot start exits and logs the reason, and
--rm removes it, log included: for a first try, leave out -d --rm and read
the log in your terminal. 127.0.0.1 keeps the port on your own machine; in
production, only your runner may reach it.
| Setting | Why |
|---|---|
--rm |
Docker removes the container when it exits after its conversation. Start a new one for the next. |
--read-only |
The model’s code cannot change the image’s files, and nothing it writes outlives the container. |
--tmpfs /tmp:…,size=512m |
Scratch space in memory, gone with the container. Optional: the engine also works without it. |
--cap-drop ALL |
Removes the Linux privileges a container gets by default. The executor needs none of them. |
--security-opt no-new-privileges |
No program in the container can gain privileges, even one marked setuid. |
--pids-limit 256 |
Stops code that starts processes until the machine runs out. |
--memory 2g, --cpus 1 |
A conversation that fills memory or loops ends only its own executor, and cannot starve other ones. |
IMGLY_CESDK_LICENSE(orCESDK_LICENSE) is your CoDesign license key itself. Contact sales to get a CoDesign license key. A value of 128 characters or more, which is a license file or a grant (a short-lived license IMG.LY issues for one engine) rather than a key, is refused at start. The executor activates the key withapi.img.lywhen it starts. A container that cannot reachapi.img.lywithin 10 seconds, or whose key is not accepted, exits and logs the reason, for examplecould not reach the license server https://api.img.ly/activate: ….EXECUTOR_TOKENis a secret you make yourself, one for all your executors and runners. Your runner sends it as a bearer token, and the executor answers 401 to a different one, which your runner sees as the error kindexecutor_unauthorized. The image refuses to start without it.GET /healthneeds no token, so your platform’s health check can call it.- Do not add
--init, and do not wrap the image’s command in a shell or in tini. The server that holds your license key must be the container’s first process, as the image’s own user (UID 1000) and without a debugger, or it refuses to start and logs why. The reason is in Why Your License Key Is Safe in the Container. It also refuses to start as root, with--inspectinNODE_OPTIONS, or with any of the session settingsEXECUTOR_ONE_SESSION,EXECUTOR_MAX_SESSIONS,EXECUTOR_IDLE_MS, andEXECUTOR_MAX_LIFETIME_MSset, because the image fixes them. PORTis the port it listens on, 8080 by default.- Run the image built for the host’s CPU (x64 or arm64; IMG.LY publishes both). An x64 image emulated on an arm64 machine, such as Apple silicon, starts but every render fails with “Could not create offscreen canvas”.
To be sure every executor runs the same build, you can pin the image by its
digest instead of its tag (imgly/codesign-executor@sha256:…), or record the
digest each deploy pulled. This command prints it:
docker buildx imagetools inspect imgly/codesign-executor:1.83.0Limits the Executor Sets#
The executor sets its own limits. These cannot be changed:
- A design’s engine session ends after 3 minutes without a call.
- The engine closes 30 seconds after its last engine session ended, or 30 minutes after it started however busy (the call in flight finishes first). Then the executor exits.
- A single call ends after 5 minutes. The executor answers it with HTTP 422,
an error the model reads (your own calls reject with the kind
executor_failed), and then exits, so the runner does not send the same runaway code again; the next call loads the design on a new executor. - The engine ends at 1.5 GB of memory.
- A container that gets no engine session within 10 minutes exits.
Still set a deadline per container from outside, as a backstop. Make it
longer than the executor’s own limits: at least 45 minutes, the 10 minutes a
container may wait for its first session, the 30-minute engine limit, and the
5 minutes a call in flight may still take. On Kubernetes, that is the
activeDeadlineSeconds of the executor’s Job. A conversation still working when that deadline stops its container moves to
a new one on its next call and loads its design there from your store.
Run Enough Executors#
An executor serves one conversation. It runs one engine, and every design of that conversation opens on it in turn, one call at a time, so a conversation never needs more than one engine’s memory however many designs it opens. So the number of executors you run is the number of conversations that can be open at the same time. To estimate it, count your peak of active chats or jobs: each holds its executor until 3 minutes and 30 seconds after its last call, or until you release it.
Give each executor one vCPU and 2 GB of memory. Renders are CPU-bound, so less
than one vCPU makes every preview and export slower. The engine’s memory grows
with the designs a conversation opens, and the executor ends it at 1.5 GB, so
2 GB leaves room for the server beside it. Try your largest designs before you
size a production deployment. Each exported file passes through your runner’s
memory on its way to your uploadExport function, so size the runner for the
largest export you allow.
When acquire has no executor to give, it returns undefined, and the call
fails with a CoDesignError of kind executor_unreachable. Your product can
retry it for a while, or queue the turn. If you see that error often, run more
executors, and call codesign.release() for a conversation’s designs when
the conversation is done, so their engine sessions end at once instead of
after 3 idle minutes. Once the last engine session of the conversation has
ended, the executor exits 30 seconds later. So never release after every
turn: the next turn would wait for a new executor.
While an executor is exiting it answers 503. A call that opens a design and
finds it so waits up to 60 seconds for it to come back, then fails with kind
executor_capacity; any other call fails at once.
A New Container for Each Conversation#
The image binds to the first conversation that opens a design on it and
refuses every other with HTTP 409, which your runner sees as the error kind
executor_bound. GET /health says whether it is bound, without a token:
{ "result": { "sessions": 0, "accepting": true, "bound": false } }The answer has more fields than these three. When the conversation’s engine has closed, the executor exits.
Start a new container from the image for the next conversation, and never hand a used one to another customer. The reason is that cleaning up after model-written code misses things. Only a new container, with the image’s own files and a new process, is clean.
What your platform does when an executor exits decides what the next one gets:
| When an executor exits, your platform… | The next executor gets |
|---|---|
| starts a new container from the image | the image’s files and a new process, bound to nobody: clean |
restarts the same container (Docker’s --restart always) |
a new process, but every volume, including the one the image keeps its binding in, so it still serves only the conversation it served before. Without --read-only, also the files the last conversation wrote |
| restarts the container in the same Kubernetes pod | the image’s files, but the pod’s volumes, its /dev/shm, and its shared memory, even with a read-only root. With the binding in memory it comes back unbound, so it is not clean but looks clean to the next conversation |
So your platform must:
- start each container with the settings above, with
--rmand no restart policy, and with an address of its own that only your runner can reach, such as its own host port (-p 8081:8080) or its own pod - hand a container to a new conversation only after
GET /healthanswers withresult.boundfalse andresult.acceptingtrue - start a replacement when a container exits, so enough are ready. A pool of containers started ahead is fine, as long as each one serves one conversation and never returns to the pool.
- after you deploy a new build, replace every container still running the
old image. A conversation whose executor is lost and comes back on another
CE.SDK version, other abilities, or other asset sources ends: the call
rejects with
executor_changed, and calling again opens a new session on the new executor ( Versions and Upgrades).
The image keeps its binding in /var/lib/codesign-executor, a volume it
declares, which is why a restarted container stays bound. Where that folder
cannot be written (a read-only root with no volume there, as in a Kubernetes
pod), the executor keeps the binding in memory and says so in its log. That is
enough when the container is never restarted in place.
A restart policy therefore only suits trying the image out, where every
conversation is yours, as in the quickstart, which also keeps the binding in
memory (a tmpfs on /var/lib/codesign-executor) so that each restart is
unbound. Docker refuses --rm and --restart together.
Nothing reports a reused container that the binding does not catch: it looks
healthy either way. To check your platform, have one conversation’s code write
a file under /tmp and the next conversation look for it, and send a second
conversation to the first one’s executor: it must be refused with
executor_bound.
Find a Conversation’s Executor#
In your runner, remoteExecutors tells the SDK where your executors are. You give
it an acquire function. The SDK calls it with { conversation, signal }
before it opens an engine session for a design of the conversation, when it
ends one (codesign.release() must reach the executor that holds it), and
again when the executor was lost.
acquire returns:
{ url }: the executor’s address. The SDK sends its requests tourlplus a path.{ url, fetch }: also how to reach that executor, when a plain HTTP request tourlcannot, such as a platform that reaches each container through its own API.fetchtakes and answers what the globalfetchdoes.undefined: no executor can be had now. The call fails withexecutor_unreachable.
acquire must keep two rules:
- The same conversation gets the same executor while that executor lives, so a call finds its design already loaded and the binding does not refuse it.
- A new conversation gets an executor no other conversation used. If you
hand out one that served someone else, it refuses with
executor_bound. That is the safety net, not the plan.
If the conversation’s executor has exited, for example when the user comes back after its idle minutes, give the conversation a new, unused executor, as for a new conversation. The design loads there from your store. Never hand it a container that served someone else, and never wait for the old one to come back.
acquire runs on every engine session, so for a conversation that has an
executor it must be fast: a lookup in a map or one row of a table. Starting
a container for a new conversation may take seconds; a pool of containers
started ahead avoids that wait. When an executor is lost (it exited, crashed,
or cannot be reached), the SDK calls acquire again for the same
conversation and loads the design on the executor it answers from your store.
Every change was saved there after the call that made it, so nothing is lost
but the time of one load.
This pool hands out executors your platform started ahead, at fixed
addresses. It remembers which conversation holds which, and gives a new
conversation one that nobody holds and whose /health reports
bound: false. Your platform replaces each container that exits with a new
one at the same address, so the list never changes. Fixed addresses suit
Docker Compose or virtual machines; on Kubernetes a new pod gets a new
address (Run It on Kubernetes):
import type { AcquiredExecutor } from '@imgly/codesign-sdk';
/** * A pool over executors your platform started ahead, at `urls`. Each * conversation gets its own and keeps it; `release` gives a finished * conversation's back, so its entry does not stay forever. */export function executorPool(urls: readonly string[]): { acquire: (req: { conversation: string; signal?: AbortSignal; }) => Promise<AcquiredExecutor | undefined>; release: (conversation: string) => void;} { // conversation → the url of its executor. const held = new Map<string, string>();
// Whether an executor is up, bound to no conversation, and taking sessions. const unbound = async (url: string, signal?: AbortSignal) => { try { const response = await fetch(`${url}/health`, { ...(signal !== undefined ? { signal } : {}) }); const body = (await response.json()) as { result?: { bound?: boolean; accepting?: boolean }; }; return body.result?.bound === false && body.result.accepting === true; } catch { return false; // unreachable or starting: not one to hand out now } };
// One claim at a time, so two conversations never pick the same executor // while the first one's health check is still in flight. let queue: Promise<unknown> = Promise.resolve();
const claim = async (conversation: string, signal?: AbortSignal) => { const mine = held.get(conversation); // Rule 1: the same conversation, the same executor. If it was lost and // your platform replaced it at the same address, the new one binds to // this conversation on its first session. if (mine !== undefined) return mine; // Rule 2: a new conversation gets an executor nobody holds and that // reports `bound: false`. A container that served another conversation // reports `bound: true` until your platform replaces it. const taken = new Set(held.values()); for (const url of urls) { if (taken.has(url) || !(await unbound(url, signal))) continue; held.set(conversation, url); return url; } return undefined; // all taken: the call fails as executor_unreachable };
return { acquire: async ({ conversation, signal }) => { const picked = queue.then(() => claim(conversation, signal)); queue = picked.catch(() => undefined); const url = await picked; return url === undefined ? undefined : { url }; }, release: (conversation) => { held.delete(conversation); } };}Use it where your runner prepares the tools. When the job is done,
codesign.release() ends the design’s engine session, and pool.release()
removes the job from the pool’s record, so its address can go to a new
conversation once the replacement container reports bound: false:
import { googleFonts, prepareCoDesign, remoteExecutors, type CoDesign} from '@imgly/codesign-sdk';
import { executorPool } from './executor-pool.js';
// Yours: your agent loop over codesign.tools, given the brief.async function runYourAgent( _codesign: CoDesign, _brief: string): Promise<void> { throw new Error('yours to write');}
// The addresses of the executors your platform keeps started.const pool = executorPool(['http://executor-1:8080', 'http://executor-2:8080']);
export async function runJob(jobId: string, brief: string): Promise<void> { const codesign = prepareCoDesign({ // One job, one executor: no other job's code ever runs where this one's did. conversation: jobId, executors: remoteExecutors({ acquire: pool.acquire, // Sent as `Authorization: Bearer …`; must equal the executors' EXECUTOR_TOKEN. token: process.env.EXECUTOR_TOKEN }), plugins: [googleFonts()], gateway: false }); try { await runYourAgent(codesign, brief); } finally { // Ends the current design's engine session now (pass { design } for each other one the job opened); // the executor exits 30 seconds after the last one ends, and your platform replaces it. await codesign.release(); pool.release(jobId); }}- The example passes only the options it needs.
pluginslists the CE.SDK plugins every engine session applies;googleFonts()is IMG.LY’s Google Fonts catalog.gateway: falseleaves out image generation. Without adesignsstore, designs live in the runner’s memory; withoutuploadExport, the model cannot export, and only your owncodesign.export()calls export files. Both are optional ( API Reference). - The pool keeps its record in the runner’s memory, so it fits one runner
process. With several runner processes, keep it in a table every runner
shares, so two never claim the same executor: one row per executor
(
url,conversation), and a claim that setsconversationonly where it is still empty, which only one runner can win. Clear a row when its executor is replaced. - Conversation ids from several tenants.
acquiregets the conversation id and nothing else. If two of your tenants could ever use the same id, put the tenant in front:conversation: `${tenantId}.${chatId}`. - Never put a round-robin load balancer in front of executors, and do not rely on cookie-based “sticky sessions”, which some platforms move while scaling. Each executor needs an address of its own, or your platform must route by conversation.
- A platform in front of the executors. The executor always answers in
JSON. If a proxy or your container platform can answer instead, for
example when it cannot start a container, turn any answer of 500 or more
that is not JSON (and a 429 of its own) into a 502. The SDK counts a 502 or
504 as an executor it cannot reach: the call rejects with
executor_unreachable, and you can retry it. Any other answer that is not JSON fails the call with the text it got. - Names your platform shows. Where your platform names a container after the conversation, use a hash of the conversation id, so no chat id appears in its dashboard.
Allow Only the Hosts the Executor Needs#
Deny all outbound traffic from the executor except to these hosts:
| Host | What the executor fetches from it |
|---|---|
| your sources’ origins | each origin your plugin modules call: to search a tenant’s images and fonts, fetch them, and store uploads. For example https://api.acme.com, and no other part of your API |
| your asset host | the images and fonts of the tenant it serves |
| your font host | brand font files, if they are not on your asset host |
cdn.img.ly |
CE.SDK’s own assets, the Google Fonts catalog and its font files, CE.SDK’s catalogs when you list cesdkDefaults() or cesdkDemo(), and the emoji font |
api.img.ly |
license activation when the container starts, and the charge for each export |
The executor never calls your runner or another executor, so block those too,
and block your cloud’s metadata address (169.254.169.254).
The model’s code can open any connection the network allows, so the list keeps your designs and your cloud’s credentials inside ( Security). The image cannot restrict its own traffic, so enforce the list outside the container, with a firewall or an egress proxy.
With an egress proxy, set HTTPS_PROXY (and HTTP_PROXY, NO_PROXY as you
need) and NODE_USE_ENV_PROXY=1 on the container. Without
NODE_USE_ENV_PROXY, Node.js ignores the proxy variables, the license
activation goes direct and fails behind the allow-list, and the container
exits at start. A proxy that re-signs HTTPS needs the certificate of its
certificate authority (CA), by path, in NODE_EXTRA_CA_CERTS ( the image’s environment variables).
A missing cdn.img.ly fails when an engine session opens: the call rejects
with plugin 0 (ly.img.google-fonts): initialize threw: Google Fonts: could not load …. You can serve a copy of the catalog from a host of your own with
googleFonts({ baseURL }); the font files it lists still load from
cdn.img.ly. A session given an image or font address it cannot reach names
it in every edit, preview, and export. Run a render and an import through
your allow-list before you rely on it.
Inbound, let only your runner reach the executor’s port, with a firewall rule or your platform’s network policy. Whoever holds the executor token and reaches the port can open engine sessions there, so the network rule keeps a leaked token from being enough on its own.
Run It on Kubernetes#
The SDK ships no Kubernetes controller. Starting, finding, and removing
executors is code you write in acquire, against your cluster’s API. The
shape that keeps every rule above is one Job per conversation:
- For a new conversation, or one whose executor has exited,
acquirecreates a Job from the manifest below and records which conversation it serves (in a table every runner shares, if you run several runners). - It waits until the pod has an IP and
GET http://<pod IP>:8080/healthanswersaccepting: trueandbound: false. - It returns
{ url: 'http://<pod IP>:8080' }. Your runner runs inside the cluster, so it can reach the pod’s IP. - For a conversation whose pod still runs, it returns the same
urlfrom its record, with no call to the cluster. - When the executor exits, its Job finishes, and
ttlSecondsAfterFinishedmakes Kubernetes delete the Job and its pod. Clear the conversation’s record then, or when its next call finds the pod gone.
A Job with backoffLimit: 0 and restartPolicy: Never runs its container
once. A Deployment, StatefulSet, or DaemonSet cannot: they allow only
restartPolicy: Always, which restarts a used container in its pod. To save
the seconds a new pod takes to start, keep a few Jobs started ahead, and let
acquire claim one by giving it the conversation’s label; a claimed pod never
goes back. A pod nobody claims exits after 10 minutes, so start new ones as
they finish.
apiVersion: batch/v1kind: Jobmetadata: generateName: codesign-executor- # or a hash of the conversation id, never the id itself labels: { app: codesign-executor }spec: backoffLimit: 0 # an executor that exited is never started again activeDeadlineSeconds: 2700 # the 45-minute backstop ttlSecondsAfterFinished: 60 # deletes the Job and its pod once the executor exits template: metadata: labels: { app: codesign-executor } spec: restartPolicy: Never automountServiceAccountToken: false # the pod holds no cluster credentials containers: - name: executor image: imgly/codesign-executor:1.83.0 ports: [{ containerPort: 8080 }] envFrom: [{ secretRef: { name: executor } }] # IMGLY_CESDK_LICENSE, EXECUTOR_TOKEN env: - { name: HTTPS_PROXY, value: 'http://egress-proxy:3128' } # your egress proxy - { name: NODE_USE_ENV_PROXY, value: '1' } readinessProbe: httpGet: { path: /health, port: 8080 } periodSeconds: 1 resources: requests: { cpu: '1', memory: 2Gi } limits: { cpu: '1', memory: 2Gi } securityContext: runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 readOnlyRootFilesystem: true allowPrivilegeEscalation: false capabilities: { drop: [ALL] } seccompProfile: { type: RuntimeDefault }- Readiness. The probe tells Kubernetes the server is up. It checks the
status code only, not
bound, soacquirereads/healthitself before it hands the pod out. - User. The image runs as UID and GID 1000, so
runAsNonRootcan check it. - Requests and limits are equal: one vCPU and 2 GiB (Run Enough Executors).
- Leave
shareProcessNamespaceoff. It makes another process the pod’s first, and the image refuses to start under it. The pod needs no/tmpvolume: the engine works without one. - Processes. Kubernetes limits processes per pod only for a whole node,
with the kubelet’s
podPidsLimit: a setting your platform team owns, not part of the pod.
Give the executors’ pods a NetworkPolicy that lets in only your runner, and lets out only your egress proxy and DNS:
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: codesign-executorspec: podSelector: { matchLabels: { app: codesign-executor } } policyTypes: [Ingress, Egress] ingress: - from: [{ podSelector: { matchLabels: { app: your-runner } } }] ports: [{ port: 8080 }] egress: - to: [{ podSelector: { matchLabels: { app: egress-proxy } } }] ports: [{ port: 3128 }] - to: [{ namespaceSelector: {}, podSelector: { matchLabels: { k8s-app: kube-dns } } }] ports: [{ port: 53, protocol: UDP }, { port: 53, protocol: TCP }]A NetworkPolicy matches addresses, not host names, so it cannot allow
cdn.img.ly and api.img.ly by name. Either send the executor’s traffic
through an egress proxy that allows only the hosts above, as these manifests
do (the image passes HTTPS_PROXY and NODE_USE_ENV_PROXY to the engine
too), or use a network plugin (CNI) whose policies allow domain names.
Billing#
The executor charges your IMG.LY account for every file it exports, inside the container, before the file leaves it. Your runner holds no billing credential; it only has to let a refused export through to your product.
What Is Charged#
Every export of a file is charged, whoever asked for it: the model, whose
export then hands the file to your uploadExport function, or your own
codesign.export() call. This covers PDF, PDF/X (the PDF standard for
print), PNG, JPEG, WebP, SVG, and HTML. The count comes from the exported file:
- A PDF counts its pages.
- An image of one page counts one. An image of the whole design counts the design’s pages: a PNG of a three-page design counts 3.
- An SVG or HTML file counts one.
Each count is priced in credits, the same credits your IMG.LY account holds for AI generation. The prices and your balance are in your IMG.LY dashboard.
Saving the design itself (.imgly, the scene), previews, edits, and failed
renders are free. Video and audio exports are refused
( Status).
A render retried after a lost connection is charged once, and so is the same version of a design exported again in the same format with the same options. An export your runner stopped waiting for before the charge is not charged.
When an Export Is Refused#
The executor refuses an export when:
- your account has no credits left
- your license key does not include CoDesign. A plain CE.SDK key starts the engine, but every export is refused. Contact sales to get a CoDesign license key.
- the export is larger than 16,000 px on its longer side, 500 pages, or 150 MB, or is a PDF the executor cannot count (one that stores its objects compressed)
- Metering, IMG.LY’s billing service at
api.img.ly, does not accept the charge
The export then fails with the error kind export_refused and a message, in
words for your user, that says which. Retrying does not help. When the model
asked for the export, the tool call rejects with that error and uploadExport
is not called; your own codesign.export() calls receive the same error.
Show the message, and an upgrade prompt if you sell one.
Keep api.img.ly on your allow-list for the container’s whole life:
- Until Metering has answered the container once, a charge that gets no answer
refuses the export, naming the route (
https://api.img.ly/exports/claim). So an egress rule that lets the license activation through but blocks/exports/*refuses every export. - After that first answer, an outage of Metering (no answer, a 5xx, a 408, or
a 429 with
Retry-Afterafter the retries) lets the export through uncharged, rather than stopping your product.
Next Steps#
- Quickstart — run one executor and one design on your machine.
- Security — see what the model’s code can reach, and where your keys live.
- Troubleshooting — fix a container that does not start or an export that is refused.
- API Reference — look up
remoteExecutors,release, and the errors.