Search Docs
Loading...
Skip to content

Host the Executor

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:

  1. Run the image with the hardened settings and a token of your own (Run the Image Hardened).
  2. Run one executor for each conversation that is open at the same time (Run Enough Executors).
  3. Give each conversation a new container, and drop the restart policy (A New Container for Each Conversation).
  4. Replace the quickstart’s acquire with one that gives each conversation its own executor, and call codesign.release() when the conversation is done (Find a Conversation’s Executor).
  5. 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).
  6. 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:

Terminal window
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 (or CESDK_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 with api.img.ly when it starts. A container that cannot reach api.img.ly within 10 seconds, or whose key is not accepted, exits and logs the reason, for example could not reach the license server https://api.img.ly/activate: ….
  • EXECUTOR_TOKEN is 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 kind executor_unauthorized. The image refuses to start without it. GET /health needs 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 --inspect in NODE_OPTIONS, or with any of the session settings EXECUTOR_ONE_SESSION, EXECUTOR_MAX_SESSIONS, EXECUTOR_IDLE_MS, and EXECUTOR_MAX_LIFETIME_MS set, because the image fixes them.
  • PORT is 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:

Terminal window
docker buildx imagetools inspect imgly/codesign-executor:1.83.0

Limits 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 --rm and 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 /health answers with result.bound false and result.accepting true
  • 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 to url plus a path.
  • { url, fetch }: also how to reach that executor, when a plain HTTP request to url cannot, such as a platform that reaches each container through its own API. fetch takes and answers what the global fetch does.
  • undefined: no executor can be had now. The call fails with executor_unreachable.

acquire must keep two rules:

  1. 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.
  2. 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. plugins lists the CE.SDK plugins every engine session applies; googleFonts() is IMG.LY’s Google Fonts catalog. gateway: false leaves out image generation. Without a designs store, designs live in the runner’s memory; without uploadExport, the model cannot export, and only your own codesign.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 sets conversation only where it is still empty, which only one runner can win. Clear a row when its executor is replaced.
  • Conversation ids from several tenants. acquire gets 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:

  1. For a new conversation, or one whose executor has exited, acquire creates a Job from the manifest below and records which conversation it serves (in a table every runner shares, if you run several runners).
  2. It waits until the pod has an IP and GET http://<pod IP>:8080/health answers accepting: true and bound: false.
  3. It returns { url: 'http://<pod IP>:8080' }. Your runner runs inside the cluster, so it can reach the pod’s IP.
  4. For a conversation whose pod still runs, it returns the same url from its record, with no call to the cluster.
  5. When the executor exits, its Job finishes, and ttlSecondsAfterFinished makes 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/v1
kind: Job
metadata:
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, so acquire reads /health itself before it hands the pod out.
  • User. The image runs as UID and GID 1000, so runAsNonRoot can check it.
  • Requests and limits are equal: one vCPU and 2 GiB (Run Enough Executors).
  • Leave shareProcessNamespace off. It makes another process the pod’s first, and the image refuses to start under it. The pod needs no /tmp volume: 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/v1
kind: NetworkPolicy
metadata:
name: codesign-executor
spec:
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-After after 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.