# Brief to deck pdf intake
Source: /docs/.mintlify/skills/moda-api/recipes/brief-to-deck-pdf-intake
# Brief-to-deck (PDF intake)
**Problem:** A user uploads a brief PDF to your app. You want to produce a branded pitch deck from it and return a PPTX download URL.
## Primitives
* `POST /v1/uploads` — upload the PDF (multipart)
* `POST /v1/tasks` with `attachments: [{file_id, role: "source"}]` + `brand_kit_id` + `number_of_slides`
* Webhook OR polling to detect completion
* `POST /v1/canvases/{id}/export?format=pptx` — signed URL inline, or a `task_id` to poll
## TypeScript (Node 20+)
```ts theme={null}
// server/api/generate-deck.ts
import { FastifyInstance } from "fastify";
import fs from "node:fs";
const HEADERS = {
Authorization: `Bearer ${process.env.MODA_API_KEY!}`,
"Layovelle-Version": "2026-05-01",
};
export default async function (app: FastifyInstance) {
app.post("/generate-deck", async (req, reply) => {
const { pdfPath, userId } = (await req.body) as { pdfPath: string; userId: string };
// 1. upload the brief
const form = new FormData();
form.set("file", new Blob([fs.readFileSync(pdfPath)]), "brief.pdf");
const uploadRes = await fetch("https://api.moda.app/v1/uploads", {
method: "POST",
headers: { Authorization: HEADERS.Authorization, "Layovelle-Version": HEADERS["Layovelle-Version"] },
body: form,
});
const brief = await uploadRes.json(); // { id: "file_...", ... }
// 2. find default brand kit
const kits = await fetch("https://api.moda.app/v1/brand-kits", { headers: HEADERS }).then(r => r.json());
const kit = kits.data.find((k: any) => k.is_default);
// 3. start the design task
const task = await fetch("https://api.moda.app/v1/tasks", {
method: "POST",
headers: { ...HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({
prompt:
"Build a pitch deck from the attached brief. Use our brand styling. " +
"Prioritize real data and quotes from the brief; do not invent specifics.",
format: { category: "slides", width: 1920, height: 1080 },
number_of_slides: 10,
brand_kit_id: kit?.id,
attachments: [
{ file_id: brief.id, role: "source", label: "Brief" },
],
callback_url: "https://myapp.com/webhooks/moda",
idempotency_key: `deck:${userId}:${brief.id}`,
}),
}).then(r => r.json());
reply.send({
message: "Generating deck — takes 2–10 minutes. You'll get a notification when it's ready.",
task_id: task.id,
canvas_url: task.links?.canvas ?? null, // useful placeholder while it cooks
});
});
}
```
Webhook handler (abbreviated — full handler in [`webhook-receiver.md`](./webhook-receiver.md)):
```ts theme={null}
async function handleAsync(event: any) {
if (event.type !== "task.succeeded") return;
const canvas_id = event.data.result.canvas_id;
// 4. export — completed inline, or an in-progress handle to poll
let exp = await fetch(
`https://api.moda.app/v1/canvases/${canvas_id}/export?format=pptx`,
{ method: "POST", headers: HEADERS },
).then(r => r.json());
if (exp.status === "in_progress") {
const taskId = exp.task_id;
do {
await new Promise(r => setTimeout(r, (exp.retry_after_seconds ?? 5) * 1000));
exp = await fetch(
`https://api.moda.app/v1/canvases/${canvas_id}/export-status?task_id=${taskId}`,
{ headers: HEADERS },
).then(r => r.json());
} while (exp.is_terminal === false);
}
if (exp.status !== "completed") {
// retryable: a fresh export can still succeed — requeue instead of telling the user it failed.
if (exp.retryable) return void scheduleExportRetry(canvas_id, event.id);
await notifyUser(eventIdToUserId(event.id), `Deck export failed: ${exp.error ?? "unknown error"}`);
return;
}
// exp.url — signed URL valid 7 days
await notifyUser(
eventIdToUserId(event.id),
`Your deck is ready: ${event.data.result.canvas_url}\nPPTX: ${exp.url}`,
);
}
```
## Python (FastAPI + httpx)
```python theme={null}
# server/api/generate_deck.py
import os, httpx
from fastapi import APIRouter, UploadFile, File, Form
router = APIRouter()
HEADERS = {
"Authorization": f"Bearer {os.environ['MODA_API_KEY']}",
"Layovelle-Version": "2026-05-01",
}
@router.post("/generate-deck")
async def generate_deck(
file: UploadFile = File(...),
user_id: str = Form(...),
):
async with httpx.AsyncClient(
base_url="https://api.moda.app/v1", headers=HEADERS, timeout=60,
) as c:
# 1. upload
content = await file.read()
brief = (await c.post(
"/uploads",
files={"file": (file.filename, content, file.content_type)},
)).json()
# 2. default brand kit
kits = (await c.get("/brand-kits")).json()
kit_id = next((k["id"] for k in kits["data"] if k["is_default"]), None)
# 3. start task
task = (await c.post("/tasks", json={
"prompt": (
"Build a pitch deck from the attached brief. Use our brand styling. "
"Prioritize real data and quotes from the brief; do not invent specifics."
),
"format": {"category": "slides", "width": 1920, "height": 1080},
"number_of_slides": 10,
"brand_kit_id": kit_id,
"attachments": [
{"file_id": brief["id"], "role": "source", "label": "Brief"},
],
"callback_url": "https://myapp.com/webhooks/moda",
"idempotency_key": f"deck:{user_id}:{brief['id']}",
})).json()
return {
"message": "Generating deck — takes 2–10 minutes.",
"task_id": task["id"],
}
```
Webhook handler does the export:
```python theme={null}
import asyncio, json
@app.post("/webhooks/moda")
async def moda_webhook(...):
# ... verify signature (see webhook-receiver.md) ...
event = json.loads(body)
if event["type"] == "task.succeeded":
canvas_id = event["data"]["result"]["canvas_id"]
async with httpx.AsyncClient(base_url="https://api.moda.app/v1", headers=HEADERS) as c:
exp = (await c.post(
f"/canvases/{canvas_id}/export",
params={"format": "pptx"},
)).json()
if exp.get("status") == "in_progress": # slow render; poll it out
task_id = exp["task_id"]
while exp.get("is_terminal") is not True:
await asyncio.sleep(exp.get("retry_after_seconds") or 5)
exp = (await c.get(
f"/canvases/{canvas_id}/export-status",
params={"task_id": task_id},
)).json()
if exp.get("status") != "completed": # .get: an error envelope has no "status"
if exp.get("retryable"): # transient — requeue out of band
await schedule_export_retry(canvas_id, event["id"])
else:
await notify_export_failure(user_for_event(event["id"]), exp.get("error"))
return {"ok": True} # ack either way — never make Layovelle retry the webhook
await notify_user(user_for_event(event["id"]),
canvas_url=event["data"]["result"]["canvas_url"],
pptx_url=exp["url"])
return {"ok": True}
```
## Gotchas
* **`idempotency_key` encoding.** Using `{user_id}:{file_id}` means re-uploading the same PDF for the same user hits the same task (desirable — idempotent, no wasted compute). If you want a fresh task each time, include a timestamp.
* **Brief → `role: "source"`.** The agent extracts content. Passing it as `reference` would make the deck mimic the PDF's formatting — not what you want.
* **`number_of_slides` is a hint, not a hard cap.** If the brief is thin, the agent may produce fewer; if it's rich, marginally more.
* **Don't tell the user a retryable export failed.** The poll response's `retryable` separates a transient fault (requeue it) from a terminal one (the canvas content has to change). Acknowledge the webhook in both cases — retrying the *webhook* never fixes an export.
* **Export has two response shapes.** `{status: "completed", url}` when the render lands inside the \~20s wait budget, `{status: "in_progress", task_id}` when it doesn't — then poll `/canvases/{id}/export-status`. A ten-slide PPTX usually completes inline; don't rely on it.
* **You may not need the export call at all.** A programmatic design task auto-exports its result, and `task.succeeded` carries it at `data.result.export` (`{status, url, format, page_count}`) in the canvas's category-default format. Read that when PPTX is the category default.
* **Signed PPTX URL expires after 7 days.** Either surface it directly to the user (they'll click within minutes usually) or download + re-host yourself.
* **`callback_url` requires API-key auth.** This recipe runs server-side so that's fine. An OAuth / MCP caller can't set `callback_url` — they'd have to poll.
* **If `brand_kit_id` is null** (empty team), the design task still runs but without brand styling — or you can error out. Decide based on UX: for internal tools, off-brand is fine; for customer-facing, force the user to set up a brand kit first.
## See also
* [`../references/uploads.md`](../references/uploads.md) — multipart upload details
* [`../references/idempotency.md`](../references/idempotency.md) — key design
* [`../references/canvases-and-exports.md`](../references/canvases-and-exports.md) — export semantics
* [`webhook-receiver.md`](./webhook-receiver.md) — production-grade handler
* [`scheduled-generation.md`](./scheduled-generation.md) — cron-driven variant
# Bulk personalization
Source: /docs/.mintlify/skills/moda-api/recipes/bulk-personalization
# Bulk personalization
**Problem:** You have a CSV of 50 prospects. Produce one personalized follow-up deck per prospect, using an uploaded brief as the content source and the team's default brand kit. Collect the canvas URLs when they're done.
## Primitives
* `POST /v1/uploads` — upload the shared brief (once)
* `POST /v1/tasks` — fan out one task per prospect with `idempotency_key`
* `callback_url` webhook — receive terminal state per task (preferred) OR polling pool (fallback)
## TypeScript (Node 20+, `fetch`)
```ts theme={null}
import fs from "node:fs";
import Papa from "papaparse";
const HEADERS = {
Authorization: `Bearer ${process.env.MODA_API_KEY!}`,
"Layovelle-Version": "2026-05-01",
"Content-Type": "application/json",
};
const CALLBACK_URL = "https://myapp.com/webhooks/moda";
// 1. upload the brief once
const briefForm = new FormData();
briefForm.set("file", new Blob([fs.readFileSync("brief.pdf")]), "brief.pdf");
const brief = await fetch("https://api.moda.app/v1/uploads", {
method: "POST",
headers: { Authorization: HEADERS.Authorization, "Layovelle-Version": HEADERS["Layovelle-Version"] },
body: briefForm,
}).then(r => r.json());
// brief.id = "file_01HT9..."
// 2. fan out — one task per prospect
const csv = Papa.parse(fs.readFileSync("prospects.csv", "utf8"), { header: true });
const kits = await fetch("https://api.moda.app/v1/brand-kits", { headers: HEADERS }).then(r => r.json());
const defaultKit = kits.data.find((k: any) => k.is_default);
const results: { prospect: string; task_id: string }[] = [];
for (const row of csv.data as any[]) {
const res = await fetch("https://api.moda.app/v1/tasks", {
method: "POST",
headers: HEADERS,
body: JSON.stringify({
prompt: `Personalized follow-up deck for ${row.company}.
Prospect: ${row.contact_name}, ${row.contact_role}.
Their focus area: ${row.focus_area}.
Use the attached brief as the source of truth for our product claims.`,
format: { category: "slides", width: 1920, height: 1080 },
number_of_slides: 8,
brand_kit_id: defaultKit?.id,
attachments: [
{ file_id: brief.id, role: "source", label: "Master brief" },
],
callback_url: CALLBACK_URL,
idempotency_key: `prospect-deck:${row.id}`, // stable per prospect
}),
});
if (res.status === 429) {
// rate limited — respect Retry-After and retry this prospect
const waitSec = Number(res.headers.get("Retry-After") ?? 10);
await new Promise(r => setTimeout(r, waitSec * 1000));
// simpler: push back onto the queue; full rate-limit handling left as an exercise
}
const task = await res.json();
results.push({ prospect: row.company, task_id: task.id });
}
fs.writeFileSync("tasks.json", JSON.stringify(results, null, 2));
console.log(`Queued ${results.length} tasks. Webhook will deliver results.`);
```
Webhook handler (same shape as [`webhook-receiver.md`](./webhook-receiver.md)) looks up `event.data.id` in `tasks.json`, matches it to a prospect, writes the canvas URL to a CRM / database / Slack.
## Python (`httpx`)
```python theme={null}
import csv, os, httpx
HEADERS = {
"Authorization": f"Bearer {os.environ['MODA_API_KEY']}",
"Layovelle-Version": "2026-05-01",
}
with httpx.Client(base_url="https://api.moda.app/v1", headers=HEADERS, timeout=60) as c:
# 1. upload shared brief
with open("brief.pdf", "rb") as f:
brief = c.post(
"/uploads",
files={"file": ("brief.pdf", f, "application/pdf")},
).json()
# 2. find default brand kit
kits = c.get("/brand-kits").json()
default_kit_id = next(
(k["id"] for k in kits["data"] if k["is_default"]),
None,
)
# 3. fan out
results = []
with open("prospects.csv") as f:
for row in csv.DictReader(f):
resp = c.post("/tasks", json={
"prompt": (
f"Personalized follow-up deck for {row['company']}.\n"
f"Prospect: {row['contact_name']}, {row['contact_role']}.\n"
f"Their focus area: {row['focus_area']}.\n"
"Use the attached brief as the source of truth for our product claims."
),
"format": {"category": "slides", "width": 1920, "height": 1080},
"number_of_slides": 8,
"brand_kit_id": default_kit_id,
"attachments": [
{"file_id": brief["id"], "role": "source", "label": "Master brief"},
],
"callback_url": "https://myapp.com/webhooks/moda",
"idempotency_key": f"prospect-deck:{row['id']}",
})
if resp.status_code == 429:
# respect Retry-After then re-queue this prospect (omitted for brevity)
continue
task = resp.json()
results.append({"prospect": row["company"], "task_id": task["id"]})
# store task_id → prospect mapping for the webhook handler to look up
with open("tasks.json", "w") as f:
import json
json.dump(results, f, indent=2)
```
## Polling fallback (no webhook server)
If you can't run a webhook receiver, poll all 50 tasks in a concurrency-capped pool instead:
```python theme={null}
import asyncio, time
async def poll(c: httpx.AsyncClient, task_id: str):
while True:
t = (await c.get(f"/tasks/{task_id}")).json()
if t["status"] in {"succeeded", "failed", "canceled", "expired"}:
return t
await asyncio.sleep((t.get("retry_after_ms") or 3000) / 1000)
async def main(task_ids: list[str]):
sem = asyncio.Semaphore(8) # cap parallelism
async def guarded(tid):
async with sem:
return await poll(c, tid)
async with httpx.AsyncClient(base_url="https://api.moda.app/v1", headers=HEADERS) as c:
return await asyncio.gather(*(guarded(t) for t in task_ids))
```
## Gotchas
* **`idempotency_key` per prospect + run.** `"prospect-deck:{id}"` works if each prospect only gets one deck per logical run. If you re-run monthly, include the month: `"prospect-deck:{id}:2026-04"`.
* **Rate limits are real.** If you fan out 50 tasks in a tight loop, you may hit a per-key or per-team cap. Back off on `429` using `Retry-After`. Serialize if recurring.
* **`brand_kit_id` is the team default** by default (so you can technically omit it). Passing it explicitly makes the code's intent clear and future-proofs it if the team adds more kits.
* **Don't poll AND have a webhook.** Pick one. Doubling up wastes your rate budget and may trigger two Slack pings.
* **On task failure**, you need a way to surface which prospect failed. The `idempotency_key` or the mapping file is your link.
## See also
* [`../references/idempotency.md`](../references/idempotency.md)
* [`../references/uploads.md`](../references/uploads.md) — multipart vs from-URL
* [`webhook-receiver.md`](./webhook-receiver.md) — the webhook handler in isolation
* [`scheduled-generation.md`](./scheduled-generation.md) — single-task pattern for comparison
# Design to code ci
Source: /docs/.mintlify/skills/moda-api/recipes/design-to-code-ci
# Design-to-code in CI
**Problem:** You have a canonical Layovelle canvas that represents your design tokens (colors, fonts, radii, spacing variables). On every push to `main`, regenerate `tailwind.theme.ts` (or equivalent) from the canvas and commit the diff, so code always tracks design.
## Primitives
* `GET /v1/canvases/{id}/tokens` — structured JSON of colors / fonts / radii / variables
* A tiny codegen step in your CI (Node / Python / Deno)
* Commit + PR if the diff is non-empty
Scope used: `designs:read` only. Note that a key created in **Settings → Developer → REST API** has no scope picker — it carries the default grant (every scope except `admin`), so the CI key is a full-privilege credential even though this recipe only reads tokens. Give CI its own key, store it as a repository secret, and revoke it independently.
## TypeScript — GitHub Actions
`.github/workflows/sync-theme.yml`:
```yaml theme={null}
name: Sync design tokens
on:
push:
branches: [main]
schedule:
- cron: "0 9 * * 1" # also weekly on Monday 09:00 UTC
permissions:
contents: write
pull-requests: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- name: Regenerate theme
env:
MODA_API_KEY: ${{ secrets.MODA_API_KEY_READONLY }}
CANVAS_ID: cvs_01HT9WK8N3M2J4A5Z6P7Q8R9TV
run: node scripts/sync-theme.mjs
- name: Open PR if changed
uses: peter-evans/create-pull-request@v6
with:
branch: design-tokens-sync
title: "chore: sync design tokens from Layovelle"
commit-message: "chore: sync design tokens from Layovelle"
body: "Auto-generated from [canvas](https://layovelle.com/canvas/${{ env.CANVAS_ID }})."
```
`scripts/sync-theme.mjs`:
```js theme={null}
import fs from "node:fs";
const API = "https://api.moda.app/v1";
const HEADERS = {
Authorization: `Bearer ${process.env.MODA_API_KEY}`,
"Layovelle-Version": "2026-05-01",
};
const res = await fetch(`${API}/canvases/${process.env.CANVAS_ID}/tokens`, {
headers: HEADERS,
});
if (!res.ok) {
const err = await res.json().catch(() => null);
console.error("Layovelle fetch failed:", err?.error ?? res.statusText);
process.exit(1);
}
const { variables, colors, fonts, radii } = await res.json();
// Emit a predictable, diffable TS file.
const body = `// AUTO-GENERATED — edit the Layovelle canvas instead.
// Source: cvs_${process.env.CANVAS_ID}
// Generated: ${new Date().toISOString()}
export const theme = {
colors: {
${Object.entries(variables)
.filter(([, v]) => typeof v === "string" && v.startsWith("#"))
.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)},`)
.join("\n")}
},
palette: ${JSON.stringify(colors, null, 2).replace(/\n/g, "\n ")},
fonts: ${JSON.stringify(fonts, null, 2).replace(/\n/g, "\n ")},
radii: ${JSON.stringify(radii, null, 2).replace(/\n/g, "\n ")},
} as const;
`;
fs.writeFileSync("src/design/theme.ts", body);
console.log("Wrote src/design/theme.ts");
```
## Python — GitLab CI variant
`.gitlab-ci.yml`:
```yaml theme={null}
sync-theme:
image: python:3.12
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
- if: $CI_COMMIT_REF_NAME == "main"
before_script:
- pip install httpx
script:
- python scripts/sync_theme.py
- |
if ! git diff --quiet src/design/theme.py; then
git config user.email "ci@example.com"
git config user.name "CI"
git checkout -b design-tokens-sync
git add src/design/theme.py
git commit -m "chore: sync design tokens from Layovelle"
git push origin design-tokens-sync -o merge_request.create
fi
variables:
MODA_API_KEY: $MODA_API_KEY_READONLY
CANVAS_ID: cvs_01HT9WK8N3M2J4A5Z6P7Q8R9TV
```
`scripts/sync_theme.py`:
```python theme={null}
import os, sys, datetime, httpx
HEADERS = {
"Authorization": f"Bearer {os.environ['MODA_API_KEY']}",
"Layovelle-Version": "2026-05-01",
}
r = httpx.get(
f"https://api.moda.app/v1/canvases/{os.environ['CANVAS_ID']}/tokens",
headers=HEADERS,
timeout=30,
)
if r.status_code != 200:
print("Layovelle fetch failed:", r.json().get("error"))
sys.exit(1)
tokens = r.json()
generated_at = datetime.datetime.utcnow().isoformat(timespec="seconds") + "Z"
with open("src/design/theme.py", "w") as f:
f.write(f'''"""AUTO-GENERATED — edit the Layovelle canvas instead.
Source: {os.environ["CANVAS_ID"]}
Generated: {generated_at}
"""
COLORS = {tokens["variables"]!r}
PALETTE = {tokens["colors"]!r}
FONTS = {tokens["fonts"]!r}
RADII = {tokens["radii"]!r}
''')
print("Wrote src/design/theme.py")
```
## Why tokens, not `get_canvas`
`GET /v1/canvases/{id}/tokens` is a dedicated endpoint that returns structured JSON. Parsing tokens out of the pseudo-HTML from `GET /v1/canvases/{id}` works but is fragile — layer names / HTML shape can change without signaling a token change. Use the dedicated endpoint for CI.
## Making it diffable
* **Sort all arrays** before emitting — otherwise insertion order in the canvas creates spurious diffs on every run.
* **Emit a deterministic timestamp header** (or omit the timestamp entirely; git tells you when the file changed).
* **Name variables** in the Layovelle canvas — named variables land as keys in the `variables` object and become your `colors.primary`, `colors.background`, etc.
## Gotchas
* **Treat the CI key as full-privilege.** This recipe only uses `designs:read`, but a Settings-created key carries the default grant — a leak is not limited to token reads. Isolate by key (one per integration) and rotate on any suspicion.
* **Unknown response fields** may appear over time. Don't fail the build if the JSON has new keys — only fail if the keys you need are missing.
* **Cache-bust properly.** If your codegen reads other files (a base theme, palette overrides), include them in the cache key for the CI action.
* **Don't bypass PR review** by committing directly to `main`. PR the change — design tokens can have visual fallout.
* **The canvas must be team-accessible** to the API key's team. Share links do NOT grant design token reads in CI — use a canvas URL + a team-scoped key.
## See also
* [`../references/canvases-and-exports.md`](../references/canvases-and-exports.md) — tokens endpoint reference
* [`../references/authentication.md`](../references/authentication.md) — scopes
* `moda-mcp/references/gotchas.md` — its Design-to-code section, the IDE-side story for turning a canvas into code
# Export pipeline
Source: /docs/.mintlify/skills/moda-api/recipes/export-pipeline
# Export pipeline
**Problem:** Archive every canvas on the team as a PDF. Upload each to S3 / Google Drive. Skip canvases that have an in-flight design task.
## Primitives
* `GET /v1/canvases` — cursor-paginate all canvases
* `POST /v1/canvases/{id}/export?format=pdf` — returns a signed URL, or a `task_id` to poll
* Handle `409 canvas_active_job` — back off and retry
* Upload the file bytes to your storage of choice
## TypeScript (Node 20+)
```ts theme={null}
import fs from "node:fs";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const HEADERS = {
Authorization: `Bearer ${process.env.MODA_API_KEY!}`,
"Layovelle-Version": "2026-05-01",
};
const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.EXPORT_BUCKET!;
async function* listAllCanvases() {
let cursor: string | null = null;
for (;;) {
const u = new URL("https://api.moda.app/v1/canvases");
u.searchParams.set("limit", "100");
if (cursor) u.searchParams.set("cursor", cursor);
const { data, next_cursor } = await fetch(u, { headers: HEADERS }).then(r => r.json());
for (const c of data) yield c;
if (!next_cursor) return;
cursor = next_cursor;
}
}
async function exportWithRetry(canvasId: string, retries = 3): Promise<string | null> {
attempts: for (let attempt = 1; attempt <= retries; attempt++) {
const res = await fetch(
`https://api.moda.app/v1/canvases/${canvasId}/export?format=pdf`,
{ method: "POST", headers: HEADERS },
);
if (res.ok) {
let exp = await res.json();
if (exp.status === "in_progress") { // slow render; poll it out
const taskId = exp.task_id;
for (;;) {
await new Promise(r => setTimeout(r, (exp.retry_after_seconds ?? 5) * 1000));
const poll = await fetch(
`https://api.moda.app/v1/canvases/${canvasId}/export-status?task_id=${taskId}`,
{ headers: HEADERS },
);
if (!poll.ok) continue attempts; // 429/5xx polling — not an export failure
exp = await poll.json();
if (exp.is_terminal !== false) break; // terminal, or a shape we don't recognise
}
}
if (exp.status !== "completed") {
console.error(`Canvas ${canvasId}: export failed`, exp.error, exp.error_code);
if (exp.retryable) continue; // transient — spend another attempt
return null; // terminal for this canvas's content
}
return exp.url;
}
if (res.status === 409) { // canvas_active_job
const wait = Number(res.headers.get("Retry-After") ?? 10) * 1000;
console.log(`Canvas ${canvasId}: task running; waiting ${wait / 1000}s`);
await new Promise(r => setTimeout(r, wait));
continue;
}
if (res.status === 429) { // rate limit
const wait = Number(res.headers.get("Retry-After") ?? 10) * 1000;
await new Promise(r => setTimeout(r, wait));
continue;
}
const body = await res.json().catch(() => null);
console.error(`Canvas ${canvasId}: export failed`, body?.error);
return null;
}
return null;
}
for await (const canvas of listAllCanvases()) {
const exportUrl = await exportWithRetry(canvas.id);
if (!exportUrl) continue;
const bytes = Buffer.from(await (await fetch(exportUrl)).arrayBuffer());
await s3.send(new PutObjectCommand({
Bucket: BUCKET,
Key: `canvases/${canvas.id}.pdf`,
Body: bytes,
ContentType: "application/pdf",
Metadata: { "canvas-name": canvas.name, "updated-at": canvas.updated_at },
}));
console.log(`Archived ${canvas.name} (${canvas.id})`);
}
```
## Python (`httpx`)
```python theme={null}
import os, time, httpx, boto3
HEADERS = {
"Authorization": f"Bearer {os.environ['MODA_API_KEY']}",
"Layovelle-Version": "2026-05-01",
}
s3 = boto3.client("s3")
BUCKET = os.environ["EXPORT_BUCKET"]
def iter_canvases(c):
cursor = None
while True:
params = {"limit": 100}
if cursor:
params["cursor"] = cursor
resp = c.get("/canvases", params=params).json()
yield from resp["data"]
cursor = resp["next_cursor"]
if cursor is None:
return
def export_with_retry(c, canvas_id, attempts=3):
for _ in range(attempts):
r = c.post(f"/canvases/{canvas_id}/export", params={"format": "pdf"})
if r.is_success:
exp = r.json()
polling_failed = False
if exp.get("status") == "in_progress": # slow render; poll it out
task_id = exp["task_id"]
while exp.get("is_terminal") is not True:
time.sleep(exp.get("retry_after_seconds") or 5)
poll = c.get(f"/canvases/{canvas_id}/export-status",
params={"task_id": task_id})
if not poll.is_success: # 429/5xx polling — not an export failure
polling_failed = True
break
exp = poll.json()
if polling_failed:
continue # re-POST the export (cache-first, so cheap)
if exp.get("status") != "completed": # .get: an error envelope has no "status"
print(f"export failed for {canvas_id}: {exp.get('error')} ({exp.get('error_code')})")
if exp.get("retryable"): # transient — spend another attempt
continue
return None # terminal for this canvas's content
return exp["url"]
if r.status_code in (409, 429):
wait = int(r.headers.get("Retry-After", 10))
time.sleep(wait)
continue
print(f"export failed for {canvas_id}: {r.json().get('error')}")
return None
return None
with httpx.Client(base_url="https://api.moda.app/v1", headers=HEADERS, timeout=120) as c:
for canvas in iter_canvases(c):
url = export_with_retry(c, canvas["id"])
if not url:
continue
# download and push to S3
content = httpx.get(url, timeout=120).content
s3.put_object(
Bucket=BUCKET,
Key=f"canvases/{canvas['id']}.pdf",
Body=content,
ContentType="application/pdf",
Metadata={"canvas-name": canvas["name"], "updated-at": canvas["updated_at"]},
)
print(f"Archived {canvas['name']} ({canvas['id']})")
```
## Gotchas
* **Export has two response shapes.** A cache hit or a quick render returns `{status: "completed", url}`; anything slower than the \~20s wait budget returns `{status: "in_progress", task_id}` and you poll `/canvases/{id}/export-status` until `is_terminal`. Full-team PDF archives hit the slow path routinely, so branch on `status` — reading `url` blindly gets you `null`.
* **A failed *poll* is not a failed export.** A 429 or 5xx on `/export-status` says nothing about the render — retry the poll (or re-POST the export, which is cache-first) instead of treating it as a terminal failure.
* **A failed export is not always final.** The poll response's `retryable` says whether a fresh attempt can succeed; `retryable: false` (e.g. `error_code: "no_renderable_content"`) means the canvas itself has to change first. Retrying those burns rate budget for nothing.
* **Signed URLs expire after 7 days.** Download and re-upload immediately — don't persist the signed URL itself.
* **`409 canvas_active_job`** is the retry signal when a design task is running on the canvas. Respect `Retry-After` (default 10s).
* **`429 rate_limited`** applies per-endpoint — exports have their own cap (25/min by default, raisable per-org; see [Usage Limits](/api-reference/usage-limits)). Respect `Retry-After`.
* **Cursor pagination is sequential.** Can't parallelize page fetches. Parallelize the **per-canvas work** within a page instead (cap to \~4–8 concurrent exports).
* **Scope requirements:** `canvases:read` for listing, `designs:export` for the export endpoint. Team membership required — a share-token-only caller can't export.
* **Don't export the same canvas twice in a row.** If you re-run this job often, compare `updated_at` against your archive and skip unchanged canvases.
## Variant: filter by search
If you only want canvases matching a pattern:
```python theme={null}
resp = c.get("/canvases/search", params={"q": "Q2 client decks", "limit": 100}).json()
for canvas in resp["canvases"]:
...
```
`/canvases/search` is the one **offset**-paginated endpoint: items are under `canvases` (not `data`), and you page with `offset + limit` while `has_more` is `true`.
## See also
* [`../references/canvases-and-exports.md`](../references/canvases-and-exports.md) — export semantics, 409 pattern
* [`../references/pagination.md`](../references/pagination.md)
* [`../references/errors.md`](../references/errors.md) — rate-limit handling
# Scheduled generation
Source: /docs/.mintlify/skills/moda-api/recipes/scheduled-generation
# Scheduled generation
**Problem:** A cron fires weekly. It should generate a branded status deck from yesterday's KPIs and post the canvas URL to a Slack channel. No human in the loop.
## Primitives
* `POST /v1/tasks` with `callback_url` + `idempotency_key`
* Webhook handler verifies signature and posts to Slack
* (Optional) `POST /v1/canvases/{id}/export` if you want a PPTX link, not just the canvas URL
## Pattern
Cron → start task with a stable `idempotency_key` → return immediately. The webhook fires 2–10 minutes later and does the Slack post.
## TypeScript (Node 20+)
Cron entry point:
```ts theme={null}
// cron/weekly-deck.ts — runs via GitHub Actions / Cloud Scheduler / your runner
const MODA_API_KEY = process.env.MODA_API_KEY!;
const CALLBACK_URL = "https://myapp.com/webhooks/moda";
const isoWeek = new Date().toISOString().slice(0, 10); // safe-enough stable key
const kpis = await fetchWeeklyKPIs();
const res = await fetch("https://api.moda.app/v1/tasks", {
method: "POST",
headers: {
Authorization: `Bearer ${MODA_API_KEY}`,
"Layovelle-Version": "2026-05-01",
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt: `Weekly status deck for the week of ${isoWeek}. Cover:
- Headline metrics: ${JSON.stringify(kpis.headline)}
- Product highlights this week
- Next-week plan`,
format: { category: "slides", width: 1920, height: 1080 },
number_of_slides: 8,
callback_url: CALLBACK_URL,
idempotency_key: `weekly-deck:${isoWeek}`,
}),
});
const task = await res.json();
// task.id = task_01HT9..., task.status = "queued" | "running", retry_after_ms: 3000
console.log(`Queued task ${task.id}; expect webhook to fire in 2–10 min`);
```
Webhook handler (Express):
```ts theme={null}
// server/webhooks/moda.ts
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.MODA_WEBHOOK_SECRET!;
const app = express();
// raw body needed for signature verification
app.post(
"/webhooks/moda",
express.raw({ type: "application/json" }),
async (req, res) => {
const ts = req.header("x-webhook-timestamp");
const sig = req.header("x-webhook-signature");
if (!ts || !sig) return res.status(400).end();
// replay protection
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(401).end();
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${ts}.${req.body.toString("utf8")}`)
.digest("hex");
const received = sig.replace(/^v1=/, "");
if (
!crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(received, "hex"))
)
return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
// dedupe on evt_… id (use Redis in production)
if (await alreadyProcessed(event.id)) return res.status(200).json({ ok: true });
await markProcessed(event.id);
res.status(200).json({ ok: true }); // 200 fast — process async
void processAsync(event);
},
);
async function processAsync(event: any) {
if (event.type === "task.succeeded") {
const { canvas_url } = event.data.result;
await postToSlack(`Weekly deck ready: ${canvas_url}`);
} else if (event.type === "task.failed") {
await postToSlack(
`Weekly deck failed: ${event.data.error?.message ?? "unknown"} ` +
`(request_id=${event.data.error?.request_id ?? "?"})`,
);
}
}
```
## Python (FastAPI + httpx)
Cron entry point:
```python theme={null}
# cron/weekly_deck.py
import datetime, os, httpx, json
MODA_API_KEY = os.environ["MODA_API_KEY"]
CALLBACK_URL = "https://myapp.com/webhooks/moda"
iso_week = datetime.datetime.utcnow().strftime("%Y-W%V")
kpis = fetch_weekly_kpis()
with httpx.Client(
base_url="https://api.moda.app/v1",
headers={
"Authorization": f"Bearer {MODA_API_KEY}",
"Layovelle-Version": "2026-05-01",
},
timeout=30,
) as c:
task = c.post("/tasks", json={
"prompt": f"""Weekly status deck for {iso_week}.
- Headline metrics: {json.dumps(kpis['headline'])}
- Product highlights this week
- Next-week plan""",
"format": {"category": "slides", "width": 1920, "height": 1080},
"number_of_slides": 8,
"callback_url": CALLBACK_URL,
"idempotency_key": f"weekly-deck:{iso_week}",
}).json()
print(f"Queued task {task['id']}; webhook fires in 2–10 min")
```
Webhook handler (FastAPI):
```python theme={null}
# server/webhooks.py
import hashlib, hmac, os, time
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
SECRET = os.environ["MODA_WEBHOOK_SECRET"]
processed: set[str] = set() # swap for Redis in production
@app.post("/webhooks/moda")
async def moda_webhook(
req: Request,
x_webhook_signature: str = Header(...),
x_webhook_timestamp: str = Header(...),
):
body = await req.body()
if abs(time.time() - int(x_webhook_timestamp)) > 300:
raise HTTPException(401, "stale timestamp")
expected = hmac.new(
SECRET.encode(),
f"{x_webhook_timestamp}.{body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, x_webhook_signature.removeprefix("v1=")):
raise HTTPException(401, "bad signature")
event = await req.json()
if event["id"] in processed:
return {"ok": True}
processed.add(event["id"])
# 200 fast; process async
enqueue(event) # e.g. Celery / Arq / RQ
return {"ok": True}
```
## Gotchas
* **Don't block the cron on the task.** Design tasks take 2–10 min, so a synchronous wait would time out — and the webhook fires anyway. Use the callback pattern.
* **`idempotency_key` must be stable and unique per cron fire.** `"weekly-deck:2026-W17"` is good. `"weekly-deck"` alone is a landmine (every week reuses the same key → same task every time).
* **`callback_url` is API-key-auth only.** The cron needs a real `moda_live_…` key.
* **Slow webhook handlers get retried.** Stay under 30s wall time by enqueueing async.
* **On `task.failed`**, check `data.error.retryable` — transient failures may retry inside Layovelle automatically; permanent ones won't. Either way, notify a channel — failures silently swallowed are the worst case.
## See also
* [`../references/webhooks.md`](../references/webhooks.md) — signing details, event types
* [`../references/idempotency.md`](../references/idempotency.md) — key design
* [`webhook-receiver.md`](./webhook-receiver.md) — the handler in isolation
# Webhook receiver
Source: /docs/.mintlify/skills/moda-api/recipes/webhook-receiver
# Webhook receiver
**Problem:** Stand up a production-grade endpoint that verifies Layovelle webhook signatures, rejects replays, deduplicates, acknowledges fast, and hands off event processing to a background worker.
## Primitives
* Raw request body (for HMAC)
* `X-Webhook-Signature` + `X-Webhook-Timestamp` headers
* Webhook signing secret (stored with the API key — **not** the API key itself)
* Event envelope `id` as the dedupe key
* Async worker (queue / task runner / Pub-Sub topic) for the actual work
## TypeScript — Express
```ts theme={null}
// server/webhooks/moda.ts
import express from "express";
import crypto from "node:crypto";
import { createClient } from "redis"; // use your dedupe store of choice
const app = express();
const SECRET = process.env.MODA_WEBHOOK_SECRET!;
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
// IMPORTANT: use express.raw here, NOT express.json — we need the exact bytes for HMAC.
app.post(
"/webhooks/moda",
express.raw({ type: "application/json", limit: "1mb" }),
async (req, res) => {
const tsHeader = req.header("x-webhook-timestamp");
const sigHeader = req.header("x-webhook-signature");
if (!tsHeader || !sigHeader) return res.status(400).end();
// 1. replay protection
const skew = Math.abs(Date.now() / 1000 - Number(tsHeader));
if (!Number.isFinite(skew) || skew > 300) return res.status(401).end();
// 2. signature verification
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${tsHeader}.${req.body.toString("utf8")}`)
.digest("hex");
const received = sigHeader.replace(/^v1=/, "");
const sigOk =
expected.length === received.length &&
crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(received, "hex"),
);
if (!sigOk) return res.status(401).end();
// 3. parse + dedupe on event id
const event = JSON.parse(req.body.toString("utf8"));
const dedupeKey = `moda:evt:${event.id}`;
const firstTime = await redis.set(dedupeKey, "1", { NX: true, EX: 60 * 60 * 24 * 7 });
if (!firstTime) return res.status(200).json({ ok: true }); // already handled
// 4. ack fast; process in the background
res.status(200).json({ ok: true });
void handleAsync(event);
},
);
async function handleAsync(event: any) {
try {
switch (event.type) {
case "task.succeeded": {
const { canvas_url } = event.data.result;
await notifySuccess(event.data.id, canvas_url);
break;
}
case "task.failed": {
const retryable = event.data.error?.retryable ?? false;
await notifyFailure(event.data.id, event.data.error?.message, retryable);
break;
}
case "task.canceled":
// handle as needed
break;
case "export.succeeded":
case "export.failed":
// declared in the taxonomy, but nothing emits them today — keep the
// arms so an unexpected delivery doesn't fall into the warn branch
break;
default:
// unexpected event type → log for ops, not for the user
console.warn("Unknown Layovelle event type", event.type, event.id);
}
} catch (e) {
console.error("Handler threw", e, "event=", event.id);
// do not re-throw — webhook was already acked
}
}
```
## Python — FastAPI
```python theme={null}
# server/webhooks/moda.py
import hashlib, hmac, json, os, time
from fastapi import FastAPI, Header, HTTPException, Request
from redis.asyncio import Redis
app = FastAPI()
SECRET = os.environ["MODA_WEBHOOK_SECRET"]
redis = Redis.from_url(os.environ["REDIS_URL"])
@app.post("/webhooks/moda")
async def moda_webhook(
req: Request,
x_webhook_signature: str = Header(...),
x_webhook_timestamp: str = Header(...),
):
body = await req.body()
# 1. replay protection
try:
skew = abs(time.time() - int(x_webhook_timestamp))
except ValueError:
raise HTTPException(401, "bad timestamp")
if skew > 300:
raise HTTPException(401, "stale timestamp")
# 2. signature verification
expected = hmac.new(
SECRET.encode(),
f"{x_webhook_timestamp}.{body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
received = x_webhook_signature.removeprefix("v1=")
if not hmac.compare_digest(expected, received):
raise HTTPException(401, "bad signature")
# 3. dedupe on event id
event = json.loads(body)
first_time = await redis.set(
f"moda:evt:{event['id']}", "1", nx=True, ex=60 * 60 * 24 * 7,
)
if not first_time:
return {"ok": True}
# 4. enqueue async work; return fast
await enqueue(event) # e.g. Celery, Arq, RQ, Pub/Sub
return {"ok": True}
async def enqueue(event: dict) -> None:
# Your enqueue implementation. Must return in < a few seconds.
...
```
## What to do per event type
```
task.succeeded → grab data.result.canvas_url, notify the user / CRM / Slack
task.failed → check data.error.retryable:
- retryable true: transient; task already dead-lettered after N attempts
- retryable false: permanent; surface to the user
task.canceled → quiet path; log only, or notify if cancellation was unexpected
export.succeeded → declared, not emitted today — poll GET /v1/canvases/{id}/export-status
export.failed → declared, not emitted today — same idea
```
Always log `data.error.request_id` on failures — it's the handle support will use to find your request.
## Gotchas
* **Use raw body** for HMAC. Express / FastAPI default JSON parsing hides the exact bytes. `express.raw` or `await req.body()` (not `await req.json()` before reading body).
* **Return 200 in \< 30s**, always. On a non-2xx or timeout, Layovelle makes up to 3 delivery attempts total (initial + 2 retries, \~1s then \~5s apart), then drops.
* **Dedupe on `event.id`**, not `event.data.id`. Same task can have multiple events (succeeded vs failed, though rare; future: export for the same task). Using `event.id` dedupes **retries of the same event**; using `task.id` would dedupe legitimate different events.
* **TTL your dedupe keys.** 7 days is plenty — Layovelle's retry window is minutes.
* **Reject stale timestamps.** 5 minutes is the conventional bound. Anything older is almost certainly a replay attack.
* **Use constant-time compare** (`timingSafeEqual` / `compare_digest`). Raw `==` leaks timing info.
* **HTTPS only.** Layovelle won't deliver to plain-HTTP `callback_url`.
* **Don't do work inside the handler.** Enqueue, then 200. If Postgres is down or Slack is slow, you want to 200 anyway and retry the downstream work yourself — not force Layovelle to retry.
* **Test with the wrong signature** in staging to make sure you reject. A handler that silently accepts unsigned bodies is the worst case.
## Local testing without public HTTPS
```
ngrok http 3000 # or `tailscale serve` / `cloudflared tunnel`
```
Point the task's `callback_url` at the ngrok HTTPS URL. Signing still works — the signing secret is per-API-key, not per-URL.
## See also
* [`../references/webhooks.md`](../references/webhooks.md) — full event list, retry curve
* [`scheduled-generation.md`](./scheduled-generation.md) — the cron that produces the task
* [`../references/authentication.md`](../references/authentication.md) — where the signing secret comes from
# Authentication
Source: /docs/.mintlify/skills/moda-api/references/authentication
# Authentication (REST API)
## Header
```
Authorization: Bearer moda_live_<suffix>
```
Every request. Always pair with `Layovelle-Version: 2026-05-01`.
## Creating a key
1. Open the Layovelle app → **Settings → Developer → REST API → Create Key**.
2. Name the key for the integration it belongs to ("CI Pipeline", "Slack bot").
3. Copy the key immediately — it's shown once.
4. Copy the **webhook signing secret** shown in the same banner. This is used to verify webhook payloads. You cannot retrieve it later.
**There is no scope picker on that screen.** A key created there carries the **default grant** — every recognized scope except `admin`. Treat it as broadly privileged: one key per integration, stored as a production secret, revoked the moment it is no longer needed.
Keys start with `moda_live_`. They are hashed at rest; only the prefix is safe to log. Lost a key? Revoke it and create a new one.
## Scopes (22)
Twenty-two scopes are recognized; twenty-one of them are in the default grant. `admin` is opt-in only and never granted by default.
| Scope | Grants | Default |
| - | - | - |
| `canvases:read` | List and search canvases; enumerate drive folders and files | ✅ |
| `canvases:write` | Canvas actions (direct edits), imports, remix, embed sessions, share links | ✅ |
| `designs:read` | Fetch canvas pseudo-HTML, tokens, page metadata; poll export status | ✅ |
| `designs:export` | Export PNG / JPEG / PDF / PPTX / MP4 / GIF | ✅ |
| `tasks:read` | Get task status; list tasks | ✅ |
| `tasks:write` | Start design tasks | ✅ |
| `tasks:cancel` | Cancel in-flight tasks | ✅ |
| `brand_kits:read` | List brand kits | ✅ |
| `brand_kits:write` | Create, update, delete brand kits; attach images | ✅ |
| `uploads:write` | Upload files (multipart, signed-URL mint + register, from-URL) | ✅ |
| `organizations:read` | List organizations and teams | ✅ |
| `credits:read` | Check credit balance | ✅ |
| `webhooks:manage` | Webhook configuration — recognized, guards no endpoint today | ✅ |
| `publish:share` | Publish / unpublish hosted websites | ✅ |
| `media:generate` | Metered media generation (credit-precharged per call) | ✅ |
| `web:search` | Metered web search + page read | ✅ |
| `websites:read` | Read hosted websites | ✅ |
| `websites:write` | Create / update / delete hosted websites | ✅ |
| `drive:write` | Create folders; move / rename / delete items; change visibility | ✅ |
| `files:read` | Download file content (`GET /v1/drive/files/{id}/download`) | ✅ |
| `expert:ask` | Grounded product Q\&A (free, read-only) | ✅ |
| `admin` | Reserved for org-admin verbs — recognized, guards no endpoint today | ❌ |
Drive **reads** (folder list / tree / file list / file metadata) ride `canvases:read`; only file **bytes** need `files:read`.
## Scope recipes by integration type
What each integration actually *exercises* — useful for auditing an integration's blast radius, not something the Settings key creator lets you enforce.
| Integration | Scopes |
| - | - |
| Read-only dashboard (list canvases, show tokens) | `canvases:read`, `designs:read`, `organizations:read` |
| Scheduled design generator with Slack webhook | `brand_kits:read`, `uploads:write`, `tasks:write`, `tasks:read`, `designs:export`, `canvases:read` |
| Export pipeline (every canvas → S3) | `canvases:read`, `designs:export` |
| Theme regeneration in CI | `designs:read` |
| Full-service automation | all scopes (least recommended — split by integration) |
## Security best practices
* **Keep keys server-side.** Never expose them in frontend code, mobile apps, or client bundles.
* **One key per integration.** Revocation affects one system; rotation doesn't cascade.
* **Narrow scopes.** A read-only dashboard shouldn't have `tasks:write`.
* **Rotate on a schedule** or after any suspected leak. Revoke in the app; issue a fresh key; update config.
* **Store in a secret manager.** `.env` works for local dev; production should use Vault / AWS Secrets Manager / GCP Secret Manager / Doppler.
* **Log the `request_id`** from error envelopes — it's how support finds your request in logs. Don't log the key itself.
## Revoking
**Settings → Developer → REST API → Delete.** Takes effect immediately. All requests using that key return `401 Unauthorized` instantly.
## Errors
| Status | Meaning |
| - | - |
| `401 authentication` | Missing, invalid, revoked, or expired key |
| `403 permission` | Valid key, but missing the scope for this endpoint, or resource belongs to a different team |
Both come with a `WWW-Authenticate: Bearer` header. Errors carry the standard typed envelope; see [`errors.md`](./errors.md).
## Common wrong guesses
* **Committing a `moda_live_…` key to a repo.** Even private repos leak eventually. Use env vars + a secret manager.
* **Assuming a key is narrow because the integration is.** Keys minted in Settings carry the default grant regardless of what the integration calls. Isolate by key, not by scope.
* **Logging the full key for debugging.** Only log the prefix (`moda_live_`) — the suffix is a secret.
* **Using the same key across production and staging.** Keys should be environment-specific so you can revoke independently.
* **Forgetting the webhook signing secret.** It's shown once alongside the key. Store it adjacent to the key in your secret manager — you'll need it to verify webhook payloads.
## Upstream
* [`docs.moda.app/api/authentication`](/docs/api/authentication)
* [`docs.moda.app/api/webhooks`](/docs/api/webhooks) — signing secret details
# Brand kits
Source: /docs/.mintlify/skills/moda-api/references/brand-kits
# Brand kits (REST)
Full CRUD-ish lifecycle for brand kits. All endpoints under `/v1/brand-kits`.
## Endpoints
| Verb | Path | Scope | Purpose |
| - | - | - | - |
| GET | `/brand-kits` | `brand_kits:read` | Cursor-paginated list for the key's team |
| POST | `/brand-kits` | `brand_kits:write` | Extract from a URL (Firecrawl) |
| PATCH | `/brand-kits/{id}` | `brand_kits:write` | Partial update |
| DELETE | `/brand-kits/{id}` | `brand_kits:write` | Soft delete; returns `204` |
| POST | `/brand-kits/{id}/images` | `brand_kits:write` | Attach uploaded file as logo / reference / asset |
| GET | `/brand-kits/{id}/images` | `brand_kits:read` | List attached images (the only source of `image_id`) |
| DELETE | `/brand-kits/{id}/images/{image_id}` | `brand_kits:write` | Remove an attached image; returns `204` |
## Default kit
The **first brand kit created for a team becomes the default automatically.** `POST /v1/tasks` with no `brand_kit_id` uses the default. At most one default per team. Promote another kit with `PATCH /brand-kits/{id}` and `{"is_default": true}` — it clears whichever kit held the default, in the same transaction as any field writes in the same body, so a correct-and-promote cannot half-land. `is_default: false` is a `400`: demoting without naming a replacement would leave the team with no default at all, so promote the replacement instead. Omit the field to leave the flag alone.
## List
```
GET /v1/brand-kits?limit=100
```
```json theme={null}
{
"data": [
{
"id": "bk_01HT9...",
"title": "Acme Corp",
"is_default": true,
"company_name": "Acme Corp",
"company_url": "https://acme.com",
"company_description": "…",
"tagline": "…",
"brand_values": ["…"],
"brand_aesthetic": ["…"],
"brand_tone_of_voice": ["…"],
"colors": [{ "color": "#2563eb", "label": "Primary" }, …],
"fonts": [{ "family": "Inter", "label": "Body", "weight": 400, "supported": true }, …],
"logos": [{ "group_name": "Primary Logo", "images": [{ "name": "logo.svg", "url": "…" }] }],
"default_theme_canvas_id": "cvs_01HT9…",
"created_at": "2026-…", "updated_at": "2026-…"
}
],
"next_cursor": "…" | null
}
```
`default_theme_canvas_id` is the prefixed `cvs_…` ID of the kit's saved default slides theme canvas (`null` if none). Returned on reads and **writable via `PATCH`** (see below). Fresh-canvas design tasks auto-apply it when present, and so does a blank `POST /v1/canvases` with `category: "slides"` and this kit's `brand_kit_id` — the create response reports the attached theme as `theme_canvas_id` (`null` when the kit has no usable theme; the create still succeeds).
Cursor-paginated; iterate with `?cursor=`. See [`pagination.md`](./pagination.md).
## Create from URL
```
POST /v1/brand-kits
Content-Type: application/json
{"url": "stripe.com"}
```
Server scrapes the URL via Firecrawl and extracts colors, fonts, logos, tone, values, aesthetic. The call is synchronous and **blocks for 10–30 seconds**.
Returns the created brand-kit record (same shape as list items).
URL accepts bare domain (`stripe.com`) or full URL (`https://stripe.com`). Cached — re-calling with the same URL returns the cached result quickly.
If the URL can't be scraped (blocked by robots, 404, login-required): `422 scraping_user_error`.
## Update (partial)
```
PATCH /v1/brand-kits/{id}
Content-Type: application/json
{
"title": "Acme Corp (Updated)",
"colors": [
{"color": "#2563eb", "label": "Primary"},
{"color": "#0a2540", "label": "Dark"},
{"color": "#ff6b35", "label": "Accent"}
]
}
```
Pass only the fields to change. Omitted fields keep their current values.
**Array fields replace wholesale.** Passing `colors` overwrites the entire array — not a merge. To add one color, fetch the existing array, append, and PATCH the full list:
```python theme={null}
existing = c.get(f"/brand-kits/{kit_id}").json() # full record
c.patch(f"/brand-kits/{kit_id}", json={
"colors": [*existing["colors"], {"color": "#ff6b35", "label": "Accent"}],
})
```
Same rule for `fonts`, `brand_values`, `brand_aesthetic`, `brand_tone_of_voice`.
**Setting the default theme canvas.** PATCH `default_theme_canvas_id` with a `cvs_…` ID (a bare UUID is also accepted) to point the kit at a slides theme canvas; send `null` to clear it:
```python theme={null}
c.patch(f"/brand-kits/{kit_id}", json={"default_theme_canvas_id": "cvs_01HT9…"}) # set
c.patch(f"/brand-kits/{kit_id}", json={"default_theme_canvas_id": None}) # clear
```
The ID must reference a canvas with `template_type='theme'` on the same team — anything else returns `400 invalid_theme_canvas`. Omitting the field leaves the current value unchanged.
**Promoting the team default.** PATCH `is_default: true` to make this the team's default kit, clearing whichever kit held it. It applies in the same transaction as any field writes in the same body, so correcting a value and promoting the kit is one call that cannot half-land:
```python theme={null}
c.patch(f"/brand-kits/{kit_id}", json={"colors": corrected, "is_default": True})
```
Promote-only: `is_default: false` returns `400`. Demoting without naming a replacement leaves the team with no default at all, so promote the replacement kit instead. Omitting the field leaves the flag unchanged.
Returns the updated record.
## Add images (logos / references / assets)
```
POST /v1/brand-kits/{id}/images
Content-Type: application/json
{
"file_id": "file_01HT9...",
"role": "logo", // one of: "logo" | "reference" | "asset"
"label": "Dark mode logo"
}
```
Requires an existing `file_id` from `POST /v1/uploads`. Attaches the uploaded file to the kit in the named role. Role here is **about the kit** (what this image represents in the brand) — different from the `role` field on task `attachments` (`source` / `reference` / `asset` / `import`).
| Role (brand kit) | Meaning |
| - | - |
| `logo` | Logo asset (Layovelle uses it automatically on title slides, footers, etc.) |
| `reference` | Style reference for the design agent |
| `asset` | Miscellaneous asset |
Returns the updated brand-kit record.
## List and remove images
```
GET /v1/brand-kits/{id}/images
```
```json theme={null}
{
"data": [
{
"id": "bki_01HT9...",
"role": "logo",
"file_id": "file_01HT9...",
"url": "https://…",
"name": "Dark mode logo",
"group_id": "…",
"group_name": "Logos"
}
],
"returned": 1,
"total": 1
}
```
Returns the full list — not cursor-paginated. `total` always equals `returned`.
**Read-back roles are `logo` or `reference` only.** `logo` rows come from the kit's logos; `reference` rows come from the design-assets bucket, which is shared by the `reference` **and** `asset` add-time roles — so an image attached with `role: "asset"` reads back as `reference`. Filtering the list for `role == "asset"` always returns nothing.
```
DELETE /v1/brand-kits/{id}/images/{image_id}
```
`image_id` is the `id` field (`bki_…`) from `GET /brand-kits/{id}/images` — **not** `file_id`, and not anything in the add-image response, which returns the whole kit record without image IDs. Returns `204 No Content`. A repeat DELETE on the same id returns `404` rather than erroring; a wrong-prefix id (e.g. a `file_` one) fails path validation with `422` before the handler runs.
## Delete
```
DELETE /v1/brand-kits/{id}
```
Returns `204 No Content`. Soft delete — historical tasks that referenced this kit continue to show it in their audit record. New design tasks with `brand_kit_id` pointing at a deleted kit get `404 not_found`.
If the deleted kit was the team's default, there is no new default automatically. The team operates brand-kit-less until another kit is promoted (`PATCH /brand-kits/{id}` with `{"is_default": true}`) or a new kit is created.
## In-task usage
Pass `brand_kit_id` explicitly on `POST /v1/tasks` to override the default:
```json theme={null}
{ "prompt": "…", "brand_kit_id": "bk_01HT9...", ... }
```
Or `skip_brand_kit: true` to apply no kit:
```json theme={null}
{ "prompt": "…", "skip_brand_kit": true, ... }
```
`skip_brand_kit` overrides `brand_kit_id` when both are present.
## Common wrong guesses
* **Merging array updates client-side without reading first.** You'll overwrite the kit's colors / fonts with just the new entries. Always read, append, PATCH.
* **Sending `is_default: false` to clear a default.** `400`. The field is promote-only — set `is_default: true` on the kit you want instead, which clears the incumbent for you.
* **Expecting `POST /v1/brand-kits` to return immediately.** The call is synchronous: it returns the completed kit, but blocks for 10–30s — budget for that in your client timeout.
* **Passing `brand_kit_id` for a deleted kit.** `404`. Check the kit exists before submitting the task.
* **Filtering the images list for `role: "asset"`.** Reads only ever return `logo` or `reference` — `asset` and `reference` share one bucket, so `asset` attachments come back as `reference`.
* **Passing a `file_` id to `DELETE /brand-kits/{id}/images/{image_id}`.** The path wants the `bki_` id from `GET /brand-kits/{id}/images`; a `file_` id fails path validation with `422`. `404` is the answer for a well-formed `bki_` id that isn't attached.
* **Mixing up the task-attachment `role` and the brand-kit-image `role`.** Task `role` is `source` / `reference` / `asset` / `import`. Brand-kit image `role` is `logo` / `reference` / `asset`. Similar words, different values.
* **Pointing `default_theme_canvas_id` at a deck instead of a theme.** It must be a `template_type='theme'` canvas on the same team, not a regular design/deck canvas. Anything else is `400 invalid_theme_canvas`.
## Upstream
* [`docs.moda.app/api/brand-kits/listBrandKits`](/docs/api/brand-kits/listBrandKits)
* [`docs.moda.app/api/brand-kits/createBrandKit`](/docs/api/brand-kits/createBrandKit)
* [`docs.moda.app/api/brand-kits/updateBrandKit`](/docs/api/brand-kits/updateBrandKit)
* [`docs.moda.app/api/brand-kits/addBrandKitImage`](/docs/api/brand-kits/addBrandKitImage)
* [`docs.moda.app/api/brand-kits/listBrandKitImages`](/docs/api/brand-kits/listBrandKitImages)
* [`docs.moda.app/api/brand-kits/removeBrandKitImage`](/docs/api/brand-kits/removeBrandKitImage)
* [`docs.moda.app/api/brand-kits/deleteBrandKit`](/docs/api/brand-kits/deleteBrandKit)
# Canvases and exports
Source: /docs/.mintlify/skills/moda-api/references/canvases-and-exports
# Canvases and exports
All canvas-shaped read + export flows live under `/v1/canvases` and `/v1/remix`. Plus the share-token read pattern for public canvases and the `/v1/share_links/resolve` helper.
## Endpoints
| Verb | Path | Scope |
| - | - | - |
| GET | `/canvases` | `canvases:read` |
| GET | `/canvases/search` | `canvases:read` |
| GET | `/canvases/{id}` | `designs:read` |
| GET | `/canvases/{id}/tokens` | `designs:read` |
| GET | `/canvases/{id}/pages` | `designs:read` |
| POST | `/canvases/{id}/export` | `designs:export` |
| POST | `/canvases/{id}/share` | `canvases:write` |
| PATCH | `/canvases/{id}` | `canvases:write` |
| POST | `/remix` | `canvases:write` |
| POST | `/share_links/resolve` | (public) |
## Listing and search
```
GET /v1/canvases?limit=100
GET /v1/canvases/search?q=summer+launch&limit=50
```
`GET /canvases` is cursor-paginated (`{data, next_cursor}`), sorted `(created_at DESC, id DESC)`. `GET /canvases/search` is the one offset-paginated lane in this catalog: it takes `q` / `limit` / `offset` and returns `{canvases, returned, limit, offset, has_more}`. See [`pagination.md`](./pagination.md).
List items include `id`, `name`, `url`, `category`, `visibility`, `created_at`, `updated_at`.
## Reading a canvas
```
GET /v1/canvases/{id}
```
Returns the canvas as semantic pseudo-HTML with CSS properties + embedded design tokens. Same format as MCP's `get_moda_canvas`. Requires team access.
### Reading a public share
```
GET /v1/canvases/{id}?share_token=<token>
```
Share-token-authenticated callers can read the canvas (view / view\_remix permission) without team access. Use in combination with `POST /v1/share_links/resolve` to parse a share URL first:
```
POST /v1/share_links/resolve
{ "url": "https://layovelle.com/s/abc123" }
→ { "canvas_id": "cvs_…", "share_token": "abc123", "permission": "view_remix", "expires_at": null }
```
Share-link-only callers can read but **cannot export** (see below).
### Tokens only
```
GET /v1/canvases/{id}/tokens
```
Returns `{variables, colors, fonts, radii, dimensions}` — fast path for theme regeneration in CI.
### Pages metadata
```
GET /v1/canvases/{id}/pages
```
Returns `{canvas_name, total_pages, pages: [{page_number, id, name, width, height, node_count}], id_note?}`. Call before `GET /v1/canvases/{id}?page_number=N` on multi-page canvases to plan per-page fetches.
Which identifier to use depends on the endpoint. The design read (`GET /v1/canvases/{id}?page_number=N`) and the export take `page_number`; the authoring and capture endpoints take `id`. Prefer `id` where it is accepted — an ordinal renumbers when a page is deleted or reordered, so it names a different page over time. `id` is either a durable real page id (`page-1786730909610-172273165`) or a session-scoped short ref (`p_a`); `id_note` is returned only in the latter case and states the lifetime, so re-list rather than storing one. A page-less canvas lists one entry with no `id`.
### Updating a canvas
```
PATCH /v1/canvases/{id}
{ "name": "New name", "description": "...", "template_type": "template" }
```
Partial update — only send the fields you want to change. `description` and `template_type` both treat an explicit `null` as "clear the field." `template_type` is `template` (any canvas) or `theme` (slides canvases only). Returns the updated canvas record.
## Export — two response shapes
```
POST /v1/canvases/{id}/export?format=pptx
```
Query parameters:
| Param | Values | Default |
| - | - | - |
| `format` | `png` / `jpeg` / `pdf` / `pptx` / `mp4` / `gif` | `png` |
| `page_number` | integer ≥ 1 | omit for **all** pages (PDF/PPTX bundle natively; multi-page PNG/JPEG come back as a `.zip` of per-page files). `mp4` / `gif` export exactly one page — required on a multi-page canvas |
| `pixel_ratio` | 1–4 (PPTX ignores it) | server default |
| `flatten` | bool (PDF only — raster-only no text) | `true` |
| `wait` | bool — block up to \~20s for the render before handing back a polling handle | `true` |
| `force_refresh` | bool — bypass the cache and force a fresh render | `false` |
The response carries one of two shapes, discriminated by `status`:
```json theme={null}
{ "status": "completed", "url": "https://assets-cdn.moda.app/.../deck.pptx", "format": "pptx", "source": "render" }
```
```json theme={null}
{ "status": "in_progress", "task_id": "…", "retry_after_seconds": 5, "format": "pptx" }
```
**Branch on `status`.** A cache hit or a quick render finishes inside the \~20s wait budget and returns `completed`. A slow one (long documents, `mp4` / `gif` animation renders) returns `in_progress` — the work continues in the background; poll for the URL:
```
GET /v1/canvases/{id}/export-status?task_id=…
→ { "task_id": "…", "status": "running", "is_terminal": false, "retry_after_seconds": 5, "url": null }
→ { "task_id": "…", "status": "completed", "is_terminal": true, "url": "https://…", "format": "pptx" }
```
Poll until `is_terminal` is `true`; `status` is then `completed` (with `url`) or `failed`. A failed task carries `error`, sometimes a typed `error_code`, and — decisively — `retryable`: `false` means a fresh export of the same canvas cannot succeed either (e.g. `no_renderable_content`, `native_export_declined` — fix the content first), `true` means a transient fault worth one more attempt. Branch on `retryable` rather than treating every failure as final. `wait=false` skips the blocking wait and hands back the `in_progress` handle immediately.
`format` in either shape reports what was actually **delivered** — `zip` for a bundled multi-page image export, even though the request asked for `png` / `jpeg`.
**The URL expires after 7 days.** Download promptly.
### Caching
Exports are **cache-first**. A repeat export of an unchanged canvas — same `format`, `page_number`, `pixel_ratio`, `flatten` — returns the previously rendered artifact with no new browser render. The `source` field reports how the response was served:
* `render` — freshly rendered.
* `cache` — a previously rendered artifact was reused.
* `slice` — a single page extracted server-side from a cached full-document export (no render).
The cache invalidates automatically when the canvas changes. Pass `force_refresh=true` only to skip the cache deliberately (e.g. you changed something the cache key doesn't capture) — the default path is faster and cheaper.
Scope: `designs:export` **and** team membership. Share-token-only callers (no team access) cannot export — reads via `share_token` are not enough.
### Export when a design task is running
If the target canvas has an in-flight design task, export returns:
```
HTTP/1.1 409 Conflict
Retry-After: 10
Content-Type: application/json
{
"error": {
"type": "conflict",
"code": "canvas_active_job",
"message": "Canvas cvs_… has an active design task; retry after it completes.",
"request_id": "019d8996-…"
}
}
```
Back off for `Retry-After` seconds and retry. But in a task → export pipeline you often don't need a separate export call at all: a programmatic design task **auto-exports its result** when it finishes, and the completion webhook payload carries it at `data.result.export` (`{status, url, format, page_count}`). Read that instead of polling, retrying the 409, or issuing your own export. The auto-export uses the canvas's category-default format — pass `export_on_complete` to `POST /v1/tasks` to override the format or disable it.
### An export is not a Task
Unlike design / remix / brand-kit extraction, an export never returns a Task envelope — even the async shape. Its `task_id` belongs to the export lane, so poll `GET /v1/canvases/{id}/export-status?task_id=…`, never `GET /v1/tasks/{id}`. The `export` task kind and the `export.*` webhook event types are declared in the public taxonomy but nothing emits them today, so don't wait on a webhook for an export either.
## Sharing — blocking thumbnail default
```
POST /v1/canvases/{id}/share
{ "wait_for_thumbnail": true } // default
```
Returns the share URL + share token. **By default, this call blocks until a thumbnail is generated** so the URL unfurls properly on social media / Slack. Thumbnail generation usually takes a few seconds; pass `wait_for_thumbnail: false` to skip if you don't care about unfurls.
## Remix
```
POST /v1/remix
{
"canvas_id": "cvs_01HT9...",
"prompt": "Change to dark mode", // optional
"new_name": "Q2 Deck (dark)", // optional
"brand_kit_id": "bk_01HT9..." // optional, only used with a prompt
}
```
Returns a Task envelope (`kind: "remix"`). Without a `prompt`: the task is synchronous, returns `status: "succeeded"` inline, `result.canvas_id` is the new canvas. With a prompt: queues a design task on the copy, returns non-terminal; poll same as `/tasks/{id}`.
The source canvas is **never** modified.
## Design-to-code walk
```
# Discover structure
pages = GET /v1/canvases/{id}/pages
# → { total_pages: N, pages: [ { page_number, name, width, height, node_count } ] }
# Extract tokens for theme
tokens = GET /v1/canvases/{id}/tokens
# → { variables, colors, fonts, radii, dimensions }
# Walk pages
for page in pages.pages:
html = GET /v1/canvases/{id}?page_number={page.page_number}
# → pseudo-HTML + embedded tokens
```
For visual reference on complex layouts, pair with `POST /v1/canvases/{id}/export?format=png`.
## Common wrong guesses
* **Expecting `POST /v1/canvases/{id}/export` to return a Task envelope.** It returns its own two-shape payload — `{status: "completed", url, format, source}` or `{status: "in_progress", task_id, retry_after_seconds}`.
* **Reading `url` without checking `status`.** `url` is `null` on the `in_progress` shape. Poll `/export-status` with the `task_id`.
* **Assuming `page_number` defaults to page 1.** Omitting it exports **every** page; multi-page PNG/JPEG arrive as a `.zip` (`format: "zip"`).
* **Passing `force_refresh=true` on every export.** The default is cache-first — a repeat export of an unchanged canvas is served from cache with no render. Only force a refresh when you deliberately need to bypass the cache.
* **Issuing a separate export after a design task.** A programmatic task auto-exports; the completion webhook carries it at `data.result.export`. Re-exporting just hits the cache, but reading the webhook field is one fewer call.
* **Treating the `409 canvas_active_job` response as a bug.** It's the intended retry signal when chaining task → export.
* **Ignoring `Retry-After` on the 409 response.** Back off the suggested seconds; don't hammer.
* **Using `share_token=` to export.** Only reads are permitted via share token. Export requires team access.
* **Skipping `wait_for_thumbnail: false` on `/share` calls in a script.** By default it blocks — in a batch share-link generator, the latency adds up. Pass `false` if you don't care about unfurls.
* **Treating `GET /v1/canvases/{id}` without `page_number` as cheap on multi-page designs.** It returns all pages concatenated.
* **Holding an export URL longer than 7 days.** Expires. Re-export if needed.
* **Storing the `X-Request-ID` header from a successful export** to use as an idempotency key. That's what the body-field `idempotency_key` on `POST /v1/tasks` is for — exports don't need it (stateless + fast).
## Upstream
* [`docs.moda.app/api/canvases/listCanvases`](/docs/api/canvases/listCanvases)
* [`docs.moda.app/api/canvases/getCanvas`](/docs/api/canvases/getCanvas)
* [`docs.moda.app/api/canvases/exportCanvas`](/docs/api/canvases/exportCanvas)
* [`docs.moda.app/api/canvases/makeCanvasPublic`](/docs/api/canvases/makeCanvasPublic)
* [`docs.moda.app/api/remix/remixCanvas`](/docs/api/remix/remixCanvas)
* [`docs.moda.app/api/share-links/resolveShareLink`](/docs/api/share-links/resolveShareLink)
# Endpoints
Source: /docs/.mintlify/skills/moda-api/references/endpoints
# Endpoints
Compressed catalog of every `2026-05-01` endpoint by router, with scope and a one-line purpose. Not a substitute for the OpenAPI spec — a scannable index.
Base URL: `https://api.moda.app/v1`
## Tasks
| Verb | Path | Operation | Scope | Purpose |
| - | - | - | - | - |
| POST | `/tasks` | `startTask` | `tasks:write` | Start an AI design task. Returns Task envelope immediately; poll or webhook. |
| GET | `/tasks/{id}` | `getTask` | `tasks:read` | Fetch current task envelope. |
| GET | `/tasks` | `listTasks` | `tasks:read` | Cursor-paginated list of team's tasks. |
| POST | `/tasks/{id}/cancel` | `cancelTask` | `tasks:cancel` | Request cancellation. Returns 202 + updated envelope. |
## Remix
| Verb | Path | Operation | Scope | Purpose |
| - | - | - | - | - |
| POST | `/remix` | `remixCanvas` | `canvases:write` | Duplicate canvas and optionally run a design task on the copy. Always returns a Task envelope (`kind: "remix"`). |
Without a `prompt`: completes synchronously, returns `status: "succeeded"` inline. With a prompt: queues a task, returns non-terminal envelope; poll.
## Canvases
| Verb | Path | Operation | Scope | Purpose |
| - | - | - | - | - |
| GET | `/canvases` | `listCanvases` | `canvases:read` | Cursor-paginated list of team canvases. |
| GET | `/canvases/search` | `searchCanvases` | `canvases:read` | Relevance-ranked search by name/content (`q` query param). Offset-paginated — the one non-cursor list in this catalog. |
| GET | `/canvases/{id}` | `getCanvas` | `designs:read` | Fetch canvas spec (pseudo-HTML + CSS). `share_token` query param for public reads. |
| GET | `/canvases/{id}/tokens` | `getCanvasTokens` | `designs:read` | Extract design variables / colors / fonts / radii only. |
| GET | `/canvases/{id}/pages` | `listCanvasPages` | `designs:read` | List page metadata (id, name, dimensions, node\_count). |
| POST | `/canvases/{id}/export` | `exportCanvas` | `designs:export` | Export to PNG / JPEG / PDF / PPTX / MP4 / GIF. Returns `{status: "completed", url, format}` when it finishes inside the \~20s wait budget, else `{status: "in_progress", task_id}`. `409 + Retry-After: 10` when an active design task runs on this canvas. |
| GET | `/canvases/{id}/export-status` | `getExportStatus` | `designs:read` | Poll an in-progress export by `task_id` until `is_terminal`. |
| POST | `/canvases/{id}/share` | `makeCanvasPublic` | `canvases:write` | Create / retrieve a public share link. Blocks on thumbnail generation by default; pass `wait_for_thumbnail=false` to skip. |
| PATCH | `/canvases/{id}` | `updateCanvas` | `canvases:write` | Update a canvas's name, description, or template\_type. |
See [`canvases-and-exports.md`](./canvases-and-exports.md) for the export semantics and the share-token read pattern.
## Brand kits
| Verb | Path | Operation | Scope | Purpose |
| - | - | - | - | - |
| GET | `/brand-kits` | `listBrandKits` | `brand_kits:read` | Cursor-paginated list. Team is implicit from the API key context. |
| POST | `/brand-kits` | `createBrandKit` | `brand_kits:write` | Extract brand data from a URL (Firecrawl-backed). Returns the kit record. |
| PATCH | `/brand-kits/{id}` | `updateBrandKit` | `brand_kits:write` | Partial update. Array fields (`colors`, `fonts`) replace wholesale. |
| DELETE | `/brand-kits/{id}` | `deleteBrandKit` | `brand_kits:write` | Soft delete. Returns 204. |
| POST | `/brand-kits/{id}/images` | `addBrandKitImage` | `brand_kits:write` | Attach an uploaded file (logo / reference / asset). Requires `file_id` from `POST /uploads`. |
| GET | `/brand-kits/{id}/images` | `listBrandKitImages` | `brand_kits:read` | List images attached to a brand kit. |
| DELETE | `/brand-kits/{id}/images/{image_id}` | `removeBrandKitImage` | `brand_kits:write` | Remove an attached image. Returns 204. |
See [`brand-kits.md`](./brand-kits.md) for update semantics.
## Uploads
| Verb | Path | Operation | Scope | Purpose |
| - | - | - | - | - |
| POST | `/uploads` | `uploadFile` | `uploads:write` | Multipart upload, files up to \~32 MiB (gateway body cap). Returns `{id: "file_...", url, filename, mime_type, size_bytes, was_duplicate}`. |
| POST | `/uploads/url` | `createUploadUrl` | `uploads:write` | Mint a short-lived signed PUT URL for direct-to-storage upload. Step 1 of the large-file flow. |
| POST | `/uploads/register` | `registerUpload` | `uploads:write` | Finalize a signed-URL upload into a file row. Returns the same shape as `uploadFile`. Step 3. |
| POST | `/uploads/from-url` | `uploadFromUrl` | `uploads:write` | Server fetches from a public URL, validates MIME, stores. SSRF-validated. |
Deduplicates by content hash. Files are capped at `max_file_bytes` on every path — that ceiling is per-workspace (250 MB is the deployment maximum, a free workspace is lower), so read it from `GET /v1/uploads/limits` rather than hard-coding a number; the multipart path is additionally capped at \~32 MiB by the gateway, so larger files must use `/uploads/url` + `/uploads/register`. See [`uploads.md`](./uploads.md).
## Organizations
| Verb | Path | Operation | Scope | Purpose |
| - | - | - | - | - |
| GET | `/organizations` | `listOrganizations` | `organizations:read` | Cursor-paginated list of orgs + teams the key's owner belongs to. |
Role scoped: admins see all, members see their own.
## Credits
| Verb | Path | Operation | Scope | Purpose |
| - | - | - | - | - |
| GET | `/credits` | `getCredits` | `credits:read` | Current balance, plan, reset date. `null` fields when billing is disabled. |
## Share links
| Verb | Path | Operation | Scope | Purpose |
| - | - | - | - | - |
| POST | `/share_links/resolve` | `resolveShareLink` | (none — public-ish) | Parse a `layovelle.com/s/…` URL into `{canvas_id, share_token, permission, expires_at}`. Distinguishes `share_link_revoked` from `share_link_not_found`. |
## Usage / events (observability)
| Verb | Path | Operation | Scope | Purpose |
| - | - | - | - | - |
| GET | `/usage` | `getApiUsage` | (admin) | Summary + daily / per-key / per-operation aggregates. 7-day default, 90-day max window. |
| GET | `/events` | `listApiEvents` | (admin) | Cursor-paginated activity log. Rows include HTTP status, `operation_id`, `timestamp`, `trigger_source` (api / mcp). |
Admins see everything; members see only their own keys' events.
## Webhook deliveries
| Verb | Path | Operation | Scope | Purpose |
| - | - | - | - | - |
| GET | `/webhook_deliveries` | `listWebhookDeliveries` | (any authenticated key) | Cursor-paginated delivery log for the team; role-scoped (admins see all, members see their own keys' deliveries). `from` / `to` window defaults to the last 7 days, 90-day max — pass them explicitly to look further back. |
| POST | `/webhook_deliveries/{id}/redeliver` | `redeliverWebhook` | (any authenticated key) | Queue a manual replay of a delivery. Returns 202. `403` unless you created the dispatching key or are a team admin — if that key has since been deleted, only admins can replay; `409` if the source task no longer exists. |
## Cross-cutting request headers
| Header | Required | Notes |
| - | - | - |
| `Authorization: Bearer moda_live_…` | Yes (except `/share_links/resolve`) | See [`authentication.md`](./authentication.md) |
| `Layovelle-Version: 2026-05-01` | Should | Pin in production. Omitting resolves to default. |
| `Content-Type: application/json` | POST / PATCH | |
| `Prefer: wait=<seconds>` | No — **not implemented**; ignored | Use webhooks or polling instead. |
| `Idempotency-Key` (header) | No — **not yet implemented** | Body field `idempotency_key` instead. |
## Cross-cutting response headers
| Header | Always present |
| - | - |
| `Layovelle-Version` | Yes — the resolved version |
| `X-Request-ID` | Yes — matches the `request_id` in error envelopes |
| `Retry-After` | On `429 rate_limited` and `409 canvas_active_job` |
## Common wrong guesses
* **Using the operation ID (`startTask`) as the path.** Operation IDs are for SDKs; the HTTP path is what matters.
* **Expecting `/tasks/{id}/cancel` to be a DELETE.** It's a POST.
* **Confusing `POST /canvases/{id}/share` with generating a share link on every call.** It creates one if missing, returns the existing one otherwise. It also blocks on thumbnail generation by default.
* **Calling `GET /canvases/{id}` without `share_token=` on a share-only-visible canvas.** You'll get `404` — the call resolves through the key's team access, not the share link.
* **Reading `url` off an export response without checking `status`.** A slow export returns `{status: "in_progress", task_id}` with `url: null` — poll `/canvases/{id}/export-status`. It is still not a Task envelope, so don't poll `/tasks/{id}`.
* **Treating `409 + Retry-After: 10` on export as a failure.** It's the retry signal when a design task is running on the canvas.
## Upstream
* Full OpenAPI spec: [`docs.moda.app/openapi/moda-public-api.yaml`](/docs/openapi/moda-public-api.yaml)
* Per-endpoint docs: [`docs.moda.app/api/*`](/docs/api)
# Errors
Source: /docs/.mintlify/skills/moda-api/references/errors
# Errors
Every error response carries a single typed envelope under an `error` key. Branch on `type`, not HTTP status.
## Shape
```json theme={null}
{
"error": {
"type": "not_found",
"code": "file_not_found",
"message": "Canvas cvs_abc123 not found",
"doc_url": "/docs/api-reference/errors",
"request_id": "019d8996-16b3-73ee-841a-5bc5038eb972",
"details": null,
"retry_after_ms": null,
"causes": null
}
}
```
## Fields
| Field | Type | Notes |
| - | - | - |
| `type` | string | Stable high-level category. Branch your retry logic on this. |
| `code` | string | Narrow machine-readable identifier. Stable once published. |
| `message` | string | For developers. Not localized, not user-facing. **Don't parse.** |
| `doc_url` | string | Permalink to the error-catalog page. A constant — `/docs/api-reference/errors` — on every envelope, not a per-code URL. |
| `request_id` | string | Correlator. Also echoed in the `X-Request-ID` response header. |
| `details` | object / null | Code-specific. Example for `validation_failed`: `{"fields": [{"field": "body.canvas_id", "code": "string_too_short"}]}` |
| `retry_after_ms` | number / null | Hint for transient / rate-limited errors |
| `causes` | array / null | Nested envelopes for aggregated failures (e.g. multi-page export with per-page errors) |
## Types (branch on these)
| Type | HTTP | Retryable? | Action |
| - | - | - | - |
| `invalid_request` | 400 | No | Fix the request shape / values |
| `authentication` | 401 | No | Renew / check the API key |
| `permission` | 403 | No | Missing scope, or resource belongs to another team |
| `not_found` | 404 | No | Bad ID, or the key's team doesn't see it |
| `conflict` | 409 | No | Name collision, resource state conflict |
| `idempotency_conflict` | 409 | No | Same `idempotency_key`, different body |
| `unprocessable` | 422 | No | Well-formed request but validation failed |
| `rate_limited` | 429 | Yes | Respect `Retry-After` header (seconds) or `retry_after_ms` |
| `upstream_error` | 502 / 503 / 504 | Yes | Transient — back off and retry |
| `internal_error` | 500 | Yes | Server bug — retry with backoff; include `request_id` when reporting |
## Selected codes worth knowing
| Code | Type | Typical trigger |
| - | - | - |
| `unsupported_version` | `invalid_request` | Unknown `Layovelle-Version` header. `details.supported` lists valid versions. |
| `validation_failed` | `unprocessable` | Body field failed a validation rule. `details.fields[]` names the offenders. |
| `share_link_revoked` | `not_found` | Share link exists but is disabled. |
| `share_link_not_found` | `not_found` | No share with that token. |
| `scraping_user_error` | `unprocessable` | Brand-kit URL scrape failed (blocked by robots, 404, etc.). |
| `canvas_active_job` | `conflict` | Export attempted on a canvas with an in-flight design task. `409 + Retry-After: 10`. See [`canvases-and-exports.md`](./canvases-and-exports.md). |
| `source_url_unreachable` | `invalid_request` | `POST /uploads/from-url` could not fetch the URL (DNS, timeout, connection, TLS, redirect loop, upstream 3xx/4xx). The envelope's `retryable` says whether this cause is transient (timeout, 429) or needs a different URL (404). |
| `unusable_source_url` | `invalid_request` | `POST /uploads/from-url` was redirected to a non-http(s) or malformed location. Pass the final URL. |
| `source_url_not_a_file` | `invalid_request` | `POST /uploads/from-url` fetched something whose type is unresolvable (pass `filename` with the real extension) or not accepted. `415`. |
## Retry rules
| Error type | Retry rule |
| - | - |
| `rate_limited` | Sleep `Retry-After` seconds (or `retry_after_ms`); retry. Exponential backoff on repeats. |
| `upstream_error` | Sleep 1s, 5s, 30s on attempts 1, 2, 3. Give up after 3. |
| `internal_error` | Same as `upstream_error`. |
| Everything else | Don't retry — unless the envelope carries `retryable: true`. |
When the envelope carries `retryable`, it wins over the type rule: `false` means the same request can never succeed. Never silently retry `invalid_request` / `authentication` / `permission` / `not_found` / `conflict` / `idempotency_conflict` / `unprocessable` — they're deterministic.
## Rate limiting
* Surfaced as `429 rate_limited` with `Retry-After` header (seconds).
* Applied per API key and per endpoint (e.g. exports are individually rate-capped).
* Slow down, don't hammer.
* The `RateLimit-*` response headers standardized in RFC 9240 are **not shipped yet**. Don't read them. Use `Retry-After` and the error's `retry_after_ms`.
## Logging & support
* **Always log `request_id`** on every error. When you email [admin@layovelle.com](mailto:admin@layovelle.com), include it.
* Log `type` and `code`; don't log `message` as the signal (it can change without notice).
* Redact the API key from any log line.
## Error handling template (TypeScript)
```ts theme={null}
async function modaFetch(path: string, init?: RequestInit) {
const res = await fetch(`https://api.moda.app${path}`, {
...init,
headers: { ...HEADERS, ...(init?.headers ?? {}) },
});
if (res.ok) return res.json();
const body = await res.json().catch(() => null);
const err = body?.error;
if (!err) throw new Error(`Layovelle ${res.status} (no envelope)`);
if (err.type === "rate_limited" || err.type === "upstream_error" || err.type === "internal_error") {
const ra = Number(res.headers.get("Retry-After") ?? 0) * 1000 || err.retry_after_ms || 3000;
await new Promise(r => setTimeout(r, ra));
return modaFetch(path, init); // caller should cap recursion
}
throw new Error(
`Layovelle ${err.type}/${err.code}: ${err.message} (request_id=${err.request_id})`,
);
}
```
## Error handling template (Python)
```python theme={null}
import time, httpx
def moda_fetch(client: httpx.Client, method: str, path: str, *, attempt: int = 1, **kw):
r = client.request(method, path, **kw)
if r.is_success:
return r.json()
body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
err = body.get("error") or {}
retryable = {"rate_limited", "upstream_error", "internal_error"}
if err.get("type") in retryable and attempt <= 3:
ra = int(r.headers.get("Retry-After", 0)) or ((err.get("retry_after_ms") or 3000) // 1000) or 3
time.sleep(ra)
return moda_fetch(client, method, path, attempt=attempt + 1, **kw)
raise RuntimeError(
f"Layovelle {err.get('type')}/{err.get('code')}: {err.get('message')} "
f"(request_id={err.get('request_id')})"
)
```
## Common wrong guesses
* **Branching on HTTP status only.** Status codes collapse types (both `rate_limited` and `idempotency_conflict` can be 409-adjacent in practice). Branch on `type`.
* **Parsing `message`.** Can change without notice. Use `type` and `code` for logic; `message` for display only.
* **Retrying `not_found` / `permission`.** Deterministic.
* **Ignoring `Retry-After` on `429`.** You'll be back to rate-limited in seconds.
* **Expecting `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset` response headers.** Not shipped yet. Use `Retry-After` and `retry_after_ms`.
* **Not including `request_id` in support requests.** Without it, logs are hard to find.
## Upstream
* [`docs.moda.app/api#error-format`](/docs/api#error-format)
* [`docs.moda.app/api-reference/errors`](/docs/api-reference/errors) — the catalog every envelope's `doc_url` points at
# Idempotency
Source: /docs/.mintlify/skills/moda-api/references/idempotency
# Idempotency
`POST /v1/tasks` accepts an optional `idempotency_key` in the request body. Reusing the same key returns the existing task instead of creating a duplicate. Several other writes accept the same body field, with **stricter** semantics — see [Beyond `POST /v1/tasks`](#beyond-post-v1tasks).
## When to use
* **Scheduled jobs.** Cron fires at 9am Monday. Use `idempotency_key: "weekly-deck:2026-W17"`. If your worker crashes after the POST succeeds but before writing the response, the next retry returns the same task.
* **Network-timeout retries.** Your HTTP client times out mid-POST. Retry with the same key — safe.
* **Upstream-triggered flows.** A Slack command fires two webhooks on the same action. Use the slack message ID as the key: `idempotency_key: "slack:T01234:123456.789"`.
* **Bulk fan-out.** 50 prospects × one task each. `idempotency_key: "prospect:{id}:2026-04-weekly"` keeps re-runs safe.
## How it works
```
POST /v1/tasks
{ "prompt": "…", "idempotency_key": "focustime-v1", ... }
```
* First call: creates the task, returns the envelope.
* Second call with the same key: returns the **original** task's envelope. The task lane matches on the key alone — it does **not** compare request bodies, so a reused key with a different prompt silently replays the first task rather than starting the new one.
No dedicated "I'm reusing" flag on the response — the `id` matches the original, that's how you can tell.
**The key is the intent.** Because there is no body comparison and no conflict error on this endpoint, a stale key is a silent no-op, not a loud failure: derive the key from everything that makes the request distinct (week, prospect, revision), and mint a fresh one whenever you want fresh output.
## Key design
* **Stable and unique per logical operation.** "weekly-deck" alone is bad (reused every week); `"weekly-deck:2026-W17"` is good.
* **Hash complex inputs** if the key would otherwise be long: `sha256(prompt + brand_kit_id + format)`. Deterministic + short.
* **Namespace per integration — keys are not isolated per API key.** The task lane looks a key up globally, and the hashed lanes scope records to `(team, user, operation, key)`. Two of your integrations sharing a team can collide on a bare `"weekly-deck"` and silently replay each other's result. Prefix them: `"myapp:weekly-deck:2026-W17"`.
* **TTL.** Idempotency records are retained long enough to cover sensible retry windows (24h on the hashed lanes) — don't build around a specific duration. If you're retrying a week later, use a different key.
## Beyond `POST /v1/tasks`
`idempotency_key` is not task-only, and the other endpoints that take it are stricter than the task lane: they ride a shared pipeline that **hashes the request payload** alongside the key. Same key + same payload replays the stored result; same key + a different payload is a real `409 idempotency_conflict`; a same-key duplicate that lands while the first call is still running gets a retryable `409` instead of a second side effect. Records expire after 24h.
`POST /v1/brand-kits` is the one to know — a retried create replays the stored kit instead of minting a second one (if the kit was deleted in the meantime, the stale record is invalidated and the create re-runs fresh). The metered verbs take it too. When in doubt, check the endpoint's request schema for an `idempotency_key` field rather than assuming it has none.
Records are scoped to `(team, user, operation, key)` — not to the API key — so namespace your keys per integration.
**Replay protection is best-effort, not a transaction.** The store fails *open*: if Redis is unavailable or the record was evicted, the call executes without replay protection rather than 5xx-ing. A retry in that window can produce a second side effect. Treat `idempotency_key` as strong protection against the common failure (a timed-out retry), not as a guarantee of exactly-once.
`POST /v1/uploads` has no `idempotency_key`, and doesn't need one: it dedupes by content hash and reports `was_duplicate: true`.
### Conflict handling (hashed lanes)
```json theme={null}
{
"error": {
"type": "conflict",
"code": "idempotency_conflict",
"message": "An existing operation is associated with this idempotency_key but with a different request body.",
"request_id": "019d8996-…"
}
}
```
Fix one of:
* Use a different `idempotency_key` for the new body.
* Change your code so the body is byte-identical (e.g. canonicalize array order, don't include a timestamp in the payload).
## Not wired (yet)
* **`Idempotency-Key` HTTP header** (Stripe-style). Today, `idempotency_key` is a **body field**. A header-based variant is on the roadmap; when it lands, existing body-field usage continues to work.
## Webhook idempotency (separate concern)
Webhook **receivers** should use the event envelope's `id` (`evt_…`) as the dedupe key. That's about guarding your handler against duplicate deliveries — a different concern from `idempotency_key` on task creation. See [`webhooks.md`](./webhooks.md).
## Worked example — weekly cron
```python theme={null}
iso_week = datetime.utcnow().strftime("%Y-W%V") # e.g. "2026-W17"
resp = httpx.post(
"https://api.moda.app/v1/tasks",
headers=HEADERS,
json={
"prompt": build_weekly_prompt(),
"format": {"category": "slides", "width": 1920, "height": 1080},
"number_of_slides": 10,
"callback_url": "https://myapp.com/webhooks/moda",
"idempotency_key": f"weekly-deck:{iso_week}",
},
)
# safe to retry this whole call on network error — same key, same body
```
## Common wrong guesses
* **Using an `Idempotency-Key` HTTP header.** Not supported today. Use the body field `idempotency_key`.
* **Reusing the same key across different operations.** "default" as the key for every task. You'll just keep getting back the first task you ever created with that key.
* **Changing the body and reusing the key on `POST /v1/tasks`.** You get the *old* task back, not an error and not a new task — the task lane never compares bodies. Use a fresh key when the intent changed. (On the hashed lanes below, the same mistake is a loud `409 idempotency_conflict`.)
* **Assuming `idempotency_key` is task-only.** `POST /v1/brand-kits` and the metered verbs take it too, with the same replay / `409` semantics. Check the endpoint's request schema.
## Upstream
* [`docs.moda.app/api/tasks/startTask`](/docs/api/tasks/startTask) — parameter reference
* [`errors.md`](./errors.md) — the full error envelope including `idempotency_conflict`
# Ids
Source: /docs/.mintlify/skills/moda-api/references/ids
# Resource IDs
Every Layovelle resource has a **prefixed wire ID** like `cvs_01HT9WK8N3M2J4A5Z6P7Q8R9TV`. The prefix disambiguates the resource type on sight and prevents cross-resource lookup mistakes.
## Prefixes
| Prefix | Resource |
| - | - |
| `cvs_` | Canvas |
| `task_` | Task (design / export / remix / brand\_kit\_extract) |
| `bk_` | Brand kit |
| `bki_` | Brand-kit image attachment |
| `file_` | Uploaded file |
| `upl_` | Legacy upload record (use `file_` going forward) |
| `conv_` | Conversation (multi-turn design context) |
| `evt_` | Webhook event |
| `whd_` | Webhook delivery |
| `ak_` | API key |
| `org_` | Organization |
| `team_` | Team |
Encoding: Crockford base32 (no I, L, O, U to avoid visual confusion). Case-insensitive.
## The two rules
### Requests — tolerant (either form)
For the resources in the table above, JSON body fields **and** path parameters both accept the prefixed form or a bare UUID string. Pass a UUID straight from your database or a tool response without re-encoding.
```bash theme={null}
# BOTH WORK — body field
curl -X POST https://api.moda.app/v1/remix \
-H "Authorization: Bearer moda_live_..." \
-H "Layovelle-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{"canvas_id": "cvs_01HT9WK8N3M2J4A5Z6P7Q8R9TV"}'
curl -X POST https://api.moda.app/v1/remix \
-H "..." \
-d '{"canvas_id": "550e8400-e29b-41d4-a716-446655440000"}'
# BOTH WORK — path parameter
curl -X POST https://api.moda.app/v1/canvases/cvs_01HT9WK8N3M2J4A5Z6P7Q8R9TV/export ...
curl -X POST https://api.moda.app/v1/canvases/550e8400-e29b-41d4-a716-446655440000/export ...
```
Tolerance is a convenience for integrators who already hold raw UUIDs from an older system. Always prefer prefixed form in new code.
Three spots are exceptions:
* **Typed form required** — drive item and folder references: the polymorphic `/v1/drive/items/{item_ref}` verbs (move, rename, delete), and the `fld_…` filter on `GET /v1/drive/folders` and `GET /v1/drive/files`. A bare UUID is ambiguous across item kinds there.
* **Typed form required** — `POST /v1/webhook_deliveries/{delivery_id}/redeliver`, which wants the `whd_…` id and answers `404` for anything else.
* **Bare UUID required** — the `task_id` *query* parameter on `GET /v1/canvases/{id}/export-status`, which answers `400 "task_id must be a UUID."` for a prefixed value. The `task_id` that `POST /v1/canvases/{id}/export` hands back is already in that form, so pass it through unchanged.
### Responses — prefixed for these resources
The `id` fields of the resources in the table above always come back prefixed, so if you store one from a response and pass it in later, you're fine. Surfaces outside that table carry raw UUIDs in both directions: the website endpoints, the export `task_id` above, and the `GET /v1/events` activity log (its row `id`, plus the nested `resource.id` and `resource.canvas_id`). The `evt_` prefix belongs to the webhook envelope below — not to activity-log rows.
## Why this design
* Prefixed IDs are self-describing. Pasting one in a log or a PR makes it immediately clear what kind of object it is.
* Request tolerance avoids breaking callers who already hold bare UUIDs from an older system or an internal store.
* Prefixed responses mean a stored reference is still self-describing the next time you use it.
## In responses
Every `id` field belonging to the resource types in the table above comes back prefixed:
```json theme={null}
{
"id": "task_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"kind": "design",
"result": {
"canvas_id": "cvs_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"canvas_url": "https://layovelle.com/canvas/..."
}
}
```
Store the prefixed form for future calls.
## Webhook payloads
Webhook events carry a prefixed `evt_…` id:
```json theme={null}
{
"id": "evt_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"type": "task.succeeded",
"created": "2026-04-15T12:01:00+00:00",
"api_version": "2026-05-01",
"data": { "id": "task_01HT9WK8N3M2J4A5Z6P7Q8R9TV", ... }
}
```
Use `id` (the event ID) as an idempotency key in your webhook handler — it's stable across retries.
## Common wrong guesses
* **Assuming bodies reject bare UUIDs.** They don't — requests take either form. Only the drive `item_ref` / `fld_…` refs and `POST /v1/webhook_deliveries/{delivery_id}/redeliver` require the typed form.
* **Storing bare UUIDs from older responses.** They work in path params today, but storing the prefixed form (from any current response) is safer for future use.
* **Parsing the prefix at the client** to dispatch on resource type. Don't — use the `kind` field on task envelopes or the response shape. Prefixes are for humans.
* **Passing a prefixed `task_…` to `GET /v1/canvases/{id}/export-status`.** That query parameter is bare-UUID-only — `400 "task_id must be a UUID."`. Forward the `task_id` from the export response verbatim.
* **Assuming `upl_` and `file_` are interchangeable.** `upl_` is legacy; current uploads return `file_`. Don't mix.
## Upstream
[`docs.moda.app/api/authentication#resource-id-formats`](/docs/api/authentication#resource-id-formats)
# Pagination
Source: /docs/.mintlify/skills/moda-api/references/pagination
# Pagination
Every list endpoint covered by this skill uses opaque cursor pagination — with one exception: `GET /v1/canvases/search` is offset-paginated (see [Search is the offset lane](#search-is-the-offset-lane) below).
Newer surfaces outside this catalog (the drive folder/file lists, the website list) are offset lanes too, with their own `{<name>, limit, offset, has_more}` shapes. Read the endpoint's own reference before assuming the cursor contract.
## Response shape
```json theme={null}
{
"data": [ ... ],
"next_cursor": "eyJ2IjoxLCJzIjoiMjAyNi0wNC0xNVQxMjow..." | null,
"returned": 20,
"has_more": true,
"limit": 20,
"total": 137 | null
}
```
* `data` — the page of items.
* `next_cursor` — opaque signed string. Pass back as `?cursor=<value>` to get the next page. `null` means you've reached the end.
* `returned` / `has_more` — derived server-side (`len(data)` and `next_cursor != null`), so a page can never silently look complete. Never infer the end from `len(data) < limit`.
* `limit` — the effective page size the response was computed with.
* `total` — see below: a real count on some lanes, `null` on others.
## Iteration pattern
**TypeScript:**
```ts theme={null}
async function paginate<T>(path: string): Promise<T[]> {
const all: T[] = [];
let cursor: string | null = null;
while (true) {
const url = new URL(`https://api.moda.app${path}`);
if (cursor) url.searchParams.set("cursor", cursor);
url.searchParams.set("limit", "100");
const { data, next_cursor } = await fetch(url, { headers: HEADERS }).then(r => r.json());
all.push(...data);
if (!next_cursor) return all;
cursor = next_cursor;
}
}
```
**Python:**
```python theme={null}
def paginate(path: str) -> list[dict]:
out = []
cursor = None
with httpx.Client(base_url="https://api.moda.app", headers=HEADERS, timeout=30) as c:
while True:
params = {"limit": 100}
if cursor:
params["cursor"] = cursor
resp = c.get(path, params=params).json()
out.extend(resp["data"])
cursor = resp["next_cursor"]
if cursor is None:
return out
```
## Limits
| Endpoint | Default | Max |
| - | - | - |
| `/v1/canvases`, `/v1/canvases/search`, `/v1/tasks`, `/v1/brand-kits`, `/v1/organizations` | 20 | 100 |
| `/v1/events`, `/v1/webhook_deliveries` | 50 | 200 |
Always request as large a page as you can tolerate — fewer roundtrips, less rate pressure.
## Sort order
List endpoints sort by `(created_at DESC, id DESC)` on immutable columns. This guarantees pagination correctness when rows are added or modified mid-scan — no skips, no duplicates — at the cost of "sort by updated\_at" being unavailable.
Client-side sort if you need a different order.
## `total` is per-lane
`total` is present in the envelope but only populated where the collection supports a cheap true count — `GET /v1/tasks`, `GET /v1/brand-kits`, and `GET /v1/organizations` return a real count after filters. It stays `null` on the lanes where counting is as expensive as the query itself: `GET /v1/canvases` (the doc-kind filter reads un-indexed JSONB), `GET /v1/events`, `GET /v1/webhook_deliveries`, and the relevance-ranked search.
So: use `total` when you have it, and never *require* it. `has_more` is the authoritative "is there more" signal on every lane. For a progress bar on a `null`-total lane, the common pattern is "processed 127 so far…" without a denominator.
## Opaque cursors
Cursors are HMAC-signed + base64url-encoded. Don't:
* Parse or mutate the cursor value.
* Attempt to construct one by hand.
* Reuse a cursor from a different list endpoint.
The server detects tampering and returns `400 invalid_request`.
Cursors are valid across sessions but have a bounded TTL — don't store one for a week and expect it to resume correctly. For long-running iterations (> a few hours), consider re-starting from the beginning.
## Endpoints that paginate
All of these return `{data, next_cursor}`:
* `GET /v1/canvases`
* `GET /v1/tasks`
* `GET /v1/brand-kits`
* `GET /v1/organizations`
* `GET /v1/events`
* `GET /v1/webhook_deliveries`
Single-resource endpoints (`GET /v1/canvases/{id}`, `GET /v1/tasks/{id}`, `GET /v1/credits`, etc.) don't paginate.
### Search is the offset lane
`GET /v1/canvases/search` is relevance-ranked, so a cursor over `(created_at, id)` would be meaningless. It pages by offset and uses its own response shape:
```
GET /v1/canvases/search?q=summer+launch&limit=50&offset=0
→ { "canvases": [ ... ], "returned": 50, "limit": 50, "offset": 0, "has_more": true }
```
Items are under `canvases`, not `data`. There is no `next_cursor` — page with `offset + limit` while `has_more` is `true`. `limit` defaults to 20 and caps at 100, same as the `/canvases` cursor lane (see [Limits](#limits) — the `/events` and `/webhook_deliveries` lanes are 50/200).
```python theme={null}
offset = 0
while True:
resp = c.get("/canvases/search", params={"q": query, "limit": 100, "offset": offset}).json()
yield from resp["canvases"]
if not resp["has_more"]:
break
offset += 100
```
## Concurrency
Don't parallelize pagination. Each page depends on the previous one's cursor. Parallelize the **work done per item** inside a page instead.
## Migration from offset pagination
If you're carrying code from the legacy `2026-04-12` shape:
```python theme={null}
# Legacy (2026-04-12):
offset = 0
while True:
resp = client.get("/canvases", params={"offset": offset, "limit": 50}).json()
for c in resp["canvases"]:
yield c
if not resp["has_more"]:
break
offset += 50
# Canonical (2026-05-01):
cursor = None
while True:
params = {"limit": 50}
if cursor:
params["cursor"] = cursor
resp = client.get("/canvases", params=params).json()
for c in resp["data"]:
yield c
cursor = resp["next_cursor"]
if cursor is None:
break
```
## Common wrong guesses
* **Assuming `total` is always there — or never there.** It is a real count on `/tasks`, `/brand-kits`, and `/organizations`, and `null` on `/canvases`, `/events`, `/webhook_deliveries`, and search. Branch on it; rely on `has_more`.
* **Parsing / mutating cursors.** Opaque; server rejects tampered values.
* **Passing `offset=` to a cursor endpoint.** Ignored (or rejected, depending on endpoint) on `2026-05-01`. Use the cursor — except on `/canvases/search`, where `offset` *is* the mechanism.
* **Holding a cursor for days.** Cursors expire; restart the iteration.
* **Parallel page fetches.** Each page depends on the prior cursor.
* **Reading `response.canvases` / `response.tasks` / `response.brand_kits`.** The cursor lanes always put items under `data`. `GET /canvases/search` is the exception — its items are under `canvases`.
* **Expecting a `next_cursor` from `/canvases/search`.** It has none. Page with `offset` while `has_more` is `true`.
## Upstream
* [`docs.moda.app/api/versioning`](/docs/api/versioning) — migration map
* Per-endpoint docs at [`docs.moda.app/api/{canvases,tasks,brand-kits,organizations}/*`](/docs/api)
# Task envelope
Source: /docs/.mintlify/skills/moda-api/references/task-envelope
# Task envelope
Every task-shaped operation — design, remix, brand-kit extraction — returns the canonical Task envelope. (Canvas export is *not* one of them; it has its own two-shape payload — see below.) Pin `Layovelle-Version: 2026-05-01` to get this shape.
## Full shape
```json theme={null}
{
"id": "task_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"kind": "design",
"status": "queued",
"created_at": "2026-04-15T12:00:00+00:00",
"started_at": null,
"completed_at": null,
"progress": null,
"attempt": 1,
"max_attempts": 3,
"attempts_started": 0,
"first_started_at": null,
"input": { "prompt": "Create a sales deck", "format": { "category": "slides", ... } },
"result": null,
"error": null,
"credits": null,
"links": {
"self": "/v1/tasks/task_01HT9...",
"events": null,
"cancel": "/v1/tasks/task_01HT9.../cancel",
"canvas": null
},
"retry_after_ms": 3000
}
```
## Fields
| Field | Type | Notes |
| - | - | - |
| `id` | string | `task_`-prefixed ID |
| `kind` | string | `design` / `export` / `remix` / `brand_kit_extract` — discriminator for `input` / `result` shape |
| `status` | string | `queued` / `running` / `succeeded` / `failed` / `canceled` / `expired` |
| `created_at` | ISO 8601 | When the task was created |
| `started_at` | ISO 8601 / null | When the worker picked up the **current** attempt. Rewritten on every retry — see `first_started_at` |
| `first_started_at` | ISO 8601 / null | When the worker picked up the **first** attempt. `null` means unknown (task predates this field, or was never claimed) |
| `completed_at` | ISO 8601 / null | Terminal timestamp |
| `progress` | object / null | `{percent, step, message}` — populated during `running` |
| `attempt` | integer | Current attempt number, floored at 1. Cannot distinguish a never-claimed task from one retrying after a dead attempt — use `attempts_started` for that |
| `attempts_started` | integer | Attempts **begun** (not completed). `0` for a task no worker has claimed yet; `1` while the first attempt runs *and* through its retry backoff |
| `max_attempts` | integer | Usually 3 — auto-retry cap for transient failures |
| `input` | object | Varies by `kind` (design: `{prompt, format, ...}`; export: `{canvas_id, format, ...}`) |
| `result` | object / null | Populated on `succeeded`; varies by `kind` (design: `{canvas_id, canvas_url, conversation_id, export}`) |
| `error` | object / null | `{message, retryable, attempt}` — populated on `failed`, and on a **non-terminal** task carrying a previous attempt's failure |
| `credits` | object / null | `{credits_used, credits_remaining}` on `succeeded` |
| `links` | object | HATEOAS-style relations: `self`, `events`, `cancel`, `canvas` |
| `retry_after_ms` | integer / null | Non-null while non-terminal; typically `3000` (3s) |
## Status state machine
```
queued → running → succeeded
→ failed
→ canceled
→ expired (rare; long-queued tasks time out)
```
Terminal: `succeeded`, `failed`, `canceled`, `expired`. Non-terminal: `queued`, `running`.
Derive `is_terminal` = `status in {"succeeded","failed","canceled","expired"}`. Derive `can_export` = `status == "succeeded" && result && result.canvas_id`.
## Distinguishing a retry from a queue wait
A task whose attempt died (execution-cap kill, worker timeout, lost heartbeat) is retried
automatically, and it sits in `queued` for the whole backoff. That is **not** queue
contention, and it is the single most-misread thing about this envelope: teams see a
10-minute `queued` and buy parallelism they do not need.
Tell them apart:
| Signal | Never started (real queue wait) | Retrying after a dead attempt |
| - | - | - |
| `status` | `queued` | `queued` |
| `attempts_started` | `0` | `>= 1` |
| `first_started_at` | `null` | the first attempt's start |
| `error` | `null` | the **previous** attempt's failure |
```
is_retrying = status in {"queued", "running"} and attempts_started >= 1 and error is not None
```
Notes:
* **`>= 1`, not `>= 2`.** `attempts_started` is incremented when a worker *claims* the task,
so a first attempt that is currently `running` already reads `1`, and it stays `1` through
that attempt's backoff. Combine it with `status` — `running` + `attempts_started == 1` is
an ordinary first run, `queued` + `attempts_started == 1` is a retry.
* **`error` on a non-terminal task describes the previous attempt, not the current one.**
Its `attempt` field says which attempt produced it. A task that is `running` again after a
failure deliberately keeps the failing reason for triage — do not render it as "this
attempt failed".
* **Wall-clock attribution.** `created_at → first_started_at` is the true queue wait.
`created_at → started_at` spans every dead attempt and its backoff too, so it overstates
queueing on any retried task.
* **`first_started_at: null` means unknown**, never "never started" — tasks created before
this field shipped have no value for it. Read `attempts_started` for the never-started case.
There is no `retrying` status. The public status taxonomy is frozen, and consumers derive
`is_terminal` from it, so a new value would break existing pollers.
## Discriminator — `kind`
Task shape is polymorphic on `kind`:
| `kind` | `input` includes | `result` includes (on succeeded) |
| - | - | - |
| `design` | `prompt`, `format`, `attachments`, `brand_kit_id`, `number_of_slides`, ... | `canvas_id`, `canvas_url`, `conversation_id`, `export` |
| `remix` | `canvas_id`, `prompt`, `brand_kit_id`, ... | `canvas_id`, `canvas_url`, `source_canvas_id` |
| `export` | declared in the enum; no code path produces a task of this kind today | — |
| `brand_kit_extract` | `url` | `brand_kit_id` |
Always check `kind` before reading `result` fields.
On a succeeded `design` task, `result.export` carries the finished design **already rendered to a file** — `{url, format, status, page_count}`, exported in the canvas's category-default format. Read it directly instead of issuing a separate `POST /v1/canvases/{id}/export`. It is absent when auto-export was disabled (`export_on_complete: {enabled: false}`) or did not finish within the budget.
Note: `POST /v1/canvases/{id}/export` never returns a Task envelope. It returns its own payload — `{status: "completed", url, format, source}` when the render lands inside the \~20s wait budget, or `{status: "in_progress", task_id, retry_after_seconds}` when it doesn't. Poll `GET /v1/canvases/{id}/export-status?task_id=…` for the async case, not `/v1/tasks/{id}`. The `export` task kind and the `export.succeeded` / `export.failed` webhook events are declared in the public taxonomy but nothing emits them today. See [`canvases-and-exports.md`](./canvases-and-exports.md).
## Delivery patterns
Pick one per task — don't stack:
### 1. Webhook (`callback_url`) — recommended for async backends
Pass `callback_url` in the task body. Webhook fires on terminal state only. Requires `moda_live_…` API key auth (OAuth callers get `400`). See [`webhooks.md`](./webhooks.md).
### 2. Polling — simplest for short-lived callers
```
POST /v1/tasks → task envelope with retry_after_ms
loop:
wait retry_after_ms
GET /v1/tasks/{id}
break when status is terminal
```
Respect `retry_after_ms`. Don't poll faster.
### No sync-feel header
The API does not currently implement RFC 7240 `Prefer: wait` — the header is ignored on every endpoint. Operations that are fast (brand-kit creation, remix without a prompt) are simply synchronous; everything else is webhook or polling.
## Timing expectations
| Operation | Typical time |
| - | - |
| `POST /v1/tasks` (design from scratch) | 2–10 min |
| `POST /v1/tasks` with `conversation_id` (follow-up edit) | 1–5 min |
| `POST /v1/remix` **with** a prompt | 2–10 min (it's a design task under the hood) |
| `POST /v1/remix` **without** a prompt (plain duplicate) | \< 1s (synchronous, no task) |
| `POST /v1/brand-kits` (from URL) | 10–30s |
| `POST /v1/canvases/{id}/export` | seconds (signed URL inline); slow renders return a `task_id` after \~20s and finish in the background |
Set user expectations up front. Don't block callers on multi-minute operations.
## Common wrong guesses
* **Polling faster than `retry_after_ms`.** Burns your rate budget for no latency benefit.
* **Reading `response.canvas_id` on a canonical response.** It's `response.result.canvas_id`. The flat field is legacy (`2026-04-12`).
* **Expecting `export` kind from `POST /v1/canvases/{id}/export`.** That endpoint does not return a Task envelope — its `task_id` is polled via `/canvases/{id}/export-status`.
* **Reading a long `queued` as queue contention.** Check `attempts_started` first — a retry after a dead attempt looks identical to a queue wait on `status` alone.
* **Treating `expired` as a failure.** It is terminal (same as the others) but the cause is queue-depth or worker-starvation, not a user-actionable error — retry the task fresh.
## Upstream
* [`docs.moda.app/api/tasks/startTask`](/docs/api/tasks/startTask)
* [`docs.moda.app/api/tasks/getTask`](/docs/api/tasks/getTask)
* [`docs.moda.app/api/versioning`](/docs/api/versioning) — for the legacy → canonical migration map
# Uploads
Source: /docs/.mintlify/skills/moda-api/references/uploads
# Uploads
Four endpoints. All require `uploads:write`.
## Entrypoints
### `POST /v1/uploads` — multipart (files up to \~32 MiB)
```bash theme={null}
curl -X POST https://api.moda.app/v1/uploads \
-H "Authorization: Bearer moda_live_..." \
-H "Layovelle-Version: 2026-05-01" \
-F "file=@/path/to/brief.pdf"
```
Response:
```json theme={null}
{
"id": "file_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"url": "https://api.moda.app/api/v2/images/ref/550e8400-...?h=abc123",
"filename": "brief.pdf",
"mime_type": "application/pdf",
"size_bytes": 245760,
"was_duplicate": false
}
```
Use when you have the bytes locally and the file is under \~32 MiB. Above that, use the signed-URL flow below — the gateway rejects the request before it reaches this handler.
### `POST /v1/uploads/url` + `POST /v1/uploads/register` — signed URL (large files)
The bytes go straight from your client to object storage, bypassing our gateway entirely.
```bash theme={null}
# 1. Mint a short-lived signed PUT URL
curl -X POST https://api.moda.app/v1/uploads/url \
-H "Authorization: Bearer moda_live_..." \
-H "Layovelle-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{"filename": "deck.pptx", "mime_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation"}'
# → {"upload_url": "https://storage.googleapis.com/...", "storage_key": "...", "mime_type": "...", "expires_in_seconds": 600}
# 2. PUT the raw bytes to upload_url with the SAME Content-Type.
# No Authorization header — the signed URL itself is the capability.
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/vnd.openxmlformats-officedocument.presentationml.presentation" \
--data-binary @deck.pptx
# 3. Register it to get a FileUploadResponse
curl -X POST https://api.moda.app/v1/uploads/register \
-H "Authorization: Bearer moda_live_..." \
-H "Layovelle-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{"storage_key": "...", "filename": "deck.pptx", "mime_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation"}'
```
`register` returns the same `FileUploadResponse` shape as multipart, including content-hash dedupe.
**Pass `mime_type` on step 3.** It's optional, but when omitted `register` re-derives the type from the basename embedded in `storage_key` — so a `filename` with no recognizable extension (perfectly legal at step 1, which takes an explicit `mime_type`) fails with `415 "Missing or unrecognizable content type"` *after* you've already paid for the whole PUT. Send the same `mime_type` you declared at step 1 and that can't happen. `filename` is optional too and defaults to the same basename.
Signed URLs expire after `expires_in_seconds` (60–3600, default 600). The TTL bounds the PUT only — `register` doesn't check it. Because `register` hashes the whole blob, a 100+ MB file can take several seconds; set your client timeout accordingly.
### `POST /v1/uploads/from-url`
```bash theme={null}
curl -X POST https://api.moda.app/v1/uploads/from-url \
-H "Authorization: Bearer moda_live_..." \
-H "Layovelle-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{"source_url": "https://example.com/mockup.png"}'
```
Server fetches the URL, validates MIME, stores. Returns the same `FileUploadResponse` shape. Use when the file is already hosted publicly and you don't want to proxy it through your own machine. The fetch has a 30s budget; a slow source returns `400`.
SSRF-validated server-side — internal / localhost / metadata URLs are rejected with `422`.
## Supported types
* Images: any `image/*` (PNG, JPEG, WebP, GIF, SVG, …)
* Documents: PDF, PPTX / PPT, DOCX / DOC, XLSX / XLS
* Text: CSV, plain text, Markdown, JSON
* Video (upload + in-app viewing, no transcoding): MP4, WebM, MOV
* Audio (upload + in-app playback, no transcoding): MP3, M4A, AAC, WAV, FLAC, OGG / OGA
* Figma local-copy exports: `.fig`, `.deck` (brand-kit import)
Anything else is rejected with `415 unsupported media type`.
## Size limits
The gateway cap is fixed; the application cap varies by workspace plan:
| Path | Effective max | Why |
| - | - | - |
| `POST /v1/uploads` (multipart) | **\~32 MiB** | Our API gateway caps inbound HTTP/1 request bodies. Larger requests are rejected before reaching the handler. |
| `POST /v1/uploads/url` + `POST /v1/uploads/register` | **`max_file_bytes`** | Bytes go client → storage directly, so the gateway cap doesn't apply. |
| `POST /v1/uploads/from-url` | **`max_file_bytes`** | The server does the fetch; nothing traverses our ingress. |
`max_file_bytes` is the application cap and it applies to every path. It is **per-workspace, not per-deployment** — 250 MB is the maximum, and a free workspace is capped lower — so read it from `GET /v1/uploads/limits` and cache it per API key rather than hard-coding a number. The same response carries `max_file_bytes_is_plan_limit`: when it is `true`, the ceiling is the workspace's plan rather than the platform, so the remedy to report is an upgrade and not only a smaller file. Oversize uploads return **`413 payload too large`** — on `POST /v1/uploads/register`, the staged blob is deleted automatically.
## Deduplication
Content-hash dedupe. Uploading the same bytes twice returns:
```json theme={null}
{ "id": "file_01HT9...", "was_duplicate": true, ... }
```
`id` / `url` / `filename` / `mime_type` / `size_bytes` point at the existing record. Safe to call repeatedly — no duplicate storage, no duplicate billing.
## Using in a task
```bash theme={null}
curl -X POST https://api.moda.app/v1/tasks \
-H "Authorization: Bearer moda_live_..." \
-H "Layovelle-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Build a pitch deck from the attached brief",
"format": { "category": "slides", "width": 1920, "height": 1080 },
"attachments": [
{ "file_id": "file_01HT9WK8...", "role": "source", "label": "Q2 strategy brief" }
]
}'
```
### Roles
| Role | Meaning |
| - | - |
| `source` | Extract content from this file (brief, notes, CSV) |
| `reference` | Emulate this file's style (mood board, screenshot) |
| `asset` | Use verbatim (logo, hero image) |
| `import` | Convert a PPTX deck into editable slides on the canvas, then design against it (PPTX-only; any other file type is rejected) |
Pick the right role — it determines what the agent does with the file. See the Attachments section of `moda-mcp/references/gotchas.md` for details; the attachment shape is identical between MCP and REST.
## URL-form attachment (legacy, less preferred)
```json theme={null}
{
"attachments": [
{ "url": "https://example.com/mockup.png", "type": "image" }
]
}
```
No role metadata. Still works for hosted public URLs when you want to avoid the upload roundtrip. Mix with file-id-form in the same list freely.
## Worked example
```python theme={null}
import httpx, os
HEADERS = {"Authorization": f"Bearer {os.environ['MODA_API_KEY']}",
"Layovelle-Version": "2026-05-01"}
with httpx.Client(base_url="https://api.moda.app/v1", headers=HEADERS, timeout=60) as c:
with open("brief.pdf", "rb") as f:
upload = c.post("/uploads", files={"file": ("brief.pdf", f, "application/pdf")}).json()
task = c.post("/tasks", json={
"prompt": "Build a 10-slide pitch deck from the brief",
"format": {"category": "slides", "width": 1920, "height": 1080},
"number_of_slides": 10,
"attachments": [
{"file_id": upload["id"], "role": "source", "label": "Q2 strategy"},
],
"idempotency_key": "q2-strategy-deck-v1",
}).json()
```
## Common wrong guesses
* **Posting files as JSON-encoded base64 into `POST /v1/uploads`.** Use multipart (`multipart/form-data`).
* **Pushing a >32 MiB file at `POST /v1/uploads` and reading the `413` as "the file is over Layovelle's limit".** The app cap is `max_file_bytes` from `GET /v1/uploads/limits`, at most 250 MB; that `413` is the gateway. Switch to the signed-URL flow.
* **PUT-ing to `upload_url` with a different `Content-Type` than you declared on `/uploads/url`.** The signature is bound to the content type — a mismatch fails the PUT at storage.
* **Calling `POST /v1/uploads/register` before the PUT finished, or after a signed URL expired without the bytes ever landing.** Both return `422` with "No blob found at storage\_key". Note the TTL bounds the **PUT** only — `POST /v1/uploads/register` does not check it, so once the blob is staged you can register whenever. A late register is never a reason to re-upload.
* **Using the proxy URL (`/api/v2/images/ref/…`) as if it were stable public content.** It includes an auth hash; it's a stable reference for use in Layovelle-side operations, not a CDN URL.
* **Re-uploading on every run when content hasn't changed.** Dedupe handles it server-side, but you still pay a roundtrip. Cache `file_id` in your app.
* **Skipping `role` in the attachment.** It drops back to a generic "reference" semantic, which is usually not what you want. Always set `role`.
* **Mixing `file_id` and `url` in one attachment item.** Pick one per item. Mix items within the array is fine.
## Upstream
* [`docs.moda.app/api/uploads/uploadFile`](/docs/api/uploads/uploadFile)
* [`docs.moda.app/api/uploads/uploadFromUrl`](/docs/api/uploads/uploadFromUrl)
* [`docs.moda.app/api-reference/large-file-uploads`](/docs/api-reference/large-file-uploads) — the signed-URL flow in full
# Versioning
Source: /docs/.mintlify/skills/moda-api/references/versioning
# Versioning
Layovelle's API uses a calendar-dated version string on the `Layovelle-Version` request header. Pinning guarantees your wire shape stays stable.
## Supported versions
| Version | Role | Status | Sunsets |
| - | - | - | - |
| `2026-05-01` | **Canonical** (newest) | Latest response shape | — |
| `2026-04-12` | **Default** (legacy) | Current default for unpinned traffic | TBD (date to be re-announced) |
* **Canonical** = routes emit this shape natively. Pin this in production.
* **Default** = what unpinned requests get. Advances to the next-newest supported version on sunset dates.
* **Sunset** = after this date, pinning the sunset version returns `400 unsupported_version`.
## Sending the header
```
Layovelle-Version: 2026-05-01
```
Pin explicitly on every request. Don't rely on the default.
## Omitting
If you omit the header, the server resolves to the current **default**. Integrations keep working across version bumps until a sunset advances the default. **For production, pin.**
## Unknown version
```json theme={null}
{
"error": {
"type": "invalid_request",
"code": "unsupported_version",
"message": "Layovelle-Version '2026-01-01' is not supported. Supported versions: 2026-04-12, 2026-05-01.",
"details": {
"requested": "2026-01-01",
"supported": ["2026-04-12", "2026-05-01"]
},
"request_id": "019d8996-…"
}
}
```
Read `details.supported` — that's the ground truth, and it updates automatically.
## Response header
Every response carries the resolved version:
```
HTTP/1.1 200 OK
Layovelle-Version: 2026-05-01
```
If you omit the request header, read the response's `Layovelle-Version` to know which shape you got.
## Canonical (`2026-05-01`) vs legacy (`2026-04-12`)
### Task endpoints
**Legacy flat shape (`2026-04-12`):**
```json theme={null}
{
"job_id": "task_01HT9...",
"status": "completed",
"canvas_id": "cvs_...",
"canvas_url": "https://...",
"conversation_id": null,
"task": "Create a sales deck",
"progress_percent": 100,
"is_terminal": true,
"can_export": true,
"retry_after_seconds": null,
...
}
```
**Canonical Task envelope (`2026-05-01`):**
```json theme={null}
{
"id": "task_01HT9...",
"kind": "design",
"status": "succeeded",
"input": { "prompt": "Create a sales deck" },
"result": { "canvas_id": "cvs_...", "canvas_url": "https://..." },
"progress": null,
"error": null,
"credits": { "credits_used": 5, "credits_remaining": 12 },
"links": { "self": "/v1/tasks/...", "cancel": null, "canvas": "..." },
"retry_after_ms": null
}
```
Migration map:
| Legacy | Canonical |
| - | - |
| `response.job_id` | `response.id` |
| `response.status == "completed"` | `response.status == "succeeded"` |
| `response.status == "cancelled"` | `response.status == "canceled"` |
| `response.canvas_id` | `response.result.canvas_id` |
| `response.canvas_url` | `response.result.canvas_url` |
| `response.conversation_id` | `response.result.conversation_id` |
| `response.task` | `response.input.prompt` |
| `response.progress_percent` | `response.progress.percent` |
| `response.current_step` | `response.progress.step` |
| `response.error` (string) | `response.error.message` (object) |
| `response.is_terminal` | derived: `status in ("succeeded","failed","canceled","expired")` |
| `response.can_export` | derived: `status == "succeeded" && result.canvas_id` |
| `response.retry_after_seconds` | `response.retry_after_ms / 1000` |
### List endpoints: offset → cursor
**Legacy:**
```json theme={null}
{ "canvases": [...], "total": 123, "offset": 20, "limit": 20, "has_more": true }
```
**Canonical:**
```json theme={null}
{ "data": [...], "next_cursor": "eyJ2..." }
```
Iterate until `next_cursor === null`. See [`pagination.md`](./pagination.md).
### Webhook events
Legacy `job.*` names are retired in favor of `task.*` / `export.*`. Spelling changed: `cancelled` → `canceled` (matches the `PublicTaskStatus.CANCELED` enum). Non-terminal states (`queued`, `running`, `expired`) no longer fire webhooks.
| Legacy | Canonical |
| - | - |
| `job.completed` | `task.succeeded` |
| `job.failed` | `task.failed` |
| `job.cancelled` | `task.canceled` |
| `job.dead_letter` | `task.failed` with `data.error.retryable: false` |
| `job.running` | removed (poll `/v1/tasks/{id}`) |
| `job.queued` | removed |
## What triggers a new version
Only **breaking** response-shape changes. Additive changes (new endpoints, new optional params, new response fields) ship at the current canonical version without a bump.
Breaking:
* Removing / renaming a response field
* Removing an endpoint or path
* Changing a field's type
* Tightening validation
* Changing status vocabulary
Additive (no bump):
* New endpoint / path / optional param / response field
* New error `code` within an existing `type`
Tolerate unknown fields in your client.
## Version-bump cadence
* New canonical arrives.
* Previous default stays supported for a sunset window (usually 2–3 weeks).
* On the sunset date, the older version retires; default advances.
* If you pinned, you upgrade on your schedule.
## Recommendations
* **Pin** `Layovelle-Version` explicitly on every production request.
* **Log the response `Layovelle-Version`** so you can tell which shape your traffic is actually hitting.
* **When you see `400 unsupported_version`**, read `details.supported` from the error.
* **Subscribe to the Layovelle changelog** for sunset dates.
## Common wrong guesses
* **Omitting `Layovelle-Version` in production.** The default will advance on sunset and your response shape will silently change.
* **Parsing legacy `job_id` / `canvas_id` from canonical responses.** They're nested under `id` and `result.canvas_id`.
* **Spelling `cancelled` with two Ls.** Canonical spelling is `canceled` (one L). `cancelled` is a legacy artifact.
* **Assuming a sunset version still works.** After the sunset date, pinning it returns `400`.
## Upstream
[`docs.moda.app/api/versioning`](/docs/api/versioning)
# Webhooks
Source: /docs/.mintlify/skills/moda-api/references/webhooks
# Webhooks
When you start a task with `POST /v1/tasks` or `POST /v1/remix`, pass a `callback_url` to receive an HTTPS POST when the task reaches terminal state. Webhooks fire **terminal-only** — there is no progress stream.
## Important auth restriction
`callback_url` is **API-key-auth only**. OAuth callers (MCP sessions) get:
```json theme={null}
{ "error": { "type": "invalid_request", "code": "unsupported_auth",
"message": "callback_url is only supported for API-key authenticated callers." } }
```
Use polling from OAuth clients.
## Event types
Closed set. If your handler sees anything outside this list, it's a bug to report.
| Event | Fires when |
| - | - |
| `task.succeeded` | Design or remix task finished successfully |
| `task.failed` | Task failed (transient or dead-lettered — see `data.error.retryable`) |
| `task.canceled` | Task was canceled (via `POST /v1/tasks/{id}/cancel` or app UI) |
| `export.succeeded` | Declared for async canvas exports — **not emitted today** |
| `export.failed` | Declared for async canvas exports — **not emitted today** |
Non-terminal states (`queued`, `running`, `expired`) **do not** fire webhooks today. If you need running/progress beats, poll `GET /v1/tasks/{id}`.
The two `export.*` types are part of the closed taxonomy but nothing emits them: an async export (`POST /v1/canvases/{id}/export` returning `status: "in_progress"`) is followed by polling `GET /v1/canvases/{id}/export-status?task_id=…`, not by a webhook. Handle the types defensively if you like — just don't wait on them.
## Payload
```json theme={null}
{
"id": "evt_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"type": "task.succeeded",
"created": "2026-04-15T12:01:00+00:00",
"api_version": "2026-05-01",
"data": {
"id": "task_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"kind": "design",
"status": "succeeded",
"result": {
"canvas_id": "cvs_...",
"canvas_url": "https://...",
"export": { "status": "completed", "url": "https://...", "format": "pptx", "page_count": 12 }
},
... /* full canonical Task envelope */
}
}
```
Fields:
| Field | Notes |
| - | - |
| `id` | `evt_…` event ID. **Stable across retries of the same event** — use as your idempotency key. |
| `type` | One of the event types above. |
| `created` | ISO 8601 timestamp when the event was generated. |
| `api_version` | The canonical Layovelle-Version of the `data` payload (currently `2026-05-01`). |
| `data` | Full canonical Task envelope. Same shape as `GET /v1/tasks/{id}`. |
For `task.failed` / `export.failed`, inspect `data.error.retryable` to distinguish transient from dead-lettered failures.
On a `task.succeeded` event for a programmatic design task, `data.result.export` carries the **auto-exported** artifact (`{status, url, format, page_count}`) — the task renders an export of its result in the canvas's category-default format when it finishes. Use this URL directly instead of issuing a separate `POST /v1/canvases/{id}/export`. The field is absent when auto-export was disabled (`export_on_complete: {enabled: false}` on the task) or did not finish within the budget.
## Signature verification
Every webhook POST includes two headers:
| Header | Value |
| - | - |
| `X-Webhook-Signature` | `v1=<hex>` — HMAC-SHA256 |
| `X-Webhook-Timestamp` | Unix seconds when the webhook was sent |
Signature is computed over `{timestamp}.{raw_body}` using the **webhook signing secret** that was shown once in **Settings → Developer → REST API** when you created the API key.
### Node.js
```js theme={null}
import crypto from "node:crypto";
function verifyWebhook(signingSecret, rawBody, sigHeader, tsHeader) {
const message = `${tsHeader}.${rawBody}`;
const expected = crypto.createHmac("sha256", signingSecret).update(message).digest("hex");
const received = sigHeader.replace(/^v1=/, "");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(received, "hex"),
);
}
```
### Python
```python theme={null}
import hashlib, hmac
def verify_webhook(signing_secret: str, raw_body: bytes, sig_header: str, ts_header: str) -> bool:
message = f"{ts_header}.{raw_body.decode()}"
expected = hmac.new(signing_secret.encode(), message.encode(), hashlib.sha256).hexdigest()
received = sig_header.removeprefix("v1=")
return hmac.compare_digest(expected, received)
```
Use `timingSafeEqual` / `hmac.compare_digest` — not raw `==` — to avoid timing attacks.
## Replay protection
Reject any webhook with an `X-Webhook-Timestamp` older than **5 minutes**. A valid recent signature could otherwise be replayed indefinitely.
```python theme={null}
import time
if abs(time.time() - int(ts_header)) > 300: # 5 minutes
return 401
```
## Retry behavior
Layovelle makes up to **3 delivery attempts** total (initial + 2 retries). If your endpoint returns a non-2xx status or doesn't respond within **30 seconds**, Layovelle retries with a short backoff:
| Attempt | When |
| - | - |
| 1 (initial) | on task completion |
| 2 (retry) | \~1s after attempt 1 fails |
| 3 (final retry) | \~5s after attempt 2 fails |
After the third attempt fails, the webhook is dropped. You can still fetch the task via `GET /v1/tasks/{id}`, or replay the dropped delivery from the delivery log (`POST /v1/webhook_deliveries/{id}/redeliver`) once your endpoint is fixed.
## Deduplication
The event envelope `id` (`evt_…`) is **stable across retries of the same event**. Use it as your dedupe key:
```python theme={null}
# pseudocode
if processed_events.contains(event["id"]):
return 200 # acknowledge; already handled
processed_events.add(event["id"])
handle(event)
return 200
```
Retries of the *same* event carry the same `id`. Different events (e.g. `task.succeeded` for two different tasks) have different `id`s — they're not dedupe-collisions.
## Handler best practices
1. **Return 200 fast.** Process asynchronously — enqueue the event, don't do the work inline. Layovelle's 30s timeout will retry if you're slow.
2. **Verify signature before trusting the payload.** Even for non-sensitive work.
3. **Check the timestamp** (>5 min old → reject).
4. **Use `evt_…` as your idempotency key.**
5. **HTTPS only.** Layovelle rejects non-HTTPS callback URLs up front.
6. **Log `request_id`** from the task envelope inside `data` when reporting issues.
## End-to-end handler (Python + FastAPI)
```python theme={null}
from fastapi import FastAPI, Header, HTTPException, Request
import os, time, hashlib, hmac
app = FastAPI()
SECRET = os.environ["MODA_WEBHOOK_SECRET"]
processed = set() # use Redis / DB in production
@app.post("/webhooks/moda")
async def moda_webhook(
req: Request,
x_webhook_signature: str = Header(...),
x_webhook_timestamp: str = Header(...),
):
body = await req.body()
if abs(time.time() - int(x_webhook_timestamp)) > 300:
raise HTTPException(401, "stale timestamp")
expected = hmac.new(
SECRET.encode(),
f"{x_webhook_timestamp}.{body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
received = x_webhook_signature.removeprefix("v1=")
if not hmac.compare_digest(expected, received):
raise HTTPException(401, "bad signature")
event = await req.json()
if event["id"] in processed:
return {"ok": True}
processed.add(event["id"])
# enqueue async; return 200 fast
enqueue_task(event)
return {"ok": True}
```
See [`../recipes/webhook-receiver.md`](../recipes/webhook-receiver.md) for both Node/Express and FastAPI worked examples with enqueue + retry plumbing.
## Common wrong guesses
* **Using `callback_url` from an OAuth/MCP session.** Rejected. API-key auth only.
* **Expecting `task.running` / `task.queued` events.** Terminal-only today.
* **Using raw `==` to compare signatures.** Timing-attack risk. Use `hmac.compare_digest` / `crypto.timingSafeEqual`.
* **Not verifying timestamp age.** Replayable. Reject >5 min.
* **Doing work inside the handler.** 30s timeout will re-fire retries. Enqueue, then 200.
* **Forgetting the signing secret.** It's shown once with the API key. Store it in your secret manager alongside the key.
* **Handling retries as new events.** Same `evt_…` id → same event. Dedupe.
## Upstream
* [`docs.moda.app/api/webhooks`](/docs/api/webhooks)
* [`authentication.md`](./authentication.md) — where the signing secret comes from
# Bulk variants
Source: /docs/.mintlify/skills/moda-mcp/recipes/bulk-variants
# Bulk variants
**Problem:** The user wants several versions of a design — personalized per persona, per customer, per channel, per brand. Bulk is the workflow that breaks the most "make one design" assumptions in the rest of this skill, so read this in full before fanning out.
There are **three** valid shapes. Pick based on what the user already has and what they want:
| User situation | Pattern |
| - | - |
| "Make 10 LinkedIn ads, one per persona" — N independent designs from scratch | **A — fan out `start_design_task`** |
| "Make 10 versions of *this* canvas" / "rebrand this template for each of our 5 clients" — N variants of a source canvas, with or without per-variant brand swap | **B — fan out `start_design_task` with `template_canvas_id`** (preserves structure; server auto-picks content-only remix vs full rebrand) |
| "Multi-panel IG carousel" — one post with multiple linked panels | **C — single `start_design_task` with `format_category="carousel"`** |
Carousel (Pattern C) page count clamps to the caller's plan tier, same as slides — see [`../references/gotchas.md#format-and-dimensions`](../references/gotchas.md#format-and-dimensions). The rest of this recipe is about A and B.
## The single biggest gotcha — concurrency caps
`start_design_task` and `remix_design` share a **per-organization in-flight cap** (pooled across every team in the org, not per-team):
| Plan | Max concurrent tasks |
| - | - |
| `free` / `free_beta` | 3 |
| `paid` | 10 |
| `ultra` / `enterprise` | 15 |
Fan out N > cap and you'll get an `AgentJobRateLimitError` partway through — surfacing as a tool error from `start_design_task`. The skill's job is to never let the user see that error. Use a **windowed launch**: keep at most `cap` tasks in flight, slot in the next prompt every time one terminates.
**Get the cap from `get_moda_bootstrap`** — its `concurrency_cap` field is the exact number for the user's plan. If you haven't called `get_moda_bootstrap` yet, default to **3** (safe everywhere) and bump up after the first batch confirms headroom.
## Pattern A — fan out `start_design_task` (N independent designs)
Best when each variant has fundamentally different content: per-persona ad copy, per-customer pitch decks, per-region landing pages. There is no source canvas to preserve structure from.
**User:** "Create 10 LinkedIn ads for our automation tool — each tailored to a different ICP persona: CFOs, CTOs, heads of ops, ..."
**Agent** (after the gate, before fanning out):
> Kicking off 10 LinkedIn ads, one per persona. I'll run them in batches of 3 (concurrency cap) and post each canvas URL as soon as it's ready — you can open them while the others are still rendering.
```python theme={null}
personas = ["CFOs", "CTOs", "heads of ops", ...] # the user-provided list
dimensions = dict(format_category="social", format_width=1080, format_height=1080,
brand_kit_id=chosen_brand_kit_id) # asked once, before the loop (see notes)
window = bootstrap_response.get("concurrency_cap", 3) # use the bootstrap value; fall back to 3 if unknown
def build_prompt(persona):
return f"""
LinkedIn ad for our automation tool, tailored to {persona}.
Lead with the single pain point this persona feels most acutely
and the one metric they measure success on. Bold typography, clean
layout, one CTA.
"""
in_flight = {} # persona -> task handle
done = {} # persona -> terminal status
queue = list(personas) # to-do
# 1. Seed the window
while len(in_flight) < window and queue:
p = queue.pop(0)
in_flight[p] = start_design_task(prompt=build_prompt(p), **dimensions)
# tell the user immediately — canvas_url is in the queued response
post(f"Started {p}: {in_flight[p]['canvas_url']}")
# 2. Drain + refill
while in_flight:
# poll the whole pool in one call — cheaper than N get_task_status calls.
# This is a pre-filter only: a task absent from the "running" list is NOT
# necessarily terminal — it may still be "queued" (hasn't started yet) or
# briefly missing from this page. Confirm with get_task_status before
# ever removing a slot; never trust absence-from-pool by itself.
pool = list_tasks(status="running", limit=window * 2)
pool_ids = {t["task_id"] for t in pool["tasks"]}
for persona, handle in list(in_flight.items()):
if handle["task_id"] in pool_ids:
continue # confirmed still running
# Not in the running pool — could be terminal, or still "queued".
# is_terminal is the only thing allowed to free this slot.
s = get_task_status(task_id=handle["task_id"])
if not s.get("is_terminal"):
continue # e.g. still queued — leave the slot occupied
done[persona] = s
del in_flight[persona]
post_done(persona, s) # deliver this one as it finishes
if queue:
p = queue.pop(0)
in_flight[p] = start_design_task(prompt=build_prompt(p), **dimensions)
post(f"Started {p}: {in_flight[p]['canvas_url']}")
sleep(3) # respect retry_after_seconds from any poll
```
Notes on the loop:
* **Never free a slot on absence-from-pool alone.** `list_tasks(status="running", ...)` is a cheap pre-filter, not the correctness check — a task can be missing from it while still "queued" (not yet running) rather than terminal. Only `get_task_status(...)["is_terminal"] == true` may remove a handle from `in_flight` and launch its replacement; skipping this check overruns the concurrency window the whole recipe exists to respect.
* `list_tasks(status="running", ...)` is one RPC for the whole pool. Beats `get_task_status` × N per tick.
* **Post each canvas URL when the task is queued**, not when it finishes. The user can open it immediately — they'll see the agent's progress in the canvas itself.
* **Deliver each result as it terminates.** Bulk feels much faster when 1/10 done shows up in 90s than when all 10 land together at 8 min.
* `post_done(persona, s)` surfaces `s["error"]` if the task failed. Don't let one bad persona block the rest.
### What goes in the prompt vs the parameters
* **Per-persona detail belongs in the prompt** — rewrite the pain point, metric, and CTA for each. Don't just template `{persona}` into a generic sentence.
* **Dimensions and `format_category` stay constant** across the batch. They're parameters.
* **One brand kit for all** unless the user says otherwise. Settle it once before the loop — ask the user which kit (one of theirs via `list_brand_kits`, a new one, or none; new designs without a choice are refused with `brand_kit_choice_required`) — then pass that same `brand_kit_id` (or `skip_brand_kit=true`) on every call. Don't fetch the kit per task.
## Pattern B — fan out `start_design_task` with `template_canvas_id` (N variants of a source canvas)
Best when the user has an existing canvas they're happy with and wants variations *of that design* — same structure, different copy, different brand. The server copies the source, applies the resolved brand kit on the copy, then runs the agent against the copy. The original is never touched.
Skill selection is automatic from brand-kit comparison:
* Same brand kit as the source → content-only remix (preserve design, swap content)
* Different `brand_kit_id` → full rebrand (rework colors, fonts, copy, imagery)
* `skip_brand_kit=True` → no forced skill; the curator decides
**User:** "I love this product flyer — make me 5 versions, one for each of these resellers: \[list]. Same layout, swap the logo and the regional pricing."
```python theme={null}
source_id = "cvs_…" # the flyer canvas the user pointed at
resellers = [...] # 5 items, each with optional brand_kit_id for per-client rebrand
window = 3
queue = list(resellers)
in_flight = {}
def build_prompt(reseller):
return f"""
Customize this flyer for {reseller['name']}:
- replace the headline price with {reseller['price']}
- update the contact strip with {reseller['phone']} / {reseller['email']}
"""
while len(in_flight) < window and queue:
r = queue.pop(0)
in_flight[r["name"]] = start_design_task(
template_canvas_id=source_id,
prompt=build_prompt(r),
brand_kit_id=r.get("brand_kit_id"), # per-client brand if rebranding; omit for same-brand fill
canvas_name=f"Flyer — {r['name']}",
# wait defaults to False on start_design_task — handle returned immediately
)
post(f"Started {r['name']}: {in_flight[r['name']]['canvas_url']}")
# Same drain + refill loop as Pattern A
```
Why `template_canvas_id` over `start_design_task(canvas_id=source_id, ...)`:
* **`canvas_id` edits the original.** Five resellers, one canvas — the last one wins. The user loses their template.
* **`template_canvas_id` duplicates first.** Original stays clean; you get N independent copies.
* **Layout / structure carry forward automatically.** You only specify what changes in the prompt.
* **Per-variant brand swap is first-class.** Pass a different `brand_kit_id` per call to produce the same design rebranded for each client.
**Note:** `template_canvas_id` is mutually exclusive with both `canvas_id` and `conversation_id` — passing either combination raises a tool error. See [`../references/gotchas.md#conversations-vs-canvases-vs-remixes`](../references/gotchas.md#conversations-vs-canvases-vs-remixes) for the full matrix.
### When to use `remix_design` instead
`remix_design(canvas_id=…)` without a prompt is a **synchronous plain duplicate** — useful when the user just wants a copy they'll edit themselves. With a prompt, it's an older async path that pre-dates `template_canvas_id`; prefer `template_canvas_id` for new bulk flows (cleaner brand-kit handling, automatic skill selection, no `wait` default asymmetry).
## Recovery: a task in the batch fails
`get_task_status` returns `{status: "failed", error: "…"}` for that task. Common cases:
* **Rate limit** (`AgentJobRateLimitError` text in `error`) — your window is too wide. Drop it by one, retry that persona/reseller.
* **Billing** (out of credits) — stop the whole batch. Tell the user; the remaining queued items would all fail the same way.
* **Validation** — bad prompt or parameters for that one task. Skip it, continue with the rest.
* **Upstream model error** — retry that one task once. If it fails again, skip.
Don't silently retry on `failed` unless you specifically know the error is transient. Surface the persona/reseller that failed so the user can decide.
## Aborting a bulk run mid-flight
If the user changes their mind ("never mind, kill them") or you spot a systemic issue (every task failing the same way), call `cancel_task(task_id)` on each in-flight handle. Already-terminal tasks are no-ops.
## See also
* [`../references/gotchas.md`](../references/gotchas.md) — `wait` asymmetry, concurrency caps, format defaults, carousel page-count tier clamp, `not_ready` retry, and the rest of the silent-fail set
# Create new design
Source: /docs/.mintlify/skills/moda-mcp/recipes/create-new-design
# Create a new design
**When to use:** The user wants something built from scratch — no source canvas, no template. "Make me a deck", "create a LinkedIn post", "generate a one-pager".
## Tool call
```python theme={null}
start_design_task(
prompt="…",
format_category="slides", # required for non-slide intents
format_width=1920, # explicit dimensions for predictable output
format_height=1080,
brand_kit_id="bk_…", # the kit the USER chose — see "Which brand kit?" below
)
```
## Decisions before the call
| Question | How to answer |
| - | - |
| What is the user making? | Set `format_category` explicitly: `slides`, `social`, `carousel`, `pdf`, `diagram`, `ui`, `animation`, `prints`, `web-ads`, `other`. Omit it and you get a generic `other` canvas at 1080×1080 with no format skill, so pass the real format. |
| How many slides / panels? | State it in the prompt text ("10-slide deck"). No `number_of_slides` MCP parameter. For carousels use `carousel_page_count` (clamps to your plan tier, same as slides). |
| Which brand kit? | No `session.brand_kit_id`: ask the user and **wait for the answer** before this call, whatever `brand_kit_count` is — offer their kits (`list_brand_kits` shows them), a new kit (`create_brand_kit(url=…)` if they want one), or none. Don't pick for them; neither the team default nor a team's only kit is an answer. Pass their pick as `brand_kit_id`, or `skip_brand_kit=true` if they want no brand. A kit (or "no brand") they already named (chat, project instructions, memory) counts — pass it. Skipping this is refused with `brand_kit_choice_required`, which lists the kits and creates nothing. |
| Are there reference materials? | Upload via `upload_file(source_url=…)` and pass `{file_id, role: "source"\|"reference"\|"asset"\|"import"}` in `attachments`. (`import` is PPTX-only — it loads the deck as editable slides into a new canvas **at the deck's own page size**, so the `format_width`/`format_height` above do not apply: send only `format_category="slides"` with it, or no format at all. Any other format field alongside an `import` is a 422, not a silent fallback.) For existing canvases as inspiration, use `reference_canvas_ids`. |
## Returns
Task handle in milliseconds — `task_id`, `canvas_id`, `canvas_url`, `conversation_id`, `status: "queued"`. Poll `get_task_status(task_id)` at `retry_after_seconds` until `is_terminal == true`.
The succeeded task already carries the finished design **as a rendered file** at `result.export` — `{url, format, status, page_count}`, exported in the canvas's category-default format. Hand that `url` to the user directly. Only call `export_canvas(canvas_id=…, format=…)` when they need a *different* format or a specific page.
## See also
* [`../references/gotchas.md#format-and-dimensions`](../references/gotchas.md#format-and-dimensions) — `format_category` defaults, carousel page-count tier clamp, slide-count placement
* [`edit-existing-canvas.md`](./edit-existing-canvas.md) — when the user has an existing canvas to modify
* [`fill-template.md`](./fill-template.md) — when there's a source canvas to copy + customize
# Edit existing canvas
Source: /docs/.mintlify/skills/moda-mcp/recipes/edit-existing-canvas
# Edit an existing canvas
**When to use:** The user wants to modify a canvas they already have, in place. "Add a footer to my deck", "change the headline on slide 2", "fix the date in the resume". The original canvas is mutated.
## Tool call
```python theme={null}
start_design_task(
canvas_id="cvs_…",
prompt="…",
# format_category, dimensions, brand_kit are inferred from the existing canvas
)
```
## Destructive — call this out to the user
`canvas_id` mutates the source canvas. Five edits → five revisions of the same canvas. If the user wants variations of the canvas while keeping the original clean, use [`fill-template.md`](./fill-template.md) or [`rebrand-template.md`](./rebrand-template.md) instead.
## Mutually exclusive
`canvas_id`, `template_canvas_id`, and `conversation_id` cannot be combined.
* `canvas_id` + `template_canvas_id` → tool error.
* `template_canvas_id` + `conversation_id` → tool error.
* `canvas_id` + `conversation_id` → no error, but `canvas_id` is **silently ignored**. The agent uses the conversation's canvas.
## Iterating in conversation
For follow-up tweaks ("now make the title bigger", "add a third bullet") on the same canvas, pass `conversation_id` from the previous response instead of `canvas_id`. The agent keeps full context of prior turns.
```python theme={null}
# turn 2: tweak slide 1
start_design_task(
prompt="Make the title on slide 1 about 30% larger.",
conversation_id=prior_response["conversation_id"],
)
```
## See also
* [`create-new-design.md`](./create-new-design.md) — start from scratch instead
* [`fill-template.md`](./fill-template.md) — produce a new canvas that mirrors the source structure
* [`../references/gotchas.md#conversations-vs-canvases-vs-remixes`](../references/gotchas.md#conversations-vs-canvases-vs-remixes) — full mutual-exclusion matrix
# Fill template
Source: /docs/.mintlify/skills/moda-mcp/recipes/fill-template
# Fill a template (content remix, same brand)
**When to use:** The user has a source canvas they like — usually a designed template — and wants a new canvas that **keeps the structure and styling** but swaps in new content. "Make this for our Q3 launch", "fill this template with our customer story". The original canvas is preserved.
## Tool call
```python theme={null}
start_design_task(
template_canvas_id="cvs_…", # the source canvas to copy
prompt="Fill this template for Acme's Q3 launch. Replace the headline with…",
# brand_kit_id omitted → the source canvas's brand kit applies on the copy.
# That's what triggers the "within-team-remix" skill: content swap, not rebrand.
)
```
## What the server does
The server:
1. Copies the source canvas into the caller's team (original untouched).
2. Applies the resolved brand kit to the copy. When it **matches** the source's brand kit (or is omitted with no session preference, so the source's kit is preserved), the agent runs the `within-team-remix` skill — content-only changes.
3. Returns a task handle for the new canvas. Poll `get_task_status` as usual.
## Source canvas access
The source must be readable by the caller's team. `template_canvas_id` accepts **any** canvas the team can read — it doesn't need to be flagged as a template type.
## Mutually exclusive
`template_canvas_id` is mutually exclusive with both `canvas_id` and `conversation_id`. Passing either combination raises a tool error.
## Prompt notes
The agent reads the prompt as a content-edit brief over the existing layout. Be specific about what to replace (headline, body, image, data) and what to keep. If the prompt asks for a rebrand ("change everything to dark mode") you actually want [`rebrand-template.md`](./rebrand-template.md) — pass a different `brand_kit_id`.
The selector is the **brand kit, not the prompt wording**. Omitting `brand_kit_id` (or
passing the source's) is what picks the content-only path; adding "keep the existing
colors" to the prompt buys nothing. Conversely, writing "rebrand this" into the prompt
does *not* trigger a rebrand when the kit matches — pass a different `brand_kit_id`
([`rebrand-template.md`](./rebrand-template.md)).
## See also
* [`find-a-template.md`](./find-a-template.md) — how to find a template and the three-rule contract for its built-in instructions
* [`rebrand-template.md`](./rebrand-template.md) — same workflow but pass a different `brand_kit_id` to drive a full rebrand
* [`bulk-variants.md`](./bulk-variants.md) — Pattern B uses `template_canvas_id` to fan out N variants
* [`../references/gotchas.md#conversations-vs-canvases-vs-remixes`](../references/gotchas.md#conversations-vs-canvases-vs-remixes) — when to fill a template vs edit in place vs iterate in conversation
# Find a template
Source: /docs/.mintlify/skills/moda-mcp/recipes/find-a-template
# Find a template (and the rules it carries)
**When to use:** Before filling or rebranding a template ([`fill-template.md`](./fill-template.md), [`rebrand-template.md`](./rebrand-template.md)), or whenever the user says "template", "theme", or "use one of our decks as a starting point" and you don't already have a `canvas_id` in hand.
## Templates vs themes
| `template_type` | What it is | Format |
| - | - | - |
| `template` | A finished design meant to be copied and refilled with new content — a deck, a social post, a one-pager, anything. | Any format |
| `theme` | A slides-only canvas supplying reusable page layouts, attached to a brand kit's `default_theme_canvas_id` and auto-applied to fresh slide decks made under that kit. | Always `slides` |
If a user says "template" loosely and means a deck or a post, they mean `template` — `theme` is the narrower, slides-only, brand-kit-attached concept. Don't reach for it unless the user is specifically talking about layouts a brand kit applies automatically.
## Finding one
Two lanes, same filters — pick by who the results are for:
```python theme={null}
# The user should see and pick — renders the visual gallery
list_my_canvases(query="pitch deck", template_type="template", limit=20)
# You just need a template_canvas_id for the next call — JSON only, no gallery
search_canvases(query="pitch deck", template_type="template", limit=10)
```
* Drop `query` from `list_my_canvases` and `template_type` alone browses the team's curated templates/themes **by recency** — useful when the user just wants to see what's available, with no search term yet.
* An empty result means the team curated none. **Say so** — don't presciently offer an ordinary canvas as if it were a template.
* Any readable canvas works as `start_design_task`'s `template_canvas_id` — the `template_type` filter only narrows discovery down to the ones the team deliberately curated for this purpose.
## The built-in instructions — aware, don't restate
The design agent **already receives** a template's author-written instructions automatically once you pass it as `template_canvas_id` — they travel onto the remix copy and are hydrated into the agent's turn prompt server-side. This gives three rules for the `prompt` you write:
1. **Never copy a template's instructions into `prompt`.** They travel automatically. A paraphrase ("never modify the title slide" → "keep it roughly as-is") creates a weaker, drifting duplicate in the more heavily-weighted user turn.
2. **Never write a prompt that asks for something the template forbids.** Read the row's `guidance` (below) before drafting.
3. **On a genuine conflict, ask the user — don't pick a side.** Locked nodes/pages are a hard constraint; everything else is author-intent vs user-request with **no defined winner at runtime**. Do not invent or publish a precedence rule the runtime doesn't implement.
Worked example — the user asks for something the template's guidance forbids:
> This template says "Never modify the title slide," but you asked for a new hero there. Want me to leave it alone, or start from a different template?
The one exception: if the user explicitly overrides after being asked, state the override plainly in the prompt (e.g. "the user asked to replace the title slide despite the template's note — do it"). That's the only case the prompt should mention a template rule at all.
## Reading `guidance` on a search/list row
A canvas with author-pinned instructions carries a `guidance` object:
```json theme={null}
{"agent_instructions": "…", "agent_instructions_truncated": true, "pinned_note_count": 4}
```
`pinned_note_count > 0` means there's additional per-element guidance (pinned notes on specific nodes/pages) you can't see from this row — you don't need to fetch it yourself, the agent will read and follow it once it runs against the copy. Just know it's there: don't assume a template with `pinned_note_count: 0` is unconstrained if `agent_instructions` is also present, and don't assume a template is safe to freely rewrite just because `guidance` is absent from the row (a canvas with no authored instructions has no `guidance` key at all — that's the "no constraints" case, not an omission).
## See also
* [`fill-template.md`](./fill-template.md) — content-only remix, same brand
* [`rebrand-template.md`](./rebrand-template.md) — full rebrand on copy, different brand kit
* [`../references/gotchas.md#templates-and-themes`](../references/gotchas.md#templates-and-themes) — the compressed reference version of this page
# Rebrand template
Source: /docs/.mintlify/skills/moda-mcp/recipes/rebrand-template
# Rebrand a template (full rebrand on copy)
**When to use:** The user has a source canvas they like and wants a new canvas with a **different brand applied** — different colors, different fonts, different logos. Common for agencies producing the same layout for multiple clients. The original canvas is preserved.
## Tool call
```python theme={null}
start_design_task(
template_canvas_id="cvs_…", # the source design to remix
brand_kit_id="bk_…", # the new brand kit — different from the source's
prompt="Rebuild this deck as Acme's Series A pitch — swap the metrics on slides 4-6 for Acme's numbers and rewrite the customer quotes.",
)
```
## What the server does
The server:
1. Copies the source canvas into the caller's team (original untouched).
2. Applies the new `brand_kit_id` to the copy.
3. Because the resolved kit **differs from the source's**, the agent runs the `template-remix` skill — full rebrand (palette, typography, hero imagery), not just content swap — and it does not need the prompt to ask for it.
4. Returns a task handle. Poll `get_task_status` as usual.
## Don't ask for the rebrand in the prompt
The rebrand is implicit and automatic. Passing a `brand_kit_id` that differs from the
source canvas's kit **is** the rebrand request — the server force-bundles the
`template-remix` skill, which instructs the agent to read your prompt as describing
only the content theme and to swap colors, fonts, logos, and hero imagery regardless
of what the prompt says.
Write the prompt as a **content brief only**:
* Good: `"Rebuild this deck as Acme's Series A pitch — swap the metrics on slides 4-6
for Acme's numbers and rewrite the customer quotes."`
* Bad: `"Rebrand for Acme. Update colors, fonts, and imagery to the new brand kit."`
The bad version isn't merely redundant. Restating styling in the prompt fights the
resolved brand kit ([`../references/gotchas.md#brand-kits`](../references/gotchas.md#brand-kits)),
and it re-opens the content-only reading of the prompt that the server-side skill
exists to close. The one thing worth putting in the prompt is a *deviation* the user
asked for ("keep the dark background even though the kit is light").
## Picking the new `brand_kit_id`
* If `get_moda_bootstrap` returned `brand_kit_count > 1`, you may already have the right id in hand from a recent `find_brand_kits` call or session preference. Use it.
* If the user named the target brand, resolve it with `find_brand_kits(query=…)` (JSON-only). If they haven't said which kit, show `list_brand_kits` and ask — don't pick the target brand for them.
## Mutually exclusive
`template_canvas_id` is mutually exclusive with both `canvas_id` and `conversation_id`.
## Bulk variant — different brand per output
For "produce N rebranded variants, one per client", iterate `start_design_task` calls with the same `template_canvas_id` and a different `brand_kit_id` each call. Pattern B of [`bulk-variants.md`](./bulk-variants.md) covers the windowed-launch shape.
## See also
* [`find-a-template.md`](./find-a-template.md) — how to find a template and the three-rule contract for its built-in instructions
* [`fill-template.md`](./fill-template.md) — same workflow but keep the source's brand (content-only remix)
* [`bulk-variants.md`](./bulk-variants.md) — fan out rebrands across multiple brand kits
* [`../references/gotchas.md#brand-kits`](../references/gotchas.md#brand-kits) — brand-kit resolution order, `find_` vs `list_` distinction
# Gotchas
Source: /docs/.mintlify/skills/moda-mcp/references/gotchas
# Gotchas
The MCP surface has several behaviors that won't show up in tool signatures. This page is the one-stop reference for the silent-fail / surprising-behavior set.
## Format and dimensions
**Set `format_category` explicitly on `start_design_task`.** If you omit it, a fresh canvas (which has no dimensions to infer a format from) is created as a generic `other` canvas at 1080×1080 with no format skill — so a deck, post, or document gets a generic layout. Pass one of:
| User wants | `format_category` | Also pass |
| - | - | - |
| Pitch deck, presentation | `slides` | typical `format_width=1920`, `format_height=1080` |
| Single Instagram / LinkedIn / Twitter / banner | `social` | platform-specific `format_width`/`format_height` |
| Instagram or LinkedIn **carousel** post | `carousel` | `carousel_dimensions` (`square`/`linkedin`/`portrait`) + optional `carousel_page_count` |
| PDF report, resume, one-pager | `pdf` | |
| Flowchart, org chart, process diagram | `diagram` | |
| UI mockup, screen, wireframe | `ui` | `format_width`/`format_height` |
| Animated video, motion graphic | `animation` | `format_width`/`format_height` |
| Poster, flyer, business card, brochure | `prints` | `format_width`/`format_height` |
| Display ad, banner ad (IAB sizes) | `web-ads` | `format_width`/`format_height` |
| Infographic, anything else | `other` | `format_width`/`format_height` |
This is the full category enum — the same 10 values as `moda_mcp/server.py`'s `start_design_task` `format_category` docstring.
**Slide count goes in the prompt text**, not a parameter. Say "10-slide deck" in the prompt. The REST API has `number_of_slides`; the MCP server does not expose it.
**Carousel page count clamps to your plan tier**, the same limit slides use (free=10, paid=30, ultra/enterprise=100) — there's no separate, lower carousel-specific cap. Pass `carousel_page_count` explicitly or omit it for a default target.
**`model_tier="pro_max"`** is silently coerced to `pro`. Pass `pro` directly.
## Task lifecycle
**`start_design_task` defaults to `wait=False`** — returns a task handle (`task_id`, `canvas_id`, `canvas_url`, `conversation_id`, `status: "queued"`) in milliseconds. Poll `get_task_status(task_id)` at `retry_after_seconds` (\~3s) until `is_terminal == true`. Call `export_canvas` only when `can_export == true`.
**`remix_design` defaults to `wait=True`** — opposite of `start_design_task`. With a prompt, it blocks until the agent finishes. For bulk fan-out, **pass `wait=False` explicitly** or each call serializes.
**Terminal statuses**: `completed`, `failed`, `cancelled`, `dead_letter`, `canvas_deleted` (source canvas was deleted mid-run), `insufficient_credits` (billing block). Don't pattern-match this list yourself — just check the `is_terminal` field on the task/response; it's derived from the full terminal set and stays correct even if this set grows.
**Cancellation**: `cancel_task(task_id)`. No-op on already-terminal tasks. Cancelling a blocking MCP call publishes the cancel signal internally — billing stops with the job.
**Failed tasks are deterministic**. Don't silently retry on `{status: "failed"}` — surface the `error` to the user. The exception is upstream model errors, which are sometimes transient (retry once).
## Conversations vs canvases vs remixes
| User intent | Use |
| - | - |
| Iterate on the same design ("make the headline bigger") | `start_design_task(prompt=…, conversation_id=…)` — agent has full prior context |
| Edit an existing canvas in place ("add a footer to my deck") | `start_design_task(prompt=…, canvas_id=…)` — destructive, modifies the canvas |
| Fork a canvas with variations, especially across brand kits ("make a version of this for each client") | `start_design_task(prompt=…, template_canvas_id=…, brand_kit_id=…)` — duplicates first; server auto-picks content-only remix vs full rebrand from brand-kit comparison; original preserved |
| Duplicate now, optional agent pass | `remix_design(canvas_id=…)` — single call, synchronous by default; pass `prompt` to also queue a design task on the copy |
**Mutual-exclusion matrix on `start_design_task`:**
| Combo | Behavior |
| - | - |
| `canvas_id` + `template_canvas_id` | Tool error |
| `template_canvas_id` + `conversation_id` | Tool error |
| `canvas_id` + `conversation_id` | No error — `canvas_id` is silently ignored; the agent uses the conversation's canvas |
The asymmetry is intentional: `conversation_id` always wins over `canvas_id` (older quirk, predates `template_canvas_id`), but `template_canvas_id` is strict because it changes the operation shape (source-copy-then-run vs in-place edit / resume).
## Templates and themes
| `template_type` | What it is | Format |
| - | - | - |
| `template` | A finished design meant to be copied and refilled with new content. | Any format |
| `theme` | A slides-only canvas of reusable page layouts, attached via a brand kit's `default_theme_canvas_id`. | Always `slides` |
**Discovery filters on `list_my_canvases` / `search_canvases`:** `template_type` ('template' or 'theme') filters to only that kind; `exclude_templates=true` returns only regular canvases (mutually exclusive with `template_type`); `category` narrows further by format. `template_type='theme'` plus a non-`slides` `category` is a valid, knowably-empty query, not an error. On `list_my_canvases`, `query` is optional — omit it (or pass an empty one) and `template_type` alone browses the team's curated set by recency. An empty result means the team curated none — say so, don't present an ordinary canvas as a template. Any readable canvas still works as `start_design_task`'s `template_canvas_id`; the filters only narrow *discovery*, not what's eligible to remix. See [`../recipes/find-a-template.md`](../recipes/find-a-template.md).
**`list_my_canvases` when the user should see the canvases; `search_canvases` when only you need the id.** Both take the same `query` + filters and return the same rows, but only `list_my_canvases` renders the gallery iframe — call it (optionally pre-filtered with `query`) when the user asked to see, browse, or pick a canvas, and `search_canvases` when you're resolving a `canvas_id` / `template_canvas_id` for your own next call. Same split as `list_brand_kits` vs `find_brand_kits` below.
**The `guidance` field:** a row carries `{"agent_instructions": "…", "agent_instructions_truncated": true, "pinned_note_count": 4}` when the canvas's author pinned agent instructions or locked pages — emitted for any canvas with authored instructions, not just templates. Truncated at 500 chars.
**The three-rule contract, aware-don't-restate:** a `template_canvas_id` remix's author-pinned instructions and locks travel onto the copy and reach the design agent automatically. So (1) never copy them into `prompt` — a paraphrase there creates a weaker, drifting duplicate; (2) never write a prompt that asks for something they forbid; (3) on a genuine conflict, ask the user rather than picking a side — locked nodes/pages are a hard constraint, but everything else is author-intent vs user-request with no defined runtime winner.
## Brand kits
**`list_brand_kits` to show the user a choice; `find_brand_kits` for your own lookups.** Both return the same data, but `list_brand_kits` renders a visual showcase iframe on **every** call (per the MCP Apps spec, iframe rendering is decided at tool-listing time and can't be suppressed per-call) — that is the picker to show when the user must choose a kit. When you're resolving the id of a kit the user already named, use `find_brand_kits` — it's JSON-only and doesn't steal screen real estate. Both take an optional `query` that filters by kit name (title or company name), so you can go straight to `find_brand_kits(query="Acme")` instead of fetching every kit and matching client-side.
**New designs need the user's brand choice.** For `start_design_task` with no `canvas_id` / `conversation_id` / `template_canvas_id`, a brand choice is an explicit `brand_kit_id`, `skip_brand_kit=true`, or a session pin. Without one it is refused with `brand_kit_choice_required` before anything is created — **whatever the kit count, even with a team default or a single kit**. Ask the user: one of their kits (show `list_brand_kits`), a new kit (`create_brand_kit`, if they want one), or none (`skip_brand_kit=true`). Wait for the answer, then retry. A kit named in the project instructions or memory counts as the choice.
**Resolution order once a choice exists (and for edits / remixes):**
1. Explicit `brand_kit_id` parameter (highest priority)
2. Session preference (`set_session_brand_kit`, or the showcase iframe's "Use for this session" button)
3. Edits and remixes: the source canvas's own kit, then the team default brand kit
4. None (only when `skip_brand_kit=true` is set)
**Don't restate brand colors / fonts / logos in the prompt** when a kit applies — it fights the kit.
**The brand kit — not the prompt — selects rebrand vs content-swap.** On
`start_design_task(template_canvas_id=…)`, a `brand_kit_id` that differs from the
source's makes the server force-bundle the full-rebrand skill; a matching (or omitted)
kit makes it content-only. The prompt does not need to ask for the rebrand and should
not — write it as a content brief.
**`set_context` clears the session brand kit.** Kits are team-scoped; a kit pinned on Team A is meaningless on Team B.
**`set_session_brand_kit` is session-only.** It does NOT touch the team default. To change the team default, use `set_default_brand_kit` — destructive, only call on explicit user request.
**Ask the user when the team has multiple kits** and `get_moda_bootstrap` shows no session preference. Silently falling back to the default isn't always what they want.
**`import_brand_kit_from_fig` is async and NOT task-registry-backed.** Unlike `start_design_task`/`remix_design`, it does not appear in `get_task_status` or `list_tasks` — it returns a `job_id` immediately and you poll the companion `get_fig_import_status(job_id)` tool instead (same Redis-backed job the web app's progress UI reads, so a job started here is visible there and vice versa). The user must have exported the file from Figma via File → Save local copy… first, then uploaded it via `create_upload_url` → `register_uploaded_file` (`.fig` is an allowed upload MIME type) to get the `file_id`. Omitting `brand_kit_id` creates a new kit; passing one merges additively (re-importing the same file adds nothing — colors/fonts are deduped against what's already there). There is no feature flag to check — the tool is always available even though the `.fig` option is gated in the web UI.
**First kit per team becomes default automatically.** To promote a different kit later as the *team* default, use `set_default_brand_kit(brand_kit_id)`.
## Attachments
**Two shapes:**
* File-id form (preferred): `{file_id, role, label?}` — carries `role` ∈ `source` / `reference` / `asset` / `import` that changes agent behavior
* URL form (legacy, public URLs only): `{url, name?, type?}` — drops role metadata
**Roles change behavior:**
* `source` — extract content from this file (use its text/data — e.g. a brief PDF, meeting notes)
* `reference` — emulate this file's style (don't copy content — e.g. a screenshot of a design you like)
* `asset` — drop the file in verbatim (e.g. a logo, hero image) — currently a hint rather than a hard guarantee (behavior still evolving per ENG-2549)
* `import` — PPTX-only: converts a PowerPoint attachment into editable canvas slides. It always creates a *new* slides canvas, so it can't be combined with `canvas_id`, `conversation_id`, or `template_canvas_id`, needs the file-id form (not the URL form), allows at most one `import` attachment, and requires `format_category` to be `slides` or omitted. The deck also sets its own page size, so every OTHER format field is rejected too — `format_width`, `format_height`, `carousel_dimensions`, `carousel_page_count`: omit them on an import (the table above is for FRESH canvases). Violating any of those is a `422 invalid_attachment_role`, not a silent fallback.
Passing a logo as `reference` makes the agent emulate its style instead of placing it. Passing a brief as `reference` makes the agent mimic its formatting instead of using its content.
**Don't confuse this with the brand-kit-image role enum.** `add_brand_kit_image(role=…)` uses a different (overlapping but distinct) set: `logo` / `reference` / `asset`. `logo` belongs to brand-kit images only; `source` belongs to design-task attachments only.
**Two upload paths:**
* `upload_file(source_url=…)` — one call, when the file is already at a public URL. Content-hash deduplicated.
* `create_upload_url` → PUT bytes to the returned `upload_url` → `register_uploaded_file(storage_key, …)` — three steps, when the file is local with no public URL.
**The `create_upload_url` PUT goes to `mcp.moda.app`.** The `upload_url` it returns is on the Layovelle MCP host itself (`https://mcp.moda.app/uploads/proxy?token=…`) — not a Google Cloud Storage URL. That's deliberate: it's the same host the MCP connector already talks to, so sandboxed clients with a network egress allow-list don't need a separate rule. If a hardened client *does* block the out-of-band PUT, the one host to allow is `mcp.moda.app` — never all of `storage.googleapis.com`.
For existing Layovelle canvases as inspiration, don't upload a screenshot — pass `reference_canvas_ids=[cvs_…]` directly. The agent sees the structure natively.
## Concurrency caps (bulk fan-out)
Per-organization in-flight cap on `start_design_task` + `remix_design` (shared across every team in the org — a two-team org on `paid` has one pool of 10 slots between both teams, not 10 each):
| Plan | Max concurrent tasks |
| - | - |
| `free` / `free_beta` | 3 |
| `paid` | 10 |
| `ultra` / `enterprise` | 15 |
Exceeding the cap surfaces as a tool error on the call that puts you over. Use a windowed launch — keep at most `cap` tasks in flight; slot in the next as each terminates. Default the window to 3 when the plan is unknown. See [`../recipes/bulk-variants.md`](../recipes/bulk-variants.md) for the pattern.
## Exports
**A finished design task already includes its export — don't re-export.** A completed `start_design_task` / `remix_design` carries `result.export` (`{url, format, status, page_count}`) — the design rendered to a file in the canvas's category-default format. Read that. Calling `export_canvas` for the same just-finished canvas re-does work the task already did; only call it for a *different* format or page, or for a canvas that wasn't just produced by a task.
**`export_canvas` returns `{status: "not_ready", reason, retry_after_seconds}`** while a design task is still running on the canvas. **Not an error** — retry after `retry_after_seconds`. Reasons: `active_design_job` (most common), `job_status_unavailable` (transient).
**Large multi-page PDFs/PPTX** can exceed the \~20s sync wait budget and return `{status: "in_progress", task_id}`. Poll `get_export_status(task_id)` for the URL. Export task records are kept \~1 hour.
**Signed export URLs expire after 7 days.**
**Image vs document defaults**: PDF / PPTX default to all pages. PNG / JPEG with `page_number` omitted on a multi-page canvas bundle **all pages into a `.zip`** (not just page 1) — pass `page_number` explicitly to get a single raw image.
## Session context (multi-org users)
Session context (org + team) is sticky for 24h across reconnections.
* Single-org user: nothing to do; primary workspace applies.
* Multi-org user: call `get_context()` at session start; if unset, ask which org/team. `set_context(org_name, team_name)` takes **names** (case-insensitive), not UUIDs.
All workspace-scoped tools (`list_brand_kits`, `start_design_task`, `remix_design`, `upload_file`) read from session context. Per-call overrides: pass `org_id` / `team_id` for one-call overrides without changing the session default.
## Design-to-code (separate workflow)
`get_moda_canvas(url=…)` returns semantic pseudo-HTML (`<Card>`, `<Button>`, `<Heading>`, etc.) that maps directly to React / Vue / HTML / SwiftUI. `get_moda_canvas_tokens(url=…)` returns colors / fonts / radii / variables as JSON for theme config.
* **Layer names drive tag quality.** A rectangle named `cta-button` becomes `<Button>`. Unnamed elements use visual heuristics.
* **Multi-page canvases**: call `list_moda_canvas_pages(url=…)` first to plan per-page fetches; omitting `page_number` concatenates everything into one (expensive) response.
* **Pair with `export_canvas(format="png")`** for pixel-perfect or complex-gradient cases that pseudo-HTML can't fully capture.
Full guides: [`docs.moda.app/mcp/design-to-code`](/docs/mcp/design-to-code) and [`docs.moda.app/mcp/naming-layers`](/docs/mcp/naming-layers).
# Ask the Layovelle Expert
Source: /docs/agents/ask-expert
A free, grounded how-to lane your agent can call mid-task — ask_expert on the connector, moda ask on the CLI.
Agents guess. When an agent is unsure how Layovelle does something — which tool to reach for, why a fill was rejected, how to get selectable text into a PDF — the cheap failure is that it invents an approach and you get a bad artifact. Layovelle ships an answer to that: a built-in expert your agent can just ask, mid-task, for free.
| Where | How to call it |
| - | - |
| Connector | `ask_expert(question: "…")` |
| CLI | `moda ask "how do I put a shader fill on text?"` |
## It routes, it does not lecture
Answers are grounded in Layovelle's own guides and cite them. Every in-domain answer names the **skill you should read** (`required_reading`) and the exact verbs or tools involved, rather than reproducing a whole workflow inline. The answer points; [`load_skill`](/agents/skills) teaches. That split is deliberate — a short routing answer plus the real guide beats a long paraphrase that can drift out of date.
## Ask early and often
This is not a last resort. It is fast and it costs nothing, so the guidance built into Layovelle's skills is to ask:
* before starting a kind of task the agent has not done here before,
* when choosing between two tools, two formats, or two models,
* when a styling or format question comes up mid-authoring,
* and **immediately after any failed call** — passing the error text as context.
```
ask_expert(
question: "canvas_apply_markup rejected my gradient fill — what is the right attribute?",
context: "<the exact error envelope>"
)
```
```sh theme={null}
moda ask "why did my markup get rejected?" --context "$(moda last-error)"
```
## Options
| Option | What it does |
| - | - |
| `context` | Extra material for the question — the failing command and its error output is the highest-value thing to pass. |
| `brand_kit_ref` | Ground the answer in a brand kit (`bk_…` from `brand_list`, or `--brand` on the CLI): palette, fonts, tone, and logo imagery become context, so styling advice is brand-specific. Opt-in only — never inferred. |
| `session_id` | Continue a previous exchange. Every answer returns one; pass it back for a follow-up and the prior turns are in scope. Sessions expire after 24 hours idle. On the CLI this is remembered for you; `moda ask --fresh` starts clean. |
## Scope and limits
* **Free.** Asking never spends credits. It is rate-limited — a per-caller burst ceiling plus a per-organization daily cap — so it is a tool for the agent's real questions, not a polling loop.
* **In scope:** how to do something in Layovelle — tools, verbs, markup, formats, design workflow, error recovery.
* **Not in scope:** your canvas data (that is `canvas_read` / `moda canvas read`), and billing or account questions (those go to [admin@layovelle.com](mailto:admin@layovelle.com)).
# CLI Quickstart
Source: /docs/agents/cli-quickstart
Install the moda CLI, sign in, and have your agent build its first canvas — three commands on any machine with a shell.
The `moda` CLI is the primary way to give an agent Layovelle. Any harness with a shell — Claude Code, Codex, Cursor, Cowork, Hermes, OpenCode, your own scripts — drives Layovelle the same way, with no per-editor configuration: **the CLI is the integration**.
**Requirements:** macOS or Linux (x64 or arm64), Node.js and npm on your PATH, and a [Layovelle account](https://layovelle.com). Windows works too, in beta — see [below](#windows).
## Install
<Steps>
<Step title="Install the CLI" icon="download">
```sh theme={null}
npm i -g [Layovelle CLI package pending]
```
</Step>
<Step title="Sign in" icon="key">
Opens your browser and mints a scoped key into your OS keychain. No credential is ever printed.
```sh theme={null}
moda auth login
```
</Step>
<Step title="Add the skills" icon="graduation-cap">
Teaches your agent how Layovelle expects to be driven — the design workflow, not just the verb list. See
[Skills](/agents/skills).
```sh theme={null}
npx skills add moda-design/moda
```
</Step>
<Step title="Check it worked" icon="stethoscope">
Connectivity, credential validity, scopes, version range, keychain health — all in one command.
```sh theme={null}
moda doctor
```
</Step>
</Steps>
<Tip>
Prefer plugin-managed skills in Claude Code? `/plugin marketplace add moda-design/moda`, then
`/plugin install moda@moda`. Steps 1 and 2 still apply — the plugin carries the skills, not the CLI.
</Tip>
### Or let your agent do it
Paste this into any shell-capable agent and it will run the whole thing — including the
failure handling and the re-verify loop this page's manual steps leave to you:
```
Set up Layovelle by following the guide at https://layovelle.com/install.md
```
That guide is the authoritative procedure ([Agent setup](/agents/setup)). This page stays
as the human reference for what each step does.
## Your first canvas
The shortest real path is to ask for something you actually want — "make a one-pager from this README", "turn these notes into a deck" — and let the skills drive. Under the hood, that is this loop:
<Steps>
<Step title="Create the canvas" icon="plus">
```sh theme={null}
moda canvas create --name "Launch one-pager" --category pdf
```
Prints the canvas id (`cvs_…`) and its editor URL. The category sets the page size and the export defaults:
`slides`, `social`, `carousel`, `pdf` (a **Document** canvas in the app), `diagram`, `ui`, `animation`, `prints`,
or `web-ads`.
</Step>
<Step title="Author it" icon="pen-ruler">
Read the canvas to get its page ids and a revision token, then apply markup to a page:
```sh theme={null}
CANVAS=cvs_01HT9WK8N3M2J4A5Z6P7Q8R9TV # the id the previous step printed
moda canvas read "$CANVAS" --summary
moda canvas markup "$CANVAS" --page <page-id-from-read> --file one-pager.xml
```
`moda docs markup` prints the full markup reference offline. For fine-grained changes to an existing design,
`moda canvas edit` runs a sandboxed JS edit batch instead.
</Step>
<Step title="Look at it" icon="camera">
Authoring is a loop — mutate, render, inspect, repair. Agents that skip the render step ship broken layouts.
```sh theme={null}
moda canvas screenshot "$CANVAS" --output ./shots/
```
Then look at the PNG. `moda canvas markup` and `moda canvas edit` also take `--screenshot <path>`, which captures
the page they just touched in the same command.
</Step>
<Step title="Deliver" icon="file-export">
```sh theme={null}
moda export "$CANVAS" --format pdf --output ./one-pager.pdf
moda canvas open "$CANVAS"
```
</Step>
</Steps>
Every canvas stays fully editable in Layovelle afterwards — the CLI never produces a dead artifact.
## The verb surface
`moda describe` prints machine-readable schemas for every verb, with `mutating` / `destructive` / `metered` markers, so an agent can introspect the CLI instead of guessing. A selection:
| Group | Verbs |
| - | - |
| `auth` | `login`, `logout`, `status` |
| `canvas` | `create`, `read`, `markup`, `edit`, `screenshot`, `add-pages`, `import-pptx`, `duplicate`, `share`, `open` |
| `export` | `pdf`, `pptx`, `png`, `jpeg`, `mp4`, `gif` via `--format` |
| `brand` | `list`, `show`, `pull`, `use`, `create`, `guides` |
| `media` | `generate-image`, `edit-image`, `generate-video`, `upscale`, `remove-background` |
| `file` | `upload`, `search`, `list`, `download` |
| `drive` | `tree`, `folders`, `mkdir`, `move`, `rename`, `rm` |
| `site` | `create`, `set-content`, `publish`, `screenshot` |
| `task` | `status`, `list`, `cancel` |
| `ask` | [Ask Layovelle how to do something](/agents/ask-expert) — free and fast |
| `account` | `status`, `usage`, `costs` |
Conventions worth knowing:
* **`--json` is for agents.** Compact JSON on stdout, `--pretty` when a human is reading. The envelope is additive-only: keys are never renamed or removed, so parse defensively and ignore what you do not recognize.
* **Metered verbs announce themselves.** Anything that spends credits prints its cost class before and a receipt after. `moda account costs` lists what is metered, straight from the server.
* **`moda last-error`** re-prints the full error envelope of the last failed command — no re-run needed.
* **`moda docs markup | edit | workflow`** prints the offline reference texts.
## Auth in CI and headless environments
| Situation | Do this |
| - | - |
| Your laptop | `moda auth login` — browser flow, key lands in the OS keychain |
| SSH / container / no browser | `moda auth login --paste` — prints the mint URL, reads the key from a hidden prompt |
| CI, cron, scheduled jobs | Set `MODA_API_KEY` to a scoped key from **Settings → Developer** |
No verb ever prints a credential. `moda auth status` shows identity, org, plan, and scopes — never the key.
Every path above mints a key, and that requires a paid plan — a free workspace is told so on the mint page, with a link to the plans. Keys you already hold keep working.
## Windows
`npm i -g [Layovelle CLI package pending]` works on Windows too — it pulls the platform binary automatically, and the CLI is
identical. Treat it as **beta**: it is built and tested on `windows-latest` in CI, but it has not been soaked in real
use. Two differences worth knowing — config lives in `%APPDATA%\moda` and state in `%LOCALAPPDATA%\moda`, and there is
no Windows keychain backend, so the credential sits in `%APPDATA%\moda\credentials.json` behind your user-profile ACL
rather than in a keychain (`moda auth login` says so once).
If it misbehaves: WSL2 runs the Linux build unchanged, and the [Layovelle connector](/agents/mcp-connector) installs nothing
at all.
## Updating
The CLI and the skills update separately:
```sh theme={null}
npm i -g [Layovelle CLI package pending] # the CLI
npx skills add moda-design/moda # the skills (hash-pinned; never auto-update)
```
The CLI prints a once-daily notice on stderr when a newer version exists. The server also enforces a minimum supported version, so a very old CLI is told to update rather than failing mysteriously.
# Layovelle for Agents
Source: /docs/agents/index
Give your coding agent or chat assistant a real design tool — the moda CLI wherever there is a shell, the Layovelle connector everywhere else.
Layovelle for Agents lets the agent you already use make **real, editable Layovelle designs** — decks, one-pagers, social posts, diagrams, websites, video — and hand you back a canvas link plus the exported file. Not a screenshot, not a flat image: every layer stays live and editable at [layovelle.com](https://layovelle.com).
**Setting up? Hand your agent [Agent setup](/agents/setup) and it does the rest** — it
installs the CLI, signs you in, installs the skills, and verifies the result. That page is
the one authoritative procedure; everything below is orientation and reference.
```
Set up Layovelle by following the guide at https://layovelle.com/install.md
```
There are two ways in, and the procedure picks for you on one question — can your agent run
shell commands **with internet access**?
* **Yes** → the [`moda` CLI](/agents/cli-quickstart). The better experience: cheaper in context, faster, versioned, and it can read the files on your machine.
* **No** → the [Layovelle connector](/agents/mcp-connector). Same account, same designs, nothing to install.
A sandboxed code interpreter — ChatGPT's, for instance — has a shell but usually cannot
reach the network, so the install fails. Those take the connector.
Both talk to the same Layovelle service, so entitlements, credits, brand kits, and canvases are identical either way. There is no transport tax.
## Pick your setup
All of these are handled for you by [Agent setup](/agents/setup); this table is for
recognising yourself and knowing what you will end up with.
| Where your agent runs | Use | Why |
| - | - | - |
| Claude Code | [CLI](/agents/cli-quickstart) | Shell present — three commands and it is wired |
| Cursor, Codex, and other local agents | [CLI](/agents/cli-quickstart) | Same three commands; no per-editor config |
| Cowork | [CLI](/agents/cli-quickstart) | It can run commands, so it gets the full surface — no shell in your session, use the connector |
| Hermes, OpenCode, OpenClaw | [CLI](/agents/cli-quickstart) | All three run shell commands — OpenClaw's `exec` tool is off by default, and the guide routes it to the connector if so |
| claude.ai and Claude Desktop | [Connector](/agents/mcp-connector) | No terminal — add Layovelle from the Claude connector directory |
| Windows | [CLI](/agents/cli-quickstart#windows) | npm install works; the Windows build is beta, WSL2 is the fallback |
| Your own code, CI, cron | [REST API](/api-reference) | Direct HTTP, API-key auth |
| ChatGPT | [Connector](/agents/mcp-connector#set-it-up-on-chatgpt) | Custom connectors need ChatGPT’s developer mode turned on |
## What your agent can make
| Surface | What you get |
| - | - |
| Canvas | Real editable designs on your account — every layer live, never a flat image |
| Decks and documents | Native editable PPTX and text-layer PDF from a brief, a doc, or a repo |
| Video and motion | Generated clips, logo animation, and animation-canvas renders to MP4 or GIF |
| Export and publish | PNG, JPEG, PDF, PPTX, MP4, GIF — or publish a live site on `*.layovelle.com` |
## How the pieces fit
<Steps>
<Step title="Connect" icon="plug">
Install the [CLI](/agents/cli-quickstart) or add the [connector](/agents/mcp-connector). One sign-in either way.
</Step>
<Step title="Teach" icon="graduation-cap">
Install the [Layovelle skills](/agents/skills) so your agent knows Layovelle's design workflow instead of guessing at it.
</Step>
<Step title="Ask" icon="comments">
Describe the artifact you want. Stuck mid-task, your agent can call [`ask_expert`](/agents/ask-expert) — free,
grounded answers about how to do something in Layovelle.
</Step>
<Step title="Ship" icon="file-export">
You get a canvas URL you can keep editing, plus the exported file.
</Step>
</Steps>
## Going further
* [CLI quickstart](/agents/cli-quickstart) — install, sign in, first canvas.
* [MCP connector](/agents/mcp-connector) — the claude.ai and Claude Desktop setup.
* [Skills](/agents/skills) — how your agent learns Layovelle's workflow.
* [`ask_expert`](/agents/ask-expert) — the built-in advice lane.
* [API reference](/api-reference) — the HTTPS surface both the CLI and the connector are built on. Use it directly when
you are writing the integration yourself.
<Note>
Looking for the older `mcp.moda.app` MCP server (`start_design_task`, `get_moda_canvas`)? Those docs are still
published under [MCP Server (Legacy)](/mcp/overview). New integrations belong here.
</Note>
# MCP Connector
Source: /docs/agents/mcp-connector
Add Layovelle to claude.ai and Claude Desktop from the Claude connector directory — one click, no install.
The Layovelle connector is the path for hosts where your agent **cannot run a shell** — claude.ai in the browser, Claude Desktop, and other chat-first surfaces. It is a hosted MCP server: you connect once, sign in, and your assistant can create, read, edit, and export real Layovelle canvases.
On claude.ai there is nothing to paste. Layovelle is published in the Claude connector directory, so you find it and press **Connect**. The connector URL below is still what other hosts need, and what claude.ai needs in the one case the directory does not cover — see [Adding it by URL](#adding-it-by-url).
If your agent does have a terminal, use the [CLI](/agents/cli-quickstart) instead. It is cheaper in context, faster, and it can see the files on your machine. The connector exists so nobody is stuck without Layovelle, not as the recommended default.
<Note>
The connector and the CLI call the same Layovelle service, so plan entitlements, credits, brand kits, and canvases are
identical across both. Nothing is gated on which transport you picked.
</Note>
## Connector URL
```
https://layovelle.com/api/mcp
```
## Authentication
The connector URL supports **two** authentication methods. Pick one per caller — the server accepts
both concurrently, so one teammate can be signed in over OAuth while a scheduled job uses a key:
* **OAuth sign-in** — the default, and what the setup steps below use. The host holds the tokens,
the model never sees a credential, and there is no plan requirement.
* **A Layovelle API key as a bearer token** — send `Authorization: Bearer moda_live_...` against the same
connector URL above.
A host whose custom-connector flow asks for an API key rather than a sign-in does **not** need a
different endpoint. Meta's Muse is one: it asks for a key even with a person in the chat. Point it at
the connector URL and give it a key.
### Using an API key
<Steps>
<Step title="Mint the key" icon="key">
In Layovelle, go to **Settings → Developer → REST API → Create API key**. Creating a key needs a paid
workspace or a payment method on file (**Settings → Billing**); OAuth sign-in has no such
requirement. The key is shown once — copy it into a secret manager.
</Step>
<Step title="Keep the default scopes" icon="shield-check">
The connector enforces scopes per tool on key auth, so a narrowed key returns
`insufficient_scope` on individual tools — export, media, drive and site calls fail first — with
nothing to indicate the key rather than the endpoint is at fault. OAuth grants skip that check.
Accept the picker's default, which is every scope except **Organization admin**. Scopes beyond
the connector's allow-list are stripped on connector calls anyway but stay live for the REST API
and CLI on the same key, so adding admin gains nothing here and widens it everywhere else. Don't
narrow it further unless you want a deliberately restricted key.
</Step>
<Step title="Point the host at the connector URL" icon="link">
Use the connector URL from the top of this page and send the key as
`Authorization: Bearer moda_live_...`. Nothing else changes — the same tool surface, the same
workspace, the same credits.
</Step>
</Steps>
Key calls run as the **key's owner and their team**, where an OAuth grant runs as the individual who
signed in. Revoke or rotate from the same settings page; revoking is immediate and kills the key for
the REST API and CLI too, since it is one credential across all three.
## Set it up on claude.ai
<Steps>
<Step title="Open the Layovelle listing" icon="magnifying-glass">
Go to [**Layovelle in the Claude connector directory**](/integrations/claude), or find
it under **Customize → Connectors** by searching for `Layovelle`.
</Step>
<Step title="Connect and sign in" icon="key">
Click **Connect**. Layovelle opens an OAuth sign-in in your browser; approve the scopes. Tokens are held by Claude — the
model never sees a credential.
</Step>
<Step title="Choose a workspace" icon="users">
If your Layovelle account belongs to more than one workspace, Layovelle asks which one this connection should act in. One
workspace and the step is skipped. Your agent can still act in another of your teams on an individual call; changing
the *default* means disconnecting and reconnecting.
</Step>
<Step title="Turn it on in the chat" icon="toggle-on">
In a conversation, click **+** at the lower left (or type `/`), hover **Connectors**, and toggle **Layovelle** on.
</Step>
</Steps>
**Claude Desktop** reads the same directory: **Customize → Connectors**, find Layovelle, **Connect**.
### Adding it by URL
Use this when you do not see Layovelle in your connector directory. It reaches exactly the same server; it is only a longer
way in.
<Warning>
On a **Team or Enterprise** workspace this is not a workaround. Both routes need an Owner to approve Layovelle for the
organization before any member can connect, so adding the URL from your personal settings will not complete either.
See [Enterprise managed auth](/agents/mcp-connector-enterprise).
</Warning>
<Steps>
<Step title="Open connector settings" icon="gear">
Go to [**Customize → Connectors**](https://claude.ai/settings/connectors), click **+**, and choose **Add custom
connector**.
</Step>
<Step title="Add Layovelle" icon="plus">
Name it `Layovelle`, paste the connector URL above, and click **Add**. Then connect and sign in as above.
</Step>
</Steps>
### Plan and workspace notes
| Plan | What to expect |
| - | - |
| Free | Supported — Claude limits Free accounts to one custom connector, so Layovelle has to be the one. |
| Pro / Max | Add it yourself with the directory steps above. |
| Team / Enterprise | Only an **Owner** can add it for the organization (**Organization settings → Connectors**). Each member then connects individually and signs in with their own Layovelle account. See [Enterprise managed auth](/agents/mcp-connector-enterprise). |
Every member holds their own grant, so each one only ever sees the canvases, orgs, and credits their own Layovelle account
has access to.
## Set it up on ChatGPT
ChatGPT reaches the same connector once its developer switch is on:
<Steps>
<Step title="Turn on developer mode" icon="flask">
**Settings → Security and login → Developer mode.** Once per account.
</Step>
<Step title="Create the connector" icon="plus">
Open **ChatGPT Plugins**, click **+**, and create a developer-mode app for a remote MCP server using the connector
URL above.
</Step>
</Steps>
Two things to expect there. Write actions ask for confirmation on each call by default — that is ChatGPT's behaviour,
not Layovelle's, and you can tell it to remember your choice for the conversation. And developer-mode availability differs
by plan, so check OpenAI's own documentation if the switch is missing.
Layovelle is not published as a ChatGPT deep-research connector — that needs a different `search`/`fetch` tool shape, which
this surface does not expose. Developer mode is the supported route.
## First calls
The connector expects two calls before real work:
1. **`moda_bootstrap`** — the handshake. Returns your identity and teams, plan tier, scopes, entitlements, and the current media model matrix in one payload, so the assistant can skip a pile of lookups. (Brand kits come from `brand_list`.)
2. **`load_skill`** — Layovelle's full authoring guides, served live by the server. Call it before authoring a format you have not authored yet in this conversation. See [Skills](/agents/skills).
After that it is the normal loop: create or find a canvas, apply markup or an edit batch, screenshot it, repair, export.
## What the connector can and cannot reach
The connector runs in the host's cloud and talks to Layovelle's servers. **It never touches your machine.**
**It can use:** conversation context, attachments the host passes to tools, URLs you paste, and files already in your Layovelle workspace.
**It cannot use:** local paths, local folders, repositories on your disk, or writing an export to a folder you choose. No connector tool takes a file path.
Two consequences worth knowing:
* **Content goes inline.** Markup and edit programs are passed as string arguments, not read from a file.
* **Results come back as links.** Every mutation returns the canvas's editor URL, and exports return a download link rather than writing a file. Opening it in the browser is the connector's equivalent of the CLI writing the file next to your source.
If a host cannot pass an attachment's bytes to a tool, the `upload` tool falls back to an in-conversation upload control, and failing that, to uploading the file at [layovelle.com](https://layovelle.com) and telling the assistant to look for it.
## The tool surface
The connector exposes one focused profile rather than everything Layovelle can do — a tool list an assistant can actually hold in its head:
| Family | Tools |
| - | - |
| Session | `moda_bootstrap`, `load_skill`, [`ask_expert`](/agents/ask-expert) |
| Canvases | `canvas_list`, `canvas_search`, `canvas_create`, `canvas_read`, `canvas_apply_markup`, `canvas_edit`, `canvas_update`, `canvas_import_pages`, `canvas_screenshot`, `canvas_share`, `canvas_delete` |
| Main Edit | `edit_read`, `edit_apply` |
| Brand | `brand_list`, `brand_show`, `brand_create`, `brand_update`, `brand_delete` |
| Assets | `upload`, `file_search`, `file_list`, `file_read`, `drive_tree`, `drive_organize`, `drive_delete`, `template_list` |
| Media | `canvas_edit_image` (generate, edit, outpaint, upscale or remove a background inside an existing design element), `media_remove_background`, `media_fetch_logo`, `media_video_frames` |
| Delivery | `export`, `site_list`, `site_show` |
| Long jobs | `task_status`, `task_cancel` |
| Delegation | `task_delegate` |
Work that takes a while — a video render, a large export — returns a task handle immediately; the assistant polls
`task_status` rather than blocking the conversation.
Authoring is the default: the assistant builds the canvas itself with the tools above. The deterministic tools are
free, `media_fetch_logo` and `media_video_frames` included. Three consume the workspace's Layovelle credits —
`canvas_edit_image`, `media_remove_background`, and `task_delegate`.
`task_delegate` is the other route — it hands the whole brief to Layovelle's own design agent, which builds the design on
a canvas by itself while the assistant waits. Like any long job it returns a task handle and the canvas link straight
away.
The assistant is told to reach for it only when your own words hand the work over — naming Layovelle's agent, making Layovelle
the one doing the designing ("I want Layovelle to design this deck"), or naming the hand-off itself ("delegate this to
Layovelle"). "Use Layovelle to make a deck" is not that: there Layovelle is where the work happens, so the assistant authors it.
That judgement is recorded rather than trusted: the tool requires a `user_request_quote` carrying your own wording
that handed the work over, the assistant may not invent one, and the quote is kept on the call so a delegation that
spent credits can be audited against what you actually asked for.
## Troubleshooting
| Symptom | Fix |
| - | - |
| Tools do not appear in the conversation | Open the tools menu and enable **Layovelle**; on Team/Enterprise, confirm an admin approved the connector. |
| Sign-in loops, or tools return "not authenticated" | Remove the connector and re-add it — the OAuth grant is tied to the connector's URL. |
| The assistant sees `start_design_task` and friends | That is the [legacy MCP server](/mcp/overview), not this connector. Re-add the connector with the URL above. |
| A tool reports the canvas is busy | Something else is editing that canvas (an agent job, or someone in the editor). Retry — the error says so. |
Still stuck? [admin@layovelle.com](mailto:admin@layovelle.com).
# Enterprise Managed Auth
Source: /docs/agents/mcp-connector-enterprise
How an organization admin configures the Layovelle connector for their whole workspace — OAuth settings, what each member signs in with, and how access is scoped and removed.
This page is for the administrator turning Layovelle on for an organization once, so every member can use it.
If you are adding Layovelle to your own account, use the [MCP Connector](/agents/mcp-connector) setup instead — this page
is the same connector, seen from the admin console.
<Note>
**There is no shared credential to distribute.** Managed auth here means you approve the connector centrally and
each member then authenticates as themselves. Layovelle mints one grant per person, so a member only ever reaches the
workspaces, canvases, and credits their own Layovelle account already has. Nothing is pooled behind a service account,
and nothing you configure grants one member access to another's work.
</Note>
## What you need to enter
Most admin consoles ask for two things. These are the answers:
| Field | Value |
| - | - |
| Remote MCP server URL | `https://layovelle.com/api/mcp` |
| Authentication | OAuth 2.1 — authorization code with PKCE |
| Client ID / client secret | **Leave blank.** Layovelle registers clients dynamically (RFC 7591). |
| Scopes | `openid profile email offline_access` |
Everything else is discoverable. Your host reads the authorization-server metadata at
`/.well-known/oauth-authorization-server` and configures itself, which is why the credential fields stay empty.
## Before you start
* **You need to be an owner.** Adding a connector for an organization is an owner-level action in Claude
(**Organization settings → Connectors**) and in most other hosts. Members cannot self-serve an org connector.
* **Members do not need a Layovelle account first.** The first time someone connects, a verified identity with no Layovelle
account is given one automatically. You do not have to pre-provision, invite, or seat anybody ahead of the rollout.
* **Entitlements are the account's, not the connector's.** Plan tier, credits, brand kits, and canvases are identical
whether a member reaches Layovelle through this connector, the [CLI](/agents/cli-quickstart), or the web app. Approving
the connector does not change anyone's plan or spend limits.
## Add the connector
<Steps>
<Step title="Open your organization's connector settings" icon="building">
In Claude, go to **Settings → Organization settings → Connectors** as an Owner. Other hosts put this under an
admin, workspace, or enterprise section — you are looking for the place that adds a *custom remote MCP server*
for everyone, not the personal connector list.
</Step>
<Step title="Add Layovelle as a custom connector" icon="plus">
Name it `Layovelle` and paste the connector URL:
```
https://layovelle.com/api/mcp
```
</Step>
<Step title="Leave the OAuth credential fields empty" icon="key">
If the form shows optional **Client ID** and **Client secret** fields, skip them. Layovelle supports dynamic client
registration, so your host mints its own client on first use. Filling these in with values from somewhere else
will break the connection rather than harden it.
If your console *requires* a client ID and secret, see [Registering a client by
hand](#registering-a-client-by-hand) below.
</Step>
<Step title="Publish it to the organization" icon="users">
Save and make it available to members. Depending on the host this is a visibility toggle, an approval, or an
assignment to specific groups. Nobody is signed in yet — you have made Layovelle *available*, not connected.
</Step>
<Step title="Have one member connect end to end" icon="circle-check">
Before you announce it, connect once yourself. You should get a Layovelle sign-in page, a consent screen, and — if
your Layovelle account belongs to more than one workspace — a workspace picker. Then the Layovelle tools appear in a
conversation. That full loop is the real test that the org configuration is right.
</Step>
</Steps>
## What each member does
After you have published it, a member's path is short:
<Steps>
<Step title="Enable Layovelle" icon="toggle-on">
They turn Layovelle on for the conversation from the connectors or tools menu.
</Step>
<Step title="Sign in to Layovelle" icon="right-to-bracket">
A Layovelle sign-in opens in their browser and they approve the scopes. Tokens are held by the host — the model never
sees a credential, and neither do you.
</Step>
<Step title="Choose a workspace" icon="users">
If their Layovelle account belongs to more than one workspace, Layovelle asks which one the connection should act in. One
workspace and the step is skipped. Their agent can still act in another of their teams on an individual call;
changing the *default* means disconnecting and reconnecting.
</Step>
</Steps>
Each member holds their own grant. Rolling the connector out to a hundred people creates a hundred independent
authorizations, and revoking one leaves the rest untouched.
## OAuth reference
Every value below is served live at the
[authorization-server metadata document](https://agents.moda.app/.well-known/oauth-authorization-server). Read it from
there rather than copying by hand where your tooling allows it. Endpoint paths are relative to the issuer.
Copy the issuer exactly as written, trailing slash and all — that is the string the metadata document advertises, and
OpenID Connect clients compare it byte-for-byte. Dropping the slash to tidy it up is a common cause of a client
rejecting an otherwise valid token.
| Property | Value |
| - | - |
| Issuer | `https://agents.moda.app/` (trailing slash included) |
| Authorization endpoint | `/authorize` |
| Token endpoint | `/token` |
| Registration endpoint (RFC 7591) | `/register` |
| Protected-resource metadata (RFC 9728) | `/.well-known/oauth-protected-resource/mcp` |
| Grant types | `authorization_code`, `refresh_token` |
| Response types | `code` |
| PKCE | `S256` — required |
| Token endpoint auth methods | `client_secret_post`, `client_secret_basic` |
| Scopes | `openid`, `profile`, `email`, `offline_access` |
The scopes are identity and session scopes: they establish *who* the member is and let the host refresh without
sending them back through sign-in. What that person can then do inside Layovelle is decided by their own Layovelle account and
workspace membership, not by anything in the token.
### Registering a client by hand
Some consoles insist on a client ID and secret and will not accept an empty pair. Layovelle's registration endpoint is
open, so you can mint one:
```bash theme={null}
curl -X POST https://agents.moda.app/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "Acme Corp — Claude",
"redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_post"
}'
```
The response contains `client_id` and `client_secret`. Paste those into the console.
Use your host's real callback URL in `redirect_uris` — the value above is Claude's. Layovelle does not enforce a
redirect-URI allowlist, so a wrong value here will not be rejected at sign-in; it is simply the address the proxy
falls back to when a host omits `redirect_uri` on the authorization request. Getting it right keeps that fallback
correct. (PKCE, the consent screen, and per-user scoped tokens are what defend the flow, rather than a registered-URI
check that a motivated attacker could satisfy with any free HTTPS host.)
<Warning>
Treat `client_secret` as a credential: store it in your secret manager, not a ticket or a shared doc. It identifies
the *host application*, not any person, so it grants nothing on its own — but it belongs with your other OAuth
secrets. To rotate, register a fresh client and update the console; the old one simply stops being used.
</Warning>
## What the connector can reach
Worth knowing before you sign off on it:
* **It never touches a member's machine.** The connector runs in the host's cloud and talks to Layovelle's servers. No
tool takes a file path; there is no access to local folders, repositories, or drives. Content is passed inline and
results come back as links.
* **It cannot cross between members.** Each grant resolves to one Layovelle account. A member reaches the organizations
and canvases their account already has, and nothing else.
* **It is the same service as everything else.** The connector is a transport, not a separate data plane. Anything a
member could already do in Layovelle's web app, they can do here; anything they could not, they still cannot.
For the tool list and per-tool detail, see [MCP Connector](/agents/mcp-connector).
## Removing access
| Goal | What to do |
| - | - |
| Turn Layovelle off for the whole org | Remove or unpublish the connector in your admin console. Members lose the tools at once. |
| Turn it off for one person | Have them remove the connector from their own account, or remove their access in the host if it supports per-member assignment. |
| Offboard someone entirely | Remove their Layovelle account. Their grant stops resolving to an identity and every tool call fails with a non-retryable `account_not_found`. |
Layovelle's authorization server does not advertise an OAuth revocation endpoint, so revoking a *single live token* out of
band is not a lever available to you today. Removing the connector and deactivating the account are the two controls
that matter, and both take effect on the member's next call.
## Network
If your organization filters outbound traffic, members' browsers need to reach:
| Host | Why |
| - | - |
| `agents.moda.app` | The MCP endpoint, the OAuth flow, and the workspace picker. |
| `clerk.moda.app` | The identity provider behind Layovelle sign-in — reached during the OAuth hop. |
| `*.moda.app` | Canvas editor links and export downloads returned by tools. |
The host's own servers make the MCP calls to `agents.moda.app`; nothing needs to be opened inbound on your side.
## Troubleshooting
| Symptom | Cause and fix |
| - | - |
| Members do not see Layovelle in the tools menu | The connector is added but not published to them. Check its visibility or group assignment in the admin console. |
| Sign-in completes, then the connection fails | Layovelle could not link an account to that identity. The member sees a page saying which: an unverified or missing email address, an address already owned by another Layovelle account, or an account still inside its deletion recovery window. Resolve that and reconnect. |
| Tools return "not authenticated" for one member | That member's grant is stale. Have them remove and re-add the connector — the grant is tied to the connector URL. |
| Everyone sees `start_design_task` and similar tool names | They are on the [legacy MCP server](/mcp/overview), not this connector. Re-add using the URL above. |
| A member connects but lands in the wrong workspace | The workspace is bound at connect time. Disconnect and reconnect to choose a different default. |
Something not covered here? [admin@layovelle.com](mailto:admin@layovelle.com).
# moda-setup
Source: /docs/agents/setup
Install the Layovelle CLI or MCP connector for agent use
Layovelle is a design tool you can drive: real editable decks, one-pagers, social posts,
diagrams, websites and video on a live canvas — not flat images.
Written to you, the agent. Do as much as you can without asking. Two things need the user:
signing in, and — if you have no shell — the connector setup in step 5, which you guide
rather than perform.
## 1. Pick your path
Can you run shell commands **with internet access**?
* **No** → step 5.
* **Yes** → step 2. The CLI is faster, cheaper in context, and can read this machine's
files. Two exceptions, in order:
* You already know sign-in cannot finish here — no browser the user will see, and no
`MODA_API_KEY` set. Go to step 5 now rather than installing something you will not be
able to authenticate.
* Unless there is no human present at all. Then stay on step 2: the connector needs
someone to click through it, and step 3 names what the operator sets instead.
A sandboxed code interpreter usually cannot reach the network, so the install fails and
nothing you do there helps the user. If you cannot tell, step 2 will tell you.
Decide by what you can do, not by what you are called. Do not look for your own name in a list.
## 2. Ask Layovelle what is missing
Run `moda doctor --json`. It works before you have signed in, and one JSON object describes
the whole install.
If the command is not found:
```sh theme={null}
npm i -g [Layovelle CLI package pending]
```
* A 401 or registry error means a private registry is shadowing the package. Retry with
`--registry https://registry.npmjs.org`.
* **The install times out, or npm cannot reach the registry at all: you are in a sandbox
with no network. Stop and go to step 5.** Do not retry, and do not ask the user to run
the command for you — the connector is the path that works from where you are.
* Installed but not on PATH: use `npx [Layovelle CLI package pending]` everywhere below.
* Never `sudo`, and never pipe `curl` to a shell.
Installing is allowed only here. Once the skills exist their own Step 0 governs: a stale CLI
you refresh with `moda update`, but a missing one you stop and hand to the user.
## 3. Fix only what doctor reports
| Doctor says | Do this |
| - | - |
| `"authenticated": false` | `moda auth login` |
| `"skills": {"installed": 0}` | `npx -y skills add moda-design/moda -y` |
| `"below_minimum": true` | `moda update`. It cannot self-update on Homebrew, Windows, or a source checkout — if it prints a command instead of updating, give that command to the user and wait. |
| `"connectivity": false` | Network or proxy problem. Report it and stop. |
`moda auth login` opens the user's browser and stores a scoped key. Tell the user to finish
signing in, then wait for the command to return — do not kill it while it waits.
* No browser on this machine: **the user** runs `moda auth login --paste` and pastes the
key themselves. You do not read it, relay it, or type it for them.
* CI, or no human present: an operator sets `MODA_API_KEY` out of band, before you run.
It is not something to ask anyone for.
Never ask for, print, echo, or store a key yourself. That rule outranks both fallbacks
above: if the only way forward is a credential passing through you, stop — use the
connector in step 5 instead. Signing up is free and needs no card.
## 4. Re-run doctor until every state holds
```json theme={null}
{
"authenticated": true,
"checks": {
"connectivity": true,
"version": { "below_minimum": false },
"skills": { "installed": 1 }
}
}
```
`skills.installed` must be one or more. Repeat step 3 until all four hold.
Do **not** use the top-level `ok` field to decide this. It is doctor's exit status: `true`
on a machine with no credential and no skills, and `false` when a saved key was rejected or
the network is unreachable. It is never a readiness signal.
If a command fails, `moda last-error` prints the full envelope. Once you are signed in,
`moda ask "<question>"` is free and answers with the exact commands — it needs a credential,
so it cannot help you before the login in step 3.
Tell the user Layovelle is ready, then go to step 6.
## 5. No shell? Use the connector
Ask the user to add Layovelle. Which of the two ways depends on the host, and they can tell
you in one look — **does their connector settings screen have a directory to browse?**
Do not decide this from the host's name.
**If it has a directory**, they search it for Layovelle and press **Connect**. Nothing to
paste. The listing, if they want to go straight there:
```
/integrations/claude
```
**If it has no directory**, or Layovelle is not in theirs, they add a custom MCP connector
with this server URL and sign in when prompted:
```
https://layovelle.com/api/mcp
```
Never hand the listing link to a host that is asking for a server URL — they are not
interchangeable.
A member of a Team or Enterprise workspace may find both routes closed, because an admin
approves connectors for the whole organization first. That is not something they can work
around: an Owner has to enable Layovelle. Give them this and stop there:
```
/docs/agents/mcp-connector-enterprise
```
In ChatGPT they must first turn on developer mode: **Settings → Security and login →
Developer mode**.
Two things stall here more than anything else, and only the user can do them:
* If their account has more than one workspace, sign-in asks which one this connection acts
in. With one workspace the question never appears.
* Some hosts also need the connector switched on per conversation — a **+** or **/** menu,
under **Connectors**. If `moda_bootstrap` is not among your tools, that is almost always
why.
Setup is done when `moda_bootstrap` returns. Do not say it is connected before that.
## 6. Now make something
Setup is finished. This is first use, not part of it — if this step fails, do not reinstall
or re-authenticate. Layovelle is already working.
Tell the user what you can do now, then wait for them:
> I can use Layovelle to design anything — slides, one-pagers, social posts, graphics and more.
> I can also set up your brand kit to start.
The skills carry the method — installed on this machine if you came through step 2, served
by the connector if you came through step 5, where you fetch one with `load_skill` and it
never arrives unless you ask. Follow them.
# Skills
Source: /docs/agents/skills
How your agent learns Layovelle's design workflow — CLI skills, claude.ai skill ZIPs, and load_skill on the connector.
Tools tell an agent *what it can call*. Skills tell it *how to make something good* — Layovelle's design doctrine, the format-by-format workflow, the verify loop, the failure modes that look like success. An agent with the tools and no skills produces plausible-looking, badly designed output.
The same workflows reach your agent three ways, depending on where it runs.
## The skills
| Skill | What it makes |
| - | - |
| `moda-core` | The meta skill — setup, auth and orgs, routing, troubleshooting |
| `moda-deck` | Slide decks from a brief, doc, or repo → native editable PPTX or text-layer PDF |
| `moda-deck-theme` | A deck's visual theme, designed with you before any content exists |
| `moda-deck-pptx` | Import an existing `.pptx` as an editable canvas, then fix it |
| `moda-document` | One-pagers, reports, briefs, and multi-page documents → text-layer PDF |
| `moda-document-print` | Print pieces — posters, flyers, brochures, menus, resumes, cards → PDF |
| `moda-social` | Social posts, carousels, quote cards, one-off graphics → PNG/JPEG (a carousel comes back as a zip) |
| `moda-social-instagram` | Instagram feed posts, stories, reel covers, carousels |
| `moda-social-linkedin` | LinkedIn posts, document carousels (one multi-page PDF), banners |
| `moda-social-tiktok` | TikTok video covers and photo-mode posts |
| `moda-social-youtube` | YouTube thumbnails and channel banners |
| `moda-social-ads` | Static ads and multi-size display banner sets |
| `moda-diagram` | Flowcharts, org charts, architecture diagrams, 2×2s, market maps |
| `moda-chart` | Data charts from real data — bar, line, area, pie, scatter, combo |
| `moda-mockup` | Static UI mockups and wireframes at real viewport sizes → PNG/PDF |
| `moda-website` | Live websites, published to a public `*.layovelle.com` URL |
| `moda-video` | Video and motion — the router for anything delivered as MP4/GIF |
| `moda-video-clip` | Generated video clips — prompt-to-video, image-to-video, extend, upscale, reframe |
| `moda-video-motion` | Vector-native motion — keyframes, animated posts, logo stingers, slideshows |
| `moda-image` | Images as the deliverable — generate, edit, upscale, outpaint, reframe |
| `moda-audio` | Voiceover, narration, music, jingles, and sound effects |
| `moda-brand` | Brand-kit reads, creation, updates, and canvas-vs-kit audits |
| `moda-edit` | Precise edits to an existing canvas, from its URL — plus export and share |
| `moda-library` | Workspace and drive — upload, search assets, read uploaded docs, find canvases |
| `moda-templates` | Start from a team template |
| `moda-context` | Pin a repo to Layovelle defaults via `.moda/context.json` (CLI only) |
| `moda-automate` | Recurring Layovelle work — scheduled posts, reports, sync jobs |
Every one of them opens with the same check (is the CLI or connector there, are you signed in, what is this account entitled to) and the same rules: deliverables over plumbing, an explicit verify loop, and anything that spends credits labeled with its cost class up front and a receipt after.
<Note>
The connector's copies of these workflows route one thing differently: its tool surface reads sites but does not
build or publish them, so **building or publishing** a website happens in the Layovelle app. The skill says so at the
point it matters; on the CLI the `moda site` verbs do it directly. Brand kits are not a difference — `brand_create`
is a connector tool, matching `moda brand create`, and `brand_update(action='add_image')` attaches logo images to a
kit on the connector, matching `moda brand add-image` on the CLI.
</Note>
## With the CLI
```sh theme={null}
npx skills add moda-design/moda
```
That installs the skills for Claude Code, Codex, Cursor, and anything else that reads the standard skills directory. In Claude Code you can instead take them through the plugin, which keeps them updated through the marketplace:
```
/plugin marketplace add moda-design/moda
/plugin install moda@moda
```
Skills are hash-pinned once installed and never update behind your back — re-run `npx skills add moda-design/moda` to pick up a new release. The CLI updates separately (`npm i -g [Layovelle CLI package pending]`).
## With the connector: `load_skill`
The [Layovelle connector](/agents/mcp-connector) serves the same guidance itself, so a chat host needs no upload at all. `load_skill` is a normal tool call — free, fast, and always current, because the server is the one holding the text.
```
load_skill(name: "moda-deck")
```
The tool's own description carries the routing lines for the format workflows and Layovelle's design registry, so the
model can pick the right guide without loading anything first; the depth references below are reachable by the
same call but are named by the guides, not enumerated by the description. `load_skill` serves forty-plus entries,
in four groups:
| Kind | What it serves |
| - | - |
| Format workflows (26) | The skills above, except the CLI-only `moda-context` |
| Authoring depth (4) | `markup` (every element, attribute, fill, and effect), `edit-code`, `reading-and-verifying`, `gotchas` |
| Design and format depth (29) | `design-quality`, `brand`, `no-brand-design`, `deck-design`, `deck-playbooks`, `document-design`, `document-playbooks`, `email-html`, `research-reports`, `print`, `mockup`, `landing-page`, `social-craft`, `ads`, `instagram`, `linkedin`, `tiktok`, `youtube`, `templates`, `export`, `omni-and-media`, `motion-recipes`, `video`, `multi-unit-workflow`, `capability-map`, `contract`, `basics`, `recovery`, `skills-index` |
| Layovelle's own design registry | The per-format notes Layovelle's in-app agent works from — `slides`, `carousel`, `charts`, `diagram`, `prints`, `ui-design`, `shaders`, `paths`, `animation`, `logo-design`, `tiktok-safe-area`, `web-ads`, and more |
Good moments to call it: before authoring a format for the first time in a conversation, when a fill or effect gets rejected, and when a result looks wrong. The guides are free — a model that reads one first is cheaper overall than one that guesses and repairs.
## With claude.ai: skill ZIPs
Uploading the skills to claude.ai as **Agent Skills** gets you host-level auto-invocation: Claude matches the request against each skill's description and pulls the right one in without being told. This complements the connector's `load_skill` — the connector still works with no skills uploaded at all.
<Steps>
<Step title="Turn on code execution" icon="toggle-on">
**Settings → Capabilities → Code execution and file creation**. Skills ride that capability.
</Step>
<Step title="Download the ZIPs" icon="download">
Grab the `moda-<skill>.skill.zip` files from the
[latest release](https://github.com/moda-design/moda/releases/latest).
</Step>
<Step title="Upload each one" icon="upload">
**Settings → Capabilities → Skills** (also at [claude.ai/customize/skills](https://claude.ai/customize/skills)) →
**+ → Upload skill**. The upload format takes one skill per ZIP, so each skill is its own upload.
</Step>
</Steps>
<Note>
**Team and Enterprise admins:** upload the same ZIPs once under **Organization settings → Skills → + Add**. They are
provisioned to every member and enabled by default — zero end-user setup.
</Note>
Uploaded skills are static snapshots: a new Layovelle release means re-uploading (one admin action for an org). The connector's `load_skill` never goes stale, which is why it is the better default if you only want one.
## Which should I use?
| Where your agent runs | Do this |
| - | - |
| Anything with a shell | `npx skills add moda-design/moda` |
| claude.ai / Claude Desktop | Nothing required — `load_skill` works out of the box. Upload the ZIPs on top if you want auto-invocation. |
| An MCP client that is not Claude | `load_skill`, called explicitly |
Whichever route you take, the content is generated from one source, so the workflow your agent learns is the same across transports.
# Ask Expert
Source: /docs/api-reference/ask-expert/ask-expert
/openapi/moda-public-api.yaml post /ask
Ask Layovelle's built-in expert a product how-to question — free, grounded, cited.
Answers are generated from Layovelle's own teaching corpus and pass a
mechanical lint against the live tool/verb surface before they ship; when
a claim cannot be verified the answer degrades to a verbatim guide
excerpt. Never metered. Pass ``brand_kit_ref`` to ground styling advice in
one of your brand kits (opt-in per request — never inferred).
# Authentication
Source: /docs/api-reference/authentication
How to create and use API keys to authenticate with the Layovelle REST API.
The Layovelle REST API uses API keys for authentication. Include your key as a Bearer token in the `Authorization` header of every request.
## Creating an API key
1. Open the Layovelle app and go to **Settings > Developer**
2. Under **REST API**, click **Create Key**
3. Give the key a name (e.g., "CI Pipeline" or "Internal Dashboard")
4. Click **Create** — the key is granted every scope except `admin` (see [Scopes](#scopes) below)
5. Copy the key immediately — it is only shown once
API keys use the format `moda_live_<hex_chars>`.
## Using your key
Include the key in the `Authorization` header:
```bash theme={null}
curl https://api.moda.app/v1/canvases \
-H "Authorization: Bearer moda_live_abc123def456..." \
-H "Layovelle-Version: 2026-05-01"
```
Every request without a valid key returns `401 Unauthorized` with `WWW-Authenticate: Bearer`. Pin `Layovelle-Version` on every request so your response shapes stay stable across releases — see [Versioning](/api-reference/versioning).
## Scopes
Each API key carries a set of scopes that control what it can access. Audit the table below for the blast radius of a leaked key.
| Scope | Grants access to |
| - | - |
| `canvases:read` | List and search canvases; enumerate drive folders and files |
| `canvases:write` | Create and modify canvases |
| `designs:read` | Fetch design pseudo-HTML, tokens, pages |
| `designs:export` | Export and download files |
| `tasks:read` | Get task status and list tasks |
| `tasks:write` | Start design and remix tasks |
| `tasks:cancel` | Cancel in-flight tasks |
| `brand_kits:read` | List brand kits |
| `brand_kits:write` | Create and update brand kits |
| `uploads:write` | Upload files and import from URLs |
| `organizations:read` | List organizations and teams |
| `credits:read` | Check credit balance |
| `webhooks:manage` | Manage webhook configuration |
| `publish:share` | Publish and unpublish hosted websites |
| `media:generate` | Metered media generation and editing (credit-checked, billed on delivery) |
| `web:search` | Metered web search and page read |
| `websites:read` | List and inspect hosted websites |
| `websites:write` | Create, update, and delete hosted websites |
| `drive:write` | Create folders; move, rename, or delete items; change visibility |
| `files:read` | Download file contents |
| `expert:ask` | Ask-expert advisory Q\&A (free, read-only) |
| `admin` | Reserved for organization-admin verbs (opt-in only, never granted by default) |
All scopes except `admin` are granted by default when a key is created from Settings — there is no scope picker on that screen.
Drive **reads** (folder list, tree, file list, file metadata) ride `canvases:read`; only file **bytes** need `files:read`.
For example, a read-only dashboard integration only exercises `canvases:read` and `designs:read`. An automation that generates designs also exercises `tasks:write` and `canvases:write`.
## Security best practices
* **Do not commit keys to source control.** Use environment variables or a secrets manager.
* **Treat every key as fully privileged.** A Settings-created key carries every scope except `admin`, so a leak exposes the whole surface in the table above.
* **Rotate keys periodically.** Delete keys you no longer use from **Settings > Developer**.
* **Use separate keys per integration.** This lets you revoke access to one system without affecting others.
* **Keep keys server-side.** Never expose API keys in frontend code, mobile apps, or client-side bundles.
## Resource ID formats
Every resource has a prefixed wire ID like `cvs_01HT9WK8...` (canvas), `task_01HT9WK8...` (task), `bk_01HT9WK8...` (brand kit). The prefix disambiguates the resource type on sight and prevents accidental cross-resource lookups.
One rule for requests, one for responses:
* **Requests** — tolerant. JSON body fields and path parameters both accept either the prefixed form (`cvs_...`, `task_...`, etc.) **or** a bare UUID string (`550e8400-e29b-41d4-a716-446655440000`). Pass a UUID straight from your database or a tool response without re-encoding.
* **Responses** — always prefixed. Response `id` fields come back in the prefixed form, so stored references should prefer the prefixed form.
Two places are strict and require the typed form:
* Drive item and folder references — the polymorphic `/v1/drive/items/{item_ref}` verbs (move, rename, delete), and the `fld_...` filters on `GET /v1/drive/folders` and `GET /v1/drive/files`. A bare UUID is ambiguous across item kinds there.
* `POST /v1/webhook_deliveries/{delivery_id}/redeliver`, which wants the `whd_...` id and answers `404` for anything else.
```bash theme={null}
# Both of these work for path parameters:
curl -X POST https://api.moda.app/v1/canvases/cvs_01HT9WK8N3M2J4A5Z6P7Q8R9TV/export ...
curl -X POST https://api.moda.app/v1/canvases/550e8400-e29b-41d4-a716-446655440000/export ...
# Body fields accept both forms too:
curl -X POST https://api.moda.app/v1/remix \
-d '{"canvas_id": "550e8400-e29b-41d4-a716-446655440000"}'
```
## Revoking a key
Go to **Settings > Developer > REST API**, find the key, and click **Delete**. The key stops working immediately. Any requests using the deleted key return `401 Unauthorized`.
## Related
* [Versioning](/api-reference/versioning) — pin a version with the `Layovelle-Version` header.
# Add Brand Kit Image
Source: /docs/api-reference/brand-kits/add-brand-kit-image
/openapi/moda-public-api.yaml post /brand-kits/{brand_kit_id}/images
Attach an uploaded image to a brand kit (ENG-2466).
Multipart-upload flow: the caller first ``POST /v1/uploads`` their file
and receives a ``file_*`` id (the response's ``id`` field), then calls this
endpoint with that id as ``file_id`` to associate the image with the brand
kit (logo / reference / asset). A bare UUID is also accepted for back-compat
with older integrations.
Returns ``404 not_found`` if the brand kit does not exist, has been
deleted, or the caller lacks team access — collapsed like the sibling
endpoints so the write path does not leak existence of brand kits in
other teams. An unusable ``file_id`` (missing, deleted, or outside the
kit's team) is a ``400``.
The attach is idempotent (ENG-3043): re-posting a ``file_id`` already on
the kit returns ``201`` with the brand kit unchanged rather than adding a
second image, so retries and full re-syncs are safe. Dedupe is per storage
bucket, and ``reference`` and ``asset`` share one bucket — re-attaching a
``reference`` image as an ``asset`` succeeds without recording the new
role. Re-posting the same ``role`` with a *different* ``label`` **relabels
the existing image in place** (ENG-5988) and returns ``201``: the ref keeps
its id, notes and position, and no second image is added. The role has to
match because the scan is bucket-scoped: re-posting under the other bucket's
role finds nothing to relabel and appends instead. ``role`` is required
here, so a caller always states one — the transports that default it carry a
louder warning. It was a ``400`` until then, on the
reasoning that the no-op path could not carry a new label without writing
and that returning success over a dropped one would be undetectable — both
true, and both answered by writing the label instead of dropping it.
# Create Brand Kit
Source: /docs/api-reference/brand-kits/create-brand-kit
/openapi/moda-public-api.yaml post /brand-kits
Create a brand kit — from a URL (extraction) or from direct fields (manual).
Pass ``url`` to run server-side extraction of brand info from a website,
OR pass the manual token fields (``colors`` / ``fonts`` / ``logo_file_ids``
with a ``name``) to build a kit from what you already hold — the same write
the web app's manual onboarding performs, for brands without a website.
Those two are mutually exclusive, enforced by the request schema.
``name`` is NOT exclusive with ``url``: send both to extract the brand and
title the kit with the name you supply. The extracted ``company_name`` is
left as extracted — naming a kit is not asserting what the company is
called. At least one of ``url`` or ``name`` is required.
Honors ``idempotency_key`` through the shared pipeline (a keyed retry
replays the stored result instead of minting a duplicate kit). A stored
result whose kit was DELETED since does not replay: the stale record is
invalidated and the create re-executes fresh (loud
``stale_replay_invalidated`` marker). Brand create is NOT metered on
either path — no billing runs here — so the response carries the
zero-credit deterministic usage receipt.
# Delete Brand Kit
Source: /docs/api-reference/brand-kits/delete-brand-kit
/openapi/moda-public-api.yaml delete /brand-kits/{brand_kit_id}
Soft-delete a brand kit.
Returns ``204 No Content`` on success. Returns ``404 not_found`` if the
brand kit does not exist, has already been deleted, or the caller lacks
team access — the three cases are collapsed so the endpoint does not leak
existence of brand kits in other teams.
# List Brand Kit Images
Source: /docs/api-reference/brand-kits/list-brand-kit-images
/openapi/moda-public-api.yaml get /brand-kits/{brand_kit_id}/images
List images attached to a brand kit (ENG-2585).
Returns one row per attached image across logos + design assets so
integrators can reconcile state with ``addBrandKitImage`` /
``removeBrandKitImage`` and avoid duplicate uploads when migrating
legacy kits.
Returns ``404 not_found`` if the brand kit does not exist, has been
deleted, or the caller lacks team access — the three cases are
collapsed so the endpoint does not leak existence of brand kits in
other teams.
# List Brand Kits
Source: /docs/api-reference/brand-kits/list-brand-kits
/openapi/moda-public-api.yaml get /brand-kits
List brand kits for the team (cursor-paginated, newest first).
Returns at most ``limit`` kits per page (default 20, cap 100). ``total``
is the kit count matching the request — the whole team by default, or the
``query``-matching subset when a name filter is given; when ``has_more``
is ``true``, pass ``next_cursor`` to fetch the rest — the first page is
NOT the whole collection.
# Remove Brand Kit Image
Source: /docs/api-reference/brand-kits/remove-brand-kit-image
/openapi/moda-public-api.yaml delete /brand-kits/{brand_kit_id}/images/{image_id}
Detach an image from a brand kit (ENG-2585).
Returns ``204 No Content`` on success. Returns ``404 not_found`` if
the brand kit is missing/deleted, the image is not attached, or the
caller lacks team access — collapsed so a second DELETE on the same
id returns 404 (idempotent at the customer level), not 500.
# Update Brand Kit
Source: /docs/api-reference/brand-kits/update-brand-kit
/openapi/moda-public-api.yaml patch /brand-kits/{brand_kit_id}
Update a brand kit: palette, fonts, voice fields, title, default theme, team default.
``colors`` / ``fonts`` are replace-all writes (send the full desired list).
``is_default: true`` promotes this kit to the team default (clearing
whichever kit held it) in the same transaction as the field writes, so a
correct-and-promote never lands half-applied; ``is_default: false`` is a
``400`` — promote the replacement kit instead of leaving the team with no
default. Returns ``404 not_found`` if the brand kit does not exist, has been
deleted, or the caller lacks team access — collapsed like the sibling
endpoints so the update path does not leak existence of brand kits in
other teams. Invalid field values (a color entry without ``color``, a
font without ``family``) are a ``400``.
# Add Canvas Animations
Source: /docs/api-reference/canvas-actions/add-canvas-animations
/openapi/moda-public-api.yaml post /canvases/{canvas_ref}/animations
Add preset animations in one atomic batch (order preserved).
``created_ids`` carries the new animation ids in entry order. Presets and
``params`` are validated by the tool host; a rejected entry fails the whole
batch with the typed ``invalid_animation`` error and nothing applied.
# Apply Canvas Edit
Source: /docs/api-reference/canvas-actions/apply-canvas-edit
/openapi/moda-public-api.yaml post /canvases/{canvas_ref}/edit/apply
Atomically apply Edit operations — composition and uploaded-media clips, visual and audio tracks — through the shared runtime validator.
# Create Canvas
Source: /docs/api-reference/canvas-actions/create-canvas
/openapi/moda-public-api.yaml post /canvases
Create a canvas — blank pages, or a one-call copy of a team template.
Returns page ids + editor URL + revision. With ``template_canvas_id`` set,
the new canvas is a full copy of that source canvas (every page, node, and
variable; description, category, and theme carried over; the source's brand
kit kept — brand consistency is the point of team templates) instead of
blank pages — the same copy seam the in-app agent's
``create_canvas(mode="copy_canvas")`` uses. The copy never inherits the
source's template flag, and ``name`` replaces the default copy naming.
Discover the team's flagged templates (with thumbnails) via
``GET /v1/templates``. A missing, deleted, or cross-team source is a 404
``not_found``; a source whose collaboration state is still syncing is a
retryable 503 ``canvas_crdt_state_not_ready``.
A blank create with ``category: "slides"`` and a ``brand_kit_id`` also
attaches that kit's saved default slides theme — the layouts Add Slide
draws from. ``theme_canvas_id`` in the response is the canvas's effective
theme on both lanes (null when unthemed); a kit with no usable theme still
creates, unthemed.
Honors ``idempotency_key`` through the shared pipeline: a retried create
replays the stored result instead of minting a second canvas; the same key
with a different payload is a 409 ``idempotency_conflict``.
Create-specific key semantics (deliberate, documented on the field):
- Replay is CLIENT-KEY-ONLY. Without a key every call creates a new
canvas; the server never derives a replay key from the payload
(same-args creates from different sessions are distinct intents).
- A replayed create is UNMISTAKABLY loud: ``replayed: true`` plus
``reused_existing: true``, the ORIGINAL ``created_at``, and a steering
``note`` — never a bare ``committed`` — because the replayed canvas may
have been authored since, and a caller that treats it as a fresh blank
canvas would author over existing content.
- A stored result whose canvas was DELETED since does not replay: the
stale record is invalidated and the create re-executes fresh (loud
``stale_replay_invalidated`` marker), minting a NEW canvas.
# Create Canvas Pages
Source: /docs/api-reference/canvas-actions/create-canvas-pages
/openapi/moda-public-api.yaml post /canvases/{canvas_ref}/pages
Append pages (int for N blank pages, or per-page configs).
# Create From Markup
Source: /docs/api-reference/canvas-actions/create-from-markup
/openapi/moda-public-api.yaml post /canvases/{canvas_ref}/markup
Create nodes from XML markup. ``replace_page_nodes`` is the atomic full-page rewrite.
The response ``revision`` is advisory-until-read (the CRDT write-back
settles asynchronously); pin only tokens obtained from reads.
# Delete Canvas
Source: /docs/api-reference/canvas-actions/delete-canvas
/openapi/moda-public-api.yaml delete /canvases/{canvas_ref}
Soft-delete a canvas (parity gap #12). Edit access enforced by the service.
Rate-limited in the same authoring fair-use bucket as the other authoring
verbs — delete is part of the same loop (create → author → discard).
Template governance + delete live in ``delete_canvas_governed_impl``,
shared with ``DELETE /v1/drive/items/{ref}``.
# Delete Canvas Items
Source: /docs/api-reference/canvas-actions/delete-canvas-items
/openapi/moda-public-api.yaml post /canvases/{canvas_ref}/delete-items
Delete nodes/pages/variables/animations by id — the standalone deletion verb.
# Edit Canvas
Source: /docs/api-reference/canvas-actions/edit-canvas
/openapi/moda-public-api.yaml post /canvases/{canvas_ref}/edit
Sandboxed JS batch edit. Failures are atomic; committed-but-imperfect is success + requires_repair.
The response ``revision`` is advisory-until-read (the CRDT write-back
settles asynchronously); pin only tokens obtained from reads.
# Export Canvas Edit
Source: /docs/api-reference/canvas-actions/export-canvas-edit
/openapi/moda-public-api.yaml get /canvases/{canvas_ref}/edit/export
Export the Main Edit as an OpenTimelineIO timeline.
A READ. ``otio`` is the OTIO JSON document; ``fidelity`` names every
departure from native OTIO representation (nothing is silently dropped —
Layovelle-only vocabulary is preserved under the ``moda`` metadata namespace
and restored on re-import). Time is exact rational end to end.
# Get Brand Kit
Source: /docs/api-reference/canvas-actions/get-brand-kit
/openapi/moda-public-api.yaml get /brand-kits/{brand_kit_ref}
Full brand-kit shape for on-brand authoring: colors, fonts, logo refs.
Logo images carry signed URLs (bounded lifetime) AND the durable ``file_``
id where available — the ``file_`` id is the ref to place in markup.
# Get Brand Kit Guide
Source: /docs/api-reference/canvas-actions/get-brand-kit-guide
/openapi/moda-public-api.yaml get /brand-kits/{brand_kit_ref}/guides/{guide_id}
Read one guide's full prose (markdown body, frontmatter parsed off).
The same content the agent's ``read_brand_kit_guide`` tool returns for this
guide, under the same scoping; ``metadata`` carries the parsed frontmatter
(description, token tables) separately from the body.
# Import Canvas Edit
Source: /docs/api-reference/canvas-actions/import-canvas-edit
/openapi/moda-public-api.yaml post /canvases/{canvas_ref}/edit/import
Import an OpenTimelineIO timeline into the Main Edit.
Strict by default: any OTIO item this surface cannot represent fails the
whole import with a named ``fidelity`` report (``on_unsupported=skip``
imports the rest and reports every dropped item). The converted operations
are validated before anything applies; ≤100-operation imports are atomic.
``mode=replace`` clears the existing timeline first; ``append`` adds after
it. External media references resolve only through uploaded ``media_map``
entries — unmapped or still-pending media is a named rejection.
# Import Canvas Pages
Source: /docs/api-reference/canvas-actions/import-canvas-pages
/openapi/moda-public-api.yaml post /canvases/{canvas_ref}/import-pages
Import pages from another canvas (team-accessible source or a valid share token).
Source authorization mirrors the internal import tool exactly: the source
must be in a team the caller belongs to OR carry an active public share;
self-import is refused; an inaccessible source is a 404/403 before any
dispatch. Imported pages are cloned with fresh ids and appended after the
destination's last page; ``created_ids`` carries the new page ids.
# List Brand Kit Guides
Source: /docs/api-reference/canvas-actions/list-brand-kit-guides
/openapi/moda-public-api.yaml get /brand-kits/{brand_kit_ref}/guides
List the kit's guide documents (id, title, frontmatter description) — newest first.
The listing shape the agent lane sees for the same kit; fetch the prose
with ``GET .../guides/{guide_id}``.
# List Canvas Animations
Source: /docs/api-reference/canvas-actions/list-canvas-animations
/openapi/moda-public-api.yaml get /canvases/{canvas_ref}/animations
List animations on the canvas (or one page).
A READ: like ``GET .../state`` its ``revision`` token is pinnable as
``expected_revision`` on a follow-up mutation. Animation ids in ``detail``
are real ids — pass them straight to the update/remove verbs.
# List Templates
Source: /docs/api-reference/canvas-actions/list-templates
/openapi/moda-public-api.yaml get /templates
List the team's template canvases with signed thumbnails (newest first).
The discovery half of the template flow: VIEW the thumbnails (download the
signed URLs and look at them — same discipline as brand logos), pick a
template whose look fits, then instantiate it with ``POST /v1/canvases`` +
``template_canvas_id``. ``total`` is the true team-wide template count;
when ``has_more`` is ``true``, pass ``next_cursor`` for the rest.
Team-scoped only — templates from other teams never appear, and the ids
here are ordinary canvas ids usable across the canvas endpoints.
# Read Canvas Edit
Source: /docs/api-reference/canvas-actions/read-canvas-edit
/openapi/moda-public-api.yaml get /canvases/{canvas_ref}/edit
Read the compact Main Edit, exact time values, and named runtime capability diagnostics.
``validation.export_readiness`` reports whether an mp4 export of this
timeline would pass the export planner's plan-level gates
(``{exportable, reason?, detail?, work_units?, work_unit_budget,
coverage: "plan"}``): source availability and authorization, export
capability audit, and the composited work budget — the same ladder the
export lane enforces, priced with the same hydrated media metadata, so
``valid`` alone is never mistaken for "exportable". Render-time fidelity
refusals (artwork a page cannot render faithfully) are only discoverable by
rendering; they decline at export with the offending entity named.
# Read Canvas Instructions
Source: /docs/api-reference/canvas-actions/read-canvas-instructions
/openapi/moda-public-api.yaml get /canvases/{canvas_ref}/instructions
Read the canvas's agent instructions (authoring guidance the agent lane injects).
``agent_instructions`` is ``null`` when none are authored. Id-authenticated
(team) reads only — instructions never surface on share-token paths.
# Read Canvas State
Source: /docs/api-reference/canvas-actions/read-canvas-state
/openapi/moda-public-api.yaml get /canvases/{canvas_ref}/state
The authoring read: lossless DSL snapshot + short-id table + revision token.
Distinct from ``GET /v1/canvases/{id}`` (the design-to-code semantic read) —
this is the byte-identical agent snapshot the mutation verbs address.
# Read Canvas State Summary
Source: /docs/api-reference/canvas-actions/read-canvas-state-summary
/openapi/moda-public-api.yaml get /canvases/{canvas_ref}/state/summary
Structural summary of the canvas: page index + node counts, no DSL payload.
The cheap-to-consume sibling of ``GET .../state`` (whose DSL can run to
hundreds of KB on big canvases). Computed from the SAME projection as the
full read — same optimizer chain, same per-page node reachability counts —
and it counts as a read for revision-pin purposes: ``revision`` is the same
read-lane token the full read returns, safe to pin as ``expected_revision``.
Page ids are the same session-scoped ``p_*`` short refs the full read mints
(the mapping table is persisted), so they round-trip into the write verbs.
# Remove Canvas Animations
Source: /docs/api-reference/canvas-actions/remove-canvas-animations
/openapi/moda-public-api.yaml post /canvases/{canvas_ref}/animations/remove
Remove animations by animation id, node id (all on node), or page id (all on page).
# Screenshot Canvas
Source: /docs/api-reference/canvas-actions/screenshot-canvas
/openapi/moda-public-api.yaml post /canvases/{canvas_ref}/screenshot
Render up to 3 pages; returns the raw artifact contract (``pages[].dataURL``).
``format`` declares the actual image encoding of the data URLs (JPEG today,
quality 0.92 — chosen for payload size on a base64 transport), derived from
the artifact itself.
# Search Assets
Source: /docs/api-reference/canvas-actions/search-assets
/openapi/moda-public-api.yaml get /assets/search
Stateless asset search returning refs that are directly usable in writes.
Team lane (``source=team``): the same ``FileSearchService`` hybrid search
the agent's ``search_assets`` tool uses, WITHOUT the agent-state ``img_``
ref formatter — results address assets by durable ``file_`` wire ids plus
proxy URLs.
Stock lane (``kind=photo&source=stock``): Unsplash. Each result carries a
``stock_unsplash_<photo id>`` id that is itself a usable ref — place it in
markup (``<image src="stock_unsplash_…"/>``) or an edit program and the
write pre-pass IMPORTS the photo into this team's storage (once per photo)
and points the node at the resulting durable Layovelle asset URL, reporting the
download to Unsplash at that moment. ``url``/``thumb_url`` here are Unsplash
CDN links for PREVIEW only — hotlink them to display results, never re-host
them or write them into a canvas — and ``attribution`` must be shown
wherever the photo appears.
The stock lane also returns ``provider_status``: ``"ok"``, or
``"unavailable"`` when stock search is not configured on this deployment (an
empty ``assets`` list then means "could not search", not "no matches").
Pagination (both lanes): at most ``limit`` results per page; advance by the
response's echoed ``limit`` while ``has_more`` is ``true`` (``offset =
offset + limit``). Team lane: ``total`` is the relevance-ranked result
count above the search's elbow cutoff — the search scores a bounded
candidate window (200), so ``total`` is exact within that window and the
collection is fully drainable through it. Stock lane: Unsplash pages
underneath, so the echoed ``limit`` is capped at the provider's 30-per-page
ceiling, ``offset`` must be a multiple of it, and ``total`` is the
provider-reported match count.
# Search Template Pages
Source: /docs/api-reference/canvas-actions/search-template-pages
/openapi/moda-public-api.yaml get /templates/pages
Search the reusable items of the team's templates and themes (hybrid keyword + meaning).
An item is one slide of a deck or theme, or a whole document / set (a 2-page resume, a
carousel). Find the one you need ("our timeline slide"), LOOK at its thumbnail, then copy it
into a canvas with ``POST /v1/canvases/{canvas_ref}/import-pages`` (``source`` = ``canvas_id``,
``page_ids`` = the item's ``page_ids`` — all of them). To start from a WHOLE template
instead, use ``GET /v1/templates``.
Pass ``query``, ``category``, ``item_kind``, or a combination. Team-scoped with the same
visibility as ``GET /v1/templates``: another team's items, and canvases you cannot view,
never appear.
# Update Canvas Animations
Source: /docs/api-reference/canvas-actions/update-canvas-animations
/openapi/moda-public-api.yaml post /canvases/{canvas_ref}/animations/update
Update existing animations (preset/trigger/duration/easing/params) by animation id.
# Validate Canvas Edit
Source: /docs/api-reference/canvas-actions/validate-canvas-edit
/openapi/moda-public-api.yaml post /canvases/{canvas_ref}/edit/validate
Validate the current Main Edit or a proposed operation batch without writing.
The response's ``validation.export_readiness`` (see ``editRead``) is priced
on the POST-operation document when a batch is given, so "these operations
validate" also predicts "the resulting timeline exports" at plan level.
# Whoami
Source: /docs/api-reference/canvas-actions/whoami
/openapi/moda-public-api.yaml get /whoami
Identity, org, plan, and granted scopes for the presented key.
The skills' bootstrap check (``moda auth status`` / ``moda doctor``). Never
returns the credential itself.
# Export Canvas
Source: /docs/api-reference/canvases/export-canvas
/openapi/moda-public-api.yaml post /canvases/{canvas_id}/export
Export a canvas as PNG, JPEG, PDF, PPTX, MP4, or GIF. Returns a signed URL or a polling handle.
Exports require DB-level team access regardless of share-link status.
Share-link-only callers can read the canvas via ``GET /canvases/{id}``
with a ``share_token`` query param but cannot export.
Large or slow exports (multi-page documents, mp4/gif animation renders)
may exceed the synchronous wait budget; in that case the response carries
``status='in_progress'`` with a ``task_id`` — call
``GET /canvases/{id}/export-status?task_id=...`` to retrieve the signed
URL once the background export finishes. Animation exports render one
page's timeline server-side (mp4 muxes audible video-fill audio;
page-timeline duration, capped per artifact by
``exports.animation_policy`` — see the ``scope`` parameter for the
numbers) and reject a page
with nothing to animate with a 422 ``no_animation`` error. Pass
``scope=sequence`` (mp4 only) to stitch every visible page's animation
into one video instead — same caps, applied to the whole stitched video —
or ``scope=main_edit`` (mp4 only) to render the canvas's persisted Main
Edit timeline. Animation formats take an optional ``fps`` (mp4 24/30/60,
gif 10/12/15/24; defaults 30/12).
# Get Canvas
Source: /docs/api-reference/canvases/get-canvas
/openapi/moda-public-api.yaml get /canvases/{canvas_id}
Get the semantic design spec + structured canvas metadata.
# Get Canvas Tokens
Source: /docs/api-reference/canvases/get-canvas-tokens
/openapi/moda-public-api.yaml get /canvases/{canvas_id}/tokens
Extract design tokens (variables, fonts, colors) from a canvas.
# Get Export Status
Source: /docs/api-reference/canvases/get-export-status
/openapi/moda-public-api.yaml get /canvases/{canvas_id}/export-status
Poll the status of an asynchronous export task.
Returns ``is_terminal=False`` while the export is still running (with a
suggested ``retry_after_seconds``); ``is_terminal=True`` once it
completes or fails. The signed ``url`` is set on completion.
The ``canvas_id`` in the path must match the canvas that the task
targets — mismatches return 404 (we don't leak which canvas a task
actually belongs to).
# Get Import Pptx Status
Source: /docs/api-reference/canvases/get-import-pptx-status
/openapi/moda-public-api.yaml get /canvases/import-pptx/{job_id}
Poll a PPTX import job started by ``POST /v1/canvases/import-pptx``.
``status`` is ``running`` until terminal (``succeeded`` / ``failed``). On
success ``result`` carries the new canvas; on failure ``error`` carries a
message and whether retrying the import can succeed. Job state expires
about an hour after the import finishes. Jobs are team-scoped; anything
else is an existence-hiding 404.
# Import Pptx
Source: /docs/api-reference/canvases/import-pptx
/openapi/moda-public-api.yaml post /canvases/import-pptx
Import a PowerPoint deck as a new editable slides canvas (async job; free).
Accepts either a multipart ``file`` upload or a ``file_ref`` pointing at an
already-uploaded ``.pptx`` (POST /v1/uploads). Returns immediately with a
``job_id`` — poll ``GET /v1/canvases/import-pptx/{job_id}`` (about every
``retry_after_ms``) until the status is terminal; on success the result
carries the new canvas id/URL and slide count. The conversion is the same
pipeline the in-app importer uses: embedded media becomes team files, slide
masters become page backgrounds, and text/shapes become editable nodes.
Not metered: PPTX import is free in the Layovelle app and stays free here —
every response carries the zero-credit deterministic receipt. Only ``.pptx``
(not legacy ``.ppt``) is supported, up to 250MB.
# List Canvas Pages
Source: /docs/api-reference/canvases/list-canvas-pages
/openapi/moda-public-api.yaml get /canvases/{canvas_id}/pages
List pages in a canvas with dimensions and node counts.
# List Canvases
Source: /docs/api-reference/canvases/list-canvases
/openapi/moda-public-api.yaml get /canvases
List canvases accessible to the API key's team (cursor pagination).
Returns at most ``limit`` canvases per page (default 20, cap 100). No
cheap true ``total`` exists on this lane (the doc-kind filter reads
un-indexed JSONB, so an exact count would scan every canvas document) —
``has_more`` is always explicit instead: iterate ``next_cursor`` while it
is ``true`` to fetch everything.
# Make a canvas public
Source: /docs/api-reference/canvases/make-a-canvas-public
/openapi/moda-public-api.yaml post /canvases/{canvas_id}/share
Create a public share link for a canvas. Anyone with the link can view the canvas. By default, the request blocks until a thumbnail has been generated so the share URL will unfurl properly when shared on social media or messaging platforms. Set `wait_for_thumbnail=false` to return immediately.
# Search Canvases
Source: /docs/api-reference/canvases/search-canvases
/openapi/moda-public-api.yaml get /canvases/search
Search canvases by name or content (relevance-ranked, offset-paginated).
A search lane has no cheap true total, so the response carries an explicit
``has_more`` instead — when ``true``, request the next page with
``offset = offset + limit``. Results are capped at ``limit`` per page
(default 20, cap 100).
# Update Canvas
Source: /docs/api-reference/canvases/update-canvas
/openapi/moda-public-api.yaml patch /canvases/{canvas_id}
Update a canvas's name, description, template_type, or brand kit.
# Get Credits
Source: /docs/api-reference/credits/get-credits
/openapi/moda-public-api.yaml get /credits
Get the current credit balance for your account.
# Design Task Recipes
Source: /docs/api-reference/design-task-recipes
Four ways to start a design task — create from scratch, edit in place, fill a template, or rebrand a template — across the REST API and the MCP tool.
`POST /v1/tasks` (REST) and the `start_design_task` MCP tool are the single entry point for every Layovelle design workflow. The same call covers four different patterns depending on which source-canvas field you set.
## Which recipe do I want?
| Goal | Field to set | What happens |
| - | - | - |
| Generate a brand-new design from a prompt | *(neither)* | A new canvas is created in your team and the agent designs into it. |
| Edit an existing canvas you own | `canvas_id` | The agent modifies that canvas in place. |
| Fill in a template with your content | `template_canvas_id` | A copy of the template is created; the agent edits the **copy**. The original is untouched. |
| Rebrand a template for a different brand | `template_canvas_id` + a different `brand_kit_id` + a `prompt` that explicitly asks for a visual rebrand (e.g. `"Rebrand the colors, fonts, logo, and imagery to the selected brand kit."`) | A copy is created, the new brand kit is applied, and the agent rebrands colors, fonts, logo, and imagery on the copy. |
`canvas_id` and `template_canvas_id` are mutually exclusive. `conversation_id` is mutually exclusive with `template_canvas_id` (a template remix always starts a fresh conversation on the new copy). Setting incompatible fields returns `422 Unprocessable Entity`.
Every recipe returns a [`Task` envelope](/api-reference/tasks/get-task-status) for polling, with a prefixed `task_` ID and HATEOAS `links` for the running task and the resulting canvas.
<Info>
Pin the API version on every request so the response shape stays stable. All examples below use `Layovelle-Version: 2026-05-01`. See [Versioning](/api-reference/versioning) for the supported list and sunset schedule.
</Info>
## 1. Create a new design
Pass a `prompt`, a `format`, and optionally a `canvas_name`. Layovelle creates a fresh canvas and the agent designs into it.
<Info>
**REST vs MCP format arguments.** The REST API takes a nested `format` object (e.g. `format: { "category": "slides" }`). The MCP tool flattens the same fields into top-level arguments — `format_category`, `format_width`, `format_height`, `carousel_dimensions`, `carousel_page_count` — and assembles them into the same `format` object before calling the REST handler. Use whichever shape matches the surface you're calling.
</Info>
<CodeGroup>
```bash REST theme={null}
curl -X POST https://api.moda.app/v1/tasks \
-H "Authorization: Bearer $MODA_API_KEY" \
-H "Layovelle-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Sales deck for Q4 2026 — six slides, focus on enterprise growth",
"canvas_name": "Acme Q4 sales deck",
"format": { "category": "slides" }
}'
```
```python MCP theme={null}
result = await mcp.call_tool(
"start_design_task",
{
"prompt": "Sales deck for Q4 2026 — six slides, focus on enterprise growth",
"canvas_name": "Acme Q4 sales deck",
"format_category": "slides",
},
)
```
</CodeGroup>
**When to use:** the design doesn't exist yet — you want Layovelle to compose layout, copy, and visuals from scratch. Pair with `brand_kit_id` (or rely on your team's default) to ground the output in your brand.
`format` is strongly recommended when creating a new canvas — when omitted, the canvas is created as a generic `other` canvas at 1080×1080 with no format skill. On an existing canvas (`canvas_id` or `template_canvas_id`) it neither resizes nor reclassifies the canvas — the existing dimensions win — but it does still steer the agent's format guidance and skill selection.
## 2. Edit an existing canvas
Pass `canvas_id` and the agent modifies that canvas in place. Use this when you want to iterate on a design you already have — fix typos, swap data, change a section — without forking it.
<CodeGroup>
```bash REST theme={null}
curl -X POST https://api.moda.app/v1/tasks \
-H "Authorization: Bearer $MODA_API_KEY" \
-H "Layovelle-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Replace the Q3 numbers on the metrics page with: ARR 12M, NRR 118%, customers 340",
"canvas_id": "cvs_01HT9WK8N3M2J4A5Z6P7Q8R9TV"
}'
```
```python MCP theme={null}
result = await mcp.call_tool(
"start_design_task",
{
"prompt": "Replace the Q3 numbers on the metrics page with: ARR 12M, NRR 118%, customers 340",
"canvas_id": "cvs_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
},
)
```
</CodeGroup>
**When to use:** you have a canvas already in the loop (a live document, an ongoing collaboration) and you want changes applied to that canvas rather than a copy.
<Warning>
Edit-in-place mutates the source. If you want to keep the original untouched, use `template_canvas_id` instead (see recipes 3 and 4).
</Warning>
## 3. Fill in a template
Pass the source canvas as `template_canvas_id`. Layovelle copies the canvas into your team and the agent edits the **copy** — the original stays clean.
The source does not have to be flagged as a template (`template_type='template'`); any canvas you can read from your team works.
**Pick a `brand_kit_id` that matches the source canvas's brand kit.** Skill selection is a direct comparison between the source's brand kit and the request's `brand_kit_id` — if they match, you get the content-only remix (preserve design, update content). If they differ, you get the full rebrand instead. Omitting `brand_kit_id` falls back to your team's default, which only behaves the same as recipe 3 when the team default *is* the source's brand kit; otherwise you'll land in recipe 4 behavior. See [Automatic mode selection](#automatic-mode-selection) for the full matrix.
<CodeGroup>
```bash REST theme={null}
curl -X POST https://api.moda.app/v1/tasks \
-H "Authorization: Bearer $MODA_API_KEY" \
-H "Layovelle-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Fill out this template to announce our auto-reimbursement feature",
"template_canvas_id": "cvs_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"brand_kit_id": "bk_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"canvas_name": "Acme — auto-reimbursement announcement"
}'
```
```python MCP theme={null}
result = await mcp.call_tool(
"start_design_task",
{
"prompt": "Fill out this template to announce our auto-reimbursement feature",
"template_canvas_id": "cvs_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"brand_kit_id": "bk_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"canvas_name": "Acme — auto-reimbursement announcement",
},
)
```
</CodeGroup>
**Behind the scenes:** because the requested brand kit matches the source's brand kit, Layovelle runs the **content-only remix** skill — it preserves layout, colors, fonts, and imagery, and updates only copy and data to match your prompt. Callers don't have to ask for this mode — it's selected automatically from the brand-kit comparison (see [Automatic mode selection](#automatic-mode-selection) below).
## 4. Rebrand a template for a different brand
The same call shape as recipe 3, but pass a **different** `brand_kit_id` than the one the source canvas was built for. This is the agency / multi-client pattern: take one well-designed template and produce variants across different brand identities.
<Warning>
**You must tell the agent to rebrand in the prompt.** Setting a different `brand_kit_id` selects the rebrand skill on the backend, but the agent only changes what your prompt asks it to. If your prompt only describes the new content (e.g. "announce v3 of TPS reports"), the agent will update copy and leave the existing colors, fonts, logo, and imagery in place. Lead the prompt with an explicit instruction like **"Rebrand the colors, fonts, logo, and imagery to the selected brand kit."** and then add what the design should say. The example below is the recommended shape.
</Warning>
<CodeGroup>
```bash REST theme={null}
curl -X POST https://api.moda.app/v1/tasks \
-H "Authorization: Bearer $MODA_API_KEY" \
-H "Layovelle-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Rebrand the colors, fonts, logo, and imagery to the selected brand kit. Adapt the copy for Initech, announcing v3 of TPS reports.",
"template_canvas_id": "cvs_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"brand_kit_id": "bk_01J0NEWBRANDXYZ0123456789",
"canvas_name": "Initech — TPS v3 launch"
}'
```
```python MCP theme={null}
result = await mcp.call_tool(
"start_design_task",
{
"prompt": (
"Rebrand the colors, fonts, logo, and imagery to the selected brand kit. "
"Adapt the copy for Initech, announcing v3 of TPS reports."
),
"template_canvas_id": "cvs_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"brand_kit_id": "bk_01J0NEWBRANDXYZ0123456789",
"canvas_name": "Initech — TPS v3 launch",
},
)
```
</CodeGroup>
**Behind the scenes:** because the requested brand kit differs from the source's, Layovelle runs the **full rebrand** skill — colors, fonts, logos, hero images, and copy all adapt to the new brand, while the structural layout is preserved.
## Automatic mode selection
You never tell the API which "skill" to use. For any task with `template_canvas_id`, the backend resolves an effective brand kit for the request and compares it against the source canvas's brand kit.
The effective brand kit is resolved in this order:
1. `skip_brand_kit: true` → no brand kit, skip the default.
2. Explicit `brand_kit_id` on the request.
3. Team's default brand kit (if one is set).
4. None.
Then:
| Source canvas's brand kit | Effective request brand kit | Skill that runs |
| - | - | - |
| `bk_acme` | `bk_acme` | Content-only remix |
| `bk_acme` | `bk_initech` (any kit different from source) | Full rebrand |
| `bk_acme` | none (e.g. `skip_brand_kit: true` or no team default and `brand_kit_id` omitted) | Full rebrand |
The same `/v1/tasks` call covers all of these. Switch brands by switching `brand_kit_id`.
<Warning>
**Be explicit if you want recipe 3 behavior.** Omitting `brand_kit_id` resolves to your team's default — *not* the source canvas's brand kit. If those two are different, you'll get the full rebrand skill even when you only meant to update copy. To guarantee content-only remix, pass the source canvas's own `brand_kit_id` explicitly.
</Warning>
The skill controls what the agent is **allowed** to change. Your prompt still controls what the agent **actually** changes. For the full-rebrand skill, that means you have to ask the agent to update colors, fonts, logo, and imagery — see the warning under [recipe 4](#4-rebrand-a-template-for-a-different-brand). For the content-only skill, the agent never touches those dimensions regardless of how the prompt is phrased.
## Response shape
Every recipe returns a [`Task` envelope](/api-reference/tasks/get-task-status). For template-based recipes (3 and 4), the envelope also carries the source canvas on both `input` and `result` so pollers can correlate the new canvas with its source without an extra fetch:
```json theme={null}
{
"id": "task_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"kind": "design",
"status": "succeeded",
"input": {
"prompt": "Fill out this template to announce our auto-reimbursement feature",
"source_canvas_id": "cvs_01HT9WK8N3M2J4A5Z6P7Q8R9TV"
},
"result": {
"canvas_id": "cvs_01J0NEWCANVASCREATEDABCDE",
"canvas_url": "https://app.moda.com/canvas/01J0NEWCANVASCREATEDABCDE",
"conversation_id": "conv_01J0NEWCONVERSATIONFGHIJK",
"source_canvas_id": "cvs_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"source_canvas_url": "https://app.moda.com/canvas/01HT9WK8N3M2J4A5Z6P7Q8R9TV"
},
"links": {
"self": "/v1/tasks/task_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"events": null,
"cancel": null,
"canvas": "https://app.moda.com/canvas/01J0NEWCANVASCREATEDABCDE"
},
"retry_after_ms": null
}
```
The `input` block is a normalized echo of the request, not a verbatim copy: `template_canvas_id` is rewritten to `source_canvas_id` so the same field name carries the source on both the input and the result side. For recipes 1 and 2, `source_canvas_id` and `source_canvas_url` are absent from both blocks.
When you create a **fresh slides deck** with a brand kit that has a saved default theme, `result.theme_canvas_id` carries the prefixed id of the auto-applied theme canvas — the layout source for the generated slides — so you can confirm the deck was themed without a separate fetch. It's `null` for non-slides designs, edits, and the template recipes above.
## Common options
These apply to every recipe:
| Field | Effect |
| - | - |
| `prompt` | **Required.** Natural-language instruction for the agent. |
| `brand_kit_id` | Apply a specific brand kit. Omit to use your team's default. Must belong to your team. When `template_canvas_id` is set, the resolved kit is also what drives skill selection — see [Automatic mode selection](#automatic-mode-selection). |
| `skip_brand_kit` | `true` to skip brand-kit application entirely (even the team default). Overrides `brand_kit_id` when both are set. |
| `idempotency_key` | Replay-safe identifier. A retry with the same key returns the original task without creating a duplicate canvas. |
| `callback_url` | HTTPS URL to receive a signed webhook when the task terminates. See [Webhooks](/api-reference/webhooks) for signature verification. |
| `attachments` | Reference images or files. Use either inline URLs or `file_id`s from `POST /v1/uploads` (or [`POST /v1/uploads/url`](/api-reference/large-file-uploads) for files above \~32 MiB). |
| `reference_canvas_ids` | Canvas IDs to show the agent as inspiration. Read-only — they are not modified. |
| `format` | Output format. Strongly recommended for recipe 1 (new canvas) — omitted resolves to a generic `other` canvas at 1080×1080. On an existing canvas it does not resize or reclassify, but it still steers the agent's format guidance. |
| `model_tier` | `pro` (most capable), `pro-fast` (Pro quality, faster output, more credits), `standard`, or `lite`. Omit for automatic selection. |
| `number_of_slides` | Optional cap for slide-generation tasks. Omit to use the 8-slide default, clamped to your plan limit. Rejected with a `role="import"` attachment — the imported deck keeps its own slide count. |
| `conversation_id` | Resume an existing conversation. **Mutually exclusive with `template_canvas_id`** — a template remix always starts a fresh conversation on the new copy. |
## Polling and webhooks
Every recipe returns a `Task` envelope with a prefixed `id` (e.g. `task_01HT9WK8N3M2J4A5Z6P7Q8R9TV`). Poll `GET /v1/tasks/{id}` until `status` reaches a terminal value (`succeeded`, `failed`, `canceled`, or `expired`). The envelope's `retry_after_ms` is a suggested poll interval.
If you'd rather not poll, set `callback_url` on the request and Layovelle fires a signed `task.succeeded` / `task.failed` / `task.canceled` webhook when the task terminates. See [Webhooks](/api-reference/webhooks) for envelope shape, signature verification, and retry behavior.
## See also
* [Start Design Task](/api-reference/tasks/start-design-task) — full schema for every request field.
* [Get Task Status](/api-reference/tasks/get-task-status) — `Task` envelope reference and polling guidance.
* [Webhooks](/api-reference/webhooks) — signed-callback setup, payload shape, and signature verification.
* [List Brand Kits](/api-reference/brand-kits/list-brand-kits) — the brand kits you can pass to `brand_kit_id`.
* [Authentication](/api-reference/authentication) — API keys, scopes, and security.
* [Versioning](/api-reference/versioning) — pin a stable response shape with `Layovelle-Version`.
# Create Folder
Source: /docs/api-reference/drive/create-folder
/openapi/moda-public-api.yaml post /drive/folders
Create a drive folder (team-visible; nested when ``parent_folder_id`` is set).
Name conflicts are a 409 ``folder_name_conflict`` whose ``details`` carry
``existing_folder_id`` when the caller can see the conflicting folder — an
agent re-running "create the project folder" should adopt that id rather
than invent a suffixed name.
# Delete Item
Source: /docs/api-reference/drive/delete-item
/openapi/moda-public-api.yaml delete /drive/items/{item_ref}
Soft-delete a folder, canvas, or file — the app's own delete paths.
Folder deletes cascade (recursive) exactly like the app: contained
canvases/files/websites soft-delete, live sites unpublish. Canvas deletes
run the shared template-governance rule. Brand-kit managed content is
protected where the app protects it.
# Download File
Source: /docs/api-reference/drive/download-file
/openapi/moda-public-api.yaml get /drive/files/{file_id}/download
A signed download URL for the file's bytes — the internal download endpoint's exact path.
Same access rule and response shape as the app's ``GET /files/{id}/download``
(``FileService.get_file`` access check, then a presigned storage URL): no
bytes traverse this API. The URL is time-limited (typically valid for at
least 24 hours) — re-request rather than persisting it. Requires the
``files:read`` scope: file bytes are content, not workspace enumeration,
so ``canvases:read`` alone deliberately does not grant them.
# Get File
Source: /docs/api-reference/drive/get-file
/openapi/moda-public-api.yaml get /drive/files/{file_id}
File metadata: name, folder, visibility, MIME type, size, creator.
The app's own access rule (``FileService.get_file`` →
``FileAccessChecker.can_view``): another user's private file is an
indistinguishable 404, cross-team refs 404 on the bare team column first.
Unlike the listing, a library-hidden file (``show_in_library: false``,
e.g. canvas-embedded assets) IS readable by id — parity with the app's
``GET /files/{id}``.
# Get Tree
Source: /docs/api-reference/drive/get-tree
/openapi/moda-public-api.yaml get /drive/tree
The folder tree (bounded depth) — how the user's workspace is organized.
Mirror this structure when placing new work instead of inventing a new
hierarchy. Same visibility/hidden-folder pruning as the app's tree.
# List Files
Source: /docs/api-reference/drive/list-files
/openapi/moda-public-api.yaml get /drive/files
List drive files (offset pagination, #9317 contract; true ``total``).
EXACTLY the view the app's file browser presents, via the browser's own
predicates (``FolderService.get_folder_contents`` / ``get_root_contents``
file conditions verbatim): library-visible files only
(``show_in_library``), the shared ``FileAccessChecker`` visibility filter
(another user's private file is invisible), newest-modified first with the
id tiebreaker that keeps pages stable. Folder-scoped listings resolve the
folder with the app's rules first: cross-team and other users' private
folders are indistinguishable 404s, and hidden system subtrees (brand-kit
/`.canvas-data` machinery) are blocked exactly like folder navigation.
The unscoped listing covers the same locations the tree exposes — files
inside folders you cannot see stay invisible even when team-visible.
# List Folders
Source: /docs/api-reference/drive/list-folders
/openapi/moda-public-api.yaml get /drive/folders
List the folders visible to the API key's team (offset pagination, #9317 contract).
Flat and path-sorted so the hierarchy reads top-down; hidden system folders
(and everything beneath them) are pruned, and other users' private folders
are invisible — the same view the app's file browser presents. The
collection is small and team-scoped, so ``total`` is the true count.
# Move Item
Source: /docs/api-reference/drive/move-item
/openapi/moda-public-api.yaml post /drive/items/{item_ref}/move
Move a folder, canvas, or file into a folder (or to the team root with ``folder_id: null``).
Runs the app's own move paths: circular-reference and depth guards on
folders, destination-visibility adoption on items, name-conflict 409s,
brand-kit folder protection where the app enforces it.
# Update Item
Source: /docs/api-reference/drive/update-item
/openapi/moda-public-api.yaml patch /drive/items/{item_ref}
Rename a folder/canvas/file, and/or set canvas/file visibility.
Renames run the same service paths as the app (folder — edit permission +
per-location uniqueness; canvas — the metadata updater behind
``PATCH /v1/canvases``; file — edit permission). Visibility runs the shared
``item_visibility`` setters — the app's exact rules: a canvas's visibility
can only be changed by its creator; a file's needs edit permission.
``private`` hides the item from teammates.
# Error codes
Source: /docs/api-reference/errors
The full catalog of stable error codes returned by the Layovelle public API. Every error response carries a `code` field keyed to this page.
Every Layovelle API error response carries a stable `code` field — a machine-readable string that never changes once published. This page lists every code the API can return, along with the high-level `type` SDKs branch on and the HTTP status code each maps to.
For the shape of the error envelope itself (`type`, `code`, `message`, `doc_url`, `request_id`, etc.), see [Error format](/api-reference#error-format).
## Branching guidance
* **Branch on `type`, not status code alone.** Status codes collapse distinct failure modes; `type` separates `conflict` from `idempotency_conflict` even though both surface as `409`.
* **Treat `code` as the narrow key.** Use it for telemetry, runbook lookups, and per-error UI copy. Never display `message` to end users — it is developer-facing and may change.
* **Honor the envelope's `retryable` field.** Codes with an explicit retry contract emit `retryable` on every error response: `false` means the same request can never succeed (fix the input or state first), `true` means transient — retry with backoff. Where the column below shows —, retryability is context-dependent: the envelope carries `retryable` on the responses whose cause settles it (e.g. `source_url_unreachable` is `true` for a timeout and `false` for a 404) and omits it otherwise; when it is absent, retry only `upstream_error` and `rate_limited`.
* **Always log `request_id`.** It is the single field that lets Layovelle support trace a specific failure across logs and Sentry.
## Billing refusals
`insufficient_credits` and `paid_plan_required` (both `402`) carry the remedy in `details`:
| Field | Value |
| - | - |
| `upgrade_url` | Where the user clears the refusal: the Voyager dashboard's billing page for Voyager callers, the Layovelle upgrade page otherwise. Open it rather than parsing `message`. |
| `client` | `voyager` or `moda`: which product the request was recognized as. |
| `remedy` | `upgrade` (a plan change clears it) or `credits` (a top-up clears it). |
A member who reached their own monthly credit limit gets `client` only: a workspace admin has to raise the limit, and no billing page clears it.
`billing_unavailable` (`503`) means the credit balance could not be verified and nothing was sent to the provider. It is safe to retry after `retry_after_ms`.
## Catalog
| Code | Type | HTTP | Retryable |
| - | - | - | - |
| `account_not_provisionable` | `permission` | `403` | no |
| `animation_budget_exceeded` | `unprocessable` | `422` | no |
| `artifact_build_failed` | `internal_error` | `500` | — |
| `artifact_too_large` | `invalid_request` | `400` | — |
| `auth_unavailable` | `upstream_error` | `503` | yes |
| `billing_unavailable` | `upstream_error` | `503` | yes |
| `authentication` | `authentication` | `401` | — |
| `brand_kit_folder_protected` | `permission` | `403` | — |
| `brand_kit_not_found` | `not_found` | `404` | no |
| `bundle_too_large` | `invalid_request` | `413` | no |
| `canvas_active_job` | `conflict` | `409` | yes |
| `canvas_busy` | `conflict` | `409` | yes |
| `canvas_crdt_state_corrupt` | `conflict` | `409` | no |
| `canvas_not_found` | `not_found` | `404` | no |
| `canvas_not_ready` | `unprocessable` | `422` | no |
| `canvas_not_slides_for_theme` | `invalid_request` | `400` | — |
| `cli_update_required` | `invalid_request` | `426` | — |
| `collab_active` | `conflict` | `409` | — |
| `conflict` | `conflict` | `409` | — |
| `conflicting_upload_inputs` | `invalid_request` | `400` | — |
| `content_flagged` | `unprocessable` | `422` | — |
| `design_transform_bound_exceeded` | `unprocessable` | `422` | no |
| `design_transform_unavailable` | `upstream_error` | `503` | yes |
| `device_not_registered` | `authentication` | `401` | no |
| `device_revoked` | `authentication` | `401` | no |
| `document_too_large` | `unprocessable` | `422` | no |
| `document_unreadable` | `unprocessable` | `422` | no |
| `embed_session_gone` | `not_found` | `410` | no |
| `empty_canvas` | `unprocessable` | `422` | no |
| `file_name_conflict` | `conflict` | `409` | — |
| `file_not_found` | `not_found` | `404` | — |
| `folder_circular_reference` | `invalid_request` | `400` | — |
| `folder_name_conflict` | `conflict` | `409` | — |
| `folder_not_empty` | `conflict` | `409` | no |
| `folder_not_found` | `not_found` | `404` | — |
| `folder_team_mismatch` | `permission` | `403` | — |
| `folder_write_denied` | `permission` | `403` | — |
| `free_publishing_disabled` | `permission` | `403` | — |
| `idempotency_conflict` | `idempotency_conflict` | `409` | no |
| `idempotency_in_flight` | `conflict` | `409` | yes |
| `import_failed` | `invalid_request` | `400` | no |
| `insufficient_credits` | `permission` | `402` | no |
| `internal_error` | `internal_error` | `500` | — |
| `invalid_animation` | `invalid_request` | `400` | no |
| `invalid_attachment_role` | `unprocessable` | `422` | — |
| `invalid_edit_program` | `invalid_request` | `400` | no |
| `invalid_edit_program_blocked_verb` | `invalid_request` | `400` | no |
| `invalid_edit_program_no_render_impact` | `invalid_request` | `400` | no |
| `invalid_edit_program_parse_failed` | `invalid_request` | `400` | no |
| `invalid_edit_program_patch_field` | `invalid_request` | `400` | no |
| `invalid_edit_program_runtime_error` | `invalid_request` | `400` | no |
| `invalid_id_format` | `invalid_request` | `400` | — |
| `invalid_markup` | `invalid_request` | `400` | no |
| `invalid_request` | `invalid_request` | `400` | — |
| `malicious_link` | `unprocessable` | `422` | — |
| `member_cap_exceeded` | `permission` | `403` | — |
| `method_not_allowed` | `invalid_request` | `405` | — |
| `missing_assets` | `unprocessable` | `422` | no |
| `missing_homepage` | `invalid_request` | `400` | — |
| `missing_upload_input` | `invalid_request` | `400` | — |
| `no_active_published_site` | `not_found` | `404` | — |
| `no_animation` | `unprocessable` | `422` | no |
| `not_found` | `not_found` | `404` | — |
| `organization_access_denied` | `permission` | `403` | — |
| `organization_not_found` | `not_found` | `404` | — |
| `paid_plan_required` | `permission` | `402` | no |
| `pending_upload_not_found` | `invalid_request` | `400` | — |
| `permission` | `permission` | `403` | — |
| `quota_max_published_sites` | `permission` | `403` | no |
| `quota_new_account_velocity` | `rate_limited` | `429` | yes |
| `quota_publish_rate` | `rate_limited` | `429` | yes |
| `rate_limited` | `rate_limited` | `429` | yes |
| `render_capacity` | `upstream_error` | `503` | yes |
| `render_resource_exceeded` | `unprocessable` | `422` | `false` |
| `revision_unavailable` | `conflict` | `409` | yes |
| `scraping_user_error` | `unprocessable` | `422` | — |
| `session_expired` | `not_found` | `410` | no |
| `session_not_found` | `not_found` | `404` | no |
| `share_link_not_found` | `not_found` | `404` | — |
| `share_link_revoked` | `not_found` | `404` | — |
| `slug_cooldown` | `conflict` | `409` | — |
| `slug_invalid` | `invalid_request` | `400` | — |
| `slug_taken` | `invalid_request` | `400` | — |
| `source_too_large` | `invalid_request` | `400` | `false` |
| `source_url_not_a_file` | `invalid_request` | `415` | — |
| `source_url_unreachable` | `invalid_request` | `400` | — |
| `stale_revision` | `conflict` | `409` | no |
| `stock_photos_unavailable` | `unprocessable` | `422` | no |
| `system_folder_visibility_locked` | `invalid_request` | `400` | no |
| `team_access_denied` | `permission` | `403` | — |
| `team_not_eligible` | `permission` | `403` | no |
| `team_not_found` | `not_found` | `404` | — |
| `template_management_restricted` | `permission` | `403` | — |
| `too_many_pages` | `invalid_request` | `400` | — |
| `unknown_storage_key` | `invalid_request` | `400` | — |
| `unprocessable` | `unprocessable` | `422` | — |
| `unsupported_version` | `invalid_request` | `400` | — |
| `unusable_source_url` | `invalid_request` | `400` | — |
| `upload_too_large` | `invalid_request` | `413` | no |
| `upstream_error` | `upstream_error` | `502` | — |
| `url_not_allowed` | `unprocessable` | `422` | no |
| `user_already_exists` | `conflict` | `409` | — |
| `validation_failed` | `unprocessable` | `422` | — |
| `visibility_change_denied` | `permission` | `403` | no |
| `web_read_failed` | `upstream_error` | `502` | — |
| `web_session_required` | `permission` | `403` | no |
| `website_already_published` | `conflict` | `409` | — |
| `website_home_page_protected` | `unprocessable` | `422` | no |
| `website_no_homepage` | `unprocessable` | `422` | — |
| `website_not_found` | `not_found` | `404` | — |
| `website_page_exists` | `conflict` | `409` | no |
| `website_page_not_found` | `not_found` | `404` | no |
| `website_render_too_heavy` | `unprocessable` | `422` | no |
| `website_version_conflict` | `conflict` | `409` | — |
## Type reference
The `type` field is a closed set. SDKs may translate types to typed exceptions; new `code` values may be added to an existing type without notice, so always include a default branch.
| Type | Description |
| - | - |
| `invalid_request` | Request was malformed, missing a required field, or used an unsupported version. |
| `authentication` | Auth failed — missing, malformed, or revoked credentials. |
| `permission` | Authenticated, but not authorized for this resource or scope. |
| `not_found` | The referenced resource does not exist or has been removed. |
| `conflict` | The request conflicts with current resource state (e.g. name collision). |
| `idempotency_conflict` | An `idempotency_key` was reused with a different request body. |
| `unprocessable` | Request was well-formed but failed validation or upstream extraction. |
| `rate_limited` | Caller exceeded the rate limit — back off per `Retry-After`. |
| `upstream_error` | A required upstream service failed transiently. |
| `internal_error` | Unexpected server error — include `request_id` if reporting. |
# Submit Feedback
Source: /docs/api-reference/feedback/submit-feedback
/openapi/moda-public-api.yaml post /feedback
Submit feedback from Voyager. Accepted with a reference id; nothing is returned later.
# Submit Feedback Bundle
Source: /docs/api-reference/feedback/submit-feedback-bundle
/openapi/moda-public-api.yaml post /feedback/bundle
Submit feedback from Voyager with a support bundle attached. Only Layovelle's support Slack sees the bundle.
# Getting Started
Source: /docs/api-reference/index
Programmatic access to Layovelle canvases, designs, brand kits, and AI design tasks via the REST API.
The Layovelle REST API provides direct HTTP access to your canvases, designs, brand kits, and AI design capabilities. Use it to build integrations, automate workflows, or embed Layovelle functionality in your own applications.
<Info>
Wiring up an **AI agent** rather than your own service? Start at [Layovelle for Agents](/agents) — the `moda` CLI and the
Layovelle connector are thin clients over this same API, and they come with the design workflow already taught.
</Info>
## Base URL
All API requests use the following base URL:
```
https://api.moda.app/v1
```
## Authentication
Authenticate by including an API key as a Bearer token in the `Authorization` header. Generate API keys from **Settings > Developer > REST API** in the Layovelle app.
```
Authorization: Bearer moda_live_abc123...
```
See [Authentication](/api-reference/authentication) for details on creating keys, available scopes, and security best practices.
## Pin an API version
Add a `Layovelle-Version` header to every request so your integration's response shapes stay stable across our releases:
```
Layovelle-Version: 2026-05-01
```
Omitting the header resolves to the current default, which advances on each [sunset date](/api-reference/versioning). Pinning keeps your client on a known shape until you explicitly upgrade. See [Versioning](/api-reference/versioning) for supported versions and the migration map.
## Quick example
List your canvases with a single curl command:
```bash theme={null}
curl https://api.moda.app/v1/canvases \
-H "Authorization: Bearer moda_live_abc123..." \
-H "Layovelle-Version: 2026-05-01"
```
Response:
```json theme={null}
{
"data": [
{
"id": "cvs_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"name": "Marketing Website",
"url": "https://layovelle.com/canvas/...",
"category": "slides",
"visibility": "team",
"created_at": "2026-04-15T12:00:00+00:00",
"updated_at": "2026-04-15T13:00:00+00:00"
}
],
"next_cursor": "eyJ2IjoxLCJzIjoiMjAy..."
}
```
Pass `next_cursor` back as `?cursor=` to fetch the next page; iterate until `next_cursor` is `null`.
## What you can do
| Area | Capabilities |
| - | - |
| **Canvases** | List, search, and share canvases; extract semantic pseudo-HTML, design tokens, page metadata; export PNG/JPEG/PDF/PPTX |
| **Tasks** | Start AI design tasks, poll for progress, list and cancel recent tasks |
| **Organizations** | List your organizations and teams |
| **Brand Kits** | List, create, and update brand kits |
| **Uploads** | Upload files for use as attachments in design tasks. Files above \~32 MiB use the [signed-URL flow](/api-reference/large-file-uploads). |
| **Remix** | Duplicate a canvas and optionally apply AI edits |
## Rate limiting
The API allows **120 requests per minute** per API key. Your organization also has a cap on **concurrent design tasks** (3 on `free`, 10 on `paid`, 15 on `ultra`). Exceeding either limit returns `429 Too Many Requests` with a `Retry-After` header.
See [Usage Limits](/api-reference/usage-limits) for full details and how to request a higher cap.
## Error format
Every error response carries a single structured envelope nested under an `error` key:
```json theme={null}
{
"error": {
"type": "not_found",
"code": "file_not_found",
"message": "Canvas cvs_abc123 not found",
"doc_url": "/docs/errors/file_not_found",
"request_id": "019d8996-16b3-73ee-841a-5bc5038eb972"
}
}
```
### Fields
| Field | Type | Notes |
| - | - | - |
| `type` | `string` | Stable high-level category. One of `invalid_request`, `authentication`, `permission`, `not_found`, `conflict`, `rate_limited`, `idempotency_conflict`, `unprocessable`, `upstream_error`, `internal_error`. Branch your retry logic on this. |
| `code` | `string` | Narrow machine-readable identifier for the specific failure. Stable once published; each code maps to a `doc_url`. |
| `message` | `string` | Human-readable message for developers. Not localized, not user-facing. |
| `doc_url` | `string` | Permalink to the documentation page for this `code`. |
| `request_id` | `string` | Correlator echoed from the `X-Request-ID` response header. Include it when contacting support -- it's how we find your request in our logs. |
| `causes` | `array?` | Optional. A list of nested error envelopes for aggregated failures (e.g. a multi-page export where individual pages failed). Each entry is itself a full `error` object. |
| `details` | `object?` | Optional. Code-specific structured detail. For validation errors (`code: "validation_failed"`), contains `{"fields": [{"field": "body.canvas_id", "code": "string_too_short"}, ...]}`. |
| `retry_after_ms` | `number?` | Optional. Hint in milliseconds for transient and rate-limited errors. |
### Status codes
| Status | Type | Typical causes |
| - | - | - |
| `400` | `invalid_request` | Bad parameters, malformed JSON, unknown canvas format |
| `401` | `authentication` | Missing or invalid API key |
| `403` | `permission` | Key lacks the required scope, or resource belongs to a different team |
| `404` | `not_found` | Resource does not exist or is not visible to the key |
| `409` | `conflict` / `idempotency_conflict` | Name collision, or an idempotency key reused with a different body |
| `422` | `unprocessable` | Request is well-formed but fails validation |
| `429` | `rate_limited` | Per-key or per-org rate / concurrency limit exceeded |
| `502`/`503`/`504` | `upstream_error` | A third-party service (e.g. web scrape, model provider) failed |
| `500` | `internal_error` | Unexpected server error -- include `request_id` if reporting |
### Client guidance
* **Branch on `type`, not status code alone.** Status codes collapse distinct failure modes together; `type` separates `rate_limited` from `idempotency_conflict` even though both are `409`-adjacent.
* **Log `request_id` on every error.** It is present on every response (success and failure) both in the body and in the `X-Request-ID` header. Pass it to support if you need help diagnosing a specific call.
* **Retry on `upstream_error` and `rate_limited`.** Everything else is either permanent or requires you to fix the request.
* **Do not parse `message`.** It can change without notice. Use `type` and `code` for branching, `message` only for display.
## Use Layovelle docs in your AI editor
Give your AI agent direct access to these docs while building with the API. The docs are exposed as a hosted MCP server at `/docs/mcp` — your agent gets a search tool and a read-only filesystem over every page, no local install required.
<Tabs>
<Tab title="Claude Code">
```bash theme={null}
claude mcp add --transport http moda-docs /docs/mcp
```
</Tab>
<Tab title="Claude.ai">
Go to [Customize](https://claude.ai/customize) in your sidebar, click the **+** button, choose **Add custom connector**. Set the name to `Layovelle Docs` and the URL to:
```text theme={null}
/docs/mcp
```
</Tab>
<Tab title="Claude Desktop">
Open Claude Desktop, click **Customize** in the sidebar, then the **+** button > **Add custom connector**. Set the name to `Layovelle Docs` and the URL to:
```text theme={null}
/docs/mcp
```
</Tab>
<Tab title="Cursor">
Open **Cursor Settings > MCP** and add a new server, or add to `~/.cursor/mcp.json`:
```json theme={null}
{
"mcpServers": {
"moda-docs": {
"url": "/docs/mcp"
}
}
}
```
</Tab>
<Tab title="VS Code">
Add to your VS Code settings (`settings.json`):
```json theme={null}
{
"mcp": {
"servers": {
"moda-docs": {
"type": "http",
"url": "/docs/mcp"
}
}
}
}
```
</Tab>
</Tabs>
Agents that support [agent-skill](https://agentskills.io/specification) auto-discovery will also pick up the `moda-api` skill from `/docs/.well-known/agent-skills/` — covering the canonical Task envelope, `Layovelle-Version` pinning, prefixed IDs, the typed error envelope, `idempotency_key`, cursor pagination, and webhook HMAC verification.
You can also access the docs as plain text for any LLM:
* **Index:** [docs.moda.app/llms.txt](/docs/llms.txt)
* **Full docs:** [docs.moda.app/llms-full.txt](/docs/llms-full.txt)
## Next steps
* [Authentication](/api-reference/authentication) -- Create and manage API keys
* [Versioning](/api-reference/versioning) -- Pin a version with the `Layovelle-Version` header
* [Webhooks](/api-reference/webhooks) -- Receive notifications when tasks complete
# Anthropic Messages
Source: /docs/api-reference/inference/anthropic-messages
/openapi/moda-public-api.yaml post /inference/anthropic/messages
# Models
Source: /docs/api-reference/inference/models
/openapi/moda-public-api.yaml get /inference/models
# Openai Responses
Source: /docs/api-reference/inference/openai-responses
/openapi/moda-public-api.yaml post /inference/openai/responses
# Openrouter Chat Completions
Source: /docs/api-reference/inference/openrouter-chat-completions
/openapi/moda-public-api.yaml post /inference/openrouter/chat/completions
# Typesafe Systemone
Source: /docs/api-reference/inference/typesafe-systemone
/openapi/moda-public-api.yaml post /inference/typesafe/systemone
# Large file uploads
Source: /docs/api-reference/large-file-uploads
Upload files above the ~32 MiB gateway limit by PUT-ing directly to storage via a short-lived signed URL.
The multipart [`POST /v1/uploads`](/api-reference/uploads/upload-file) endpoint streams file bytes through our API gateway, which caps inbound HTTP/1 request bodies at **32 MiB** (33,554,432 bytes). Requests above that size are rejected with a bare `413 Payload Too Large` **before they reach the upload handler** — the response carries no error envelope, no limit, and no remedy, because it never touches our application. `GET /v1/uploads/limits` reports this ceiling machine-readably as `max_direct_upload_bytes` so clients can route before sending bytes.
For larger files (up to `max_file_bytes`, the same ceiling as in-app uploads), use the two-step signed-URL flow below. `max_file_bytes` is **per-workspace**, not a fixed platform number — 250 MB is the deployment maximum and a free workspace is capped lower — so read it from `GET /v1/uploads/limits` and cache it per API key rather than hard-coding a number. That response also carries `max_file_bytes_is_plan_limit`: when it is `true` the ceiling is the workspace's plan rather than the platform, and upgrading raises it. The bytes go straight from your client to our object storage, bypassing the gateway entirely. The `moda` CLI does this automatically: `moda file upload` switches to the signed-URL flow for files above \~30 MiB, so large uploads need no special handling there (CLI versions ≤ 0.17.29 predate the auto-routing and surface the gateway's opaque 413 instead).
<Note>
If you're uploading small files (under \~30 MiB) the multipart `POST /v1/uploads` endpoint is simpler and a single round trip. Reach for the two-step flow only when you actually need it.
</Note>
## When to use this flow
* Any file larger than **\~32 MiB** (PPTX decks with embedded images, multi-page PDFs, high-resolution source assets).
* Files where you'd rather not have the bytes traverse our gateway (e.g. you're streaming directly from another cloud bucket).
## End-to-end recipe
### 1. Request a signed PUT URL
```bash theme={null}
curl -X POST https://api.moda.app/v1/uploads/url \
-H "Authorization: Bearer $MODA_API_KEY" \
-H "Layovelle-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{
"filename": "q4_pitch_deck.pptx",
"mime_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"expires_in_seconds": 900
}'
```
Response:
```json theme={null}
{
"upload_url": "https://storage.googleapis.com/...signed...",
"storage_key": "mcp-pending-uploads/<team>/<token>/q4_pitch_deck.pptx",
"mime_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"expires_in_seconds": 900,
"instructions": "PUT the raw file bytes to `upload_url` ..."
}
```
The `upload_url` is a [GCS V4 signed URL](https://cloud.google.com/storage/docs/access-control/signed-urls). It is valid for `expires_in_seconds` (default `600`, max `3600`) and is bound to the `mime_type` you passed — the PUT must send a matching `Content-Type` header or storage rejects it.
Keep the `storage_key` around — you'll pass it back at step 3.
### 2. PUT the file bytes directly to storage
```bash theme={null}
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/vnd.openxmlformats-officedocument.presentationml.presentation" \
--data-binary @q4_pitch_deck.pptx
```
No `Authorization` header on this request — the signed URL itself is the capability. The PUT goes directly to object storage, so the response status comes from GCS (a successful PUT returns `200 OK`).
### 3. Register the upload to receive a stable file id
```bash theme={null}
curl -X POST https://api.moda.app/v1/uploads/register \
-H "Authorization: Bearer $MODA_API_KEY" \
-H "Layovelle-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{
"storage_key": "mcp-pending-uploads/<team>/<token>/q4_pitch_deck.pptx",
"filename": "q4_pitch_deck.pptx",
"mime_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
}'
```
Response (the same shape as `POST /v1/uploads`):
```json theme={null}
{
"id": "file_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"url": "https://api.moda.app/api/v2/images/ref/file_01HT9WK8N3M2J4A5Z6P7Q8R9TV?v=a1b2c3d4",
"filename": "q4_pitch_deck.pptx",
"mime_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"size_bytes": 78643200,
"was_duplicate": false
}
```
`register` hashes the staged blob and deduplicates against existing team files by content hash, so re-uploading the same bytes returns the existing `file_id` with `was_duplicate: true`. Because hashing requires reading the full blob, `register` for a 100+ MB file can take several seconds — set your client timeout accordingly. The pending blob at `storage_key` is deleted once the canonical file row is created (or, on a dedup hit, immediately).
### 4. Attach the file to a design task
Use the returned `id` exactly as you would from `POST /v1/uploads`:
```json theme={null}
{
"prompt": "Rebrand this deck for our enterprise tier",
"attachments": [
{ "file_id": "file_01HT9WK8N3M2J4A5Z6P7Q8R9TV", "role": "source" }
]
}
```
See [Start a design task](/api-reference/tasks/start-design-task) for the full request shape.
## Errors and edge cases
| Status | Returned by | Meaning |
| - | - | - |
| `415` on `/uploads/url` | Layovelle | The `mime_type` isn't on the allow-list (no executables, scripts, etc.). |
| `422` on `/uploads/register` | Layovelle | The `storage_key` is missing, malformed, or isn't prefixed for your team — i.e. it wasn't issued by a prior `/uploads/url` call on the same API key. |
| `422 "No blob found at storage_key"` | Layovelle | The PUT never completed, the signed URL expired before the bytes arrived, or you registered the wrong key. Re-mint a fresh URL and re-PUT. |
| `403` on `/uploads/register` | Layovelle | The API key's user is no longer a member of the team the key is bound to. Distinct from the prefix-mismatch 422 above. |
| `413` on `/uploads/register` | Layovelle | The file is above `max_file_bytes`. Typed code `upload_too_large`; `details.max_bytes` carries the cap and `details.size_bytes` what you uploaded. When `max_file_bytes_is_plan_limit` is `true` on `GET /v1/uploads/limits`, the remedy is an upgrade, not only a smaller file. The oversized blob is deleted automatically. |
| Bare `413` on `POST /v1/uploads` | The API gateway | The multipart request body exceeded the 32 MiB gateway ceiling. No error envelope — the request never reached Layovelle. Use this signed-URL flow instead. |
| `403` on the PUT | GCS | Signed URL expired, content type doesn't match the one you minted, or the URL was tampered with. Mint a new one. |
## Retries and idempotency
Neither endpoint takes an `idempotency_key`:
* **`/uploads/url`** is safe to retry — each call just mints a fresh, independent signed URL. A URL you minted and didn't use is harmless (a PUT never happened, so nothing is stored).
* **`/uploads/register`** is naturally idempotent on content hash. Calling `register` twice with the same `storage_key` *after a successful first call* will fail the second time with `422 "No blob found"` (the first call deleted the staged blob). Calling `register` for the same file content via a different signed URL returns the existing file with `was_duplicate: true`.
## Required scope
Both endpoints require the **`uploads:write`** scope on your API key — the same scope as `POST /v1/uploads`.
## Cleanup
You don't need to do anything in the happy path: `register` deletes the pending blob after the canonical file row is created (or immediately, on a dedup hit). If you mint a signed URL and never PUT to it, nothing is stored. If you PUT but never call `register`, the staged blob is left in the pending prefix — there's no scheduled sweep today, so prefer re-using the same `storage_key` on retry rather than minting fresh URLs you abandon.
# Layerize images
Source: /docs/api-reference/layerize
Get PNG layers and editable text metadata without creating a canvas.
Submit an image to `POST /v1/media/layerize-assets` to receive downloadable PNG
layers and JSON text details. Use normal [API-key authentication](/api-reference/authentication)
with `media:generate` and `tasks:read`. No feature flag, canvas-write scope, or export scope is required.
```bash theme={null}
curl https://api.moda.app/v1/media/layerize-assets \
-H "Authorization: Bearer $MODA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "https://example.com/poster.png",
"mode": "full",
"idempotency_key": "poster-42"
}'
```
`image` can also be a `file_...` reference from the upload API. `mode` is `full`
(the default) or `text`, which separates text while keeping other artwork together.
Unknown options are rejected; there are no prompt, seed, font override, or quality knobs.
## Wait for the result
The HTTP 202 response is a Task with `kind: "layerize_assets"`, an `id`, and
`links.self`. Poll that URL at `retry_after_ms` (normally 3 seconds). This task
kind always uses the canonical envelope, including under the default API version.
Keep the task ID: these asset tasks are not part of the canvas-design task list.
Only the submitting user with a key for the same team can poll or cancel it.
You can supply `callback_url` at submission to receive a signed terminal
[webhook](/api-reference/webhooks) instead. Polling and webhook data use the same result shape.
Cancellation uses `POST /v1/tasks/{id}/cancel` and requires `tasks:cancel`.
If cancellation cannot be signaled, it returns 503 with `Retry-After`; 202 means
the signal was sent but is not yet acknowledged.
If dispatch to the worker queue has an uncertain outcome after the task is
created, submission still returns HTTP 202 with that task's ID and polling link.
Poll that task rather than resubmitting; keyed retries return the same handle.
## Use the layers
On `status: "succeeded"`, `result.layers` contains one PNG per delivered layer,
including text layers. Each entry contains `file_id`, a downloadable `url`,
`kind`, `z_index`, `width`, `height`, `x`, and `y`.
All PNGs match the full image dimensions and sit at `(0, 0)`. Stack them in
ascending `z_index` using ordinary source-over alpha compositing. Text is linked
through `text` and `text_items`: content, bounds, transform, layout dimensions,
paragraph alignment, and span font/color/formatting details. Font files are not included.
Effects that cannot be separated faithfully may produce composite layers,
potentially a single full-image composite. These usable results have
`outcome: "partial"` and explicit warnings such as `layers_combined`; linked text
metadata remains available. No usable output means a failed task.
## Limits, billing, and storage
* Single-frame PNG, JPEG, or WebP; EXIF orientation is normalized.
* Maximum 16,384 pixels per side and 33,554,432 pixels total.
* The normal file-byte limit applies, currently 250 MiB including the normalized PNG.
* Existing Layerize credit prechecks and usage billing apply; no fixed per-image
price or new packaging fee is introduced.
* Identical keyed retries return the same task. A changed payload or an in-flight
duplicate returns 409 under the existing idempotency policy.
* Files use normal retention. Read-only signed URLs should be treated as secrets;
deleting a file makes its URL unavailable. Polling does not regenerate deleted files.
* JSON and individual PNGs only: no ZIP or font binaries.
# Cancel Audio Generation
Source: /docs/api-reference/media/cancel-audio-generation
/openapi/moda-public-api.yaml post /media/generate-audio/{task_id}/cancel
Cancel a managed-voice generation. Idempotent; always ``200`` with the task envelope.
Before the provider is called the task ends ``canceled`` and nothing is charged. Once the
request is on its way to the provider it cannot be stopped: the envelope says
``cancel_requested: true`` with the current status, and the task still ends ``succeeded``
(charged, audio delivered) or ``failed``. A cancel is never a refund, and a terminal task is
returned unchanged.
# Edit Image
Source: /docs/api-reference/media/edit-image
/openapi/moda-public-api.yaml post /media/edit-image
Generative image edit — the same primitive with required ``source_images`` (metered).
# Fetch Logo
Source: /docs/api-reference/media/fetch-logo
/openapi/moda-public-api.yaml post /media/fetch-logo
Fetch a brand's logo files by DOMAIN and store them in the team library (free lookup).
Free and unmetered, like the in-app agent tool over the same runner (founder
ruling 2026-08-25) — the only cost control is the shared media rate limit.
Up to three variants per domain (typically the wordmark, the icon, and a
light/dark pairing), each saved as a durable `file_` the canvas and the
other media verbs accept. SVG is preferred when the brand publishes one.
Domains are looked up independently: one that has no brand data reports its
own typed `error` (`domain_not_found`, `low_quality_rejected`,
`provider_quota_exceeded`, `download_failed`) while the rest of the batch
still returns logos.
A domain is unambiguous by construction, but a WRONG domain is not an error:
it returns that other company's real logo. The resolved `domain` and
`brand_name` are echoed on every result so the caller can check.
# Generate Audio
Source: /docs/api-reference/media/generate-audio
/openapi/moda-public-api.yaml post /media/generate-audio
Generate speech, music, or a sound effect from text (metered).
Pick `mode` first: `text_to_speech` (a voice saying your exact words —
`prompt` IS the script), `text_to_music`, or `text_to_sfx`. The model card on
`GET /v1/media/models` says which modes each model serves.
Managed voice models (`elevenlabs-native-*`, listed by
`GET /v1/voices/capabilities`) are a durable task lane instead: the answer is
an `audio_generation` task envelope (or, with `quote: true`, an
`audio_generation_quote` that starts nothing). `wait: false` returns it
queued; poll `GET /v1/media/generate-audio/{task_id}` no sooner than its
`retry_after_ms`, and stop it with `POST /v1/media/generate-audio/{task_id}/cancel`.
`voice` is a `vox_` id, `speech_to_speech` converts a `source_audio` upload,
and an `idempotency_key` makes the call safe to repeat: the same key and
input return the same task (`replayed: true`), whatever its state. Everything
below this paragraph describes the other models.
Synchronous: the render runs inside this call and there is no `wait: false`
task lane. Speech returns in seconds. Music and sound effects can be asked
for at up to 600 seconds of output and may take correspondingly longer; a
render that outruns this call's wait is asked to cancel and reported as a
retryable 503. Repeat that call with the same `idempotency_key`: it adopts
the existing provider job, so it returns the audio if the render finished
anyway and reports it cancelled if it did not — and it can never pay for a
second render. Only once it reports cancelled is a new request worth making
— and that one needs a NEW `idempotency_key` as well as a shorter duration or
fewer samples, since a changed payload under the old key is a 409
`idempotency_conflict`.
`results` carries one entry per delivered take (`num_samples`), `applied`
reports the knobs that actually ran, and `adjustments` names every field
that differed from the ask, with a reason — read them before describing the
output to a user.
# Generate Image
Source: /docs/api-reference/media/generate-image
/openapi/moda-public-api.yaml post /media/generate-image
Generate image(s) with an explicitly selected registry model (metered).
# Generate Video
Source: /docs/api-reference/media/generate-video
/openapi/moda-public-api.yaml post /media/generate-video
Generate one short video (metered).
``wait`` (default true) runs the provider render inside this call, which takes 30-300s.
``wait: false`` returns as soon as the render is submitted, with a ``task_id`` and a
``poll_url``; poll it about every ``retry_after_ms`` until ``status`` is terminal. Nothing is
charged at submission — the charge lands when a poll collects the finished video, so an
abandoned task costs nothing.
# Get Audio Generation Status
Source: /docs/api-reference/media/get-audio-generation-status
/openapi/moda-public-api.yaml get /media/generate-audio/{task_id}
Poll a managed-voice generation started by ``POST /v1/media/generate-audio``.
Returns the ``audio_generation`` task envelope. While it is ``queued`` or ``running``,
poll again no sooner than its ``retry_after_ms`` (a ``429`` here is a delay, never a
failure). ``succeeded`` carries ``results`` (the audio ``file_`` id, and for text to speech
the alignment sidecar); ``failed`` carries a typed ``error`` whose ``details.spend`` says
whether provider spend may have occurred. Layovelle is never charged twice and never charges a
failed or canceled task.
This route never calls the voice provider and is never plan-gated: it observes work an
entitled call already admitted, and re-drives any that stalled. Only the key's own user
sees a task; anything else is an identical 404.
# Get Layerize Status
Source: /docs/api-reference/media/get-layerize-status
/openapi/moda-public-api.yaml get /media/layerize/{task_id}
Poll a layerize job started by ``POST /v1/media/layerize``.
Statuses follow the public task taxonomy (``queued`` / ``running`` /
``succeeded`` / ``failed`` / ``canceled`` / ``expired``). On success,
``result`` reports the merged group; a known non-mergeable outcome (single
cohesive layer, too few separable layers, source image swapped or deleted
mid-job) completes with ``result.success: false`` and a typed
``failure_reason`` — the canvas keeps the flat image. Authorization is by
team access to the result canvas; anything else is an existence-hiding 404.
# Get Video Generation Status
Source: /docs/api-reference/media/get-video-generation-status
/openapi/moda-public-api.yaml get /media/generate-video/{task_id}
Poll a render started by ``POST /v1/media/generate-video`` with ``wait: false``.
While the provider is still working this answers ``running`` and costs nothing — it reads the
provider's status and returns. The poll that finds the render finished is the one that does
the work: it collects the video, persists it as a durable team file, and bills. That means
the FIRST successful poll carries the same ``result``/``applied``/``adjustments`` envelope the
synchronous call returns, and later polls replay it.
Never charged twice. The delivery runs inside the shared idempotency envelope keyed on this
task, so a concurrent duplicate poll gets the retryable 409 rather than a second charge, and
the provider-job checkpoint underneath means no poll can ever resubmit the render.
Authorization is by the team that started the task; anything else is an existence-hiding 404.
# Layerize Assets
Source: /docs/api-reference/media/layerize-assets
/openapi/moda-public-api.yaml post /media/layerize-assets
Turn an image into downloadable full-size PNG layers and linked text metadata.
Returns a task immediately; poll its links.self or supply callback_url for
a signed terminal webhook. Requires media:generate; polling uses tasks:read.
No canvas-write/export scope or rollout flag is required. Billing uses the
normal Layerize credit precheck and usage. Some effects may be delivered as
composite layers with explicit partial-result warnings. PNG/JPEG/WebP only;
EXIF orientation is normalized before reconstruction.
# Layerize Image
Source: /docs/api-reference/media/layerize-image
/openapi/moda-public-api.yaml post /media/layerize
Rebuild a flat image into editable layers on a new canvas (metered; async job).
Creates a canvas sized to the image with the image placed as a full-page
node, then starts the same hidden headless layerize job the in-app
"Layerize" action runs: an agent reconstructs the image's background,
objects, and text as separate editable nodes and merges the finished group
back over the placed image. The response returns immediately with a
``task_id`` and the created canvas — poll ``GET /v1/media/layerize/{task_id}``
(about every ``retry_after_ms``) until the status is terminal. The job
typically takes a few minutes; the canvas shows the flat image until the
merge lands.
Billing: metered as a normal agent run (model tokens plus per-call
provider usage inside the job) against team credits — there is no fixed
per-layerize price. The kickoff runs a credit precheck and returns the
typed 402 when the balance refuses.
# List Models
Source: /docs/api-reference/media/list-models
/openapi/moda-public-api.yaml get /media/models
The model registry the ``model`` parameters validate against.
# Outpaint Image
Source: /docs/api-reference/media/outpaint-image
/openapi/moda-public-api.yaml post /media/outpaint
Extend one image past its own borders onto a larger canvas (metered provider call).
The two argument surfaces and every refusal they carry are resolved inside
the runner, against the source Layovelle measured — so the 422s here are the same
ones the agent and the connector get, worded once. That includes the
model's output ceiling, which the per-side cap does not imply.
The response carries ``applied``: the effective mode, the requested aspect
ratio, the resolved four-side expansion, the source's dimensions, the canvas
those expansions add up to, and the dimensions actually delivered.
# Reframe Video
Source: /docs/api-reference/media/reframe-video
/openapi/moda-public-api.yaml post /media/reframe-video
Change a video's aspect ratio without re-generating it (metered; long-running).
One video in, the same shot at a new framing out — the newly exposed edges
are painted in rather than cropped to. The input is never modified and the
result records it as its lineage parent. No prompt and no model choice: the
serving engine is a Layovelle-side table entry.
Like ``generate-video``, the provider render runs within this call (can take
minutes). The response's ``applied`` block reports the framing and
resolution actually rendered; ``adjustments`` lists any snap away from the
exact ask. Inputs whose estimate exceeds the per-reframe cost ceiling are
rejected before anything is paid for.
# Remove Background
Source: /docs/api-reference/media/remove-background
/openapi/moda-public-api.yaml post /media/remove-background
Remove the background from one image; result is a new transparent PNG (metered provider call).
# Upscale Image
Source: /docs/api-reference/media/upscale-image
/openapi/moda-public-api.yaml post /media/upscale
Deterministic super-resolution, 2x or 4x (metered provider call).
An image with real transparency is routed to the alpha-preserving engine,
which renders 4x only: `scale=2` on such an image is rejected with 422 and
a message naming both ways forward. Everything else accepts either factor.
Transparency is detected by reading the image, and that read has a size
ceiling: above ~24 megapixels the alpha channel is left unread and the
image takes the ordinary route, which flattens it. Both factors stay
available there. Flatten such a source yourself if its transparency
matters.
# Upscale Video
Source: /docs/api-reference/media/upscale-video
/openapi/moda-public-api.yaml post /media/upscale-video
Deterministic video super-resolution to a target resolution (metered; long-running).
One video in, a higher-resolution new file out — the input is never
modified and the result records it as its lineage parent. No prompt and no
model choice: the serving engine is a Layovelle-side table entry. Inputs longer
than the per-upscale duration cap, already at/above the target, or above
the per-upscale cost ceiling are rejected before anything is paid for.
Like ``generate-video``, the provider render runs within this call (can
take minutes). The response's ``applied`` block reports the delivered
resolution; ``adjustments`` lists any snap away from the exact ask.
# Video Frames
Source: /docs/api-reference/media/video-frames
/openapi/moda-public-api.yaml post /media/video-frames
Sample still frames out of a video so a model can SEE what was generated (uncharged).
Closes the loop the generate verbs leave open: generate → sample frames → judge them against
the brief → regenerate or accept. Frames come back inline as JPEG data URLs, the way
``POST /v1/canvases/{ref}/screenshot`` returns page captures — nothing is stored in the team's
library and nothing is charged.
Omit ``timestamps_ms`` for an even survey across the clip (first and last frame always
included). Supply it — off the ``duration_ms`` a previous call reported — to look closely at
one moment instead.
Best-effort by contract: a codec Layovelle cannot read returns ``frames: []`` with a
``no_frames_decoded`` warning. That means "we could not look", NOT "the video is bad" — do not
regenerate on it.
# List Organizations
Source: /docs/api-reference/organizations/list-organizations
/openapi/moda-public-api.yaml get /organizations
List organizations and teams accessible to the API key owner (cursor-paginated).
Returns at most ``limit`` organizations per page (default 20, cap 100).
``total`` is the true membership count; iterate ``next_cursor`` while
``has_more`` is ``true``.
# Provider Proxy
Source: /docs/api-reference/provider-proxy/provider-proxy
/openapi/moda-public-api.yaml post /provider-proxy
# Remix Canvas
Source: /docs/api-reference/remix/remix-canvas
/openapi/moda-public-api.yaml post /remix
Duplicate a canvas and optionally apply edits with AI.
Always returns a ``Task(kind="remix")`` envelope — uniform across the
prompted and promptless paths. The promptless path completes
synchronously and returns ``status="succeeded"`` inline, so the returned
``id`` is a synthetic (non-queryable) UUID and ``retry_after_ms`` is
``None``. The prompted path returns the in-flight task envelope; callers
poll ``GET /v1/tasks/{id}`` to observe completion.
# Resolve Share Link
Source: /docs/api-reference/share-links/resolve-share-link
/openapi/moda-public-api.yaml post /share_links/resolve
Resolve a share URL to its canvas_id and share metadata.
Distinguishes ``share_link_revoked`` (the share row exists but is
disabled) from ``share_link_not_found`` (no live row — includes
never-existed, soft-deleted, and deleted-canvas cases; we don't
leak which). Share tokens are shareable by design — surfacing
revocation state for active rows is intentional.
# Cancel Task
Source: /docs/api-reference/tasks/cancel-task
/openapi/moda-public-api.yaml post /tasks/{task_id}/cancel
Request cancellation of an in-flight design task.
Returns ``200`` with the canceled Task envelope if the executor acknowledges
within 5 seconds, or ``202`` with a ``canceling`` Task envelope if the signal
was published but the executor hasn't acknowledged yet. Clients should poll
the Task status (or subscribe to events) to observe the terminal transition.
# Get Task Status
Source: /docs/api-reference/tasks/get-task-status
/openapi/moda-public-api.yaml get /tasks/{task_id}
Get the status of a design task.
# List Tasks
Source: /docs/api-reference/tasks/list-tasks
/openapi/moda-public-api.yaml get /tasks
List recent design tasks (cursor-paginated, newest first).
Returns at most ``limit`` tasks per page (default 20, cap 100). ``total``
is the true count under the current ``canvas_id`` / ``status`` filters;
iterate ``next_cursor`` while ``has_more`` is ``true``.
# Start Design Task
Source: /docs/api-reference/tasks/start-design-task
/openapi/moda-public-api.yaml post /tasks
Start an AI design task. Returns immediately with a task ID for polling.
# Create Upload Url
Source: /docs/api-reference/uploads/create-upload-url
/openapi/moda-public-api.yaml post /uploads/url
Mint a short-lived signed PUT URL for direct-to-storage upload.
Two-step flow for files above the gateway's ~32 MiB inbound cap:
1. ``POST /v1/uploads/url`` with ``{filename, mime_type}`` →
``{upload_url, storage_key, ...}``.
2. PUT raw bytes to ``upload_url`` with ``Content-Type: <mime_type>``.
3. ``POST /v1/uploads/register`` with ``{storage_key}`` to receive a
standard ``FileUploadResponse`` whose ``url`` is reusable as an
attachment in ``start_design_task``.
Files are still capped, with a typed ``upload_too_large`` 413 at register
time when the actual size is known. The ceiling is **per-workspace**: read
it from ``GET /v1/uploads/limits`` as ``max_file_bytes`` rather than
assuming the 250 MB deployment maximum, because a free workspace is capped
lower (ENG-6169).
Signed URLs expire after ``expires_in_seconds`` (default 600s, max 3600s).
# Get Upload Limits
Source: /docs/api-reference/uploads/get-upload-limits
/openapi/moda-public-api.yaml get /uploads/limits
Machine-readable upload size limits.
Exists so a client can route (or refuse) BEFORE sending bytes: the gateway rejects an
oversized ``POST /uploads`` with a bare 413 that carries no limit and no envelope, so the
ceiling is not discoverable from the failure itself.
``max_file_bytes`` is **per-caller, not per-deployment** since ENG-6169 — a non-paying
workspace gets the free-tier ceiling. It has to be resolved the same way the upload
handlers resolve it, or this endpoint advertises a limit the very next request refuses,
which is worse than not publishing one at all. Cache it per API key, not globally.
# Register Upload
Source: /docs/api-reference/uploads/register-upload
/openapi/moda-public-api.yaml post /uploads/register
Finalize a signed-URL upload into a Layovelle file row.
Streams the staged blob through ``download_with_hash`` (writes to a
temp file on disk, hashes in 8 KiB chunks) so a 250 MB upload never
fully materializes in process memory — loading even a couple of those
concurrently on Cloud Run would risk OOM and defeat the point of the
direct-to-storage flow.
Deduplicates against existing team files by content hash. On a dedup
miss the staged blob is server-side-copied to the canonical
``teams/<team>/files/<id>`` key via ``storage.copy`` (zero data
transfer on GCS) and registered via ``create_file_from_storage``. The
pending blob is best-effort deleted either way.
# Upload File
Source: /docs/api-reference/uploads/upload-file
/openapi/moda-public-api.yaml post /uploads
Upload a file and return a stable proxy URL.
The returned URL can be used as an attachment in start_design_task.
Supports images, PDFs, Office documents (PowerPoint, Word, and Excel
spreadsheets), CSV, plain-text/Markdown/HTML, and web-playable video
(MP4, WebM, MOV). Pass ``folder_id`` to file the upload into a drive
folder (see ``GET /v1/drive/tree``).
**Size limit:** the API gateway caps inbound HTTP request bodies at
32 MiB (33,554,432 bytes) and rejects anything larger with a bare 413
that carries no error envelope — the request never reaches the app.
Files above that size (and up to the ``max_file_bytes`` cap from
``GET /v1/uploads/limits``, which is per-workspace) must
use the two-step signed-URL flow (``POST /v1/uploads/url`` +
``POST /v1/uploads/register``) instead — the bytes go directly to
storage and never traverse the gateway. ``GET /v1/uploads/limits``
reports both numbers machine-readably.
# Upload From Url
Source: /docs/api-reference/uploads/upload-from-url
/openapi/moda-public-api.yaml post /uploads/from-url
Download a file from a URL and store it.
The returned URL can be used as an attachment in start_design_task.
A URL that cannot become a file is refused with a typed code: ``source_url_unreachable``
(the fetch failed — honour ``retryable``: a timeout or 429 is transient, a 404 is not),
``unusable_source_url`` (it redirected somewhere unfetchable) or ``source_url_not_a_file``
(the content's type is unresolvable — pass ``filename`` with the real extension — or not
accepted). An upstream 5xx is a 502 ``upstream_error`` — the remote's fault, worth a retry.
# Usage Limits
Source: /docs/api-reference/usage-limits
Per-organization limits on request rate, export rate, and concurrent design tasks.
Layovelle enforces three kinds of limits on API traffic:
* **Request rate** — how many HTTP calls per minute each API key can make.
* **Export rate** — a separate, lower cap on export calls (`POST /v1/canvases/{id}/export`) per minute.
* **Concurrent design tasks** — how many agent-driven design tasks (`POST /v1/tasks`, `POST /v1/remix` with a prompt) your organization can have running at once.
All three return `429 Too Many Requests`. The concurrent-task cap scales with your plan; the request-rate and export-rate caps are currently flat defaults. Some limits can be raised for your organization on request — see [Need a higher limit?](#need-a-higher-limit).
## Request rate
Every API response carries a `Retry-After` header when rate-limited. If you exceed your minute budget, you receive:
```json theme={null}
{
"error": {
"type": "rate_limited",
"code": "rate_limited",
"message": "Rate limit exceeded. Please retry after 60 seconds."
}
}
```
with HTTP status `429 Too Many Requests`. Back off and retry after the hint.
### Default request-rate limits
| Plan | Requests per minute |
| - | - |
| `free` | 120 |
| `free_beta` | 120 |
| `paid` | 120 |
| `ultra` | 120 |
Today the rate limit is a flat 120 rpm for all plans, applied per API key. We plan to move to per-plan and per-org limits in a future release; when we do, we'll pre-announce via our changelog and the `Layovelle-Version` header.
## Export rate
Exporting a canvas (`POST /v1/canvases/{id}/export`) is metered separately from — and more tightly than — the general request rate, since each export spins up a headless render. Polling an export's status (`GET /v1/canvases/{id}/export-status`) does **not** count against this cap.
Exceeding the export rate returns the same `429 rate_limited` envelope shown above, carrying `Retry-After: 60` and a message that names the export cap. Back off and retry after the hint.
### Default export-rate limits
| Plan | Exports per minute |
| - | - |
| `free` | 25 |
| `free_beta` | 25 |
| `paid` | 25 |
| `ultra` | 25 |
The default is a flat 25 exports per minute on every plan. Unlike the general request rate, the export cap is **configurable per organization** — if a batch-export workload legitimately needs more, support can raise your org's export limit without a plan change (see [Need a higher limit?](#need-a-higher-limit)).
## Concurrent design tasks
Every organization has a cap on how many design tasks can be **queued or running** at the same time. The cap applies across:
* `POST /v1/tasks` (REST API) — counts against your org's task cap.
* `POST /v1/remix` with a `prompt` (REST API) — counts against your org's task cap.
* Design-dispatch tools on the **Layovelle MCP server** (`start_design_task`, `remix_design`) — also counts against your org's task cap.
* Webhook-triggered tasks — count against your org's task cap.
The cap does **not** apply to:
* Interactive design sessions in the Layovelle web app (WebSocket).
* Slack integration traffic.
* Internal / background jobs we run on your behalf.
When your org is at its cap, new requests return `429 Too Many Requests` with `Retry-After: 30`:
```json theme={null}
{
"error": {
"type": "rate_limited",
"code": "rate_limited",
"message": "Rate limit exceeded: 10/10 concurrent tasks active. Please wait for existing tasks to complete before submitting new ones."
}
}
```
Polling your existing tasks via `GET /v1/tasks/{id}` is unaffected by the cap — only *starting* new tasks counts toward it.
### Default concurrent-task limits
| Plan | Max concurrent tasks |
| - | - |
| `free` | 3 |
| `free_beta` | 3 |
| `paid` | 10 |
| `ultra` | 15 |
These defaults are tuned for the typical integration. If you consistently hit your cap and your workflow legitimately requires more parallelism, **[contact support](mailto:admin@layovelle.com)** — we can raise your org's limit without a plan change.
## Recommended client behavior
* **Use the `Retry-After` header.** It's populated on both rate-limit and concurrency-limit responses.
* **Back off with jitter**, not a tight loop. A simple `time.sleep(retry_after + random())` is enough.
* **Pipeline, don't parallel-spray.** If you have a batch of 50 designs to generate, submit them in waves of N (where N = your cap) and wait for each wave to finish before the next. See [task polling](/api-reference/tasks/get-task-status).
* **Share one API key across your integration**, not one per deployment. Concurrency is per-org, not per-key, so using multiple keys does not raise your effective cap.
* **Branch on `error.type`, not on HTTP status.** The request-rate limit, the export-rate cap, and the concurrency cap all return 429 with both `type` and `code` set to `rate_limited` — distinguish them by which endpoint returned the 429 and by the `Retry-After` value and `message`.
## Need a higher limit?
Email **[admin@layovelle.com](mailto:admin@layovelle.com)** with:
* Your organization name or slug.
* Which limit you're hitting (request rate, export rate, or concurrent tasks).
* The peak concurrency or rpm your integration needs.
* A sentence on the use case.
We grant case-by-case per-org overrides for legitimate workloads — you don't need to move plan tiers to get a temporary or permanent bump.
# Get Api Usage
Source: /docs/api-reference/usage/get-api-usage
/openapi/moda-public-api.yaml get /usage
Summary + daily / per-key / per-operation aggregates for the caller's team.
Role-scoped: admins/owners see every row in the team; members see only
rows attributable to their own API keys plus their own MCP-origin rows.
Max range 90 days; defaults to the last 7 days when unspecified.
# List Api Events
Source: /docs/api-reference/usage/list-api-events
/openapi/moda-public-api.yaml get /events
Cursor-paginated activity log for the caller's team.
Role-scoped identically to ``GET /v1/usage``: admins/owners see every
row in the team, members see only rows attributable to their own API
keys plus their own MCP-origin rows. Sort is ``(timestamp DESC,
id DESC)``; iterate ``next_cursor`` until ``null`` to drain.
# Versioning
Source: /docs/api-reference/versioning
How the Layovelle REST API is versioned, how to pin a version with the Layovelle-Version header, and the sunset schedule for supported versions.
The Layovelle REST API uses a calendar-dated version string carried on the `Layovelle-Version` request header. Pinning a version guarantees the wire shape you built against stays stable even when we ship a newer version.
## Supported versions
| Version | Role | Status | Sunsets |
| - | - | - | - |
| `2026-05-01` | **Canonical** (newest) | Latest response shape | — |
| `2026-04-12` | **Default** (legacy) | Current default for unpinned traffic | TBD (date to be re-announced) |
* **Canonical** means routes emit this shape natively. Pin this to get the newest response fields.
* **Default** means unpinned requests (no `Layovelle-Version` header) resolve to this version. The default advances to the next-newest supported version once the current default sunsets.
* **Sunset** means the version stops being supported on the listed date. After that date, pinning the sunset version returns `400 unsupported_version`, and the default advances to the next-newest.
## The `Layovelle-Version` header
### Sending the header
Pin explicitly on every request:
```bash theme={null}
curl https://api.moda.app/v1/tasks \
-H "Authorization: Bearer moda_live_abc123..." \
-H "Layovelle-Version: 2026-05-01"
```
### Omitting the header
If you omit `Layovelle-Version`, the server resolves your request to the current **default** (`2026-04-12`). Unpinned integrations keep working across version bumps until a sunset advances the default. **For production, pin explicitly** so future default advancement doesn't silently change your response shapes.
### Unknown versions
Any value other than a currently-supported version returns `400 unsupported_version`:
```json theme={null}
{
"error": {
"type": "invalid_request",
"code": "unsupported_version",
"message": "Layovelle-Version '2026-01-01' is not supported. Supported versions: 2026-04-12, 2026-05-01.",
"details": {
"requested": "2026-01-01",
"supported": ["2026-04-12", "2026-05-01"]
},
"request_id": "019d8996-16b3-73ee-841a-5bc5038eb972"
}
}
```
The response's `Layovelle-Version` header still carries the current default, so clients can detect the drift and upgrade.
### Response header
Every response — success or error — carries the resolved `Layovelle-Version` back:
```
HTTP/1.1 200 OK
Layovelle-Version: 2026-04-12
Content-Type: application/json
...
```
If you omit the header and want to record which version the server resolved you to, read it off the response.
## What changes between versions
The `2026-05-01` canonical shape is a cleaner envelope for all task-shaped operations (`POST /v1/tasks`, `GET /v1/tasks/{id}`, `GET /v1/tasks`). The `2026-04-12` legacy shape is the original flat `JobResponse` form.
### Task endpoints
**`2026-04-12` (legacy flat shape)**
```json theme={null}
{
"job_id": "task_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"status": "completed",
"canvas_id": "cvs_77DX73QNEE9QPTBRB654CN1SBV",
"canvas_url": "https://app.moda.com/canvas/...",
"conversation_id": null,
"message": "Job completed successfully.",
"task": "Create a sales deck",
"progress_percent": 100,
"current_step": null,
"error": null,
"created_at": "2026-04-15T12:00:00",
"started_at": "2026-04-15T12:00:02",
"completed_at": "2026-04-15T12:01:00",
"is_terminal": true,
"can_export": true,
"retry_after_seconds": null,
"credits": { "credits_used": 5, "credits_remaining": 12 }
}
```
**`2026-05-01` (canonical Task envelope)**
```json theme={null}
{
"id": "task_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"kind": "design",
"status": "succeeded",
"created_at": "2026-04-15T12:00:00",
"started_at": "2026-04-15T12:00:02",
"completed_at": "2026-04-15T12:01:00",
"progress": null,
"attempt": 1,
"max_attempts": 3,
"attempts_started": 1,
"first_started_at": "2026-04-15T12:00:02",
"input": { "prompt": "Create a sales deck" },
"result": {
"canvas_id": "cvs_77DX73QNEE9QPTBRB654CN1SBV",
"canvas_url": "https://app.moda.com/canvas/..."
},
"error": null,
"credits": { "credits_used": 5, "credits_remaining": 12 },
"links": {
"self": "/v1/tasks/task_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"events": null,
"cancel": null,
"canvas": "https://app.moda.com/canvas/..."
},
"retry_after_ms": null
}
```
### Quick migration map
| Old (`2026-04-12`) | New (`2026-05-01`) |
| - | - |
| `response.job_id` | `response.id` |
| `response.status == "completed"` | `response.status == "succeeded"` |
| `response.canvas_id` | `response.result.canvas_id` |
| `response.canvas_url` | `response.result.canvas_url` |
| `response.conversation_id` | `response.result.conversation_id` |
| `response.task` | `response.input.prompt` |
| `response.progress_percent` | `response.progress.percent` |
| `response.current_step` | `response.progress.step` |
| `response.error` (string) | `response.error.message` (object) |
| `response.is_terminal` | derived: `status in ("succeeded","failed","canceled","expired")` |
| `response.can_export` | derived: `status == "succeeded" && result.canvas_id` |
| `response.retry_after_seconds` | `response.retry_after_ms / 1000` |
Per-operation examples are on each endpoint's reference page.
### List endpoints: offset → cursor pagination
Every list endpoint moved from offset pagination to opaque cursor pagination. Iterate until `next_cursor` is `null`:
**Before (`2026-04-12`):**
```json theme={null}
{
"canvases": [ ... ],
"total": 123,
"offset": 20,
"limit": 20,
"has_more": true
}
```
**After (`2026-05-01`):**
```json theme={null}
{
"data": [ ... ],
"next_cursor": "eyJ2IjoxLCJzIjoiMjAyNi0wNC0xNVQxMjow..."
}
```
**Migration pattern:**
```python theme={null}
cursor = None
while True:
resp = client.get("/v1/canvases", headers={"Layovelle-Version": "2026-05-01"}, params={"cursor": cursor}).json()
for canvas in resp["data"]:
process(canvas)
cursor = resp["next_cursor"]
if cursor is None:
break
```
| Old (`2026-04-12`) | New (`2026-05-01`) |
| - | - |
| `response.canvases` / `brand_kits` / `organizations` / `jobs` | `response.data` |
| `response.total` | no longer computed — iterate until `next_cursor` null |
| `response.offset` + `response.limit` | opaque `cursor` query param (server-managed position) |
| `response.has_more` | `response.next_cursor !== null` |
| `?offset=20&limit=20` | `?cursor=<opaque>&limit=20` |
Sort order changed from `updated_at DESC` (canvases) / `name ASC` (orgs) to `(created_at DESC, id DESC)` across every list endpoint — immutable columns only, which avoids row skip / duplicate bugs when rows mutate mid-scan.
`GET /v1/brand-kits` also dropped the `team_id` field from the canonical response — it's implicit in the API key context.
### Error responses
Every endpoint now explicitly declares `ErrorEnvelope` responses for `401 / 403 / 404 / 409 / 422 / 429 / 500` in the OpenAPI spec. The wire shape is unchanged — this is a spec-hardening change for SDK generators to emit typed error classes. No migration action required for direct HTTP callers.
### Webhook event taxonomy
Webhook `event.type` values moved from the legacy `job.*` prefix to `task.*` / `export.*`. Legacy spelling `task.cancelled` (two Ls) is retired — canonical uses `task.canceled` (matching the `PublicTaskStatus.CANCELED` enum value). Non-terminal states (`queued`, `running`, `expired`) no longer fire webhooks — the v1 taxonomy is terminal-only.
| Legacy event | Canonical event |
| - | - |
| `job.completed` | `task.succeeded` |
| `job.failed` | `task.failed` |
| `job.cancelled` | `task.canceled` |
| `job.dead_letter` | `task.failed` (plus `data.error.retryable: false`) |
| `job.running` | removed (poll `/v1/tasks/{id}` for progress) |
| `job.queued` | removed |
See [Webhooks](/api-reference/webhooks) for the full event envelope.
## What's covered by a version bump
New versions only ship for **breaking** response-shape changes. Additive changes — new endpoints, new optional request parameters, new response fields, new error codes within an existing error type — land at the current canonical version without a bump. Your client should tolerate unknown fields in responses so these additions don't surprise it.
### Breaking changes (new version ships)
* Removing or renaming a response field
* Removing an endpoint, path, or parameter
* Changing a field's type or shape
* Tightening validation (new 400s for inputs that used to succeed)
* Changing status vocabulary (e.g. `completed` → `succeeded`)
* Changing default behavior of an existing parameter
### Additive changes (no version bump)
* New endpoint, path, or optional parameter
* New response field (existing fields unchanged)
* New optional request header
* New `code` within an existing `type` on error responses
* Loosened validation (fewer 400s)
## Version bump policy
We ship additive version bumps. When a new canonical version lands:
1. The new version becomes the **canonical** (newest) — routes emit it natively.
2. The previous default stays supported for a sunset window (typically 2–3 weeks for small shape changes; longer for substantial ones).
3. On the sunset date, the older version retires (pinning it returns 400), and the default advances to whichever supported version is next-oldest.
**Practical upshot:** if you pin explicitly, you upgrade on your schedule. If you stay unpinned, you'll track the current default and will see a shape change whenever the default advances — which we announce here with a calendar date well in advance.
## Recommendations
* **Pin** `Layovelle-Version` explicitly on every request in production. Don't rely on the default — the default advances on sunset dates.
* **Log the response's `Layovelle-Version` header** so you can tell at a glance which version your traffic is actually hitting.
* **When you see `400 unsupported_version`**, read the `supported` list out of the error `details` — that's the ground truth and updates automatically.
* **Subscribe to the changelog** to hear about new versions and sunset dates as they're announced.
# Create Pronunciation Dictionary
Source: /docs/api-reference/voices/create-pronunciation-dictionary
/openapi/moda-public-api.yaml post /pronunciation-dictionaries
Create a dictionary with revision 1 (``201``).
``purpose: "client"`` is content-addressed by ``client_ref``: the same rules again answer
``200`` with ``revision.created: false``; different rules are ``409 conflict``.
# Create Pronunciation Dictionary Revision
Source: /docs/api-reference/voices/create-pronunciation-dictionary-revision
/openapi/moda-public-api.yaml post /pronunciation-dictionaries/{dictionary_id}/revisions
Publish the complete rule list as ``latest + 1`` (``201``).
Identical rules to the latest revision answer ``200`` with ``revision.created: false``;
a stale ``base_revision`` is ``409 dictionary_revision_conflict``.
# Get Pronunciation Dictionary
Source: /docs/api-reference/voices/get-pronunciation-dictionary
/openapi/moda-public-api.yaml get /pronunciation-dictionaries/{dictionary_id}
One revision (default the latest) with its rules. Revision ``n`` never changes.
# Get Voice
Source: /docs/api-reference/voices/get-voice
/openapi/moda-public-api.yaml get /voices/{voice_id}
One voice with its provider defaults and the caller's favorite flags.
Revoked voices answer ``200`` with ``availability.status: "revoked"`` so historical takes can
show a name. ``voice_id`` is decoded here, so a malformed id is the same ``404
voice_not_found`` as an unknown one.
# Get Voice Capabilities
Source: /docs/api-reference/voices/get-voice-capabilities
/openapi/moda-public-api.yaml get /voices/capabilities
The versioned model manifest and whether managed voice is available.
Always ``200`` for an authenticated key; ``provider_status`` is ``unavailable`` (every model
``status: unavailable``) when the provider is not configured. Never calls the provider.
# List Pronunciation Dictionaries
Source: /docs/api-reference/voices/list-pronunciation-dictionaries
/openapi/moda-public-api.yaml get /pronunciation-dictionaries
The team's dictionaries, oldest first, each with its latest revision (without rules).
# List Voice Favorites
Source: /docs/api-reference/voices/list-voice-favorites
/openapi/moda-public-api.yaml get /voice-favorites
The caller's personal favorites and the team's workspace favorites, newest first.
# List Voices
Source: /docs/api-reference/voices/list-voices
/openapi/moda-public-api.yaml get /voices
Search the managed voice catalog (ElevenLabs premade voices).
Every filter is enforced server-side and echoed in ``filters_applied``. The opaque
``next_cursor`` is bound to these filters; changing them with a cursor is ``400
invalid_cursor``.
# Put Voice Favorite
Source: /docs/api-reference/voices/put-voice-favorite
/openapi/moda-public-api.yaml put /voice-favorites
Add or remove a favorite; ``is_default`` sets the workspace default voice.
Any team member may write workspace favorites. Removing a favorite that does not exist is
``200``; favoriting a revoked voice is ``409 voice_unavailable``.
# Voyager Auth Config
Source: /docs/api-reference/voyager/voyager-auth-config
/openapi/moda-public-api.yaml get /voyager/auth/config
OAuth parameters for the desktop sign-in; the desktop never hardcodes the client id.
# Voyager Auth Register
Source: /docs/api-reference/voyager/voyager-auth-register
/openapi/moda-public-api.yaml post /voyager/auth/register
Register this install for the signed-in user and one workspace; returns the device secret once.
Several eligible workspaces and no ``team_id``: ``200 {needs_team, teams}`` and nothing
is written; the client asks and posts again. Any plan may register (no payment gate).
# Voyager Auth Revoke
Source: /docs/api-reference/voyager/voyager-auth-revoke
/openapi/moda-public-api.yaml post /voyager/auth/revoke
Sign this install out: revoke its registration, then (best effort) the Clerk authorization.
Idempotent for the device that is signing out: an already-revoked registration still
answers 200, so an offline sign-out's pending retry completes. Only the Clerk step is
best effort; a failure there is logged and reported, never an error.
# Voyager List Devices
Source: /docs/api-reference/voyager/voyager-list-devices
/openapi/moda-public-api.yaml get /voyager/devices
The user's live Voyager registrations, plus their legacy ``Voyager on <host>`` keys
(``kind: legacy_key``). Accepts a Voyager OAuth principal, an API key or a web session.
# Voyager Me
Source: /docs/api-reference/voyager/voyager-me
/openapi/moda-public-api.yaml get /voyager/me
Who this credential is: identity, the device (or the legacy key standing in for one),
the user's workspaces and the credential kind.
# Voyager Redeem Promo
Source: /docs/api-reference/voyager/voyager-redeem-promo
/openapi/moda-public-api.yaml post /voyager/promo/redeem
Redeem a promo or referral code for the organization of this credential's workspace.
The same redemption and eligibility as entering the code in the Layovelle web app, for a
Voyager sign-in or a legacy Voyager API key: the billing promo path first, then the
referral path when the code is not a promo code. At most 10 attempts per user per
10 minutes.
# Voyager Revoke Device
Source: /docs/api-reference/voyager/voyager-revoke-device
/openapi/moda-public-api.yaml delete /voyager/devices/{device_ref}
Revoke one registration (a ``voyager_devices`` id) or one legacy key (an ``ak_`` id).
A web session may revoke any of the user's own; an OAuth device or a legacy key only
itself (``403 web_session_required`` otherwise). Studio-side only: Clerk tokens are
revoked by ``POST /v1/voyager/auth/revoke`` from the device, which holds them.
# Web Read
Source: /docs/api-reference/web/web-read
/openapi/moda-public-api.yaml post /web/read
Read one web page or document as clean markdown (metered).
Web pages return their main content plus the page title and links. URLs
serving PDF, DOCX, or PPTX are detected automatically and extracted as
document markdown (``links`` is empty for documents).
# Web Search
Source: /docs/api-reference/web/web-search
/openapi/moda-public-api.yaml post /web/search
Search the web (metered). Returns ranked results with query-relevant snippets.
# Webhooks
Source: /docs/api-reference/webhooks
Receive HTTP notifications when AI design tasks complete.
When you start a design task via `POST /tasks`, you can provide a `callback_url` to receive an HTTP notification when the task finishes. This lets you avoid polling `GET /tasks/{task_id}` and instead react to completion events asynchronously. `POST /remix` does not accept `callback_url`: a prompted remix returns a task you can poll with `GET /tasks/{task_id}`, while a promptless remix completes synchronously and returns a synthetic, non-queryable task ID.
## Setting up a webhook
Include a `callback_url` when starting a task:
```bash theme={null}
curl -X POST https://api.moda.app/v1/tasks \
-H "Authorization: Bearer moda_live_abc123..." \
-H "Layovelle-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Create a modern SaaS landing page",
"callback_url": "https://your-server.com/webhooks/moda"
}'
```
When the task reaches a terminal state (`succeeded`, `failed`, or `canceled`), Layovelle sends a `POST` request to your `callback_url`.
## Event types
Closed set. Layovelle only fires webhooks for the event types below. Anything else your integration sees is a bug and should be reported.
| Event | When it fires |
| - | - |
| `task.succeeded` | A design task finished successfully |
| `task.failed` | A design task failed (transient or dead-lettered — see `data.error.retryable`) |
| `task.canceled` | A design task was canceled |
Standalone canvas exports do **not** emit webhooks today. If `POST /canvases/{canvas_id}/export` returns `status="in_progress"`, poll `GET /canvases/{canvas_id}/export-status?task_id=...` for completion.
Non-terminal states (`queued`, `running`, `expired`) do **not** fire webhooks today. If you need running/progress beats, poll `GET /tasks/{task_id}` or open an issue — we'll expand the enum on concrete customer need.
## Webhook payload
The payload is a JSON event envelope wrapping the canonical `Task` object under a `data` key. Same shape for every event type.
```json theme={null}
{
"id": "evt_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"type": "task.succeeded",
"created": "2026-04-15T12:01:00+00:00",
"api_version": "2026-05-01",
"data": {
"id": "task_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"kind": "design",
"status": "succeeded",
"created_at": "2026-04-15T12:00:00+00:00",
"started_at": "2026-04-15T12:00:02+00:00",
"completed_at": "2026-04-15T12:01:00+00:00",
"progress": null,
"attempt": 1,
"max_attempts": 3,
"attempts_started": 1,
"first_started_at": "2026-04-15T12:00:02+00:00",
"input": { "prompt": "Create a modern SaaS landing page" },
"result": {
"canvas_id": "cvs_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"canvas_url": "https://app.moda.com/canvas/..."
},
"error": null,
"credits": { "credits_used": 5, "credits_remaining": 12 },
"links": {
"self": "/v1/tasks/task_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"events": null,
"cancel": null,
"canvas": "https://app.moda.com/canvas/..."
},
"retry_after_ms": null
}
}
```
### Envelope fields
| Field | Type | Description |
| - | - | - |
| `id` | `string` | Unique event ID (prefixed `evt_…`). Stable across retries of this event |
| `type` | `string` | One of the event types listed above |
| `created` | `string` | ISO 8601 timestamp when the event was generated |
| `api_version` | `string` | Canonical `Layovelle-Version` of the `data` payload (currently `2026-05-01`) |
| `data` | `object` | Canonical `Task` object. See [Get Task](/api-reference/tasks/get-task-status) for details |
For `task.failed` events, inspect `data.error.retryable` to distinguish transient failures (worth retrying your own downstream work) from dead-lettered ones.
## Verifying webhook signatures
Every webhook request includes two headers for signature verification:
| Header | Description |
| - | - |
| `X-Webhook-Signature` | HMAC-SHA256 signature in the format `v1=<hex>` |
| `X-Webhook-Timestamp` | Unix timestamp (seconds) when the webhook was sent |
Each API key has its own unique webhook signing secret, shown once when the key is created. You can find it in the credentials banner alongside your API key in **Settings > Developer > REST API**. Store it securely -- it cannot be retrieved later.
The signature is computed over the string `{timestamp}.{request_body}` using HMAC-SHA256. To verify:
1. Extract the timestamp from the `X-Webhook-Timestamp` header
2. Concatenate: `{timestamp}.{raw_request_body}`
3. Compute `HMAC-SHA256(signing_secret, concatenated_string)`
4. Compare the result with the signature value after the `v1=` prefix
### Verification example
<CodeGroup>
```javascript Node.js theme={null}
import crypto from 'node:crypto';
function verifyWebhook(signingSecret, requestBody, signatureHeader, timestampHeader) {
const message = `${timestampHeader}.${requestBody}`;
const expected = crypto.createHmac('sha256', signingSecret).update(message).digest();
const received = Buffer.from(signatureHeader.replace('v1=', ''), 'hex');
// timingSafeEqual throws when buffer lengths differ; check first so a
// malformed signature can't crash the handler.
if (expected.length !== received.length) {
return false;
}
return crypto.timingSafeEqual(expected, received);
}
```
```python Python theme={null}
import hashlib
import hmac
def verify_webhook(
signing_secret: str,
request_body: bytes,
signature_header: str,
timestamp_header: str,
) -> bool:
message = f"{timestamp_header}.{request_body.decode()}"
expected = hmac.new(
signing_secret.encode(), message.encode(), hashlib.sha256
).hexdigest()
received = signature_header.removeprefix("v1=")
return hmac.compare_digest(expected, received)
```
</CodeGroup>
## Retry behavior
Layovelle makes up to **3 delivery attempts** total (the initial delivery plus 2 retries). If your endpoint returns a non-2xx status code or does not respond within 30 seconds, Layovelle retries with a short backoff:
| Attempt | When |
| - | - |
| 1 (initial) | immediately when the task reaches a terminal state |
| 2 (retry) | \~1 second after attempt 1 fails |
| 3 (final retry) | \~5 seconds after attempt 2 fails |
A persistent rejection (e.g., your endpoint returning `401` because signature verification is failing) exhausts all three attempts within a few seconds and the webhook is then dropped. You can still retrieve the task status via `GET /tasks/{task_id}` — the task itself completes independently of callback delivery — or replay the dropped delivery from the **delivery log** (see below) once your endpoint is fixed.
## Best practices
* **Return 200 quickly.** Process the webhook payload asynchronously to avoid timeouts. Acknowledge receipt first, then handle the event.
* **Verify signatures.** Always validate the `X-Webhook-Signature` header before trusting the payload.
* **Check timestamps.** Reject webhooks with timestamps more than 5 minutes old to prevent replay attacks.
* **Handle duplicates.** In rare cases, the same event may be delivered more than once. Use the envelope `id` (`evt_…`) as an idempotency key — it's stable across retries of the same event.
* **Use HTTPS.** Your `callback_url` must use HTTPS to protect the payload in transit.
## Delivery log & manual replay
Every webhook attempt is recorded in a **delivery log** you can query and replay. Useful when your endpoint was down, a signature-verification bug slipped through, or you just want to confirm "did this fire?"
* **List deliveries**: `GET /webhook_deliveries` ([reference](/api-reference/webhooks/list-deliveries)) — cursor-paginated, filterable by event type, status, API key, and date range. Response rows include the target URL, HTTP status code, response body excerpt (first 2KB), and attempt count.
* **Replay a delivery**: `POST /webhook_deliveries/{id}/redeliver` ([reference](/api-reference/webhooks/redeliver-webhook)) — queues a fresh attempt with the original payload to the original URL. Returns 202 with the new delivery's ID so you can follow it.
**Retention**: deliveries are kept for 90 days and then hard-deleted.
**Permission**: replay is restricted to the user who created the API key that dispatched the original delivery, or any team admin/owner. If the original API key was deleted, only admins can replay.
The log is also surfaced in the **Settings → API → View API usage** drawer with a Webhooks tab if you prefer a UI over the API.
# List Deliveries
Source: /docs/api-reference/webhooks/list-deliveries
/openapi/moda-public-api.yaml get /webhook_deliveries
List webhook deliveries for the caller's team, newest first.
Role-scoped: admin / owner sees every delivery in the team; member
sees only deliveries triggered by keys they created (rows whose
``api_key_id`` was set to NULL after the key was deleted stay
admin-only).
# Redeliver Webhook
Source: /docs/api-reference/webhooks/redeliver-webhook
/openapi/moda-public-api.yaml post /webhook_deliveries/{delivery_id}/redeliver
Queue a manual replay of a previous delivery.
Creates a new ``pending`` row linked to the original via
``replayed_from_id`` and returns its id. The actual HTTP send runs
as a background task so the caller gets an immediate 202.
Permission: the caller must be the user who created the API key
that dispatched the original delivery, OR an admin / owner of the
team. If the original key was deleted, only admins can replay.
# Add Website Page
Source: /docs/api-reference/websites/add-website-page
/openapi/moda-public-api.yaml post /websites/{website_id}/pages
Add a page at ``path`` with the given HTML.
An existing page at the path is 409 ``website_page_exists`` (use PUT
.../pages/content to rewrite it). The new page is part of the DRAFT —
it goes live on the next POST .../publish.
# Create Website
Source: /docs/api-reference/websites/create-website
/openapi/moda-public-api.yaml post /websites
Create a static site from HTML. The site is a draft until POST .../publish runs.
# Delete Website
Source: /docs/api-reference/websites/delete-website
/openapi/moda-public-api.yaml delete /websites/{website_id}
Delete the site. Any live publish is taken down as part of the delete.
# Delete Website Page
Source: /docs/api-reference/websites/delete-website-page
/openapi/moda-public-api.yaml delete /websites/{website_id}/pages
Delete the page at ``path`` (and its entry file) from the draft.
The homepage (``/``) cannot be deleted — 422 ``website_home_page_protected``.
A missing page is 404 ``website_page_not_found``, so a repeated delete
converges instead of silently succeeding twice. A live site keeps serving
its published artifact until the next publish.
# Get Website
Source: /docs/api-reference/websites/get-website
/openapi/moda-public-api.yaml get /websites/{website_id}
One site's metadata, publish state, and live URL.
# List Website Pages
Source: /docs/api-reference/websites/list-website-pages
/openapi/moda-public-api.yaml get /websites/{website_id}/pages
The site's pages in display order, plus the site's current version.
``version`` is the same optimistic-concurrency token every other response
on this surface carries — pin it as ``expected_version`` on a follow-up
page mutation.
# List Websites
Source: /docs/api-reference/websites/list-websites
/openapi/moda-public-api.yaml get /websites
List the team's sites the key's owner can view, newest-updated first.
Offset-paginated: at most ``limit`` sites per page (default 25, cap 100).
``total`` is the true visible-site count (not the page length); keep
fetching with ``offset += limit`` while ``has_more`` is ``true``.
# Publish Website
Source: /docs/api-reference/websites/publish-website
/openapi/moda-public-api.yaml post /websites/{website_id}/publish
Publish (or republish) the site to its public URL.
``review_status: "pending_review"`` means the publish succeeded but the
site is held for review before it serves.
# Screenshot Website
Source: /docs/api-reference/websites/screenshot-website
/openapi/moda-public-api.yaml post /websites/{website_id}/screenshot
Render up to 3 of the site's pages to images; returns short-lived signed URLs.
Renders the DRAFT (current saved content) — identical to the live site
whenever ``has_unpublished_changes`` is false. Uses the shared website
render core, so it is the same JS-on, full-page capture the in-app export
produces. Binary delivery matches the export pattern: signed URLs, never
inlined bytes.
Requires edit permission on the site (renders are an editor affordance and
consume scarce browser capacity — the same gate as the in-app export).
# Unpublish Website
Source: /docs/api-reference/websites/unpublish-website
/openapi/moda-public-api.yaml post /websites/{website_id}/unpublish
Take the live site down. The draft content is preserved for republishing.
# Update Website Content
Source: /docs/api-reference/websites/update-website-content
/openapi/moda-public-api.yaml put /websites/{website_id}/content
Replace the site's page content with new HTML.
Content changes do NOT auto-republish: a live site keeps serving its last
published artifact until POST .../publish runs again (the returned
``has_unpublished_changes`` signals the gap).
# Update Website Page Content
Source: /docs/api-reference/websites/update-website-page-content
/openapi/moda-public-api.yaml put /websites/{website_id}/pages/content
Replace ONE page's HTML, addressed by its route path.
``path: "/"`` targets the homepage — the same content ``PUT .../content``
writes. A missing page is 404 ``website_page_not_found`` (use POST
.../pages to add it). Content changes do NOT auto-republish (same contract
as the homepage flow).
# How the AI Agent Works
Source: /docs/help/ai-agent/how-it-works
Understand how Layovelle's AI agent designs for you — its capabilities, process, and limitations.
When you create a design in Layovelle, an AI agent handles the entire design process. Here's how it works.
## The design process
The agent follows a multi-stage process:
<Steps>
<Step title="Research" icon="magnifying-glass">
If needed, the agent searches the web, analyzes uploaded files (PPTX, PDF, DOCX, XLSX/CSV, images), reads your brand kit, or scrapes a website URL for content and style reference.
</Step>
<Step title="Planning" icon="list-check">
The agent outlines the structure of the design — what slides or sections to create, what content goes where.
</Step>
<Step title="Building" icon="hammer">
The agent creates elements on the canvas: text, shapes, images, layouts, charts, and more.
</Step>
<Step title="Refinement" icon="wand-magic-sparkles">
The agent reviews and adjusts the design, fixing alignment, styling, and visual consistency.
</Step>
</Steps>
You can watch this happen in real time. The agent's thinking and progress are shown in the chat panel alongside the canvas.
## What the agent can do
The agent has access to a wide range of tools:
* **Create and edit design elements**: Text, shapes, images, icons, charts, tables, and more
* **Generate images**: Create AI-generated images and backgrounds using FAL AI
* **Search the web**: Research topics, find reference material, and gather content
* **Scrape websites**: Extract content and visual style from URLs
* **Read uploaded files**: Process PPTX, PDF, DOCX, XLSX/CSV, text files, and Google Slides
* **Apply brand kits**: Use your brand colors, fonts, logo, and visual style
* **Search for icons**: Find and insert icons that match your design
* **Generate QR codes**: Create QR codes for URLs
* **Fetch company logos**: Look up and insert logos for named companies
## Execution modes
Layovelle's agent can run in two modes:
* **Interactive (WebSocket)**: The default mode when you're working in the browser. The agent runs tools in your browser session, so you see changes in real time.
* **Headless (Job system)**: Used for API-triggered designs and background tasks. The agent runs on the server with a headless browser for operations like export.
## Iterating on designs
After the initial generation, you can continue chatting with the agent to refine the design:
* Ask it to change colors, fonts, or layout
* Request additional slides or sections
* Ask it to swap images or update text
* Give it feedback on what to improve
The agent maintains context from the conversation, so it understands what you've already discussed.
# Prompting Guide
Source: /docs/help/ai-agent/prompting
Write effective prompts to get better designs from Layovelle's AI agent.
The quality of your prompt directly affects the quality of your design. Here are tips and examples for getting the best results.
## General tips
* **Be specific about content**: Don't just say "make a pitch deck." Describe what the deck is about, who the audience is, and what key points to cover.
* **Mention structure**: If you want a specific number of slides or sections, say so.
* **Describe the tone**: Words like "professional," "playful," "minimal," "bold," or "corporate" help the agent make style decisions.
* **Include real data**: If you have specific metrics, quotes, or text, include them in your prompt rather than expecting the agent to make them up.
* **Reference your brand kit**: If you have a brand kit selected, the agent will use it automatically. You can also call out specific brand elements in your prompt.
## How to get less "AI-looking" designs
* Provide **specific content** rather than generic placeholders
* Reference a **website or existing design** as style inspiration
* Ask for **minimal, clean layouts** rather than overly decorated ones
* Upload **reference images** showing the style you want
* Use the **Pro model** for more sophisticated design decisions (Pro and Ultra plans)
## Using reference images and files
You can attach files to your prompt to give the agent more context:
* **Images**: Style references, mood boards, screenshots of designs you like
* **PPTX files**: Existing presentations to redesign or update
* **PDFs**: Documents to convert into designed layouts
* **DOCX/XLSX/CSV**: Content to incorporate into designs
* **URLs**: Websites to scrape for content or style reference
## Prompts for specific use cases
### Pitch decks
```
Create a 5-slide investor pitch deck for [company name].
Cover: problem, solution, market size ($X TAM), traction (Y users, Z% MoM growth),
team backgrounds, and a $Xm Series A ask.
Tone: confident, data-forward, clean modern layouts.
```
### Social media posts
```
Create an Instagram carousel (5 slides) announcing our new product launch.
Product: [description]. Key features: [list].
Style: bold typography, vibrant colors, clear call to action on the last slide.
Format: square post (1080x1080).
```
### Marketing one-pagers
```
Design a one-page sales sheet for [product/service].
Include: problem/solution, 3 key benefits with icons, a customer quote,
and contact information. Professional and information-dense.
```
### Diagrams and workflows
```
Create a diagram showing our customer onboarding flow:
Sign up → Email verification → Profile setup → First project → Success.
Use clean icons and connecting arrows. Keep it simple and easy to follow.
```
## Iterating on designs
After the initial generation, you can refine with follow-up prompts:
* "Make the headings larger and bolder"
* "Change the background color to our brand blue"
* "Add a slide about our team between slides 3 and 4"
* "Replace the stock image on slide 2 with something more professional"
* "Make this look less corporate and more startup-friendly"
The agent remembers the full conversation context, so you can build on previous instructions.
# AI Agent Troubleshooting
Source: /docs/help/ai-agent/troubleshooting
Common issues with the AI agent and how to resolve them.
## Design stuck on "Building — please wait"
If your design has been generating for a long time:
* **Wait up to 5–7 minutes** for complex multi-page designs — this is normal, especially for slide decks with research or image generation
* **Check the chat panel**: the agent's progress and thinking are shown there. If it's still actively working, give it more time
* **Refresh the page**: your work is auto-saved, so refreshing won't lose progress. The agent may resume or you can start a new generation
* **Try a simpler prompt**: very complex or vague prompts can cause the agent to take longer. Be more specific about what you want
## Agent seems stuck in a loop or not responding
* **Refresh the page** and check if the design was partially completed
* **Start a new chat**: sometimes starting fresh with a clearer prompt works better than continuing a stuck conversation
* **Check your internet connection**: the agent communicates via WebSocket, so an unstable connection can cause issues
## Output doesn't match my prompt
* **Be more specific**: vague prompts give the agent more room for interpretation. Include specific content, structure, and style details
* **Use reference images or URLs**: visual references help the agent understand what you're looking for
* **Iterate**: use follow-up messages to guide the agent toward what you want. "Make the layout more like \[description]" or "Change the color scheme to \[specifics]"
* **Try a different model**: the Pro model (available on Pro and Ultra plans) handles complex design requests better than Lite or Standard
## "Connection to server lost" errors
This typically means the WebSocket connection between your browser and the server was interrupted:
* **Check your internet connection**
* **Refresh the page**: the connection will be re-established
* **Try a different network**: some corporate firewalls or VPNs may interfere with WebSocket connections
* Your work is auto-saved, so you won't lose progress when reconnecting
# Animation and video
Source: /docs/help/animation/index
Animate designs on the Layovelle canvas with keyframes, then export MP4 or GIF.
Layovelle animates the designs you build on the canvas. Anything you can place — text, shapes, images, charts — can move, fade, scale, or rotate over time, and you can export the result as video.
The Main Edit is a real timeline: cut, trim, and sequence your Layovelle pages — and your own uploaded video — into one film, then export it as MP4.
## Start an animation
Choose **Animation** as the format in the homepage prompt bar, then pick the output size. You choose the size up front because an animation has one output size for the whole piece:
| Preset | Size | Typical use |
| - | - | - |
| **Landscape** | 1920 × 1080 | YouTube, presentations, embeds |
| **Portrait** | 1080 × 1920 | Reels, TikTok, Stories |
| **Square** | 1080 × 1080 | Instagram and LinkedIn feeds |
| **4K Landscape** | 3840 × 2160 | High-resolution delivery |
| **4K Portrait** | 2160 × 3840 | High-resolution vertical |
Landscape is the default. You can change the size later from the **Animation size** control in the toolbar.
You can also ask the agent for one directly — *"animate this logo reveal for Instagram Stories"* — and it will pick the format and build the motion.
## Keyframes
A keyframe records a property's value at a moment in time. Set two, and Layovelle animates between them.
<Warning>
**Keyframes require an animation canvas.** The diamond gutter and the timeline dock only appear when the canvas category is Animation. On a slides deck you get slide transitions instead; on a social post or other canvas you get the preset-based Animate panel. Neither offers per-property keyframes.
</Warning>
Each property in the Details panel has a **diamond** in its left gutter — the control that turns animation on for that property and adds keyframes. Changing a value on its own does *not* create a keyframe; you arm the property first.
<Steps>
<Step title="Open the timeline">
The timeline dock sits below the canvas. It shows one row per animated property.
</Step>
<Step title="Move the playhead to where the animation should start">
Drag the playhead in the timeline dock.
</Step>
<Step title="Click the property's diamond to arm it">
Select your layer, find the property in the Details panel, and click its diamond. That records your **first keyframe** at the playhead, holding the property's current value. The diamond goes from hollow light grey to keyed.
</Step>
<Step title="Move the playhead and add a second keyframe">
Move the playhead to the end of the move, then click the diamond again to add a keyframe there.
</Step>
<Step title="Set the value for that keyframe">
With the playhead on the new keyframe, edit the property. The value writes to that keyframe, and Layovelle animates between the two.
</Step>
</Steps>
<Note>
**If a value field looks locked, the playhead isn't on a keyframe.** Once a property is animated, its field is only editable when the playhead sits exactly on one of its keyframes — that's what stops an edit from silently overwriting an interpolated frame. Click the diamond to add a keyframe at the playhead, then edit.
</Note>
The diamond tells you where you are: hollow light grey means not animated, hollow dark grey means animated with the playhead between keys, and solid means the playhead is on a keyframe — clicking it then *removes* that keyframe. A property driven by an effect shows a muted diamond and is edited in the timeline instead.
In the timeline dock you can drag keyframe markers along a row to retime them.
### Easing
Easing controls how a value travels between two keyframes — whether it starts abruptly or glides in. Select a keyframe and pick from the easing menu:
* **Linear** — constant speed. Mechanical; good for continuous motion like a rotating loader.
* **Ease in / out / in & out** — the everyday choices. Natural-feeling starts and stops.
* **Cubic** and **quint** variants — the same shapes, more pronounced.
* **Back** — overshoots slightly before settling. Good for a playful entrance.
* **Bounce** — settles with a bounce.
If you're unsure, **Ease out** for things entering and **Ease in & out** for things moving between positions will cover most cases.
For a curve the presets don't cover, open the **easing curve editor** from the track in the timeline dock and drag the handles to author your own cubic curve. You can convert a named preset into a custom curve this way; once you do, the property reads **Custom** instead of a preset name.
## Multi-page animations and transitions
An animation can have several pages, played in order like scenes. Each page has its own duration and its own keyframes.
Between pages you can set a **scene transition**:
* **Dissolve** — cross-fade between scenes.
* **Push** — the outgoing scene is pushed off by the incoming one (left, right, up, or down).
* **Slide over** — the incoming scene slides across the top of the outgoing one.
* **None** — a hard cut.
Transitions apply when you play the animation through and when you export all pages as a single video.
## Export
Use **Download** in the toolbar:
| Format | What it produces | Frame rate |
| - | - | - |
| **MP4** | H.264 video | 30 by default (24 / 30 / 60) |
| **GIF** | Animated image, no sound | 12 by default (10 / 12 / 15 / 24) |
| **WebP** | Like GIF — smaller, and supports transparency | 15 by default (10 / 12 / 15 / 24) |
| **Layers (.zip)** | Transparent layers for video editors and compositors | 30 |
The frame-rate picker appears for **single-page MP4, GIF, and WebP**. Layers always renders at 30 FPS, and so does a full-video MP4 — see below.
**MP4 isn't two formats.** Whether you get one page or the whole piece is the **Pages** setting in the same dialog: **Single page**, or **All pages** (labelled **Main edit** when the edit timeline is on) to join every page with your scene transitions. A full-video export always renders at 30 FPS — the frame-rate picker doesn't apply to it.
Which to pick: **MP4** for anything going to a social platform or an embed — better quality, smaller file. **WebP** instead of GIF whenever the destination supports it; it's smaller and keeps transparency. **Layers (.zip)** when someone downstream is compositing in a real video editor.
### Length limits depend on the format
Each format has its own ceiling, and they're counted in frames rather than seconds — so your frame rate decides how much time you get.
* **MP4** encodes in your browser and **isn't capped**. A two-minute MP4 is fine. It only meets a limit if your browser can't encode it and the export falls back to Layovelle's servers.
* **GIF and WebP** encode on Layovelle's servers, capped at **2,000 frames**. That's about 166 seconds at 12 FPS, or 66 seconds at 30 FPS.
* **Layers (.zip)** has a different shape of limit entirely — see below.
* **Handing a render to Layovelle's servers** (available for single-page MP4 and GIF only, and not yet enabled for everyone) adds a **120-second** ceiling. WebP, Layers, and full-video MP4 have no such path, so it never applies to them.
If a GIF or WebP is refused, lower the frame rate or shorten the animation. On a single-page export both work — dropping GIF from 15 to 12 FPS buys you a quarter more length. On a full-video export the rate is fixed at 30 FPS, so shortening is the only option.
#### Layers is limited by element count, not just length
PNG-sequence Layers (the default) is assembled **in your browser**; choosing ProRes 4444 instead encodes on Layovelle's servers. Either way the budget is two numbers:
* **\~2,000 frames per element**, and
* **12,000 frames total** across every element on the page.
The second one bites first on a busy page: it's element count × frames, so eight animated elements at 1,600 frames each is 12,800 — over budget at about 53 seconds, even though no single element is near its own 2,000-frame cap. (Exactly 12,000 is still allowed; only more than that is refused.) So if a Layers export is refused, **reducing the number of animated elements** — group them, or remove some — helps as much as shortening the animation.
You can keep working while an export runs. A 4K export takes noticeably longer than 1080p.
<Note>
**PowerPoint and Google Slides aren't offered for an animation canvas** — its export list is video formats only. If you need motion in a deck, export MP4 here and insert that video into the deck yourself.
Separately: if you animate a *slides* canvas, PPTX and Google Slides export those slides as **static frames** — the motion doesn't carry across.
</Note>
## Tips
* **Animate fewer things than you think.** Two or three moving elements per scene reads as deliberate; everything moving at once reads as noise.
* **Keep entrances short.** 300–600 ms is enough for something to arrive. Long entrances make a piece feel sluggish on second viewing.
* **Give the viewer a beat to read.** If a scene contains text, let it hold still for a moment before the next transition.
* **Check it at final size.** Motion that looks smooth on a large canvas can feel frantic in a small feed placement.
## Troubleshooting
<AccordionGroup>
<Accordion title="My export has no motion in it">
Check that the keyframes are on the page you exported — a single-page export only includes that page. If you meant to export the whole piece, choose MP4 and set **Pages** to **All pages** (labelled **Main edit** when the edit timeline is on).
</Accordion>
<Accordion title="Playback stutters in the editor">
Editor playback shares the GPU with everything else on the canvas, so a heavy design can drop frames while scrubbing. The export is rendered separately and won't have the same stutter — export a short test to confirm before troubleshooting further. If the editor is consistently slow, see [System requirements](/help/getting-started/system-requirements).
</Accordion>
<Accordion title="The GIF looks worse than the preview">
GIF supports only 256 colors, so gradients and photographic content band visibly. This is a limitation of the format, not the export. Use MP4 wherever the destination allows video.
</Accordion>
<Accordion title="The export was refused because the animation is too long">
Which limit you hit depends on the format — see [Length limits depend on the format](#length-limits-depend-on-the-format).
**GIF or WebP:** the cap is 2,000 frames. Lower the frame rate (15 → 12 FPS buys about a quarter more length) or shorten the animation.
**Layers:** the cap is per element *and* across all elements (12,000 frames total), so a page with many animated elements can be refused even on a short animation. Reduce the number of elements as well as the length.
**MP4:** normally uncapped, because it encodes in your browser. If a long MP4 was refused, your browser couldn't encode it and the export fell back to Layovelle's servers.
On a full-video export the frame rate is fixed at 30 FPS, so shortening is the only remedy there.
</Accordion>
<Accordion title="The exported video is longer or shorter than expected">
Total length is the sum of your page durations plus transition time. Check each page's duration in the timeline — a page left at its default can quietly pad the end.
</Accordion>
</AccordionGroup>
## Next steps
<Columns>
<Card title="Export formats" icon="file-export" href="/docs/help/export/formats">
Every format Layovelle exports, and what transfers.
</Card>
<Card title="Prompting the agent" icon="wand-magic-sparkles" href="/docs/help/ai-agent/prompting">
Get better results from the AI agent.
</Card>
</Columns>
# Billing & Payments
Source: /docs/help/billing/billing
Manage your Layovelle subscription, payment methods, invoices, and team billing.
## How to upgrade to Pro or Ultra
1. Click your workspace name in the sidebar
2. Go to **Settings → Billing**
3. Select the plan you want
4. Complete checkout through Stripe
See the [Pricing page](https://layovelle.com/pricing) for current plan rates.
Your new credit allocation takes effect immediately after upgrading.
## I paid but my plan didn't upgrade
If your payment went through but your account still shows the Free plan:
* **Refresh the page**: plan changes may take a moment to propagate
* **Check your email** for a payment confirmation from Stripe
* **Go to Settings → Billing** and check your current plan status
* If the issue persists, email [admin@layovelle.com](mailto:admin@layovelle.com) with your workspace name and the email on the Stripe receipt
## Managing payment methods
To update your payment method:
1. Go to **Settings → Billing**
2. Click **Manage** to open the Stripe billing portal
3. Update your payment method from there
## Getting a receipt or invoice
Invoices and receipts are available through the Stripe billing portal:
1. Go to **Settings → Billing**
2. Click **Manage**
3. View and download past invoices
## Refund policy
Per the [Terms of Service](https://layovelle.com/terms), fees are **non-refundable except where required by law**. Layovelle may issue a refund or credit at its discretion in specific circumstances.
If you think you've been charged in error — a duplicate charge, a seat you'd already removed, an unexpected overage — email [admin@layovelle.com](mailto:admin@layovelle.com) with the invoice number and we'll look into it.
To stop future charges, cancel from **Settings → Billing → Manage**. Cancelling ends the subscription at the end of the current period; you keep access until then.
## How team billing works
On paid plans, billing is **per seat**:
* Each team member counts as one seat
* The total monthly cost is: (number of seats) × (per-seat price)
* When you add a new team member, a seat is added and billing is adjusted
* When you remove a team member, the change takes effect at the end of the billing period
* Each seat adds credits to the team's shared pool
Internal users and specifically excluded user IDs do not count toward the billable seat count.
## Can I upgrade or downgrade at any time?
Yes. You can upgrade at any time and your new credit allocation takes effect immediately. If you downgrade, the change takes effect at the end of your current billing period — you keep your current benefits until then.
# How Credits Work
Source: /docs/help/billing/credits
Understand AI credits in Layovelle — what they are, what costs credits, and how they reset.
## What are credits?
AI credits are the units that measure your usage of AI-powered features in Layovelle. Every time you use an AI feature — like generating a design, creating an image, removing a background, or asking the AI agent to edit your layout — it consumes credits.
Different actions cost different amounts of credits depending on how computationally intensive they are. More complex requests use more credits.
## How many credits do different actions cost?
Credit costs vary based on the complexity of the task and the AI model used. Here are rough estimates:
| Action | Lite model | Standard model |
| - | - | - |
| Social post | \~60 credits | \~120 credits |
| Document | \~100 credits | \~200 credits |
| Slide deck (5 slides) | \~150 credits | \~300 credits |
| Slide deck (10 slides) | \~225 credits | \~450 credits |
Lite costs roughly half of Standard; Pro and Pro (Fast) cost more per request — the model picker in the app shows each tier's approximate cost.
These are estimates — actual usage can vary widely depending on the complexity of your design task. Actions like image generation, web research, and background removal each consume additional credits.
## Credit allowances by plan
Each plan includes a monthly credit allowance. **Credits are pooled at the workspace level on every plan, including Free** — all members draw from one shared balance rather than each having a private allowance.
Each seat contributes credits to that pool, with one important exception:
<Warning>
**On the Free plan, only the first 3 seats add credits to the pool.** A free workspace can have up to 10 members, but seats beyond the third contribute 0 credits. Inviting your fourth teammate gives them access — it does not increase your balance.
Paid plans have no such cap: every seat you pay for adds its full allowance.
</Warning>
See the [Pricing page](https://layovelle.com/pricing) for current credit allowances per plan.
## Do credits reset?
Monthly credits reset at the start of each billing cycle, and **unused monthly credits do not roll over**.
Purchased credits are different — they roll over between cycles and last about a year. See [Buying extra credits](#buying-extra-credits).
## What happens when I run out?
* **Free plan**: AI features pause until your credits refresh at the start of the next billing cycle. You can still use all non-AI canvas tools, export your work, and collaborate normally.
* **Paid plans (Pro and Ultra)**: You won't be interrupted. Once you exceed your included credits, additional usage is billed at a metered rate. You can set a maximum overage budget in **Settings → Billing**, or buy credits up front instead.
## Buying extra credits
Paid workspaces can buy prepaid credit bundles at any time, as an alternative to metered overage.
| Purchase | Credits |
| - | - |
| \$10 | 2,000 |
| \$25 | 5,000 |
| \$50 | 10,000 |
| \$100 | 22,000 |
You can also enter a custom amount — **between $5 and $500, in whole dollars**. Purchases of **\$100 or more** earn credits at the best rate, applied to the whole purchase.
Two things worth knowing:
* **Purchased credits last about a year, and they roll over between months.** Unlike your monthly allowance — which resets every cycle — a purchase stays on your balance across cycles. It does eventually expire, roughly 12 months after purchase, timed to land just after a billing date rather than mid-cycle.
* **Your monthly credits are always spent first.** Layovelle draws down the allowance that expires soonest before touching credits you bought, so a purchase isn't wasted at a cycle boundary.
### Auto top-up
Rather than watching your balance, you can have Layovelle buy more automatically. In **Settings → Billing**, enable auto top-up and set:
* the **balance threshold** that triggers a purchase,
* the **amount** to buy each time, and
* a **monthly cap**, so a runaway job can't spend more than you intend.
## Bonus credits
* **Referrals** — when someone signs up through your link, you and they each get **300 bonus credits**, awarded once they make their first AI request (not at signup). Find your link via the **gift icon** in the left sidebar.
**Your referral link doesn't expire** — share it as widely and for as long as you like. What's time-limited is the person claiming it: they need to claim while the workspace they created is still new (within an hour of creating it), which is what stops long-standing users from retroactively claiming a bonus.
Two things catch people out:
* **The person you refer must create their own workspace.** Someone who joins an *existing* organization — by invitation, or because their company already has one — isn't eligible, even if they used your link.
* Each person can refer up to **50 people**.
* **Work email** — signing up with a work email address and creating a new workspace grants a one-time **500 bonus credits**.
# Promo Codes & Discounts
Source: /docs/help/billing/promos
How Layovelle promo codes work and where to enter one, referral and work-email bonus credits, and how to ask about nonprofit or education pricing.
There are two different things people mean by "a discount" in Layovelle, and they work differently:
* **Promo codes** give you **bonus credits** — you enter them yourself, in the app.
* **A discount on your subscription price** (for example nonprofit or education pricing) is set up by our team and applied when you check out.
## Promo codes
If you have a promo code, redeeming it adds **bonus credits** to your workspace's shared credit pool. To enter one:
1. Go to **Settings → Billing**
2. Find the **Promo code** field, enter your code, and select **Apply**
A few things to know:
* Each code can be redeemed **once per workspace**.
* The credits land in your workspace's shared balance, the same pool everyone on the workspace draws from. See [How Credits Work](/help/billing/credits) for how that balance is spent.
* Some codes also start a free trial of a paid plan. If yours does, the trial begins when you redeem the code.
<Note>
A promo code adds credits — it doesn't change your monthly subscription price. For discounted pricing, see [Nonprofit and education pricing](#nonprofit-and-education-pricing) below.
</Note>
## Referral and work-email bonus credits
You don't need a code for these — Layovelle grants them automatically:
* **Referrals** — when someone signs up through your link and makes their first AI request, you and they each earn bonus credits.
* **Work email** — signing up with a work email address and creating a new workspace grants a one-time bonus.
For the current amounts, eligibility, and where to find your referral link, see [Bonus credits](/help/billing/credits#bonus-credits).
## Nonprofit and education pricing
Layovelle considers discounted pricing for eligible nonprofits and schools, handled **case by case** — there isn't a self-serve code for it. To ask about it, email [admin@layovelle.com](mailto:admin@layovelle.com) and include:
* your organization and how it qualifies (the nonprofit, school, or program)
* the email on your Layovelle account
* the plan you're interested in
We'll follow up with what's available for your situation.
## Entering a discount at checkout
When our team sets up a subscription discount for your account, it's applied through Stripe checkout. Depending on how it's set up, either you'll enter a code in the **promotion code** field on the checkout page when you upgrade, or the discount will already be showing when you check out — we'll let you know which. If you expected a discount and don't see it at checkout, reply to us before completing the purchase and we'll sort it out.
# Setting Up Your Brand Kit
Source: /docs/help/brand-kit/setup
Create a brand kit in Layovelle by importing from a website, uploading guidelines, or adding assets manually.
A brand kit tells the AI agent about your brand identity — your logo, colors, fonts, and visual style. When you create a design with a brand kit selected, the agent uses these assets to produce on-brand results.
## Creating a brand kit
There are several ways to create a brand kit:
### Import from a website URL
The fastest way to set up a brand kit is to provide your website URL. Layovelle will crawl the site and extract:
* Logo
* Brand colors
* Typography
* Visual style cues
The import uses Firecrawl and Brandfetch to analyze your website. Some domains are blocked from import (email providers, major social media platforms, and similar consumer services).
### Upload brand guidelines
You can upload brand guideline files (PDF or images) for the AI to analyze and extract brand elements from.
### Add assets manually
You can also build your brand kit manually by uploading:
* **Logo**: Your primary logo file
* **Colors**: Your brand color palette
* **Fonts**: Your brand typefaces
* **Imagery**: Reference images that represent your visual style
## How many brand kits can I create?
You can create brand kits on every plan, including Free, and each design chooses which one it uses.
**There is no enforced limit on how many you can create today.** The [Pricing page](https://layovelle.com/pricing) is the reference for what each plan advertises, and is the source of truth if that changes.
## Editing your brand kit
You can update your brand kit at any time. Changes will apply to new designs going forward — existing designs are not automatically updated.
To edit a brand kit:
1. Open your workspace settings
2. Navigate to your brand kits
3. Update any assets (logo, colors, fonts, imagery)
The brand kit system uses a conversational AI agent, so you can also describe changes you want to make in natural language.
# Brand Kit Troubleshooting
Source: /docs/help/brand-kit/troubleshooting
Common issues with brand kit setup and how to resolve them.
## Logo won't upload
* Check that your file is a supported image format (JPEG, PNG, GIF, SVG)
* The maximum file upload size is 250 MB
* Try a different file format — SVG or PNG with transparency work best for logos
## Website crawl not pulling the right assets
The website import uses automated tools to extract brand elements, and it may not always get everything right:
* **Colors are wrong**: The crawler extracts colors from your site's CSS and visible elements. If your website uses different colors than your brand guidelines, the extracted palette may not match. Edit the brand kit manually to correct colors.
* **Logo not found**: Some websites serve logos in ways that are difficult to extract automatically. Upload your logo manually.
* **Fonts not detected**: Web fonts may not always be identified correctly. Add your brand fonts manually.
Some domains are blocked from crawling, including email providers and major social media platforms.
## Designs aren't using my brand colors or fonts
* **Check that a brand kit is selected**: Make sure you've chosen your brand kit in the prompt bar before creating the design
* **Be explicit in your prompt**: If the agent isn't using your brand consistently, try mentioning specific colors or styles in your prompt (e.g., "Use our primary blue for headings")
* **Review your brand kit**: Make sure the colors and fonts in your brand kit are correct and complete
* **Update the brand kit**: If you've recently changed your brand assets, make sure the brand kit reflects the latest versions
# Using Your Brand Kit
Source: /docs/help/brand-kit/using-your-brand
How Layovelle applies your brand kit to designs, and tips for consistent results.
## How Layovelle applies your brand kit
When you select a brand kit before creating a design, the AI agent has access to your brand assets and uses them throughout the design process:
* Your **logo** is placed appropriately in the design
* Your **brand colors** are used for backgrounds, text, accents, and other elements
* Your **fonts** are applied to headings and body text
* Your **visual style** influences layout and imagery choices
The brand kit is stored as structured data that the AI agent references during design generation.
## Tips for consistent brand results
* **Provide clear, high-quality assets**: A clean logo file (SVG or PNG with transparency) and well-defined color palette give the agent the best starting point
* **Include multiple brand colors**: Give the agent a primary color, secondary colors, and accent colors so it has options for different design elements
* **Add reference images**: Upload examples of designs you like to help the agent understand your visual style
* **Be specific in your prompts**: If you want a particular color treatment or layout style, mention it in your prompt alongside selecting the brand kit
## Switching between brand kits
If you have multiple brand kits, you can pick which one to use in two places:
* **When you start a design** — the brand kit selector sits on the homepage prompt bar.
* **At any point afterwards** — the brand kit indicator sits beside the canvas title. Click it to switch or clear the kit.
You are not locked in at creation time. Each design remembers its own brand kit, so switching on one canvas doesn't affect any other design or change the default that new designs start with.
<Note>
Switching the brand kit doesn't restyle what's already on the canvas. It changes the brand the agent works from next — so ask the agent to apply it ("restyle this deck with the new brand kit") if you want existing pages updated.
</Note>
# Overview
Source: /docs/help/canvas/index
Overview of the Layovelle canvas — editing, tools, keyboard shortcuts, auto-save, and version history.
The canvas is where you view and edit your designs. It's a 2D vector canvas where you can select, move, resize, and modify design elements.
## Navigation
* **Pan**: hold **Space** and drag, or use a trackpad/mouse scroll
* **Zoom**: pinch on trackpad, or use the zoom controls in the bottom bar
* **Select**: click an element to select it, or click and drag on empty space to create a selection box
## Editing
Once an element is selected, you can:
* **Move it** by dragging
* **Resize it** by dragging the corner handles (hold Shift to maintain aspect ratio)
* **Edit text** by double-clicking a text element
* **Delete it** with the Delete or Backspace key
The properties panel on the right side of the canvas lets you adjust colors, fonts, alignment, and other properties of the selected element.
## Tools
The toolbar provides tools for creating new elements: rectangles, ellipses, lines, text, pen paths, and more. You can also use the hand tool for panning and the comment tool for leaving feedback.
## Keyboard shortcuts
Layovelle has keyboard shortcuts for most common actions. To see the full list, open **File** in the top menu bar and click **Keyboard shortcuts**.
## Working with slides
For multi-page designs (like slide decks), you can add, reorder, duplicate, and delete pages using the page controls at the bottom of the canvas.
## Media and images
* Upload your own images by dragging them onto the canvas or using **File → Upload image**
* The AI agent can generate images as part of the design process, or on request
* Supported image formats: JPEG, PNG, GIF, SVG (max 250 MB per file)
## Auto-save
Layovelle auto-saves your work continuously. Changes are saved automatically after about 1 second of inactivity, with a safety net save every 20 seconds. You don't need to manually save.
If your canvas won't load, try refreshing the page or clearing your browser cache. Your last auto-saved state will be restored.
## Version history
Layovelle keeps a history of your canvas versions. To view or restore a previous version:
1. Open **File** in the top menu bar
2. Click **Version history**
3. Browse previous versions and restore if needed
This is useful if you want to undo a large set of changes or recover a previous state of your design.
# Export Formats
Source: /docs/help/export/formats
Export your Layovelle designs as PDF, PowerPoint, PNG, JPG, MP4, GIF, WebP, or Google Slides.
Layovelle supports several export formats. The default format depends on your canvas type: slide decks default to PPTX, document canvases default to PDF, animation canvases default to MP4, and other canvases default to PNG.
## Available formats
| Format | Description | Best for |
| - | - | - |
| **PNG** | Lossless raster image | Complex images, illustrations |
| **JPG** | Compressed raster image | Sharing, social media |
| **PDF** | Document format | Documents, emailing, printing |
| **PowerPoint (PPTX)** | Microsoft PowerPoint | Presentations, editable slides |
| **MP4 Video** | Video of a single page | Animated single-page designs |
| **GIF** | Animated image | Short clips, no sound |
| **WebP** | Animated image of a single page, supports transparency | Smaller animated clips with transparency |
| **MP4 (Full Video)** | All pages as a single video | Full presentations with transitions |
## Exporting as PDF
PDF export creates a hybrid PDF with:
* **Selectable text**: Text elements are rendered as real text in the PDF, not rasterized
* **Vector elements** where possible
* **Hyperlinks** preserved from the design
There is also a **Flatten PDF** option that rasterizes everything, which can be useful if the hybrid PDF has rendering differences.
## Exporting as PowerPoint (PPTX)
PowerPoint export converts your design to a native `.pptx` file:
* **Supported elements** are converted to native PowerPoint objects (text, shapes, images)
* **Backgrounds** are detected and placed as slide masters (non-editable background layer)
* **Unsupported shapes** are rasterized — complex vector elements that don't have a PowerPoint equivalent are rendered as images
### What transfers as an editable object
These element types can leave Layovelle as native PowerPoint objects rather than pictures:
| Element | Exports as |
| - | - |
| Text | A PowerPoint text box you can retype |
| Rectangles and containers | A `rect` or `roundRect` shape |
| Ellipses | An ellipse shape |
| Lines | A line shape |
| Freeform shapes | A custom-geometry shape |
| Tables | A PowerPoint table |
| Images | An embedded picture |
| Video | An embedded media item |
| Charts | A PowerPoint chart |
Animations do not transfer to PowerPoint.
### What gets flattened to an image, and why
Some effects have no PowerPoint equivalent. When an element uses one, that element exports
as a picture instead. It's per element, not per slide — one shadowed shape becomes a
picture while the text and tables around it stay editable.
For the element types in the table above, this is the complete set of *styling* reasons one
of them turns into a picture:
| Cause | Flattens |
| - | - |
| backdrop blur | Containers, Ellipses, Freeform, Images, Rectangles, Video |
| chart with no data rows | Charts |
| colour adjustment on an image fill | Images, Video |
| container that clips its content | Containers |
| container with group opacity below 1 | Containers |
| custom data-point labels | Charts |
| distort warp | Containers, Ellipses, Freeform, Images, Rectangles, Text, Video |
| donut ellipse (non-zero inner radius) | Ellipses |
| drop shadow | Freeform, Video |
| drop shadow and inner shadow together | Containers, Ellipses, Images, Rectangles |
| drop shadow on a contain-fit image fill | Images |
| flipped or non-finite video scale | Video |
| gradient fill | Freeform |
| gradient stroke | Freeform, Images, Video |
| image fill | Containers, Ellipses, Freeform |
| inner shadow | Video |
| inner shadow on a contain-fit image fill | Images |
| inner shadow with spread | Containers, Ellipses, Images, Rectangles |
| inside a clip mask or a clipping container | Charts, Containers, Ellipses, Freeform, Generated graphics, Images, Lines, Rectangles, Tables, Text, Video |
| layer blur | Containers, Ellipses, Freeform, Images, Rectangles, Video |
| mirrored by a negative ancestor scale | Charts |
| non-normal blend mode | Containers, Ellipses, Freeform, Images, Rectangles, Video |
| non-solid fill priority | Freeform |
| non-solid fill priority, except a linear or radial gradient | Containers, Ellipses |
| non-solid fill priority, except a pattern-image or video fill (those move to the image and video lanes) or a linear/radial gradient | Rectangles |
| partial ellipse (arc or pie) | Ellipses |
| partial or counter-clockwise pie, exploded slices, or a donut centre label | Charts |
| pie with no positive slice value | Charts |
| quadrant overlay, trend line, or dual axis | Charts |
| right-aligned chart title | Charts |
| rotated image fill | Images, Video |
| rotated, off-centre or elliptical radial gradient | Containers, Ellipses, Rectangles |
| rotation beyond the embed tolerance | Charts, Video |
| scatter, bubble, or combo chart | Charts |
| shader fill | Containers, Ellipses, Freeform, Rectangles |
| source-mask shader effect | Containers, Ellipses, Images, Rectangles, Video |
| subtitle, source attribution, or date-scaled x axis | Charts |
| tiled (repeating) image fill | Images, Video |
| zero or non-finite image scale | Images |
**How to read the second column.** An element type that isn't listed on a row still exports
as a real PowerPoint object when it has that effect. Lines and tables have no styling gate
at all beyond clipping, and text adds only a distort warp. The tradeoff is that the effect
itself may not survive: you get an editable line rather than a picture of one, but the
styling PowerPoint can't express is dropped or approximated rather than preserved. For the
element types that do gate on an effect, the whole element rasterizes so the effect is kept.
Three rows need a word of explanation:
* `image fill` says **Containers**, not rectangles, on purpose. A plain rectangle with an
image fill isn't flattened — it leaves the shape lane and exports through the image lane,
still a native picture. A container with an image fill has no such lane to move to, and
neither does an ellipse.
* `non-solid fill priority` is split for the same reason. On a rectangle the two priorities
you actually produce — an image fill and a video fill — move to the image and video lanes
instead of flattening, so only gradient and shader priorities rasterize it.
* `rotation beyond the embed tolerance` means a video or chart rotated more than 0.5°.
**Generated graphics** — a chart or illustration saved as a single image asset — appear on
one row only. They export as an embedded picture and are flattened just by clipping, so a
generated graphic keeps its shadow, blur, or blend mode and still lands as a native picture.
### Other reasons something can still export as a picture
The table above covers styling. Two things sit outside it:
* **Charts drawn as live shapes.** A bar, line, area, pie, or doughnut chart Layovelle renders as
its own bars, axes, and labels becomes a real PowerPoint chart you can restyle and edit the
data of; PowerPoint lays it out itself, so it is editable rather than pixel-identical. Any
other chart type always exports as one picture of the whole chart, and the Charts rows
above say which of the supported ones still flatten. A chart saved as a single image asset
is the case above and behaves differently.
* **Images whose source can't be read at export time.** If the image bytes can't be
fetched, or an SVG has no intrinsic size to render at, that image stays in the picture
layer even though nothing in the table applies to it.
### Video: when an embed becomes a still
A video that clears everything above can still fall back to its poster frame. The usual
reasons show up only while the file is being written:
* the media type isn't one PowerPoint can embed
* the clip is over the per-video size cap, or the export has already used its total
embedded-video budget
* fetching the file failed
* PowerPoint rejected the media while the slide was being written
In each case the export falls back to the clip's poster image in the same spot. A clip with
no usable poster leaves a gap there instead.
<Note>
The table above is checked against the exporter's own rules in CI: the causes and the
element types each one applies to are read out of the export code. If you hit a case that
doesn't match what you see here, that's a bug worth reporting rather than expected
variance.
</Note>
## Exporting as PNG / JPG
Raster image export renders your design as a pixel image:
* You can choose the **export scale** (1x, 2x, 3x, etc.) for higher resolution
* You can export **individual pages** or **all pages**
* Very large exports (dimensions over 8,192px or area over 50 million pixels) use tiled rendering to work within GPU limits
* If rendering fails at high scale, Layovelle automatically retries at 1x scale
## Exporting as video
Video export is available for designs with animations:
* **MP4 (single page)**: Exports one page's animation as an H.264 (or H.265) video — frame rate selectable: 24, 30 (default), or 60 FPS
* **GIF (single page)**: Exports one page's animation as a GIF — frame rate selectable: 10, 12 (default), 15, or 24 FPS
* **WebP (single page)**: Exports one page's animation as an animated WebP at 15 FPS — smaller than GIF and supports transparency
* **MP4 Full Video (all pages / Main Edit)**: Exports all pages as a single video with transitions between slides, or the canvas's Main Edit timeline. The in-app dialog renders these at 30 FPS; the API and CLI accept 24, 30, or 60
Video exports render and encode server-side on Layovelle's native render lane. The per-file budget is per format: MP4 allows 18,000 frames and 600 seconds, GIF and WebP allow 9,000 frames and 300 seconds — whichever binds first. At 60 FPS the frame budget caps an MP4 at 300 seconds. Large exports may take longer and continue in the background; you'll get the file when they finish.
For mixed page sizes in full video export, Layovelle adds letterboxing to maintain consistent dimensions.
## Exporting for Google Slides
Google Slides export is available for **slide and document canvases** (the export dialog offers it only for slides and documents):
1. Connect your Google account from the export menu
2. Click **Export to Google Slides**
3. The exported presentation opens in a new tab in Google Slides
### What transfers and what doesn't
* Text, basic shapes, and many images transfer as editable Google Slides content
* Complex vector artwork, blends, SVG-backed assets, and advanced image treatments may be rasterized for fidelity
* Semi-transparent text may appear more opaque after export because Slides does not preserve editable text alpha reliably
* Custom letter-spacing does not transfer
* Animations do not transfer
* Portrait and custom page sizes may still open as standard 16:9 Slides presentations
Google Slides export is a best-effort conversion. Layovelle keeps content editable where the Slides API supports it and uses raster fallbacks where fidelity matters more than editability.
## Downloading individual slides vs. full deck
When exporting, the page selector at the top of the export dialog offers:
* **All pages** — every page in the design
* **Current page** — just the page you're on, named so you can confirm it's the right one
* **Selected pages** — tick individual pages from the list
For formats that produce one file per page (PNG, JPG), exporting several pages gives you a ZIP. PDF and PPTX combine the selected pages into a single document.
# Sharing & Presenting
Source: /docs/help/export/sharing
Share your Layovelle designs with others and present directly from the app.
## Sharing a link
You can share your designs with others through the **Share** button in the toolbar.
### Team sharing
* Copy a link that **team members can use to edit** the design
* Invite specific people by email
### Public links
You can create public links with different permission levels:
* **View only**: Anyone with the link can view the design but not edit it
* **View & remix**: Anyone with the link can view the design and create their own copy to edit
## Presenting directly from Layovelle
For slide-type canvases, you can present directly from Layovelle:
1. Click the **Slideshow** button in the toolbar (available when your canvas is in pages/slides layout mode)
2. The presentation enters fullscreen mode
3. Navigate between slides using arrow keys or clicking
Presenting works on both desktop and mobile, though on touch devices the fullscreen behavior may differ slightly between browsers.
## Embedding Layovelle designs
There are two different things people mean by "embedding", and Layovelle handles them differently.
**Putting a design on a page you control.** Export it and embed the file — a PNG or JPG for a static image, an MP4 for an animation. There's no oEmbed endpoint, and no self-serve way to turn a share link into an iframe.
**Putting a Layovelle canvas inside your own product.** That's the [Canvas Embed SDK](/canvas-embed/overview) — a canvas in an iframe, authorized by a session your backend creates. It supports **view-only, view-without-export, and fully editable** modes, so it covers read-only embedding as well as letting your users design in place. It's aimed at companies building Layovelle into their own app rather than at sharing a single design, and it's in beta enabled per workspace; [contact support](mailto:admin@layovelle.com) for access.
For simply letting someone view a design, a [share link](#sharing-a-link) is usually what you want.
# Export Troubleshooting
Source: /docs/help/export/troubleshooting
Common issues with exporting and downloading designs from Layovelle.
## Download fails or page crashes on export
Large or complex exports can strain your browser's GPU and memory:
* **Reduce the export scale**: Try exporting at 1x instead of 2x or 3x
* **Export fewer pages**: Export individual pages instead of the full deck
* **Refresh and retry**: Free up browser memory by refreshing the page first
* **Try a different browser**: Chrome and Edge tend to handle large exports best
Layovelle automatically uses tiled rendering for very large exports (over 8,192px in any dimension or over 50 million total pixels) to work within GPU limits. If rendering still fails at high scale, it retries at 1x scale automatically.
## Video export is truncated or incomplete
* Video export encodes on the server for large clips, which can take longer than expected
* Make sure you don't close the browser tab while export is in progress
* GIF exports run at 10–24 FPS and MP4 at 24/30/60 FPS (defaults 12 and 30). The frame budget is rate-independent and per format (MP4 18,000 frames / 600 s, GIF 9,000 / 300 s), so a higher frame rate shortens the longest exportable clip (300 s of MP4 at 60 FPS vs 600 s at 30)
* If the export fails, try exporting a shorter animation or fewer pages
## PowerPoint export looks different from the web version
This is expected to some degree. PowerPoint export is a best-effort conversion:
* **Complex vector elements** are rasterized (converted to images) because PowerPoint doesn't support all the same shape types
* **Backgrounds** are placed as slide masters, which are non-editable in PowerPoint
* **Fonts** may render differently if the recipient doesn't have the same fonts installed
* **Blending modes and advanced effects** may not have PowerPoint equivalents
* **Animations** from Layovelle do not transfer to PowerPoint
For the most faithful reproduction, use **PDF export** instead.
## Google Slides export looks different from the web version
Google Slides export is also a best-effort conversion:
* **Complex vector elements and SVG-backed assets** may be rasterized because Slides does not accept raw SVG images
* **Semi-transparent text** may appear more opaque because editable Slides text does not preserve Layovelle text alpha exactly
* **Advanced image treatments** such as masked pattern fills or partial-opacity images may be flattened into PNGs
* **Fonts** may render differently if Google Slides substitutes a different font
* **Animations** do not transfer to Google Slides
If exact visual fidelity matters more than editability, use **PDF export** instead.
## Animations behavior in exported files
* **PDF**: Animations are not included; the design is exported as static pages
* **PowerPoint**: Animations are not included
* **PNG/JPG**: Static snapshot of the current frame
* **MP4/GIF**: Animations are rendered as video. Single-page export captures that page's animation; full video export includes transitions between pages
* **Google Slides**: Animations are not included
Animations do not convert to PowerPoint or Google Slides animations, and there's no setting that will change that — the two animation models are too different to translate faithfully.
If you need motion in a deck, export the animation as MP4 and insert that video into the slide yourself. See [Animation and video](/help/animation).
# FAQ
Source: /docs/help/faq
Frequently asked questions about Layovelle — plans, credits, exports, browser support, and more.
<AccordionGroup>
<Accordion title="What can I create with Layovelle?">
Three things:
* **Designs** — slide decks, social media graphics, marketing collateral, documents, UI mockups, and diagrams, on a 2D vector canvas.
* **Animations** — keyframe animation on an animation canvas, exported as MP4, GIF, or WebP. (Decks get slide transitions; other canvases get preset animations.) See [Animation and video](/help/animation).
* **Websites** — describe a site and publish it to a free `*.layovelle.com` address or your own domain. See [Websites](/help/websites).
Layovelle is not a 3D tool or a photo editor, and it isn't a video *editor* — it animates designs you build in Layovelle rather than cutting together existing footage.
See [What is Layovelle?](/help) for a full overview.
</Accordion>
<Accordion title="How do credits work?">
AI credits measure your usage of AI-powered features. Every AI action — generating a design, creating an image, asking the agent to edit — consumes credits. Different actions cost different amounts depending on complexity and the AI model used — Lite costs roughly half of Standard, and Pro costs more; the model picker in the app shows each tier's approximate cost.
Credits reset monthly and do not roll over. See [How credits work](/help/billing/credits) for details.
</Accordion>
<Accordion title="What's included in the free plan vs Pro?">
The Free plan includes Standard and Lite design models, community support, and a monthly credit allowance. Pro adds the Pro design model, email support, pooled team credits, and a larger credit allowance. Ultra adds unlimited brand kits, SSO/SAML, shared team workspaces, and priority support.
All plans include unlimited projects and real-time collaboration. See the [Pricing page](https://layovelle.com/pricing) for the full comparison.
</Accordion>
<Accordion title="I upgraded but my account still shows the free plan">
* Refresh the page — plan changes may take a moment to propagate
* Check your email for a payment confirmation from Stripe
* Go to **Settings → Billing** to verify your plan status
* If the issue persists, email [admin@layovelle.com](mailto:admin@layovelle.com)
</Accordion>
<Accordion title="How do I get Layovelle to use my brand kit?">
Make sure your brand kit is **selected** in the prompt bar before creating a design. The AI agent will automatically use your brand colors, fonts, logo, and visual style. You can also mention specific brand elements in your prompt for more control.
See [Using your brand kit](/help/brand-kit/using-your-brand) for tips.
</Accordion>
<Accordion title="Why is my design taking so long to generate?">
Design generation typically takes 1–5 minutes for a multi-page slide deck, and less for simpler designs. Complex prompts with web research, image generation, or many pages take longer.
If a design has been generating for more than 10 minutes, try refreshing the page. Your work is auto-saved.
</Accordion>
<Accordion title="My design is stuck on "Building" — what should I do?">
* Check the chat panel to see if the agent is still actively working
* Wait up to 5–7 minutes for complex designs
* Refresh the page — your work is auto-saved, so you won't lose progress
* Try starting a new design with a clearer, more specific prompt
See [AI agent troubleshooting](/help/ai-agent/troubleshooting) for more details.
</Accordion>
<Accordion title="How do I export to PowerPoint?">
Click the **Download** button in the toolbar and select **PowerPoint** as the format. Slide-type canvases default to PPTX export. Note that some complex elements may be rasterized in the export, and animations do not transfer.
See [Export formats](/help/export/formats) for details on what transfers.
</Accordion>
<Accordion title="Why does my export look different from the web?">
PowerPoint and Google Slides exports are best-effort conversions. Complex vector elements, blending modes, SVG-backed assets, and advanced styling may be rasterized or simplified. Google Slides also has extra limitations around editable text alpha and some image treatments, so translucent text or masked images may look different after export. Fonts may also render differently if the recipient doesn't have the same fonts installed.
For the most faithful reproduction, use PDF export. See [Export troubleshooting](/help/export/troubleshooting).
</Accordion>
<Accordion title="Can I insert video into my slides?">
Not as embedded playback — you can't drop an MP4 into a slide and have it play inside the design.
**A slide deck can't be exported as video either.** Video export — whether one page or all pages joined together — requires an **Animation**-format canvas. Slide transitions affect how the deck plays in Present mode; they don't make a video export available, because their motion lives *between* pages rather than on one.
So if you need a video, build it as an Animation canvas: create one, animate it there, and export MP4. See [Animation and video](/help/animation).
</Accordion>
<Accordion title="Does Layovelle support languages other than English?">
**Yes for prompting, and yes for most scripts on the canvas.** You can prompt the agent in your own language, and the canvas renders non-Latin scripts including Chinese, Japanese, Korean, Cyrillic and Greek. When your chosen font doesn't cover a script, Layovelle automatically falls back to one that does, per run of text — so a single text box can mix Latin and non-Latin content.
<Warning>
**Right-to-left scripts aren't supported yet.** Arabic, Hebrew, and other RTL languages won't lay out correctly on the canvas. This is on the roadmap, but don't build an RTL design in Layovelle today.
</Warning>
One thing to expect for the scripts that do work: **your brand font probably doesn't cover them.** Most Latin typefaces have no CJK glyphs, so those characters render in a fallback face rather than your brand font. That's a limitation of the font rather than of Layovelle — if you need brand consistency across scripts, upload a font family that covers them as a [team font](/help/brand-kit/setup).
</Accordion>
<Accordion title="What browsers does Layovelle work on?">
Layovelle requires **WebGPU** support. It works best on **Chrome** and **Edge**. Firefox and Safari are supported but may require enabling WebGPU in browser settings.
See [System requirements](/help/getting-started/system-requirements) for the overview, or [WebGPU troubleshooting](/help/getting-started/webgpu-troubleshooting) for browser-specific steps.
</Accordion>
<Accordion title="How do I enable WebGPU?">
Each browser has a different process, and on Chrome or Edge the first step is usually checking graphics acceleration and `://gpu`, not enabling random flags.
See [WebGPU troubleshooting](/help/getting-started/webgpu-troubleshooting) for step-by-step browser-specific instructions.
</Accordion>
<Accordion title="Does Layovelle work on mobile?">
Layovelle works on mobile devices with limitations. The canvas is viewable and basic interactions work, but full design editing is best done on desktop. Presenting slideshows works on mobile. iOS in-app browsers may have issues — open in Safari or your default browser instead.
</Accordion>
<Accordion title="Are my designs private? Can I use them commercially?">
Your designs are **private by default**. No one can see them unless you share a link. Designs are only accessible to you and anyone you explicitly share them with.
**Yes — you can use what you make commercially.** Per the [Terms of Service](https://layovelle.com/terms), you own all rights, title, and interest in the content you generate with Layovelle, including AI-generated designs. Where a Layovelle template is incorporated into your design, you get a worldwide, transferable, royalty-free license to use it for personal *and* commercial purposes.
Two caveats worth knowing:
* Ownership doesn't extend to the underlying models or templates themselves, or to third-party material embedded in an output — only to the design you produced.
* You're responsible for having the rights to whatever you upload. If you import a logo, font, or photo you don't have a license for, generating a design from it doesn't grant you one.
See [Trust & security](/help/trust-security) for privacy and data handling.
</Accordion>
</AccordionGroup>
# Your Account
Source: /docs/help/getting-started/account
Create your Layovelle account, understand plans, manage your subscription, and set up your team.
## Creating your account
Sign up at [layovelle.com](https://layovelle.com) with your email or a social login (Google, etc.). Authentication is handled through Clerk.
## Plans
Layovelle offers Free, Pro, and Ultra plans. For current pricing, credit allowances, and feature comparisons, see the [Pricing page](https://layovelle.com/pricing).
### Design models
Layovelle offers four AI design models that differ in capability and credit cost (the model picker in the app shows each tier's approximate cost):
* **Lite** (roughly half the cost of Standard): best for simple designs and most edits. Good for quick social posts, basic layouts, and straightforward tweaks.
* **Standard**: recommended for most designs. Handles complex layouts, multi-page documents, and sophisticated requests.
* **Pro** (costs more than Standard, paid plans only): for the most complex and demanding designs. Credit costs can add up quickly, so monitor usage.
* **Pro (Fast)** (paid plans only): same quality as Pro but faster, and uses more credits.
## Upgrading
You can upgrade at any time from your workspace settings:
1. Click your workspace name in the sidebar
2. Go to **Settings → Billing**
3. Choose your plan
Your new credit allocation takes effect immediately. If you downgrade, the change takes effect at the end of your current billing period.
## Managing your subscription
To manage your subscription (update payment method, view invoices, cancel):
1. Go to **Settings → Billing** in your workspace
2. Click **Manage** to open the billing portal
## Teams
On paid plans, each team member is a **seat**. Each seat adds credits to your team's shared pool. See [Pricing](https://layovelle.com/pricing) for per-seat rates.
### Adding and removing members
Invite people from **Settings → Team**:
<Steps>
<Step title="Invite by email">
Enter the person's email address and send the invitation. They'll get an email with a link to join your workspace.
</Step>
<Step title="Choose a role">
Members can be **Member** or **Admin**. You can change someone's role later from the same panel.
</Step>
<Step title="Manage pending invitations">
Invitations that haven't been accepted can be **resent** or **canceled**.
</Step>
</Steps>
To remove someone, use the member list in the same panel.
**Billing follows seats automatically.** Adding a seat adjusts your subscription immediately and charges the prorated difference; removing one takes effect at the end of the billing period, and existing credits aren't revoked.
<Warning>
**The Free plan is capped at 10 members, and only the first 3 seats add credits.** A free workspace can invite up to 10 people, but seats beyond the third contribute nothing to your shared credit pool. Paid plans lift both limits — every seat you pay for adds its full credit allowance. See [How credits work](/help/billing/credits).
</Warning>
# Your First Design
Source: /docs/help/getting-started/first-design
Start a design from a prompt, file upload, or website URL.
There are several ways to start a new design in Layovelle.
## Starting from a prompt
The most common way to start is by typing a description of what you want. From the Layovelle homepage:
1. Select a format category (Slides, Social, Document, Carousel, Animation, or Website — more formats live under **More**)
2. Optionally select a brand kit to apply your brand identity
3. Type your prompt: describe the content, style, and purpose of your design
4. Click **Create**
The AI agent will research, plan, and build your design. This typically takes **1–5 minutes** for a multi-page slide deck, and less for simpler designs like a single social post.
### Tips for good prompts
**Do:**
* Be specific about the content, not just the style
* Include real information: company name, metrics, product details, audience
* Mention the number of slides or pages you want
* Describe the purpose and audience: "for a board meeting" or "for Instagram"
**Don't:**
* Write one-word prompts like "pitch deck" with no context
* Leave out the subject matter: "make a nice presentation" gives the agent nothing to work with
* Over-specify visual details before the first draft: let the agent make layout decisions, then refine
**Good examples:**
* "Create a 5-slide pitch deck for a Series A fintech startup. Cover the problem (SMBs can't access credit), our solution (AI underwriting), market size, traction (10K users, 40% MoM growth), and the ask (\$5M raise)."
* "Design an Instagram carousel (5 slides) announcing a new coffee blend. Include the blend name, tasting notes (chocolate, cherry, citrus), price, and a CTA to order online."
* "Make a one-page case study about how a logistics company reduced delivery times by 30% using our route optimization software. Include a customer quote and key metrics."
**Weak examples (and how to improve them):**
* "Make a pitch deck" → Add what the company does, who the audience is, and what slides you want
* "Design something for social media" → Specify the platform, topic, and format (square post, story, carousel)
* "Create a beautiful presentation" → Describe the content and purpose; the agent handles the design
See the [Prompting Guide](/help/ai-agent/prompting) for more examples by use case.
## Starting from a file
You can upload files to use as a starting point:
* **PPTX**: import an existing PowerPoint presentation
* **PDF**: import a PDF document
* **Images** (JPEG, PNG, GIF, SVG): use images as reference or content
The maximum file upload size is **250 MB**.
To start from a file, click the attachment button on the homepage prompt bar and upload your file along with a prompt describing what you want.
## Starting from a website
You can provide a URL and Layovelle will crawl the website to extract design elements, content, and style direction. This is useful for:
* Replicating the look and feel of an existing page
* Extracting content from a website into a presentation
* Using a website as style reference for your design
## What happens during generation
When you submit a prompt, the AI agent goes through several stages:
<Steps>
<Step title="Research" icon="magnifying-glass">
The agent may search the web, analyze uploaded files, or review your brand kit.
</Step>
<Step title="Planning" icon="list-check">
The agent outlines the structure and content of the design.
</Step>
<Step title="Building" icon="hammer">
The agent creates elements on the canvas (shapes, text, images, layouts).
</Step>
<Step title="Refinement" icon="wand-magic-sparkles">
The agent may iterate on the design, adjusting styling and layout.
</Step>
</Steps>
You can watch this process happen in real time on the canvas. The agent's progress and thinking are shown in the chat panel.
### Expected generation times
* **Single social post or graphic**: 30 seconds – 2 minutes
* **Multi-page slide deck (5–10 slides)**: 2–5 minutes
* **Complex documents or research-heavy designs**: 3–7 minutes
If a design seems stuck for more than 10 minutes, try refreshing the page. Your work is auto-saved, so you won't lose progress.
## After generation
Once the agent finishes, your design is fully editable on the canvas. You can:
* **Ask the agent to refine**: type follow-up instructions in the chat panel
* **Edit manually**: select and modify any element directly on the canvas
* **Export**: download as PDF, PowerPoint, PNG, or other formats
See [The Canvas](/help/canvas) for a guide to editing your designs.
# Importing files
Source: /docs/help/getting-started/importing-files
Import PowerPoint and Google Slides as editable designs, and understand how Beautify redesigns a file (and why PDFs look different).
Layovelle can bring an existing file onto the canvas in two different ways: **importing** it (keep the original layout, then edit) or **beautifying** it (let the AI redesign it). Which one you want depends on whether you're trying to *edit* a deck or *redesign* it.
## Import a file (keep the layout, then edit)
Use **Import a file** when you have a PowerPoint or Google Slides deck and want to keep working on it in Layovelle with the layout largely preserved.
From the homepage:
1. Choose **Import a file**.
2. Upload a **PowerPoint (.pptx)** file, or connect **Google Slides**.
3. Layovelle imports the slides as an editable Layovelle design.
Importing does its best to reproduce the deck's slides as editable Layovelle elements — text, shapes, and images become objects you can move and restyle, rather than a flat picture. It's a best-effort conversion, not a pixel-perfect copy.
<Note>
**Import a file** is for presentation files — **PowerPoint** and **Google Slides**. Google Slides decks are imported by converting them to PowerPoint format first.
</Note>
### What may not carry over
Import parses what it can and skips what it can't, so some things can be simplified, omitted, or lose editability:
* **Complex or unsupported elements** — for example SmartArt, charts, and other advanced objects — may not come in as editable objects.
* Individual shapes that can't be parsed are skipped rather than blocking the import, so a slide can come in with a shape missing.
* **Animations and transitions from the source deck are not preserved on import.** The imported design comes in without them.
For anything that doesn't survive the import, you can rebuild it on the Layovelle canvas or ask the AI agent to recreate it.
<Note>
Importing is available on every plan. The maximum PowerPoint file size is **250 MB**.
</Note>
## Beautify a file (let the AI redesign it)
Use **Beautify a file** when you want the AI agent to *redesign* a file — refresh the styling, apply your brand, and clean up the layout.
From the homepage:
1. Choose **Beautify a file**.
2. Upload a file (PowerPoint, PDF, and more) or **attach slides from Google Drive**.
3. Layovelle hands the file to the AI agent, which produces a redesigned version.
Because Beautify is a *redesign*, the result is intentionally different from the original — that's the point. If you want to stay as close to the original layout as possible, use **Import a file** instead.
## Which formats can be imported?
| File | How Layovelle handles it |
| - | - |
| **PowerPoint (.pptx)** | Imported as editable slides (best-effort layout), or redesigned with Beautify. |
| **Google Slides** | Imported as editable slides (converted via PowerPoint), or redesigned with Beautify. |
| **PDF** | **Not** imported directly — the AI **recreates** it during Beautify (see below). |
| **Images & videos** (JPEG, PNG, MP4, WebM, …) | Used as reference or content in a design. |
See [Your First Design](/help/getting-started/first-design) for starting a design from an uploaded file, and [Export Formats](/help/export/formats) for exporting back out to PowerPoint or Google Slides.
## Why a PDF (or a beautified deck) looks different
<Warning>
A **PDF is not imported directly** — Layovelle has no way to open a PDF as editable slides. Instead, the AI agent **recreates** the document as a new design. Because it's rebuilt rather than copied, fonts, spacing, and styling can shift. This is expected behavior for PDFs.
</Warning>
A couple of things to keep in mind:
* **PowerPoint and Google Slides** stay closest to the original when you use **Import a file**, because Layovelle reads the real slide structure — but it's still a best-effort conversion, and complex or unsupported elements may be simplified or omitted (see [What may not carry over](#what-may-not-carry-over)).
* **PDFs** are always recreated by the AI, so treat the result as a redesigned starting point rather than a copy.
* **Beautify** redesigns *any* file — including PowerPoint and Google Slides — so a beautified deck will also look different from the original by design. Use **Import a file** when you want to stay as close to the original layout as possible.
### A note on animations
Two separate things to know about animations:
* **On import**, animations and transitions in the source PowerPoint or Google Slides deck are **not** brought into Layovelle — the imported design comes in without them.
* **On export**, animations you add in Layovelle **do not transfer** to PowerPoint or Google Slides. See [Export Formats](/help/export/formats) for the full list of what transfers.
# Trouble Signing In
Source: /docs/help/getting-started/login-troubleshooting
Fix common sign-in problems - a code that didn't arrive, a missing Google button, signing in a different way than you signed up, or an email that's already in use.
Most sign-in problems come down to one of a few things: a code that didn't arrive, trying to sign in a different way than you signed up, or opening Layovelle inside another app's browser. Here's how to get back in.
## Your code didn't arrive
When you sign in with your email, Layovelle sends a **6-digit code** (not a link) and shows **"We sent a code to ..."**. If it hasn't shown up:
* **Check your spam or junk folder** - search for "Layovelle".
* **Confirm your email is typed correctly** - a small typo sends the code to the wrong address.
* **Select "Resend code"** to get a fresh one.
<Note>
Enter the most recent code you received. Codes expire after a short time, so an older one may no longer work.
</Note>
## The "Continue with Google" button is missing, or Google sign-in fails
This almost always means you opened Layovelle **inside another app's built-in browser** - for example, by tapping a link in X, Facebook, Instagram, or LinkedIn. Google doesn't allow sign-in from those in-app browsers, so Layovelle hides the button there.
To fix it, open **layovelle.com in your normal browser** (Safari, Chrome, and so on) and **Continue with Google** will be available. Or continue with your email address and a sign-in code instead.
## Sign in the same way you signed up
If you don't see the option you expect, you may be signing in differently than you created your account:
* If you signed up with **Continue with Google**, use that button rather than the email option.
* If you signed up with your **email address**, sign in with that same email address.
* If you're not sure which email you used, try your other address - a sign-in code only arrives for the account that address belongs to.
## "This email is already in use"
If you're adding or switching an email and Layovelle says it's already taken, that address is on another account. You can often resolve this yourself - see [Managing your email](/help/getting-started/managing-your-email) for how to claim an email from an old, empty account, and when to hand it to us instead.
## You're signed in, but in an unexpected workspace
If you land in a shared team workspace you didn't expect, it may be because your email's domain automatically joins an existing workspace when you sign up. See [Files, teams, and privacy](/help/workspace/files-teams-and-privacy) for how workspace membership and domain auto-join work.
## Still stuck?
Some situations need a person on our side:
* A suspected **account takeover** or an email change you didn't make.
* **Merging two accounts** that both have designs you want to keep.
For those, [contact support](mailto:admin@layovelle.com) and we'll help sort it out. We can't automatically move designs between accounts, which is why an account merge needs a human.
# Managing Your Email Addresses
Source: /docs/help/getting-started/managing-your-email
Add, verify, and switch the email addresses on your account, and resolve an email that's already in use.
Your account can have more than one email address. This is handy if you signed up with a personal email and later want to add your work email, or if you need to move your account to a new address.
## Your email addresses
Every account has one **primary** email address and can have additional ones alongside it.
<Note>
Your **primary** email is the address you sign in with and where account mail
— verification codes, security notices, and receipts — is sent. Any verified
address on your account can also be used to sign in.
</Note>
You'll find your email addresses under **Settings → Profile**, in the **Email addresses** card. Your primary address shows a **Primary** badge, and an address you haven't confirmed yet shows an **Unverified** badge, so you can see each address's status at a glance.
### Adding another email
<Steps>
<Step title="Open Settings → Profile" icon="user">
Find the **Email addresses** card and click **Add email address**.
</Step>
<Step title="Enter the address" icon="envelope">
Type the email you want to add and click **Add email**. For security, we may
first ask you to **confirm it's you** — usually with a code sent to your
current primary email — before the new address is added.
</Step>
<Step title="Verify the new address" icon="key">
We send a verification code to the new address. Check that inbox and enter
the code to confirm you own it, then click **Verify**. If the code doesn't
arrive, click **Resend code**.
</Step>
</Steps>
Once verified, the new address appears in the list and can be used to sign in.
### Making an address your primary
Next to any verified, non-primary address, click **Make primary**. That address becomes the one you sign in with and where account mail is sent.
<Note>
Changing your primary email triggers a couple of security protections — see
**Security you'll notice** below.
</Note>
### Removing an address
Click **Remove** next to the address you no longer want, then confirm. A removed address can no longer be used to sign in.
A few things to know:
* You can't remove your **primary** address. Add and verify another address if you don't have one yet, make it primary, then remove the old one.
* Removing an address can't be undone — though you can always add the same address back later, it will need to be verified again.
## "This email is already taken"
When you try to add an address, you may see a message that **this email belongs to an existing account**. An email can only live on one account at a time, so it can't be added to yours until it's freed up. What to do depends on that other account.
### It's an empty account you own
If the other account is a duplicate you created but never used — no designs, no team, nothing in it — you can **claim** the email onto your current account.
<Steps>
<Step title="Choose "That's my old account"" icon="hand-pointer">
In the "This email belongs to an existing account" message, choose **That's
my old account**, then click **Continue**.
</Step>
<Step title="Sign in to the other account" icon="shield-check">
Enter the other account's email and click **Send code**. We send a one-time
sign-in code to that address — you stay signed in to your current account
the whole time. Enter the code and click **Verify** to prove the account is
yours.
</Step>
<Step title="Add the freed email" icon="circle-check">
Once confirmed, the empty account is removed and its email is freed. Click
**Add** to attach it to your current account, then enter one more
verification code sent to that address. You can then make it your primary if
you like.
</Step>
</Steps>
<Note>
Claiming only works when the other account is empty. If it has any designs or
other content, we can't move the email automatically — see the next section.
And if the other account uses two-factor authentication or needs extra sign-in
steps, we'll point you to a full sign-in or to support instead.
</Note>
### The other account has content
If the other account has designs, a team, or other work in it, we **can't automatically merge** the two accounts, and the email can't be claimed on your own. Instead, [contact support](mailto:admin@layovelle.com) and we'll help you consolidate.
If you don't recognize the account, or you own the address and think someone else is using it, [contact support](mailto:admin@layovelle.com) right away.
## Security you'll notice
When you change your **primary** email, you'll see two things happen. Both are deliberate protections in case someone else ever changes your email without your knowledge.
* **You're signed out of your other sessions.** The device you're currently using stays signed in, but any other browsers or devices are signed out and will need to sign in again. This makes sure a session you don't control can't keep access after an email change.
* **A notice goes to your old address.** We send a heads-up to the previous primary email so the original owner always finds out if their primary was changed.
If you ever receive an email-change notice you didn't expect, [contact support](mailto:admin@layovelle.com) right away.
## Consolidating two accounts
If you have two accounts and the one holding an email you want has content in it, we handle the consolidation manually so nothing is lost. To get started, [contact support](mailto:admin@layovelle.com) and include:
* The email address you want to keep as your primary.
* The email address (or account) you want consolidated, and which account has the designs or team you want to keep.
* Whether either account is on a paid plan.
Support will confirm you own both accounts, move the email over, and help bring your work together onto a single account.
<Warning>
We can't automatically merge two accounts that both have content — always
reach out to support so your designs, team, and billing are handled safely.
</Warning>
# System Requirements
Source: /docs/help/getting-started/system-requirements
Supported browsers, WebGPU setup, and device requirements for Layovelle.
Layovelle uses **WebGPU** for rendering, which means you need a browser that supports it.
## Supported browsers
| Browser | Support notes |
| - | - |
| **Google Chrome** | Best supported on desktop |
| **Microsoft Edge** | Best supported on desktop |
| **Firefox** | Best-effort support; may require manual setup |
| **Safari (macOS)** | Supported on newer Safari releases; older setups may vary |
| **Safari (iOS)** | Varies by iOS version and browser engine limitations |
For the best experience, we recommend **Chrome or Edge** on a desktop or laptop computer.
## If Layovelle says WebGPU is unavailable
Start with the browser-specific troubleshooting guide:
* [WebGPU Troubleshooting](/help/getting-started/webgpu-troubleshooting)
That guide covers:
* Chrome and Edge checks like `://settings/system` and `://gpu`
* Which flags matter, if any
* Safari feature-flag guidance
* Best-effort Firefox steps
* Common causes like GPU blocklists, remote desktop, and outdated drivers
## Quick guidance
* **Chrome / Edge desktop**: usually the best place to start
* **Firefox**: may work, but setup can vary more by machine
* **Safari**: newer releases are better than older ones, but support still depends on the exact version and device
* **Mobile**: usable for viewing and some interactions, but full editing is still best on desktop
## Mobile support
Layovelle works on mobile devices with some limitations:
* The canvas is viewable and basic interactions work
* Full design editing is best done on desktop
* iOS in-app browsers (e.g., opening Layovelle from a link in another app) may have issues — open in Safari or your default browser instead
* Presenting slideshows works on mobile, though the fullscreen API may behave differently on touch devices
## Recommended hardware
Layovelle runs in the browser and doesn't require installation. For the best experience:
* A computer with a **dedicated or recent integrated GPU**
* At least **4 GB of RAM** available to the browser
* A stable internet connection (Layovelle auto-saves to the cloud)
# WebGPU Troubleshooting
Source: /docs/help/getting-started/webgpu-troubleshooting
What to do when Layovelle says WebGPU is unavailable, with browser-specific steps and source links.
Layovelle uses **WebGPU** for canvas rendering. If Layovelle says WebGPU is unavailable, the problem is usually not your account. It is usually one of these:
* Your browser can see `navigator.gpu`, but cannot get a working GPU adapter on this computer
* Graphics acceleration is turned off in the browser
* The GPU is blocklisted by the browser
* The machine is using remote desktop, a VM, or an older graphics setup that does not expose WebGPU reliably
## Start here
Before trying browser-specific steps:
1. Fully close the browser and open it again
2. Make sure your browser is updated
3. If you are on a work laptop, VM, or remote desktop session, try a normal local desktop session instead
## Chrome
Chrome should support WebGPU by default on supported desktop systems.
### If Layovelle says WebGPU is unavailable in Chrome
1. Open `chrome://settings/system`
2. Make sure **Use graphics acceleration when available** is turned on
3. Click **Relaunch**
4. Open `chrome://gpu`
5. Find the line labeled **WebGPU**
If `chrome://gpu` says **WebGPU: Hardware accelerated**, reload Layovelle.
If `chrome://gpu` says WebGPU is **disabled** or **blocklisted**:
1. Open `chrome://flags/#enable-unsafe-webgpu`
2. Set it to **Enabled**
3. Open `chrome://flags/#ignore-gpu-blocklist`
4. Set it to **Enabled**
5. Relaunch Chrome
### On Linux, also check Vulkan
On Linux, Chrome runs WebGPU through **Vulkan**. WebGPU will not work if Vulkan is off — **even when `chrome://gpu` shows "WebGPU: Hardware accelerated"**. So on Linux, check the **Vulkan** line in `chrome://gpu` too, not just the WebGPU line.
If the **Vulkan** line is not enabled:
1. Open `chrome://flags/#enable-vulkan`
2. Set it to **Enabled**
3. Relaunch Chrome
4. Reopen `chrome://gpu` and confirm the **Vulkan** line is now enabled
If Vulkan will not turn on, your graphics driver is usually the cause — update it (for example Mesa on Fedora/Ubuntu) and try again.
Do **not** turn on lots of unrelated experimental flags. Those are usually not needed.
## Edge
Edge follows the Chromium WebGPU stack closely, so the Chrome-style troubleshooting steps are usually the right place to start.
### If Layovelle says WebGPU is unavailable in Edge
1. Open `edge://settings/system`
2. Make sure **Use graphics acceleration when available** is turned on
3. Click **Restart**
4. Open `edge://gpu`
5. Find the line labeled **WebGPU**
If WebGPU is disabled or blocklisted:
1. Open `edge://flags/#enable-unsafe-webgpu`
2. Set it to **Enabled**
3. Open `edge://flags/#ignore-gpu-blocklist`
4. Set it to **Enabled**
5. Restart Edge
On Linux, Edge runs WebGPU through **Vulkan**, so check the **Vulkan** line in `edge://gpu` too — WebGPU will not work if Vulkan is off, even when the WebGPU line shows "Hardware accelerated". If Vulkan is not enabled:
1. Open `edge://flags/#enable-vulkan`
2. Set it to **Enabled**
3. Restart Edge
4. Reopen `edge://gpu` and confirm the **Vulkan** line is now enabled
## Safari on macOS
Safari 26 and later ship WebGPU. Older Safari versions may still require enabling a feature flag.
### If WebGPU is unavailable in Safari
1. Update macOS and Safari if updates are available
2. Open **Safari -> Settings -> Advanced**
3. Turn on **Show features for web developers**
4. Open **Safari -> Settings -> Feature Flags**
5. If **WebGPU** appears there, turn it on
6. Fully quit and reopen Safari
## Safari, Chrome, or Edge on iPhone and iPad
On iPhone and iPad, browser behavior is more complicated because Chrome and Edge do not use the same browser engine they use on desktop.
If WebGPU is unavailable:
1. Open the **Settings** app
2. Go to **Apps -> Safari -> Advanced -> Feature Flags**
3. On some iOS versions, Safari may appear directly in Settings instead of under Apps
4. If **WebGPU** appears there, turn it on
5. Fully quit and reopen the browser
If your browser or iOS version still does not expose WebGPU, try Layovelle on a desktop browser instead.
## Firefox
Firefox support is less predictable across devices, OS versions, and GPU drivers.
### Best-effort Firefox steps
1. Open `about:config`
2. Accept the warning if prompted
3. Search for `dom.webgpu.enabled`
4. Set it to `true`
5. Restart Firefox
If WebGPU is still unavailable:
1. Open `about:config`
2. Search for `gfx.webgpu.ignore-blocklist`
3. Set it to `true`
4. Restart Firefox
## If it still does not work
Try these next:
* Update your graphics driver, especially on Windows or Linux
* Try a different supported desktop browser
* Try the same browser outside remote desktop or virtualization
* If this is a locked-down work machine, browser or GPU policy may be preventing WebGPU
## Why this happens
Layovelle checks more than "are you using Chrome?" The browser also has to provide a working WebGPU adapter on your current machine. That is why WebGPU can work on one computer and fail on another, even in the same browser.
## Sources
These steps are based on browser vendor documentation where available:
* [Chrome for Developers: WebGPU troubleshooting tips and fixes](https://developer.chrome.com/docs/web-platform/webgpu/troubleshooting-tips)
* [Chrome for Developers: What's New in WebGPU (Chrome 146)](https://developer.chrome.com/blog/new-in-webgpu-146)
* [WebKit: WebKit Features in Safari 26.0](https://webkit.org/blog/17333/webkit-features-in-safari-26-0)
* [WebKit: WebGPU now available for testing in Safari Technology Preview](https://webkit.org/blog/14879/webgpu-now-available-for-testing-in-safari-technology-preview)
* [Chrome for Developers: Chromium Chronicle #28 - Getting started with Chrome on iOS](https://developer.chrome.com/blog/chromium-chronicle-28)
For Firefox, Mozilla does not currently provide an equivalent end-user troubleshooting page that matches Chrome's level of detail, so the Firefox section above is best-effort guidance.
# What is Layovelle?
Source: /docs/help/index
Layovelle is an AI-powered 2D vector canvas for creating slide decks, social media posts, ads, and other visual content.
Layovelle is an AI-powered design tool that creates slide decks, social media posts, marketing collateral, and other visual content. You describe what you want, and an AI agent builds the design for you on a 2D vector canvas.
## What can I create?
**Designs** on a 2D vector canvas:
* **Slide decks**: pitch decks, quarterly reviews, all-hands presentations, team updates
* **Social media graphics**: Instagram posts, LinkedIn banners, YouTube thumbnails, stories and reels
* **Marketing collateral**: one-pagers, case studies, event invitations, sales materials
* **Documents**: PDFs, reports, branded documents
* **UI mockups**: landing pages, dashboards, pricing pages
* **Diagrams and charts**: data visualizations, workflows
**Animations** — per-property keyframes on an animation canvas, exported as MP4, GIF, or WebP. Decks get slide transitions and other canvases get preset animations instead; keyframing is specific to the Animation format. See [Animation and video](/help/animation).
**Websites** — describe a site, iterate on it with the agent, and publish it to a free `*.layovelle.com` address or a domain you own. See [Websites](/help/websites).
## Quickstart
Getting from zero to your first design takes about 3 minutes.
<Steps>
<Step title="Create your account">
Sign up at [layovelle.com](https://layovelle.com).
</Step>
<Step title="Set up a brand kit">
Import your brand from a website URL, or add your logo, colors, and fonts manually. Highly recommended — designs created with a brand kit are significantly better and more consistent.
</Step>
<Step title="Start your first design">
Type a prompt describing what you want, or upload a file (PDF, PPTX, or images) to start from. The AI agent builds your design on the canvas. From there you can edit, refine, and export.
</Step>
</Steps>
## Next steps
<Columns>
<Card title="Your account" icon="user" href="/docs/help/getting-started/account">
Plans, billing, and team setup.
</Card>
<Card title="Your first design" icon="wand-magic-sparkles" href="/docs/help/getting-started/first-design">
Different ways to start a design.
</Card>
<Card title="Brand kit" icon="palette" href="/docs/help/brand-kit/setup">
Set up your brand identity.
</Card>
<Card title="The AI agent" icon="sparkles" href="/docs/help/ai-agent/how-it-works">
How the AI designs for you.
</Card>
<Card title="The canvas" icon="pen-ruler" href="/docs/help/canvas">
Editing, tools, shortcuts, and version history.
</Card>
<Card title="Export & sharing" icon="share" href="/docs/help/export/formats">
Get your designs out of Layovelle.
</Card>
</Columns>
# Overview
Source: /docs/help/integrations/index
Connect Layovelle to other tools — AI agents, the REST API, and more.
## AI agents
Give the agent you already use a real design tool. See [Layovelle for Agents](/agents).
* [CLI quickstart](/agents/cli-quickstart): the `moda` CLI, for Claude Code, Codex, Cursor, and anything else with a terminal
* [MCP connector](/agents/mcp-connector): one URL for claude.ai and Claude Desktop
* [Skills](/agents/skills): teach your agent Layovelle's design workflow
* [Ask the Layovelle expert](/agents/ask-expert): free, grounded how-to answers mid-task
The original MCP server at `mcp.moda.app` is still documented under [MCP Server (Legacy)](/mcp/overview) for integrations built against it, including the [design-to-code guide](/mcp/design-to-code).
## REST API
Layovelle provides a REST API for programmatic access to canvases, designs, brand kits, exports, and more.
* [API Overview](/api-reference): getting started with the REST API
* [Authentication](/api-reference/authentication): API key setup
* [Webhooks](/api-reference/webhooks): real-time event notifications
## Canvas Embed SDK
Embed a live, editable Layovelle canvas inside your own product. Your backend mints a short-lived session, your page loads it in an iframe, and your users design without leaving your app.
* [Canvas Embed overview](/canvas-embed/overview): what it is and when to use it
* [Quickstart](/canvas-embed/quickstart): a working embed in a few minutes
The embed SDK is in beta and enabled per workspace — [contact support](mailto:admin@layovelle.com) if you'd like access.
## Connecting accounts
Both Slack and Google are connected from **Settings → Integrations** in the app.
### Slack
Connect your Slack workspace and you can work with the Layovelle agent without opening Layovelle.
**To connect:** go to **Settings → Integrations** and click **Connect Slack**. You'll be asked to authorize Layovelle in your Slack workspace. The same panel has **Disconnect Slack** when you want to remove it.
**To use it**, do any of these in Slack:
* **`@Layovelle` mention the bot** in a channel it's been added to.
* **Send the bot a direct message.**
* **Reply in a thread** the bot is already part of.
From there you can ask it to create a design from a prompt, attach images, a PPTX, or a PDF to work from, or paste the URL of an existing canvas and ask for changes. Work done in Slack lands in your Layovelle workspace like anything else.
### Google
The same Integrations panel connects your Google account, which enables three things:
* **Export canvases to Google Slides**
* **Import presentations from Google Drive**
* **Attach Drive files to chat** — reference a Drive document when prompting the agent
See [Importing files](/help/getting-started/importing-files) and [Export formats](/help/export/formats) for what happens to your content in each direction.
### Using your own API keys
Layovelle currently does not support bringing your own API keys for AI providers. All AI operations use Layovelle's managed infrastructure and are metered through the credit system.
### CLI / Programmatic access
The `moda` CLI lets any agent with a terminal — Claude Code, Codex, Cursor — create, edit, and export Layovelle designs for you. See the [CLI quickstart](/agents/cli-quickstart).
For chat assistants with no terminal, add the [Layovelle connector](/agents/mcp-connector) to claude.ai or Claude Desktop. To build your own integration, use the [REST API](/api-reference).
# Overview
Source: /docs/help/trust-security/index
Privacy, data handling, commercial usage rights, and security practices at Layovelle.
## Are my designs private?
Your designs are private by default. Other users cannot see your canvases unless you explicitly share them via:
* A **team sharing link** (editable by team members)
* A **public link** (view-only or view-and-remix, accessible to anyone with the link)
If you are part of an organizational or enterprise account, the organization's administrator may be able to access, manage, or remove content associated with your account.
## Commercial usage rights
You own the content you create in Layovelle. From the [Terms of Service](https://layovelle.com/terms):
* **Your content**: you own all right, title, and interest in your User Content (anything you upload or provide to Layovelle).
* **Generated content**: you own all rights to content generated through the Services (presentations, slides, social media content, etc.). You may use generated content for **personal and commercial purposes**, subject to the Terms of Service.
* **Layovelle templates**: to the extent Layovelle templates are incorporated into your generated content, Layovelle grants you a worldwide, sublicensable, transferable, royalty-free license to use those templates as part of your generated content for personal and commercial purposes.
* **License to Layovelle**: you grant Layovelle a non-exclusive, worldwide, royalty-free license to host, store, and use your content and generated content solely to operate, provide, maintain, and improve the Services.
Note that generated content is produced by automated systems and may not be unique. Similar or identical content may be generated for other users. You are responsible for reviewing generated content and ensuring your use complies with applicable laws.
For the full terms, see the [Terms of Service](https://layovelle.com/terms).
## Data handling & privacy
Layovelle collects personal data necessary to provide the service. From the [Privacy Policy](https://layovelle.com/privacy):
* **What is collected**: name, email address, contact information, usage data (device, browser, IP address, how you interact with the service), and cookies
* **How it's used**: to provide and operate the service, improve and personalize it, communicate with you, process transactions, and comply with legal obligations
* **Layovelle does not sell** your personal information
* **Data sharing**: your information is shared only with your consent, to comply with legal obligations, to protect Layovelle's rights, or with service providers who assist in operating the service
### Your rights
Depending on your location, you may have rights regarding your personal information, including access, correction, deletion, data portability, and objection to certain processing.
### Usage data and model training
Layovelle may use usage data and, where permitted, user content in aggregated or de-identified form to improve the Services, including machine learning models. You may opt out of the use of your content for model training as described in the [Privacy Policy](https://layovelle.com/privacy).
For the full policy, see the [Privacy Policy](https://layovelle.com/privacy).
## Where is my data stored?
Layovelle is hosted on cloud infrastructure in the United States, and uses a set of third-party services to operate — authentication, payments, hosting for published websites, error monitoring, analytics, and the AI models behind the agent.
For the authoritative account of what is collected, where it goes, and how long it's kept, see the [Privacy Policy](https://layovelle.com/privacy).
If you're evaluating Layovelle for procurement and need a subprocessor list, a specific data-residency commitment, or a security questionnaire completed, email [admin@layovelle.com](mailto:admin@layovelle.com) and we'll route it to the right person.
## Security practices
Layovelle implements several security measures:
* **Authentication** is handled through Clerk, supporting email/password and social login
* **SSO / SAML** authentication is available on the Ultra plan
* **Payments** are processed through Stripe; Layovelle does not store your credit card information directly
* **SSRF protection**: server-side URL fetches (for web scraping, image loading, etc.) are validated against SSRF attacks, blocking private IP ranges and metadata endpoints
* **URL validation**: brand kit imports and web scraping validate URLs to prevent abuse
## Refunds
From the [Terms of Service](https://layovelle.com/terms): all fees are non-refundable except as required by law. Layovelle may, in its sole discretion, provide refunds or credits in specific circumstances.
## Age requirement
You must be 18 years of age or older to use Layovelle.
## Contact
For privacy-related questions, contact [admin@layovelle.com](mailto:admin@layovelle.com).
# Custom domains
Source: /docs/help/websites/custom-domains
Connect a domain you own to your published Layovelle site, including root domains like yoursite.com.
Every published Layovelle site gets a free `*.layovelle.com` address. On a paid plan you can also connect a domain you own — like `www.yoursite.com` or `portfolio.yoursite.com` — so visitors reach your site at your own address.
## Before you start
* Your site must be **published** (custom domains attach to the published site).
* You need access to your domain's **DNS settings** — usually at the registrar where you bought the domain (GoDaddy, Cloudflare, Namecheap, FastHosts, …).
## Connect a domain
1. Open your site's **Publish** panel and find **Custom domains**.
2. Click **Add domain**. In the dialog, enter the hostname you want, e.g. `www.yoursite.com`, and click **Add**. If you type a root domain like `yoursite.com`, Layovelle suggests `www.yoursite.com` instead — most registrars can't point a root at Layovelle, and `www` works everywhere (see [Root domains](#root-domains-yoursite-com-without-www)).
3. The dialog now shows your domain's setup. Under **Where is your DNS hosted?** pick your provider — **GoDaddy**, **Cloudflare**, **Namecheap**, or **Other** — and the click path, field names and TTL guidance switch to match.
4. Add the **two DNS records** shown at your DNS provider. Add them both in one visit — they're independent:
* **Ownership (TXT)** — proves you control the domain.
* **Routing (CNAME)** — points traffic at Layovelle.
5. Back in Layovelle, click **Verify ownership**. Layovelle also re-checks automatically for a few minutes.
Once ownership verifies and your CNAME propagates, Layovelle issues a TLS certificate for your domain (usually 1–2 minutes) and the status flips to **Active**. You can reopen the dialog any time from the domain's row in the Publish panel (**Set up** while it's pending, **Manage** once active).
<Warning>
**Enter each record's Name exactly as shown in Layovelle.** Most DNS providers automatically append your domain to whatever you type in the Name/Host field. If Layovelle shows the Name `_moda-verify` and you paste the full `_moda-verify.yoursite.com`, your provider saves it as `_moda-verify.yoursite.com.yoursite.com` — a doubled record that never verifies. This is the single most common reason verification fails.
</Warning>
For TTL, use the lowest value your provider offers — 300 seconds if available. GoDaddy's minimum of ½ Hour (600 seconds) is fine too; it only affects how quickly a later change propagates.
## Provider guides
The same steps the in-app dialog shows, checked against each provider's own help pages on 2026-09-03.
### GoDaddy
1. Sign in, open **Domain Portfolio**, select your domain, then open the **DNS** tab.
2. Click **Add New Record** and pick the record **Type**.
3. Fill in **Name**, **Value** and **TTL** exactly as Layovelle shows. GoDaddy adds `.yoursite.com` to Name for you; `@` means the root.
4. Save, then repeat for the second record.
* **TTL:** the lowest option is **½ Hour** (600 seconds) — that's fine.
* **Root domains:** GoDaddy's CNAME Name field can't be `@` and there is no ALIAS/ANAME type, so `yoursite.com` can't route to Layovelle at GoDaddy. Connect `www.yoursite.com` instead, then forward the root: **DNS → Forwarding → Add Forwarding**, forward to `https://www.yoursite.com`, **Permanent (301)**.
### Cloudflare
1. Select your domain, then open **DNS → Records**.
2. Click **Add record** and pick the record **Type**.
3. Enter **Name** and **Content** exactly as Layovelle shows. On the CNAME, set **Proxy status** to **DNS only** (gray cloud).
4. Leave **TTL** on **Auto** and save.
* **DNS only is required.** A proxied (orange-cloud) CNAME terminates TLS in your own Cloudflare zone and conflicts with the certificate Layovelle issues for your hostname.
* **Root domains:** Cloudflare flattens CNAMEs at the root, so a CNAME on `@` works — you can connect `yoursite.com` directly.
* **Root → www redirect:** add a redirect rule under **Rules → Redirect Rules** from `yoursite.com` to `https://www.yoursite.com`.
### Namecheap
1. Sign in, open **Domain List**, click **Manage** next to your domain, then open the **Advanced DNS** tab.
2. Under **Host Records** click **Add New Record** and pick the record type.
3. Fill in **Host** and **Value** exactly as Layovelle shows — Namecheap calls the name field *Host*. It adds `.yoursite.com` to Host for you; `@` means the root.
4. Leave **TTL** on **Automatic** and click **Save All Changes**.
* **Root domains:** Namecheap doesn't allow a CNAME on `@`. Use the **ALIAS Record** type for the routing record instead (Layovelle shows this type when you pick Namecheap for a root domain). Remove any existing A, CNAME or URL Redirect record on `@` first — they block it.
* **Root → www redirect:** add a **URL Redirect Record** with Host `@`, Value `https://www.yoursite.com`, type **Permanent (301)**. Remove any A record on `@` first.
### Other providers
1. Open the DNS settings for your domain at your DNS provider (usually the registrar you bought it from).
2. Add a new record for each card Layovelle shows, copying **Type**, **Name** and **Value** exactly. Remember the auto-append gotcha above — enter the short Name, not the full hostname.
3. Use the lowest TTL your provider offers, then save.
* **Root domains:** your provider may not allow a CNAME at the root; if it offers an **ALIAS** or **ANAME** type, use that. If it offers neither, connect `www.yoursite.com` instead and set up a web forward from the root — providers name it differently: *Web Forwarding* (FastHosts), *Domain Forwarding* (GoDaddy), *URL Redirect* (Porkbun and others). It's usually a toggle in the same panel as your DNS settings.
## Root domains (yoursite.com without www)
A root domain (also called an *apex* domain — `yoursite.com` rather than `www.yoursite.com`) is special: the DNS standard doesn't allow a CNAME record at the root, and many registrars offer no alternative.
**Recommended: use `www` and forward the root.** Visitors reach your site either way, and it works at every provider:
1. In Layovelle, click **Add domain** and connect `www.yoursite.com`. (If you already added the root domain, open its row and **Remove** it — it can't route at a provider without ALIAS/ANAME.)
2. Add the TXT and CNAME records Layovelle shows. Note the TXT Name will be `_moda-verify.www` — enter it exactly as shown.
3. At your registrar, forward `yoursite.com` to `https://www.yoursite.com` as a permanent (301) redirect. See the [provider guides](#provider-guides) for where that lives.
That's the standard setup across the web: `www` serves the site, the bare domain redirects to it.
**If your provider supports ALIAS/ANAME or root CNAMEs** (Cloudflare, Namecheap, DNSimple, and others), you can connect the root directly:
1. Add `yoursite.com` as your custom domain in Layovelle.
2. Create the routing record with Name `@` and the value Layovelle shows — as a CNAME on Cloudflare (flattened automatically) or as the **ALIAS/ANAME** type elsewhere.
3. Optionally, also add `www.yoursite.com` as a second domain in Layovelle so both addresses serve your site.
## Status meanings
| Status | What it means |
| - | - |
| **Verify ownership** | Waiting for your TXT record. Add it, then click Verify. |
| **Point DNS to Layovelle** | Ownership verified — waiting for your CNAME to propagate. |
| **Issuing certificate** | DNS is set; the TLS certificate is being issued (1–2 minutes). |
| **Active** | Your domain is live. |
| **Needs attention / DNS disconnected** | Your DNS no longer points at Layovelle — re-check your records. |
## Removing a domain
In the Publish panel, open the domain's row (**Set up** or **Manage**) and click **Remove** in the dialog, then confirm. Removal frees the hostname immediately and detaches the certificate; your `*.layovelle.com` address keeps working throughout.
## Troubleshooting
**Verification keeps failing.** Almost always the doubled-Name problem from the warning above. In your DNS provider's record list, check what the TXT record's full name actually is — if you see your domain repeated twice, edit the record's Name (Host on Namecheap) to just the short value Layovelle shows (e.g. `_moda-verify`).
**Records added but nothing happens.** DNS changes take a few minutes to propagate — longer with a high TTL, so use the lowest your provider offers (300 if available; GoDaddy's 600-second minimum is fine). Layovelle re-checks automatically for a few minutes after you add a domain; you can always open the domain's row and click **Verify ownership** to re-check immediately.
**Your DNS is on Cloudflare.** Set the CNAME to **DNS only** (gray cloud, not orange). Proxying through your own Cloudflare zone conflicts with the certificate Layovelle issues for your hostname.
**Your root domain is stuck at GoDaddy.** GoDaddy can't route a root domain to Layovelle at all. Remove the root entry, add `www.yoursite.com`, and forward the root as described in the [GoDaddy guide](#godaddy).
# Websites
Source: /docs/help/websites/index
Build a website with the Layovelle agent and publish it to a free layovelle.com address or your own domain.
Alongside designs, Layovelle can build and host a real website. You describe the site you want, the agent builds it, you refine it in the editor, and you publish — to a free `*.layovelle.com` address, or to a domain you own.
This is a separate surface from the canvas. A website is real HTML, CSS, and JavaScript rather than a design exported to a page, which is why it can do things a static export can't: multiple linked pages, working forms, responsive layouts that reflow on a phone.
## Create a site
Start from the homepage and choose **Website**, then describe what you want — *"a one-page site for my architecture studio with a project gallery and a contact form"*. The agent builds a first version you can look at immediately.
If you have a [brand kit](/help/brand-kit/setup), the site uses it. Setting one up first is the single biggest improvement you can make to the result.
## Edit
The website editor gives you a live preview beside the agent chat.
* **Ask for changes in chat** — *"make the header sticky"*, *"add a pricing section with three tiers"*, *"use more whitespace between sections"*. This is the main way to work.
* **Switch between pages** in the sidebar for multi-page sites.
* **Preview at different widths** to check how the layout behaves on a phone.
Changes are saved as you go, but they aren't visible to the public until you publish. If you have unpublished work, the editor tells you so.
## Publish
Open the **Publish** dialog from the editor.
Your site goes live at a `*.layovelle.com` address. Publishing is available on every plan, including Free.
| Feature | Free | Paid |
| - | - | - |
| Sites you can have live at once, **per team** | 5 | 50 |
| Custom domain | — | ✓ |
**Republishing an existing site isn't rate-limited.** Push changes live as often as you like — the limits below apply to publishing *new* sites, not to updating one you've already published. (Abuse screening still runs on every publish, including republishes — see [My site was flagged or suspended](#troubleshooting).) Until you republish, visitors keep seeing the previous version.
<Note>
**The limit is on sites live at the same time, so unpublishing or deleting a site frees its slot.** On Free you can have five sites live at once; take one down and you can publish another. A site you unpublish keeps its `*.layovelle.com` address reserved, so you can put it back later without losing the address.
</Note>
<Warning>
**A `*.layovelle.com` address you delete is not immediately reusable.** Deleting a site releases its quota slot right away, but holds its address for **30 days** before anyone — including you — can publish to it again. If you want to keep an address, unpublish the site rather than deleting it.
</Warning>
A brand-new workspace can also publish at most **3 new sites in its first 24 hours**. The limit lifts on its own after that.
### Publish settings
The same dialog controls how your site behaves in public:
* **Require a password** — gate the whole site behind a password. Useful for client review before launch.
* **Visitor comments** — let people leave comments directly on the published site.
* **Allow search engine indexing** — whether search engines may list the site. Turn this off for staging or private work.
* **Allow AI crawlers** — whether AI crawlers may read the site, separately from search engines.
* **Copy for LLMs** — offer visitors a clean, plain-text version of the page suitable for pasting into an AI tool.
* **Public listing** — whether the site can appear in Layovelle's public gallery.
* **Show Layovelle hub** — whether the small Layovelle badge appears on the site.
## Custom domains
On a paid plan you can serve the site from a domain you own — `www.yoursite.com` instead of `something.layovelle.com`. It's two DNS records and a verification step.
See [Custom domains](/help/websites/custom-domains) for the full walkthrough, including root domains like `yoursite.com` and the one mistake that causes most failed verifications.
## Export
You can download a site as a ZIP of static files from the editor's export menu — useful if you want to host it elsewhere or hand it to a developer. You can also export a site to PDF.
## Troubleshooting
<AccordionGroup>
<Accordion title="I made changes but the live site looks the same">
Changes save automatically in the editor, but the published site only updates when you **republish**. Open the Publish dialog — if there's unpublished work, it will say so — and publish again.
</Accordion>
<Accordion title="My site was flagged or suspended">
**Published sites are automatically screened for abuse, on every plan.** Paying doesn't exempt a site. Screening most often catches phishing-like patterns — a login form imitating another company's brand being the classic case.
Being flagged doesn't always take a site down: a flagged site can go live pending review rather than being blocked outright.
If you believe your site was flagged or suspended in error, email [admin@layovelle.com](mailto:admin@layovelle.com) with the site address and we'll review it.
</Accordion>
<Accordion title="I hit a publishing limit">
Three limits exist, and none is about how often you update a site you've already published:
* **Sites live at once, per team** — 5 on Free, 50 on paid. The one you're most likely to meet. **Unpublishing or deleting a site frees its slot**, so taking down a site you no longer need lets you publish another. Email [admin@layovelle.com](mailto:admin@layovelle.com) if you need a higher cap.
* **New-account velocity, per workspace** — a workspace less than 24 hours old can publish 3 new sites.
* **New sites per hour, per workspace** — 10 on Free, 30 on paid. Hard to reach, since the per-team site cap binds first on Free.
None of the three applies to republishing a site you've already published. Abuse screening does still run on every publish, so a republish can be flagged on its content even though it isn't rate-limited.
</Accordion>
<Accordion title="Can I edit the HTML directly?">
The agent is the intended way to change a site, and it handles the code for you. If you need full control over the markup, export the site as a ZIP and take it from there.
</Accordion>
</AccordionGroup>
## Next steps
<Columns>
<Card title="Custom domains" icon="globe" href="/docs/help/websites/custom-domains">
Point your own domain at a published site.
</Card>
<Card title="Brand kit" icon="palette" href="/docs/help/brand-kit/setup">
Give the agent your colors, fonts, and logo first.
</Card>
</Columns>
# Files, teams, and privacy
Source: /docs/help/workspace/files-teams-and-privacy
Where your designs save (My Files vs. Team Files), how team visibility works, and how to control domain auto-join.
Your **workspace** is your organization in Layovelle. Teammates share it, and by default new designs are saved where the whole team can see them. This page explains where files land, how to change that, and how people join your workspace.
## Where new designs are saved
Every workspace has two places a design can live:
* **My Files** — your own space. Designs you create land here only if you choose it as the destination.
* **Team Files** — the shared team space. This is the **default** for new designs.
<Note>
New designs go to **Team Files** unless you choose otherwise. If you expected a design to be out of sight and a teammate can see it, it was likely saved to Team Files — move it to My Files (see below) to make it **private**.
</Note>
### Location vs. who can see it
These are two separate things:
* **Location** is *where* a design lives — **My Files** or **Team Files**.
* **Visibility** is *who* can see it — **Restricted** (only you), **Shared**, or **Team**.
My Files is your own bucket, but it holds both **Restricted** (only-you) designs and **Shared** ones. If you share a design with specific people, it stays in your My Files while remaining visible to the people you shared it with. In other words, "it's in My Files" does **not** guarantee "only I can see it."
To make sure a design is visible only to you:
1. Open the design and click the **Share** control.
2. Remove anyone with access
3. Ensure **General access** is set to **Restricted**.
When a design is Restricted, the Share control shows *"Only you can see this."* Sharing it with people changes its visibility to **shared** (the Share control then shows *"Shared with specific people"*), and it stays in My Files.
### The save-location control
When you create a design from the homepage prompt box, there's a **save-location control** next to the prompt that shows where the result will land. It displays the current destination — **My Files**, **Team Files**, or a specific folder name — with a matching icon. Hover it and the tooltip reads *"Generations will be saved to …"*.
To change it:
1. Click the save-location control.
2. In the **Save location** dialog, switch between **My Files** and **Team Files**.
3. Optionally pick a folder within that bucket.
4. Click **Save here**.
Layovelle remembers this choice for next time. It does not sync across devices or browsers, and each team has its own remembered destination. If you switch to another device, another browser, or a different team, the destination falls back to that team's default (Team Files, or a workspace default if your admin set one), so check the save-location control before creating.
### Moving a design after it's created
You can move a design between My Files and Team Files at any time from the **Files** view — drag it onto the **My Files** or **Team Files** section, or use its move option. Moving a design from My Files to Team Files makes it visible to the whole team; Layovelle shows a confirmation before it becomes team-visible.
## Setting a default save location for the whole workspace
On the **Ultra** and **Enterprise** plans, a workspace admin can set where new designs land for everyone:
1. Open **Settings → Workspace → General**.
2. Find **Default save location** and click the current value.
3. Choose **My Files** or **Team Files** (and optionally a folder), then **Set default**.
Anyone can still override this per design using the save-location control described above.
## Teams and privacy
* A **workspace** contains your team and everyone's files.
* **Team Files** are visible to all members of the team.
* **My Files** is your own bucket; designs in it are **Restricted** (only you) unless you share them, in which case they become **Shared** with the specific people you choose while staying in My Files.
Members are managed in **Settings → Workspace → General**, where an admin can invite teammates by email.
## Domain auto-join
If your workspace has a **claimed email domain**, people who sign up with a work email at that domain can **automatically join** your workspace instead of creating their own.
### How a domain gets claimed
You don't claim a domain from a settings page — it happens automatically at signup. When the first person signs up with a **work email** (a business domain, not a personal provider like Gmail or Outlook), Layovelle creates a workspace for that domain, marks the domain as claimed, and turns Domain auto-join **on** by default. While the setting stays on, eligible same-domain sign-ups then join that workspace automatically — subject to the workspace's member limit and standard account-verification safeguards (for example, a user removed from the workspace earlier won't be re-added).
Because of this, the Domain auto-join setting only appears for workspaces that were created from a work-email domain. Workspaces started from a personal email address don't have a claimable domain, so they don't show this setting.
### Turning it on or off
1. Open **Settings → Workspace → General**.
2. Find the **Domain auto-join** setting. It reads *"Users with @yourdomain.com emails auto-join this workspace."*
3. Toggle it off to stop new same-domain users from joining automatically, or on to allow it.
<Note>
Only a workspace **owner or admin** can change the Domain auto-join setting, and it only appears when your workspace has a claimed domain.
</Note>
# Agent Skill
Source: /docs/mcp/agent-skill
A drop-in skill that teaches Claude, Cursor, and other AI agents how to drive the Layovelle MCP server effectively — without you re-explaining the rules in every conversation.
<Warning>
**Legacy.** This section documents the original Layovelle MCP server at `mcp.moda.app/mcp`, built around
`start_design_task` and `get_moda_canvas`. It is superseded by [Layovelle for Agents](/agents) — the `moda` CLI wherever
your agent has a shell, and the [Layovelle connector](/agents/mcp-connector) for claude.ai and other chat hosts. The
connector replaces this server, so the tools below retire with it. These pages stay published for existing
integrations; start new ones on [Layovelle for Agents](/agents).
</Warning>
The Layovelle **agent skill** is a small Markdown package — one `SKILL.md` plus a gotchas reference and six task recipes — that you install into an AI agent. It teaches the agent how Layovelle's MCP tools work, which one to call for a given user request, and the non-obvious behaviors it should respect (concurrency caps for bulk fan-out, brand-kit resolution order, conversation vs. canvas vs. template semantics, etc.).
With the skill installed, the agent gets:
* A mandatory first-call **`get_moda_bootstrap`** handshake that loads workspace context and skips downstream lookups when defaults are unambiguous.
* A consolidated **gotchas reference** covering the silent failures the tool signatures can't communicate.
* **6 recipes** for the canonical design-task patterns (create / edit / find-a-template / fill-template / rebrand-template / bulk).
* Clear guidance on **`find_brand_kits` vs `list_brand_kits`** so brand-kit lookups don't take over the screen.
The skill works with any MCP-capable agent. Pick the install path that matches how you use Layovelle:
## Install
<Tabs>
<Tab title="Claude Code (plugin)">
Bundles the MCP server **and** the skill in one command:
```bash theme={null}
claude /plugin marketplace add moda-design/skills
```
Then enable the Layovelle plugin when prompted. The skill becomes active in any conversation that touches Layovelle.
To update later:
```bash theme={null}
claude /plugin marketplace update moda-design/skills
```
</Tab>
<Tab title="Vercel skills CLI">
[Vercel's `skills` CLI](https://github.com/vercel-labs/skills) installs skills into Claude Code, Cursor, VS Code, or any other compatible client without touching MCP server config:
```bash theme={null}
npx skills add github.com/moda-design/skills
```
This is the right path if you already have the Layovelle MCP server connected (manually or via OAuth Custom Connector) and just want to add the skill on top.
</Tab>
<Tab title="claude.ai / Claude Desktop (zip upload)">
For users on claude.ai web or Claude Desktop who aren't running a terminal — download the skill zip and upload it:
1. Download the latest `moda-mcp` skill zip:
[`github.com/moda-design/skills/releases/latest/download/moda-mcp.zip`](https://github.com/moda-design/skills/releases/latest/download/moda-mcp.zip)
2. Open claude.ai (or Claude Desktop) → **Settings** → **Capabilities** → **Skills** → **Upload skill**.
3. Select the downloaded `moda-mcp.zip`.
4. In any chat where you want the skill active, enable it under the **+ Skills** menu in the message composer.
<Note>
Upload the per-skill `moda-mcp.zip`, **not** the bundled `skills.zip`. claude.ai's uploader
requires exactly one skill per zip; `skills.zip` contains both `moda-mcp` and `moda-api` and
will be rejected.
</Note>
The MCP server itself is added separately via the **Layovelle** Custom Connector — see [Setup](/mcp/setup) for that flow.
</Tab>
<Tab title="Manual include">
For raw system prompts, the [Claude Agent SDK](https://docs.claude.com/en/docs/agents-and-tools/agent-sdk/overview), or any other context where you control the prompt:
1. Clone or download [`github.com/moda-design/skills`](https://github.com/moda-design/skills).
2. Include `skills/moda-mcp/SKILL.md` in your system prompt. The agent will autoload referenced files (`references/*.md`, `recipes/*.md`) as needed during the conversation.
The agent SDK supports skill packages natively — see [the Agent SDK skills docs](https://docs.claude.com/en/docs/agents-and-tools/agent-sdk/skills) for the canonical wiring.
</Tab>
</Tabs>
## What the agent learns
A short tour of the skill files, in order of how the agent uses them:
* **`SKILL.md`** — orientation page. The "call `get_moda_bootstrap` first" handshake, a 13-bullet "what goes silently wrong" sheet, a recipe map, and pointers to references.
* **`references/gotchas.md`** — single-page consolidated reference for non-obvious behaviors: the `format_category` default (an omitted category resolves to a generic `other` canvas at 1080×1080), the `wait`-default asymmetry between `start_design_task` and `remix_design`, the `canvas_id` / `template_canvas_id` / `conversation_id` mutual-exclusion matrix, brand-kit resolution order, attachment role semantics, the `not_ready` export retry state, and more.
* **`recipes/create-new-design.md`**, **`edit-existing-canvas.md`**, **`fill-template.md`**, **`rebrand-template.md`** — one short page per canonical design-task pattern. Mirrors the patterns documented at [Design Task Recipes](/api-reference/design-task-recipes), but framed for an AI agent making tool-call decisions.
* **`recipes/find-a-template.md`** — how to discover the team's curated templates and themes (`list_my_canvases` vs `search_canvases`, the `template_type` filter, what an empty result means), and the "be aware of a template's author-pinned instructions, don't restate them, don't contradict them" contract that governs the prompt written against a template.
* **`recipes/bulk-variants.md`** — fan-out patterns for "make N variations of this": windowed launch respecting the plan's `concurrency_cap` (from `get_moda_bootstrap`), `list_tasks(status="running")` polling, deliver-as-queued UX.
## Stay updated
The skill ships from the [`moda-design/skills`](https://github.com/moda-design/skills) GitHub repo. Watch the **Releases** page for changelogs.
* Released versions: [github.com/moda-design/skills/releases](https://github.com/moda-design/skills/releases)
* Latest `moda-mcp` skill zip (stable URL): [github.com/moda-design/skills/releases/latest/download/moda-mcp.zip](https://github.com/moda-design/skills/releases/latest/download/moda-mcp.zip)
Each release also attaches `moda-api.zip` (the REST-API skill) and `skills.zip` (both skills bundled, for tooling that wants everything in one download).
When a new version ships, `claude /plugin marketplace update`, `npx skills add` (re-run), or a re-upload of the zip all pull the latest.
## See also
* [MCP Tools Reference](/mcp/tools) — full tool catalog with parameters
* [Design Task Recipes](/api-reference/design-task-recipes) — the canonical patterns the skill recipes are based on (REST + MCP side-by-side)
* [Setup](/mcp/setup) — install paths for the MCP server itself
# Authentication
Source: /docs/mcp/authentication
How the Layovelle MCP server authenticates users via OAuth 2.1 and Clerk.
<Warning>
**Legacy.** This section documents the original Layovelle MCP server at `mcp.moda.app/mcp`, built around
`start_design_task` and `get_moda_canvas`. It is superseded by [Layovelle for Agents](/agents) — the `moda` CLI wherever
your agent has a shell, and the [Layovelle connector](/agents/mcp-connector) for claude.ai and other chat hosts. The
connector replaces this server, so the tools below retire with it. These pages stay published for existing
integrations; start new ones on [Layovelle for Agents](/agents).
</Warning>
The hosted MCP server at `mcp.moda.app/mcp` supports **two authentication methods**. Pick whichever matches how you'll be invoking it.
## Which method should I use?
### OAuth 2.1 — pick this when a human is driving
**Use cases**
* **Claude Desktop / claude.ai**: everyday design work by you or your team.
* **Cursor / VS Code**: a developer translating Layovelle designs to code, or generating UI.
* **Claude Code on your laptop**: ad-hoc canvas queries from the terminal.
* **Claude mobile**: carry-over of your desktop connector.
**How it works.** On first use, your client pops a browser, you sign in to Layovelle (via Clerk), and tokens get stored by the client. Every subsequent tool call is authenticated silently — you won't see the sign-in again unless your session expires or you revoke access. Individual team members see their own canvases, orgs, and credits.
**What you get.** Per-user identity. Every tool call runs as *you*; `list_my_canvases` returns the canvases you personally have access to, design tasks bill against your team's credits, and audit logs attribute actions to your user.
### API key — pick this when there's no consent screen to click
**Use cases**
* **Hosts whose connector flow is credential-first.** Some chat hosts ask for a key rather than
offering a sign-in, even with a person present — Meta's Muse takes an API key for a custom
connector. A human in the loop does not mean OAuth is on offer; follow whatever the host asks for.
Set those hosts up against the [Layovelle connector](/agents/mcp-connector), not this server — the
client-configuration examples below all point at the legacy endpoint.
* **Scheduled content generation.** Daily social post at 9am? Weekly carousel? There's no human to click a consent screen — the job needs a durable credential.
* **Claude Managed Agents.** Anthropic's platform-managed agents can run on a cron. They need a bearer token, not an OAuth flow.
* **CI/CD pipelines.** Regenerating branded assets on merge to main, or running design smoke tests in GitHub Actions.
* **Server-side integrations.** Your backend code calling the MCP directly (rather than wrapping the REST API) — a bot that drafts design tasks from a Slack command, or a webhook handler that kicks off tasks.
* **Internal tools your team uses across many sessions.** Some teams prefer a stable, rotatable credential per integration instead of managing individual OAuth sessions per teammate.
**How it works.** Generate a key in **Settings → Developer → REST API**, then configure your MCP client to send `Authorization: Bearer moda_live_...`. The key ties every tool call to the team it was created under.
**What you get.** Team-level identity. Every call runs as the key's owner + team.
### Rules of thumb
| Situation | Use |
| - | - |
| "A person is clicking something and will see the result in chat" | **OAuth** — unless the host only offers a key field, then **API key** |
| "The host's connector setup asks for a key, not a sign-in" | **API key**, against the [connector](/agents/mcp-connector) — Meta's Muse works this way |
| "A scheduled job runs at 3am" | **API key** |
| "Different teammates each connect their own editor" | **OAuth** (each person signs in as themselves) |
| "One service account drives a pipeline" | **API key** |
| "I need to rotate credentials regularly" | **API key** (revoke + regenerate in Settings; no end-user impact) |
| "I need the simplest possible setup" | **OAuth** (no key management) |
### Mixing both
The server accepts both methods simultaneously — you can have your own Cursor connected via OAuth, and a Claude Managed Agent connected with an API key, against the same Layovelle account. They don't conflict; pick the right auth for each caller.
## When is authentication required?
| Action | Auth required |
| - | - |
| Fetch a **public** share link (`layovelle.com/s/...`) | No |
| Fetch a **private** canvas (`layovelle.com/canvas/...`) | Yes |
| List your canvases | Yes |
| Search your canvases | Yes |
Public share links work without authentication in both local and remote server modes. Private canvases and canvas listing/searching require the remote server with either OAuth or an API key.
## Setting up API-key auth
### 1. Generate a key
In your Layovelle account, go to **Settings → Developer → REST API → Create API key**.
Creating a key requires a paid plan — a free workspace is asked to upgrade under **Settings → Billing** first. Existing keys keep working either way.
Copy the `moda_live_...` key — **it's shown once**. Store it in a secret manager.
### 2. Configure your client
<Tabs>
<Tab title="Claude Code">
```bash theme={null}
claude mcp add moda https://mcp.moda.app/mcp \
--transport http \
--scope user \
--header "Authorization: Bearer moda_live_..."
```
The URL must come immediately after the server name; `--header` uses an HTTP-style `Name: value` format with a colon, not `=`.
`--scope user` keeps the key in your per-user Claude config rather than a repo-level file that could get committed.
</Tab>
<Tab title="Cursor">
Edit `~/.cursor/mcp.json` (or project-level `.cursor/mcp.json`):
```json theme={null}
{
"mcpServers": {
"moda": {
"type": "streamable-http",
"url": "https://mcp.moda.app/mcp",
"headers": {
"Authorization": "Bearer moda_live_..."
}
}
}
}
```
</Tab>
<Tab title="VS Code">
In user or workspace `settings.json`:
```json theme={null}
{
"mcp": {
"servers": {
"moda": {
"type": "streamable-http",
"url": "https://mcp.moda.app/mcp",
"headers": {
"Authorization": "Bearer moda_live_..."
}
}
}
}
}
```
</Tab>
<Tab title="curl / scripts">
Any HTTP client can connect directly — useful for CI, smoke tests, or custom integrations:
```bash theme={null}
curl -X POST https://mcp.moda.app/mcp \
-H "Authorization: Bearer $MODA_API_KEY" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
</Tab>
</Tabs>
<Info>
**Claude Desktop and claude.ai don't support custom headers** in their connector UI today — they assume OAuth for
remote MCPs, so use OAuth there. Cursor, VS Code and Claude Code support both: they can sign in over OAuth, and they
accept a header, so a key is simply the simpler choice for unattended or shared-credential use. CI and cron have no
browser step and need a key. Some chat hosts ask for a key even with a person present — Meta's Muse does. Match what
the host offers, not whether someone is watching.
</Info>
### 3. Identity
Every tool call authenticated with an API key runs as the **key's owner + team**. `list_my_canvases` returns the key-owner's canvases; `start_design_task` bills their team's credits. Session context (`set_context`) persists per-user across calls, same as OAuth — different keys mean different owners, so multi-tenant reuse is safe.
### 4. Rotating or revoking a key
Go to **Settings → Developer → REST API**, revoke the key, and generate a new one. Update the bearer token in your MCP client config. No coordination with end-users needed — API keys are meant to rotate.
## How the OAuth flow works
When you first use the MCP server, your editor initiates the OAuth flow:
1. Your editor sends a request to the MCP server
2. The server responds with `401 Unauthorized`
3. Your editor discovers the OAuth endpoints automatically
4. A browser window opens for you to sign in via Layovelle (powered by Clerk)
5. After sign-in, tokens are exchanged and stored by your editor
6. All subsequent requests are authenticated automatically
If you're already signed in to [layovelle.com](https://layovelle.com) in your browser, the sign-in step is instant — your existing session is detected automatically.
## Token lifecycle
| Token | Lifetime | Notes |
| - | - | - |
| Access token | 24 hours | Refreshed automatically by your editor |
| Refresh token | 30 days | Rotated on each use for security |
Your editor manages token refresh transparently. You should rarely need to re-authenticate unless you revoke access or your refresh token expires.
## Local server authentication
The local `stdio` server does not use authentication. It can only access **public** share links. To access private canvases, use the remote server at `mcp.moda.app`.
## Revoking access
**OAuth sessions** — remove the connector in the client:
* **Claude Desktop / claude.ai**: **Settings > Connectors** → disconnect or remove the Layovelle connector.
* **Claude Mobile**: Disconnect the Layovelle connector from [claude.ai/settings](https://claude.ai/settings), then restart the mobile app.
* **Claude Code**: `claude mcp remove moda`.
* **Cursor**: Remove the server from Cursor Settings > MCP.
* **VS Code**: Remove the Layovelle entry from your MCP `settings.json`.
This removes the stored tokens locally. You'll re-authenticate on next connect.
**API keys** — revoke the key itself at **Settings → Developer → REST API**. Every client using that key loses access immediately across all sessions and devices. Generate a fresh key and update your client config to continue.
## Security
* OAuth 2.1 with PKCE (Proof Key for Code Exchange) prevents authorization code interception.
* OAuth access tokens are short-lived JWTs (24 hours); refresh tokens are rotated on each use.
* API keys are hashed at rest; only the `moda_live_` prefix is logged for debugging. Lose a key? Revoke it in **Settings → Developer**.
* A compromised or prompt-injected MCP call can't use the key to rotate webhooks or read raw credit balances — MCP-originated requests are restricted to a safe subset of operations regardless of what the key itself allows at the REST layer.
* All communication with `mcp.moda.app` uses TLS.
* The MCP server never stores your Layovelle password — OAuth authentication is delegated to Clerk.
# Creating Designs with Claude
Source: /docs/mcp/create-designs
Use Claude to create slide decks, social posts, marketing visuals, and more in Layovelle — directly from a conversation.
<Warning>
**Legacy.** This section documents the original Layovelle MCP server at `mcp.moda.app/mcp`, built around
`start_design_task` and `get_moda_canvas`. It is superseded by [Layovelle for Agents](/agents) — the `moda` CLI wherever
your agent has a shell, and the [Layovelle connector](/agents/mcp-connector) for claude.ai and other chat hosts. The
connector replaces this server, so the tools below retire with it. These pages stay published for existing
integrations; start new ones on [Layovelle for Agents](/agents).
</Warning>
The most popular way to use Layovelle's MCP server is to **create designs by chatting with Claude**. Instead of opening a design tool and building layouts by hand, you describe what you want and Claude creates it for you in Layovelle.
This works from any Claude environment — the desktop app, the browser at claude.ai, Claude Code in your terminal, or even Claude on mobile.
Claude is the primary workflow covered here because it's the most common for Layovelle design creation. The same MCP tools can also be used from Cursor and other MCP-capable clients, but the exact UX depends on the host app.
## How it works
1. You ask Claude to create a design (a slide deck, social post, ad, report, etc.)
2. Claude calls the `start_design_task` tool on the Layovelle MCP server
3. The tool returns immediately with a task handle and a canvas URL — your design starts building right away
4. On claude.ai web and Claude Desktop, an embedded progress view shows live updates and rendered pages as the agent works. On other clients, Claude (or any MCP host) polls `get_task_status(task_id)` to track progress.
5. Once the design is ready, you can open it, edit it, export it, or ask Claude to revise it
Every design Claude creates is fully editable in Layovelle — you're never locked into what the AI produces.
## Quick setup
If you haven't connected Layovelle yet, it takes about 30 seconds. Follow the [setup guide](/mcp/setup) for your environment (Claude Desktop, claude.ai, Claude Code, Claude mobile, Cursor, or VS Code).
Before your first prompt:
1. In Claude, click the **+** button in the chat box.
2. Open **Connectors**.
3. Enable **Layovelle** for the current conversation.
If you belong to multiple Layovelle organizations or teams, ask Claude to switch to the right workspace first:
```
What Layovelle organization and team am I currently using?
```
```
Switch Layovelle to the "Acme Corp" organization and the "Marketing" team.
```
## Example prompts
Here are real prompts you can copy and paste into Claude right now.
Be explicit about the output type in every prompt, especially for non-slide work. Phrases like `PDF report`, `Instagram post`, `diagram`, `flowchart`, or `presentation slides` help Layovelle choose the right format.
### Slide decks and presentations
```
Create a 10-slide pitch deck for a B2B SaaS startup called "Relay" that does
automated customer onboarding. Include a title slide, problem, solution,
how it works, market size, business model, traction, team, and a closing CTA.
```
```
Turn these meeting notes into a clean presentation:
[paste your notes here]
```
```
Create a quarterly business review deck with sections for revenue,
customer growth, product updates, and next quarter goals. Use a
professional dark theme.
```
### Social media posts
```
Create 5 Instagram carousel slides announcing our new product feature.
The feature is [describe feature]. Use bold typography and a modern layout.
```
```
Design a LinkedIn post image announcing that we just raised a Series A.
Keep it clean and professional.
```
```
Create a set of 4 social media ads for a summer sale — 20% off everything.
Make versions for Instagram (1080x1080) and Instagram Stories (1080x1920).
```
### Marketing and ads
```
Create a one-page product flyer for our new wireless headphones.
Key features: 40hr battery, active noise cancellation, $149 price point.
Use a clean white background with product-focused layout.
```
```
Design a banner ad (1200x628) for a webinar called "The Future of AI in Design"
happening on May 15th. Include a register CTA.
```
### Reports and documents
```
Create a project status report as a PDF. The project is "Website Redesign",
it's 65% complete, on track for the June deadline. Include sections for
accomplishments, risks, and next steps.
```
```
Create a one-page PDF resume for a senior product designer with 8 years of
experience. Use a modern, minimal layout.
```
### Diagrams and visuals
```
Create a flowchart showing our user onboarding process:
Sign up → Verify email → Choose plan → Connect integrations → Dashboard
```
```
Create an organizational chart diagram for a 20-person startup with
Engineering, Design, Product, and Operations teams.
```
## Using your brand
Before each new design, Claude asks which brand kit to use: one of your kits, a new one it creates from your website, or none. It asks even when you have only one kit, and it waits for your answer before creating anything. Name a kit in your request ("using the Acme brand kit") to skip the question. Your brand colors, fonts, logos, and style guidelines are then used without you having to mention them. To use the same kit every time in a Claude Project, add a line like `Always use the Layovelle brand kit "Acme"` to the project's instructions.
To set up a brand kit from Claude:
```
Create a brand kit from our website: https://yourcompany.com
```
Layovelle will scrape your site and extract colors, fonts, logos, and brand guidelines. The first brand kit created for a team becomes the default automatically. If your team already has a default brand kit, you can ask Claude to use the new one explicitly:
```
Create a slide deck using the "Acme Corp" brand kit.
```
You can also ask Claude to check what brand assets are available:
```
What brand kits do we have set up? Show me the colors and fonts.
```
## Iterating on designs
Claude remembers the context of your conversation, so you can refine designs with follow-up messages:
```
Make the headline bigger and change the background to dark blue.
```
```
Add a third slide with customer testimonials.
```
```
Swap the layout to put the image on the left and text on the right.
```
Each follow-up modifies the same canvas — Claude tracks the conversation automatically. You don't need to start over.
## Attaching references
You can give Claude reference images, PDFs, or existing Layovelle designs to work from:
```
Here's a screenshot of a landing page I like: [paste image]
Create something similar but with our branding.
```
```
Use this PDF as the content source and turn it into a slide deck:
[paste or upload PDF]
```
```
Look at my existing canvas "Q1 Marketing Deck" and create a Q2 version
with updated numbers.
```
## Specifying format and dimensions
For best results, tell Claude what kind of design you want:
| What you're making | Suggested prompt addition |
| - | - |
| Slide deck / presentation | "Create slides" or "Create a presentation" |
| Social media post | "Create an Instagram post" (or specify platform) |
| PDF / report / document | "Create a PDF report" or "Create a one-pager" |
| Diagram / flowchart | "Create a diagram" or "Create a flowchart" |
| UI mockup | "Create a UI mockup" or "Design a screen" |
You can also specify exact dimensions:
```
Create a social media post at 1080x1080 pixels.
```
```
Create presentation slides at 1920x1080.
```
## Exporting your designs
Once Claude creates a design, you can export it directly from the conversation. Under the hood, the MCP flow is:
1. `start_design_task(...)` — returns immediately with `{task_id, canvas_id, canvas_url, status: 'queued'}`
2. `get_task_status(task_id)` until `can_export == true` or the task reaches a terminal failure state
3. `export_canvas(canvas_id=..., format=...)`
On claude.ai web and Claude Desktop, the live-progress iframe handles step 2 visually so neither you nor Claude has to wait for the tool to finish before seeing pages render. On hosts without iframe support, Claude polls `get_task_status` itself. If export is attempted too early, Layovelle MCP returns a structured `not_ready` response with a retry interval instead of a tool error.
Then Claude can export from the conversation:
```
Export that as a PDF.
```
```
Export the slide deck as a PowerPoint file.
```
```
Export page 1 as a PNG.
```
Supported formats: PNG, JPEG, PDF, PPTX, MP4, and GIF (MP4 and GIF render one page's animation timeline).
## Tips for great results
* **Be specific about content.** The more detail you give Claude about text, structure, and layout, the better the output. Vague prompts produce generic designs.
* **Mention the format.** Saying "create a slide deck" vs. "create a social post" helps Layovelle choose the right canvas dimensions and layout style.
* **Provide real content.** Instead of "add some text about our product," paste the actual copy you want on the design.
* **Iterate in conversation.** Don't try to get everything perfect in one prompt. Start with the structure, then refine colors, layout, and content in follow-ups.
* **Use brand kits.** Setting up a brand kit once means every design automatically uses your colors, fonts, and logos.
* **Attach references.** If you have a design you like, share it as a reference image. Claude can match the style while using your content.
## Usage limits
Your organization has a cap on how many design tasks can run at once. `start_design_task` and `remix_design` calls from the MCP server share this cap with your REST API usage.
| Plan | Max concurrent tasks |
| - | - |
| `free` | 3 |
| `free_beta` | 3 |
| `paid` | 10 |
| `ultra` | 15 |
When you hit the cap, the tool returns an error like `"Rate limit exceeded: 10/10 concurrent tasks active."` Wait for an existing task to finish (poll via `get_task_status`) and retry. See [Usage Limits](/api-reference/usage-limits) for the full picture, including how to [contact support](mailto:admin@layovelle.com) if you need a higher cap.
## What's next
* [Setup Guide](/mcp/setup) — Detailed setup for all editors
* [Agent Skill](/mcp/agent-skill) — Drop our skill into your AI agent so it picks the right pattern without you re-explaining
* [Design Task Recipes](/api-reference/design-task-recipes) — The four canonical `start_design_task` patterns (create / edit / fill template / rebrand template) with side-by-side REST + MCP examples
* [Tools Reference](/mcp/tools) — Full reference for `start_design_task`, `remix_design`, and all other tools
* [Design-to-Code Guide](/mcp/design-to-code) — The reverse workflow: turning Layovelle designs into code
# Design-to-Code Workflow
Source: /docs/mcp/design-to-code
Best practices for turning Layovelle designs into production-ready code using AI agents.
<Warning>
**Legacy.** This section documents the original Layovelle MCP server at `mcp.moda.app/mcp`, built around
`start_design_task` and `get_moda_canvas`. It is superseded by [Layovelle for Agents](/agents) — the `moda` CLI wherever
your agent has a shell, and the [Layovelle connector](/agents/mcp-connector) for claude.ai and other chat hosts. The
connector replaces this server, so the tools below retire with it. These pages stay published for existing
integrations; start new ones on [Layovelle for Agents](/agents).
</Warning>
This guide covers the recommended workflow for converting Layovelle designs to production code using the MCP server and an AI coding agent.
## The basic workflow
<Steps>
<Step title="Design in Layovelle" icon="pen-ruler">
Create your UI design on the Layovelle canvas.
</Step>
<Step title="Share" icon="link">
Generate a public share link (or use the private canvas URL with auth).
</Step>
<Step title="Prompt" icon="keyboard">
Paste the link in your editor and tell the agent what to build.
</Step>
<Step title="Review" icon="code">
Check the generated code and iterate.
</Step>
</Steps>
## Writing effective prompts
The quality of generated code depends heavily on your prompt. Be specific about:
### Framework and language
```
Build this as a React component using TypeScript and Tailwind CSS:
https://layovelle.com/s/abc123
```
### Component structure
```
Implement this as three separate components:
- LoginForm (the card with inputs)
- SocialLoginButtons (the OAuth buttons)
- LoginPage (the full page layout)
Design: https://layovelle.com/s/abc123
```
### Responsive behavior
```
Build this hero section as a responsive React component.
Stack vertically on mobile, side-by-side on desktop.
https://layovelle.com/s/abc123
```
## Multi-page designs
For slide decks or multi-page canvases, the agent can work through pages sequentially:
```
This is a 4-page marketing site design. Implement each page as a
separate Next.js route. Use shared components where the design
repeats elements across pages.
https://layovelle.com/s/abc123
```
The agent will call `list_moda_canvas_pages` to discover the pages, then fetch each one with `get_moda_canvas`.
## Generating theme files
If you already have a component library and just need design tokens:
```
Extract the design tokens from this canvas and create a
Tailwind theme configuration file:
https://layovelle.com/s/abc123
```
The agent will use `get_moda_canvas_tokens` to extract colors, fonts, and spacing.
## Tips for better results
### Name your layers
The MCP server's transformer uses layer names to determine semantic meaning. A layer named `cta-button` produces a `Button` tag; an unnamed rectangle might be interpreted as a `Box`.
See [Naming Layers](/mcp/naming-layers) for the full keyword reference.
### Use design variables
Colors and values defined as Layovelle variables appear in the design tokens output with their names, making it easier for the agent to create meaningful CSS custom properties or theme tokens.
### Keep designs clean
* Remove hidden layers you don't need — they won't appear in the output, but keeping your canvas tidy helps
* Use Layovelle's auto-layout (flex) features for consistent spacing — the transformer detects flex layouts and outputs them as `display: flex` with `gap`
* Group related elements — groups become semantic containers in the output
### Use exports for complex layouts
When the pseudo-HTML alone doesn't capture the visual intent, the agent can export a visual reference with `export_canvas` (use `png` format for design-to-code). This is especially useful for:
* Overlapping elements
* Complex gradients
* Precise visual spacing that the structured data doesn't capture
## Common patterns
### Design system from canvas
```
Create a design system (colors, typography, spacing) from this
Layovelle canvas. Output as CSS custom properties.
https://layovelle.com/s/abc123
```
### Pixel-perfect implementation
```
Implement this design pixel-perfect as a React component.
Use exact colors, fonts, and spacing from the design.
https://layovelle.com/s/abc123
```
### Component library
```
This canvas contains a component library. Generate a Storybook
story for each component on the page.
https://layovelle.com/s/abc123
```
# Getting Started with Layovelle MCP
Source: /docs/mcp/getting-started
Connect Layovelle to Claude and start creating designs from a conversation in minutes.
<Warning>
**Legacy.** This section documents the original Layovelle MCP server at `mcp.moda.app/mcp`, built around
`start_design_task` and `get_moda_canvas`. It is superseded by [Layovelle for Agents](/agents) — the `moda` CLI wherever
your agent has a shell, and the [Layovelle connector](/agents/mcp-connector) for claude.ai and other chat hosts. The
connector replaces this server, so the tools below retire with it. These pages stay published for existing
integrations; start new ones on [Layovelle for Agents](/agents).
</Warning>
Layovelle is a 2D vector canvas tool for creating slide decks, social media posts, ads, and other visual content.
The Layovelle MCP server connects Layovelle to Claude and other AI agents through the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/). Once connected, you can **create designs just by chatting** — describe what you want and Claude builds it for you in Layovelle.
This works from anywhere you use Claude: the desktop app, claude.ai in your browser, Claude Code in the terminal, Claude on mobile, Cursor, VS Code, and other MCP-compatible environments.
This guide uses Claude examples because it's the most common setup for Layovelle. Cursor and other MCP-capable clients can call the same Layovelle tools too, and OpenAI also supports MCP through its app and API surfaces.
For example:
* Ask Claude to **create a pitch deck** from your meeting notes.
* Generate **dozens of social media ads** for a campaign in one conversation.
* Turn a product brief into a **branded one-pager** without opening a design tool.
* Have Claude **implement a Layovelle design as code** in React, Vue, or HTML/CSS.
Once connected, Claude can treat Layovelle as both **a creative output surface and a design system**.
## What you can do
<Columns>
<Card title="Create designs from a conversation" icon="comments" href="/docs/mcp/create-designs">
Ask Claude to generate slide decks, social posts, ads, reports, and more. Claude creates a fully editable Layovelle canvas and returns a link.
</Card>
<Card title="Iterate with follow-ups" icon="arrows-rotate">
Refine any design by continuing the conversation. "Make the headline bigger," "add a testimonial slide," "switch to dark mode."
</Card>
<Card title="Use your brand automatically" icon="palette">
Set up a brand kit once (Claude can do this from your website URL) and every design uses your colors, fonts, and logos.
</Card>
<Card title="Design to code" icon="code" href="/docs/mcp/design-to-code">
Paste a Layovelle share link and Claude generates framework-specific code (React, Vue, HTML/CSS, and more).
</Card>
<Card title="Extract design tokens" icon="eye-dropper">
Pull colors, fonts, spacing, and corner radii from any canvas to build consistent theme files.
</Card>
<Card title="Browse and search" icon="magnifying-glass">
List your canvases or search by name directly from your conversation without switching to the browser.
</Card>
<Card title="Install the agent skill" icon="sparkles" href="/docs/mcp/agent-skill">
Drop our skill into Claude, Cursor, or the Agent SDK and your agent learns the right way to drive Layovelle — without you re-explaining the rules.
</Card>
</Columns>
## How it works
Layovelle uses the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) — a standard for connecting AI agents to external tools and data sources. When you connect Claude to Layovelle's MCP server, Claude gains the ability to create, read, and manage designs on your behalf.
**Design creation** — the most common flow:
```mermaid theme={null}
flowchart LR
A[You describe<br/>what you want] --> B[Claude calls<br/>Layovelle MCP tools]
B --> C[Layovelle's AI agent<br/>builds the canvas]
C --> D[You get a link<br/>to your design]
```
**Design to code** — Claude can work in reverse:
```mermaid theme={null}
flowchart LR
A[You share a<br/>Layovelle design link] --> B[Claude reads<br/>the design]
B --> C[Claude generates<br/>framework-specific code]
```
Because MCP is a standard protocol, Claude can combine Layovelle with other tools in the same conversation — pull content from a knowledge base, look up data from an API, and create a design that uses all of it.
## Two common workflows
### 1. Create designs from a conversation
The most popular workflow. Ask Claude to create visual content and it builds a Layovelle canvas for you.
* Generate a pitch deck from a product brief
* Create dozens of social ads for a campaign
* Turn meeting notes into presentation slides
* Produce branded marketing visuals from structured data
* Design reports, one-pagers, resumes, and flyers
Because Claude can access other tools in the same conversation (knowledge bases, APIs, docs, etc.), it can combine those inputs with Layovelle to generate **brand-aligned visuals automatically**.
Every canvas Claude creates is fully editable in Layovelle. See the [Creating Designs guide](/mcp/create-designs) for detailed prompts and tips.
### 2. Turn designs into production code
Start with a design in Layovelle and ask Claude to implement it in your preferred framework.
* Convert a login page design into a React component
* Generate HTML/CSS from a marketing page layout
* Extract design details (colors, fonts, spacing) into a theme file
* Build a full component library from existing designs
Because Layovelle provides structured design data instead of screenshots, Claude can generate much more accurate UI code. See the [Design-to-Code guide](/mcp/design-to-code) for best practices.
## Quickstart
### Prerequisites
* A [Layovelle](https://layovelle.com) account (free to start)
* A supported MCP-capable host app: [Claude Desktop](https://claude.ai/download), [claude.ai](https://claude.ai), [Claude Code](https://claude.ai/code), [Claude mobile](https://claude.ai/download), [Cursor](https://cursor.com), or [VS Code](https://code.visualstudio.com/) with Copilot
### Connect to the remote server
The hosted MCP server at `mcp.moda.app` requires no local installation. Authentication is handled via OAuth — you sign in through your browser on first use.
<Tabs>
<Tab title="Claude Desktop">
Open Claude Desktop, click **Customize** in the sidebar, then the **+** button > **Add custom connector**. For the **Name**, input `Layovelle`, and use the MCP server URL below and click **Add**:
```
https://mcp.moda.app/mcp
```
Click **Connect** after adding and you'll be prompted to sign into Layovelle and authorize your account.
</Tab>
<Tab title="Claude.ai (browser)">
Go to **Customize** in your sidebar ([claude.ai/customize](https://claude.ai/customize)) and click the **+** button > **Add custom connector**. For the **Name**, input `Layovelle`, and use the MCP server URL below and click **Add**:
```
https://mcp.moda.app/mcp
```
Click **Connect** after adding and you'll be prompted to sign into Layovelle and authorize your account.
**Team and Enterprise plans:** An admin must first add the Layovelle connector in **Admin Settings > Connectors**. After that, individual members can connect from their personal **Customize** sidebar.
</Tab>
<Tab title="Claude Code">
Run in your terminal:
```bash theme={null}
claude mcp add --transport http moda https://mcp.moda.app/mcp
```
Then type `/mcp` to see your connected servers. Click **Authenticate** next to the Layovelle server to sign in via your browser.
</Tab>
<Tab title="Claude Mobile">
Connectors set up on claude.ai sync to the Claude mobile app automatically. Add the Layovelle connector on [claude.ai/settings](https://claude.ai/settings) and it will be available on your phone.
This lets you create and iterate on designs from your phone.
</Tab>
<Tab title="Cursor">
Open **Cursor Settings > MCP** and add a new server, or add to `~/.cursor/mcp.json`:
```json theme={null}
{
"mcpServers": {
"moda": {
"type": "streamable-http",
"url": "https://mcp.moda.app/mcp"
}
}
}
```
After adding the server, go to **Cursor Settings > MCP** and click the **Authenticate** button next to the Layovelle server. This opens a browser window where you sign in to your Layovelle account.
</Tab>
<Tab title="VS Code">
Add to your `settings.json`:
```json theme={null}
{
"mcp": {
"servers": {
"moda": {
"type": "streamable-http",
"url": "https://mcp.moda.app/mcp"
}
}
}
}
```
VS Code will prompt you to authenticate when you first use an MCP tool. Click the sign-in link to authorize in your browser.
</Tab>
</Tabs>
### Enable Layovelle in Claude chats
This step only applies to Claude's chat apps (`Claude Desktop`, `claude.ai`, and `Claude Mobile`). If you're using `Claude Code`, `Cursor`, or `VS Code`, you can skip this section and go straight to trying prompts.
After you add and authenticate the connector, turn it on for the chat where you want to use it.
1. Click the **+** button in the chat box.
2. Open **Connectors**.
3. Enable **Layovelle** for that conversation.
If the connector is added in settings but not enabled in the current chat, Claude won't be able to call Layovelle's tools.
### Choose the right workspace
If you belong to multiple Layovelle organizations or teams, Layovelle uses your default workspace unless you switch it first.
Ask your AI assistant:
```
What Layovelle organization and team am I currently using?
```
```
Switch Layovelle to the "Acme Corp" organization and the "Marketing" team.
```
This helps make sure new designs, exports, and brand kits are created in the correct workspace.
### Try it out
Ask Claude to create your first design:
```
Create a 5-slide pitch deck for a mobile app called "FocusTime" that helps
people reduce screen time. Include a title slide, the problem, the solution,
key features, and a call to action.
```
Claude will create a new Layovelle canvas and give you a link to view and edit it.
You can also work with existing designs. Open a Layovelle canvas, click **Share** to get a link, and paste it into your conversation:
```
Build this login page as a React component: https://layovelle.com/s/your-share-token
```
Claude will fetch the design as structured data and generate production-ready code.
## Give your AI agent access to Layovelle docs
You can give your AI agent direct access to these docs through a hosted MCP server at `/docs/mcp`. Your agent gets a search tool and a read-only filesystem over every page — no local install required, and it works in any editor that speaks HTTP MCP, including claude.ai on the web.
<Tabs>
<Tab title="Claude Code">
Run in your terminal:
```bash theme={null}
claude mcp add --transport http moda-docs /docs/mcp
```
</Tab>
<Tab title="Claude.ai">
Go to [Customize](https://claude.ai/customize) in your sidebar, click the **+** button, choose **Add custom connector**. Set the name to `Layovelle Docs` and the URL to:
```text theme={null}
/docs/mcp
```
</Tab>
<Tab title="Claude Desktop">
Open Claude Desktop, click **Customize** in the sidebar, then the **+** button > **Add custom connector**. Set the name to `Layovelle Docs` and the URL to:
```text theme={null}
/docs/mcp
```
</Tab>
<Tab title="Cursor">
Open **Cursor Settings > MCP** and add a new server, or add to `~/.cursor/mcp.json`:
```json theme={null}
{
"mcpServers": {
"moda-docs": {
"url": "/docs/mcp"
}
}
}
```
</Tab>
<Tab title="VS Code">
Add to your VS Code settings (`settings.json`):
```json theme={null}
{
"mcp": {
"servers": {
"moda-docs": {
"type": "http",
"url": "/docs/mcp"
}
}
}
}
```
</Tab>
</Tabs>
Your agent can then look up API endpoints, authentication details, webhook formats, and more — directly from Layovelle's docs.
Agents that support [agent-skill](https://agentskills.io/specification) auto-discovery will also pick up the `moda-mcp` skill from `/docs/.well-known/agent-skills/` — a canonical pre-flight for `start_design_task` covering the prompt-gathering checklist, brand-kit defaults, the 2–10 minute task lifecycle, and `format_category` disambiguation.
You can also access the docs as plain text for any LLM:
* **Index:** [/docs/llms.txt](/docs/llms.txt)
* **Full docs:** [/docs/llms-full.txt](/docs/llms-full.txt)
## Example prompts
Once connected, you can start chatting right away. Here are a few prompts to try.
Be explicit about the output type in your prompt, especially for non-slide work. Phrases like `PDF report`, `Instagram carousel`, `diagram`, or `presentation slides` help Layovelle choose the right format.
### Create a slide deck
```
Create a 10-slide pitch deck for a developer tools startup.
Include problem, solution, market, product, traction, team, and ask slides.
```
### Create social media posts
```
Create 5 Instagram carousel slides announcing our new AI feature.
Use bold typography and a modern, clean layout.
```
### Create a report
```
Create a one-page project status report as a PDF. The project is 70% complete
and on track. Include sections for progress, risks, and next steps.
```
### Set up your brand
```
Create a brand kit from our website: https://yourcompany.com
```
### Implement a design as code
```
Build this landing page layout as a React component:
https://layovelle.com/s/your-share-token
```
### Extract design details
```
Read this Layovelle design and create a theme file with colors, fonts, and spacing tokens.
```
### Browse your canvases
```
Search my Layovelle canvases for anything related to "product launch" and summarize what's there.
```
For many more examples, see the [Creating Designs guide](/mcp/create-designs).
## What's next
* [Creating Designs Guide](/mcp/create-designs) — Detailed prompts and tips for creating designs with Claude
* [MCP Server Overview](/mcp/overview) — Architecture, transport modes, and what the agent sees
* [Setup Details](/mcp/setup) — Full setup instructions including local server and troubleshooting
* [Tools Reference](/mcp/tools) — Detailed reference for all MCP tools
* [Design-to-Code Guide](/mcp/design-to-code) — Best practices for turning designs into code
# Help & Support
Source: /docs/mcp/help
Get help with Layovelle and the MCP server.
<Warning>
**Legacy.** This section documents the original Layovelle MCP server at `mcp.moda.app/mcp`, built around
`start_design_task` and `get_moda_canvas`. It is superseded by [Layovelle for Agents](/agents) — the `moda` CLI wherever
your agent has a shell, and the [Layovelle connector](/agents/mcp-connector) for claude.ai and other chat hosts. The
connector replaces this server, so the tools below retire with it. These pages stay published for existing
integrations; start new ones on [Layovelle for Agents](/agents).
</Warning>
## Contact us
Have a question or need help? Reach out to our team at **[admin@layovelle.com](mailto:admin@layovelle.com)**.
# Interactive Apps
Source: /docs/mcp/interactive-apps
Hosts that support io.modelcontextprotocol/ui automatically render visual UIs on top of Layovelle's MCP tools.
<Warning>
**Legacy.** This section documents the original Layovelle MCP server at `mcp.moda.app/mcp`, built around
`start_design_task` and `get_moda_canvas`. It is superseded by [Layovelle for Agents](/agents) — the `moda` CLI wherever
your agent has a shell, and the [Layovelle connector](/agents/mcp-connector) for claude.ai and other chat hosts. The
connector replaces this server, so the tools below retire with it. These pages stay published for existing
integrations; start new ones on [Layovelle for Agents](/agents).
</Warning>
Some Layovelle MCP tools ship with paired interactive UIs. Hosts that support the [`io.modelcontextprotocol/ui`](https://modelcontextprotocol.io/) extension — currently claude.ai web and Claude Desktop — render an embedded iframe on top of the tool's normal JSON result. The same tools called from any other MCP host (Claude Code, Cursor, custom clients) return exactly the same JSON they always did. Interactive apps are purely additive.
Interactive apps are useful when:
* The tool returns visual data (canvases, brand colors, logos) that's much easier to scan as a grid of thumbnails than as JSON
* The tool starts a long-running job whose progress benefits from a live view
* The user might want to take a follow-up action (search, set as default, remove) without round-tripping through the LLM
## Available apps
| App | Tool(s) | What it shows |
| - | - | - |
| Canvas Gallery | [`list_my_canvases`](/mcp/tools#list_my_canvases) | A grid of canvas thumbnails with names and last-updated timestamps. The inline search box and "Show more" both re-call `list_my_canvases` — forwarding the keyword and the active template filter — to refine and paginate results without involving the LLM. [`search_canvases`](/mcp/tools#search_canvases) is the JSON-only counterpart for agent-side lookups and renders no app. |
| Brand Kit Showcase | [`list_brand_kits`](/mcp/tools#list_brand_kits) | Per-kit cards with logos, color swatches, and live font samples. Includes a "Set as default" button that calls [`set_default_brand_kit`](/mcp/tools#set_default_brand_kit). |
| Design Task Progress | [`start_design_task`](/mcp/tools#start_design_task) | Live progress, current step, and rendered page thumbnails as the design agent works. Polls `get_task_status` itself every couple of seconds and updates in place — no need to block the tool call or have the LLM babysit the polling loop. |
| Brand Kit Image Gallery | [`list_brand_kit_images`](/mcp/tools#list_brand_kit_images) | Logos and reference images grouped by role, with a per-card Remove button that calls [`remove_brand_kit_image`](/mcp/tools#remove_brand_kit_image). |
## How it works
Each tool's result includes a `_meta.ui.resourceUri` pointing to a `ui://moda/...` HTML resource. Iframe-aware hosts fetch that resource, hand it the tool result, and render it next to (or in place of) the raw JSON. Apps can call back into the same MCP server via `app.callServerTool(...)` to refine results, paginate, or perform follow-up actions — those follow-up calls hit the same authenticated session as the originating tool call.
## Non-interactive hosts
Hosts that don't support `io.modelcontextprotocol/ui` simply ignore the `_meta.ui` block and present the raw tool result as before. Every interactive feature has a non-interactive equivalent:
* The Design Task Progress iframe replaces an explicit `get_task_status` polling loop. On non-interactive hosts, the host (or its LLM) polls `get_task_status(task_id)` directly.
* The "Set as default" button calls `set_default_brand_kit`, which is also callable directly when a user explicitly asks to change their default.
* The Remove button calls `remove_brand_kit_image`, which is also directly callable.
If you're building an MCP integration, you can ignore interactive apps entirely and rely on the JSON tool results — nothing breaks, nothing is hidden.
# Naming Layers
Source: /docs/mcp/naming-layers
How to name layers in Layovelle for better semantic output from the MCP server.
<Warning>
**Legacy.** This section documents the original Layovelle MCP server at `mcp.moda.app/mcp`, built around
`start_design_task` and `get_moda_canvas`. It is superseded by [Layovelle for Agents](/agents) — the `moda` CLI wherever
your agent has a shell, and the [Layovelle connector](/agents/mcp-connector) for claude.ai and other chat hosts. The
connector replaces this server, so the tools below retire with it. These pages stay published for existing
integrations; start new ones on [Layovelle for Agents](/agents).
</Warning>
The Layovelle MCP server's transformer uses layer names to determine what each element represents. Well-named layers produce better semantic tags, which leads to better generated code.
## How it works
When a layer has a descriptive name, the transformer matches it against a keyword list and assigns the appropriate semantic tag. Layer names take priority over visual heuristics.
For example:
* A rectangle named `cta-button` becomes `<Button>` instead of `<Box>`
* A group named `nav-bar` becomes `<Nav>` instead of `<Section>`
* An image named `user-avatar` becomes `<Avatar>` instead of `<Image>`
## Keyword reference
These keywords in layer names trigger specific semantic tags. Keywords are case-insensitive and can sit anywhere in the name as long as a separator sets them off (e.g., `hero-section`, `main-hero`, `my_hero`). If a name contains two keywords, only one of them wins — prefer names with a single keyword.
### Interactive elements
| Keywords | Semantic tag | Example layer names |
| - | - | - |
| `button`, `btn`, `cta` | `Button` | `submit-btn`, `cta-button`, `primary-cta` |
| `input`, `text input`, `search` | `TextInput` | `email-input`, `user-search`, `name-text-input` |
| `link` | `Link` | `nav-link`, `external-link` |
| `checkbox` | `Checkbox` | `agree-checkbox`, `terms-checkbox` |
| `toggle`, `switch` | `Toggle` | `dark-mode-toggle`, `notifications-switch` |
| `dropdown`, `select` | `Dropdown` | `country-dropdown`, `role-select` |
### Layout elements
| Keywords | Semantic tag | Example layer names |
| - | - | - |
| `card` | `Card` | `product-card`, `testimonial-card` |
| `section` | `Section` | `features-section`, `pricing-section` |
| `header` | `Header` | `page-header`, `card-header` |
| `footer` | `Footer` | `page-footer`, `card-footer` |
| `nav`, `navbar`, `navigation` | `Nav` | `main-nav`, `top-nav` |
| `sidebar` | `Sidebar` | `left-sidebar`, `filter-sidebar` |
| `modal`, `dialog` | `Modal` | `confirm-modal`, `settings-dialog` |
| `hero` | `Hero` | `homepage-hero`, `landing-hero` |
| `banner` | `Banner` | `promo-banner`, `notification-banner` |
### Content elements
| Keywords | Semantic tag | Example layer names |
| - | - | - |
| `avatar` | `Avatar` | `user-avatar`, `profile-avatar` |
| `badge` | `Badge` | `status-badge`, `notification-badge` |
| `tag`, `chip` | `Badge` | `category-tag`, `filter-chip` |
| `icon` | `Icon` | `arrow-icon`, `close-icon` |
| `logo` | `Image` | `company-logo`, `brand-logo` |
| `divider`, `separator` | `Divider` | `section-divider`, `menu-separator` |
| `list` | `List` | `feature-list`, `menu-list` |
| `table` | `Table` | `pricing-table`, `data-table` |
## Naming conventions
The transformer matches whole words, so keywords must be separated from other words by non-alphanumeric characters (hyphens, underscores, or spaces):
* `kebab-case`: `hero-section`, `cta-button`
* `snake_case`: `hero_section`, `cta_button`
* Space-separated: `Hero Section`, `CTA Button`
Fused compounds like `heroSection` or `HeroSection` do **not** match — the keyword can't be picked out of a single run of letters. Use a separator between words.
## What happens without names
When layers don't have descriptive names (e.g., `Rectangle 1`, `Group 3`), the transformer falls back to visual heuristics:
* Text with large font size → `Heading`
* Rectangle with text and dark fill → `Button`
* Rectangle with thin border and no text → `TextInput`
* Small circular image → `Avatar`
* Group with background → `Card`
Names also feed the `TextInput` shape heuristic: a box that looks like an input (28–80px tall, no text content) is tagged `TextInput`, and an `input`, `email`, `password`, `search`, or `field` substring anywhere in its name — fused or not — settles it. That applies only to such shapes: a name like `password-field` on a text layer or a group does not force the tag the way the keywords above do.
These heuristics work well for common patterns, but explicit layer names produce more accurate results, especially for non-obvious elements.
## Tips
* **Name layers that aren't visually obvious** — A subtle text link or a custom checkbox benefits most from naming
* **Use semantic names, not visual descriptions** — `submit-button` is better than `blue-rectangle`
* **Name groups and sections** — Group-level names like `features-section` improve the structure of the generated code
* **Be consistent** — Pick a naming convention and stick with it across your canvas
# Overview
Source: /docs/mcp/overview
Create designs with Claude and connect AI agents to your Layovelle canvases via MCP.
<Warning>
**Legacy.** This section documents the original Layovelle MCP server at `mcp.moda.app/mcp`, built around
`start_design_task` and `get_moda_canvas`. It is superseded by [Layovelle for Agents](/agents) — the `moda` CLI wherever
your agent has a shell, and the [Layovelle connector](/agents/mcp-connector) for claude.ai and other chat hosts. The
connector replaces this server, so the tools below retire with it. These pages stay published for existing
integrations; start new ones on [Layovelle for Agents](/agents).
</Warning>
The Layovelle MCP server connects Claude and other AI agents to Layovelle through the [Model Context Protocol](https://modelcontextprotocol.io/). It works with Claude Desktop, claude.ai, Claude Code, Claude mobile, Cursor, VS Code, and other MCP-compatible environments.
Claude is the most common way people use Layovelle today, so the guides in this section use Claude examples first. But MCP is not Claude-specific: Cursor and other MCP-capable clients can use the same Layovelle tools, and OpenAI also supports MCP through its app and API surfaces.
## How it works
The MCP server supports two main workflows. **Design creation**: ask Claude to create or edit designs directly — it generates canvases from prompts, applies your brand kit, and can remix existing designs. **Design to code**: paste a Layovelle share link and Claude fetches the design as semantic pseudo-HTML with CSS properties, then translates it to any frontend framework.
```mermaid theme={null}
flowchart TD
A[You describe what you want] --> B[Claude calls Layovelle MCP tools]
B --> C[Layovelle's AI agent builds the canvas]
C --> D[You get a link to your design]
D --> E[Edit in Layovelle, export, or ask Claude to revise]
```
The diagram above shows the design-creation flow. The MCP server also supports the reverse — [turning existing Layovelle designs into code](/mcp/design-to-code).
## What the agent sees
The `get_moda_canvas` tool returns output like this:
```html theme={null}
## Page: Login Screen (1440x900) [page 1]
<section
background="#f3f4f6"
height="900px"
width="1440px"
display="flex"
justify-content="center"
align-items="center"
>
<Card background="#ffffff" border-radius="16px" width="600px" display="flex" flex-direction="column" gap="20px">
<Heading font-size="32px" font-weight="700" color="#111827">Welcome back</Heading>
<Text font-size="14px" color="#6b7280"> Sign in to your account</Text>
<TextInput border="1px solid #d1d5db" border-radius="8px" height="48px" width="520px" placeholder="Email" />
<button background="#2563eb" border-radius="8px" height="48px" width="520px">Sign in</button>
</Card>
</section>
## Design Tokens - Colors: #111827, #2563eb, #6b7280, #d1d5db, #f3f4f6, #ffffff - Font Inter: weights [400, 700] roles
[body, heading] - Corner radii: 8px, 16px
```
The semantic tag names (`Card`, `Button`, `TextInput`, `Heading`) tell the agent **what** each element is, not just what it looks like. This produces better code than raw coordinates or screenshots alone.
## Transport modes
The MCP server supports two transport modes:
| Transport | Auth | Use case |
| - | - | - |
| `stdio` | None | Local IDE integrations (Cursor, Claude Code) |
| `streamable-http` | OAuth 2.1 | Hosted endpoint at `mcp.moda.app` |
The hosted server at `mcp.moda.app` is the easiest way to get started. See [Setup](/mcp/setup) for configuration details.
## Available tools
### Design creation
| Tool | Description | Auth required |
| - | - | - |
| [`start_design_task`](/mcp/tools#start_design_task) | Create or edit a design from a prompt | Yes |
| [`get_task_status`](/mcp/tools#get_task_status) | Check progress of a design task | Yes |
| [`list_tasks`](/mcp/tools#list_tasks) | List recent design tasks | Yes |
| [`remix_design`](/mcp/tools#remix_design) | Duplicate canvas + optional AI edits | Yes |
### Brand kits
| Tool | Description | Auth required |
| - | - | - |
| [`list_brand_kits`](/mcp/tools#list_brand_kits) | List brand kits for a team | Yes |
| [`create_brand_kit`](/mcp/tools#create_brand_kit) | Create brand kit from website URL | Yes |
| [`update_brand_kit`](/mcp/tools#update_brand_kit) | Update brand kit colors, fonts, etc. | Yes |
### Canvas management
| Tool | Description | Auth required |
| - | - | - |
| [`list_my_canvases`](/mcp/tools#list_my_canvases) | Browse your canvases | Yes |
| [`search_canvases`](/mcp/tools#search_canvases) | Search canvases by name | Yes |
| [`list_organizations`](/mcp/tools#list_organizations) | List your orgs and teams | Yes |
### Design to code
| Tool | Description | Auth required |
| - | - | - |
| [`get_moda_canvas`](/mcp/tools#get_moda_canvas) | Fetch semantic pseudo-HTML and design tokens | Public links: No, Private: Yes |
| [`get_moda_canvas_tokens`](/mcp/tools#get_moda_canvas_tokens) | Extract design tokens only (colors, fonts, radii) | Public links: No, Private: Yes |
| [`list_moda_canvas_pages`](/mcp/tools#list_moda_canvas_pages) | List pages with dimensions and node counts | Public links: No, Private: Yes |
| [`export_canvas`](/mcp/tools#export_canvas) | Export as PNG, JPEG, PDF, PPTX, MP4, or GIF | Yes |
The tables above highlight the most-used tools. The MCP server also provides `get_moda_bootstrap` for mandatory session orientation, `set_context` / `get_context` / `set_session_brand_kit` for session management, `find_brand_kits` for agent-side kit lookups (the JSON sibling of `list_brand_kits`), and `upload_file` for attaching files to design tasks. See the [full tools reference](/mcp/tools) for all 20+ tools.
## Get the most out of the MCP
For AI agents — Claude, Cursor, claude.ai, and others — the Layovelle **agent skill** teaches the agent how to drive these tools effectively without you re-explaining every conversation. It covers the `get_moda_bootstrap` first-call handshake, the canonical design-task patterns (create / edit / fill template / rebrand / bulk), and the non-obvious behaviors tool signatures alone can't communicate.
<Card title="Install the agent skill" icon="sparkles" href="/docs/mcp/agent-skill">
Install paths for Claude Code, Cursor, claude.ai, and the Agent SDK — including a zip download for non-technical users.
</Card>
## Next steps
* [Setup](/mcp/setup) — Install and configure the MCP server
* [Agent Skill](/mcp/agent-skill) — Drop our skill into your AI agent for best results
* [Creating Designs](/mcp/create-designs) — Prompts and tips for creating designs with Claude
* [Authentication](/mcp/authentication) — Understand the OAuth flow for the remote server
* [Tools Reference](/mcp/tools) — Detailed reference for each tool
* [Design-to-Code](/mcp/design-to-code) — Best practices for turning designs into code
# Setup
Source: /docs/mcp/setup
Connect Layovelle to Claude Desktop, claude.ai, Claude Code, Claude mobile, Cursor, or VS Code.
<Warning>
**Legacy.** This section documents the original Layovelle MCP server at `mcp.moda.app/mcp`, built around
`start_design_task` and `get_moda_canvas`. It is superseded by [Layovelle for Agents](/agents) — the `moda` CLI wherever
your agent has a shell, and the [Layovelle connector](/agents/mcp-connector) for claude.ai and other chat hosts. The
connector replaces this server, so the tools below retire with it. These pages stay published for existing
integrations; start new ones on [Layovelle for Agents](/agents).
</Warning>
The Layovelle MCP server at `mcp.moda.app/mcp` requires no local installation. Authentication is handled via OAuth — you sign in through your browser on first use.
This page focuses on the most common **interactive** setups — you sign in through your browser once, then Claude / Cursor / VS Code manage tokens for you.
<Info>
**Running Layovelle MCP in a scheduled job, managed agent, or CI pipeline?** Use API-key auth instead of OAuth — no browser
required. See the [Authentication page → Setting up API-key auth](/mcp/authentication#setting-up-api-key-auth) for
per-client setup with `Authorization: Bearer` headers.
</Info>
## Connect your editor
<Tabs>
<Tab title="Claude Desktop">
Open Claude Desktop, click **Customize** in the sidebar, then the **+** button > **Add custom connector**. For the **Name**, input `Layovelle`, and use the MCP server URL below and click **Add**:
```
https://mcp.moda.app/mcp
```
Click **Connect** after adding and you'll be prompted to sign into Layovelle and authorize your account.
</Tab>
<Tab title="Claude.ai (browser)">
Go to **Customize** in your sidebar ([claude.ai/customize](https://claude.ai/customize)) and click the **+** button > **Add custom connector**. For the **Name**, input `Layovelle`, and use the MCP server URL below and click **Add**:
```
https://mcp.moda.app/mcp
```
Click **Connect** after adding and you'll be prompted to sign into Layovelle and authorize your account.
**Team and Enterprise plans:** An admin must first add the Layovelle connector in **Admin Settings > Connectors**. After that, individual members can connect from their personal **Customize** sidebar.
</Tab>
<Tab title="Claude Code">
Run this command in your terminal:
```bash theme={null}
claude mcp add --transport http moda https://mcp.moda.app/mcp
```
Verify the connection:
```bash theme={null}
claude /mcp
```
You should see `moda` listed as a connected server. When you first use an MCP tool, Claude Code will open your browser for sign-in.
To remove the server later:
```bash theme={null}
claude mcp remove moda
```
</Tab>
<Tab title="Claude Mobile">
Connectors set up on claude.ai sync to the Claude mobile app automatically.
1. Go to [claude.ai/settings](https://claude.ai/settings) on your computer
2. Add the Layovelle connector (see the **Claude.ai (browser)** tab)
3. Open the Claude app on your phone — Layovelle will be available
This lets you create designs from your phone by chatting with Claude.
</Tab>
<Tab title="Cursor">
Open **Cursor Settings > MCP** and add a new server, or add to `~/.cursor/mcp.json`:
```json theme={null}
{
"mcpServers": {
"moda": {
"type": "streamable-http",
"url": "https://mcp.moda.app/mcp"
}
}
}
```
You can also use a project-level config at `.cursor/mcp.json` in your project root.
Cursor handles the OAuth flow automatically when you first connect.
</Tab>
<Tab title="VS Code">
Add to your VS Code settings (`settings.json`):
```json theme={null}
{
"mcp": {
"servers": {
"moda": {
"type": "streamable-http",
"url": "https://mcp.moda.app/mcp"
}
}
}
}
```
</Tab>
</Tabs>
## Verifying the connection
After setting up the server, test it by asking the AI agent to describe a Layovelle design:
```
What's in this Layovelle design? https://layovelle.com/s/your-share-token
```
The agent should call the `get_moda_canvas` tool and return a description of the canvas contents.
## Troubleshooting
### Server not connecting
* **Claude Desktop**: Check that Layovelle appears under **Connectors** (click the **+** button in the chat box). If not, go to **Settings > Connectors** and re-add it. Try restarting the app.
* **Claude.ai**: Check that the connector appears in [claude.ai/settings](https://claude.ai/settings) under **Connectors**. Try removing and re-adding it.
* **Claude Code**: Run `claude /mcp` to verify the server is listed. Try `claude mcp remove moda` and re-add it.
* **Claude Mobile**: Connectors sync from claude.ai. Make sure the connector is set up in [claude.ai/settings](https://claude.ai/settings) under **Connectors** first, then restart the mobile app.
* **Cursor**: Check that the MCP server shows as "connected" in Cursor Settings > MCP. Try restarting the MCP server from the settings panel.
* **VS Code**: Ensure the MCP configuration is in your user or workspace `settings.json`. Restart VS Code after adding the config.
# Tools Reference
Source: /docs/mcp/tools
Complete reference for all Layovelle MCP tools — parameters, return types, and usage examples.
<Warning>
**Legacy.** This section documents the original Layovelle MCP server at `mcp.moda.app/mcp`, built around
`start_design_task` and `get_moda_canvas`. It is superseded by [Layovelle for Agents](/agents) — the `moda` CLI wherever
your agent has a shell, and the [Layovelle connector](/agents/mcp-connector) for claude.ai and other chat hosts. The
connector replaces this server, so the tools below retire with it. These pages stay published for existing
integrations; start new ones on [Layovelle for Agents](/agents).
</Warning>
The Layovelle MCP server provides 20+ tools for working with canvas designs. These tools are called automatically by your AI coding agent when it needs design data or wants to create and manage designs.
| Tool | Description | Auth required |
| - | - | - |
| [`get_moda_bootstrap`](#get_moda_bootstrap) | Fetch Layovelle session bootstrap before any canvas work (call first) | Yes |
| [`set_context`](#set_context) | Set your active organization and team | Yes |
| [`get_context`](#get_context) | Show your current session context | Yes |
| [`set_session_brand_kit`](#set_session_brand_kit) | Pin a brand kit for this session | Yes |
| [`get_moda_canvas`](#get_moda_canvas) | Fetch semantic pseudo-HTML and design tokens | Public links: No |
| [`get_moda_canvas_tokens`](#get_moda_canvas_tokens) | Extract design tokens only | Public links: No |
| [`list_moda_canvas_pages`](#list_moda_canvas_pages) | List pages with dimensions and node counts | Public links: No |
| [`export_canvas`](#export_canvas) | Export as PNG, JPEG, PDF, PPTX, MP4, or GIF | Yes |
| [`get_export_status`](#get_export_status) | Poll an in-progress export | Yes |
| [`list_my_canvases`](#list_my_canvases) | Browse your canvases | Yes |
| [`search_canvases`](#search_canvases) | Search canvases by name | Yes |
| [`list_organizations`](#list_organizations) | List your orgs and teams | Yes |
| [`find_brand_kits`](#find_brand_kits) | JSON-only brand-kit lookup (agent-side, no iframe) | Yes |
| [`list_brand_kits`](#list_brand_kits) | List brand kits — renders visual showcase iframe | Yes |
| [`create_brand_kit`](#create_brand_kit) | Create brand kit from website URL | Yes |
| [`update_brand_kit`](#update_brand_kit) | Update brand kit colors, fonts, etc. | Yes |
| [`set_default_brand_kit`](#set_default_brand_kit) | Mark a brand kit as the **team's** default (destructive) | Yes |
| [`list_brand_kit_images`](#list_brand_kit_images) | List logos and reference images for a kit | Yes |
| [`remove_brand_kit_image`](#remove_brand_kit_image) | Detach an image from a brand kit | Yes |
| [`upload_file`](#upload_file) | Upload a file from URL for attachments | Yes |
| [`start_design_task`](#start_design_task) | Start an AI design task | Yes |
| [`get_task_status`](#get_task_status) | Get task status and progress | Yes |
| [`list_tasks`](#list_tasks) | List recent design tasks | Yes |
| [`remix_design`](#remix_design) | Duplicate canvas + optional AI edits | Yes |
| [`list_my_websites`](#list_my_websites) | Browse websites in your team | Yes |
| [`get_website`](#get_website) | Read HTML, title, share state, published URL | Yes |
| [`create_website_from_upload`](#create_website_from_upload) | Create a website from an HTML upload | Yes |
| [`update_website_from_upload`](#update_website_from_upload) | Replace HTML and/or title of an existing website | Yes |
## Session context
When you first connect, Layovelle uses your default organization and team. If you belong to multiple organizations, you can switch with `set_context`:
1. Call `list_organizations` to see your orgs and teams.
2. Call `set_context` with the org name (and optionally team name).
3. All subsequent tool calls use that workspace automatically.
Your context persists for 24 hours across reconnections. Tools like `list_brand_kits`, `create_brand_kit`, `start_design_task`, and `remix_design` all read from your session context automatically. You can also pass `org_id` or `team_id` explicitly to override the session context for a single call.
### URL formats
All tools that accept a `url` parameter support these formats:
| Format | Example |
| - | - |
| Share link | `https://layovelle.com/s/abc123-token` |
| Private canvas URL | `https://layovelle.com/canvas/550e8400-e29b-41d4-a716-446655440000` |
| Raw share token | `abc123-token` |
| Legacy domain | `https://app.moda.so/s/abc123-token` |
Private canvas URLs require [authentication](/mcp/authentication) via the remote server.
***
## set\_context
Sets your preferred organization and team for the current session. Once set, all subsequent tools that operate within a workspace (brand kits, design tasks, remixes) will use these defaults automatically.
<Info>
This tool requires [authentication](/mcp/authentication) via the remote MCP server at `mcp.moda.app`. It is not
available when using the local `stdio` server.
</Info>
### Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `org_name` | `string` | Yes | Name of the organization to use (case-insensitive). Use `list_organizations` to see options. |
| `team_name` | `string` | No | Name of the team within the org (case-insensitive). Defaults to the org's default team. |
### Returns
A confirmation string:
```
Context set to organization 'Acme Corp' and team 'Design Team'. All subsequent operations will use this workspace.
```
### Example usage
```
# Set org (uses default team)
set_context(org_name="Acme Corp")
# Set org and specific team
set_context(org_name="Acme Corp", team_name="Marketing")
```
### Notes
* Call `list_organizations` first to see available organization and team names
* Context persists for 24 hours across reconnections
* You can call `set_context` again at any time to switch workspaces
***
## get\_context
Shows your current session context -- which organization and team are active for workspace-scoped tools.
<Info>
This tool requires [authentication](/mcp/authentication) via the remote MCP server at `mcp.moda.app`. It is not
available when using the local `stdio` server.
</Info>
### Parameters
This tool takes no parameters.
### Returns
A string describing your current context:
```
Organization: Acme Corp | Team: Design Team
```
If no context is set:
```
No session context set. Call set_context to choose your organization and team.
```
***
## get\_moda\_bootstrap
MANDATORY FIRST CALL, every new conversation, before creating, editing, reading, remixing, or exporting any canvas — even if the agent already knows the user's identity: this returns far more than identity (the Layovelle skill pointer, active user, active workspace, and entitlements that affect tool-call decisions). The response also lets agents skip downstream lookups when defaults are unambiguous.
<Info>
This tool requires [authentication](/mcp/authentication) via the remote MCP server at `mcp.moda.app`. It is not
available when using the local `stdio` server.
</Info>
### Parameters
This tool takes no parameters.
### Returns
```json theme={null}
{
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Jane Doe",
"email": "jane@acme.com"
},
"session": {
"org_id": "660e8400-e29b-41d4-a716-446655440000",
"org_name": "Acme Corp",
"team_id": "770e8400-e29b-41d4-a716-446655440000",
"team_name": "Design Team",
"brand_kit_id": null
},
"org_count": 1,
"team_default_brand_kit": {
"id": "880e8400-e29b-41d4-a716-446655440000",
"title": "Acme Corp"
},
"brand_kit_count": 1,
"plan": "paid",
"concurrency_cap": 10
}
```
| Field | Type | Description |
| - | - | - |
| `user.id` | `string` | The Layovelle user UUID. |
| `user.name` | `string` | Display name. |
| `user.email` | `string` | Email address. |
| `session.org_id` | `string \| null` | Active org for this session, or null if unset. |
| `session.team_id` | `string \| null` | Active team for this session. |
| `session.brand_kit_id` | `string \| null` | Session-pinned brand kit, if any. Set via the showcase iframe button or `set_session_brand_kit`. |
| `org_count` | `integer` | Total orgs the user belongs to. If 1, no multi-org disambiguation is needed. |
| `team_default_brand_kit` | `object \| null` | The team default's id + title, if a default exists. |
| `brand_kit_count` | `integer` | Brand kits in the active team. If 1, the agent can use that kit without further listing. |
| `plan` | `string` | Billing plan: `free`, `free_beta`, `paid`, `ultra`, or `enterprise`. |
| `concurrency_cap` | `integer` | Max concurrent design tasks for this plan. Use as the upper bound for bulk fan-out. |
### Notes
* Reads from existing session context — no side effects.
* A good first call on any new conversation: `org_count`, `brand_kit_count`, and `concurrency_cap` collectively eliminate the need for several downstream tool calls.
* The `plan` value is the same enum returned by the billing service; `concurrency_cap` is pre-resolved so callers don't need to maintain their own mapping.
***
## set\_session\_brand\_kit
Pin the brand kit the user chose for the current session **without** changing the team default. Subsequent calls to `start_design_task` / `remix_design` that omit `brand_kit_id` will resolve to this kit, and it counts as the user's brand choice for new designs.
<Info>
This tool requires [authentication](/mcp/authentication) via the remote MCP server at `mcp.moda.app`. It is not
available when using the local `stdio` server.
</Info>
This is what the brand-kit showcase iframe's **Use for this session** button calls. For changing the team default (destructive, affects every team member), use [`set_default_brand_kit`](#set_default_brand_kit) instead.
### Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `brand_kit_id` | `string \| null` | No | Brand kit UUID (or `bk_…` wire form). Pass null/empty to clear the session pin. |
### Returns
A confirmation string:
```
Session brand kit set to <uuid>. Subsequent design tasks will apply it unless overridden.
```
Or when cleared:
```
Session brand kit cleared. Edits keep each canvas's own kit; a new design needs the user's brand choice again.
```
### Notes
* Switching workspaces via [`set_context`](#set_context) clears the session brand kit (kits are team-scoped).
* Resolution order for `start_design_task` / `remix_design`: explicit `brand_kit_id` → session preference → (edits and remixes) the canvas's own kit, then the team default → none (if `skip_brand_kit=true`). A new design with no choice is gated instead — see [`start_design_task`](#start_design_task).
* The kit must belong to the active team; trying to pin a kit from a different team raises a tool error.
***
## get\_moda\_canvas
The primary tool for design-to-code workflows. Fetches a Layovelle canvas and returns semantic pseudo-HTML with CSS properties and design tokens that AI agents can translate to any frontend framework.
### Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `url` | `string` | Yes | Layovelle share URL (`https://layovelle.com/s/...`), private canvas URL (`https://layovelle.com/canvas/...`), or raw share token |
| `page_number` | `integer \| null` | No | 1-indexed page number. Omit to get all pages. |
### Returns
A string containing semantic pseudo-HTML with CSS properties, followed by a design tokens summary.
```html theme={null}
# Layovelle Design Export — Canvas Title # Source: https://layovelle.com/s/abc123 ## Page: Login Screen (1440x900) [page 1]
<section
background="#f3f4f6"
height="900px"
width="1440px"
display="flex"
justify-content="center"
align-items="center"
>
<Card background="#ffffff" border-radius="16px" width="600px" display="flex" flex-direction="column" gap="20px">
<Heading font-size="32px" font-weight="700" color="#111827">Welcome back</Heading>
<Text font-size="14px" color="#6b7280"> Sign in to your account</Text>
<TextInput border="1px solid #d1d5db" border-radius="8px" height="48px" width="520px" />
<button background="#2563eb" border-radius="8px" height="48px" width="520px">Sign in</button>
</Card>
</section>
## Design Tokens - Colors: #111827, #2563eb, #6b7280, #d1d5db, #f3f4f6, #ffffff - Font Inter: weights [400, 700] roles
[body, heading] - Corner radii: 8px, 16px
```
### Semantic tags
The transformer assigns semantic tag names based on visual properties and layer names:
| Tag | When used |
| - | - |
| `Heading` | Text with font-size >= 24px or font-weight >= 600 |
| `Text` | Default text elements |
| `Button` | Rectangle with text, dark fill, and button-like dimensions |
| `TextInput` | Rectangle with light fill, thin border, no text |
| `Image` | Element with an image fill |
| `Avatar` | Small circular element with an image fill |
| `Card` | Group with a background rectangle and content |
| `Row` | Container with `flex-direction: row` |
| `Column` | Container with `flex-direction: column` |
| `Divider` | Line element |
| `Nav`, `Hero`, `Footer` | Matched from layer names |
Layer names in Layovelle take priority over visual heuristics. See [Naming Layers](/mcp/naming-layers) for best practices.
### Example usage
```
# Fetch all pages
get_moda_canvas(url="https://layovelle.com/s/abc123")
# Fetch a specific page
get_moda_canvas(url="https://layovelle.com/s/abc123", page_number=2)
# Fetch a private canvas (requires authentication)
get_moda_canvas(url="https://layovelle.com/canvas/550e8400-e29b-41d4-a716-446655440000")
```
### Notes
* The output is intentionally HTML-like so agents can map it directly to React, Vue, or HTML components
* Design tokens are appended as a summary at the end of the output
* For multi-page canvases, omitting `page_number` returns all pages in a single response
* Hidden layers are excluded from the output
* Rich text extracts the first style span's properties; mixed-style paragraphs use the first style encountered
***
## get\_moda\_canvas\_tokens
Returns only the design tokens from a canvas — colors, fonts, variables, and corner radii — as structured JSON. Use this when you need to generate theme configuration files without the full semantic layout.
### Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `url` | `string` | Yes | Layovelle share URL, private canvas URL, or raw share token |
| `page_number` | `integer \| null` | No | 1-indexed page number. Omit for all pages. |
### Returns
```json theme={null}
{
"variables": {
"primary": "#2563eb",
"secondary": "#6b7280",
"background": "#f3f4f6"
},
"colors": ["#111827", "#2563eb", "#6b7280", "#d1d5db", "#f3f4f6", "#ffffff"],
"fonts": [
{
"family": "Inter",
"weights": [400, 700],
"roles": ["body", "heading"]
}
],
"radii": ["8px", "16px"],
"dimensions": {
"width": 1440,
"height": 900
}
}
```
| Field | Type | Description |
| - | - | - |
| `variables` | `object` | Named design variables defined in the canvas (colors, numbers, strings) |
| `colors` | `string[]` | All unique colors used in the canvas |
| `fonts` | `object[]` | Font families with weights and inferred roles |
| `radii` | `string[]` | All unique corner radii used |
| `dimensions` | `object` | Canvas page dimensions (width and height) |
### Example usage
```
# Extract tokens for theme generation
get_moda_canvas_tokens(url="https://layovelle.com/s/abc123")
# Extract tokens from a specific page
get_moda_canvas_tokens(url="https://layovelle.com/s/abc123", page_number=1)
```
### Notes
* Variables defined in the Layovelle canvas (via the variables panel) appear in the `variables` field with their default values
* Colors are deduplicated and sorted
* Font roles (`body`, `heading`) are inferred from usage context (font size and weight)
***
## list\_moda\_canvas\_pages
Returns metadata about each page in a canvas, including page names, dimensions, and the number of design elements. Use this to understand the structure of multi-page canvases before fetching specific pages.
### Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `url` | `string` | Yes | Layovelle share URL, private canvas URL, or raw share token |
### Returns
```json theme={null}
{
"canvas_name": "Marketing Website",
"total_pages": 3,
"pages": [
{
"page_number": 1,
"id": "p_a",
"name": "Hero Section",
"width": 1440,
"height": 900,
"node_count": 12
},
{
"page_number": 2,
"id": "p_b",
"name": "Features",
"width": 1440,
"height": 1200,
"node_count": 24
}
],
"id_note": "IDs like n1/p_a/img1 are session-scoped short refs..."
}
```
| Field | Type | Description |
| - | - | - |
| `canvas_name` | `string` | Name of the canvas |
| `total_pages` | `integer` | Total number of pages |
| `pages[].page_number` | `integer` | 1-indexed page number |
| `pages[].id` | `string` | The page's addressable id — omitted only for a page-less canvas |
| `pages[].name` | `string` | Page name as set in Layovelle |
| `pages[].width` | `number` | Page width in pixels |
| `pages[].height` | `number` | Page height in pixels |
| `pages[].node_count` | `integer` | Number of design elements on the page |
| `id_note` | `string` | Present only when the ids are short refs; states their lifetime |
### Notes
* Page numbers are 1-indexed
* **`page_number` vs `id`.** The tools on this server (`get_moda_canvas`, `get_moda_canvas_tokens`, `export_canvas`) take `page_number`. `pages[].id` is for the page-scoped authoring endpoints, and unlike an ordinal it survives a page being deleted or reordered
* `id` is either a durable real page id (`page-1786730909610-172273165`) or a session-scoped short ref (`p_a`). When `id_note` is present they are short refs — re-list to refresh rather than storing one
* The `node_count` includes all visible elements on the page (shapes, text, images, groups)
* Hidden elements are excluded from the count
***
## export\_canvas
Export a Layovelle canvas as an image or document file. Pass exactly one of `canvas_id` or `url`.
### Parameters
| Parameter | Type | Required | Default | Description |
| - | - | - | - | - |
| `canvas_id` | `string` | No | — | Canvas UUID to export directly. Preferred after `start_design_task` returns a new canvas. |
| `url` | `string` | No | — | Layovelle share URL, private canvas URL, or raw share token. Use this for share-link or design-to-code workflows. |
| `format` | `string` | No | `"png"` | Export format: `png`, `jpeg`, `pdf`, `pptx`, `mp4`, or `gif` |
| `scope` | `string` | No | `"page"` | What the export renders: `page` (one page, addressed by `page_number`), `sequence` (mp4 only — every visible page's animation stitched into ONE video, in canvas order, transitions included; takes no `page_number`, requires a multi-page animation canvas), or `main_edit` (mp4 only — the canvas's persisted Main Edit timeline; page-less, fine on a single-page canvas, rejected with `no_animation` when no edit is persisted). |
| `fps` | `integer \| null` | No | `null` | Animation formats only — capture frame rate: mp4 `24`/`30`/`60` (default 30), gif `10`/`12`/`15`/`24` (default 12). Integer rates only. The frame budget is rate-independent, so a higher fps shortens the longest exportable timeline (150 s of mp4 at 60 fps); over-budget requests are rejected with the live bound. |
| `page_number` | `integer \| null` | No | `null` | 1-indexed page number. Omit to export all pages — PDF/PPTX bundle every page natively, while multi-page PNG/JPEG are returned as a `.zip` of per-page files (`page-1.png`, `page-2.png`, …) since a single image container can't hold multiple pages. Single-page canvases still return a raw PNG/JPEG. The `format` field in the response reflects what was actually delivered (`zip` in the bundled case). MP4/GIF export exactly one page: required on a multi-page canvas, defaulted to 1 on a single-page one. |
| `pixel_ratio` | `integer \| null` | No | `null` | Render scale 1–4 for PNG, JPEG, PDF, MP4, and GIF (MP4/GIF default 1). Ignored for PPTX. |
| `flatten` | `boolean` | No | `true` | PDF only — produce a raster-only PDF with no searchable text layer. |
| `task_id` | `string \| null` | No | `null` | Optional task identifier to scope the export's cache key. When provided, the rendered file is cached as a stable snapshot for that task — useful for design-task progress UIs that should keep showing the original task output even after the canvas is edited. When omitted (typical LLM use), the cache busts on any canvas-data change so direct re-exports always reflect the current state. |
| `wait` | `boolean` | No | `true` | When `true` (default), block up to \~20s for the export to finish before returning an in-progress handle. When `false`, return `status='in_progress'` with a `task_id` immediately — call `get_export_status(task_id)` to retrieve the URL once the background work completes. Cache hits return synchronously regardless. |
### Format guide
| Format | Best for |
| - | - |
| `png` | Design-to-code workflows — lossless image, ideal for visual reference |
| `jpeg` | Smaller file size when lossless quality isn't needed |
| `pdf` | Multi-page documents, printing, sharing |
| `pptx` | PowerPoint presentations, slide decks |
| `mp4` | Video of one page's animation timeline, with its video-fill audio |
| `gif` | Animated image of one page's animation timeline |
`mp4` and `gif` render a single page's animation timeline by default (see `scope` for the stitched sequence and Main Edit forms). The page must actually have animation — a still page is rejected with a `no_animation` error. Each format is bounded per file, whichever half binds first: mp4 by 18,000 encoded frames and 600 s (600 s at the default 30 fps, 300 s at 60 fps), gif by 9,000 frames and 300 s (300 s at its 10–24 fps rates). A longer timeline is rejected at submit time with the concrete ceiling.
For multi-page PNG/JPEG without `page_number`, the response carries `format: "zip"` and the URL serves a `.zip` of per-page raster files. The requested raster format is preserved inside the archive (`page-1.png`, `page-2.png`, …).
### Returns
On success:
```json theme={null}
{
"status": "completed",
"url": "https://assets-cdn.moda.app/exports/abc123.png",
"format": "png"
}
```
When the canvas has multiple pages and `page_number` was omitted, a PNG/JPEG export bundles into a zip:
```json theme={null}
{
"status": "completed",
"url": "https://assets-cdn.moda.app/exports/abc123.zip",
"format": "zip",
"total_pages": 8
}
```
When the export hasn't finished within the synchronous wait budget (large multi-page canvases, cold renders), the call returns an in-progress handle. Call `get_export_status(task_id)` to fetch the URL once it's ready — the same `task_id` keeps returning the in-progress shape until terminal:
```json theme={null}
{
"status": "in_progress",
"task_id": "550e8400-e29b-41d4-a716-446655440000",
"canvas_id": "660e8400-e29b-41d4-a716-446655440000",
"canvas_url": "https://layovelle.com/canvas/660e8400-e29b-41d4-a716-446655440000",
"format": "pdf",
"total_pages": 23,
"retry_after_seconds": 5
}
```
If a design task is still active for the target canvas:
```json theme={null}
{
"status": "not_ready",
"reason": "active_design_job",
"retry_after_seconds": 3,
"canvas_id": "550e8400-e29b-41d4-a716-446655440000",
"canvas_url": "https://layovelle.com/canvas/550e8400-e29b-41d4-a716-446655440000",
"task_id": "990e8400-e29b-41d4-a716-446655440000"
}
```
`canvas_id`, `canvas_url`, and `task_id` are only included for authenticated private-canvas exports (i.e., when using `canvas_id` or a private URL). Share-link exports omit these fields to avoid leaking internal identifiers.
| Field | Type | Description |
| - | - | - |
| `status` | `string` | `"completed"`, `"in_progress"`, or `"not_ready"` (active design job blocks export) |
| `url` | `string` | Signed URL to the exported file (only when `status='completed'`) |
| `format` | `string` | The format that was used for the export |
| `reason` | `string` | Retryable not-ready reason: `active_design_job` or `job_status_unavailable` |
| `retry_after_seconds` | `integer` | Suggested delay before retrying when `status` is `"not_ready"` or `"in_progress"` |
| `task_id` | `string` | Export task ID when `status='in_progress'`; pass to `get_export_status` to poll. Also surfaces the active design task ID when `status='not_ready'`. |
| `canvas_id` | `string` | Canvas UUID for the export target (private exports only) |
| `canvas_url` | `string` | Direct Layovelle link to the canvas (private exports only) |
| `total_pages` | `integer` | Total page count for the canvas (echoed for client convenience) |
### Example usage
Provide exactly one of `canvas_id` or `url` per call:
```
# Export as PNG for design-to-code (default)
export_canvas(url="https://layovelle.com/s/abc123")
# Export directly from a canvas_id returned by start_design_task
export_canvas(canvas_id="550e8400-e29b-41d4-a716-446655440000", format="pdf")
# Export a specific page as JPEG
export_canvas(url="https://layovelle.com/s/abc123", format="jpeg", page_number=2)
# Export all pages as a PDF
export_canvas(url="https://layovelle.com/s/abc123", format="pdf")
# Export as a PowerPoint file
export_canvas(url="https://layovelle.com/s/abc123", format="pptx")
```
### Notes
* Pass exactly one of `canvas_id` or `url`
* For design-generation workflows, prefer `canvas_id` from `start_design_task` / `get_task_status`
* Export-before-ready and temporary readiness-check failures are normal retryable MCP states, not tool failures
* The signed URL expires after 7 days
* In production, the URL is served from the signed `assets-cdn.moda.app` CDN; raw `storage.googleapis.com` links are a fallback and not the canonical host
* Page exports are cached in Redis for 24 hours per `(canvas, page, delivered format, pixel_ratio, flatten)` combination. *Delivered*, not requested: a multi-page PNG or JPEG request is bundled into a `.zip`, so those two resolve to the same cache slot. With `task_id` omitted the cache key includes the canvas's scene version, so any content edit auto-invalidates; a `task_id`-scoped export is keyed on the task instead and stays valid across edits (see the `task_id` parameter above). Re-exporting an unchanged page returns in milliseconds.
* For document formats (`pdf`, `pptx`), all pages are included by default unless `page_number` is specified
* For PNG/JPEG: pass a specific `page_number` to get a single image; omit it to get every page — single-page canvases return a raw image, multi-page canvases return a `.zip` of `page-1.png`/`page-2.png`/… (`format: "zip"` in the response)
* When a design task auto-exports via `export_on_complete`, the artifact lives at `result.export` on the task and is keyed identically to a manual `export_canvas` call. A follow-up `export_canvas` for the same canvas with no edits hits the cache instead of re-rendering.
* Screenshots are rendered server-side using a headless browser
***
## get\_export\_status
Poll the status of an asynchronous export started by `export_canvas`. Use when `export_canvas` returned `status='in_progress'` (large multi-page exports that exceed the synchronous wait budget).
### Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `task_id` | `string` | Yes | Export task ID returned by `export_canvas` when `status='in_progress'` — bare UUID or prefixed wire form. |
### Returns
While running:
```json theme={null}
{
"status": "running",
"is_terminal": false,
"task_id": "550e8400-e29b-41d4-a716-446655440000",
"canvas_id": "660e8400-e29b-41d4-a716-446655440000",
"canvas_url": "https://layovelle.com/canvas/660e8400-e29b-41d4-a716-446655440000",
"format": "pdf",
"total_pages": 23,
"retry_after_seconds": 5
}
```
On completion:
```json theme={null}
{
"status": "completed",
"is_terminal": true,
"task_id": "550e8400-e29b-41d4-a716-446655440000",
"canvas_id": "660e8400-e29b-41d4-a716-446655440000",
"canvas_url": "https://layovelle.com/canvas/660e8400-e29b-41d4-a716-446655440000",
"format": "pdf",
"url": "https://assets-cdn.moda.app/exports/abc123.pdf",
"total_pages": 23
}
```
On failure:
```json theme={null}
{
"status": "failed",
"is_terminal": true,
"task_id": "550e8400-e29b-41d4-a716-446655440000",
"error": "Failed to export document: ...",
"format": "pdf"
}
```
| Field | Type | Description |
| - | - | - |
| `status` | `string` | `queued`, `running`, `completed`, or `failed`. |
| `is_terminal` | `boolean` | `true` once the task has reached `completed` or `failed`. |
| `task_id` | `string` | The export task ID being polled. |
| `url` | `string` | Signed download URL once `status='completed'`. |
| `error` | `string` | Failure detail when `status='failed'`. |
| `retry_after_seconds` | `integer` | Suggested poll cadence while `is_terminal` is `false`. |
### Notes
* Export task records are kept for \~1 hour. After that the task ID is treated as unknown and the call returns a tool error.
* Call `get_export_status` instead of repeatedly calling `export_canvas` while a known task is running — `export_canvas` may return the same in-progress handle, but `get_export_status` is the canonical poll path.
***
## list\_my\_canvases
Returns a paginated list — or, with a `query`, a paginated keyword search — of canvases accessible to the authenticated user. Use this to browse available canvases when you don't have a specific URL, and whenever the user should see or pick one.
<Info>
This tool requires [authentication](/mcp/authentication) via the remote MCP server at `mcp.moda.app`. It is not
available when using the local `stdio` server.
</Info>
### Parameters
| Parameter | Type | Required | Default | Description |
| - | - | - | - | - |
| `query` | `string` | No | `null` | Keyword to match against canvas names (fuzzy) and content (full-text). Omit to browse by recency; combines with the other filters |
| `limit` | `integer` | No | `6` | Number of canvases to return (max 100) |
| `offset` | `integer` | No | `0` | Number of canvases to skip for pagination |
### Returns
```json theme={null}
{
"canvases": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Marketing Website",
"url": "https://layovelle.com/canvas/550e8400-e29b-41d4-a716-446655440000",
"updated_at": "2025-03-15T10:30:00Z"
}
],
"total": 42,
"limit": 20,
"offset": 0
}
```
| Field | Type | Description |
| - | - | - |
| `canvases[].id` | `string` | Canvas UUID |
| `canvases[].name` | `string` | Canvas name |
| `canvases[].url` | `string` | Direct canvas URL |
| `canvases[].updated_at` | `string` | Last update timestamp (ISO 8601) |
| `total` | `integer` | Total number of canvases |
| `limit` | `integer` | Page size |
| `offset` | `integer` | Current offset |
### Notes
* Canvases are returned in order of most recently updated; with a `query`, results are ranked by relevance and then recency
* Pairs with the Canvas Gallery [interactive app](/mcp/interactive-apps), which renders the results as a thumbnail grid and calls this tool again from its search box and "Show more" button
* Prefer this tool over `search_canvases` whenever the user should see or pick a canvas — pass `query` to hand them a pre-filtered gallery
* The `url` field can be passed directly to other tools like `get_moda_canvas`
* The maximum `limit` is 100 per request
***
## search\_canvases
Searches your canvases by name or content and returns matching results as plain JSON. Use this when *you* need to resolve a canvas ID for a follow-up call and don't have the exact URL.
<Info>
This tool requires [authentication](/mcp/authentication) via the remote MCP server at `mcp.moda.app`. It is not
available when using the local `stdio` server.
</Info>
### Parameters
| Parameter | Type | Required | Default | Description |
| - | - | - | - | - |
| `query` | `string` | Yes | — | Search query to match against canvas names and content |
| `limit` | `integer` | No | `20` | Maximum number of results to return (max 100) |
### Returns
```json theme={null}
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Marketing Website v2",
"url": "https://layovelle.com/canvas/550e8400-e29b-41d4-a716-446655440000"
}
]
```
| Field | Type | Description |
| - | - | - |
| `[].id` | `string` | Canvas UUID |
| `[].name` | `string` | Canvas name |
| `[].url` | `string` | Direct canvas URL |
### Notes
* The search matches against canvas names and content
* Results are ranked by relevance
* Renders **no** interactive app — it's the JSON-only counterpart to `list_my_canvases`, which takes the same `query` and renders the Canvas Gallery. Use that one when the user should see the results
* The returned `url` can be passed directly to other tools like `get_moda_canvas`
* The maximum `limit` is 100 per request
***
## list\_organizations
Returns a list of organizations and teams you belong to. Use this to discover available workspaces, then call `set_context` to choose which one to use.
<Info>
This tool requires [authentication](/mcp/authentication) via the remote MCP server at `mcp.moda.app`. It is not
available when using the local `stdio` server.
</Info>
### Parameters
This tool takes no parameters.
### Returns
```json theme={null}
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Acme Corp",
"role": "admin",
"teams": [
{
"id": "660e8400-e29b-41d4-a716-446655440000",
"name": "Design Team",
"is_default": true
},
{
"id": "770e8400-e29b-41d4-a716-446655440000",
"name": "Marketing",
"is_default": false
}
]
}
]
```
| Field | Type | Description |
| - | - | - |
| `[].id` | `string` | Organization UUID |
| `[].name` | `string` | Organization name |
| `[].role` | `string` | Your role in the organization (admin or member) |
| `[].teams` | `array` | Teams you have access to within this org |
| `[].teams[].id` | `string` | Team UUID |
| `[].teams[].name` | `string` | Team name |
| `[].teams[].is_default` | `boolean` | Whether this is the org's default team |
### Notes
* Use org and team names with `set_context` to choose your active workspace
* IDs are included but typically don't need to be shown to users — use names in conversation instead
* Organizations are sorted alphabetically by name
* Only teams you have access to are included
***
## find\_brand\_kits
JSON-only sibling of [`list_brand_kits`](#list_brand_kits). Same data, same parameters — the difference is presentation. `find_brand_kits` does **not** render the visual brand-kit showcase iframe, so it's appropriate when the agent is just looking up a `brand_kit_id` to pass to another tool (e.g. `start_design_task`).
<Info>
This tool requires [authentication](/mcp/authentication) via the remote MCP server at `mcp.moda.app`. It is not
available when using the local `stdio` server.
</Info>
Per the [MCP Apps spec](https://blog.modelcontextprotocol.io/posts/2026-01-26-mcp-apps/), iframe rendering is decided at tool-listing time from a tool's `_meta.ui.resourceUri`. There is no per-call way to suppress rendering. So Layovelle exposes two tools sharing the same backing data:
* `find_brand_kits` — JSON-only. Use for the agent's own lookups, e.g. resolving a kit the user already named.
* `list_brand_kits` — renders the visual showcase. Use when the user asked to see their kits, or must choose one for a new design.
### Parameters
Identical to [`list_brand_kits`](#list_brand_kits): `query`, `org_id`, `team_id`, `verbose`, `limit`, `offset`.
### Returns
Identical to [`list_brand_kits`](#list_brand_kits).
***
## list\_brand\_kits
Returns brand kits for a team. **Renders the visual brand-kit showcase iframe on every call** (on app-aware hosts like claude.ai and Claude Desktop). Brand kits contain colors, fonts, logos, and brand guidelines that were extracted from company websites. Uses your session context (set via `set_context`) or defaults to your primary workspace.
It is the picker the agent shows when the user must choose a kit for a new design (see [`start_design_task`](#start_design_task)). For agent-side lookups where the iframe would take over screen space the user didn't ask for, prefer [`find_brand_kits`](#find_brand_kits) — same data, no UI.
<Info>
This tool requires [authentication](/mcp/authentication) via the remote MCP server at `mcp.moda.app`. It is not
available when using the local `stdio` server.
</Info>
### Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `query` | `string` | No | Case-insensitive name filter — matches the kit's title or its company name (substring). Omit to list every kit on the team. |
| `org_id` | `string` | No | Organization UUID. Overrides session context for this call. |
| `team_id` | `string` | No | Team UUID. Overrides session context for this call. |
| `verbose` | `boolean` | No | Default `true`. Include full kit detail (colors, fonts, logos, brand values). Pass `false` for an id/title-only projection. |
| `limit` | `integer` | No | Default `10`. Caps the response so teams with many kits don't overflow the host's tool-result context window. Use `offset` to paginate. |
| `offset` | `integer` | No | Default `0`. Pagination offset; check `has_more` in the response to know when to stop. |
If neither `org_id` nor `team_id` is provided, uses your [session context](#session-context) or defaults to your primary workspace.
### Returns
```json theme={null}
{
"brand_kits": [
{
"id": "880e8400-e29b-41d4-a716-446655440000",
"title": "Acme Corp",
"is_default": true,
"created_at": "2025-03-15T10:30:00",
"updated_at": "2025-03-16T14:20:00",
"company_name": "Acme Corp",
"company_url": "https://acme.com",
"company_description": "Modern design tools for teams",
"tagline": "Design at scale",
"brand_values": ["innovation", "simplicity"],
"brand_aesthetic": ["modern", "minimal"],
"brand_tone_of_voice": ["professional", "friendly"],
"default_color_mode": "light",
"default_theme_canvas_id": "cvs_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
"colors": [
{ "id": "clr_01HT9WK8N3M2J4A5Z6P7Q8R9TV", "color": "#2563eb", "label": "Primary", "mode": "light" },
{ "id": "clr_01HT9WK8N3M2J4A5Z6P7Q8R9TW", "color": "#111827", "label": "Text", "mode": null }
],
"fonts": [{ "family": "Inter", "label": "Body", "weight": 400, "supported": true }],
"logos": [
{
"group_name": "Primary Logo",
"images": [{ "name": "logo-dark.svg", "url": "https://..." }]
}
]
}
],
"team_id": "660e8400-e29b-41d4-a716-446655440000",
"total": 12,
"offset": 0,
"limit": 10,
"has_more": true
}
```
| Field | Type | Description |
| - | - | - |
| `[].id` | `string` | Brand kit UUID |
| `[].title` | `string` | Display name for the brand kit |
| `[].is_default` | `boolean` | Whether this kit is the team's default (the fallback for edits of a canvas with no kit) |
| `[].default_color_mode` | `string` | Preferred color mode when both light and dark palettes are present. Matches `colors[].mode` — typically `"light"` or `"dark"`, or `null` when no mode distinction exists. |
| `[].default_theme_canvas_id` | `string` | Prefixed `cvs_…` ID of the kit's saved default slides theme canvas, or `null`. Set/clear it via `update_brand_kit`. |
| `[].colors` | `array` | Ordered brand color palette (lower index = higher primacy) |
| `[].colors[].id` | `string` | Unique identifier for this color entry |
| `[].colors[].color` | `string` | Hex color value (e.g. `"#2563eb"`) |
| `[].colors[].label` | `string` | Semantic role label (e.g. `"Primary"`, `"Text"`) |
| `[].colors[].mode` | `string` | Color palette mode: `"light"`, `"dark"`, or `null` when the color isn't mode-specific |
| `[].fonts` | `array` | Brand typography entries |
| `[].logos` | `array` | Logo image groups |
When `verbose=false`, each kit drops the `colors`, `fonts`, `logos`, `brand_values`, `brand_aesthetic`, `brand_tone_of_voice`, `company_url`, and `company_description` fields — useful when you only need ids/titles to pick a kit by name.
`brand_aesthetic` is a deprecated keyword array describing the brand's visual style. The brand's visual aesthetic is otherwise captured internally and is not exposed through this API.
### Notes
* Uses your session context to determine the workspace. Call `set_context` to switch organizations or teams.
* Default-kit-first, then created-at descending. Stable across paginated calls when no edits intervene.
* The `is_default` kit does not settle a new design — `start_design_task` asks for the user's choice (`brand_kit_choice_required`), however many kits the team has.
* Cache: signed export URLs and signed CDN logo URLs are valid for 24h; a chat refresh that re-renders the showcase iframe within that window hits a Redis cache instead of re-signing.
***
## create\_brand\_kit
Creates a brand kit by extracting brand information from a company website. Provide a URL or domain and Layovelle will extract colors, fonts, logos, and brand guidelines automatically. Uses your session context (set via `set_context`) or defaults to your primary workspace.
<Info>
This tool requires [authentication](/mcp/authentication) via the remote MCP server at `mcp.moda.app`. It is not
available when using the local `stdio` server.
</Info>
### Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `url` | `string` | Yes | Website URL or domain (e.g., `stripe.com` or `https://stripe.com`) |
| `org_id` | `string` | No | Organization UUID. Overrides session context for this call. |
| `team_id` | `string` | No | Team UUID. Overrides session context for this call. |
### Returns
Returns the created brand kit in the same format as `list_brand_kits` entries.
```json theme={null}
{
"id": "880e8400-e29b-41d4-a716-446655440000",
"title": "Stripe",
"is_default": false,
"company_name": "Stripe",
"company_url": "https://stripe.com",
"company_description": "Financial infrastructure for the internet",
"default_color_mode": null,
"colors": [
{ "id": "clr_01HT9WK8N3M2J4A5Z6P7Q8R9TV", "color": "#635bff", "label": "Primary", "mode": null },
{ "id": "clr_01HT9WK8N3M2J4A5Z6P7Q8R9TW", "color": "#0a2540", "label": "Dark", "mode": null }
],
"fonts": [{ "family": "Inter", "label": "Body", "weight": 400, "supported": true }],
"logos": []
}
```
### Notes
* Extraction typically takes 10–30 seconds depending on the website
* Uses Firecrawl to scrape the website and extract brand data
* If the brand has been extracted before, a cached result is used for faster response
* The first brand kit created for a team is automatically set as the default
***
## update\_brand\_kit
Updates an existing brand kit. Pass only the fields you want to change — all other fields remain unchanged.
<Info>
This tool requires [authentication](/mcp/authentication) via the remote MCP server at `mcp.moda.app`. It is not
available when using the local `stdio` server.
</Info>
### Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `brand_kit_id` | `string` | Yes | ID of the brand kit to update |
| `title` | `string` | No | Display title for the brand kit |
| `colors` | `array` | No | Array of `{ color, label?, id?, mode? }` objects (replaces all colors). `color` is required (hex string). `id` is assigned automatically if omitted. |
| `fonts` | `array` | No | Array of `{ family, label, weight }` objects |
| `company_name` | `string` | No | Company name |
| `company_description` | `string` | No | Company description |
| `tagline` | `string` | No | Brand tagline |
| `brand_values` | `string[]` | No | List of brand values |
| `brand_aesthetic` | `string[]` | No | **Deprecated** list of aesthetic descriptors. The free-text `aesthetic` is derived during brand-kit population and is not settable here. |
| `brand_tone_of_voice` | `string[]` | No | List of tone descriptors |
| `default_theme_canvas_id` | `string` | No | Canvas ID (`cvs_…` or bare UUID) of a `template_type='theme'` canvas on the same team to set as the kit's default theme |
| `clear_default_theme_canvas_id` | `boolean` | No | Set `true` to clear the saved default theme. Mutually exclusive with `default_theme_canvas_id` |
Setting `default_theme_canvas_id` to a non-theme canvas — or one on a different team — fails with `invalid_theme_canvas`.
### Returns
Returns the updated brand kit in the same format as `list_brand_kits` entries.
### Notes
* Pass only the fields you want to change — omitted fields are not modified
* When updating `colors` or `fonts`, the entire array is replaced (not merged)
* You must have access to the team that owns the brand kit
***
## set\_default\_brand\_kit
Marks a brand kit as the team's default and clears the default flag on the previously-default kit. The default fills in when an edited canvas has no kit of its own; it does not stand in for the user's choice on a new design.
<Info>
This tool requires [authentication](/mcp/authentication) via the remote MCP server at `mcp.moda.app`. It is not
available when using the local `stdio` server.
</Info>
<Warning>
**Destructive — affects every team member.** This tool clears the default flag on whichever kit was previously default.
Only call it when the user explicitly asks to change their **team** default brand kit. For session-only changes
(no team-wide impact), use [`set_session_brand_kit`](#set_session_brand_kit) instead — that's what the Brand Kit
Showcase iframe's "Use for this session" button now calls.
</Warning>
### Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `brand_kit_id` | `string` | Yes | Brand kit UUID — bare UUID or prefixed `bk_` wire form is accepted |
### Returns
Returns the team's brand kits in the same shape as `list_brand_kits`, with the new default first. Use this to rerender any UI that depends on default-kit state without an additional round-trip.
### Example usage
```
# Promote a kit to default after the user explicitly asks
set_default_brand_kit(brand_kit_id="880e8400-e29b-41d4-a716-446655440000")
```
### Notes
* Idempotent: setting the already-default kit is a no-op
* Only the user's currently-active team is affected
* Pairs with the Brand Kit Showcase [interactive app](/mcp/interactive-apps), which calls this tool from its "Set as default" button
***
## list\_brand\_kit\_images
Returns every image attached to a brand kit — both logos and design references — in newest-insertion order. Useful when reconciling an existing brand kit before adding new images, since blindly calling `add_brand_kit_image` would create duplicates.
<Info>
This tool requires [authentication](/mcp/authentication) via the remote MCP server at `mcp.moda.app`. It is not
available when using the local `stdio` server.
</Info>
### Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `brand_kit_id` | `string` | Yes | Brand kit UUID — bare UUID or prefixed `bk_` wire form is accepted |
### Returns
```json theme={null}
{
"brand_kit_id": "bk_abc123",
"images": [
{
"id": "bki_550e8400e29b41d4a716446655440000",
"role": "logo",
"file_id": "file_660e8400e29b41d4a716446655440000",
"url": "https://assets-cdn.moda.app/brand-kits/.../logo-dark.svg",
"name": "logo-dark.svg",
"group_id": "770e8400-e29b-41d4-a716-446655440000",
"group_name": "Primary Logo"
},
{
"id": "bki_aa0e8400e29b41d4a716446655440000",
"role": "reference",
"file_id": "file_bb0e8400e29b41d4a716446655440000",
"url": "https://assets-cdn.moda.app/brand-kits/.../homepage-hero.png",
"name": "homepage-hero.png",
"group_id": "cc0e8400-e29b-41d4-a716-446655440000",
"group_name": "Website screenshots"
}
]
}
```
| Field | Type | Description |
| - | - | - |
| `brand_kit_id` | `string` | Echoed back for round-tripping |
| `images[].id` | `string` | Image-row ID (prefixed `bki_`) — pass to `remove_brand_kit_image` |
| `images[].role` | `string` | `"logo"` or `"reference"` |
| `images[].file_id` | `string` | Underlying file ID (prefixed `file_`) |
| `images[].url` | `string` | Direct URL to the image (CDN-served) |
| `images[].name` | `string` | Original filename, if known |
| `images[].group_id` | `string` | UUID of the containing logo group / reference group |
| `images[].group_name` | `string` | Display name of the group (e.g., `"Primary Logo"`, `"Photography"`) |
### Example usage
```
list_brand_kit_images(brand_kit_id="880e8400-e29b-41d4-a716-446655440000")
```
### Notes
* Pairs with the Brand Kit Image Gallery [interactive app](/mcp/interactive-apps), which renders the images grouped by role
* The image `url` is served from the signed CDN; treat it as opaque
***
## remove\_brand\_kit\_image
Detaches an image from a brand kit by its `bki_` ID. Returns the freshly re-listed images so callers can rerender from authoritative state.
<Info>
This tool requires [authentication](/mcp/authentication) via the remote MCP server at `mcp.moda.app`. It is not
available when using the local `stdio` server.
</Info>
<Warning>
**Destructive.** Only call this when the user explicitly asks to remove a specific image, or in response to a user
action (e.g., the per-card Remove button in the Brand Kit Image Gallery iframe). The underlying file remains in
storage; only the brand-kit reference is removed.
</Warning>
### Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `brand_kit_id` | `string` | Yes | Brand kit UUID — bare UUID or prefixed `bk_` wire form is accepted |
| `image_id` | `string` | Yes | Brand-kit image-row ID — bare UUID or prefixed `bki_` wire form |
### Returns
Returns the same payload shape as `list_brand_kit_images` after the removal completes, so the caller can rerender directly from the response.
### Example usage
```
remove_brand_kit_image(
brand_kit_id="880e8400-e29b-41d4-a716-446655440000",
image_id="bki_550e8400e29b41d4a716446655440000",
)
```
### Notes
* The underlying file is **not** deleted from storage — only the brand-kit reference is removed
* Pairs with the Brand Kit Image Gallery [interactive app](/mcp/interactive-apps), which calls this tool from each card's Remove button
* Not idempotent: calling twice with the same `image_id` raises a not-found error on the second call
***
## upload\_file
Uploads a file from a URL to Layovelle's storage. Returns a stable proxy URL that can be used as an attachment in `start_design_task`. Supports images, PDFs, Office documents (PowerPoint, Word, and Excel spreadsheets), CSV, plain-text/Markdown, and web-playable video (MP4, WebM, MOV).
<Info>
This tool requires [authentication](/mcp/authentication) via the remote MCP server at `mcp.moda.app`. It is not
available when using the local `stdio` server.
</Info>
### Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `source_url` | `string` | Yes | Public URL of the file to download and store |
| `filename` | `string` | No | Filename to use. Inferred from URL if omitted. |
| `org_id` | `string` | No | Organization UUID. Overrides session context for this call. |
| `team_id` | `string` | No | Team UUID. Overrides session context for this call. |
### Returns
```json theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://api.moda.app/api/v2/images/ref/550e8400-e29b-41d4-a716-446655440000?h=abc123",
"filename": "design-reference.png",
"mime_type": "image/png",
"size_bytes": 245760,
"was_duplicate": false,
"message": "File uploaded successfully. Use the 'url' field as an attachment URL in start_design_task."
}
```
| Field | Type | Description |
| - | - | - |
| `id` | `string` | Unique file identifier (UUID) |
| `url` | `string` | Stable proxy URL for the uploaded file |
| `filename` | `string` | Filename of the uploaded file |
| `mime_type` | `string` | MIME type of the file |
| `size_bytes` | `integer` | File size in bytes |
| `was_duplicate` | `boolean` | True if an identical file already existed |
| `message` | `string` | Human-readable confirmation message |
### Example usage
```
# Upload an image for use as a design reference
upload_file(source_url="https://example.com/mockup.png")
# Upload with a custom filename
upload_file(source_url="https://example.com/file.pdf", filename="brand-guidelines.pdf")
```
### Notes
* The returned `url` can be passed directly as an attachment URL in `start_design_task`
* Files are deduplicated by content hash — uploading the same file twice returns the existing record
* Supported types include images (PNG, JPEG, WebP, etc.), PDFs, Office documents (PowerPoint `.pptx`/`.ppt`, Word `.docx`/`.doc`, Excel `.xlsx`/`.xls`), CSV, plain text/Markdown, and web-playable video (MP4, WebM, MOV)
***
## start\_design\_task
Starts an AI design task using Layovelle's design agent. The agent creates or edits a canvas based on your prompt. Provide a `canvas_id` to edit an existing canvas, or omit it to create a new one.
By default this tool returns **immediately** in milliseconds with a task handle (`{task_id, canvas_id, canvas_url, status: 'queued'}`). On hosts that support [interactive apps](/mcp/interactive-apps) (claude.ai web, Claude Desktop), the paired Design Task Progress iframe shows live progress and rendered pages, so you don't need to block on completion. On non-interactive hosts, poll `get_task_status(task_id)` to track progress and detect completion. Pass `wait=True` to opt back into synchronous behavior — useful only for non-interactive consumers that need a single blocking call.
Uses your session context (set via `set_context`) or defaults to your primary workspace.
<Info>
This tool requires [authentication](/mcp/authentication) via the remote MCP server at `mcp.moda.app`. It is not
available when using the local `stdio` server.
</Info>
### Parameters
| Parameter | Type | Required | Default | Description |
| - | - | - | - | - |
| `prompt` | `string` | Yes | — | Design instructions for the AI agent |
| `canvas_id` | `string` | No | — | Canvas to edit. Omit to create a new canvas. Ignored when resuming a conversation. |
| `canvas_name` | `string` | No | — | Name for the new canvas (only used when `canvas_id` is omitted) |
| `brand_kit_id` | `string` | No | — | Brand kit to apply. For a new design, pass the kit the user chose (or `skip_brand_kit=true`) — omitting it is refused with `brand_kit_choice_required`, whatever the kit count. Edits keep the canvas's own kit. |
| `org_id` | `string` | No | — | Organization UUID. Overrides session context for this call. |
| `team_id` | `string` | No | — | Team UUID. Overrides session context for this call. |
| `conversation_id` | `string` | No | — | UUID of a previous conversation to resume. The agent will have full context of all prior interactions. Returned in every response — pass it back to continue. |
| `attachments` | `array` | No | — | List of reference files. Each item is **either** a URL-shape object `{url, name?, type?}` (type ∈ `"image"` / `"pdf"` / `"pptx"` / `"url"`) **or** a file-id-shape object `{file_id, role, label?}` where `file_id` is returned by `upload_file` and `role` ∈ `"source"` (extract content) / `"reference"` (emulate style) / `"asset"` (use directly in outputs) / `"import"` (PPTX-only — load the deck onto the canvas as editable slides, then design against it; any other file type is rejected — and because the deck sets its own page size, an `import` is rejected alongside any format field other than `format_category="slides"`). Prefer the file-id form for uploaded files — it surfaces role metadata to the agent. |
| `reference_canvas_ids` | `string[]` | No | — | List of canvas UUIDs to use as design inspiration. The agent can see these designs and reference their style, layout, or content. |
| `format_category` | `string` | No | — | Format category — **recommended** for new designs. One of `"slides"` (presentations), `"social"` (social posts), `"carousel"` (multi-slide social), `"pdf"` (documents/reports), `"diagram"` (flowcharts/diagrams), `"ui"` (UI mockups), `"animation"` (motion graphics), `"prints"` (posters/flyers/cards), `"web-ads"` (display ads), or `"other"`. Without it, a fresh canvas resolves to `"other"` at 1080×1080 — not the format you intended. |
| `format_width` | `integer` | No | — | Canvas width in pixels. Common: 1920x1080 (slides), 1080x1080 (social square), 1080x1920 (social story). Rejected alongside an `import` attachment — the deck sets its own page size. |
| `format_height` | `integer` | No | — | Canvas height in pixels. Rejected alongside an `import` attachment — the deck sets its own page size. |
| `model_tier` | `string` | No | — | AI model tier: `"pro"` (best for complex tasks), `"pro-fast"` (Pro quality with faster output and higher credit usage), `"standard"`, or `"lite"` (fastest). Defaults to automatic selection. |
| `wait` | `boolean` | No | `false` | When `false` (default), return immediately with a task handle so callers can render the live-progress iframe or poll `get_task_status`. When `true`, block until the agent finishes and return the complete design — only needed for synchronous, non-interactive consumers. |
| `export_on_complete` | `object` | No | — | Auto-export preferences applied when the task finishes — the rendered file is attached to `result.export` and the cache is warmed so a follow-up `export_canvas` for the same canvas hits it instantly. Shape: `{enabled?: boolean = true, format?: "png" \| "jpeg" \| "pdf" \| "pptx", pixel_ratio?: 1..4}`. Omit to use the canvas category default (`slides`→PPTX, `pdf`→PDF, others→PNG); an `animation` canvas defaults to video, which the auto-export pipeline does not render — request a still format here to get an auto-export of one. Pass `{enabled: false}` to skip the auto-export. Multi-page PNG/JPEG bundles into a `.zip` of per-page files; `result.export.format` is `"zip"` in that case. |
### Returns
```json theme={null}
{
"task_id": "990e8400-e29b-41d4-a716-446655440000",
"canvas_id": "550e8400-e29b-41d4-a716-446655440000",
"canvas_url": "https://layovelle.com/canvas/550e8400-e29b-41d4-a716-446655440000",
"conversation_id": "bb0e8400-e29b-41d4-a716-446655440000",
"theme_canvas_id": null,
"status": "queued",
"message": "Design task started. Use get_task_status to check progress.",
"retry_after_seconds": 3
}
```
`theme_canvas_id` reports the brand kit's slides theme that was auto-applied to the new canvas — the canvas whose page layouts the agent themes from. It's populated only when you create a **fresh slides deck** (`format_category="slides"`, no `canvas_id`) with a brand kit that has a saved default theme; it's `null` for non-slides designs, edits of existing canvases, and brand kits without a saved theme.
Once `get_task_status` reports `status: "completed"`, the same envelope additionally carries `result.export` with the auto-exported artifact:
```json theme={null}
{
"result": {
"canvas_id": "550e8400-e29b-41d4-a716-446655440000",
"canvas_url": "https://layovelle.com/canvas/550e8400-e29b-41d4-a716-446655440000",
"export": {
"url": "https://assets-cdn.moda.app/exports/abc123.pptx",
"format": "pptx",
"status": "completed",
"page_count": 8
}
}
}
```
| Field | Type | Description |
| - | - | - |
| `result.export.url` | `string` | Signed download URL for the rendered file (expires after 7 days). |
| `result.export.format` | `string` | Delivered format — `png`, `jpeg`, `pdf`, `pptx`, or `zip` (multi-page PNG/JPEG bundle). |
| `result.export.status` | `string` | `completed` when the file is ready; `failed` if the auto-export couldn't render (the design task still succeeded). |
| `result.export.page_count` | `integer` | Total pages in the canvas — useful for sizing UIs ahead of downloading. |
### Example usage
```
# Create a new design (returns a conversation_id)
start_design_task(prompt="Create a modern SaaS landing page with a hero section, features grid, and pricing table")
# Resume the conversation to make changes (agent remembers previous context)
start_design_task(prompt="Change the color scheme to use blues and grays", conversation_id="bb0e8400...")
# Edit an existing canvas
start_design_task(prompt="Add a footer section", canvas_id="550e8400...")
# Create slides with specific dimensions
start_design_task(prompt="Create a pitch deck", format_category="slides", format_width=1920, format_height=1080)
# Create a PDF document (report, resume, etc.)
start_design_task(prompt="Create a project report", format_category="pdf")
# Provide reference images as attachments — file-id form (recommended)
# Upload first, then reference the returned file_id with a role:
uploaded = upload_file(source_url="https://example.com/reference.png")
start_design_task(
prompt="Recreate this design in our brand style",
attachments=[{"file_id": uploaded["id"], "role": "reference", "label": "brand-style ref"}],
brand_kit_id="880e8400..."
)
# Or the legacy URL form — still accepted for hosted public URLs:
start_design_task(
prompt="Recreate this design in our brand style",
attachments=[{"url": "https://example.com/reference.png", "type": "image"}],
brand_kit_id="880e8400..."
)
# Mix shapes in one call — a source brief plus a reference image:
start_design_task(
prompt="Build a pitch deck from the brief; match the reference style",
attachments=[
{"file_id": "file_01HT9W…", "role": "source", "label": "Q2 strategy brief"},
{"file_id": "file_01HT9X…", "role": "reference"},
],
)
# Use existing canvases as design inspiration
start_design_task(
prompt="Create a similar landing page",
reference_canvas_ids=["550e8400...", "660e8400..."]
)
# Canonical MCP flow: start -> poll -> read result.export (no second render)
task = start_design_task(prompt="Create a sales deck", format_category="slides")
status = get_task_status(task_id=task["task_id"])
while not status["can_export"] and not status["is_terminal"]:
# Wait status["retry_after_seconds"] seconds, then poll again.
status = get_task_status(task_id=task["task_id"])
# When the task auto-exports successfully, the rendered file rides in
# status["result"]["export"] — read it directly instead of calling
# export_canvas (the cache would hit, but result.export saves a round trip).
if status["can_export"]:
export_info = status["result"]["export"]
print(export_info["url"], export_info["format"]) # e.g. ".../abc.pptx", "pptx"
# Opt out of the auto-export when you don't need it:
start_design_task(prompt="Iterate on the layout", canvas_id="550e8400...", export_on_complete={"enabled": False})
# Or override the format/scale (e.g. PDF instead of the slides category default):
start_design_task(prompt="Build a deck", format_category="slides", export_on_complete={"format": "pdf", "pixel_ratio": 3})
```
### Notes
* The default `wait=False` returns a task handle in milliseconds. Pass `wait=True` only when you specifically want a single blocking call.
* On iframe-aware hosts (claude.ai web, Claude Desktop), the paired Design Task Progress app polls and renders pages on its own — see [Interactive Apps](/mcp/interactive-apps).
* Use `retry_after_seconds` as the default delay before the first `get_task_status(task_id)` call
* Keep polling until `can_export == true` or `is_terminal == true`
* `can_export` describes the canvas, not the outcome: it becomes `true` for any terminal task whose canvas has renderable pages, even if the task itself failed
* A new design (no `canvas_id`, `conversation_id`, or `template_canvas_id`) needs a brand choice — `brand_kit_id`, `skip_brand_kit=true`, or a session pin. Without one it is refused with `brand_kit_choice_required` — whatever the kit count, even with a team default — listing the kits so the agent can ask the user: one of them, a new kit, or none. Edits keep the canvas's own kit
* If no `canvas_id` is provided, a new canvas is created with the name from `canvas_name` or "Untitled"
* Every response includes a `conversation_id`. Pass it back in subsequent calls so the agent has context of all previous interactions.
* When resuming a conversation, the `canvas_id` parameter is ignored — the agent automatically operates on the conversation's canvas
* Use `upload_file` to upload local files first, then pass the returned `file_id` in an `attachments` item with a `role` (`source` / `reference` / `asset` / `import`). `import` is PPTX-only — it loads the deck onto the canvas as editable slides. The older URL form is still accepted but drops role metadata.
* Reference canvas IDs let the agent see and draw inspiration from existing designs without modifying them
* Always pass `format_category` when creating new designs — without it, a fresh canvas is created as a generic `"other"` canvas at 1080×1080 with no format skill, so a deck/post/document gets a generic layout
* The auto-export attached to `result.export` is keyed identically to a follow-up `export_canvas(canvas_id=...)`, so calling `export_canvas` afterward with default args hits the cache instead of re-rendering. Pin `export_on_complete.format` and `export_on_complete.pixel_ratio` when you need a non-default artifact — the cache match depends on the exact `(format, pixel_ratio)` pair.
***
## get\_task\_status
Returns the status and progress of a specific design task. Use this to check on tasks started with `start_design_task` or `remix_design`.
<Info>
This tool requires [authentication](/mcp/authentication) via the remote MCP server at `mcp.moda.app`. It is not
available when using the local `stdio` server.
</Info>
### Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `task_id` | `string` | Yes | ID of the task to check |
### Returns
```json theme={null}
{
"task_id": "990e8400-e29b-41d4-a716-446655440000",
"canvas_id": "550e8400-e29b-41d4-a716-446655440000",
"canvas_url": "https://layovelle.com/canvas/550e8400-e29b-41d4-a716-446655440000",
"conversation_id": "bb0e8400-e29b-41d4-a716-446655440000",
"theme_canvas_id": null,
"status": "running",
"prompt": "Create a modern SaaS landing page...",
"progress_percent": 45,
"current_step": "Generating hero section",
"is_terminal": false,
"can_export": false,
"retry_after_seconds": 3,
"operations_streamed": 12,
"created_at": "2025-03-15T10:30:00",
"started_at": "2025-03-15T10:30:02",
"completed_at": null,
"error": null
}
```
| Field | Type | Description |
| - | - | - |
| `task_id` | `string` | Task UUID |
| `canvas_id` | `string` | Canvas being edited |
| `canvas_url` | `string` | Direct link to the canvas |
| `conversation_id` | `string` | Conversation UUID. Pass to `start_design_task` to resume the conversation. |
| `theme_canvas_id` | `string` | Brand-kit slides theme attached to the canvas, or `null` when unthemed. |
| `status` | `string` | `queued`, `running`, `cancelling`, `completed`, `failed`, `cancelled`, `dead_letter`, `canvas_deleted`, or `insufficient_credits` |
| `prompt` | `string` | The original prompt, in full |
| `progress_percent` | `integer` | Estimated progress (0-100), if available |
| `current_step` | `string` | Description of what the agent is currently doing |
| `is_terminal` | `boolean` | `true` when the task is finished and polling can stop |
| `can_export` | `boolean` | `true` when the task is terminal and the canvas has exportable content, so `export_canvas` can be called — set even for failed or dead-lettered tasks |
| `retry_after_seconds` | `integer` | Suggested delay before polling again when the task is still in progress |
| `operations_streamed` | `integer` | Number of canvas operations applied so far |
| `created_at` | `string` | Job creation timestamp (ISO 8601) |
| `started_at` | `string` | When the agent started working (ISO 8601) |
| `completed_at` | `string` | When the task finished (ISO 8601), or `null` |
| `error` | `string` | Error message if the task failed, or `null` |
### Notes
* Use this after `start_design_task` to poll for progress
* Poll again after `retry_after_seconds` while `is_terminal == false`
* On `status="completed"`, the response carries a `result.export` block (`{url, format, status, page_count}`) with the auto-exported design — read it directly instead of calling `export_canvas` for the same canvas. If the auto-export was disabled via `export_on_complete: {enabled: false}`, or skipped because the resolved format is a video (an `animation` canvas with no explicit `export_on_complete.format`), `result.export` is absent.
* Failed or cancelled tasks are terminal and still set `can_export` when the canvas has renderable pages — if `can_export` is `true`, export the design rather than regenerating it
* The `prompt` field carries the user's original prompt in full (the task-progress iframe surfaces it verbatim)
***
## list\_tasks
Returns a list of recent design tasks. Use this to see what design tasks have been run recently, optionally filtered by canvas or status.
<Info>
This tool requires [authentication](/mcp/authentication) via the remote MCP server at `mcp.moda.app`. It is not
available when using the local `stdio` server.
</Info>
### Parameters
| Parameter | Type | Required | Default | Description |
| - | - | - | - | - |
| `canvas_id` | `string` | No | — | Filter tasks to a specific canvas |
| `status` | `string` | No | — | Filter by status (`queued`, `running`, `cancelling`, `completed`, `failed`, `cancelled`, `dead_letter`, `canvas_deleted`, `insufficient_credits`) |
| `limit` | `integer` | No | `10` | Number of tasks to return (max 50) |
### Returns
```json theme={null}
{
"tasks": [
{
"task_id": "990e8400-e29b-41d4-a716-446655440000",
"canvas_id": "550e8400-e29b-41d4-a716-446655440000",
"canvas_url": "https://layovelle.com/canvas/550e8400-e29b-41d4-a716-446655440000",
"conversation_id": "bb0e8400-e29b-41d4-a716-446655440000",
"theme_canvas_id": null,
"status": "completed",
"prompt": "Create a modern SaaS landing page...",
"progress_percent": 100,
"current_step": null,
"operations_streamed": 42,
"created_at": "2025-03-15T10:30:00",
"started_at": "2025-03-15T10:30:02",
"completed_at": "2025-03-15T10:32:15",
"error": null
}
]
}
```
### Notes
* Tasks are returned in order of most recently created
* The maximum `limit` is 50 per request
* Only tasks for canvases you have access to are returned
***
## remix\_design
Duplicates an existing canvas and optionally starts a design task on the copy. Use this to create variations of existing designs without modifying the original. When a prompt is provided, the call blocks by default (`wait=true`, unlike `start_design_task`) until the agent finishes and returns the completed remix — pass `wait=false` to get a task handle immediately and poll `get_task_status` the same way as `start_design_task`. Uses your session context (set via `set_context`) or defaults to your primary workspace.
<Info>
This tool requires [authentication](/mcp/authentication) via the remote MCP server at `mcp.moda.app`. It is not
available when using the local `stdio` server.
</Info>
### Parameters
| Parameter | Type | Required | Default | Description |
| - | - | - | - | - |
| `canvas_id` | `string` | Yes | — | Canvas to duplicate |
| `prompt` | `string` | No | — | Design instructions to apply to the copy. Omit for a plain duplicate. |
| `new_name` | `string` | No | — | Name for the new canvas. Defaults to `"Remix of <original name>"`. |
| `wait` | `boolean` | No | `true` | When `true` (default) and a `prompt` is supplied, block until the agent finishes and return the completed remix. Pass `false` to get a task handle immediately. |
| `brand_kit_id` | `string` | No | — | Brand kit to apply (only used when `prompt` is provided) |
| `org_id` | `string` | No | — | Organization UUID. Overrides session context for this call. |
| `team_id` | `string` | No | — | Team UUID. Overrides session context for this call. |
### Returns
Without a prompt (plain duplicate):
```json theme={null}
{
"canvas_id": "aa0e8400-e29b-41d4-a716-446655440000",
"canvas_url": "https://layovelle.com/canvas/aa0e8400-e29b-41d4-a716-446655440000",
"canvas_name": "Remix of Marketing Website",
"source_canvas_id": "550e8400-e29b-41d4-a716-446655440000",
"message": "Canvas duplicated successfully."
}
```
With a prompt and `wait=false` (duplicate + backgrounded design task):
```json theme={null}
{
"canvas_id": "aa0e8400-e29b-41d4-a716-446655440000",
"canvas_url": "https://layovelle.com/canvas/aa0e8400-e29b-41d4-a716-446655440000",
"canvas_name": "Remix of Marketing Website",
"source_canvas_id": "550e8400-e29b-41d4-a716-446655440000",
"task_id": "990e8400-e29b-41d4-a716-446655440000",
"task_status": "queued",
"message": "Design task started. Use get_task_status to check progress.",
"retry_after_seconds": 3
}
```
### Example usage
```
# Plain duplicate
remix_design(canvas_id="550e8400...")
# Duplicate and restyle
remix_design(canvas_id="550e8400...", prompt="Change the color scheme to dark mode")
# Duplicate with new branding
remix_design(canvas_id="550e8400...", prompt="Apply our new brand", brand_kit_id="880e8400...")
```
### Notes
* The original canvas is never modified — all changes are applied to the copy
* When a `prompt` is provided, a design task is started on the duplicate; with the default `wait=true` the call blocks until the task finishes — pass `wait=false` to return immediately with a task handle
* Poll `get_task_status(task_id)` until `can_export == true` or the task reaches a terminal failure state, same as `start_design_task`
***
## Websites
Layovelle websites are HTML projects you can draft and manage, then publish from the Layovelle editor to a public `*.layovelle.com` URL. These MCP tools let your agent draft, inspect, and update websites in Layovelle — available to every authenticated MCP user.
Publishing the resulting draft is opt-in: `create_website_from_upload` and `update_website_from_upload` both accept a `publish` flag that defaults to `false`. Publishing runs through Layovelle's server-side publish gate (content screening plus publish-rate, live-site, and new-account quotas); when the gate blocks a publish the draft is still saved and the response comes back with `published_url: null`. The user can publish from the editor once any blocking gate condition clears — the editor runs through the same gate.
HTML payloads are uploaded out-of-band (presigned URL → PUT bytes → call the tool with the returned `storage_key`) so large HTML documents — especially ones with inlined base64 images — don't have to pass through MCP tool args. Inline `data:` images are extracted to Layovelle Files automatically so the published artifact stays small.
<Info>
Every website tool requires [authentication](/mcp/authentication) via the remote MCP server at `mcp.moda.app`. The
local `stdio` server does not expose them.
</Info>
***
## list\_my\_websites
Lists the websites visible to the caller in the active team, newest-updated first. Scoped to the team in your session context — call `set_context` first if you belong to multiple teams.
### Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `limit` | `integer` | No | Page size (default 20, clamped to 100 server-side). |
| `offset` | `integer` | No | Pagination offset, 0-indexed. |
### Returns
```json theme={null}
{
"websites": [
{
"id": "bb0e8400-e29b-41d4-a716-446655440000",
"title": "Launch Announcement",
"url": "https://launch-announcement.layovelle.com",
"share_state": "public",
"comments_enabled": true,
"recent_comments_count": 3,
"updated_at": "2026-05-26T17:42:11Z"
}
],
"total": 1,
"limit": 20,
"offset": 0,
"has_more": false
}
```
`url` is `null` for unpublished drafts.
***
## get\_website
Fetches the full agent-facing projection of one website — the source HTML for the home page, title, share state, comments toggle, editor URL, and (if published) the live `*.layovelle.com` URL. Use this before calling `update_website_from_upload` so the agent has the current source as a base.
### Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `website_id` | `string` | Yes | ID of the website to fetch. |
### Returns
```json theme={null}
{
"website_id": "bb0e8400-e29b-41d4-a716-446655440000",
"title": "Launch Announcement",
"html": "<!doctype html><html>…</html>",
"editor_url": "https://layovelle.com/website/bb0e8400-e29b-41d4-a716-446655440000?source=agent",
"published_url": "https://launch-announcement.layovelle.com",
"url": "https://launch-announcement.layovelle.com",
"share_state": "public",
"comments_enabled": true,
"recent_comments_summary": []
}
```
`published_url` and `url` are both `null` for unpublished drafts. `url` is a deprecated alias kept for backward compatibility — prefer `published_url` in new code.
***
## create\_website\_from\_upload
Creates a new Layovelle website from an HTML file. The HTML is uploaded out-of-band first so large documents (especially ones with inlined base64 images) don't have to pass through MCP args.
**Required upload sequence:**
1. `create_upload_url(filename="site.html", mime_type="text/html")` → returns `{upload_url, storage_key}`.
2. PUT the HTML bytes to `upload_url` with header `Content-Type: text/html`.
3. Call `create_website_from_upload(storage_key=…)` with the `storage_key` from step 1.
Server-side, Layovelle extracts every inline `data:image/…` URI to a CDN-hosted Layovelle File, rewrites the HTML to reference the proxy URL, then creates the website row. Sites default to `category='html-document'`, `share_state='unlisted'`, and `comments_enabled=true`.
### Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `storage_key` | `string` | Yes | The `storage_key` from `create_upload_url`, after you PUT the HTML bytes to its `upload_url`. Not the `upload_url` itself, not a filename. |
| `title` | `string` | No | Display title. Omit to let Layovelle auto-generate a title from the page content. |
| `publish` | `boolean` | No | When `true`, also publish to a slugified `*.layovelle.com` URL. Defaults to `false` (draft only). Subject to the server-side publish gate (content screening and publish quotas); if the gate blocks the publish the draft is still saved and `published_url` comes back `null`. |
| `category` | `string` | No | Website category. Defaults to `html-document` for uploaded HTML documents. Valid values are `html-document`, `landing-page`, `email`, and `other`. |
### Returns
```json theme={null}
{
"website_id": "bb0e8400-e29b-41d4-a716-446655440000",
"editor_url": "https://layovelle.com/website/bb0e8400-e29b-41d4-a716-446655440000?source=agent",
"published_url": "https://launch-announcement.layovelle.com",
"share_state": "unlisted",
"comments_enabled": true,
"extracted_image_count": 4,
"skipped_inline_asset_count": 0
}
```
* `editor_url` is always present — give this link to the user. The `?source=agent` marker is a presentation hint the app consumes when the link opens; pass the URL through unchanged.
* `published_url` is the live `*.layovelle.com` URL when `publish=true` succeeded; `null` for drafts. Do **not** fabricate a `*.layovelle.com` URL from the `website_id`.
***
## update\_website\_from\_upload
Replace the HTML and/or title of an existing website. The website\_id is preserved, existing comments stay anchored via a reconciliation pass over the new DOM, and inline `data:` images in the uploaded HTML are extracted to Layovelle Files (same pipeline as `create_website_from_upload`).
Same upload sequence as `create_website_from_upload` — omit `storage_key` to keep the current HTML and only rename via `title`.
### Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `website_id` | `string` | Yes | The website's ID. |
| `storage_key` | `string` | No | `storage_key` from `create_upload_url` after you PUT the new HTML bytes. Omit to keep the current HTML and only change the title. |
| `title` | `string` | No | New display title. Omit to keep the current title. |
| `publish` | `boolean` | No | When `true`, republish so the live URL reflects the new content. Defaults to `false` (draft update). Subject to the same server-side publish gate as `create_website_from_upload`. |
Supply at least one of `storage_key` or `title` — a call with neither is rejected.
### Returns
```json theme={null}
{
"website_id": "bb0e8400-e29b-41d4-a716-446655440000",
"editor_url": "https://layovelle.com/website/bb0e8400-e29b-41d4-a716-446655440000?source=agent",
"published_url": "https://launch-announcement.layovelle.com",
"updated_at": "2026-05-26T17:42:11Z",
"extracted_image_count": 4,
"skipped_inline_asset_count": 0
}
```
`published_url` is `null` when `publish=false` — the live URL keeps serving the previously-published version until the user republishes from the editor.
Layovelle
llms-full.txt
Documentation