Custom AI endpoint
The request format the image editor sends and the response format it expects.
Your server receives generation requests and returns the result for the requested operation. Configure the connection in AI providers.
Address security
Use HTTPS for every server and remote media address you pass to the editor. The only HTTP exceptions are http://localhost, http://127.0.0.1 and http://[::1], for development on your own computer. Relative paths and storage keys must resolve to an HTTPS address outside local development.
Use HTTPS for endpointUrl, captions endpoints, job status and cancellation addresses, and generated media returned by your server. The former proxyUrl name follows the same rule. A requestFn owns its network calls and must also use secure addresses.
Hosted requests that carry your license key, including hosted captions, are refused over HTTP to another computer. For other addresses, HTTPS is the integration rule; the SDK does not enforce it on every option.
Request format
endpointUrl receives JSON by POST, except for caption uploads. A requestFn receives the same request object with an AbortSignal. Neither transport receives your SDK license key.
A request without a models list uses its existing provider request format. The built-in picture request is:
{
"type": "image", "prompt": "A red fox in snow",
"input": { "operation": "image", "prompt": "A red fox in snow" },
"options": { "action": "create", "provider": "…", "count": 1 }
}provider is shown as … here. Its real value comes from the request builder. Your server owns provider selection and API mapping. Classic custom providers can build their own options.
| Field | Type | Description |
|---|---|---|
type | string | The result category used for routing. |
prompt | string | The outer prompt supplied by the request builder. |
input | AIInput | The operation-specific request. |
options | Optional object | The classic provider’s settings; absent on the models route. |
Operations
Read input.operation to identify the requested work.
| Operation | Request type | Input |
|---|---|---|
image | image | prompt, optional aspectRatio. |
imageEdit | image | prompt, source, optional mask and sourceSize. |
sticker | image | Wrapped prompt, style, count. |
svgPath | SVGPath | prompt. |
text | text | prompt. |
textRewrite | text | text, instruction. |
A source is { url } or { dataUrl }, never both. Picture edits from the editor send the picture in source.dataUrl. Read that field; do not assume source.url exists. The optional mask has white editable areas and black preserved areas.
Requests with models
A configured models list sends a checked selection in input.generation without options.
{
"type": "image", "prompt": "A red fox in snow",
"input": {
"operation": "image", "prompt": "A red fox in snow",
"generation": {
"contract": 1, "catalogId": "fast-images",
"modelId": "vendor-fast-image-1", "revision": "2026-10-11",
"settings": { "size": "1024x1024" }
}
}
}| Selection field | Type | Description |
|---|---|---|
contract | 1 | The selection contract version. |
catalogId | Optional string | The model entry’s id, when supplied. |
modelId | Optional string | The model name your host supplied. |
revision | string | The configured or computed field revision. |
settings | Scalar-valued object | Validated settings keyed by sendAs or name. |
Unset and local-only fields are absent from settings. Bound settings can also appear on their corresponding input fields. The models route’s outer rewrite prompt is input.instruction; read input.text separately. A classic rewrite builder combines both into its outer prompt.
Route by model
Validate the model identity, revision and settings on your server before calling a provider. Client checks do not replace server validation, authorization, billing limits or idempotency storage.
Types
Derive the settings and request types from your configured model.
import { defineGenerationModel, type AIGenerationRequestOf, type AIGenerationSettingsOfModel } from '@layermetry/media-editor';
const model = defineGenerationModel({
label: 'Images', operation: 'image',
fields: [{ name: 'quality', label: 'Quality', options: ['low', 'high'] }],
});
export type Settings = AIGenerationSettingsOfModel<typeof model>;
export type Request = AIGenerationRequestOf<typeof model>;
export function readQuality(request: Request): Settings['quality'] {
return request.input.generation.settings.quality;
}Brand in requests
When the user leaves Use brand on, the request carries input.brand. It holds the id and name of the kit, the parts that were used, and addedToPrompt, the exact words that the editor added to the prompt, including the blank line before them. A server that words its own prompts can remove those words by an exact match, or read the named fields instead.
{
"input": {
"operation": "text",
"prompt": "A headline\n\nWrite in a warm voice.",
"brand": { "kitId": "northwind", "name": "Northwind Studio", "addedToPrompt": "\n\nWrite in a warm voice.", "used": ["voice"], "voice": "warm" }
}
}input.brand is missing when no brand is active, when ai.brand is false. A part that the person turned off is missing from the prompt and from input.brand. ai.brand.addToPrompt: false sends input.brand with addedToPrompt set to ''.
The name brand is reserved. A model field cannot be named brand, and a brand key in the model settings is removed before the editor adds its own. See Generate with brand for every field.
Response format
Return HTTP 200 with the operation result, or map your custom response in the model’s mapResponse.
| Operation | Result |
|---|---|
text, textRewrite | { items: [{ text: "Suggested words" }] }. |
image, imageEdit, sticker | { items: [{ url: "https://cdn.example.com/result.png" }] }. |
svgPath | { items: [{ pathData: "M10 10 L90 10 L50 90 Z" }] }. |
items must contain at least one object with the fields required by the operation. Both routes refuse another operation’s result, an empty list or malformed SVG path data with invalid-output. An optional top-level receipt records the model and job details. Sticker results reject known JPEG addresses because JPEG cannot preserve transparency.
Both the built-in providers and the models route read { items: [{ text: "Suggested words" }] }. A custom parseResponse or mapResponse must return the same result format.
Serve media addresses with a valid content type. Your storage's CORS rules must allow the origin of the page that runs the editor, including its scheme and port: https://localhost:3000 and http://localhost:3000 are different origins. The editor loads generated pictures with crossOrigin="anonymous", so that the canvas can still be exported. If the origin is not allowed, the browser refuses the picture, and the editor cannot show it or place it. Generated media is also downloaded for storage and export.
A complete endpoint
This Node.js fixture accepts picture requests with and without a models selection.
import { createServer } from 'node:http';
const PORT = 8787;
const ALLOWED_ORIGIN = 'http://localhost:5173';
const IMAGE_MODELS = {
'fast-images': { modelId: 'vendor-fast-image-1', revision: '2026-10-11', fields: ['size', 'quality'] },
'detailed-images': { modelId: 'vendor-detailed-image-2', revision: '2026-10-11', fields: [] },
};
const DEFAULT_IMAGE_MODEL = 'fast-images';
// Replace this fixture with your server-side provider call.
async function makePictures(modelId, prompt, settings) {
return ['https://cdn.example.com/pictures/sample.png'];
}
function send(response, status, body) {
response.writeHead(status, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
Vary: 'Origin',
});
response.end(JSON.stringify(body));
}
function refuse(response, status, code, message) {
send(response, status, { ok: false, error: { code, message } });
}
async function readJson(request) {
let length = 0;
const chunks = [];
for await (const chunk of request) {
length += chunk.length;
if (length > 2 * 1024 * 1024) throw new Error('Body exceeds 2 MiB');
chunks.push(chunk);
}
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}
async function answer(request, response) {
let body;
try { body = await readJson(request); }
catch { return refuse(response, 400, 'invalid-input', 'Expected JSON within 2 MiB.'); }
const input = body?.input;
if (input?.operation !== 'image' || typeof input.prompt !== 'string' || !input.prompt.trim()) {
return refuse(response, 400, 'invalid-input', 'Expected an image prompt.');
}
const generation = input.generation;
if (generation !== undefined && (generation === null || generation.contract !== 1)) {
return refuse(response, 400, 'invalid-input', 'Expected generation contract 1.');
}
const name = generation?.catalogId ?? DEFAULT_IMAGE_MODEL;
if (!Object.hasOwn(IMAGE_MODELS, name)) {
return refuse(response, 400, 'unsupported-model', 'Image model not offered.');
}
const model = IMAGE_MODELS[name];
if (generation && (generation.revision !== model.revision ||
(generation.modelId !== undefined && generation.modelId !== model.modelId))) {
return refuse(response, 400, 'invalid-input', 'Model settings have changed.');
}
const settings = generation?.settings ?? {};
if (typeof settings !== 'object' || settings === null || Array.isArray(settings) ||
Object.keys(settings).some((key) => !model.fields.includes(key)) ||
(settings.size !== undefined && !['1024x1024', '1536x1024'].includes(settings.size)) ||
(settings.quality !== undefined && !['low', 'medium', 'high'].includes(settings.quality))) {
return refuse(response, 400, 'invalid-input', 'Invalid image settings.');
}
try {
const imageUrls = await makePictures(model.modelId, input.prompt, settings);
send(response, 200, { items: imageUrls.map((url) => ({ url })) });
} catch {
refuse(response, 502, 'transport-failed', 'Image service unavailable.');
}
}
createServer(async (request, response) => {
const path = new URL(request.url, `http://localhost:${PORT}`).pathname;
if (request.method === 'OPTIONS' && path === '/ai') {
response.writeHead(204, {
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
Vary: 'Origin',
});
return response.end();
}
if (request.method === 'POST' && path === '/ai') return answer(request, response);
refuse(response, 404, 'invalid-input', 'Not found.');
}).listen(PORT, '127.0.0.1', () => console.log(`AI endpoint: http://localhost:${PORT}/ai`));Save the file as server.mjs, then start it with node server.mjs. It listens at http://localhost:8787/ai. The sample model’s revision is 2026-10-11.
This fixture returns a placeholder address. Replace makePictures with your provider call and a readable media address. Configure your page’s origin in ALLOWED_ORIGIN. Add authentication and your billing policy before production use.
Error codes
Return a non-2xx response with a known SDK code to identify a failure.
{ "ok": false, "error": {
"code": "rate-limited", "message": "Too many requests. Try again later.",
"requestId": "request-123"
} }An unknown provider code stays in error.providerError.code. The SDK maps HTTP 401 and 403 to auth-refused, HTTP 429 to rate-limited, and other unknown failures to transport-failed. See AI errors for every code and retry guidance.
providerError contains optional code, message, status and requestId. Request IDs can come from x-request-id, x-correlation-id or the body. Keep vendor details here; compare error.code for application logic.
Configure your own endpointUrl or requestFn. hosted: true is coming soon and available only with an enterprise licence from Layermetry.