@lottiefiles/creator-mcp 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +5 -5
  2. package/dist/index.mjs +2595 -198
  3. package/package.json +4 -4
package/dist/index.mjs CHANGED
@@ -18,89 +18,36 @@ const WS_HOST = "127.0.0.1";
18
18
  const WS_URL = `ws://${WS_HOST}:${DEFAULT_WS_PORT}`;
19
19
  /** Custom close code: another tab is already connected. */
20
20
  const WS_CLOSE_ANOTHER_TAB = 4001;
21
- /** Canonical tool name for the run_script MCP tool. */
22
- const RUN_SCRIPT_TOOL_NAME = "run_script";
23
- /** Canonical tool description for the run_script MCP tool. */
24
- const RUN_SCRIPT_TOOL_DESCRIPTION = `Execute a JavaScript script in the LottieFiles Creator animation editor. The script runs in a sandboxed runtime with access to the \`creator\` global object (the Creator API) for reading and manipulating animations, layers, shapes, keyframes, colors, and more. Use console.log() to output results — all console output is captured and returned.
25
-
26
- IMPORTANT: Before calling this tool, you MUST call these tools (once per session):
27
- 1. get_api_doc — The API type definitions (paginated, read ALL pages). The Creator API is unique and cannot be guessed.
28
- 2. get_rules — Conventions like layer ordering that affect visual correctness.
29
-
30
- Skipping these tools will lead to TypeError and incorrect scripts. Call them first, then write your script.`;
31
- /** Canonical tool name for the get_api_doc MCP tool. */
32
- const GET_API_DOC_TOOL_NAME = "get_api_doc";
33
- /** Lines per page for paginated get_api_doc responses. */
34
- const API_DOC_PAGE_SIZE = 1e3;
35
- /** Canonical tool description for the get_api_doc MCP tool. */
36
- const GET_API_DOC_TOOL_DESCRIPTION = `Get the TypeScript type definitions for the Creator API. The API is large, so results are paginated (~1000 lines per page). Call with page=1 first, then continue calling with each subsequent page number until all pages are read.
37
-
38
- YOU MUST read ALL pages before writing any scripts with run_script. The Creator API is unique and cannot be guessed from common patterns. Skipping pages will cause TypeErrors and incorrect scripts.`;
39
- /** Canonical tool name for the get_rules MCP tool. */
40
- const GET_RULES_TOOL_NAME = "get_rules";
41
- /** Canonical tool description for the get_rules MCP tool. */
42
- const GET_RULES_TOOL_DESCRIPTION = "Get the rules and conventions for writing Creator scripts. YOU MUST call this tool before writing scripts to avoid common pitfalls like incorrect layer ordering.";
43
21
  /** Canonical MCP setup guide URL. */
44
22
  const MCP_GUIDE_URL = "https://lottie.link/mcp-guide";
45
- /** Message returned when a script produces no console output. */
46
- const NO_RETURN_VALUE_MESSAGE = "(Script executed successfully, no console output)";
47
23
 
48
24
  //#endregion
49
25
  //#region ../creator-mcp-shared/src/schemas.ts
50
- /** Zod schema for validating incoming ScriptRequest messages over WebSocket. */
51
- const scriptRequestSchema = z.object({
26
+ const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
27
+ /** Server -> Client: request to execute a Creator native gateway tool. */
28
+ const toolRequestSchema = z.object({
52
29
  id: z.string(),
53
- type: z.literal("run_script"),
54
- script: z.string()
30
+ type: z.literal("tool_request"),
31
+ name: z.string().min(1),
32
+ input: z.unknown()
33
+ }).refine((request) => hasOwn(request, "input"), {
34
+ message: "Tool request must include input",
35
+ path: ["input"]
55
36
  });
56
- /** Zod schema for validating ScriptResponse messages over WebSocket. */
57
- const scriptResponseSchema = z.object({
58
- type: z.literal("script_response"),
37
+ /** Client -> Server: result or error from a Creator native gateway tool. */
38
+ const toolResponseSchema = z.object({
39
+ type: z.literal("tool_response"),
59
40
  id: z.string(),
60
- logs: z.array(z.string()),
41
+ result: z.unknown().optional(),
61
42
  error: z.string().optional()
43
+ }).refine((response) => hasOwn(response, "result") !== hasOwn(response, "error"), "Tool response must include exactly one of result or error");
44
+ /** Client -> Server: confirms that Creator durably queued a tool request. */
45
+ const toolRequestAckSchema = z.object({
46
+ type: z.literal("tool_request_ack"),
47
+ id: z.string()
62
48
  });
63
-
64
- //#endregion
65
- //#region ../creator-mcp-shared/src/resources.ts
66
- /** Path to the Creator API type definitions, relative to the Creator origin. */
67
- const TYPES_RESOURCE = { path: "/mcp-resources/types.d.ts" };
68
- /** Path to the Creator scripting rules, relative to the Creator origin. */
69
- const RULES_RESOURCE = { path: "/mcp-resources/rules.md" };
70
-
71
- //#endregion
72
- //#region ../creator-mcp-shared/src/format-tool-result.ts
73
- /** Format script execution logs and optional error into an MCP tool result. */
74
- function formatToolResult(logs, error) {
75
- if (error) return {
76
- content: [{
77
- type: "text",
78
- text: logs.length > 0 ? `${logs.join("\n")}\n\nError: ${error}` : `Error: ${error}`
79
- }],
80
- isError: true
81
- };
82
- return { content: [{
83
- type: "text",
84
- text: logs.length > 0 ? logs.join("\n") : NO_RETURN_VALUE_MESSAGE
85
- }] };
86
- }
87
- /** Paginate a large text document and return a single page with navigation hints. */
88
- function paginateText(text, page, toolName, pageSize = API_DOC_PAGE_SIZE) {
89
- const lines = text.split("\n");
90
- const totalPages = Math.ceil(lines.length / pageSize);
91
- if (page < 1 || page > totalPages) return { content: [{
92
- type: "text",
93
- text: `Invalid page ${page}. The document has ${totalPages} pages.`
94
- }] };
95
- const start = (page - 1) * pageSize;
96
- const pageLines = lines.slice(start, start + pageSize);
97
- const header = `[Page ${page} of ${totalPages}]`;
98
- const footer = page < totalPages ? `\n\n[End of page ${page}/${totalPages} — IMPORTANT: You MUST call ${toolName} with page=${page + 1} to continue reading. Do NOT write scripts until you have read ALL pages.]` : `\n\n[End of page ${page}/${totalPages} — You have read the complete API documentation.]`;
99
- return { content: [{
100
- type: "text",
101
- text: `${header}\n\n${pageLines.join("\n")}${footer}`
102
- }] };
103
- }
49
+ /** Every protocol message sent from the Creator client to the Node relay. */
50
+ const toolClientMessageSchema = z.union([toolRequestAckSchema, toolResponseSchema]);
104
51
 
105
52
  //#endregion
106
53
  //#region src/constants.ts
@@ -110,13 +57,12 @@ const PACKAGE_VERSION = pkg.version;
110
57
  const env = z.object({
111
58
  CREATOR_URL: z.string().url().default("https://creator.lottiefiles.com"),
112
59
  WS_PORT: z.coerce.number().int().min(1).max(65535).default(3847),
113
- SCRIPT_TIMEOUT_MS: z.coerce.number().int().min(1e3).max(3e5).default(6e4)
60
+ SCRIPT_TIMEOUT_MS: z.coerce.number().int().min(1e3).max(3e5).default(7e4)
114
61
  }).parse(process.env);
115
62
  const CREATOR_URL = env.CREATOR_URL;
116
63
  const WS_PORT = env.WS_PORT;
117
64
  const PING_INTERVAL_MS = 5e3;
118
- const SCRIPT_TIMEOUT_MS = env.SCRIPT_TIMEOUT_MS;
119
- const WEBSOCKET_SERVER_UNAVAILABLE_MESSAGE = "Creator MCP bridge is unavailable in this session. Another Creator MCP server may already be running in a different app or terminal. Stop other Creator MCP servers, then restart this one.";
65
+ const TOOL_TIMEOUT_MS = env.SCRIPT_TIMEOUT_MS;
120
66
  const NO_CREATOR_CONNECTED_MESSAGE = `No Creator tab is connected. Open LottieFiles Creator at ${CREATOR_URL} in your browser and enable MCP. If you need setup help, see ${MCP_GUIDE_URL}`;
121
67
 
122
68
  //#endregion
@@ -157,10 +103,13 @@ const logger = new Logger();
157
103
  //#endregion
158
104
  //#region src/websocket-server.ts
159
105
  const allowedOrigin = new URL(CREATOR_URL).origin;
106
+ const PIPELINE_DRAINING_MESSAGE = "Tool pipeline is waiting for the timed-out execution to settle";
160
107
  var ConnectionManager = class {
161
108
  _wss = null;
162
109
  _activeConnection = null;
163
110
  _pendingRequests = /* @__PURE__ */ new Map();
111
+ _requestQueue = [];
112
+ _isDraining = false;
164
113
  _pingInterval = null;
165
114
  _isWebSocketReady = false;
166
115
  async start(port = DEFAULT_WS_PORT) {
@@ -202,13 +151,15 @@ var ConnectionManager = class {
202
151
  isAlive: true
203
152
  };
204
153
  logger.info(`Tab connected (id: ${id})`);
154
+ this._resumePendingRequest();
155
+ this._processNextRequest();
205
156
  ws.on("pong", () => {
206
157
  if (this._activeConnection?.id === id) this._activeConnection.isAlive = true;
207
158
  });
208
159
  ws.on("message", (data) => {
209
160
  try {
210
161
  const parsed = JSON.parse(data.toString());
211
- const result = scriptResponseSchema.safeParse(parsed);
162
+ const result = toolClientMessageSchema.safeParse(parsed);
212
163
  if (!result.success) {
213
164
  logger.warn("Received message with unexpected shape:", JSON.stringify(parsed));
214
165
  return;
@@ -219,34 +170,38 @@ var ConnectionManager = class {
219
170
  }
220
171
  });
221
172
  ws.on("close", () => {
222
- this._handleDisconnect();
173
+ this._handleDisconnect(id);
223
174
  });
224
175
  ws.on("error", () => {
225
- this._handleDisconnect();
176
+ this._handleDisconnect(id);
226
177
  });
227
178
  });
228
179
  });
229
180
  }
230
181
  _handleMessage(message) {
231
- if (message.type !== "script_response") return;
232
182
  const pending = this._pendingRequests.get(message.id);
233
183
  if (!pending) {
234
184
  logger.warn(`Received response for unknown request id: ${message.id}`);
235
185
  return;
236
186
  }
187
+ if (message.type === "tool_request_ack") {
188
+ pending.accepted = true;
189
+ return;
190
+ }
237
191
  clearTimeout(pending.timer);
238
192
  this._pendingRequests.delete(message.id);
193
+ if (pending.timedOut) {
194
+ this._isDraining = false;
195
+ logger.info(`Timed-out tool execution settled (request id: ${message.id})`);
196
+ return;
197
+ }
239
198
  pending.resolve(message);
199
+ this._processNextRequest();
240
200
  }
241
- _handleDisconnect() {
242
- if (!this._activeConnection) return;
201
+ _handleDisconnect(connectionId) {
202
+ if (this._activeConnection?.id !== connectionId) return;
243
203
  const { id } = this._activeConnection;
244
204
  this._activeConnection = null;
245
- for (const [reqId, pending] of this._pendingRequests) {
246
- clearTimeout(pending.timer);
247
- pending.reject(/* @__PURE__ */ new Error("Creator tab disconnected during script execution"));
248
- this._pendingRequests.delete(reqId);
249
- }
250
205
  logger.info(`Tab disconnected (id: ${id})`);
251
206
  }
252
207
  _startPingInterval() {
@@ -256,7 +211,7 @@ var ConnectionManager = class {
256
211
  if (!conn.isAlive) {
257
212
  logger.warn(`Tab unresponsive, terminating (id: ${conn.id})`);
258
213
  conn.ws.terminate();
259
- this._handleDisconnect();
214
+ this._handleDisconnect(conn.id);
260
215
  return;
261
216
  }
262
217
  conn.isAlive = false;
@@ -269,44 +224,89 @@ var ConnectionManager = class {
269
224
  get isWebSocketReady() {
270
225
  return this._isWebSocketReady;
271
226
  }
272
- async executeScript(script) {
227
+ async executeTool(name, input) {
228
+ if (this._isDraining) throw new Error(PIPELINE_DRAINING_MESSAGE);
273
229
  if (!this._activeConnection) throw new Error(NO_CREATOR_CONNECTED_MESSAGE);
230
+ return new Promise((resolve, reject) => {
231
+ this._requestQueue.push({
232
+ name,
233
+ input,
234
+ resolve,
235
+ reject
236
+ });
237
+ this._processNextRequest();
238
+ });
239
+ }
240
+ _processNextRequest() {
241
+ if (this._pendingRequests.size > 0) return;
242
+ const queued = this._requestQueue.shift();
243
+ if (!queued) return;
274
244
  const conn = this._activeConnection;
245
+ if (!conn) {
246
+ const error = new Error(NO_CREATOR_CONNECTED_MESSAGE);
247
+ queued.reject(error);
248
+ this._rejectQueuedRequests(error);
249
+ return;
250
+ }
275
251
  const id = randomUUID();
276
252
  const request = {
277
253
  id,
278
- type: "run_script",
279
- script
254
+ type: "tool_request",
255
+ name: queued.name,
256
+ input: queued.input
280
257
  };
281
- return new Promise((resolve, reject) => {
282
- const timer = setTimeout(() => {
283
- this._pendingRequests.delete(id);
284
- reject(/* @__PURE__ */ new Error(`Script execution timed out after ${SCRIPT_TIMEOUT_MS / 1e3}s`));
285
- }, SCRIPT_TIMEOUT_MS);
286
- this._pendingRequests.set(id, {
287
- resolve,
288
- reject,
289
- timer
290
- });
291
- try {
292
- conn.ws.send(JSON.stringify(request));
293
- } catch (error) {
294
- clearTimeout(timer);
295
- this._pendingRequests.delete(id);
296
- reject(/* @__PURE__ */ new Error(`Failed to send script to Creator: ${error instanceof Error ? error.message : String(error)}`));
297
- }
298
- });
258
+ const timer = setTimeout(() => {
259
+ const pending = this._pendingRequests.get(id);
260
+ if (!pending) return;
261
+ const timeoutError = /* @__PURE__ */ new Error(`Tool execution timed out after ${TOOL_TIMEOUT_MS / 1e3}s`);
262
+ pending.timedOut = true;
263
+ this._isDraining = true;
264
+ queued.reject(timeoutError);
265
+ this._rejectQueuedRequests(timeoutError);
266
+ }, TOOL_TIMEOUT_MS);
267
+ const pending = {
268
+ request,
269
+ resolve: queued.resolve,
270
+ reject: queued.reject,
271
+ timer,
272
+ accepted: false,
273
+ timedOut: false
274
+ };
275
+ this._pendingRequests.set(id, pending);
276
+ this._sendPendingRequest(conn, pending);
277
+ }
278
+ _resumePendingRequest() {
279
+ const conn = this._activeConnection;
280
+ if (!conn) return;
281
+ const pending = this._pendingRequests.values().next().value;
282
+ if (!pending || pending.accepted) return;
283
+ this._sendPendingRequest(conn, pending);
284
+ }
285
+ _sendPendingRequest(conn, pending) {
286
+ try {
287
+ conn.ws.send(JSON.stringify(pending.request));
288
+ } catch (error) {
289
+ logger.warn(`Failed to send tool request to Creator; waiting for reconnect: ${error instanceof Error ? error.message : String(error)}`);
290
+ this._handleDisconnect(conn.id);
291
+ }
292
+ }
293
+ _rejectQueuedRequests(error) {
294
+ for (const queued of this._requestQueue) queued.reject(error);
295
+ this._requestQueue = [];
299
296
  }
300
297
  stop() {
301
298
  if (this._pingInterval) {
302
299
  clearInterval(this._pingInterval);
303
300
  this._pingInterval = null;
304
301
  }
302
+ const shutdownError = /* @__PURE__ */ new Error("Server shutting down");
305
303
  for (const [, pending] of this._pendingRequests) {
306
304
  clearTimeout(pending.timer);
307
- pending.reject(/* @__PURE__ */ new Error("Server shutting down"));
305
+ pending.reject(shutdownError);
308
306
  }
309
307
  this._pendingRequests.clear();
308
+ this._rejectQueuedRequests(shutdownError);
309
+ this._isDraining = false;
310
310
  if (this._activeConnection) {
311
311
  this._activeConnection.ws.close();
312
312
  this._activeConnection = null;
@@ -318,113 +318,2510 @@ var ConnectionManager = class {
318
318
  };
319
319
 
320
320
  //#endregion
321
- //#region src/tools/run-script.ts
322
- async function handleRunScript(connectionManager, script) {
323
- if (!connectionManager.isWebSocketReady) return {
324
- content: [{
325
- type: "text",
326
- text: WEBSOCKET_SERVER_UNAVAILABLE_MESSAGE
327
- }],
328
- isError: true
321
+ //#region ../motion-copilot-contract/src/client-tools.ts
322
+ const rgbColorSchema = z.object({
323
+ r: z.number().min(0).max(255),
324
+ g: z.number().min(0).max(255),
325
+ b: z.number().min(0).max(255)
326
+ }).strict();
327
+ const jsonStringifiedRgbColorSchema = z.string().regex(/^(?=[\s\S]*"r"\s*:)(?=[\s\S]*"g"\s*:)(?=[\s\S]*"b"\s*:)\s*\{[\s\S]*\}\s*$/, "Expected a JSON-stringified RGB object such as {\"r\":66,\"g\":133,\"b\":244}.").describe("Compatibility only: JSON-stringified RGB object. Prefer a real {r,g,b} object.");
328
+ const rgbColorInputSchema = z.union([rgbColorSchema, jsonStringifiedRgbColorSchema]);
329
+ const fillColorInputSchema = z.union([rgbColorInputSchema, z.literal("none")]);
330
+ const vector2Schema = z.object({
331
+ x: z.number(),
332
+ y: z.number()
333
+ }).strict();
334
+ const sizeSchema = z.object({
335
+ width: z.number(),
336
+ height: z.number()
337
+ }).strict();
338
+ const pathPointSchema = z.object({
339
+ vertex: vector2Schema,
340
+ inTan: vector2Schema,
341
+ outTan: vector2Schema
342
+ }).strict();
343
+ const pathDataInputSchema = z.object({
344
+ points: z.array(pathPointSchema).min(2),
345
+ closed: z.boolean()
346
+ }).strict();
347
+ const maskRectInputSchema = z.object({
348
+ x: z.number(),
349
+ y: z.number(),
350
+ width: z.number().min(0),
351
+ height: z.number().min(0)
352
+ }).strict();
353
+ const maskEllipseInputSchema = z.object({
354
+ cx: z.number(),
355
+ cy: z.number(),
356
+ width: z.number().min(0),
357
+ height: z.number().min(0)
358
+ }).strict();
359
+ const maskModeInputSchema = z.enum(["add", "subtract"]).describe("Default 'add'.");
360
+ const maskOpacityInputSchema = z.number().min(0).max(100).describe("Default 100.");
361
+ const createMaskInputSchema = z.object({
362
+ layer_id: z.string(),
363
+ shape: z.enum([
364
+ "rectangle",
365
+ "ellipse",
366
+ "path"
367
+ ]),
368
+ rect: maskRectInputSchema.describe("Required when `shape` is \"rectangle\"; omit for other mask shapes.").optional(),
369
+ ellipse: maskEllipseInputSchema.describe("Required when `shape` is \"ellipse\"; omit for other mask shapes.").optional(),
370
+ path: pathDataInputSchema.describe("Required when `shape` is \"path\"; omit for other mask shapes.").optional(),
371
+ mode: maskModeInputSchema.optional(),
372
+ opacity: maskOpacityInputSchema.optional()
373
+ }).strict().superRefine((value, ctx) => {
374
+ const addShapeIssue = (path, message) => {
375
+ ctx.addIssue({
376
+ code: z.ZodIssueCode.custom,
377
+ path,
378
+ message
379
+ });
329
380
  };
330
- if (!connectionManager.hasActiveConnection) return {
381
+ if (value.shape === "rectangle") {
382
+ if (!value.rect) addShapeIssue(["rect"], "`rect` is required when `shape` is \"rectangle\".");
383
+ if (value.ellipse) addShapeIssue(["ellipse"], "`ellipse` must be omitted for rectangle masks.");
384
+ if (value.path) addShapeIssue(["path"], "`path` must be omitted for rectangle masks.");
385
+ return;
386
+ }
387
+ if (value.shape === "ellipse") {
388
+ if (!value.ellipse) addShapeIssue(["ellipse"], "`ellipse` is required when `shape` is \"ellipse\".");
389
+ if (value.rect) addShapeIssue(["rect"], "`rect` must be omitted for ellipse masks.");
390
+ if (value.path) addShapeIssue(["path"], "`path` must be omitted for ellipse masks.");
391
+ return;
392
+ }
393
+ if (!value.path) addShapeIssue(["path"], "`path` is required when `shape` is \"path\".");
394
+ if (value.rect) addShapeIssue(["rect"], "`rect` must be omitted for path masks.");
395
+ if (value.ellipse) addShapeIssue(["ellipse"], "`ellipse` must be omitted for path masks.");
396
+ });
397
+ const gradientStopSchema = z.object({
398
+ offset: z.number().min(0).max(1),
399
+ color: rgbColorInputSchema,
400
+ opacity: z.number().min(0).max(1).optional()
401
+ }).strict();
402
+ const solidPaintInputSchema = z.object({
403
+ type: z.literal("SOLID"),
404
+ color: rgbColorInputSchema
405
+ }).strict();
406
+ const linearGradientPaintInputSchema = z.object({
407
+ type: z.literal("GRADIENT_LINEAR"),
408
+ stops: z.array(gradientStopSchema).min(2),
409
+ start: vector2Schema.optional(),
410
+ end: vector2Schema.optional()
411
+ }).strict();
412
+ const radialGradientPaintInputSchema = z.object({
413
+ type: z.literal("GRADIENT_RADIAL"),
414
+ stops: z.array(gradientStopSchema).min(2),
415
+ start: vector2Schema.optional(),
416
+ end: vector2Schema.optional(),
417
+ highlightAngle: z.number().optional(),
418
+ highlightLength: z.number().min(-100).max(100).optional()
419
+ }).strict();
420
+ const paintInputSchema = z.union([
421
+ solidPaintInputSchema,
422
+ linearGradientPaintInputSchema,
423
+ radialGradientPaintInputSchema
424
+ ]);
425
+ const paintInputWithOptionalWidthSchema = z.union([
426
+ solidPaintInputSchema.extend({ width: z.number().min(.1).optional() }),
427
+ linearGradientPaintInputSchema.extend({ width: z.number().min(.1).optional() }),
428
+ radialGradientPaintInputSchema.extend({ width: z.number().min(.1).optional() })
429
+ ]);
430
+ const paintInputWithOptionalStrokeWidthSchema = z.union([
431
+ solidPaintInputSchema.extend({ stroke_width: z.number().min(.1).optional() }),
432
+ linearGradientPaintInputSchema.extend({ stroke_width: z.number().min(.1).optional() }),
433
+ radialGradientPaintInputSchema.extend({ stroke_width: z.number().min(.1).optional() })
434
+ ]);
435
+ const shapeStrokeInputSchema = z.object({
436
+ color: rgbColorInputSchema,
437
+ width: z.number().min(.1)
438
+ }).strict();
439
+ const easingPresetNameSchema = z.enum([
440
+ "ease-in",
441
+ "ease-out",
442
+ "ease-in-out",
443
+ "gentle-out",
444
+ "snappy-out",
445
+ "smooth-in-out",
446
+ "overshoot-soft",
447
+ "overshoot-hard",
448
+ "anticipate"
449
+ ]);
450
+ const easingInputSchema = z.union([z.object({ type: z.literal("LINEAR") }).strict(), z.object({
451
+ type: z.literal("CUBIC_BEZIER"),
452
+ x1: z.number(),
453
+ y1: z.number(),
454
+ x2: z.number(),
455
+ y2: z.number()
456
+ }).strict()]);
457
+ const keyframeEasingInputSchema = z.union([easingInputSchema, z.object({
458
+ type: z.literal("PRESET"),
459
+ name: easingPresetNameSchema
460
+ }).strict().describe("Named curve — prefer this over hand-picked CUBIC_BEZIER coefficients. Presets expand to the exact bezier values server-side. Each maps to a motion PERSONALITY archetype — pick the one matching your Direction and use it consistently (see motion-design.md → Motion Personality):\n- 'gentle-out' — PREMIUM archetype (elegant/luxury/minimal): smooth ease-out, 0% overshoot, ~350-600ms feel. The reveal default; spreads motion across the full duration.\n- 'snappy-out' — CORPORATE archetype (clean/professional/dashboard): quick, decisive settle, 0-3% overshoot, ~200-400ms.\n- 'overshoot-soft' / 'overshoot-hard' — PLAYFUL archetype (fun/bouncy/cute): pop-in / bouncy settle, 10-20% overshoot, ~150-300ms.\n- 'anticipate' — PLAYFUL/ENERGETIC wind-up: pulls back before moving forward.\n- 'ease-in-out' / 'smooth-in-out' — both ends, cinematic (neutral).\n- 'ease-in' / 'ease-out' — CSS-standard, reliable neutral default.")]);
461
+ const coreAnimatedPropertyValues = [
462
+ "position",
463
+ "scale",
464
+ "rotation",
465
+ "opacity",
466
+ "fill_color",
467
+ "trim_start",
468
+ "trim_end",
469
+ "trim_offset",
470
+ "stroke_width"
471
+ ];
472
+ const extendedOnlyAnimatedPropertyValues = [
473
+ "stroke_color",
474
+ "fill_opacity",
475
+ "stroke_opacity",
476
+ "gradient_stops",
477
+ "gradient_start",
478
+ "gradient_end",
479
+ "highlight_angle",
480
+ "highlight_length",
481
+ "shape_path",
482
+ "shape_size",
483
+ "rectangle_roundness",
484
+ "outer_radius",
485
+ "inner_radius",
486
+ "outer_roundness",
487
+ "inner_roundness",
488
+ "polygon_points",
489
+ "star_points",
490
+ "text_fill_color",
491
+ "text_stroke_color",
492
+ "text_stroke_width",
493
+ "mask_path",
494
+ "mask_opacity",
495
+ "time_mapping"
496
+ ];
497
+ const animatablePropertyValues = [...coreAnimatedPropertyValues, ...extendedOnlyAnimatedPropertyValues];
498
+ const coreAnimatedPropertySchema = z.enum(coreAnimatedPropertyValues);
499
+ const extendedOnlyAnimatedPropertySchema = z.enum(extendedOnlyAnimatedPropertyValues);
500
+ const animatablePropertySchema = z.enum(animatablePropertyValues);
501
+ const paintKeyframeValueSchema = z.union([
502
+ rgbColorInputSchema,
503
+ z.array(gradientStopSchema).min(2),
504
+ vector2Schema,
505
+ z.number()
506
+ ]);
507
+ const extendedPropertyKeyframeValueSchema = z.union([
508
+ paintKeyframeValueSchema,
509
+ sizeSchema,
510
+ pathDataInputSchema
511
+ ]);
512
+ const layoutShapeSchemaFields = {
513
+ points: z.number().min(3).describe("Polygon/star layout item: number of points/sides. 3 = triangle, 5 = pentagon or 5-pointed star. Default 5.").optional(),
514
+ outer_radius: z.number().min(1).describe("Polygon/star layout item: radius to the vertices/outer points. May be used instead of size.").optional(),
515
+ inner_radius: z.number().min(1).describe("Star layout item: radius to the inner points. Use with `shape: \"star\"` for custom star geometry.").optional(),
516
+ outer_roundness: z.number().min(0).max(100).describe("Polygon/star layout item: rounds the outer corners (0 = sharp, 100 = fully rounded). Default 0.").optional(),
517
+ stroke: shapeStrokeInputSchema.describe("Shape layout item stroke outline. Supported on created shape items.").optional()
518
+ };
519
+ /**
520
+ * Client-executed Motion Copilot V3 tools.
521
+ *
522
+ * These Zod schemas are the source of truth for tools that execute in Creator.
523
+ * The motion-copilot-agent imports this file while the packages are linked
524
+ * locally, so Creator does not need to send tool definitions in chat payloads.
525
+ */
526
+ const allClientTools = {
527
+ read_scene: {
528
+ description: "Read the current Creator scene. Returns scene dimensions, framerate, duration, all layers, current selection, render order, visual bounds, scene-center deltas, and clipping/off-canvas flags. Call this once at the start of most turns to understand what's on the canvas. Use each layer's `ordering` and `layout` blocks to avoid wrong-layer, wrong-stack, and off-canvas mistakes. A layer may also carry an `occlusion` block (`likely_hidden: true`, `occluded_by: [ids]`) when an opaque layer in front of it covers it — treat that layer as INVISIBLE on the canvas (e.g. a label buried behind a button fill); reorder it above the cover with `reorder_layer` before reporting done. No input required — always reads the active scene.",
529
+ inputSchema: z.object({}).strict()
530
+ },
531
+ apply_keyframes: {
532
+ description: "Add keyframes to ONE animatable property on ONE layer. This is the preferred tool for simple single-property animations (rotate, fade, slide, pulse, recolor). If you need to animate multiple properties on the same layer, plan separate tool calls in the order they should be applied; Creator serializes native tool execution through the dispatcher.\n\n**POLISH TURNS (snappier / smoother / tighter / less bouncy / fix timing / more premium / etc.) — call `read_keyframes` FIRST on every animated layer in scope BEFORE calling this tool.** The conversation history's view of existing keyframes is stale once the user edits in Creator directly; reading is the only safe source of truth. Then prefer the smallest possible mutation: easing-only change > retiming > amplitude tweak > full rebuild. See motion-design.md → 'Repair / polish protocol' for the full rule.\n\nValue type depends on the property:\n- `position`, `scale` → value is `{x, y}`. **`scale` is a percentage of the layer's AUTHORED size, matching AE / Creator semantics — NOT a percentage of 100.** A layer the user imported or hand-resized can sit at `50`, `200`, `1250`, etc. — and THAT value is the resting state. Animating a layer authored at 1250 → 110 collapses it to ~9% of its on-canvas size and looks like a bug. **Always read the current static value via `read_keyframes` or `read_scene` before writing scale keyframes**, then animate RELATIVE to that authored value (1250 pulses to 1350, not to 110). Same baseline rule applies to `position` and `rotation` — the layer's authored values are the resting state, not 0/0 or 0°. See motion-design.md → 'Baseline awareness'. **TEXT `position` is doubly dangerous: a text layer's transform anchor is the baseline / box origin, so its raw `position` value is NOT its `visual_center` and NOT the `position` arg you gave `create_text` (that arg is the visual center). Feeding `visual_center` in as a `position` keyframe throws the text off-canvas. For a settle-into-place entrance use `animate_entrance` (no coordinate needed); for any other text move, `read_keyframes` the raw `static_value` and animate RELATIVE to it. Never repair a keyframed text layer with `set_anchor_point` → `center_layer` — they stack two shifts and fling it off-canvas.**\n- `rotation`, `opacity` → value is a number (rotation in degrees; opacity 0–100)\n- `fill_color` → value is `{r, g, b}` with 0-255 per channel. **Requires a SOLID fill at `fills[0]`** — animation only works when the layer's first fill is SOLID. Gradient or empty layers will fail with `'Layer has no SOLID fill at fills[0]'`. To animate the color of a layer that currently has a gradient (or no fill), call `set_fill({type:'SOLID', color: <starting color>})` FIRST, then this tool. Verify the fill type via `read_scene` (look at the layer's fill metadata) or just call `set_fill` defensively before animating colors on a layer you didn't create with a solid fill.\n- `trim_start`, `trim_end` → number 0–100 (percent of path). Use for stroke draw-on reveals: animate `trim_end` from 0 → 100 to grow the path in. A trim path is auto-created on first use. If the current playhead stays at a frame where `trim_end` is 0 or opacity is 0, the layer is correctly present but invisible on canvas; after creating a reveal, call `set_current_frame` to leave Creator on a visible frame.\n- `trim_offset` → number 0–360 (degrees around the path). For rotating-around-path effects.\n- `stroke_width` → number (pixels). Requires the layer to already have a stroke — use `set_stroke` first.\n\nTo set a static color (no animation), pass a single keyframe at frame 0 and consider `clear_first: true` to remove any existing color animation.\n\nEasing is optional on any keyframe; omit it for linear-ish default. Use `CUBIC_BEZIER` with explicit control points for polished motion; `LINEAR` for deliberate no-easing.\n\nUse `clear_first: true` to replace existing keyframes on that property; otherwise new keyframes are merged with existing ones (sorted by frame).\n\nFor paint, shape geometry, masks, groups, scene settings, and timing edits, prefer the dedicated typed tools (`animate_paint`, `animate_property`, `create_shape_group`, `update_mask`, `retime_keyframes`) over rebuilding with many generic keyframe calls.",
533
+ inputSchema: z.object({
534
+ layer_id: z.string().describe("Layer id from `read_scene` (e.g. 'qttDVXo7OQ')."),
535
+ property: coreAnimatedPropertySchema,
536
+ clear_first: z.boolean().describe("If true, clear existing keyframes on this property before adding. Default false.").optional(),
537
+ keyframes: z.array(z.object({
538
+ frame: z.number(),
539
+ value: z.union([
540
+ z.number(),
541
+ vector2Schema,
542
+ rgbColorSchema
543
+ ]),
544
+ easing: keyframeEasingInputSchema.optional()
545
+ }).strict()).min(1)
546
+ }).strict()
547
+ },
548
+ apply_motion_preset: {
549
+ description: "Apply a Motion Preset from the user's Motion System to a single layer. A preset is a multi-property keyframe collection for supported layer transform properties (position + scale + rotation + opacity, any subset) with a duration. This bakes the preset's keyframes into the layer — there's NO link semantics, future preset edits don't propagate. Preset trimPath data is not currently baked by the native gateway; use `apply_keyframes` on `trim_start`/`trim_end` for stroke reveals.\n\nUse this when the user names a preset (e.g. 'apply our brand entrance to this card'). For single-property motion (just an easing, just a duration), use `list_motion_system_items` + the existing keyframe tools instead — presets are the multi-property shortcut.\n\n**Mode** defaults to `'replace'` — this is what the user almost always wants. 'replace' clears existing keyframes on the preset's target properties first, then bakes the preset in. Only pass `'add'` when the user explicitly says 'on top of', 'in addition to', 'while keeping', 'compose with', or similar layering language. In `'add'` mode existing keyframes are preserved and the preset's absolute values are inserted with their frames shifted to the current playhead; scale/rotation/opacity are not added relative to the layer's current authored values. 'apply our brand entrance' = `'replace'`; 'apply our brand entrance while keeping the existing wobble' = `'add'`.\n\nReturns `{ layer_id, preset_id, preset_label, mode, duration_ms, affected_properties, applied_properties, readback }`. Success means the target layer was read back with changed, meaningful animated keyframes: at least one applied property has 2 or more distinct keyframe frames. Throws on unknown preset_id, unknown layer_id, presets with no meaningful supported motion, or actor no-ops/incompatible targets where no meaningful keyframes were actually created. If only some preset properties apply, inspect `applied_properties` and `readback` before describing the result to the user.",
550
+ inputSchema: z.object({
551
+ layer_id: z.string().describe("Target layer."),
552
+ preset_id: z.string().describe("Preset id from `list_motion_system_items({ type: 'motion_presets' })`."),
553
+ mode: z.enum(["replace", "add"]).describe("Default 'replace' (clears existing keyframes on the preset's target properties first). 'add' preserves existing keyframes and inserts the preset's absolute values shifted to the current playhead.").optional()
554
+ }).strict()
555
+ },
556
+ apply_motion_path: {
557
+ description: "Make a layer's position FOLLOW a path shape — Creator's 'apply path to motion' feature. Each vertex of the path becomes a position keyframe and the path's bezier in/out tangents become the keyframe's spatial tangents, so the layer curves smoothly along the path instead of jumping in straight-line segments between waypoints.\n\n**Reach for this when the user wants a custom curving motion** — 'have the ball arc over the obstacle', 'make the icon trace an S-curve', 'follow this road', 'orbit around the logo', 'fly across the scene'. The path is a regular path shape created via `create_shape`/`bend_shape` (or any existing path in the scene). The motion is whatever curve the path describes.\n\nThe tool writes SpatialKeyframes (the same primitive Creator's motion-path editor produces), which is the only way to get smooth curved motion between waypoints. Hand-authoring position keyframes via `apply_keyframes` gives straight-line interpolation between frames — fine for ramps and pulses, wrong for anything 'curve along this shape'.\n\n**Framing — the path must be centered on the scene:**\n- Read scene size first (`read_scene`). Set the path layer's `position` to the scene CENTER (e.g. `{ x: 256, y: 256 }` for 512×512). Author vertex positions as signed displacements from that center, capped at roughly ±(scene_size/2 − 40) so the motion stays inside the viewport with a margin (so x ∈ [−216, +216] on a 512-wide scene).\n- World motion coords are `layer.position + vertex`. If the target ends up off-canvas, either the layer wasn't centered or a vertex is past the viewport bound. Tangents can extend past the bound — only vertex anchor points need to stay inside.\n\n**Use TWO vertices for an S-curve, not three.** A 2-anchor bezier with strong opposing tangents produces a smooth flowing S; adding a middle vertex constrains the curve and tends to read as a zig-zag. Only use 3+ vertices when the user explicitly asks for a 'sharp' or 'kinked' S, or when the path has multiple distinct turns.\n\n**Tangent rules — get this right or the curve renders as a polyline:**\n- Tangent magnitude ≈ 30–50% of the distance to the adjacent vertex. Smaller flattens; larger loops.\n- For a smooth pass-through at any intermediate vertex, that vertex's inTan and outTan must be ANTI-PARALLEL — `outTan = -inTan`. Any other combo produces a visible corner.\n- For a TWO-vertex S, the start's outTan-y and the end's inTan-y must have OPPOSITE SIGNS (e.g. start outTan = (120, -150), end inTan = (-120, 150)). That sign flip is what creates the S inflection. Same-sign tangents produce an arc.\n- See `motion-patterns.md` → 'S-curve recipe' for a worked 2-vertex example.\n\nPrerequisites:\n- `path_layer_id` points to a SHAPE_LAYER containing exactly one path shape with at least 2 vertices, OR to a path shape directly.\n- `target_layer_id` points to any transformable layer (shape, text, image, precomp).\n- Both layers must be in the active scene.\n\nDefaults:\n- `constant_speed: true` distributes the keyframes by arc length, so the motion is uniform along the curve (vs. evenly spaced in time, which makes long path segments visibly speed up). Leave it on unless the user asks for 'ease into corners'.\n- **`auto_orient`: pass `true` whenever the verb implies directional motion** — 'fly', 'follow', 'swoop', 'travel', 'tour', 'drive', 'race', 'trace'. The target rotates to face the direction of travel for the entire path. Only leave `auto_orient: false` when the user explicitly wants the layer to keep its original orientation (e.g. 'slide along the path without rotating').\n- `remove_source: true` deletes the source path layer after applying (the agent typically drew it as scaffolding). Pass `false` to keep the path visible in the scene.",
558
+ inputSchema: z.object({
559
+ path_layer_id: z.string().describe("Path layer id from `read_scene`. A shape layer wrapping exactly one path shape, or a path shape directly. Multi-path layers are ambiguous and rejected."),
560
+ target_layer_id: z.string().describe("Layer whose position will follow the path."),
561
+ start_frame: z.number().describe("Frame for the first vertex."),
562
+ end_frame: z.number().describe("Frame for the last vertex. Must be > start_frame."),
563
+ constant_speed: z.boolean().describe("When true (default), space keyframes by arc length so the motion is uniform along the path. When false, space evenly in time (long segments will appear to speed up).").optional(),
564
+ auto_orient: z.boolean().describe("When true, keyframe rotation so the target faces the direction of travel. Default false.").optional(),
565
+ remove_source: z.boolean().describe("When true (default), delete the source path layer after applying. Set to false to keep the path visible.").optional()
566
+ }).strict()
567
+ },
568
+ set_anchor_point: {
569
+ description: "Move a layer's ANCHOR POINT — the pivot for rotation and scale. Mirrors Creator's property-panel 9-grid anchor selector. Compensates the layer's position so the visible rendering DOES NOT JUMP when the anchor moves; if position is animated, every position keyframe shifts together so the motion shape is preserved.\n\n**Reach for this BEFORE animating rotation or scale** whenever the verb implies a specific pivot:\n- 'door opens / swings from the left hinge' → `direction: 'left'`, then animate rotation\n- 'drawer slides down from the top' / 'curtain drops' → `direction: 'top'`, then animate scale.y\n- 'pendulum / clock hand / chandelier swing' → `direction: 'top'`, then rotation\n- 'standing thing falls over' (tips on its base) → `direction: 'bottom'`, then rotation\n- 'pop in from the corner' / 'punch in from the bottom-right' → `direction: 'bottom-right'`, then scale + opacity\n- 'flag flapping from the pole on the left' → `direction: 'left'`, then rotation/skew\n\n**Critical: set the anchor BEFORE adding the rotation/scale keyframes.** Setting it AFTER moves where those existing keyframes pivot — the layer will appear to lurch because each frame's render uses the new pivot but the keyframed values were authored against the old one. Stage the anchor, then animate.\n\nPass EXACTLY ONE of:\n- `direction`: one of 9 presets (top-left, top, top-right, left, center, right, bottom-left, bottom, bottom-right). The gateway maps to the corresponding corner / edge / midpoint of the layer's local bounding box.\n- `position`: raw `{x, y}` in layer-local pixels for custom placement (e.g. matching a specific anchor on a complex shape).\n\nIf the layer has no clear bounding box (rare — text and shape layers always have one), the gateway falls back to a 100×100 box centered on local origin, so `direction` still gives sensible results.",
570
+ inputSchema: z.object({
571
+ layer_id: z.string().describe("Layer whose anchor to move. Must have transform.anchorPoint."),
572
+ direction: z.enum([
573
+ "top-left",
574
+ "top",
575
+ "top-right",
576
+ "left",
577
+ "center",
578
+ "right",
579
+ "bottom-left",
580
+ "bottom",
581
+ "bottom-right"
582
+ ]).describe("9-grid preset. Mapped to the corresponding corner / edge / midpoint of the layer's local bounding box. Pass this OR `position`, not both.").optional(),
583
+ position: z.object({
584
+ x: z.number(),
585
+ y: z.number()
586
+ }).strict().describe("Custom anchor in layer-local pixels. Pass this OR `direction`, not both.").optional()
587
+ }).strict()
588
+ },
589
+ create_shape: {
590
+ description: "**BEFORE CALLING: check the creation order.** New layers are inserted at the TOP of the render stack. If this turn creates multiple layers, emit the calls in BOTTOM→TOP order — the background FIRST, the foreground LAST. A card background MUST be created BEFORE its content; a sky rectangle MUST be created BEFORE the logo on it. (Reorder APIs exist if needed via `reorder_layer`, but planning the order is cheaper.)\n\nCreate a new shape layer with a vector primitive. Supports rectangle, ellipse, polygon (regular N-gon), star, and arbitrary bezier path. By default it gets a solid fill (`fill_color`, or gray if omitted). For a RING / outline / donut, pass `fill_color: \"none\"` + `stroke: { color, width }` — that's a stroke-only shape in ONE call. You can also combine a fill AND a stroke. Returns `layer_id` so you can immediately animate it with `apply_keyframes`. If the user asks for several shapes at once, emit one tool call per shape in the intended render order; Creator serializes native tool execution.\n\n**Position is the VISUAL CENTER for every primitive (rectangle, ellipse, polygon, star).** `path` is positioned at the layer origin and extends as its `path.points` define. A rectangle with `size: {512, 512}` at `position: {x:0, y:0}` in a 512×512 scene shows ONLY the bottom-right quadrant — its center is pinned to the scene's top-left corner. To cover that scene the position must be `{x:256, y:256}` (the rectangle's center → the scene's center).\n\n**BEFORE you reach for `fill_scene: true` — STOP. Do you actually need an exported background layer?** Motion-design convention: animations float on transparency so they composite anywhere. The exported Lottie keeps a transparent background — adding a full-scene rectangle BAKES IN a color the user almost never wants in the final file. The three valid reasons to add a background SHAPE LAYER: (1) gradient/multi-stop background that IS the design, (2) contrast genuinely unsolvable any other way, (3) the user EXPLICITLY says \"add a background layer\". For ALL other cases — including \"premium dashboard\", \"dark mode preview\", \"card on a tinted backdrop\" — use **`set_scene_background({ color })`** instead. That changes Creator's preview tint WITHOUT polluting the export. See motion-aesthetics → \"NO BACKGROUND LAYER BY DEFAULT\".\n\n`fill_scene: true` is for the narrow case where one of the three valid reasons above actually applies. The native tool reads the active scene's dimensions and sets `size` + `position` correctly. Only supported for `rectangle`.\n\nParameter shape is a discriminated field — provide the params for the chosen shape:\n- `shape: \"rectangle\"` → `size: { width, height }` OR `fill_scene: true`. Optional `roundness` (px) — set this for ANY card/panel/modal/button; default-sharp corners read as wireframe.\n- `shape: \"ellipse\"` → `size: { width, height }`.\n- `shape: \"polygon\"` → `points` (number of sides, ≥3), `outer_radius` OR `size`, optional `outer_roundness` (0-100)\n- `shape: \"star\"` → `points` (≥3), `outer_radius` + `inner_radius` OR `size` (inner defaults to half outer), optional `outer_roundness` / `inner_roundness` (0-100 each)\n- `shape: \"path\"` → `path: { points: [{vertex:{x,y}, inTan:{x,y}, outTan:{x,y}}, ...], closed: boolean }`\n\nUse one tool call per shape. 'Three circles stacked vertically' = 3 serialized create_shape calls.",
591
+ inputSchema: z.object({
592
+ shape: z.enum([
593
+ "rectangle",
594
+ "ellipse",
595
+ "polygon",
596
+ "star",
597
+ "path"
598
+ ]),
599
+ size: z.object({
600
+ width: z.number().min(1),
601
+ height: z.number().min(1)
602
+ }).strict().describe("Required for rectangle/ellipse unless `fill_scene: true`. Ignored for polygon/star/path (those use their own sizing params).").optional(),
603
+ fill_scene: z.boolean().describe("Sizes + positions the rectangle to cover the entire active scene. **Use ONLY when an exported full-scene background is actually required** (gradient design, unsolvable contrast, explicit user ask). For simple 'I want a tinted preview surface', use `set_scene_background` instead — that changes Creator's preview color without baking the color into the export. Bare prompts like 'design a dashboard' do NOT warrant a background layer; the export should stay transparent. Only valid when shape is `rectangle`.").optional(),
604
+ points: z.number().min(3).describe("Polygon/star: number of points/sides. 3 = triangle, 5 = pentagon or 5-pointed star. Default 5.").optional(),
605
+ outer_radius: z.number().min(1).describe("Polygon: radius to the vertices. Star: radius to the outer points.").optional(),
606
+ inner_radius: z.number().min(1).describe("Star only: radius to the inner points.").optional(),
607
+ outer_roundness: z.number().min(0).max(100).describe("Polygon/star: rounds the outer corners (0 = sharp, 100 = fully rounded). Default 0.").optional(),
608
+ inner_roundness: z.number().min(0).max(100).describe("Star only: rounds the inner corners. Default 0.").optional(),
609
+ roundness: z.number().min(0).describe("Rectangle only: corner radius in pixels (0 = sharp, larger = more rounded). Strongly recommended for any 'card', 'panel', 'sheet', 'modal', 'button' rectangle — sharp 90° corners read as wireframe, not finished UI. Sensible defaults: chips 8, buttons 10, inputs 8, cards 16, modals 20, hero panels 24. Pills/avatars: use ellipse instead, or set roundness to half the shorter side. Prefer `radius_token` for the standard scale.").optional(),
610
+ radius_token: z.enum([
611
+ "xs",
612
+ "sm",
613
+ "md",
614
+ "lg",
615
+ "pill"
616
+ ]).describe("Rectangle only: corner radius from the design scale — xs=2 sm=4 md=8 lg=24, `pill`=stadium (half the shorter side). Wins over numeric `roundness`. Prefer this over a raw `roundness` so cards/buttons land on the system scale. Ignored on non-rectangles.").optional(),
617
+ role: z.enum([
618
+ "surface",
619
+ "panel",
620
+ "action",
621
+ "accent"
622
+ ]).describe("Optional. Resolves a DEFAULT slate fill when `fill_color` is OMITTED. 'accent' = slate-700 NEUTRAL default (NOT a chromatic hue — pass `fill` for chroma). An explicit `fill_color` always wins; ignored when `fill_color` is given.").optional(),
623
+ fill: z.string().describe("Optional named Flat-UI accent, e.g. 'peter-river','carrot','emerald','sun-flower','alizarin','belize-hole','pomegranate'. ONE chromatic hue; purple/violet never (unknown names are ignored). Wins over `role`; only applies when `fill_color` is omitted.").optional(),
624
+ path: z.object({
625
+ points: z.array(z.object({
626
+ vertex: z.object({
627
+ x: z.number(),
628
+ y: z.number()
629
+ }).strict(),
630
+ inTan: z.object({
631
+ x: z.number(),
632
+ y: z.number()
633
+ }).strict(),
634
+ outTan: z.object({
635
+ x: z.number(),
636
+ y: z.number()
637
+ }).strict()
638
+ }).strict()).min(2),
639
+ closed: z.boolean()
640
+ }).strict().describe("Path only: raw bezier points (vertex + tangents relative to vertex) with open/closed flag. Use closed:false for lines/curves; open paths default to stroke/no fill because fill-only open paths do not draw. If you provide fill_color without stroke on an open path, the gateway uses that color as the stroke so the path remains visible.").optional(),
641
+ position: z.object({
642
+ x: z.number(),
643
+ y: z.number()
644
+ }).strict().optional(),
645
+ rotation: z.number().describe("Degrees, default 0.").optional(),
646
+ scale: z.object({
647
+ x: z.number(),
648
+ y: z.number()
649
+ }).strict().describe("Percentage; default { x: 100, y: 100 }.").optional(),
650
+ opacity: z.number().min(0).max(100).optional(),
651
+ fill_color: fillColorInputSchema.describe("Solid fill RGB (0-255), OR the string 'none' for a stroke-only shape. Pass a real object like {r:66,g:133,b:244}; JSON strings are accepted only for compatibility. Omit for a default gray fill.").optional(),
652
+ stroke: z.object({
653
+ color: rgbColorInputSchema,
654
+ width: z.number().min(.1).describe("Stroke width in px.")
655
+ }).strict().describe("Optional inline stroke (outline). Combine with `fill_color: 'none'` for a ring/outline in ONE call, or with a fill for a filled-and-outlined shape.").optional(),
656
+ name: z.string().optional()
657
+ }).strict()
658
+ },
659
+ bend_shape: {
660
+ description: "Bend one edge of an existing rectangle/square-like SHAPE_LAYER by replacing its first shape with a 5-point bezier path, OR animate an already-bent path by keyframing its pathData. Use this for prompts like 'bend the top of this square', 'curve the bottom edge', 'bow the card inward', or 'animate the bend'.\n\nStatic bend: pass `amount` (typical 8-24px). Animated bend: pass `animate: true`; the tool reuses an existing bent PATH shape when present, infers its bounds, and writes pathData keyframes directly. Do NOT fake bend animation with scale keyframes. `tangent_width` controls how broad/smooth the curve is; default 60.",
661
+ inputSchema: z.object({
662
+ layer_id: z.string(),
663
+ edge: z.enum([
664
+ "top",
665
+ "right",
666
+ "bottom",
667
+ "left"
668
+ ]).describe("Which edge to bend inward."),
669
+ amount: z.number().describe("Bend amount in pixels. Positive values bend the edge inward toward the center. Default 16.").optional(),
670
+ tangent_width: z.number().min(0).describe("Bezier tangent width in layer-local pixels. Larger values make a broader/smoother bow. Default 60.").optional(),
671
+ size: z.object({
672
+ width: z.number().min(1),
673
+ height: z.number().min(1)
674
+ }).strict().describe("Fallback size for the original rectangle if it can't be inferred from the first shape.").optional(),
675
+ replace_original: z.boolean().describe("When true, removes the first existing shape before creating the bent path. Default true.").optional(),
676
+ preserve_fill: z.boolean().describe("When true, preserves the first SOLID fill by recreating it after the path so the bent path renders with the same color. Default true.").optional(),
677
+ animate: z.boolean().describe("When true, animates the bend by keyframing pathData. Use for \"animate the bend\", \"pulse the bend\", or \"make it flex\".").optional(),
678
+ amplitude: z.number().describe("Animated bend peak in pixels. Defaults to `amount`, then 20.").optional(),
679
+ start_frame: z.number().describe("First animation pulse start frame. Default 0.").optional(),
680
+ repeat_count: z.number().min(1).max(24).describe("Number of spring pulses to create. For a repeated 5s loop at 30fps, use 3 pulses with repeat_interval_frames 50 or 5 pulses with interval 30.").optional(),
681
+ repeat_interval_frames: z.number().min(1).describe("Frame spacing between repeated spring pulses. Default 30.").optional(),
682
+ decay: z.number().min(0).describe("Spring decay per oscillation. Default 0.6.").optional(),
683
+ oscillations: z.number().min(1).describe("Oscillations per pulse. Default 5.").optional(),
684
+ frames_per_oscillation: z.number().min(1).describe("Timing density for each oscillation. Default 4.").optional(),
685
+ clear_first: z.boolean().describe("When animating, clears existing pathData keyframes first. Default true.").optional()
686
+ }).strict()
687
+ },
688
+ create_text: {
689
+ description: "**BEFORE CALLING: check the creation order.** New text layers are inserted at the TOP of the render stack. If this turn also creates a card / background / banner, create the background FIRST and THIS text layer LAST — otherwise the text will land beneath the background. (Reorder APIs exist if needed via `reorder_layer`, but planning the order is cheaper.)\n\nCreate a new native TEXT_LAYER with the given content and optional native TextDocument style. Returns `layer_id` so you can immediately animate it with `apply_keyframes` (opacity for fade-in, position for slide, scale for pop). For letter-by-letter or word-by-word staggered animations, emit one text-layer call per unit in the order they should be created; Creator serializes native tool execution.\n\n**Centering is by VISUAL BOUNDS, not layer origin.** Use `center_in_scene: true` for scene center. Use `position: {x,y}` when you want the bounds center at a specific target point (e.g. top-center hero text). Do not manually compensate for text origin/anchor.\n\n**For long body copy that needs to wrap, pass `wrap_width` instead of constructing a text box yourself.** The gateway translates `wrap_width` into a centered wrap box internally — your `position` / `center_in_scene` still controls visual placement. Omit `wrap_width` for headlines, subtitles, footers, single-line text.\n\nNative text styling supports font size, font family/variant or font name, fill color, stroke color/width, alignment, line height, tracking, caps, and `wrap_width`. For \"slight tracking\", use a small value such as 2-8; values around 20-40 are very wide and can make title text exceed the scene.\n\n**RESPONSE — verify these BEFORE saying \"Done\":**\n- `visual_center` is where the rendered text actually centers. Compare against your intent (`position` or scene center).\n- `visual_bounds` is the rendered rectangle in world coords.\n- `scene_fit.clipped` / `scene_fit.off_canvas` / `scene_fit.overflow.{left,right,top,bottom}`: if any overflow > 0, the new text is clipped at the canvas edge. Self-correct (reduce `font_size`, add `wrap_width`, shorten the text) or surface the limitation in one short line. See motion-design.md → \"Post-mutation layout integrity\".",
690
+ inputSchema: z.object({
691
+ text: z.string().min(1).describe("The text content to display."),
692
+ name: z.string().describe("Layer name (defaults to the first ~24 chars of `text`).").optional(),
693
+ position: z.object({
694
+ x: z.number(),
695
+ y: z.number()
696
+ }).strict().describe("Visual center target for the text bounds. Ignored if center_in_scene is true.").optional(),
697
+ center_in_scene: z.boolean().describe("If true, place the rendered text bounding box at scene center using rendered bounds when available and measured text bounds as fallback. Use whenever the user asks for centered text; overrides `position`.").optional(),
698
+ rotation: z.number().optional(),
699
+ scale: z.object({
700
+ x: z.number(),
701
+ y: z.number()
702
+ }).strict().optional(),
703
+ opacity: z.number().min(0).max(100).optional(),
704
+ font_size: z.number().gt(0).optional(),
705
+ font_name: z.string().describe("Direct Creator font asset name, e.g. \"Inter Bold\". Prefer font_family/font_variant when known.").optional(),
706
+ font_family: z.string().describe("Font family, e.g. Inter.").optional(),
707
+ font_variant: z.string().describe("Font variant/style, e.g. Bold.").optional(),
708
+ align: z.enum([
709
+ "left",
710
+ "center",
711
+ "right",
712
+ "justify_left",
713
+ "justify_center",
714
+ "justify_right",
715
+ "justify_full"
716
+ ]).optional(),
717
+ line_height: z.number().gt(0).optional(),
718
+ tracking: z.number().describe("Native text tracking / letter spacing value. For \"slight tracking\", use 2-8. Avoid 20+ unless the user explicitly asks for very wide spacing.").optional(),
719
+ text_caps: z.enum([
720
+ "regular",
721
+ "all_caps",
722
+ "small_caps"
723
+ ]).optional(),
724
+ fill_color: rgbColorInputSchema.optional(),
725
+ stroke_color: rgbColorInputSchema.optional(),
726
+ stroke_width: z.number().min(0).optional(),
727
+ wrap_width: z.number().gt(0).describe("Max rendered width (in scene pixels) before text wraps to a new line. **Use ONLY when the text needs to wrap inside a fixed-width column** (e.g. paragraph copy, multi-line body text in a card). Omit for headlines, subtitles, footers, and any single-line text. Internally folds the text into multiple lines by inserting `\\n` characters at word boundaries — the same hard line breaks 'Shift+Enter' produces in the property-panel text input. The layer's `position` / `center_in_scene` still controls where the wrapped block sits.").optional(),
728
+ role: z.enum([
729
+ "heading",
730
+ "body",
731
+ "caption",
732
+ "accent"
733
+ ]).describe("Optional. Resolves a DEFAULT slate text fill when `fill_color` is omitted: heading=slate-900, body=slate-600, caption=slate-500. Sets COLOR ONLY (not size/line-height — use `font_size` / create_text_group for typography). 'accent' = slate-700 neutral; pass `fill` for chroma. Explicit `fill_color` always wins.").optional(),
734
+ fill: z.string().describe("Optional named Flat-UI accent (kebab, e.g. 'peter-river'). Wins over `role`; only applies when `fill_color` is omitted; purple/unknown ignored.").optional()
735
+ }).strict()
736
+ },
737
+ create_text_group: {
738
+ description: "**USE THIS for any prompt that asks for 2+ text layers as a coherent composition** — hero slides, title+subtitle+footer, label+value pairs, multi-line callouts. One tool call lays out the whole group with sensible typography defaults and centers it on the scene as a unit.\n\nWhy this exists: composing the same layout out of `create_text` + `align_layer` typically takes 6-15 tool calls (create each, align each, shrink if clipped, re-center). This tool runs the equivalent sequence server-side with one response, picking Cal Sans (the project default), role-derived sizes (H2 = ½ × H1), and the correct typography color hierarchy.\n\n**Roles** drive font_size + font_variant + fill_color + line-height + tracking when you don't override them (Slate ramp, never pure black; display headings get NEGATIVE tracking so they read tight, not loose):\n- `h1` → big bold slate-900 (the main title), tight leading, negative tracking\n- `h2` → half of H1, slate-600 (subtitle), slight negative tracking\n- `h3` → ~40% of H1, slate-600\n- `body` → ~36% of H1, slate-600, neutral tracking\n- `caption` / `footer` → ~28% of H1, slate-500 (muted but readable)\n\nIf you omit `role`, the position picks it: first item = h1, second = h2, rest = body. So `items: [{ text: 'Welcome' }, { text: 'Get started' }, { text: 'lottiefiles.com' }]` Just Works.\n\n**Sizing**: `font_size` defaults scale with the scene size — on a 512×512 canvas H1 ≈ 44px; on a 1080×1080 canvas H1 ≈ 80px. Override `font_size` per item only when the design specifically calls for it.\n\n**Layout**: `vstack-centered` (the only mode for now) stacks items vertically with `gap` pixels between, centered horizontally on the scene, with the group's vertical center landing on `scene_anchor`. `scene_anchor: 'center'` (default) centers the whole stack. `'top-center'` biases it to the upper third; `'bottom-center'` to the lower third. Pass `{x, y}` for an arbitrary anchor.\n\n**RESPONSE**: returns `items: [{ layer_id, text, visual_center, visual_bounds, ... }, ...]`, `group_visual_bounds`, and `scene_fit` for the whole group. Check `scene_fit.clipped` and adjust the largest `font_size` once if the group overflowed — don't loop guessing sizes.",
739
+ inputSchema: z.object({
740
+ items: z.array(z.object({
741
+ text: z.string().min(1),
742
+ role: z.enum([
743
+ "h1",
744
+ "h2",
745
+ "h3",
746
+ "body",
747
+ "caption",
748
+ "footer"
749
+ ]).describe("Typography role. Drives font_size, font_variant, and fill_color defaults. Omit and the position picks it (first=h1, second=h2, rest=body).").optional(),
750
+ font_size: z.number().gt(0).describe("Explicit font_size override (px). Omit to use the role default.").optional(),
751
+ font_family: z.string().describe("Defaults to \"Cal Sans\" (the project font). Override only if asked.").optional(),
752
+ font_variant: z.string().optional(),
753
+ fill_color: rgbColorInputSchema.optional(),
754
+ text_caps: z.enum([
755
+ "regular",
756
+ "all_caps",
757
+ "small_caps"
758
+ ]).optional(),
759
+ line_height: z.number().gt(0).optional(),
760
+ tracking: z.number().optional(),
761
+ wrap_width: z.number().gt(0).optional(),
762
+ name: z.string().optional()
763
+ }).strict()).min(1).max(8),
764
+ layout: z.literal("vstack-centered").describe("Default and only option for now. More layouts will land as needed.").optional(),
765
+ gap: z.number().min(0).describe("Pixels of vertical space between items. Defaults to ~50% of the H1 size (e.g. 22px on a 512 canvas).").optional(),
766
+ scene_anchor: z.union([z.enum([
767
+ "center",
768
+ "top-center",
769
+ "bottom-center"
770
+ ]), z.object({
771
+ x: z.number(),
772
+ y: z.number()
773
+ }).strict()]).describe("Where the group's center lands. 'center' (default) puts the group at scene center. 'top-center' / 'bottom-center' bias to upper/lower third. `{x, y}` for arbitrary.").optional()
774
+ }).strict()
775
+ },
776
+ create_button: {
777
+ description: "Build a button / pill / chip in ONE call. Composes a rounded fill panel + a CENTERED label + optical padding (vertical < horizontal) + a WCAG-AA contrast-correct label color + correct z-order (label in front) + an auto elevation, grouped into one animatable precomp.\n\n**Use this instead of hand-rolling `create_shape` + `create_text` + `reorder_layer`** for any button/pill/chip/CTA/tag. It can't get the z-order, padding, radius, or label contrast wrong, and you never have to reference a layer id you're still creating.\n\n`variant`: `button` (md/8 radius), `pill` (stadium — auto-clamps to half the shorter side), `chip` (smaller, md/8). `fill_color` defaults to Slate-700 `{r:51,g:65,b:85}`; pass a REAL RGB object for an explicit CTA color, or `fill_color:'none'` + a `stroke` for an outlined button. Do not pass stringified JSON. `fill` is only an optional named Flat-UI accent string (e.g. 'peter-river') and is ignored when `fill_color` is provided. The **label color is auto-picked** (slate-50 vs slate-900, whichever clears AA on the fill) — only pass `label_color` to override, and it's kept only if it still passes AA. `elevation`: a tier (`resting`/`raised`/`floating`), or `false`/`'none'` to suppress the auto-resting lift. Returns `{ button_layer_id (the group), panel_layer_id, label_layer_id, label_color, contrast_ratio, passes_aa, visual_center, visual_bounds }` — animate the whole button via `button_layer_id` (e.g. `animate_entrance`).",
778
+ inputSchema: z.object({
779
+ label: z.string().describe("The button text."),
780
+ variant: z.enum([
781
+ "button",
782
+ "pill",
783
+ "chip"
784
+ ]).describe("Default 'button'. 'pill' = stadium corners; 'chip' = smaller.").optional(),
785
+ fill_color: fillColorInputSchema.describe("Solid fill RGB object (default Slate-700), OR 'none' for an outlined button. Pass a real object like {r:6,g:182,b:212}; JSON strings are accepted only for compatibility.").optional(),
786
+ fill: z.string().describe("Optional named Flat-UI accent, e.g. 'peter-river','emerald','sun-flower'. Only used when `fill_color` is omitted. Do not pass RGB here.").optional(),
787
+ radius_token: z.enum([
788
+ "xs",
789
+ "sm",
790
+ "md",
791
+ "lg",
792
+ "pill"
793
+ ]).describe("Override the variant corner radius. pill = stadium clamp.").optional(),
794
+ padding: z.union([z.number(), z.object({
795
+ top: z.number().optional(),
796
+ right: z.number().optional(),
797
+ bottom: z.number().optional(),
798
+ left: z.number().optional()
799
+ }).strict()]).describe("Override the optical padding (keep vertical < horizontal).").optional(),
800
+ font_size: z.number().describe("Label size. Default 20 (14 for chip).").optional(),
801
+ font_variant: z.string().describe("Default 'Regular'.").optional(),
802
+ stroke: z.object({
803
+ color: rgbColorInputSchema,
804
+ width: z.number()
805
+ }).strict().describe("Outline. Required when `fill_color: 'none'`.").optional(),
806
+ elevation: z.union([
807
+ z.enum([
808
+ "resting",
809
+ "raised",
810
+ "floating"
811
+ ]),
812
+ z.literal(false),
813
+ z.literal("none")
814
+ ]).describe("Shadow tier. Omit for auto-resting on a filled button; false/'none' suppresses.").optional(),
815
+ label_color: rgbColorInputSchema.describe("Override the auto-picked label color (kept only if it passes AA).").optional(),
816
+ origin: z.union([z.enum([
817
+ "scene-center",
818
+ "top-center",
819
+ "bottom-center"
820
+ ]), z.object({
821
+ x: z.number(),
822
+ y: z.number()
823
+ }).strict()]).describe("Where the button centers. Default 'scene-center'.").optional(),
824
+ group: z.boolean().describe("Group panel + label into one precomp. Default true.").optional(),
825
+ name: z.string().optional()
826
+ }).strict()
827
+ },
828
+ create_reference_board: {
829
+ description: "Build a whole multi-section REFERENCE BOARD in ONE call — a title plus a stack of labelled sections. **This is the right tool for \"illustrate my motion system\", a style guide, a color-palette board, a token/spec sheet, an icon or feature list, or any reference overview.** Do NOT hand-place a board out of many `create_text` + `create_shape` calls — that scatters off-center, drops sections off-grid, and floats the title in the middle (the #1 failure mode). This composes everything into ONE `layout` column (origin scene-center), so the board is centered, evenly gapped, edge-aligned, safe-zone-fit, and on-canvas BY CONSTRUCTION.\n\nEach `section` has an optional `heading` and ONE content kind: `swatches` (color chips — a compact bare grid, or a {swatch,label} legend when ≤8 chips carry `label`s), `rows` ({label,value} spec rows), or `lines` (text specimens like font samples or easing values). Returns the composed `layout` result (`items`, `cascade_order` for `stagger_layers`, `content_bounds`, `created_layer_ids`).",
830
+ inputSchema: z.object({
831
+ title: z.string().describe("Board title, shown large at the top.").optional(),
832
+ subtitle: z.string().describe("Optional subtitle under the title.").optional(),
833
+ sections: z.array(z.object({
834
+ heading: z.string().describe("Section label, e.g. \"Colors\", \"Fonts\", \"Easings\".").optional(),
835
+ swatches: z.array(z.object({
836
+ color: z.union([z.object({
837
+ r: z.number(),
838
+ g: z.number(),
839
+ b: z.number()
840
+ }).strict(), z.literal("none")]),
841
+ label: z.string().optional(),
842
+ shape: z.enum(["rectangle", "ellipse"]).optional(),
843
+ stroke: z.object({
844
+ color: z.object({
845
+ r: z.number(),
846
+ g: z.number(),
847
+ b: z.number()
848
+ }).strict(),
849
+ width: z.number()
850
+ }).strict().optional()
851
+ }).strict()).describe("Color chips. ≤8 chips WITH `label`s render as a {swatch,label} legend; otherwise a compact bare grid (heading names the group).").optional(),
852
+ swatch_columns: z.number().describe("Columns for a bare swatch grid (default ≤8).").optional(),
853
+ swatch_size: z.number().describe("Chip size in px. Default 28.").optional(),
854
+ rows: z.array(z.object({
855
+ label: z.string(),
856
+ value: z.string().optional()
857
+ }).strict()).describe("Key/value spec rows.").optional(),
858
+ lines: z.array(z.object({
859
+ text: z.string(),
860
+ role: z.enum([
861
+ "heading",
862
+ "body",
863
+ "caption",
864
+ "accent"
865
+ ]).optional(),
866
+ fill: z.string().optional(),
867
+ font_size: z.number().optional(),
868
+ font_variant: z.string().optional()
869
+ }).strict()).describe("Text specimens (font samples, easing values, notes).").optional()
870
+ }).strict()).describe("The board sections, top to bottom. Each needs a content kind."),
871
+ caption: z.string().describe("Optional footer caption.").optional(),
872
+ gap: z.number().describe("Gap between sections. Default 40.").optional(),
873
+ align: z.enum(["start", "center"]).describe("Default 'start' (content left-flush, whole block centered).").optional(),
874
+ origin: z.union([z.enum([
875
+ "scene-center",
876
+ "top-center",
877
+ "bottom-center"
878
+ ]), z.object({
879
+ x: z.number(),
880
+ y: z.number()
881
+ }).strict()]).describe("Where the board centers. Default 'scene-center'.").optional(),
882
+ group: z.boolean().describe("Group the whole board into one precomp. Default false.").optional()
883
+ }).strict()
884
+ },
885
+ set_text: {
886
+ description: "Update the text content of an existing TEXT_LAYER using native TextDocument APIs. By default the tool preserves the current visual bounds center after the text changes, so the layer does not jump when the new string is wider or narrower.\n\n**RESPONSE — verify before saying \"Done\":** returns `visual_center`, `visual_bounds`, and `scene_fit`. A string swap can push the new bounds past the canvas edge — check `scene_fit.clipped` / `scene_fit.overflow.{left,right,top,bottom}` after EVERY set_text. If anything overflows, self-correct (downscale `font_size` via `set_text_style`, add `wrap_width` via `set_text_style` for paragraph copy, or shorten the text) or surface the limitation in one short line. See motion-design.md → \"Post-mutation layout integrity\".",
887
+ inputSchema: z.object({
888
+ layer_id: z.string(),
889
+ text: z.string().min(1),
890
+ recenter: z.boolean().describe("Defaults true. Keeps the previous visual bounds center after changing text.").optional()
891
+ }).strict()
892
+ },
893
+ set_text_style: {
894
+ description: "Update native TextDocument styling on an existing TEXT_LAYER. Supports font size, font family/variant or font name, fill color, stroke color/width, alignment, line height, tracking, caps, and `wrap_width` (for paragraph copy that needs to wrap inside a fixed-width column). Pass `fill_color: null` to clear the fill override, `stroke_color: null` to clear the stroke color override, and `stroke_width: null` to clear the stroke width override. By default it preserves the current visual bounds center after style changes. For \"slight tracking\", use 2-8, not 20-40.\n\n**RESPONSE — verify before saying \"Done\":** returns `visual_center`, `visual_bounds`, and `scene_fit`. Bumping `font_size` can push the rendered text past the canvas edge — check `scene_fit.clipped` / `scene_fit.overflow.{left,right,top,bottom}` after EVERY set_text_style call. If anything overflows, self-correct (smaller multiplier, add `wrap_width` to wrap, or bump the scene via `set_scene_settings`) or surface the limitation in one short line. See motion-design.md → \"Post-mutation layout integrity\".",
895
+ inputSchema: z.object({
896
+ layer_id: z.string(),
897
+ font_size: z.number().gt(0).optional(),
898
+ font_name: z.string().describe("Direct Creator font asset name, e.g. \"Inter Bold\". Prefer font_family/font_variant when known.").optional(),
899
+ font_family: z.string().optional(),
900
+ font_variant: z.string().optional(),
901
+ align: z.enum([
902
+ "left",
903
+ "center",
904
+ "right",
905
+ "justify_left",
906
+ "justify_center",
907
+ "justify_right",
908
+ "justify_full"
909
+ ]).optional(),
910
+ line_height: z.number().gt(0).optional(),
911
+ tracking: z.number().describe("Native text tracking / letter spacing value. For \"slight tracking\", use 2-8. Avoid 20+ unless the user explicitly asks for very wide spacing.").optional(),
912
+ text_caps: z.enum([
913
+ "regular",
914
+ "all_caps",
915
+ "small_caps"
916
+ ]).optional(),
917
+ fill_color: rgbColorInputSchema.nullable().optional(),
918
+ stroke_color: rgbColorInputSchema.nullable().optional(),
919
+ stroke_width: z.number().min(0).nullable().optional(),
920
+ wrap_width: z.number().gt(0).describe("Max rendered width (in scene pixels) before text wraps. **Use ONLY when the text needs to wrap inside a fixed-width column.** Omit for headlines, subtitles, footers, and any single-line text. Internally folds the text by inserting `\\n` characters at word boundaries — the same hard line breaks 'Shift+Enter' produces in the property panel. Existing `\\n` in the text is preserved.").optional(),
921
+ recenter: z.boolean().describe("Defaults true. Keeps the previous visual bounds center after changing style.").optional()
922
+ }).strict()
923
+ },
924
+ measure_text_units: {
925
+ description: "Return per-line, per-word, and per-character bounds for an existing text layer. Uses the same canvas.measureText pipeline that powers `read_scene`'s `measured_bounds`, but exposes the cumulative pen position per glyph so per-character recipes can place each new layer at the source's exact kerning — no `bounds.width / char_count` approximation.\n\nWhen to call (REQUIRED before splitting):\n- Any per-character recipe (typewriter, per-character-rise, bottom-up-letters, top-down-letters, kinetic-center-build, short-slide-*).\n- Any per-word recipe where each word needs its own layer (per-word-crossfade, spring-scale-in) so the rebuilt phrase reads as one centered unit.\n- Layout-aware effects that need exact word widths (short-slide-down, short-slide-right, kinetic-center-build).\n\nWhen NOT to call:\n- Whole-layer recipes (micro-scale-fade, fade-through, scale-down-fade, mask-reveal-up) — those animate one layer; `read_scene`'s `visual_bounds` is enough.\n- Layers that aren't text — call returns an error.\n\nReturns (positions match `read_scene`'s coordinate system):\n- `local_bounds` / `world_bounds` for the whole layer (matches `text_style.measured_bounds` and `layout.visual_bounds` from `read_scene`).\n- `font`: `{ name, size, tracking, line_height, alignment }` — pass these to `create_text` per character/word/line to preserve the source typeface.\n- `lines[]`: one entry per `\\n`-separated line. Each line carries `text`, `local_bounds`, `world_bounds`, and optionally `words[]` / `characters[]` depending on `granularity`.\n- `characters[i].local_bounds.x + width/2` is the visual CENTER of glyph i; use that as the `position.x` for the per-character layer.\n- `characters[i].advance_x_local` is the pen position AFTER glyph i (left edge of glyph i+1 with tracking applied). Sum across glyphs and you reproduce the line width.\n\nGranularity:\n- `line` — only line bounds. Cheapest. Use for per-line recipes.\n- `word` — adds word bounds inside each line. Use for per-word recipes.\n- `character` — adds character bounds inside each line. Use for per-character recipes.\n- `all` (default) — words + characters. Cheap; the heavy lifting is already done.\n\nLimits:\n- Measurement uses the active document fonts. If the layer's font hasn't loaded yet, widths fall back to a coarse `0.48 * fontSize` estimate (returned as one-line/zero-words/zero-characters in non-DOM contexts). Verify with `inspect_scene` if you suspect a font-load race.\n- The returned positions are in the same coordinate space as `read_scene` — `local_bounds` is relative to the layer's anchor (alignment already applied), `world_bounds` is in scene coordinates.",
926
+ inputSchema: z.object({
927
+ layer_id: z.string().describe("Text layer id from `read_scene`. Pass the source phrase's layer id before splitting it."),
928
+ granularity: z.enum([
929
+ "line",
930
+ "word",
931
+ "character",
932
+ "all"
933
+ ]).describe("Which units to return. Default 'all' (words + characters per line). Use 'character' for per-character recipes, 'word' for per-word, 'line' for per-line.").optional()
934
+ }).strict()
935
+ },
936
+ split_text: {
937
+ description: "Split a text layer into one new text layer per character, word, or line at exact `measure_text_units` positions. Copies the source font/style to every new layer and (by default) deletes the source so the new layers don't render on top of the original. Replaces the manual `measure_text_units` → `delete_layer` → loop-of-create_text dance.\n\nWhen to call:\n- Any per-character recipe (typewriter, per-character-rise, bottom-up-letters, top-down-letters) — pass `granularity: 'character'`.\n- Any per-word recipe (per-word-crossfade, spring-scale-in) — pass `granularity: 'word'`.\n- Any per-line recipe (line-by-line-slide, mask-reveal-up) — pass `granularity: 'line'`.\n\nWhen NOT to call:\n- Whole-layer animations — there's only one layer; nothing to split.\n- You need different fonts per unit — split with `copy_style: false` then `set_text_style` per layer.\n- You want to animate the original AND a split copy — pass `delete_source: false`.\n\nReturns `{ source_layer_id, source_deleted, granularity, layers: [{ layer_id, text, position: {x,y}, bounds }] }`. The `layers` array is in reading order (line 0 first, then line 1, …) so you can iterate with a running stagger index. Each `position` is the visual center of the unit in scene coordinates — already aligned with what `create_text` does when called with that position, so no `center_layer` follow-up is needed.\n\nLimits:\n- Up to ~80 units per call. Beyond that the per-create dispatch overhead dominates; consider `granularity: 'word'` instead of 'character' on long copy.\n- Splits are sequential, not transactional today — N undo steps, not one. Plan accordingly when chaining with other edits.\n- Spaces ARE returned as their own layers when `granularity: 'character'` (their bounds may be zero-width). Skip them client-side if your recipe doesn't animate them, but keep the layer count exact if your stagger formula uses `i` as the index.",
938
+ inputSchema: z.object({
939
+ layer_id: z.string().describe("Source text layer id (from `read_scene`)."),
940
+ granularity: z.enum([
941
+ "character",
942
+ "word",
943
+ "line"
944
+ ]).describe("'character' = one layer per glyph, 'word' = one layer per /\\S+/ run, 'line' = one layer per \\n-separated row."),
945
+ delete_source: z.boolean().describe("Delete the source layer after splitting. Default true. Set false only when you need both source and split copies in the scene at the same time.").optional(),
946
+ copy_style: z.boolean().describe("Copy the source's font, tracking, fill, stroke, alignment, line_height, and caps to every new layer. Default true. Set false only when each unit needs a different style.").optional(),
947
+ animate: z.object({
948
+ preset: z.enum([
949
+ "slide",
950
+ "scale",
951
+ "fade"
952
+ ]).describe("'slide' = move in + fade (default), 'scale' = grow from 0 (centers anchor), 'fade' = opacity only.").optional(),
953
+ per_item_delay_frames: z.number().min(0).describe("Frames between each unit's start. If the user gives a literal number (\"10-frame stagger\") use it verbatim. Default 4.").optional(),
954
+ start_frame: z.number().min(0).describe("Frame the first unit starts. Default 0.").optional(),
955
+ duration_frames: z.number().min(1).describe("Each unit's entrance duration. Default 16.").optional(),
956
+ easing: z.enum([
957
+ "ease-in",
958
+ "ease-out",
959
+ "ease-in-out",
960
+ "gentle-out",
961
+ "snappy-out",
962
+ "smooth-in-out",
963
+ "overshoot-soft",
964
+ "overshoot-hard",
965
+ "anticipate"
966
+ ]).describe("Easing preset name. Default 'gentle-out'.").optional(),
967
+ distance: z.number().min(0).describe("Slide travel in px (preset 'slide' only). Default 40.").optional(),
968
+ direction: z.enum([
969
+ "up",
970
+ "down",
971
+ "left",
972
+ "right"
973
+ ]).describe("Slide-in direction (preset 'slide' only). Default 'up'.").optional(),
974
+ fade: z.boolean().describe("Fade opacity 0→100 alongside. Default true.").optional()
975
+ }).strict().describe("Apply a staggered entrance to the split units IN ONE CALL — strongly preferred over a manual per-letter `apply_keyframes` loop (which is ≈3N calls and easy to mis-center). The stagger reads each unit's OWN resting position, so letters slide into their correct kerned spots. For 'each letter slides up with a 10-frame stagger', pass `{ preset: 'slide', direction: 'up', per_item_delay_frames: 10 }`.").optional()
976
+ }).strict()
977
+ },
978
+ stagger_layers: {
979
+ description: "Apply a staggered entrance to MANY existing layers in ONE call — each layer enters slightly after the previous, reading as one cascading sequence. This replaces the per-layer `set_anchor_point` + 2×`apply_keyframes` loop (≈3N calls) that the agent used to hand-roll for letter cascades, card grids, list reveals, etc.\n\nThe stagger reads EACH layer's own resting position, so it's safe on already-distinct layers (split-text letters at their kerned spots, grid cards at their slots) — unlike `copy_animation`, which would copy one layer's absolute position onto all the others and collapse them into a single spot.\n\nLayer order in `layer_ids` IS the cascade order. Each layer gets a frame-0 hold so it stays invisible/offset until its turn (no 'everything visible at frame 0' bug). Returns `{ staggered, last_entrance_end_frame, ... }` — use `last_entrance_end_frame` to place the NEXT beat (subtitle, button) so the whole scene reads as one composed sequence.\n\nFor 'letters slide up with a 10-frame stagger', either call `split_text` with its `animate` option (one call total), or split then call this with `{ preset: 'slide', direction: 'up', per_item_delay_frames: 10 }`.",
980
+ inputSchema: z.object({
981
+ layer_ids: z.array(z.string()).min(1).describe("Layers to stagger, in cascade order (first enters first)."),
982
+ preset: z.enum([
983
+ "slide",
984
+ "scale",
985
+ "fade"
986
+ ]).describe("'slide' = move in + fade (default), 'scale' = grow from 0 (centers each anchor), 'fade' = opacity only.").optional(),
987
+ per_item_delay_frames: z.number().min(0).describe("Frames between each layer's start. If the user gives a literal number (\"10-frame stagger\") use it verbatim. Default 4.").optional(),
988
+ start_frame: z.number().min(0).describe("Frame the first layer starts. Default 0.").optional(),
989
+ duration_frames: z.number().min(1).describe("Each layer's entrance duration. Default 16.").optional(),
990
+ easing: z.enum([
991
+ "ease-in",
992
+ "ease-out",
993
+ "ease-in-out",
994
+ "gentle-out",
995
+ "snappy-out",
996
+ "smooth-in-out",
997
+ "overshoot-soft",
998
+ "overshoot-hard",
999
+ "anticipate"
1000
+ ]).describe("Easing preset name. Default 'gentle-out'.").optional(),
1001
+ distance: z.number().min(0).describe("Slide travel in px (preset 'slide' only). Default 40.").optional(),
1002
+ direction: z.enum([
1003
+ "up",
1004
+ "down",
1005
+ "left",
1006
+ "right"
1007
+ ]).describe("Slide-in direction (preset 'slide' only). Default 'up'.").optional(),
1008
+ fade: z.boolean().describe("Fade opacity 0→100 alongside. Default true.").optional()
1009
+ }).strict()
1010
+ },
1011
+ animate_entrance: {
1012
+ description: "Animate ONE layer's entrance (a settle-into-place reveal) WITHOUT supplying any position coordinate. The tool reads the layer's OWN resting transform and animates relative to it, so the layer settles back EXACTLY where it rests.\n\n**This is the SAFE path for a TEXT reveal.** A text layer's `position` keyframes live in RAW transform (baseline / box-origin) space — which is DIFFERENT from `visual_center` and from the `position` arg you passed to `create_text` (that arg is the visual center). Reusing the visual center as a `position` keyframe value silently throws the text off-canvas (the cause of a 30-call, 3-rebuild logo-reveal failure). `animate_entrance` takes no coordinate, so that mistake is impossible.\n\nUse it right after creating a layer to give it a premium entrance. `style`: `rise` (slide up into place, position only), `rise-fade` (slide + fade, the default), `fade` (opacity only), `scale` (grow from a centered anchor + fade). It only SETTLES the layer to its current resting spot — it is not a relocate, and it must NOT be re-run on a layer that already has position keyframes. Do NOT chain `set_anchor_point` or `center_layer` before/after it. For MANY layers cascading, use `stagger_layers`. For a non-entrance move along a path, read the raw `static_value` via `read_keyframes` and use `apply_keyframes` relative to it.\n\nReturns `{ layer_id, style, entrance_start_frame, entrance_end_frame, duration_frames }` — use `entrance_end_frame` to time the next beat.",
1013
+ inputSchema: z.object({
1014
+ layer_id: z.string().describe("The layer to animate in."),
1015
+ style: z.enum([
1016
+ "rise",
1017
+ "fade",
1018
+ "scale",
1019
+ "rise-fade"
1020
+ ]).describe("'rise-fade' = slide up + fade (default), 'rise' = slide only, 'fade' = opacity only, 'scale' = grow from centered anchor + fade.").optional(),
1021
+ direction: z.enum([
1022
+ "up",
1023
+ "down",
1024
+ "left",
1025
+ "right"
1026
+ ]).describe("Slide-in direction for rise/rise-fade. Default 'up'.").optional(),
1027
+ distance: z.number().min(0).describe("Slide travel in px for rise/rise-fade. Default 40.").optional(),
1028
+ frames: z.number().min(1).describe("Entrance duration in frames. Default 16.").optional(),
1029
+ start_frame: z.number().min(0).describe("Frame the entrance starts (holds offset/invisible before). Default 0.").optional(),
1030
+ easing: z.enum([
1031
+ "ease-in",
1032
+ "ease-out",
1033
+ "ease-in-out",
1034
+ "gentle-out",
1035
+ "snappy-out",
1036
+ "smooth-in-out",
1037
+ "overshoot-soft",
1038
+ "overshoot-hard",
1039
+ "anticipate"
1040
+ ]).describe("Easing preset name. Default 'gentle-out'.").optional()
1041
+ }).strict()
1042
+ },
1043
+ list_fonts: {
1044
+ description: "List font families available in the active project — Creator's curated presets, Google Fonts, OS-installed local fonts (when permission has been granted), and project-uploaded font assets. Use this when the user names a specific font you're not sure is loaded ('animate \"Acme\" in Bebas Neue letter-by-letter'), or when you need to recommend a fallback that's actually present.\n\nWhen to call:\n- User names a font you're not certain exists in this project (brand fonts, niche typefaces).\n- You're about to call `create_text` / `set_text_style` with a specific `font_name` and want to verify the family + style are loaded.\n- You're deciding between multiple style options for the same family.\n\nWhen NOT to call:\n- The user didn't specify a font — `create_text` uses the project default. Calling this would just burn a turn.\n- You already called it earlier in the conversation.\n\nReturns `{ families: [{ family, styles, default_style, source }] }`. `source` is one of `'PRESET' | 'GOOGLE_FONT' | 'LOCAL' | 'ASSET'`. If the requested family isn't in the list, fall back to a similar style + tell the user (e.g. 'Bebas Neue isn't loaded; I used Anton, the closest condensed sans available'). Pass `source` to filter to one category.\n\nNote: the catalog is a snapshot of what's currently loaded. Local OS fonts only appear after the user grants the browser's `local-fonts` permission, which Creator does not prompt for automatically.",
1045
+ inputSchema: z.object({ source: z.enum([
1046
+ "all",
1047
+ "preset",
1048
+ "google",
1049
+ "local",
1050
+ "asset"
1051
+ ]).describe("Filter the result to one font origin. 'all' (default) returns every loaded family.").optional() }).strict()
1052
+ },
1053
+ center_layer: {
1054
+ description: "Reposition an EXISTING layer so its visual center lands on a SPECIFIC target point (e.g. a callout at `(180, 380)`, or anywhere off-center). For text, this uses rendered bounds when Creator has them and measured text bounds otherwise, so it avoids the top-left/origin centering bug.\n\n**For aligning to scene center (the common case), use `align_layer({ direction: 'horizontal-center' })` + `align_layer({ direction: 'vertical-center' })` instead — that's Creator's own alignment path and the more reliable default.** Use `center_layer` only when the target is not the scene center. For a brand-new centered text layer, prefer `create_text` with `center_in_scene: true`.\n\n**RESPONSE — verify before saying \"Done\":**\n- `target_reached` is the authoritative placement result: `true` only when both an independent rendered-bounds check and the returned `visual_center` match the target within ~1px; `false` when a rendered verification result exists but its center misses, or the returned `visual_center` is unavailable or misses; and `undefined` when no target or rendered verification result is available, including unavailable bounds, verification errors, or the 1,500ms timeout. **If it is not `true`**: retry with `align_layer` (which uses Creator's authoritative alignment math), OR surface the limitation to the user in one short line.\n- `visual_center` may be the projected post-mutation center while rendering settles. Use it to understand the returned layout, not as proof that the move landed.\n- For text layers the response intentionally omits `position` — the layer's transform anchor sits at the baseline/box origin, not the visual center.\n- `scene_fit` also returned so you can confirm the layer didn't end up clipped. See motion-design.md → \"Post-mutation layout integrity\".",
1055
+ inputSchema: z.object({
1056
+ layer_id: z.string(),
1057
+ target: z.object({
1058
+ x: z.number(),
1059
+ y: z.number()
1060
+ }).strict().describe("Target center point. Defaults to scene center when omitted.").optional()
1061
+ }).strict()
1062
+ },
1063
+ set_fill: {
1064
+ description: "Set (or replace) the fill on a SHAPE_LAYER. Supports SOLID colors AND linear/radial gradients.\n\n**Picking a default color (no brand defined, no mood specified):** check `list_slots` and `list_motion_system_items` first. If both empty and the user didn't name a color, stay MONOCHROME — Slate-700 `{r:51,g:65,b:85}` for accents / buttons, Slate-50 `{r:248,g:250,b:252}` for surfaces. Indigo / purple / pink / teal as defaults read as AI slop. When the user DOES name a color or mood, pick from the Flat UI defo palette (see `motion-aesthetics` skill) instead of inventing Tailwind values. ONE chromatic accent + neutrals; no rainbow palettes.\n\n**Defaults to replacing `fills[0]`.** Pass `replace_first: false` to append as a second fill instead.\n\nColor stops: each stop has `offset` (0–1, position along the gradient line), `color` ({r,g,b} 0–255), and optional `opacity` (0–1, default 1). Keep stop count modest (2–5 stops typical). For premium 'glow' effects fade the color to pure black and set blend mode with `set_blend_mode`.\n\n**Gradient geometry — `start` / `end` are PIXEL coordinates in layer-local space (NOT normalized 0–1 ratios).** The shape primitive sits at the layer's local origin (0, 0); a rectangle/ellipse with size {W, H} extends from (-W/2, -H/2) to (+W/2, +H/2). The gradient line runs from `start` to `end`; offset 0 is at `start`, offset 1 is at `end`. Common patterns for a centered shape with size {W, H}:\n\n| Direction | start | end |\n| ---------------- | -------------- | -------------- |\n| Top → bottom | {x:0, y:-H/2} | {x:0, y:H/2} |\n| Bottom → top | {x:0, y:H/2} | {x:0, y:-H/2} |\n| Left → right | {x:-W/2, y:0} | {x:W/2, y:0} |\n| Diagonal TL→BR | {x:-W/2, y:-H/2}| {x:W/2, y:H/2}|\n| Radial center-out| {x:0, y:0} | {x:W/2, y:0} |\n\n**Omit `start`/`end` entirely if you don't have a strong opinion** — the native tool computes sensible pixel defaults from the layer's bounds (linear: top→bottom; radial: center→edge). Do NOT pass {0, 0}–{0, 1} or {0.5, 0}–{0.5, 1} thinking those are 'normalized' coordinates: those would render the gradient inside a 1-pixel strip and you'll see only the first stop's color across the entire shape.\n\n- Radial-only: `highlightAngle` (degrees) and `highlightLength` (-100 to 100) shift the focal point off-center.\n\nTo animate paint, use `animate_paint` for gradient stops/handles or `apply_keyframes` with `fill_color` for SOLID color cycles. `set_fill` is for changing the static fill kind.",
1065
+ inputSchema: z.object({
1066
+ layer_id: z.string(),
1067
+ replace_first: z.boolean().describe("If true (default), remove fills[0] before adding. If false, append.").optional(),
1068
+ fill: paintInputSchema
1069
+ }).strict()
1070
+ },
1071
+ set_stroke: {
1072
+ description: "Set (or replace) the stroke on a SHAPE_LAYER. Mirror of `set_fill` but for outlines/lines, plus a `width` (pixels). Supports SOLID and gradient strokes (same stop structure as set_fill).\n\nDefaults to replacing `strokes[0]`. Pass `replace_first: false` to append.\n\nAfter the stroke exists, you can animate its width over time with `apply_keyframes` property 'stroke_width'.",
1073
+ inputSchema: z.object({
1074
+ layer_id: z.string(),
1075
+ width: z.number().min(.1).describe("Stroke width in pixels."),
1076
+ replace_first: z.boolean().describe("If true (default), replace strokes[0]; else append.").optional(),
1077
+ fill: paintInputSchema
1078
+ }).strict()
1079
+ },
1080
+ animate_paint: {
1081
+ description: "Keyframe fill or stroke paint directly. Use this for true animated gradients: create/ensure a gradient fill or stroke, then animate `gradient_stops`, `gradient_start`, `gradient_end`, or radial highlight properties. Use `solid_color` for a SOLID paint color cycle. This is the preferred tool for prompts like 'add animated gradients', 'make the gradient sweep', or 'animate the stroke colors'.",
1082
+ inputSchema: z.object({
1083
+ layer_id: z.string(),
1084
+ target: z.enum(["fill", "stroke"]),
1085
+ paint_index: z.number().min(0).optional(),
1086
+ ensure: paintInputWithOptionalStrokeWidthSchema.optional(),
1087
+ property: z.enum([
1088
+ "solid_color",
1089
+ "gradient_stops",
1090
+ "gradient_start",
1091
+ "gradient_end",
1092
+ "highlight_angle",
1093
+ "highlight_length"
1094
+ ]),
1095
+ clear_first: z.boolean().optional(),
1096
+ keyframes: z.array(z.object({
1097
+ frame: z.number(),
1098
+ value: paintKeyframeValueSchema,
1099
+ easing: easingInputSchema.optional()
1100
+ }).strict()).min(1)
1101
+ }).strict()
1102
+ },
1103
+ animate_property: {
1104
+ description: "Keyframe extended properties not covered by apply_keyframes, including paint opacity/color, gradient handles, shape geometry, mask path/opacity, text animator colors, and scene-layer time mapping. Use this when the property is explicitly named and a typed high-level tool is not a better fit.",
1105
+ inputSchema: z.object({
1106
+ layer_id: z.string(),
1107
+ property: extendedOnlyAnimatedPropertySchema,
1108
+ target_index: z.number().min(0).optional(),
1109
+ target_id: z.string().optional(),
1110
+ clear_first: z.boolean().optional(),
1111
+ keyframes: z.array(z.object({
1112
+ frame: z.number(),
1113
+ value: extendedPropertyKeyframeValueSchema,
1114
+ easing: easingInputSchema.optional()
1115
+ }).strict()).min(1)
1116
+ }).strict()
1117
+ },
1118
+ set_blend_mode: {
1119
+ description: "Set a layer's blend mode. One of: normal, multiply, screen, overlay, darken, lighten, color-dodge, color-burn, hard-light, soft-light, difference, exclusion, hue, saturation, color, luminosity.\n\nCommon uses:\n- `screen` → glow / shine / highlight layers (fades to black = invisible)\n- `multiply` → shadows / darkening overlays\n- `overlay` / `soft-light` → color tinting\n- `difference` → inverting effects",
1120
+ inputSchema: z.object({
1121
+ layer_id: z.string(),
1122
+ blend_mode: z.enum([
1123
+ "normal",
1124
+ "multiply",
1125
+ "screen",
1126
+ "overlay",
1127
+ "darken",
1128
+ "lighten",
1129
+ "color-dodge",
1130
+ "color-burn",
1131
+ "hard-light",
1132
+ "soft-light",
1133
+ "difference",
1134
+ "exclusion",
1135
+ "hue",
1136
+ "saturation",
1137
+ "color",
1138
+ "luminosity"
1139
+ ])
1140
+ }).strict()
1141
+ },
1142
+ add_drop_shadow: {
1143
+ description: "Attach a native Drop Shadow effect to a layer. Renders in Creator's preview and in dotlottie-web (the default Lottie player). lottie-web silently drops it.\n\nUse for: card/panel shadows, button lift, soft glow (distance=0 + large blur), text shadow. **Replaces the legacy 'duplicate dark rectangle behind the card' workaround** — call this instead.\n\n**Prefer `elevation` for UI surfaces.** Pass `elevation:'resting'|'raised'|'floating'` to attach a tuned TWO-shadow stack (a tight contact shadow + a soft ambient one, slate-tinted) in one call — this reads far more premium than a single hand-tuned shadow. `resting` = a card at rest, `raised` = hover/lifted, `floating` = modal/popover. When `elevation` is set the manual color/opacity/angle/distance/blur are ignored.\n\nManual single-shadow param tips (when NOT using `elevation`):\n- `color` defaults to black {r:0,g:0,b:0}. Tint toward dark slate {r:15,g:23,b:42} for premium UI shadows.\n- `opacity` is 0-255 (Lottie convention). 191 ≈ 75% (default). 64 ≈ 25% for subtle, 230 ≈ 90% for dramatic.\n- `angle` in degrees, 135 = bottom-right (default). 90 = straight down, 270 = straight up.\n- `distance` in px. 0 = no offset (creates a soft glow). 4-8 = resting card. 12-24 = floating/modal lift.\n- `blur` in px. 0-2 = hard shadow. 8-15 = soft. 20-40 = very soft. Default 20.",
1144
+ inputSchema: z.object({
1145
+ layer_id: z.string(),
1146
+ color: z.object({
1147
+ r: z.number().min(0).max(255),
1148
+ g: z.number().min(0).max(255),
1149
+ b: z.number().min(0).max(255)
1150
+ }).strict().optional(),
1151
+ elevation: z.enum([
1152
+ "resting",
1153
+ "raised",
1154
+ "floating"
1155
+ ]).describe("Attach a tuned two-shadow elevation stack instead of a single shadow. Overrides color/opacity/angle/distance/blur. resting = card at rest, raised = hover, floating = modal.").optional(),
1156
+ opacity: z.number().min(0).max(255).optional(),
1157
+ angle: z.number().optional(),
1158
+ distance: z.number().min(0).optional(),
1159
+ blur: z.number().min(0).optional(),
1160
+ name: z.string().describe("Optional effect name (shows in Creator effects panel).").optional()
1161
+ }).strict()
1162
+ },
1163
+ add_blur: {
1164
+ description: "Attach a native Gaussian Blur effect to a layer. Renders in Creator's preview and in dotlottie-web. lottie-web silently drops it.\n\nUse for: blurred backgrounds, focus/defocus effects, soft glow with drop_shadow distance=0, glassy/frosted surfaces.\n\nParam tips:\n- `sigma` (px): blur strength. 5-10 = subtle, 15-25 = noticeable, 30-60 = dreamy/dramatic. Default 20.\n- `direction`: 0 = both axes (default), 1 = horizontal only, 2 = vertical only.\n- `wrap`: 0 = clamp edges (default, normal). 1 = repeat. Almost always leave at 0.",
1165
+ inputSchema: z.object({
1166
+ layer_id: z.string(),
1167
+ sigma: z.number().min(0).optional(),
1168
+ direction: z.union([
1169
+ z.literal(0),
1170
+ z.literal(1),
1171
+ z.literal(2)
1172
+ ]).optional(),
1173
+ wrap: z.union([z.literal(0), z.literal(1)]).optional(),
1174
+ name: z.string().optional()
1175
+ }).strict()
1176
+ },
1177
+ add_color_overlay: {
1178
+ description: "Attach a native Fill effect that overlays a solid color on the layer (multiply-style recolor). Renders in dotlottie-web; lottie-web drops it.\n\nUse for: recoloring imported SVG/image content without editing the source paths, hover-state overlays, themed tints. For shape layers you usually want `set_fill` instead — this is for cases where the layer's intrinsic fill should stay and a color washes over it.\n\nParam tips:\n- `color` required, RGB 0-255.\n- `opacity` 0-1 (Lottie Fill convention, not 0-255). Default 1 (full overlay).",
1179
+ inputSchema: z.object({
1180
+ layer_id: z.string(),
1181
+ color: z.object({
1182
+ r: z.number().min(0).max(255),
1183
+ g: z.number().min(0).max(255),
1184
+ b: z.number().min(0).max(255)
1185
+ }).strict(),
1186
+ opacity: z.number().min(0).max(1).optional(),
1187
+ name: z.string().optional()
1188
+ }).strict()
1189
+ },
1190
+ add_tint: {
1191
+ description: "Attach a native Tint effect (two-color recolor: dark pixels → black color, light pixels → white color, with adjustable intensity). Renders in dotlottie-web; lottie-web drops it.\n\nUse for: duotone treatments, retro/poster looks, single-color overlays that preserve luminance variation. NOT for replacing a single fill color — use `set_fill` for that.\n\nParam tips:\n- `black` color maps to the darkest pixels (default {0,0,0}).\n- `white` color maps to the brightest pixels (default {255,255,255}).\n- `intensity` 0-100: 0 = no effect, 100 = full duotone replacement (default 100).",
1192
+ inputSchema: z.object({
1193
+ layer_id: z.string(),
1194
+ black: z.object({
1195
+ r: z.number().min(0).max(255),
1196
+ g: z.number().min(0).max(255),
1197
+ b: z.number().min(0).max(255)
1198
+ }).strict().optional(),
1199
+ white: z.object({
1200
+ r: z.number().min(0).max(255),
1201
+ g: z.number().min(0).max(255),
1202
+ b: z.number().min(0).max(255)
1203
+ }).strict().optional(),
1204
+ intensity: z.number().min(0).max(100).optional(),
1205
+ name: z.string().optional()
1206
+ }).strict()
1207
+ },
1208
+ add_tritone: {
1209
+ description: "Attach a native Tritone effect (three-color recolor: highlight + midtone + shadow). Renders in dotlottie-web; lottie-web drops it.\n\nUse for: cinematic color grading, three-tone aesthetic treatments (sepia, vintage, dusk), branded recolors with more depth than `add_tint`.\n\nParam tips:\n- `highlight` color maps to brightest pixels (default white).\n- `midtone` color maps to middle luminance (default gray).\n- `shadow` color maps to darkest pixels (default black).\n- `intensity` 0-100: 0 = no effect, 100 = full recolor (default 100).",
1210
+ inputSchema: z.object({
1211
+ layer_id: z.string(),
1212
+ highlight: z.object({
1213
+ r: z.number().min(0).max(255),
1214
+ g: z.number().min(0).max(255),
1215
+ b: z.number().min(0).max(255)
1216
+ }).strict().optional(),
1217
+ midtone: z.object({
1218
+ r: z.number().min(0).max(255),
1219
+ g: z.number().min(0).max(255),
1220
+ b: z.number().min(0).max(255)
1221
+ }).strict().optional(),
1222
+ shadow: z.object({
1223
+ r: z.number().min(0).max(255),
1224
+ g: z.number().min(0).max(255),
1225
+ b: z.number().min(0).max(255)
1226
+ }).strict().optional(),
1227
+ intensity: z.number().min(0).max(100).optional(),
1228
+ name: z.string().optional()
1229
+ }).strict()
1230
+ },
1231
+ list_layer_effects: {
1232
+ description: "Return the effects currently attached to a layer, with their ids, types, and enabled state. Call this before stacking another effect, before removing one, or whenever you need to know what's already there.",
1233
+ inputSchema: z.object({ layer_id: z.string() }).strict()
1234
+ },
1235
+ remove_layer_effect: {
1236
+ description: "Remove a specific effect from a layer. Call `list_layer_effects` first to get the `effect_id`. Other effects on the layer are left intact.",
1237
+ inputSchema: z.object({
1238
+ layer_id: z.string(),
1239
+ effect_id: z.string()
1240
+ }).strict()
1241
+ },
1242
+ update_layer_effect: {
1243
+ description: "Nudge one slot on an existing effect without removing and re-adding it. Use this when the user asks to refine an effect ('a bit more blur', 'darken the shadow', 'rotate the shadow direction') — much cheaper than `remove_layer_effect` + `add_*` and preserves the slot order.\n\n**Slot indices** (match the parser order — see add_* tool docs):\n- Drop shadow: 0=color, 1=opacity (0-255), 2=angle (degrees), 3=distance (px), 4=blur (px)\n- Gaussian blur: 0=sigma, 1=direction (0|1|2), 2=wrap (0|1)\n- Fill (color overlay): 2=color, 6=opacity (0-1)\n- Tint: 0=black color, 1=white color, 2=intensity (0-100)\n- Tritone: 0=highlight, 1=midtone, 2=shadow, 3=intensity (0-100)\n\nPass `value` as a number for scalar/angle slots and as an `{r,g,b}` triplet for color slots. Call `list_layer_effects` first if you don't already have the `effect_id`.",
1244
+ inputSchema: z.object({
1245
+ layer_id: z.string(),
1246
+ effect_id: z.string(),
1247
+ slot_index: z.number().int().min(0),
1248
+ value: z.union([z.number(), z.object({
1249
+ r: z.number().min(0).max(255),
1250
+ g: z.number().min(0).max(255),
1251
+ b: z.number().min(0).max(255)
1252
+ }).strict()])
1253
+ }).strict()
1254
+ },
1255
+ list_themes: {
1256
+ description: "List the themes embedded in this file. Themes group design-token slots (brand colors, named text strings, etc.). The Default theme is always present; users can add named alternates (e.g. 'Dark', 'Holiday'). Returns `{ themes: [{ id, slot_count, is_active }] }`. Call before `list_slots` if the user references a specific theme by name, or to confirm that any tokens exist before suggesting slot-based edits.",
1257
+ inputSchema: z.object({}).strict()
1258
+ },
1259
+ list_slots: {
1260
+ description: "List design-token slots defined in the file. Each slot has ONE stable id (e.g. 'brand-red') but can supply different VALUES per theme — Default theme might have brand-red as crimson, a Dark theme might have it as burgundy. The toolkit renders whichever value the currently active theme provides.\n\nUse before inventing hardcoded values whenever the user names a brand concept ('brand red', 'primary blue', 'the header color', 'our headline text'). Filter with `type` to narrow (e.g. COLOR for fill/stroke pickers, TEXT for headline strings).\n\nReturns `{ slots: [{ id, type, values_by_theme: { themeId: value, ... }, active_theme, active_value }] }`. Without `theme_id`, `active_value` is what the canvas renders right now. With a `theme_id` filter for an inactive theme, `active_value` may be that filtered theme's fallback value for inspection, while `active_theme` still names the canvas-rendered theme. The `values_by_theme` map shows variants if the user wants to recolor a specific theme. Empty `slots` means the file has no tokens of that type — fall back to inline values and say so.",
1261
+ inputSchema: z.object({
1262
+ type: z.enum([
1263
+ "COLOR",
1264
+ "TEXT",
1265
+ "GRADIENT",
1266
+ "POSITION",
1267
+ "VECTOR",
1268
+ "SCALAR"
1269
+ ]).describe("Optional slot type filter. Omit to list every slot across every theme.").optional(),
1270
+ theme_id: z.string().describe("Optional theme id to scope the list. Omit to list slots from all themes. Defaults to no filter; use 'Default' for the always-present built-in theme.").optional()
1271
+ }).strict()
1272
+ },
1273
+ list_motion_system_items: {
1274
+ description: "Read the user's Motion System library — a separate design library from the file's theme slots. Returns colors / gradients / fonts / easings / durations / motion presets that the user has saved (typically synced from Workflow). Use this when the user names a brand value (e.g. 'brand red', 'standard ease-out', 'fast duration') and `list_slots` did NOT find a match — motion system is the fallback library. The values are returned for inline use (set_fill with the rgb you found, apply_keyframes with the control_points you found, etc.) — they are NOT linked tokens. For multi-property motion presets, use `apply_motion_preset` instead of trying to reconstruct the keyframes.\n\n**Hierarchy:** theme slots beat motion-system entries. Always `list_slots` first; only fall through to `list_motion_system_items` on a miss.\n\n**Colors vs gradients — same UI section, two enum values.** The Motion System UI shows solid colors AND gradients together under one 'Brand Colors' tab, so a name the user thinks is a 'color' may actually be a gradient (e.g. a logo gradient saved as `clogo`). When the user names a color and `type: 'colors'` returns no match, ALWAYS try `type: 'gradients'` before giving up. Only declare 'not found' after BOTH lookups miss.\n\nReturns `{ items: [...] }` typed per family (see schemas below). Pass no `type` to get a summary across all families. Order of `animated_properties` on motion-preset items is not guaranteed; treat as a set.",
1275
+ inputSchema: z.object({ type: z.enum([
1276
+ "colors",
1277
+ "gradients",
1278
+ "fonts",
1279
+ "easings",
1280
+ "durations",
1281
+ "motion_presets"
1282
+ ]).describe("Which asset family to read. Omit to get a `{ count, ids }` summary across all families. 'colors' returns solid colors only; 'gradients' returns linear/radial gradients (motion-system stores both under one section internally — this tool splits them).").optional() }).strict()
1283
+ },
1284
+ link_slot_to_layer: {
1285
+ description: "Link an existing design-token slot to a layer's property so the property reads from the slot reference instead of a hardcoded value. Future slot updates propagate automatically (this is the whole point of slots). Call `list_slots` first to find the slot id.\n\n**The link is to the slot id, NOT to a specific theme's value.** Whichever theme is active at render time supplies the color/text. If the user later switches themes, the linked layer follows the new theme's value automatically. You never pass a theme id here.\n\nWhat each `slot_type → shape_type` does:\n- `COLOR` + `shape_type: FILL` (default for shape layers): replaces the layer's fill color with the slot reference.\n- `COLOR` + `shape_type: STROKE`: replaces the layer's stroke color.\n- `TEXT` on a text layer: replaces the text content with the slot's string. Ignore `shape_type` for text.\n- `GRADIENT` + `shape_type: GRADIENT_FILL` / `GRADIENT_STROKE`: replaces the gradient.\n- `POSITION` / `VECTOR`: links the matching transform property; `shape_type` ignored.\n- `SCALAR`: links rotation / opacity / skew / skew_axis / stroke_width and REQUIRES `property_context`; `shape_type` ignored. Opacity additionally accepts `opacity_source: 'layer' | 'fill'` and defaults to `'layer'`.\n\nReturns `{ slot_id, layer_id, slot_type, shape_type }` on success. Errors if the slot or layer is missing, if `property_context` is missing for SCALAR, or if the slot type is incompatible with the layer (e.g. text slot on a shape layer).",
1286
+ inputSchema: z.object({
1287
+ slot_id: z.string().describe("The slot id from `list_slots` (matches SlotJSON.id)."),
1288
+ layer_id: z.string().describe("The target layer id from `read_scene`."),
1289
+ shape_type: z.enum([
1290
+ "FILL",
1291
+ "STROKE",
1292
+ "GRADIENT_FILL",
1293
+ "GRADIENT_STROKE"
1294
+ ]).describe("For COLOR or GRADIENT slots on shape layers, which paint to replace. Defaults to FILL for COLOR slots, GRADIENT_FILL for GRADIENT slots. Ignored for TEXT/POSITION/VECTOR/SCALAR slots.").optional(),
1295
+ property_context: z.enum([
1296
+ "rotation",
1297
+ "opacity",
1298
+ "skew",
1299
+ "skew_axis",
1300
+ "stroke_width"
1301
+ ]).describe("REQUIRED when the target slot is SCALAR; ignored for other slot types. Picks which scalar property to link: 'rotation' (transform), 'opacity' (transform OR fill - see `opacity_source`), 'skew' / 'skew_axis' (transform), 'stroke_width' (paint).").optional(),
1302
+ opacity_source: z.enum(["layer", "fill"]).describe("For SCALAR + `property_context: 'opacity'` only. 'layer' (default) links the layer transform opacity; 'fill' links the fill paint opacity. Ignored otherwise.").optional()
1303
+ }).strict()
1304
+ },
1305
+ create_slot: {
1306
+ description: "Create a new design-token slot (motion token) by capturing the current value of a layer property. The slot's value comes FROM the layer — you cannot create a slot from an arbitrary RGB/text input. The typical agent flow is: (a) `set_fill` (or set rotation/opacity/scale/etc.) on the layer to the target value, then (b) `create_slot` to capture it as a named token. By default the source layer is linked to the new slot so future updates propagate.\n\n**SCALAR slots** (rotation / opacity / skew / skew_axis / stroke_width) share one slot type, so they REQUIRE `property_context` to pick the source property — without it the capture is ambiguous and the call fails. Opacity additionally takes `opacity_source: 'layer' | 'fill'` (defaults to `'layer'`).\n\nReturns `{ slot_id, type, theme_id, property_context }`. Errors if the slot name is invalid, already exists, or the layer has no property of the requested kind (e.g., COLOR + FILL on a text layer, SCALAR + rotation on a fully-locked transform).\n\n**Slot name rules:** lowercase letters / numbers / underscore / dash, 1–64 chars, must be unique across all themes. If the user gives an unsupported name (spaces, punctuation, uppercase), normalize it (e.g., 'Brand Red' → 'brand-red') and tell them in the reply.",
1307
+ inputSchema: z.object({
1308
+ slot_id: z.string().describe("Unique slot name (becomes the SlotJSON.assetId — also what `list_slots` returns as `id`). Lowercase, digits, underscore, dash; 1–64 chars. e.g. 'brand-red', 'header-text'."),
1309
+ type: z.enum([
1310
+ "COLOR",
1311
+ "TEXT",
1312
+ "GRADIENT",
1313
+ "POSITION",
1314
+ "VECTOR",
1315
+ "SCALAR"
1316
+ ]).describe("Slot type. COLOR = fill/stroke colors. TEXT = reusable strings. GRADIENT = gradient fills/strokes. POSITION = layer position. VECTOR = layer scale. SCALAR = rotation / opacity / skew / skew_axis / stroke_width — see `property_context`."),
1317
+ layer_id: z.string().describe("Source layer whose property value gets captured into the new slot. Must already have a property of the requested kind (e.g. COLOR slot needs a shape layer with a fill or stroke; SCALAR + rotation needs a layer with an unlocked rotation transform)."),
1318
+ shape_type: z.enum([
1319
+ "FILL",
1320
+ "STROKE",
1321
+ "GRADIENT_FILL",
1322
+ "GRADIENT_STROKE"
1323
+ ]).describe("For COLOR / GRADIENT slots on shape layers, which paint to capture. Defaults to FILL (COLOR) or GRADIENT_FILL (GRADIENT). Ignored for TEXT/POSITION/VECTOR/SCALAR.").optional(),
1324
+ property_context: z.enum([
1325
+ "rotation",
1326
+ "opacity",
1327
+ "skew",
1328
+ "skew_axis",
1329
+ "stroke_width"
1330
+ ]).describe("REQUIRED for SCALAR slots; ignored for other types. Picks which scalar property to capture: 'rotation' (transform), 'opacity' (transform OR fill — see `opacity_source`), 'skew' / 'skew_axis' (transform), 'stroke_width' (paint).").optional(),
1331
+ opacity_source: z.enum(["layer", "fill"]).describe("For SCALAR + `property_context: 'opacity'` only. 'layer' (default) captures the layer transform opacity; 'fill' captures the fill paint's opacity. Ignored otherwise.").optional(),
1332
+ link_source: z.boolean().describe("When true (default), the source layer is linked to the new slot — future updates propagate. Set to false only if you want to create the slot without linking the source.").optional()
1333
+ }).strict()
1334
+ },
1335
+ create_slot_from_value: {
1336
+ description: "Create a new COLOR design-token slot DIRECTLY from a value — no source layer required, no scratch-shape dance. Use this when the agent has a `{r, g, b}` in hand (e.g. from `list_motion_system_items`) and wants to register it as a token the user can link future layers to.\n\n**When to use this vs `create_slot`:**\n- `create_slot_from_value`: agent has a value, no source layer. Common after a motion-system lookup: 'tokenize the motion-system primary color' → `list_motion_system_items` → `create_slot_from_value`.\n- `create_slot`: agent wants to capture a layer's existing fill/stroke/etc. value AND optionally link the source. The traditional flow when the user has selected a layer they want to tokenize from.\n\n**Linking is separate.** This tool only registers the slot. To link a layer, call `link_slot_to_layer` afterwards.\n\n**Current limitation: COLOR only.** Other slot types (TEXT, GRADIENT, POSITION, VECTOR, SCALAR) still require the scratch-shape pattern via `create_slot`. The tool throws a clear error if you pass a non-COLOR type.\n\nReturns `{ slot_id, type, theme_id, value }`. Errors on duplicate name or invalid slot id (lowercase / digits / underscore / dash, 1-64 chars).",
1337
+ inputSchema: z.object({
1338
+ slot_id: z.string().describe("Unique slot name. Lowercase, digits, underscore, dash; 1-64 chars. e.g. 'brand-red', 'header-text'. If the user gives an unsupported name (spaces, punctuation, uppercase), normalize and mention it in the reply."),
1339
+ type: z.literal("COLOR").describe("Currently only 'COLOR' is supported. Use `create_slot` (capture-from-layer) for other slot types."),
1340
+ color: z.object({
1341
+ r: z.number().min(0).max(255),
1342
+ g: z.number().min(0).max(255),
1343
+ b: z.number().min(0).max(255),
1344
+ a: z.number().min(0).max(1).optional()
1345
+ }).strict().describe("RGB components 0-255; optional alpha 0-1 (defaults to 1).")
1346
+ }).strict()
1347
+ },
1348
+ update_slot_value: {
1349
+ description: "Change the value of an existing design-token slot in one theme. Every layer linked to the slot updates automatically when that theme is the active one — this is the canonical 'recolor everything that uses brand-red' / 'shift all opacity tokens by 20%' operation.\n\n**Themes are per-value scopes.** A slot has one id but multiple values, one per theme. By default this writes to the currently active theme (the one the canvas is rendering). Pass `theme_id` to write to a non-active variant — useful for setting 'Dark' theme colors without leaving the active theme. The response's `applied_to_active_theme: false` plus `note` field tells you when the canvas won't visibly redraw (you targeted an inactive theme).\n\n**Provide exactly one value param matching the slot's type:**\n- `color: { r, g, b, a? }` for COLOR slots\n- `text: string` for TEXT slots\n- `gradient: { stops: [{ offset, r, g, b, a? }, ...] }` for GRADIENT slots — stops sorted by offset 0..1\n- `position: { x, y }` for POSITION slots\n- `scale: { x, y }` for VECTOR slots (scale; e.g. `{ x: 100, y: 100 }` for 100%)\n- `scalar: number` for SCALAR slots — interpreted by the underlying property: degrees for rotation/skew, percent 0..100 for opacity, pixels for stroke_width\n\nRejects type mismatches (e.g. `color` on a TEXT slot) and unknown theme ids.\n\nReturns `{ slot_id, theme_id, type, value, applied_to_active_theme, note? }`.",
1350
+ inputSchema: z.object({
1351
+ slot_id: z.string().describe("The slot id (from `list_slots`)."),
1352
+ theme_id: z.string().describe("Which theme's value to update. Defaults to the currently active theme (what the canvas renders). Use `list_themes` to see options. Updating a non-active theme is silent on the canvas until the user switches themes.").optional(),
1353
+ color: z.object({
1354
+ r: z.number().min(0).max(255),
1355
+ g: z.number().min(0).max(255),
1356
+ b: z.number().min(0).max(255),
1357
+ a: z.number().min(0).max(1).optional()
1358
+ }).strict().describe("New color for a COLOR slot. Components in 0–255; alpha 0–1.").optional(),
1359
+ text: z.string().describe("New string for a TEXT slot. Replaces the slot text content; font / size / alignment are unchanged.").optional(),
1360
+ gradient: z.object({ stops: z.array(z.object({
1361
+ offset: z.number().min(0).max(1),
1362
+ r: z.number().min(0).max(255),
1363
+ g: z.number().min(0).max(255),
1364
+ b: z.number().min(0).max(255),
1365
+ a: z.number().min(0).max(1).optional()
1366
+ }).strict()).min(2) }).strict().describe("New gradient for a GRADIENT slot. Provide stops sorted by offset (0 = start, 1 = end). Each stop carries its color + alpha; the gateway splits them into the Lottie color/alpha track shape.").optional(),
1367
+ position: z.object({
1368
+ x: z.number(),
1369
+ y: z.number()
1370
+ }).strict().describe("New position for a POSITION slot (scene-space pixels). `{ x: 256, y: 256 }` = scene center on a 512×512 scene.").optional(),
1371
+ scale: z.object({
1372
+ x: z.number(),
1373
+ y: z.number()
1374
+ }).strict().describe("New scale for a VECTOR slot. `{ x: 100, y: 100 }` = 100% (unchanged); `{ x: 150, y: 150 }` = 1.5×. Uniform scale is the same value on both axes.").optional(),
1375
+ scalar: z.number().describe("New number for a SCALAR slot. Interpretation depends on which property this slot represents (use `list_slots` to confirm). Units: degrees (0..360) for rotation / skew / skew_axis; percent (0..100) for opacity; pixels (or whatever the property accepts) for stroke_width.").optional()
1376
+ }).strict()
1377
+ },
1378
+ unlink_slot: {
1379
+ description: "Disconnect a layer's property from its design-token slot. The property keeps its current value (frozen at the moment of unlink) but stops following the slot — future `update_slot_value` calls on that slot no longer affect this layer.\n\nUse when the user says things like 'detach this from the brand color', 'stop using the token here', 'this one should be one-off', 'break the link'. Returns `{ layer_id, slot_type, shape_type, property_context, unlinked }`.\n\n**For SCALAR slots, `property_context` is required** — SCALAR is shared by rotation, opacity, skew, skew_axis, and stroke_width, so the gateway needs to know which property to unlink. For OPACITY specifically, optionally pass `opacity_source: 'layer' | 'fill'` to disambiguate between layer transform opacity and a shape's fill opacity (defaults to `'layer'`).",
1380
+ inputSchema: z.object({
1381
+ layer_id: z.string().describe("The layer whose property should be unlinked from its slot."),
1382
+ slot_type: z.enum([
1383
+ "COLOR",
1384
+ "TEXT",
1385
+ "GRADIENT",
1386
+ "POSITION",
1387
+ "VECTOR",
1388
+ "SCALAR"
1389
+ ]).describe("Which slot family to unlink. COLOR / GRADIENT need `shape_type`. SCALAR needs `property_context`. POSITION / VECTOR / TEXT need neither."),
1390
+ shape_type: z.enum([
1391
+ "FILL",
1392
+ "STROKE",
1393
+ "GRADIENT_FILL",
1394
+ "GRADIENT_STROKE"
1395
+ ]).describe("For COLOR / GRADIENT, which paint to unlink. Defaults to FILL (COLOR) or GRADIENT_FILL (GRADIENT). Ignored for TEXT / POSITION / VECTOR / SCALAR.").optional(),
1396
+ property_context: z.enum([
1397
+ "rotation",
1398
+ "opacity",
1399
+ "skew",
1400
+ "skew_axis",
1401
+ "stroke_width"
1402
+ ]).describe("Required for SCALAR slots. Tells the gateway which specific scalar property to unlink (since all five share SlotType.SCALAR). Look at the slot's label in `list_slots` to pick. Ignored for non-SCALAR slot types.").optional(),
1403
+ opacity_source: z.enum(["layer", "fill"]).describe("For SCALAR + OPACITY only — disambiguates between the layer's transform opacity and a shape's fill opacity. Defaults to 'layer' when omitted. Ignored otherwise.").optional()
1404
+ }).strict()
1405
+ },
1406
+ add_theme: {
1407
+ description: "Create a new theme. The new theme starts with copies of the Default theme's slots (so every slot id continues to render with a value when this theme is active). Use this when the user asks for a 'Dark' / 'Holiday' / etc. variant before customizing values.\n\nAfter creating, the agent typically wants to (a) `select_theme` to make it active, and (b) `update_slot_value({ theme_id, color | text })` per slot to give the new theme its own values.\n\n**Naming rules:** lowercase / digits / underscore / dash, 1–64 chars. Convert unsupported user-facing names before calling this tool (for example, 'Holiday Theme' → `holiday-theme`). If there's a collision with an existing theme id, theme-manager appends a suffix and the response returns the actual id used.\n\nReturns `{ theme_id, was_renamed }`. `was_renamed: true` means theme-manager picked a different id than requested (collision); surface that to the user.",
1408
+ inputSchema: z.object({ id: z.string().min(1).max(64).regex(/^[a-z0-9_-]+$/).describe("Requested theme id. Lowercase / digits / underscore / dash; 1–64 chars. e.g. 'dark', 'holiday', 'high-contrast'.") }).strict()
1409
+ },
1410
+ select_theme: {
1411
+ description: "Switch the currently active theme. The canvas immediately re-renders using the new theme's slot values — every layer linked to a token shifts to the new theme's value for that slot. Use when the user says 'switch to Dark', 'preview the Holiday theme', 'show me what this looks like with the alt theme'. Also useful right after `update_slot_value({ theme_id: 'Dark', ... })` if the user wants to see the change live.\n\nReturns `{ active_theme }`. Throws if the theme id doesn't exist.",
1412
+ inputSchema: z.object({ theme_id: z.string().describe("Theme id to activate. Use `list_themes` to enumerate. 'Default' is always valid.") }).strict()
1413
+ },
1414
+ remove_theme: {
1415
+ description: "Delete a theme and all its per-slot value variants. Slot ids stay; only the per-theme values defined in this theme are removed. The Default theme cannot be removed — that always exists.\n\nUse when the user asks to drop a variant they no longer want ('get rid of the Holiday theme'). If the deleted theme was active, the active theme falls back to Default.\n\nReturns `{ removed: true, theme_id }`. Throws if the theme is 'Default' or doesn't exist.",
1416
+ inputSchema: z.object({ theme_id: z.string().describe("Theme id to delete. Cannot be 'Default'.") }).strict()
1417
+ },
1418
+ list_segments: {
1419
+ description: "List the named animation segments (markers) on the active scene. Each segment is a name + a frame range (start_frame, end_frame). Segments are used by Lottie players to loop / play named ranges and by dotLottie state machines as playback targets.\n\nReturns `{ segments: [{ id, name, start_frame, end_frame, duration_frames }] }`. Empty array means the scene has no segments yet.\n\nCall before `add_segment` if you need to check for duplicate names, before `add_state` on a state machine that targets a segment, or whenever the user asks 'what segments / loops / chapters does this animation have?'.",
1420
+ inputSchema: z.object({}).strict()
1421
+ },
1422
+ add_segment: {
1423
+ description: "Add a named segment (marker) to the active scene's timeline. Segments are reusable named frame ranges — players can loop on one, dotLottie state machines can reference one by name.\n\nReturns `{ segment_id, name, start_frame, end_frame }`. The `segment_id` is the toolkit node id; subsequent rename / remove / select calls take this id.\n\n**Name uniqueness:** segments don't enforce unique names at the toolkit level, but the agent should avoid duplicate names (state machines reference segments by name and a collision is ambiguous). Call `list_segments` first if unsure.",
1424
+ inputSchema: z.object({
1425
+ name: z.string().describe("Human-readable segment name (e.g. 'intro', 'loop', 'outro'). Plain text — any characters allowed."),
1426
+ start_frame: z.number().min(0).describe("Inclusive start frame. Must be >= 0 and <= end_frame."),
1427
+ end_frame: z.number().min(0).describe("Inclusive end frame. Must be >= start_frame. Typically <= the scene end frame, but the toolkit allows beyond.")
1428
+ }).strict()
1429
+ },
1430
+ rename_segment: {
1431
+ description: "Rename an existing segment. Other properties (frames) are unchanged. If state machines reference the old name, they will NOT auto-update — the agent or user must fix those references separately. Returns `{ segment_id, name }`.",
1432
+ inputSchema: z.object({
1433
+ segment_id: z.string().describe("Segment id from `list_segments`."),
1434
+ name: z.string().describe("New segment name.")
1435
+ }).strict()
1436
+ },
1437
+ remove_segment: {
1438
+ description: "Delete a segment from the active scene. Layer timing and keyframes are NOT affected — segments are pure metadata. If state machines reference this segment, they will break at runtime; clean those up separately. Returns `{ removed: true, segment_id }`.",
1439
+ inputSchema: z.object({ segment_id: z.string().describe("Segment id from `list_segments`.") }).strict()
1440
+ },
1441
+ select_segment: {
1442
+ description: "Set the editor's work-area to a segment's frame range so the user can preview / scrub within it. Visible only in the timeline UI; does not change the underlying segment data. Use after `add_segment` if the user wants to immediately preview the new segment, or when they say 'jump to the intro segment'.\n\nReturns `{ segment_id, start_frame, end_frame }`.",
1443
+ inputSchema: z.object({ segment_id: z.string().describe("Segment id from `list_segments`.") }).strict()
1444
+ },
1445
+ list_state_machines: {
1446
+ description: "List the interactivity state machines defined in this file. Each one is `{ id, name, state_count, transition_count, is_active }`. Returns the active state machine id separately as `active_state_machine_id`. State machines are runtime configurations — Lottie players use them to switch playback based on events (click, hover, completion). Most files have 0 or 1 machine.",
1447
+ inputSchema: z.object({}).strict()
1448
+ },
1449
+ add_state_machine: {
1450
+ description: "Create a new interactivity state machine. The new (or reused) machine becomes the active one. After creating, add states with `add_state` and link them with `add_transition`. Designate one state as the entry point with `set_initial_state`.\n\n**Auto-reuse of empty default machines.** Creator files ship with an empty `StateMachine1`. If you call `add_state_machine` and one or more empty machines (zero states, inputs, and interactions) already exist, this tool reuses the first one — renaming it to your requested name — instead of creating a duplicate. The response includes `reused: true` and `previous_name` so you can mention it (e.g. \"I renamed the default empty machine to ...\").\n\nReturns `{ state_machine_id, name, reused?, previous_name? }`.",
1451
+ inputSchema: z.object({ name: z.string().describe("Human-readable name, e.g. 'Hover Reveal', 'Idle / Click'.") }).strict()
1452
+ },
1453
+ set_active_state_machine: {
1454
+ description: "Switch which state machine is active in the editor. Subsequent `add_state` / `add_transition` calls operate on this one. Returns `{ active_state_machine_id }`.",
1455
+ inputSchema: z.object({ state_machine_id: z.string().describe("Id from `list_state_machines`.") }).strict()
1456
+ },
1457
+ remove_state_machine: {
1458
+ description: "Delete an entire state machine and all its states / transitions / inputs / interactions. Returns `{ removed: true, state_machine_id }`.",
1459
+ inputSchema: z.object({ state_machine_id: z.string().describe("Id from `list_state_machines`.") }).strict()
1460
+ },
1461
+ list_states: {
1462
+ description: "List the states in the active state machine. Each is `{ id, name, type, segment, autoplay, loop, mode, is_initial, transitions: [{ id, to_state_id, trigger?, type }] }`. Returns `state_machine_id` and `initial_state_id` at the top level for convenience. Empty array means the active machine has no states yet.",
1463
+ inputSchema: z.object({}).strict()
1464
+ },
1465
+ add_state: {
1466
+ description: "Add a new playback state to the active state machine. States represent a playback config — what segment plays, whether it loops, autoplay, etc. The state starts with no transitions; add them with `add_transition`.\n\n`segment` references a segment NAME from `list_segments` (not id). If the segment doesn't exist, the player will fail at runtime — call `list_segments` first to verify, or accept that the user might add the segment later.\n\nState names must be unique within a machine (case-insensitive — 'Idle' and 'IDLE' collide).\n\nReturns `{ state_id, name, segment }`.",
1467
+ inputSchema: z.object({
1468
+ name: z.string().describe("Human-readable state name, e.g. 'Idle', 'Hover', 'Clicked'."),
1469
+ segment: z.string().describe("Segment NAME (not id) to play in this state. Omit for a state that doesn't play anything (rare).").optional(),
1470
+ autoplay: z.boolean().describe("Start playing as soon as this state is entered. Default true for most states.").optional(),
1471
+ loop: z.boolean().describe("Loop the segment indefinitely. Default false.").optional(),
1472
+ loop_count: z.number().min(1).describe("Number of times to loop. Ignored unless loop is true.").optional(),
1473
+ mode: z.enum([
1474
+ "Forward",
1475
+ "Reverse",
1476
+ "Bounce",
1477
+ "ReverseBounce"
1478
+ ]).describe("Playback direction. Default Forward.").optional(),
1479
+ speed: z.number().describe("Playback speed multiplier (e.g. 1.0, 0.5, 2.0). Default 1.").optional(),
1480
+ must_complete: z.boolean().describe("If true, the segment must finish before any transition can fire. Default false.").optional()
1481
+ }).strict()
1482
+ },
1483
+ add_global_state: {
1484
+ description: "Add a GlobalState to the active state machine. Unlike regular states, a GlobalState's transitions are checked FIRST every tick, regardless of which state is currently active — so they fire from ANY state. Use this when the user says 'from any state when X, go to Y' or 'no matter where we are' or 'always allow Z'. Adding identical transitions to every regular state is the wrong shape — use GlobalState instead.\n\n**Constraints:** A GlobalState cannot have incoming transitions (the gateway rejects `add_transition` calls targeting one). Each machine can have at most ONE GlobalState — the gateway rejects a second. Convention names it 'GLOBAL STATE' (uppercase, with space).\n\nReturns `{ state_id, name, type: 'GlobalState' }`. Errors on missing active machine, duplicate name (case-insensitive), or attempting to add a second GlobalState.",
1485
+ inputSchema: z.object({ name: z.string().describe("Name for the global state. Convention: 'GLOBAL STATE' (uppercase, with space).") }).strict()
1486
+ },
1487
+ remove_state: {
1488
+ description: "Delete a state from the active state machine. Any transitions pointing TO this state are automatically removed. If the deleted state was the initial state, the machine loses its entry point — set a new initial state with `set_initial_state`. Returns `{ removed: true, state_id }`.",
1489
+ inputSchema: z.object({ state_id: z.string().describe("State id from `list_states`.") }).strict()
1490
+ },
1491
+ update_state: {
1492
+ description: "Update fields on an existing state. Only the fields you pass are changed; everything else is preserved. Returns `{ state_id, ...updated_fields }`.",
1493
+ inputSchema: z.object({
1494
+ state_id: z.string().describe("State id from `list_states`."),
1495
+ name: z.string().optional(),
1496
+ segment: z.string().describe("New segment name. Use empty string to clear.").optional(),
1497
+ autoplay: z.boolean().optional(),
1498
+ loop: z.boolean().optional(),
1499
+ loop_count: z.number().min(1).optional(),
1500
+ mode: z.enum([
1501
+ "Forward",
1502
+ "Reverse",
1503
+ "Bounce",
1504
+ "ReverseBounce"
1505
+ ]).optional(),
1506
+ speed: z.number().optional(),
1507
+ must_complete: z.boolean().optional()
1508
+ }).strict()
1509
+ },
1510
+ set_state_actions: {
1511
+ description: "Set entry and/or exit actions on a state. Entry actions fire when the state machine ENTERS the state; exit actions fire when LEAVING. Common uses: 'on enter set isPlaying = true, on leave set false', or 'on enter increment session counter'.\n\nSemantics: passing `entry_actions: [...]` REPLACES the existing entry array. Passing `entry_actions: []` clears it. OMITTING the field leaves the existing actions untouched — pass only the field you want to change.\n\nActions follow the same 13-shape vocabulary as `add_pointer_interaction` (SetNumeric, SetBoolean, Toggle, Fire, SetTheme, etc.).",
1512
+ inputSchema: z.object({
1513
+ state_id: z.string().describe("State id (from `list_states`) to set actions on."),
1514
+ entry_actions: z.array(z.union([
1515
+ z.object({
1516
+ type: z.literal("SetNumeric"),
1517
+ input_name: z.string(),
1518
+ value: z.number()
1519
+ }).strict().describe("Set a Numeric input to a literal number value."),
1520
+ z.object({
1521
+ type: z.literal("Increment"),
1522
+ input_name: z.string(),
1523
+ value: z.number().optional()
1524
+ }).strict().describe("Increment a Numeric input by `value` (defaults to 1)."),
1525
+ z.object({
1526
+ type: z.literal("Decrement"),
1527
+ input_name: z.string(),
1528
+ value: z.number().optional()
1529
+ }).strict().describe("Decrement a Numeric input by `value` (defaults to 1)."),
1530
+ z.object({
1531
+ type: z.literal("SetString"),
1532
+ input_name: z.string(),
1533
+ value: z.string()
1534
+ }).strict().describe("Set a String input to a literal string value."),
1535
+ z.object({
1536
+ type: z.literal("SetBoolean"),
1537
+ input_name: z.string(),
1538
+ value: z.boolean()
1539
+ }).strict().describe("Set a Boolean input to true or false."),
1540
+ z.object({
1541
+ type: z.literal("Toggle"),
1542
+ input_name: z.string()
1543
+ }).strict().describe("Flip a Boolean input (true ↔ false)."),
1544
+ z.object({
1545
+ type: z.literal("Fire"),
1546
+ input_name: z.string()
1547
+ }).strict().describe("Fire an Event input by name (the input must be of type 'Event')."),
1548
+ z.object({
1549
+ type: z.literal("Reset"),
1550
+ input_name: z.string()
1551
+ }).strict().describe("Reset an input to its initial value (works on any input type)."),
1552
+ z.object({
1553
+ type: z.literal("SetFrame"),
1554
+ value: z.string()
1555
+ }).strict().describe("Jump the player to a specific frame. `value` is a string — a numeric literal like '60' or a $inputName reference to a Numeric input."),
1556
+ z.object({
1557
+ type: z.literal("SetProgress"),
1558
+ value: z.string()
1559
+ }).strict().describe("Jump to a normalized progress (0–1). `value` is a string — a numeric literal like '0.5' or a $inputName reference."),
1560
+ z.object({
1561
+ type: z.literal("SetTheme"),
1562
+ value: z.string()
1563
+ }).strict().describe("Switch the active theme by name (must match a theme from `list_themes`)."),
1564
+ z.object({
1565
+ type: z.literal("OpenUrl"),
1566
+ url: z.string(),
1567
+ target: z.enum([
1568
+ "_blank",
1569
+ "_self",
1570
+ "_parent",
1571
+ "_top"
1572
+ ]).optional()
1573
+ }).strict().describe("Open a URL. `target` follows HTML anchor semantics ('_blank' opens a new tab)."),
1574
+ z.object({
1575
+ type: z.literal("FireCustomEvent"),
1576
+ value: z.string()
1577
+ }).strict().describe("Emit a custom DOM event from the player host (advanced; consumed by surrounding code).")
1578
+ ])).describe("Actions that fire when this state is entered. Pass `[]` to clear the existing array; OMIT to leave it untouched. Same 13-variant vocabulary as `add_pointer_interaction.actions[]`.").optional(),
1579
+ exit_actions: z.array(z.union([
1580
+ z.object({
1581
+ type: z.literal("SetNumeric"),
1582
+ input_name: z.string(),
1583
+ value: z.number()
1584
+ }).strict().describe("Set a Numeric input to a literal number value."),
1585
+ z.object({
1586
+ type: z.literal("Increment"),
1587
+ input_name: z.string(),
1588
+ value: z.number().optional()
1589
+ }).strict().describe("Increment a Numeric input by `value` (defaults to 1)."),
1590
+ z.object({
1591
+ type: z.literal("Decrement"),
1592
+ input_name: z.string(),
1593
+ value: z.number().optional()
1594
+ }).strict().describe("Decrement a Numeric input by `value` (defaults to 1)."),
1595
+ z.object({
1596
+ type: z.literal("SetString"),
1597
+ input_name: z.string(),
1598
+ value: z.string()
1599
+ }).strict().describe("Set a String input to a literal string value."),
1600
+ z.object({
1601
+ type: z.literal("SetBoolean"),
1602
+ input_name: z.string(),
1603
+ value: z.boolean()
1604
+ }).strict().describe("Set a Boolean input to true or false."),
1605
+ z.object({
1606
+ type: z.literal("Toggle"),
1607
+ input_name: z.string()
1608
+ }).strict().describe("Flip a Boolean input (true ↔ false)."),
1609
+ z.object({
1610
+ type: z.literal("Fire"),
1611
+ input_name: z.string()
1612
+ }).strict().describe("Fire an Event input by name (the input must be of type 'Event')."),
1613
+ z.object({
1614
+ type: z.literal("Reset"),
1615
+ input_name: z.string()
1616
+ }).strict().describe("Reset an input to its initial value (works on any input type)."),
1617
+ z.object({
1618
+ type: z.literal("SetFrame"),
1619
+ value: z.string()
1620
+ }).strict().describe("Jump the player to a specific frame. `value` is a string — a numeric literal like '60' or a $inputName reference to a Numeric input."),
1621
+ z.object({
1622
+ type: z.literal("SetProgress"),
1623
+ value: z.string()
1624
+ }).strict().describe("Jump to a normalized progress (0–1). `value` is a string — a numeric literal like '0.5' or a $inputName reference."),
1625
+ z.object({
1626
+ type: z.literal("SetTheme"),
1627
+ value: z.string()
1628
+ }).strict().describe("Switch the active theme by name (must match a theme from `list_themes`)."),
1629
+ z.object({
1630
+ type: z.literal("OpenUrl"),
1631
+ url: z.string(),
1632
+ target: z.enum([
1633
+ "_blank",
1634
+ "_self",
1635
+ "_parent",
1636
+ "_top"
1637
+ ]).optional()
1638
+ }).strict().describe("Open a URL. `target` follows HTML anchor semantics ('_blank' opens a new tab)."),
1639
+ z.object({
1640
+ type: z.literal("FireCustomEvent"),
1641
+ value: z.string()
1642
+ }).strict().describe("Emit a custom DOM event from the player host (advanced; consumed by surrounding code).")
1643
+ ])).describe("Actions that fire when this state is exited. Pass `[]` to clear the existing array; OMIT to leave it untouched. Same 13-variant vocabulary as `entry_actions`.").optional()
1644
+ }).strict()
1645
+ },
1646
+ set_initial_state: {
1647
+ description: "Designate which state the machine enters first at runtime. Every state machine needs exactly one initial state. Returns `{ initial_state_id }`.",
1648
+ inputSchema: z.object({ state_id: z.string().describe("State id from `list_states`.") }).strict()
1649
+ },
1650
+ add_transition: {
1651
+ description: "Add a transition from one state to another in the active state machine. Transitions can fire immediately when the from-state's segment completes (no trigger), or in response to an event like 'click', 'pointerEnter', 'pointerExit' (pass `trigger_event`).\n\n**If you pass `trigger_event`, you MUST also call `add_pointer_interaction` with a matching `fire_event` — or the transition will never fire at runtime.** `trigger_event` is just a NAME the transition listens for; nothing emits that name until a pointer interaction is wired to fire it. A transition with `trigger_event: 'click'` and no matching `add_pointer_interaction({ pointer_event: 'Click', fire_event: 'click', layer_id })` looks correct in the panel but is dead at playback time. This is the #1 way to ship a broken state machine — always add BOTH calls when using `trigger_event`. The only exception: if a previous interaction in this state machine already fires the same event name, you can reuse it.\n\nDefault `type: 'Transition'` is immediate. Pass `type: 'Tweened'` with `duration_ms` (number) + `easing` (cubic-bezier 4-tuple) for smooth interpolation between states. Tweened cannot target a GlobalState.\n\nReturns `{ transition_id, from_state_id, to_state_id, trigger_event, type }`. Errors if either state id is missing.",
1652
+ inputSchema: z.object({
1653
+ from_state_id: z.string().describe("State the transition leaves."),
1654
+ to_state_id: z.string().describe("State the transition arrives at."),
1655
+ trigger_event: z.string().describe("Event NAME this transition listens for (a string, not a literal event type). Common conventions: 'click', 'pointerEnter', 'pointerExit'. **If you set this, you MUST also call `add_pointer_interaction` with the matching `fire_event` value, or the transition never fires.** The string here is arbitrary — what makes it work is a pointer interaction emitting the same name. Omit `trigger_event` entirely for a transition that fires automatically when the from-state's segment completes.").optional(),
1656
+ type: z.enum(["Transition", "Tweened"]).describe("Default 'Transition' (immediate). 'Tweened' interpolates over duration_ms with the given easing.").optional(),
1657
+ duration_ms: z.number().min(1).describe("Required when type is 'Tweened'. Tween duration in milliseconds.").optional(),
1658
+ easing: z.array(z.number()).min(4).max(4).describe("Required when type is 'Tweened'. Cubic-bezier control points [x1, y1, x2, y2]. Example: [0.4, 0, 0.2, 1] for material-style ease-in-out.").optional(),
1659
+ guards: z.array(z.object({
1660
+ input_name: z.string().describe("Name of an input on this state machine (matches `list_inputs[].name`)."),
1661
+ type: z.enum([
1662
+ "Boolean",
1663
+ "Numeric",
1664
+ "String"
1665
+ ]).describe("Input type — must match the input."),
1666
+ condition: z.enum([
1667
+ "Equal",
1668
+ "NotEqual",
1669
+ "GreaterThan",
1670
+ "GreaterThanOrEqual",
1671
+ "LessThan",
1672
+ "LessThanOrEqual"
1673
+ ]).describe("Comparison operator. For Boolean use Equal / NotEqual; for Numeric all six work; for String use Equal / NotEqual."),
1674
+ compare_to: z.string().describe("Value to compare against, as a string. For Boolean 'true' or 'false'; for Numeric a numeric string; for String the literal value.")
1675
+ }).strict()).describe("Input-comparison guards. Each guard tests one input value at runtime; ALL guards must pass for the transition to fire. Combine with `trigger_event` to require both an event AND a condition (e.g. transition fires only when 'click' arrives AND isHovered === true). Add the referenced inputs first via `add_input`.").optional()
1676
+ }).strict()
1677
+ },
1678
+ remove_transition: {
1679
+ description: "Remove a transition from a state. Returns `{ removed: true, transition_id }`.",
1680
+ inputSchema: z.object({
1681
+ state_id: z.string().describe("State id that owns the transition."),
1682
+ transition_id: z.string().describe("Transition id from `list_states`.")
1683
+ }).strict()
1684
+ },
1685
+ list_interactions: {
1686
+ description: "List the interactions defined on the active state machine. Interactions BIND runtime pointer / state events to actions — usually a `Fire` action that emits a named event a transition listens for.\n\n**Without an interaction, transition `trigger_event` guards never fire.** If you added an `add_transition({ trigger_event: 'click' })`, you must also `add_pointer_interaction({ pointer_event: 'Click', fire_event: 'click' })` for the click to do anything at runtime.\n\nReturns `{ interactions: [{ id, type, layer_name, state_name, actions, fires_event }] }`. `type` is the pointer event name (Click / PointerEnter / …) for pointer interactions OR `OnComplete` / `OnLoopComplete` for state-event interactions. `layer_name` is set for pointer interactions; `state_name` is set for state-event interactions. `actions[]` is the full action list in the agent-facing shape (snake_case + natural value types). `fires_event` is a legacy convenience: it's the input_name of the first `Fire` action when present, otherwise `null` — inspect `actions[]` for everything else.",
1687
+ inputSchema: z.object({}).strict()
1688
+ },
1689
+ add_pointer_interaction: {
1690
+ description: "Bind a pointer event (Click, PointerEnter, etc.) on a layer to one or more state-machine actions. This is the wiring that makes `trigger_event` and input mutations actually fire at runtime.\n\nTwo call shapes — pass EITHER `fire_event` (shorthand) OR `actions[]` (full vocabulary), never both:\n\n1. `fire_event: 'click'` — shorthand for a single `Fire` action against the named event input. Pairs with `add_transition({ trigger_event: 'click' })`. Match exactly, case-sensitive.\n\n2. `actions: [...]` — full action vocabulary. Each action targets an input by name or carries its own value. The 13 action types are: `SetNumeric`, `Increment`, `Decrement` (Numeric inputs); `SetString` (String); `SetBoolean`, `Toggle` (Boolean); `Fire`, `Reset` (Event / any input); `SetFrame`, `SetProgress`, `SetTheme`, `FireCustomEvent` (no input ref); `OpenUrl` (carries `url` + optional `target`). Multiple actions in one interaction fire in order.\n\nAction type must match input type (Increment requires Numeric, Toggle requires Boolean, etc.) — the gateway validates and errors clearly on mismatch.\n\n**Event input auto-create.** A `Fire` action (whether via `fire_event` shorthand or `actions: [{ type: 'Fire', input_name: 'X' }]`) needs an Event input named `X` to receive the fire — otherwise the fire vanishes and transitions listening for `X` never trigger. To keep this one-call, the gateway AUTO-CREATES any missing Event inputs referenced by Fire actions. The response includes an `auto_created_inputs` array when this happens, so you can surface it to the user (e.g. \"Created event input `click` and wired the click handler.\").\n\nReturns `{ interaction_id, pointer_event, fire_event?, actions?, layer_id, layer_name, auto_created_inputs? }`.",
1691
+ inputSchema: z.object({
1692
+ pointer_event: z.enum([
1693
+ "Click",
1694
+ "PointerEnter",
1695
+ "PointerExit",
1696
+ "PointerDown",
1697
+ "PointerUp",
1698
+ "PointerMove"
1699
+ ]).describe("Which pointer interaction fires this event. Click + PointerEnter / PointerExit are the most common."),
1700
+ fire_event: z.string().describe("SHORTHAND for a single Fire action. Event name to emit; must match the `trigger_event` you used on a transition (case-sensitive). Common values: 'click', 'hover', 'leave'. Mutually exclusive with `actions[]` — pass one or the other, not both.").optional(),
1701
+ actions: z.array(z.union([
1702
+ z.object({
1703
+ type: z.literal("SetNumeric"),
1704
+ input_name: z.string(),
1705
+ value: z.number()
1706
+ }).strict().describe("Set a Numeric input to a literal number value."),
1707
+ z.object({
1708
+ type: z.literal("Increment"),
1709
+ input_name: z.string(),
1710
+ value: z.number().optional()
1711
+ }).strict().describe("Increment a Numeric input by `value` (defaults to 1)."),
1712
+ z.object({
1713
+ type: z.literal("Decrement"),
1714
+ input_name: z.string(),
1715
+ value: z.number().optional()
1716
+ }).strict().describe("Decrement a Numeric input by `value` (defaults to 1)."),
1717
+ z.object({
1718
+ type: z.literal("SetString"),
1719
+ input_name: z.string(),
1720
+ value: z.string()
1721
+ }).strict().describe("Set a String input to a literal string value."),
1722
+ z.object({
1723
+ type: z.literal("SetBoolean"),
1724
+ input_name: z.string(),
1725
+ value: z.boolean()
1726
+ }).strict().describe("Set a Boolean input to true or false."),
1727
+ z.object({
1728
+ type: z.literal("Toggle"),
1729
+ input_name: z.string()
1730
+ }).strict().describe("Flip a Boolean input (true ↔ false)."),
1731
+ z.object({
1732
+ type: z.literal("Fire"),
1733
+ input_name: z.string()
1734
+ }).strict().describe("Fire an Event input by name (the input must be of type 'Event'). The shorthand `fire_event` field on this tool is sugar for a single Fire action."),
1735
+ z.object({
1736
+ type: z.literal("Reset"),
1737
+ input_name: z.string()
1738
+ }).strict().describe("Reset an input to its initial value (works on any input type)."),
1739
+ z.object({
1740
+ type: z.literal("SetFrame"),
1741
+ value: z.string()
1742
+ }).strict().describe("Jump the player to a specific frame. `value` is a string — a numeric literal like '60' or a $inputName reference to a Numeric input (e.g. '$progress')."),
1743
+ z.object({
1744
+ type: z.literal("SetProgress"),
1745
+ value: z.string()
1746
+ }).strict().describe("Jump to a normalized progress (0–1). `value` is a string — a numeric literal like '0.5' or a $inputName reference."),
1747
+ z.object({
1748
+ type: z.literal("SetTheme"),
1749
+ value: z.string()
1750
+ }).strict().describe("Switch the active theme by name (must match a theme from `list_themes`)."),
1751
+ z.object({
1752
+ type: z.literal("OpenUrl"),
1753
+ url: z.string(),
1754
+ target: z.enum([
1755
+ "_blank",
1756
+ "_self",
1757
+ "_parent",
1758
+ "_top"
1759
+ ]).optional()
1760
+ }).strict().describe("Open a URL. `target` follows HTML anchor semantics ('_blank' opens a new tab)."),
1761
+ z.object({
1762
+ type: z.literal("FireCustomEvent"),
1763
+ value: z.string()
1764
+ }).strict().describe("Emit a custom DOM event from the player host (advanced; consumed by surrounding code).")
1765
+ ])).min(1).describe("Full action vocabulary. Mutually exclusive with `fire_event`. Each entry is one of the 13 action variants below; multiple actions fire in order. Must contain at least one action.").optional(),
1766
+ layer_id: z.string().describe("Layer id to bind the pointer event to (from `read_scene`). REQUIRED — scene-level pointer interactions don't fire in the player. Pick the most semantically meaningful clickable layer (a button, a card, the whole hero element).")
1767
+ }).strict()
1768
+ },
1769
+ add_state_interaction: {
1770
+ description: "Add a state-event interaction that fires actions when a state's playback reaches a milestone. Use `OnComplete` for 'when this state's segment finishes' or `OnLoopComplete` for 'each time this state's segment loops'.\n\nNot to be confused with `add_transition`: a transition WITHOUT `trigger_event` also fires on segment completion, but it transitions to ANOTHER state. Use `add_state_interaction` when you want to fire actions (Fire, Increment, SetTheme, OpenUrl, etc.) on completion WITHOUT changing state.\n\nThe action vocabulary is identical to `add_pointer_interaction.actions[]` — 13 variants covering input mutations (SetNumeric, Increment, Toggle, …), playback (SetFrame, SetProgress), theme (SetTheme), and side effects (OpenUrl, FireCustomEvent). Action type must match input type or the gateway errors clearly.\n\n**Event input auto-create.** Like `add_pointer_interaction`, this tool auto-creates any Event input referenced by a `Fire` action that doesn't yet exist on the active machine. The response includes `auto_created_inputs` when this happens.\n\nReturns `{ interaction_id, trigger, state_id, state_name, actions, auto_created_inputs? }`.",
1771
+ inputSchema: z.object({
1772
+ trigger: z.enum(["OnComplete", "OnLoopComplete"]).describe("Which playback milestone fires this interaction. `OnComplete` fires once when the state's segment ends. `OnLoopComplete` fires every time the segment loops back."),
1773
+ state_id: z.string().describe("State id (from `list_states`) whose milestone fires the interaction."),
1774
+ actions: z.array(z.union([
1775
+ z.object({
1776
+ type: z.literal("SetNumeric"),
1777
+ input_name: z.string(),
1778
+ value: z.number()
1779
+ }).strict().describe("Set a Numeric input to a literal number value."),
1780
+ z.object({
1781
+ type: z.literal("Increment"),
1782
+ input_name: z.string(),
1783
+ value: z.number().optional()
1784
+ }).strict().describe("Increment a Numeric input by `value` (defaults to 1)."),
1785
+ z.object({
1786
+ type: z.literal("Decrement"),
1787
+ input_name: z.string(),
1788
+ value: z.number().optional()
1789
+ }).strict().describe("Decrement a Numeric input by `value` (defaults to 1)."),
1790
+ z.object({
1791
+ type: z.literal("SetString"),
1792
+ input_name: z.string(),
1793
+ value: z.string()
1794
+ }).strict().describe("Set a String input to a literal string value."),
1795
+ z.object({
1796
+ type: z.literal("SetBoolean"),
1797
+ input_name: z.string(),
1798
+ value: z.boolean()
1799
+ }).strict().describe("Set a Boolean input to true or false."),
1800
+ z.object({
1801
+ type: z.literal("Toggle"),
1802
+ input_name: z.string()
1803
+ }).strict().describe("Flip a Boolean input (true ↔ false)."),
1804
+ z.object({
1805
+ type: z.literal("Fire"),
1806
+ input_name: z.string()
1807
+ }).strict().describe("Fire an Event input by name (the input must be of type 'Event')."),
1808
+ z.object({
1809
+ type: z.literal("Reset"),
1810
+ input_name: z.string()
1811
+ }).strict().describe("Reset an input to its initial value (works on any input type)."),
1812
+ z.object({
1813
+ type: z.literal("SetFrame"),
1814
+ value: z.string()
1815
+ }).strict().describe("Jump the player to a specific frame. `value` is a string — a numeric literal like '60' or a $inputName reference to a Numeric input (e.g. '$progress')."),
1816
+ z.object({
1817
+ type: z.literal("SetProgress"),
1818
+ value: z.string()
1819
+ }).strict().describe("Jump to a normalized progress (0–1). `value` is a string — a numeric literal like '0.5' or a $inputName reference."),
1820
+ z.object({
1821
+ type: z.literal("SetTheme"),
1822
+ value: z.string()
1823
+ }).strict().describe("Switch the active theme by name (must match a theme from `list_themes`)."),
1824
+ z.object({
1825
+ type: z.literal("OpenUrl"),
1826
+ url: z.string(),
1827
+ target: z.enum([
1828
+ "_blank",
1829
+ "_self",
1830
+ "_parent",
1831
+ "_top"
1832
+ ]).optional()
1833
+ }).strict().describe("Open a URL. `target` follows HTML anchor semantics ('_blank' opens a new tab)."),
1834
+ z.object({
1835
+ type: z.literal("FireCustomEvent"),
1836
+ value: z.string()
1837
+ }).strict().describe("Emit a custom DOM event from the player host (advanced; consumed by surrounding code).")
1838
+ ])).min(1).describe("Actions to fire when the trigger hits. Same 13-variant vocabulary as `add_pointer_interaction.actions[]`. Multiple actions fire in order. Must contain at least one action.")
1839
+ }).strict()
1840
+ },
1841
+ remove_interaction: {
1842
+ description: "Remove an interaction from the active state machine. Returns `{ removed: true, interaction_id }`.",
1843
+ inputSchema: z.object({ interaction_id: z.string().describe("Interaction id from `list_interactions`.") }).strict()
1844
+ },
1845
+ list_inputs: {
1846
+ description: "List the typed inputs (variables) on the active state machine. Inputs are how state machines store runtime values — booleans for toggle state, numerics for progress / counters, strings for labels, and events for one-shot triggers. Inputs feed guards on transitions and actions in interactions.\n\nReturns `{ inputs: [{ id, name, type, value? }] }`. The `value` is the initial value at machine start (events don't have one).",
1847
+ inputSchema: z.object({}).strict()
1848
+ },
1849
+ add_input: {
1850
+ description: "Add a typed input variable to the active state machine. Inputs power guard transitions (e.g. transition fires when `isHovered === true`) and action targets (e.g. an action that sets `progress` to 50).\n\nTypes:\n- `Boolean` — initial_value must be 'true' or 'false' (string).\n- `Numeric` — initial_value must be a stringified number.\n- `String` — initial_value is a plain string.\n- `Event` — pure one-shot trigger; do not pass initial_value.\n\nReturns `{ input_id, name, type }`.",
1851
+ inputSchema: z.object({
1852
+ name: z.string().describe("Variable name — referenced by guards and actions. e.g. 'isHovered', 'progress', 'currentTab'."),
1853
+ type: z.enum([
1854
+ "Boolean",
1855
+ "Numeric",
1856
+ "String",
1857
+ "Event"
1858
+ ]).describe("Input type. Boolean for toggles, Numeric for counters/progress, String for labels, Event for one-shot triggers."),
1859
+ initial_value: z.string().describe("Initial value as a string. For Boolean pass 'true' or 'false'; for Numeric pass a numeric string like '0'; for String any text; OMIT for Event.").optional()
1860
+ }).strict()
1861
+ },
1862
+ update_input: {
1863
+ description: "Update an existing input's name or value. Renaming an input also updates all references to it across guards and actions automatically (theme-manager handles the cascade). Returns `{ input_id, name, value }`.",
1864
+ inputSchema: z.object({
1865
+ input_id: z.string().describe("Input id from `list_inputs`."),
1866
+ name: z.string().optional(),
1867
+ value: z.string().describe("New value as a string (matches the input type).").optional()
1868
+ }).strict()
1869
+ },
1870
+ remove_input: {
1871
+ description: "Delete an input. All guards / actions that reference it are also cleaned up automatically. Returns `{ removed: true, input_id }`.",
1872
+ inputSchema: z.object({ input_id: z.string().describe("Input id from `list_inputs`.") }).strict()
1873
+ },
1874
+ import_asset: {
1875
+ description: "**BEFORE CALLING: check the creation order.** Imported assets are inserted at the TOP of the render stack. If this turn creates multiple layers, emit the calls in BOTTOM→TOP order — the backdrop / sky / base image FIRST, foreground layers LAST. (Reorder APIs exist if needed via `reorder_layer`, but planning the order is cheaper.)\n\nImport an SVG, Lottie, or image into the active scene as a new layer. This is the preferred tool whenever the user pastes SVG markup, provides a URL for an image/Lottie, or asks to add an existing asset. Returns the new layer's id so you can immediately animate it with `apply_keyframes`.\n\nProvide either `content` (for inline SVG markup) or `url` (for Lottie JSON / image URLs). Optional `name`, `position`, and `scale` are applied after import.",
1876
+ inputSchema: z.object({
1877
+ asset_type: z.enum([
1878
+ "SVG",
1879
+ "LOTTIE",
1880
+ "IMAGE"
1881
+ ]).describe("SVG: use inline `content`. LOTTIE/IMAGE: use `url`. Inline SVG content is the common case when the user pastes markup."),
1882
+ content: z.string().describe("Raw SVG markup (for asset_type='SVG'). Mutually exclusive with `url`.").optional(),
1883
+ url: z.string().describe("URL to fetch (for asset_type='LOTTIE' or 'IMAGE'). Mutually exclusive with `content`.").optional(),
1884
+ name: z.string().describe("Optional override for the imported layer's name.").optional(),
1885
+ position: z.object({
1886
+ x: z.number(),
1887
+ y: z.number()
1888
+ }).strict().describe("Optional {x, y} to position the imported layer.").optional(),
1889
+ scale: z.object({
1890
+ x: z.number(),
1891
+ y: z.number()
1892
+ }).strict().describe("Optional {x, y} scale percentage (100 = 100%).").optional()
1893
+ }).strict()
1894
+ },
1895
+ vectorize_image: {
1896
+ description: "Convert an existing PNG/JPG image layer into editable vector shape layers using Creator native vectorizer. Use this when the user asks to vectorize, trace, convert an image/logo/photo to vectors, or make an imported raster image editable. Defaults to the selected image layer when `layer_id` is omitted. The generated vector layers are inserted above the source image and selected.",
1897
+ inputSchema: z.object({ layer_id: z.string().describe("Image layer id from `read_scene`. Optional when an image layer is selected.").optional() }).strict()
1898
+ },
1899
+ remove_keyframes: {
1900
+ description: "Remove keyframes from a single property on a single layer. Omit `frame_range` to clear all keyframes on that property; pass `frame_range: { from, to }` to remove only keyframes whose frame falls in the inclusive range. Use this before calling `apply_keyframes` with the same property when you want to fully replace an existing animation rather than merge with it — or just pass `clear_first: true` on `apply_keyframes` for the common case.",
1901
+ inputSchema: z.object({
1902
+ layer_id: z.string(),
1903
+ property: animatablePropertySchema,
1904
+ frame_range: z.object({
1905
+ from: z.number(),
1906
+ to: z.number()
1907
+ }).strict().optional()
1908
+ }).strict()
1909
+ },
1910
+ duplicate_layer: {
1911
+ description: "Duplicate an existing layer N times and return the new layer IDs. Optional `stagger_frames` offsets each duplicate's timelineOffset by that many frames (i × stagger) — use for stagger/wave effects. Optional `offset` shifts each duplicate's position by (i × offset.x, i × offset.y) — use for arrays and rows.\n\nCommon flows:\n- 'make a row of 5 dots spaced 40px apart': duplicate_layer with count=4 (+ the source = 5 total) and offset={x:40,y:0}, then a single apply_keyframes call on the source animates the base motion.\n- 'stagger 5 letters': duplicate_layer with count=4 and stagger_frames=3; each clone gets timelineOffset = source + i*3.\n- **2-D grid (rows × cols)**: call duplicate_layer once per row — first call with count=cols-1 and offset={x:tile,y:0} (top row), then for each additional row, call with count=cols-1 on ONE of the top-row tiles and offset={x:0,y:tile}. Or simpler: N serialized calls, one per row, each duplicating a source tile with x-offsets. Do NOT attempt a 2-D grid with a single duplicate_layer call — `offset` is 1-D.\n\nReturns an array of `{ layer_id, name }` for the new layers, in creation order. Capped at 24 copies per call.",
1912
+ inputSchema: z.object({
1913
+ layer_id: z.string().describe("Source layer id from `read_scene`."),
1914
+ count: z.number().min(1).max(24).describe("How many duplicates to create. Default 1. Capped at 24 to keep the timeline sane.").optional(),
1915
+ stagger_frames: z.number().describe("Per-copy timelineOffset step, in frames. Copy i (1-indexed) gets source.timelineOffset + i*stagger_frames.").optional(),
1916
+ offset: z.object({
1917
+ x: z.number(),
1918
+ y: z.number()
1919
+ }).strict().describe("Per-copy position shift. Copy i gets source.position + i*offset. Ignored if the source layer has animated position (copy keyframes untouched).").optional(),
1920
+ name_prefix: z.string().describe("Optional base name. Copies are suffixed ' 1', ' 2', etc. Defaults to the source layer's name.").optional()
1921
+ }).strict()
1922
+ },
1923
+ tile_layer: {
1924
+ description: "Fill the active scene with a grid of copies of a layer. This is the tool to reach for whenever the user says 'tile across the scene', 'fill with a grid of', 'cover with', 'scatter N', or 'pattern the background with'. It ALWAYS uses `Math.floor` for auto cols/rows so tiles never spill outside the scene bounds.\n\nBehavior:\n- `tile_width` / `tile_height` default to the source layer's visual bounds (read via getBounds). For a polygon with outerRadius 50, that's ~100×100.\n- `cols` / `rows` default to `floor((scene_dim − 2*margin + gap) / (tile_dim + gap))` so the grid exactly fits. Pass either explicitly to override.\n- Grid is centered inside the `margin`-inset scene rect.\n- Optional `gap` between tiles (default 0).\n- Optional `margin` (default 0) to inset the grid from scene edges.\n- Optional `alternate_flip: true` rotates odd rows by 180° — ideal for triangle tessellation.\n- Reuses the source layer as cell (0,0); only cols×rows−1 new layers are created.\n- Hard cap of 144 tiles (12×12) to keep Creator responsive. Throws if exceeded.\n\nExample: 'tile this triangle to fill the scene' → one call, no params beyond layer_id. For a 512×512 scene with a 100px triangle: fills as 5×5 with the grid centered.",
1925
+ inputSchema: z.object({
1926
+ layer_id: z.string().describe("Source layer to duplicate into the grid."),
1927
+ cols: z.number().min(1).max(20).describe("Explicit column count. Omit to auto-fit from tile_width + margin + gap.").optional(),
1928
+ rows: z.number().min(1).max(20).describe("Explicit row count. Omit to auto-fit.").optional(),
1929
+ tile_width: z.number().describe("Override the per-tile width step. Defaults to the source layer's visual bounds width (via getBounds).").optional(),
1930
+ tile_height: z.number().describe("Override the per-tile height step. Defaults to source bounds height.").optional(),
1931
+ gap: z.object({
1932
+ x: z.number(),
1933
+ y: z.number()
1934
+ }).strict().describe("Spacing between tiles in pixels. Default { x: 0, y: 0 } (tiles touch).").optional(),
1935
+ margin: z.object({
1936
+ x: z.number(),
1937
+ y: z.number()
1938
+ }).strict().describe("Inset from scene edges in pixels. Default { x: 0, y: 0 }.").optional(),
1939
+ alternate_flip: z.boolean().describe("When true, rotate odd-indexed rows by 180°. Use for triangle tessellation and pointing alternating directions.").optional()
1940
+ }).strict()
1941
+ },
1942
+ reorder_layer: {
1943
+ description: "Restack a layer relative to another in the active scene's render order. Use when creation order was wrong or the user asks to put one layer above/below/in-front-of/behind another.\n\nZ-ORDER (index 0 = front/top, per read_scene `ordering`):\n- To put the layer ON TOP of the sibling (visible in front — e.g. a label over a button fill, text over a circle): `position: 'in_front_of'`.\n- To put the layer UNDER the sibling (behind — e.g. a shadow behind a card, a backdrop behind content): `position: 'behind'`.\n\nUSE `in_front_of` / `behind` — they say exactly what you mean. The legacy `before` / `after` refer to render-ARRAY position and invert intuitively (`before` the sibling = drawn after it = renders ON TOP); only use them if you're certain. \"bring to front\" = `in_front_of` the current front layer; \"send to back\" = `behind` the current back layer.\n\nPass `layer_id` (the layer to move), `sibling_layer_id` (stays put), `position`. The canvas re-renders after the move — verify with `inspect_scene` and check the target isn't `occlusion.likely_hidden`.",
1944
+ inputSchema: z.object({
1945
+ layer_id: z.string().describe("Layer to move."),
1946
+ sibling_layer_id: z.string().describe("Reference layer that stays in place; the target moves relative to it."),
1947
+ position: z.enum([
1948
+ "in_front_of",
1949
+ "behind",
1950
+ "before",
1951
+ "after"
1952
+ ]).describe("Where to place the target relative to the sibling. `in_front_of` = on top (visible over it); `behind` = under it. (`before` = same as in_front_of, `after` = same as behind — legacy, easy to invert.)")
1953
+ }).strict()
1954
+ },
1955
+ group_layers: {
1956
+ description: "Group multiple active-scene root layers by converting them into a nestable precomposition layer. Creator's native shape-group command only groups drawable shapes inside a shape layer, so root-level layer grouping is represented as a precomp. Use this when the user asks to group several layers, package a component, or create a reusable/nestable precomp from existing layers. Pass layer IDs from `read_scene` in the intended visual set.",
1957
+ inputSchema: z.object({
1958
+ layer_ids: z.array(z.string()).min(1).describe("Root layer ids to precompose/group."),
1959
+ name: z.string().describe("Optional name for the resulting precomposition layer.").optional()
1960
+ }).strict()
1961
+ },
1962
+ create_shape_group: {
1963
+ description: "Create an empty group inside an existing SHAPE_LAYER or group. Use this to build composed single-layer components like cards, icons, badges, and shadows without creating many top-level layers.",
1964
+ inputSchema: z.object({
1965
+ parent_id: z.string(),
1966
+ name: z.string().optional()
1967
+ }).strict()
1968
+ },
1969
+ add_shape_to_layer: {
1970
+ description: "Add a rectangle, ellipse, polygon, star, or path to an existing SHAPE_LAYER or group. Use with create_shape_group to build one composed unit with internal groups.",
1971
+ inputSchema: z.object({
1972
+ parent_id: z.string(),
1973
+ shape: z.enum([
1974
+ "rectangle",
1975
+ "ellipse",
1976
+ "polygon",
1977
+ "star",
1978
+ "path"
1979
+ ]),
1980
+ size: z.object({
1981
+ width: z.number().min(1),
1982
+ height: z.number().min(1)
1983
+ }).strict().optional(),
1984
+ points: z.number().min(3).optional(),
1985
+ outer_radius: z.number().min(1).optional(),
1986
+ inner_radius: z.number().min(1).optional(),
1987
+ roundness: z.number().min(0).optional(),
1988
+ outer_roundness: z.number().min(0).max(100).optional(),
1989
+ inner_roundness: z.number().min(0).max(100).optional(),
1990
+ path: pathDataInputSchema.optional(),
1991
+ fill: paintInputSchema.optional(),
1992
+ stroke: paintInputWithOptionalWidthSchema.optional(),
1993
+ name: z.string().optional()
1994
+ }).strict()
1995
+ },
1996
+ set_group_transform: {
1997
+ description: "Set or keyframe a shape group transform/opacity property. Use this when a composed unit has internal groups that should move or fade together.",
1998
+ inputSchema: z.object({
1999
+ group_id: z.string(),
2000
+ property: z.enum([
2001
+ "position",
2002
+ "scale",
2003
+ "rotation",
2004
+ "opacity"
2005
+ ]),
2006
+ value: z.union([z.number(), vector2Schema]).optional(),
2007
+ clear_first: z.boolean().optional(),
2008
+ keyframes: z.array(z.object({
2009
+ frame: z.number(),
2010
+ value: z.union([z.number(), vector2Schema]),
2011
+ easing: easingInputSchema.optional()
2012
+ }).strict()).optional()
2013
+ }).strict()
2014
+ },
2015
+ reorder_shape: {
2016
+ description: "Restack a child shape or group relative to a sibling inside the same shape container — to fix internal card/icon order without rebuilding the layer. Z-order: `position: 'in_front_of'` draws the node ON TOP of the sibling, `'behind'` draws it under. Use `in_front_of`/`behind` (clear); `before`/`after` are legacy aliases that invert intuitively (`before` = on top).",
2017
+ inputSchema: z.object({
2018
+ node_id: z.string(),
2019
+ sibling_id: z.string(),
2020
+ position: z.enum([
2021
+ "in_front_of",
2022
+ "behind",
2023
+ "before",
2024
+ "after"
2025
+ ]).describe("`in_front_of` = on top of the sibling; `behind` = under it. (`before`=in_front_of, `after`=behind — legacy.)")
2026
+ }).strict()
2027
+ },
2028
+ set_layer_time: {
2029
+ description: "Retime an existing layer on the timeline. Any combination of start_frame, end_frame, and timeline_offset can be set — omitted values stay as-is.\n\n- `start_frame` / `end_frame`: when the layer is visible in the parent scene.\n- `timeline_offset`: how far the layer's internal timeline is shifted (negative = play later). This is the stagger primitive — give each sibling a different offset.\n\nPrefer this over `apply_keyframes` on opacity when the user asks to 'delay' or 'stagger' layers — this is cheaper, correct, and doesn't collide with existing opacity animations.",
2030
+ inputSchema: z.object({
2031
+ layer_id: z.string(),
2032
+ start_frame: z.number().optional(),
2033
+ end_frame: z.number().optional(),
2034
+ timeline_offset: z.number().optional()
2035
+ }).strict()
2036
+ },
2037
+ retime_keyframes: {
2038
+ description: "Shift, scale, reverse, evenly distribute, or RE-EASE keyframes on one layer property WITHOUT rebuilding values. Use for timing/feel polish like \"shift this 10 frames later\", \"make the bounce twice as fast\", or \"make it springier\".\n\n**To change the EASING / feel (springy, snappy, smoother, bouncy, more/less overshoot) use `operation: 'set_easing'` with `easing` — NOT apply_keyframes.** set_easing swaps only the easing curve and preserves every frame and value. Rewriting the animation via apply_keyframes forces you to restate all values and risks corrupting positions (e.g. collapsing a staggered word onto itself). For a multi-layer cascade (split-text letters, card grid), call set_easing once PER layer — each call is value-safe, so the layout cannot drift.",
2039
+ inputSchema: z.object({
2040
+ layer_id: z.string(),
2041
+ property: animatablePropertySchema,
2042
+ operation: z.enum([
2043
+ "shift_frames",
2044
+ "scale_time",
2045
+ "reverse",
2046
+ "distribute_evenly",
2047
+ "set_easing"
2048
+ ]),
2049
+ offset: z.number().optional(),
2050
+ scale: z.number().optional(),
2051
+ start_frame: z.number().optional(),
2052
+ end_frame: z.number().optional(),
2053
+ easing: easingPresetNameSchema.describe("Easing preset for operation 'set_easing'. 'overshoot-soft'/'overshoot-hard' = springy/bouncy; 'snappy-out' = snappy; 'smooth-in-out' = smooth. Applied to the property's keyframes in place, values untouched.").optional()
2054
+ }).strict()
2055
+ },
2056
+ delete_layer: {
2057
+ description: "Remove ONE layer from the scene by id. If the scene digest selection is non-empty and the user says 'delete this', 'remove it', 'delete selected', etc., use the selected layer id(s) with this tool. For multiple selected layers, call this tool once per selected id. Returns `{ layer_id, removed: true }`.",
2058
+ inputSchema: z.object({ layer_id: z.string() }).strict()
2059
+ },
2060
+ create_mask: {
2061
+ description: "Add a mask to a layer. Masks clip the layer's visible content to an arbitrary shape — use for reveal wipes ('reveal from left'), clipping images/Lotties to a shape ('mask this image to a circle'), and any 'show only the part inside' request.\n\nFor the common shapes, pass one of the shorthands:\n\n- `shape: \"rectangle\"` + `rect: { x, y, width, height }` — axis-aligned rectangle in LAYER-local coordinates.\n- `shape: \"ellipse\"` + `ellipse: { cx, cy, width, height }` — center-defined ellipse, width/height being the bounding box. Plugin synthesizes a 4-point cubic bezier approximation (standard kappa constant).\n- `shape: \"path\"` + `path: { points: [{vertex:{x,y}, inTan:{x,y}, outTan:{x,y}}...], closed: boolean }` — raw PathData for complex shapes.\n\nCoords are LAYER-LOCAL — a rect at (0, 0, 100, 100) covers the top-left 100×100 of the layer's content, regardless of where the layer sits in the scene.\n\nOptional `mode` defaults to 'add' (show only inside the mask). Pass 'subtract' to cut a hole. Optional `opacity` (0–100) controls mask strength. Animate mask opacity/path with `animate_property({ layer_id, property: 'mask_opacity' | 'mask_path', target_id: mask_id, ... })`; `apply_keyframes` targets layer properties only.\n\nCommon recipes:\n- 'reveal from left': rectangle mask at (0, 0, 0, layerH), then animate width 0 → layerW with `animate_property` on `mask_path`.\n- 'circle clip': ellipse mask, cx/cy = layer center, width/height = desired diameter.\n- 'oval-vignette': ellipse mask slightly inset from layer bounds, opacity 0 → 100 animated with `animate_property` on `mask_opacity`.",
2062
+ inputSchema: createMaskInputSchema
2063
+ },
2064
+ update_mask: {
2065
+ description: "Update an existing mask path, opacity, or mode. Use after create_mask when the reveal shape needs tuning without recreating the mask.",
2066
+ inputSchema: z.object({
2067
+ layer_id: z.string(),
2068
+ mask_id: z.string().optional(),
2069
+ path: pathDataInputSchema.optional(),
2070
+ opacity: z.number().min(0).max(100).optional(),
2071
+ mode: z.enum(["add", "subtract"]).optional()
2072
+ }).strict()
2073
+ },
2074
+ remove_mask: {
2075
+ description: "Remove one mask from a layer. Pass mask_id when known; otherwise removes the first mask only.",
2076
+ inputSchema: z.object({
2077
+ layer_id: z.string(),
2078
+ mask_id: z.string().optional()
2079
+ }).strict()
2080
+ },
2081
+ set_matte: {
2082
+ description: "Apply a track/alpha matte: use another layer's alpha/shape as the visibility matte for this layer. This is THE premium-reveal primitive — what AE calls 'Alpha Matte'.\n\nTypical flow:\n1. User has (or you create) the layer to be revealed (the logo, image, text).\n2. User has (or you create) a second layer whose shape defines the reveal region (a growing circle, a wipe rectangle, a word-shaped text layer).\n3. Call `set_matte({ layer_id: target, matte_layer_id: matteSource })`.\n\nThe native tool sets `matteSource.isMatte = true` so the matte layer itself becomes invisible in the render while still defining the reveal region. Animate the matte source (position, scale, trim path, etc.) and the target layer is revealed accordingly.\n\nPass `inverted: true` to show the target OUTSIDE the matte shape instead of inside (inverted alpha matte).\n\nCommon recipes:\n- 'reveal logo through a growing circle': create ellipse shape → set_matte with matte_layer_id = circle → apply_keyframes on circle.scale 0 → 100.\n- 'text appears through a gradient sweep': create rectangle → set_matte → animate rectangle x across the text width.\n- 'punch a hole': set_matte with inverted: true.",
2083
+ inputSchema: z.object({
2084
+ layer_id: z.string().describe("The layer whose visibility will be gated by the matte."),
2085
+ matte_layer_id: z.string().describe("The layer whose shape defines the matte region. The native tool flips its `isMatte` flag so it stops rendering as its own layer."),
2086
+ inverted: z.boolean().describe("When true, show the target OUTSIDE the matte shape (subtract). Default false (show inside).").optional()
2087
+ }).strict()
2088
+ },
2089
+ remove_matte: {
2090
+ description: "Clear the track matte from a layer.",
2091
+ inputSchema: z.object({ layer_id: z.string() }).strict()
2092
+ },
2093
+ rename_node: {
2094
+ description: "Rename a layer, shape, group, mask, or scene node by id. Use for cleanup and making generated structures understandable.",
2095
+ inputSchema: z.object({
2096
+ node_id: z.string(),
2097
+ name: z.string()
2098
+ }).strict()
2099
+ },
2100
+ set_layer_visibility: {
2101
+ description: "Show or hide one active-scene layer.",
2102
+ inputSchema: z.object({
2103
+ layer_id: z.string(),
2104
+ visible: z.boolean()
2105
+ }).strict()
2106
+ },
2107
+ set_layer_lock: {
2108
+ description: "Lock or unlock one active-scene layer.",
2109
+ inputSchema: z.object({
2110
+ layer_id: z.string(),
2111
+ locked: z.boolean()
2112
+ }).strict()
2113
+ },
2114
+ flip_layer: {
2115
+ description: "Mirror a layer horizontally or vertically. Wraps `layer.flip(direction)`. Use for 'mirror this', 'flip horizontally', 'face the other way'. Works on any layer type.",
2116
+ inputSchema: z.object({
2117
+ layer_id: z.string(),
2118
+ direction: z.enum(["horizontal", "vertical"])
2119
+ }).strict()
2120
+ },
2121
+ align_layer: {
2122
+ description: "**Use this as your first choice for centering or aligning layers to the scene.** Wraps Creator's own alignment math — the exact same path the property-panel 'align center' / 'align left' / 'align top' buttons run. Works reliably across text, shape, and image layers.\n\n`horizontal-center` aligns the layer's visual bounds to the scene's horizontal midpoint; `vertical-center` to the vertical midpoint. For a fully-centered layer, emit BOTH in order: one tool call with `direction: 'horizontal-center'` and then one with `direction: 'vertical-center'`. Same for 'top-left' → `top` + `left`, and so on.\n\n**Prefer this over `center_layer({ target })` unless you need to land on a NON-scene-center point** (e.g. a callout at (180, 380)). For brand-new centered text, prefer `create_text({ center_in_scene: true })` — that's even fewer calls.\n\n**RESPONSE:** returns `visual_center`, `visual_bounds`, and `scene_fit`. For text layers, `position` is intentionally omitted — `visual_center` is the only placement coordinate that matters.",
2123
+ inputSchema: z.object({
2124
+ layer_id: z.string(),
2125
+ direction: z.enum([
2126
+ "left",
2127
+ "right",
2128
+ "top",
2129
+ "bottom",
2130
+ "horizontal-center",
2131
+ "vertical-center"
2132
+ ])
2133
+ }).strict()
2134
+ },
2135
+ align_layers: {
2136
+ description: "Align a SET of existing layers to a shared edge derived from the set itself — the multi-layer counterpart of `align_layer` (which only aligns ONE layer to a SCENE edge). Use this to FIX a row/column you (or the user) hand-placed that came out ragged or misaligned: e.g. make a column of labels share a LEFT edge, or a row of icons share a TOP edge.\n\n`edge`: which edge to make flush — `left`/`right`/`horizontal-center` (x-axis) or `top`/`bottom`/`vertical-center` (y-axis). Edge-anchored by each layer's RENDERED visual bounds (so text aligns by its real left/right edge, not its baseline anchor).\n\n`to` (optional): the coordinate to align to — `min` (leftmost/topmost; default for left/top), `max` (default for right/bottom), `mean` (default for centers), `first` (the first layer's edge), or an explicit number. \n\nGuards: refuses layers with ANIMATED position (all-or-nothing) — align BEFORE animating, then `stagger_layers`. Returns `{ aligned, edge, axis, value, layer_ids }`.",
2137
+ inputSchema: z.object({
2138
+ layer_ids: z.array(z.string()).describe("The layers to align to each other (≥2)."),
2139
+ edge: z.enum([
2140
+ "left",
2141
+ "right",
2142
+ "top",
2143
+ "bottom",
2144
+ "horizontal-center",
2145
+ "vertical-center"
2146
+ ]),
2147
+ to: z.union([z.enum([
2148
+ "min",
2149
+ "max",
2150
+ "first",
2151
+ "mean"
2152
+ ]), z.number()]).describe("Coordinate to align to: 'min' | 'max' | 'first' | 'mean' | a number. Omit for sensible defaults (min for left/top, max for right/bottom, mean for centers).").optional()
2153
+ }).strict()
2154
+ },
2155
+ layout: {
2156
+ description: "Lay out multiple items in a ROW or COLUMN with EVEN spacing and shared-edge alignment — in ONE call. **Use this instead of hand-placing positions** for any multi-item arrangement: nav menus, button rows, lists, label stacks, feature rows, and especially LEGENDS / swatch grids / spec-sheet rows / icon lists (a swatch legend = a column of `{swatch, label}` row-blocks — hand-placing those overlaps the label onto the swatch). It creates the items (or arranges existing layers), measures each rendered size, and computes even gaps + alignment so the result is correct by construction — no pixel math, no uneven gaps, no ragged edges.\n\n`type`: `row` (horizontal) or `column` (vertical). Provide EITHER `items` (to create text/shapes) OR `layer_ids` (to arrange existing layers) — not both.\n\n`gap` (default 24): the SINGLE even gap between items. `align` (cross-axis, default `center`): `start` = shared leading edge (left for a column, top for a row — this is how you get a left-aligned list), `center`, `end`. `origin` (default `scene-center`): where the whole block centers — `scene-center` / `top-center` / `bottom-center` / `{x,y}`.\n\nItem kinds: `{ kind: 'text', text, font_size?, fill_color?, font_variant? }`, `{ kind: 'shape', shape, size, fill_color?, roundness?, points?, outer_radius?, inner_radius?, stroke? }`, `{ kind: 'layer', layer_id }`, or a NESTED `{ kind: 'block', type:'row'|'column', items:[…], gap?, align?, group? }` — a sub-layout the parent treats as one unit. Use a block to compose multi-row/column layouts in ONE call: e.g. a vertical list of icon+label rows where the icons all share a left edge and the labels all share another →\n`layout({ type:'column', align:'start', gap:24, items:[\n { kind:'block', type:'row', align:'center', gap:16, items:[{kind:'shape',shape:'ellipse',size:{width:32,height:32}},{kind:'text',text:'Create account'}] },\n { kind:'block', type:'row', align:'center', gap:16, items:[…'Verify email'] },\n { kind:'block', type:'row', align:'center', gap:16, items:[…'Invite your team'] } ]})`.\nThe outer `column align:'start'` flushes each row-block's left edge, so all circles land on one x and all labels on another — no follow-up `align_layers`. A nested block takes `items` only (no `origin`/`layer_ids`); `group:true` on a block makes it one animatable precomp. Max nesting depth 3.\n\n**Cards:** add `background:{ fill, roundness?, padding?, stroke?, shadow? }` to a block to drop a rounded panel sized to its content + padding BEHIND it — so a card = panel + content builds in ONE call (never create the content then bolt a panel on top). **`background` works on a block that CREATES its `items`, OR on the top-level `layout` call itself (it wraps the whole layout in one card panel)** — so build the card's content INSIDE the block (`items:[…]`), do NOT pre-create the text/shapes and then arrange them by `layer_ids` and try to wrap them. If you catch yourself doing `create_shape` (panel) + `reorder_layer` (behind) + `add_drop_shadow` around already-made content, STOP — that's the legacy bolt-on this `background` replaces; rebuild the content as `items` inside the block instead. With `group:true` the whole card becomes one animatable precomp you can slide/scale/fade as a unit. `fill` is required (use `fill:'none'` with a `stroke` for an outlined card); `padding` defaults to 24. E.g. a feature card: `{ kind:'block', type:'column', align:'center', gap:12, group:true, background:{ fill:{r:248,g:250,b:252}, roundness:16, padding:24, shadow:{} }, items:[ {kind:'shape',shape:'ellipse',size:{width:48,height:48}}, {kind:'text',text:'Fast'} ] }`.\n\nReturns `items` (each with `layer_id`, `visual_center`, `visual_bounds`; a block item also has `kind:'block'` + `child_layer_ids`), `cascade_order` (flattened reading order of staggerable root layers — pass straight to `stagger_layers`), `evenness` (`main_gaps`, `uniform`), and `content_bounds`. Examples: a left-aligned nav = `layout({ type:'column', align:'start', items:[{kind:'text',text:'Home'},…] })`; a feature row = `layout({ type:'row', align:'center', items:[{kind:'shape',shape:'ellipse',size:{width:40,height:40}},{kind:'text',text:'Fast'}] })`.",
2157
+ inputSchema: z.object({
2158
+ type: z.enum(["row", "column"]),
2159
+ items: z.array(z.object({
2160
+ kind: z.enum([
2161
+ "text",
2162
+ "shape",
2163
+ "layer",
2164
+ "block"
2165
+ ]),
2166
+ text: z.string().min(1).optional(),
2167
+ font_size: z.number().optional(),
2168
+ font_variant: z.string().optional(),
2169
+ font_family: z.string().optional(),
2170
+ fill_color: z.union([rgbColorInputSchema, z.literal("none")]).optional(),
2171
+ shape: z.enum([
2172
+ "rectangle",
2173
+ "ellipse",
2174
+ "polygon",
2175
+ "star"
2176
+ ]).optional(),
2177
+ size: z.object({
2178
+ width: z.number(),
2179
+ height: z.number()
2180
+ }).strict().optional(),
2181
+ roundness: z.number().optional(),
2182
+ radius_token: z.enum([
2183
+ "xs",
2184
+ "sm",
2185
+ "md",
2186
+ "lg",
2187
+ "pill"
2188
+ ]).describe("Rounded-corner token (rectangle only): xs=2 sm=4 md=8 lg=24, pill=stadium. Wins over numeric `roundness`. Ignored on non-rectangles.").optional(),
2189
+ ...layoutShapeSchemaFields,
2190
+ role: z.enum([
2191
+ "heading",
2192
+ "body",
2193
+ "caption",
2194
+ "accent",
2195
+ "surface",
2196
+ "panel",
2197
+ "action"
2198
+ ]).describe("Semantic color role, resolved to a default slate fill when `fill_color` is omitted (text: heading=slate-900, body=slate-600, caption=slate-500; shapes: surface/panel/action). A text item with NO color defaults to `body` slate — never pure black. 'accent' alone stays neutral slate-700; pass `fill` for chroma.").optional(),
2199
+ fill: z.string().describe("Named Flat-UI accent (kebab, e.g. 'peter-river','emerald','sun-flower'). Wins over `role`; only when `fill_color` is omitted; purple/unknown ignored.").optional(),
2200
+ layer_id: z.string().optional(),
2201
+ name: z.string().optional(),
2202
+ type: z.enum(["row", "column"]).optional(),
2203
+ gap: z.number().optional(),
2204
+ align: z.enum([
2205
+ "start",
2206
+ "center",
2207
+ "end"
2208
+ ]).optional(),
2209
+ group: z.boolean().optional(),
2210
+ background: z.object({
2211
+ fill: z.union([rgbColorInputSchema, z.literal("none")]),
2212
+ roundness: z.number().optional(),
2213
+ radius_token: z.enum([
2214
+ "xs",
2215
+ "sm",
2216
+ "md",
2217
+ "lg",
2218
+ "pill"
2219
+ ]).describe("Card corner token (wins over numeric `roundness`): xs=2 sm=4 md=8 lg=24, pill=stadium. Omit for the default rounded card (16).").optional(),
2220
+ padding: z.union([z.number(), z.object({
2221
+ top: z.number().optional(),
2222
+ right: z.number().optional(),
2223
+ bottom: z.number().optional(),
2224
+ left: z.number().optional()
2225
+ }).strict()]).optional(),
2226
+ stroke: z.object({
2227
+ color: rgbColorInputSchema,
2228
+ width: z.number()
2229
+ }).strict().optional(),
2230
+ shadow: z.union([
2231
+ z.object({
2232
+ color: rgbColorInputSchema.optional(),
2233
+ opacity: z.number().optional(),
2234
+ angle: z.number().optional(),
2235
+ distance: z.number().optional(),
2236
+ blur: z.number().optional(),
2237
+ elevation: z.enum([
2238
+ "resting",
2239
+ "raised",
2240
+ "floating"
2241
+ ]).optional()
2242
+ }).strict(),
2243
+ z.literal(false),
2244
+ z.literal("none")
2245
+ ]).describe("Card lift. OMIT for the default: a filled card auto-gets a resting elevation. Pass `{elevation:'raised'|'floating'}` for a stronger named tier, a bespoke single-shadow object, or `false`/`'none'` to suppress the auto-shadow.").optional()
2246
+ }).strict().optional(),
2247
+ items: z.array(z.object({
2248
+ kind: z.enum([
2249
+ "text",
2250
+ "shape",
2251
+ "layer",
2252
+ "block"
2253
+ ]),
2254
+ text: z.string().min(1).optional(),
2255
+ font_size: z.number().optional(),
2256
+ font_variant: z.string().optional(),
2257
+ font_family: z.string().optional(),
2258
+ fill_color: z.union([rgbColorInputSchema, z.literal("none")]).optional(),
2259
+ shape: z.enum([
2260
+ "rectangle",
2261
+ "ellipse",
2262
+ "polygon",
2263
+ "star"
2264
+ ]).optional(),
2265
+ size: z.object({
2266
+ width: z.number(),
2267
+ height: z.number()
2268
+ }).strict().optional(),
2269
+ roundness: z.number().optional(),
2270
+ radius_token: z.enum([
2271
+ "xs",
2272
+ "sm",
2273
+ "md",
2274
+ "lg",
2275
+ "pill"
2276
+ ]).describe("Rounded-corner token (rectangle only): xs=2 sm=4 md=8 lg=24, pill=stadium. Wins over numeric `roundness`. Ignored on non-rectangles.").optional(),
2277
+ ...layoutShapeSchemaFields,
2278
+ role: z.enum([
2279
+ "heading",
2280
+ "body",
2281
+ "caption",
2282
+ "accent",
2283
+ "surface",
2284
+ "panel",
2285
+ "action"
2286
+ ]).describe("Semantic color role, resolved to a default slate fill when `fill_color` is omitted (text: heading=slate-900, body=slate-600, caption=slate-500; shapes: surface/panel/action). A text item with NO color defaults to `body` slate — never pure black. 'accent' alone stays neutral slate-700; pass `fill` for chroma.").optional(),
2287
+ fill: z.string().describe("Named Flat-UI accent (kebab, e.g. 'peter-river','emerald','sun-flower'). Wins over `role`; only when `fill_color` is omitted; purple/unknown ignored.").optional(),
2288
+ layer_id: z.string().optional(),
2289
+ name: z.string().optional(),
2290
+ type: z.enum(["row", "column"]).optional(),
2291
+ gap: z.number().optional(),
2292
+ align: z.enum([
2293
+ "start",
2294
+ "center",
2295
+ "end"
2296
+ ]).optional(),
2297
+ group: z.boolean().optional(),
2298
+ background: z.object({
2299
+ fill: z.union([rgbColorInputSchema, z.literal("none")]),
2300
+ roundness: z.number().optional(),
2301
+ radius_token: z.enum([
2302
+ "xs",
2303
+ "sm",
2304
+ "md",
2305
+ "lg",
2306
+ "pill"
2307
+ ]).describe("Card corner token (wins over numeric `roundness`): xs=2 sm=4 md=8 lg=24, pill=stadium. Omit for the default rounded card (16).").optional(),
2308
+ padding: z.union([z.number(), z.object({
2309
+ top: z.number().optional(),
2310
+ right: z.number().optional(),
2311
+ bottom: z.number().optional(),
2312
+ left: z.number().optional()
2313
+ }).strict()]).optional(),
2314
+ stroke: z.object({
2315
+ color: rgbColorInputSchema,
2316
+ width: z.number()
2317
+ }).strict().optional(),
2318
+ shadow: z.union([
2319
+ z.object({
2320
+ color: z.object({
2321
+ r: z.number(),
2322
+ g: z.number(),
2323
+ b: z.number()
2324
+ }).strict().optional(),
2325
+ opacity: z.number().optional(),
2326
+ angle: z.number().optional(),
2327
+ distance: z.number().optional(),
2328
+ blur: z.number().optional(),
2329
+ elevation: z.enum([
2330
+ "resting",
2331
+ "raised",
2332
+ "floating"
2333
+ ]).optional()
2334
+ }).strict(),
2335
+ z.literal(false),
2336
+ z.literal("none")
2337
+ ]).describe("Card lift. OMIT for the default: a filled card auto-gets a resting elevation. Pass `{elevation:'raised'|'floating'}` for a stronger named tier, a bespoke single-shadow object, or `false`/`'none'` to suppress the auto-shadow.").optional()
2338
+ }).strict().optional(),
2339
+ items: z.array(z.object({
2340
+ kind: z.enum([
2341
+ "text",
2342
+ "shape",
2343
+ "layer",
2344
+ "block"
2345
+ ]),
2346
+ text: z.string().min(1).optional(),
2347
+ font_size: z.number().optional(),
2348
+ font_variant: z.string().optional(),
2349
+ font_family: z.string().optional(),
2350
+ fill_color: z.union([z.object({
2351
+ r: z.number(),
2352
+ g: z.number(),
2353
+ b: z.number()
2354
+ }).strict(), z.literal("none")]).optional(),
2355
+ shape: z.enum([
2356
+ "rectangle",
2357
+ "ellipse",
2358
+ "polygon",
2359
+ "star"
2360
+ ]).optional(),
2361
+ size: z.object({
2362
+ width: z.number(),
2363
+ height: z.number()
2364
+ }).strict().optional(),
2365
+ roundness: z.number().optional(),
2366
+ radius_token: z.enum([
2367
+ "xs",
2368
+ "sm",
2369
+ "md",
2370
+ "lg",
2371
+ "pill"
2372
+ ]).describe("Rounded-corner token (rectangle only): xs=2 sm=4 md=8 lg=24, pill=stadium. Wins over numeric `roundness`. Ignored on non-rectangles.").optional(),
2373
+ ...layoutShapeSchemaFields,
2374
+ role: z.enum([
2375
+ "heading",
2376
+ "body",
2377
+ "caption",
2378
+ "accent",
2379
+ "surface",
2380
+ "panel",
2381
+ "action"
2382
+ ]).describe("Semantic color role, resolved to a default slate fill when `fill_color` is omitted (text: heading=slate-900, body=slate-600, caption=slate-500; shapes: surface/panel/action). A text item with NO color defaults to `body` slate — never pure black. 'accent' alone stays neutral slate-700; pass `fill` for chroma.").optional(),
2383
+ fill: z.string().describe("Named Flat-UI accent (kebab, e.g. 'peter-river','emerald','sun-flower'). Wins over `role`; only when `fill_color` is omitted; purple/unknown ignored.").optional(),
2384
+ layer_id: z.string().optional(),
2385
+ name: z.string().optional(),
2386
+ type: z.enum(["row", "column"]).optional(),
2387
+ gap: z.number().optional(),
2388
+ align: z.enum([
2389
+ "start",
2390
+ "center",
2391
+ "end"
2392
+ ]).optional(),
2393
+ group: z.boolean().optional(),
2394
+ background: z.object({
2395
+ fill: z.union([z.object({
2396
+ r: z.number(),
2397
+ g: z.number(),
2398
+ b: z.number()
2399
+ }).strict(), z.literal("none")]),
2400
+ roundness: z.number().optional(),
2401
+ radius_token: z.enum([
2402
+ "xs",
2403
+ "sm",
2404
+ "md",
2405
+ "lg",
2406
+ "pill"
2407
+ ]).describe("Card corner token (wins over numeric `roundness`): xs=2 sm=4 md=8 lg=24, pill=stadium. Omit for the default rounded card (16).").optional(),
2408
+ padding: z.union([z.number(), z.object({
2409
+ top: z.number().optional(),
2410
+ right: z.number().optional(),
2411
+ bottom: z.number().optional(),
2412
+ left: z.number().optional()
2413
+ }).strict()]).optional(),
2414
+ stroke: z.object({
2415
+ color: z.object({
2416
+ r: z.number(),
2417
+ g: z.number(),
2418
+ b: z.number()
2419
+ }).strict(),
2420
+ width: z.number()
2421
+ }).strict().optional(),
2422
+ shadow: z.union([
2423
+ z.object({
2424
+ color: z.object({
2425
+ r: z.number(),
2426
+ g: z.number(),
2427
+ b: z.number()
2428
+ }).strict().optional(),
2429
+ opacity: z.number().optional(),
2430
+ angle: z.number().optional(),
2431
+ distance: z.number().optional(),
2432
+ blur: z.number().optional(),
2433
+ elevation: z.enum([
2434
+ "resting",
2435
+ "raised",
2436
+ "floating"
2437
+ ]).optional()
2438
+ }).strict(),
2439
+ z.literal(false),
2440
+ z.literal("none")
2441
+ ]).describe("Card lift. OMIT for the default: a filled card auto-gets a resting elevation. Pass `{elevation:'raised'|'floating'}` for a stronger named tier, a bespoke single-shadow object, or `false`/`'none'` to suppress the auto-shadow.").optional()
2442
+ }).strict().optional(),
2443
+ items: z.array(z.object({
2444
+ kind: z.enum([
2445
+ "text",
2446
+ "shape",
2447
+ "layer",
2448
+ "block"
2449
+ ]),
2450
+ text: z.string().min(1).optional(),
2451
+ font_size: z.number().optional(),
2452
+ font_variant: z.string().optional(),
2453
+ font_family: z.string().optional(),
2454
+ fill_color: z.union([z.object({
2455
+ r: z.number(),
2456
+ g: z.number(),
2457
+ b: z.number()
2458
+ }).strict(), z.literal("none")]).optional(),
2459
+ shape: z.enum([
2460
+ "rectangle",
2461
+ "ellipse",
2462
+ "polygon",
2463
+ "star"
2464
+ ]).optional(),
2465
+ size: z.object({
2466
+ width: z.number(),
2467
+ height: z.number()
2468
+ }).strict().optional(),
2469
+ roundness: z.number().optional(),
2470
+ radius_token: z.enum([
2471
+ "xs",
2472
+ "sm",
2473
+ "md",
2474
+ "lg",
2475
+ "pill"
2476
+ ]).describe("Rounded-corner token (rectangle only): xs=2 sm=4 md=8 lg=24, pill=stadium. Wins over numeric `roundness`. Ignored on non-rectangles.").optional(),
2477
+ ...layoutShapeSchemaFields,
2478
+ role: z.enum([
2479
+ "heading",
2480
+ "body",
2481
+ "caption",
2482
+ "accent",
2483
+ "surface",
2484
+ "panel",
2485
+ "action"
2486
+ ]).describe("Semantic color role, resolved to a default slate fill when `fill_color` is omitted (text: heading=slate-900, body=slate-600, caption=slate-500; shapes: surface/panel/action). A text item with NO color defaults to `body` slate — never pure black. 'accent' alone stays neutral slate-700; pass `fill` for chroma.").optional(),
2487
+ fill: z.string().describe("Named Flat-UI accent (kebab, e.g. 'peter-river','emerald','sun-flower'). Wins over `role`; only when `fill_color` is omitted; purple/unknown ignored.").optional(),
2488
+ layer_id: z.string().optional(),
2489
+ name: z.string().optional(),
2490
+ type: z.enum(["row", "column"]).optional(),
2491
+ gap: z.number().optional(),
2492
+ align: z.enum([
2493
+ "start",
2494
+ "center",
2495
+ "end"
2496
+ ]).optional(),
2497
+ group: z.boolean().optional(),
2498
+ background: z.object({
2499
+ fill: z.union([z.object({
2500
+ r: z.number(),
2501
+ g: z.number(),
2502
+ b: z.number()
2503
+ }).strict(), z.literal("none")]),
2504
+ roundness: z.number().optional(),
2505
+ radius_token: z.enum([
2506
+ "xs",
2507
+ "sm",
2508
+ "md",
2509
+ "lg",
2510
+ "pill"
2511
+ ]).describe("Card corner token (wins over numeric `roundness`): xs=2 sm=4 md=8 lg=24, pill=stadium. Omit for the default rounded card (16).").optional(),
2512
+ padding: z.union([z.number(), z.object({
2513
+ top: z.number().optional(),
2514
+ right: z.number().optional(),
2515
+ bottom: z.number().optional(),
2516
+ left: z.number().optional()
2517
+ }).strict()]).optional(),
2518
+ stroke: z.object({
2519
+ color: z.object({
2520
+ r: z.number(),
2521
+ g: z.number(),
2522
+ b: z.number()
2523
+ }).strict(),
2524
+ width: z.number()
2525
+ }).strict().optional(),
2526
+ shadow: z.union([
2527
+ z.object({
2528
+ color: z.object({
2529
+ r: z.number(),
2530
+ g: z.number(),
2531
+ b: z.number()
2532
+ }).strict().optional(),
2533
+ opacity: z.number().optional(),
2534
+ angle: z.number().optional(),
2535
+ distance: z.number().optional(),
2536
+ blur: z.number().optional(),
2537
+ elevation: z.enum([
2538
+ "resting",
2539
+ "raised",
2540
+ "floating"
2541
+ ]).optional()
2542
+ }).strict(),
2543
+ z.literal(false),
2544
+ z.literal("none")
2545
+ ]).describe("Card lift. OMIT for the default: a filled card auto-gets a resting elevation. Pass `{elevation:'raised'|'floating'}` for a stronger named tier, a bespoke single-shadow object, or `false`/`'none'` to suppress the auto-shadow.").optional()
2546
+ }).strict().optional()
2547
+ }).strict()).optional()
2548
+ }).strict()).optional()
2549
+ }).strict()).optional()
2550
+ }).strict()).describe("Items to create + lay out (mutually exclusive with layer_ids). An item is a leaf (text/shape/layer) or a nested `block` (a row/column sub-layout).").optional(),
2551
+ layer_ids: z.array(z.string()).describe("Existing layers to arrange (mutually exclusive with items).").optional(),
2552
+ gap: z.number().describe("Even gap between items (default 24).").optional(),
2553
+ align: z.enum([
2554
+ "start",
2555
+ "center",
2556
+ "end"
2557
+ ]).describe("Cross-axis alignment. 'start' = shared leading edge (left for a column / top for a row); 'center' (default); 'end'.").optional(),
2558
+ origin: z.union([z.enum([
2559
+ "scene-center",
2560
+ "top-center",
2561
+ "bottom-center"
2562
+ ]), z.object({
2563
+ x: z.number(),
2564
+ y: z.number()
2565
+ }).strict()]).describe("Where the block centers: 'scene-center' | 'top-center' | 'bottom-center' | {x,y}.").optional(),
2566
+ group: z.boolean().describe("Wrap the whole laid-out block into ONE precomp (returns `group_id`) so you can move/scale/fade it as a single unit. Default false. NOTE: grouped items leave the scene root, so you can NO LONGER stagger them individually — for a staggered entrance keep this false and pass `cascade_order` to `stagger_layers`.").optional(),
2567
+ name: z.string().optional(),
2568
+ background: z.object({
2569
+ fill: z.union([rgbColorInputSchema, z.literal("none")]),
2570
+ roundness: z.number().optional(),
2571
+ radius_token: z.enum([
2572
+ "xs",
2573
+ "sm",
2574
+ "md",
2575
+ "lg",
2576
+ "pill"
2577
+ ]).describe("Card corner token (wins over numeric `roundness`): xs=2 sm=4 md=8 lg=24, pill=stadium. Omit for the default rounded card (16).").optional(),
2578
+ padding: z.union([z.number(), z.object({
2579
+ top: z.number().optional(),
2580
+ right: z.number().optional(),
2581
+ bottom: z.number().optional(),
2582
+ left: z.number().optional()
2583
+ }).strict()]).optional(),
2584
+ stroke: z.object({
2585
+ color: rgbColorInputSchema,
2586
+ width: z.number()
2587
+ }).strict().optional(),
2588
+ shadow: z.union([
2589
+ z.object({
2590
+ color: rgbColorInputSchema.optional(),
2591
+ opacity: z.number().optional(),
2592
+ angle: z.number().optional(),
2593
+ distance: z.number().optional(),
2594
+ blur: z.number().optional(),
2595
+ elevation: z.enum([
2596
+ "resting",
2597
+ "raised",
2598
+ "floating"
2599
+ ]).optional()
2600
+ }).strict(),
2601
+ z.literal(false),
2602
+ z.literal("none")
2603
+ ]).describe("Card lift. OMIT for the default: a filled card auto-gets a resting elevation. Pass `{elevation:'raised'|'floating'}` for a stronger named tier, a bespoke single-shadow object, or `false`/`'none'` to suppress the auto-shadow.").optional()
2604
+ }).strict().optional()
2605
+ }).strict()
2606
+ },
2607
+ read_keyframes: {
2608
+ description: "Read the current keyframes on a layer's keyframe-tool properties. Use this whenever the user asks to TUNE, POLISH, or MODIFY an existing animation ('make the bounce snappier', 'slow down the fade', 'shift this 10 frames later', 'make the rotation smoother') — so you can see what's actually there before changing it, instead of rebuilding from scratch.\n\nWithout `property`, returns a map of the core properties handled by `apply_keyframes` (`position`, `scale`, `rotation`, `opacity`, `fill_color`, trim path fields, and `stroke_width`). With `property`, returns just that explicit core or extended property. Extended properties such as masks, shape paths, gradients, text animator fields, and time mapping must be named explicitly; they are not included in the bulk read.\n\nFor static (non-animated) properties, `is_animated` is false and `static_value` is returned instead of keyframes.\n\n**For `position` on a TEXT layer, `static_value` is the RAW transform (baseline / box-origin anchor) — NOT the visual center. To animate a text position relative to its rest (the only safe way), keyframe from THIS `static_value`, not from `visual_center`. For a settle-into-place entrance, prefer `animate_entrance`, which reads this for you.**\n\nEasing in returned keyframes comes through as the raw stored shape: `{ type: \"LINEAR\" }` or `{ type: \"CUBIC_BEZIER\", x1, y1, x2, y2 }`. (Named presets are expanded at write-time, so reads only see the resolved bezier coefficients.)",
2609
+ inputSchema: z.object({
2610
+ layer_id: z.string(),
2611
+ property: animatablePropertySchema.describe("Optional — restrict to one core or extended keyframe-tool property. Omit to read all core keyframe-tool properties on the layer.").optional()
2612
+ }).strict()
2613
+ },
2614
+ copy_animation: {
2615
+ description: "Copy keyframes from one layer's core keyframe-tool properties onto another. The classic use: 'match this layer's animation to the others,' 'copy the rotation from the big triangle to the small one,' 'give this the same entrance as that one.'\n\nBy default, animated core properties from `apply_keyframes` are copied — pass `properties` to restrict. Extended `animate_property` fields such as masks, shape paths, gradients, text animator fields, and time mapping are not copied by this tool. Pass `frame_offset` to shift all copied keyframes by N frames, which stacks with this tool for manual staggers (e.g. call copy_animation for each sibling with a different offset).\n\n`clear_first_on_target` (default true) replaces any existing keyframes on the target's matching property; pass false to merge.\n\nProperties that don't exist on the target layer type (e.g., `fill_color` on a text layer) are skipped with a note in the response rather than erroring the whole call.",
2616
+ inputSchema: z.object({
2617
+ source_layer_id: z.string(),
2618
+ target_layer_id: z.string(),
2619
+ properties: z.array(coreAnimatedPropertySchema).describe("Optional — which core keyframe-tool properties to copy. Omit to copy all animated core properties on the source.").optional(),
2620
+ frame_offset: z.number().describe("Shift every copied keyframe's `frame` by this many frames. Negative = play earlier, positive = play later. Default 0.").optional(),
2621
+ clear_first_on_target: z.boolean().describe("When true (default), clear each property's existing keyframes on the target before copying. When false, merge with existing.").optional()
2622
+ }).strict()
2623
+ },
2624
+ list_scenes: {
2625
+ description: "List every scene in the current project plus which one is active. Cheap read. Use when the user refers to scenes by name ('switch to the outro', 'work on the vertical') so you can find the right `scene_id`, or when deciding whether to create a new scene vs. switch to an existing one.\n\nReturns active scene id, and for each scene: id, name, size, framerate, duration, layer_count, and is_nestable (whether the scene can be used as a source for a nested scene layer).",
2626
+ inputSchema: z.object({}).strict()
2627
+ },
2628
+ create_scene: {
2629
+ description: "Create a new scene (a.k.a. composition / artboard) in the project. Use for 'make a vertical variant', 'add a second scene for the outro', 'new 1080×1920 version', 'start fresh on a new composition'.\n\nAll fields optional — omit to inherit defaults. If size is omitted, Creator uses its default (typically 1920×1080). Provide explicit size when the user asks for a specific aspect ratio (9:16 → 1080×1920, 1:1 → 1080×1080, etc.).\n\n**IMPORTANT**: `activate` defaults to true, meaning the newly created scene becomes the active scene. Any layer IDs you had from before (in history or from a recent read_scene) are INVALID in the new active scene — they belong to the previous scene. Call `read_scene` immediately after this tool before using any layer-targeting tool. If the user wants a new scene without switching, pass `activate: false` and the current scene stays active.",
2630
+ inputSchema: z.object({
2631
+ name: z.string().describe("Scene name as shown in Creator's scene picker.").optional(),
2632
+ size: z.object({
2633
+ width: z.number().min(1),
2634
+ height: z.number().min(1)
2635
+ }).strict().optional(),
2636
+ framerate: z.number().min(1).describe("Frames per second. Default: Creator's fallback.").optional(),
2637
+ duration: z.number().min(.1).describe("Scene duration in seconds.").optional(),
2638
+ activate: z.boolean().describe("Switch to the new scene immediately (default true). Set false to create but keep the current scene active.").optional()
2639
+ }).strict()
2640
+ },
2641
+ switch_scene: {
2642
+ description: "Switch Creator's active scene. Provide either `scene_id` (exact) or `name` (exact or case-insensitive substring match across scenes). Use `list_scenes` first if you're not sure what's available.\n\n**IMPORTANT**: After this tool runs, any layer_id from prior turns is INVALID — those layers live in the scene you just switched away from. Call `read_scene` before using any layer-targeting tool on the new active scene. If you switched temporarily into a selected precomp/source scene, switch back to the original active scene before ending the turn.",
2643
+ inputSchema: z.object({
2644
+ scene_id: z.string().describe("Exact scene id from `list_scenes`. Preferred.").optional(),
2645
+ name: z.string().describe("Scene name. Exact first, then case-insensitive substring. Errors if 0 or more than 1 match.").optional()
2646
+ }).strict()
2647
+ },
2648
+ set_current_frame: {
2649
+ description: "Move the active scene's playhead to a specific frame. Use this after creating reveal/draw-on/fade-in animations where frame 0 is intentionally invisible (`trim_end: 0`, opacity 0, offscreen position) so the Creator canvas is left on a representative visible frame. This changes the editor preview frame only; it does not add animation keyframes or alter layer timing.",
2650
+ inputSchema: z.object({
2651
+ frame: z.number().int().min(0).describe("Scene frame to show in Creator. Creator clamps to the scene range."),
2652
+ add_to_history: z.boolean().describe("Whether this navigation should enter undo history. Default false; leave false for Motion Copilot preview positioning.").optional()
2653
+ }).strict()
2654
+ },
2655
+ set_scene_background: {
2656
+ description: "Set the PREVIEW background color of the active scene. **This is preview-only — the color is NOT exported with the final animation; exports always have a transparent background.** Use this whenever you want to see how the design reads against a specific surface (e.g. dark mode preview, brand tint preview, contrast check for white-on-X) WITHOUT polluting the scene with a background rectangle layer.\n\n**Strongly prefer this over creating a background SHAPE LAYER.** Motion-design convention: animations float on transparency so they can be composited anywhere. Adding a background rectangle layer adds noise, exports unwanted color, and forces the user to delete it if they want to use the animation on a different surface. The only reasons to add a real background SHAPE LAYER are: (1) a gradient or multi-stop background that's part of the design, (2) a contrast issue that can't be solved any other way, (3) the user EXPLICITLY says 'add a background layer' or similar.\n\nFor every other case (the user says 'on a dark background', 'against a navy backdrop', 'preview on cream', etc.) use this tool — it changes the scene's preview color, leaves the final animation transparent.\n\n**REQUIRED, not optional:** when the scene has no background AND you place near-white (luminance > 0.5, e.g. #f8fafc / #ffffff) full-bleed text or fills, you MUST call this in the SAME turn with a dark color (Slate-900 {r:15,g:23,b:42}) — the default preview is white and light text would be invisible. Button labels on a dark button fill are self-contained and do NOT trigger this; only full-bleed text on the canvas does.",
2657
+ inputSchema: z.object({ color: z.object({
2658
+ r: z.number().min(0).max(255),
2659
+ g: z.number().min(0).max(255),
2660
+ b: z.number().min(0).max(255)
2661
+ }).strict().describe("RGB color, channels 0–255.") }).strict()
2662
+ },
2663
+ set_scene_settings: {
2664
+ description: "Update the active scene size, framerate, duration, and/or origin point. Use for prompts like \"make this 1080x1920\", \"make it 60fps\", or \"extend to 8 seconds\".",
2665
+ inputSchema: z.object({
2666
+ size: z.object({
2667
+ width: z.number().min(1),
2668
+ height: z.number().min(1)
2669
+ }).strict().optional(),
2670
+ framerate: z.number().min(1).optional(),
2671
+ duration: z.number().min(.1).optional(),
2672
+ origin_point: z.object({
2673
+ x: z.number(),
2674
+ y: z.number()
2675
+ }).strict().optional()
2676
+ }).strict()
2677
+ },
2678
+ create_scene_layer: {
2679
+ description: "Place an existing NESTABLE scene/precomp into the active scene as a SceneLayer (nested composition / component / precomp). Use this for professional card, panel, logo, or UI units only when `list_scenes` reports the source scene has `is_nestable: true`.\n\nImportant: newly created top-level scenes that report `is_nestable: false`; those cannot be used as scene-layer sources. Do not create a fresh scene and immediately try to place it unless `list_scenes` confirms it is nestable. For generated cards/panels, prefer a single grouped ShapeLayer when no nestable source exists.\n\nProvide `source_scene_id` from `list_scenes`, or `source_scene_name` if unique. The layer is created in the current active scene. The source scene cannot be the current active scene — switch to the parent scene first. After creation, animate the returned `layer_id` like any other layer, and apply `set_matte` to this one layer for viewport clipping.",
2680
+ inputSchema: z.object({
2681
+ source_scene_id: z.string().describe("Exact source scene id from `list_scenes`.").optional(),
2682
+ source_scene_name: z.string().describe("Source scene name. Exact first, then case-insensitive substring. Errors if 0 or more than 1 match.").optional(),
2683
+ name: z.string().describe("Optional name for the created SceneLayer.").optional(),
2684
+ position: z.object({
2685
+ x: z.number(),
2686
+ y: z.number()
2687
+ }).strict().optional(),
2688
+ opacity: z.number().min(0).max(100).describe("Optional initial opacity.").optional(),
2689
+ scale: z.object({
2690
+ x: z.number(),
2691
+ y: z.number()
2692
+ }).strict().describe("Optional initial scale in percent. Applied after layer creation.").optional(),
2693
+ start_frame: z.number().describe("Optional layer start frame.").optional(),
2694
+ end_frame: z.number().describe("Optional layer end frame.").optional()
2695
+ }).strict()
2696
+ },
2697
+ break_scene_layer: {
2698
+ description: "Destructively break an active-scene scene/precomp layer into independent editable layers. Use only when the user explicitly asks to break apart, detach, ungroup, or extract a nested scene. Do not use for normal edits to a precomp/scene layer contents; switch to the source scene and edit there instead.",
2699
+ inputSchema: z.object({ layer_id: z.string() }).strict()
2700
+ },
2701
+ export_lottie: {
2702
+ description: "Export the active scene as Lottie JSON for debugging. Result content is redacted in audit logs but returned to the tool caller.",
2703
+ inputSchema: z.object({}).strict()
2704
+ },
2705
+ ask_user: {
2706
+ description: "Pause and ask the user a clarifying question. The user's pick is returned to you as the tool result `{ answer: string }`. Use this *instead of* asking the question in your prose so the UI can render an interactive chip.\n\nWhen to call:\n- The answer materially changes what you'll build (style direction, scope, target layer when ambiguous).\n- A sensible default would be wrong often enough that asking is cheaper than redoing.\n\nWhen NOT to call:\n- You can pick a reasonable default and proceed. Most turns should NOT ask.\n- The user already specified the answer in their message.\n- You're just confirming you understood the task.\n\nKinds:\n- `kind: \"choice\"` — render as a row of buttons. Provide 2–5 short, parallel `choices` (≤40 chars each). Use for 'A or B?' picks where each option produces a meaningfully different output. Set `allow_freetext: true` to also offer an 'Other…' fallback.\n- `kind: \"yes_no\"` — render as Yes/No buttons. Use for confirming an assumption.\n- `kind: \"open\"` — render as a free-text input. Use only when the answer space is too broad for choices.\n\nRules:\n- Do NOT also restate the question in your text — the chip carries it.\n- Keep `question` to one short sentence.\n- For `choice`, choices should be substantively different (not 'fast' vs 'quickly').\n- Maximum one ask_user per turn — chains of questions feel like an interrogation.",
2707
+ inputSchema: z.object({
2708
+ question: z.string().describe("One short sentence. The user reads this on the chip."),
2709
+ kind: z.enum([
2710
+ "choice",
2711
+ "yes_no",
2712
+ "open"
2713
+ ]),
2714
+ choices: z.array(z.string().max(40)).min(2).max(5).describe("Required when kind='choice'. Short parallel labels (≤40 chars). Ignored otherwise.").optional(),
2715
+ allow_freetext: z.boolean().describe("When kind='choice', also show an 'Other…' free-text fallback. Default false.").optional()
2716
+ }).strict()
2717
+ },
2718
+ inspect_scene: {
2719
+ description: "Render PNG snapshots of the active Creator scene at one or more frames so you can SEE what's on the canvas — composition, colors, layout, animation poses. The tool result also includes the current `read_scene` layer state (ids, names, positions, scale, opacity, text metadata, selection, ordering, visual bounds, scene-center deltas, clipping/off-canvas flags, and an `occlusion` block on any layer hidden behind an opaque layer in front of it), so use that state instead of guessing positions or stack order from pixels alone. If a layer you expect to see has `occlusion.likely_hidden`, it is buried (e.g. a label behind a button fill) — do NOT report it as done; reorder it to the front with `reorder_layer`.\n\n**The result is structured issues-first:** (1) a VISIBILITY REPORT listing any occluded / off-canvas / empty / hidden layers (or a clean ✓), (2) a readable LAYER MAP in render order (#0 = front), (3) the rendered FRAMES, (4) the full raw layer data last. READ the visibility report first, then VERIFY against the frames — the images are the ground truth. A layer present in the data can still be invisible (occluded, off-canvas, transparent, same-color-on-same-color); never report a layer as done unless you can SEE it in a frame.\n\n**ALWAYS pass `frames` explicitly when the scene has motion — never rely on the default single-playhead snapshot.** Frame 0 alone catches almost nothing: most animations start with opacity:0 or off-screen position, so the snapshot looks empty. Use:\n\n- **One-shot reveal / pulse / state change**: `frames: [start, peak, resolve]` where `start` is just before the first beat fires, `peak` is the strongest mid-point of the hero motion (usually the end frame of the hero entrance), `resolve` is a few frames after the last beat ends. For a 0 → 22f card entrance with content cascading to f40, use `[0, 22, 45]`.\n- **Looping animation** (spinner / ambient pulse / rotation / breath): `frames: [0, end*0.25, end*0.5, end*0.75, end]` — five evenly-spaced frames across the loop. A single snapshot can never verify a seamless loop.\n\nWhen to call (REQUIRED):\n- **After any `apply_keyframes` call that produces motion** — additive or modifying. Use the 3-frame `[start, peak, resolve]` pattern. Yes, even for a 'subtle pulse on one layer' — a new pulse on a layer that's ALREADY animated can collide with the existing entrance motion, and you only catch that by looking. Skipping this is the #1 source of 'I declared done, user opens it and it's broken' failures.\n- **After any structural change** (new layer, deletion, fill swap, reorder).\n- The user describes a visual problem ('it looks off', 'too dark', 'overlapping') and you can't tell from `read_scene` alone.\n- The user references something visual ('move the logo to the left') and you need to see the current layout.\n\nWhen NOT to call:\n- Simple structural questions (\"what layers are on the canvas?\") — `read_scene` is cheaper.\n- Pure data ops with no visual change: `set_text` on an existing string, `read_scene` to check layer ids, scene metadata fetches.\n- Do NOT inspect mid-change between every micro-call. Batch your apply_keyframes / create_shape calls, THEN one 3-frame inspect at the end.\n\nDefaults:\n- `frames` omitted → snapshots the current playhead (RARELY what you want — see above).\n- `scale` omitted → 1× native scene size.\n- `background_color` omitted → transparent (the scene's own background renders if it has one).\n\nLimits: at most 6 frames per call, scale clamped to [0.25, 2].",
2720
+ inputSchema: z.object({
2721
+ frames: z.array(z.number()).min(1).max(6).describe("Frames to capture (in scene-frame units, same space as `read_scene` keyframes). Defaults to the current playhead.").optional(),
2722
+ scale: z.number().min(.25).max(2).describe("Multiplier on the scene's native size. Defaults to 1. Use 2 to read fine detail; ≤1 to save vision tokens.").optional(),
2723
+ background_color: z.string().describe("Optional CSS color (e.g. '#fff' or 'black'). Composited behind transparent scenes. Default: transparent.").optional()
2724
+ }).strict()
2725
+ },
2726
+ apply_bounce: {
2727
+ description: "Drop or lift a layer with physics-correct bounce keyframes. **Use this for any prompt mentioning bounce / drop / fall / land / gravity**. Computes a parametric trajectory: each successive bounce decays in BOTH amplitude and duration, which is the part hand-tuned bounces always miss (uniform-timing decay reads as wrong).\n\n**Position-only by design.** Squash-and-stretch is a separate concern — call `apply_squash` afterward if the user explicitly asked for a 'playful' / 'cartoon' / 'springy' bounce. A bare 'bounce' should not deform the layer's scale.\n\nDefaults pick a 'snappy but natural' bounce. Tune `elasticity` and `gravity` for feel:\n- elasticity 0.4 (stiff, settles fast) → 0.85 (cartoony, lots of bounces)\n- gravity 1500 (floaty) → 12000 (heavy)\n- max_bounces 4 is visible; >6 is sub-pixel\n\nReturns the impact frame numbers in the response under `impact_frames`. These frames are non-uniform (each bounce decays in BOTH amplitude and duration). Any follow-up tool that targets impacts — `apply_squash`, `apply_coupled_shadow` — **must consume this exact array from the response**. Do NOT estimate, do NOT recompute from elasticity/gravity, do NOT call those tools in parallel with `apply_bounce` (they need the response in scope). Sequence: `apply_bounce` → read response → `apply_squash` / `apply_coupled_shadow` with `impact_frames` from that response.",
2728
+ inputSchema: z.object({
2729
+ layer_id: z.string().describe("Layer to animate."),
2730
+ axis: z.enum(["x", "y"]).describe("Position axis to bounce on. Default 'y' (gravity-driven drop).").optional(),
2731
+ from: z.number().describe("Starting value for the chosen axis. Defaults to the layer's current static position on that axis.").optional(),
2732
+ to: z.number().describe("Resting value the bounce settles to. For a 'drop and bounce', this is the floor y (greater than `from`). For a 'lift in', it's the peak y (less than `from`). Required."),
2733
+ elasticity: z.number().min(0).max(.99).describe("Energy retained per bounce, 0-0.99. 0.65 is the playful default.").optional(),
2734
+ gravity: z.number().min(100).describe("Gravity in pixels/second². Default 4000.").optional(),
2735
+ max_bounces: z.number().min(1).max(12).describe("Max bounces to generate. Default 4.").optional(),
2736
+ start_frame: z.number().describe("Frame at which the bounce begins. Default 0.").optional(),
2737
+ clear_first: z.boolean().describe("Replace existing position keyframes. Default true.").optional()
2738
+ }).strict()
2739
+ },
2740
+ apply_squash: {
2741
+ description: "Add squash-and-stretch scale keyframes at specified impact frames. **Only call this when the user explicitly asks for 'playful' / 'cartoon' / 'springy' / 'squashy' / 'with squash and stretch' on top of a bounce.** A bare 'bounce' should NOT include squash — leave the layer's scale untouched.\n\n**CRITICAL — `impact_frames` MUST be the literal array returned by `apply_bounce`'s response. NEVER invent them, NEVER estimate them, NEVER guess at multiples of a frame number.** This tool is SEQUENTIALLY DEPENDENT on `apply_bounce`: emit `apply_bounce` first, READ its response's `impact_frames` array, then pass that exact array into this tool's `impact_frames` parameter. Do not emit both calls in parallel — `apply_squash` is meaningless without the bounce response in scope. The failure mode if you invent frames: squash fires AFTER the ball has already come to rest (impact frames must land exactly on the bounce arcs, which are non-uniform because each bounce decays in BOTH amplitude and duration — you cannot derive them from elasticity/gravity by hand).\n\nGiven the impact frames (always from `apply_bounce`'s response), emits a 5-keyframe envelope per impact: anticipation stretch (taller, narrower) → squash at impact (wider, shorter) → rebound stretch → settle. Decays the squash strength on successive impacts so the squash fades out as the bounce dies.\n\nThe scale.x/scale.y inversion is built in (squash widens X, narrows Y). Don't try to add this manually with apply_keyframes — the timing windows have to align with the bounce arcs or the squash flickers between bounces.",
2742
+ inputSchema: z.object({
2743
+ layer_id: z.string(),
2744
+ impact_frames: z.array(z.number()).min(1).describe("Frames where squash should fire. Use the `impact_frames` array returned by `apply_bounce`. Skipping the first impact for a 'bounces but doesn't squash on first contact' feel is fine."),
2745
+ ratio: z.number().min(1).max(2).describe("Squash extent at the FIRST impact. 1.15 = subtle, 1.3 = playful, 1.5 = cartoon. Default 1.3.").optional(),
2746
+ decay_per_impact: z.number().min(0).max(1).describe("Squash strength multiplier per successive impact. Default 0.7 — preserves visible squash for ~3 bounces.").optional(),
2747
+ min_spacing_frames: z.number().min(1).describe("Skip impacts whose preceding/following bounce is shorter than this many frames (their squash window would overlap neighbors). Default 5.").optional(),
2748
+ clear_first: z.boolean().describe("Replace existing scale keyframes. Default true.").optional()
2749
+ }).strict()
2750
+ },
2751
+ apply_coupled_shadow: {
2752
+ description: "Animate an existing shadow layer to track a bouncing object. **Always use this for shadows under bouncing balls** — it solves all five shadow bugs (A: position pinned at ball center, B: shadow wider than object, C: inverted scale direction, D: copying ball.scale onto shadow, E: redundant static keyframes) by computing every value from the ball's existing position keyframes via the rule:\n\n`shadow.position.y = ball.y_at_rest + ball_radius` (CENTER, not top)\n`shadow.scale.x = widthRatio × ball_diameter` (interpolates between apex and impact ratios; flat `scale.y`)\n`shadow.opacity = inverse of altitude` (impact = brightest, apex = faintest)\n\n**Two-layer stacked-solid fill.** A single solid black ellipse reads as a sticker; a radial-gradient ellipse currently can't export with per-stop alpha. Create TWO shadow ellipses (both circular at natural size, same dark slate solid fill `{r:15, g:23, b:42}`) and call `apply_coupled_shadow` once on each: an Outer 'Shadow Edge' layer (`width_ratio_at_impact: 1.35, width_ratio_at_apex: 0.6, opacity_at_impact: 0.30, opacity_at_apex: 0.10`) and an Inner 'Shadow Core' layer (`width_ratio_at_impact: 0.9, width_ratio_at_apex: 0.4, opacity_at_impact: 0.65, opacity_at_apex: 0.25`). Create the Outer FIRST so the Inner (created LAST) lands on TOP of it (Creator inserts new layers at the top of the stack).\n\n**REQUIRED FOLLOW-UPS — do not skip:**\n1. Call `add_blur` on EACH shadow layer to soften the edge. Use `sigma ≈ ball_diameter × 0.15` (e.g. 12 for an 80px ball, cap ~30). Without blur the shadow reads as a hard ellipse, not as a penumbra.\n2. Call `reorder_layer` on EACH shadow layer with `position: 'behind'` and `sibling_layer_id` = the ball's id, to push each shadow BEHIND the ball. New layers ship at the top of the render stack; the shadow needs to be sent back. Without this the shadow paints over the ball.\n\nSee `motion-patterns.md` → 'Shadow fill' for the full recipe.\n\nReads the ball's animated position to derive `restY` (max y) and impact frames automatically. The shadow ellipse is auto-resized to `ball_diameter × ball_diameter` natural size so scale percentages render at the right physical width — you don't have to compute scale = target_width / natural_width.\n\nPrerequisite: the ball must already have animated position (run `apply_bounce` on it first), and both the ball and the shadow must be ellipse SHAPE_LAYERS. **Create the shadow ellipse with CIRCULAR natural size — `{width: ball_diameter, height: ball_diameter}`. Do NOT pre-flatten the shape (e.g. height=24 for an 80px ball).** The flat-on-the-floor look is produced by this tool keyframing a low `scale.y` constant; if you pre-flatten the shape, the blur falloff zone falls outside the visible ellipse and the user sees a flat dark band. The shadow's fill is preserved through this tool (only size + position + scale + opacity are touched), so apply the fill via `set_fill` BEFORE calling this.",
2753
+ inputSchema: z.object({
2754
+ ball_layer_id: z.string().describe("The bouncing ball layer (must have animated position)."),
2755
+ shadow_layer_id: z.string().describe("The shadow ellipse layer. Will be resized to ball_diameter × ball_diameter and have its position + scale + opacity keyframed."),
2756
+ width_ratio_at_impact: z.number().min(.5).max(2).describe("Shadow width at impact, as a multiplier of ball diameter. 1.0 = matches ball, 1.1 = slightly wider (default), 1.3 = noticeably wider. Never > 1.5 or it stops looking like the ball's shadow.").optional(),
2757
+ width_ratio_at_apex: z.number().min(.1).max(1).describe("Shadow width at apex, as a multiplier of ball diameter. 0.45 default — visibly shrunk but not a dot.").optional(),
2758
+ flatness: z.number().min(.1).max(.5).describe("Shadow vertical extent as a multiplier of ball diameter. Default 0.3. Stays constant — the shadow doesn't pulse vertically.").optional(),
2759
+ opacity_at_impact: z.number().min(0).max(1).describe("0-1 ratio. Default 0.75 (75%). Bug guard: pass a ratio, NOT a percentage — values > 1 throw.").optional(),
2760
+ opacity_at_apex: z.number().min(0).max(1).describe("0-1 ratio. Default 0.30 (30%).").optional(),
2761
+ clear_first: z.boolean().describe("Replace existing scale + opacity keyframes on the shadow. Default true.").optional()
2762
+ }).strict()
2763
+ }
2764
+ };
2765
+ const { ask_user: askUserTool, inspect_scene: inspectSceneTool, ...gatewayTools } = allClientTools;
2766
+ const clientGatewayTools = gatewayTools;
2767
+ const clientUiTools = {
2768
+ ask_user: askUserTool,
2769
+ inspect_scene: inspectSceneTool
2770
+ };
2771
+ const clientTools = {
2772
+ ...clientGatewayTools,
2773
+ ...clientUiTools
2774
+ };
2775
+ const clientToolNames = Object.keys(allClientTools);
2776
+ const clientGatewayToolNames = Object.keys(clientGatewayTools);
2777
+ const clientUiToolNames = Object.keys(clientUiTools);
2778
+
2779
+ //#endregion
2780
+ //#region src/tools/native-tool.ts
2781
+ function successfulResult(result) {
2782
+ return { content: [{
2783
+ type: "text",
2784
+ text: typeof result === "string" ? result : JSON.stringify(result ?? null, null, 2) ?? "null"
2785
+ }] };
2786
+ }
2787
+ function errorResult(error) {
2788
+ return {
331
2789
  content: [{
332
2790
  type: "text",
333
- text: NO_CREATOR_CONNECTED_MESSAGE
2791
+ text: error instanceof Error ? error.message : String(error)
334
2792
  }],
335
2793
  isError: true
336
2794
  };
2795
+ }
2796
+ async function handleNativeTool(connectionManager, name, input) {
337
2797
  try {
338
- const response = await connectionManager.executeScript(script);
339
- return formatToolResult(response.logs, response.error);
2798
+ const response = await connectionManager.executeTool(name, input);
2799
+ if (response.error !== void 0) return errorResult(response.error);
2800
+ return successfulResult(response.result);
340
2801
  } catch (error) {
341
- return {
342
- content: [{
343
- type: "text",
344
- text: error instanceof Error ? error.message : String(error)
345
- }],
346
- isError: true
347
- };
2802
+ return errorResult(error);
348
2803
  }
349
2804
  }
350
2805
 
351
2806
  //#endregion
352
2807
  //#region src/mcp-server.ts
353
- async function fetchCreatorText(resourcePath) {
354
- const url = `${CREATOR_URL}${resourcePath}`;
355
- const response = await fetch(url);
356
- if (!response.ok) throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
357
- return response.text();
358
- }
359
- function textResult(text) {
360
- return { content: [{
361
- type: "text",
362
- text
363
- }] };
2808
+ const MCP_EXCLUDED_TOOL_NAMES = new Set(["export_lottie"]);
2809
+ function registerCreatorTools(server, connectionManager) {
2810
+ for (const name of clientGatewayToolNames) {
2811
+ if (MCP_EXCLUDED_TOOL_NAMES.has(name)) continue;
2812
+ const tool = clientGatewayTools[name];
2813
+ server.registerTool(name, {
2814
+ description: tool.description,
2815
+ inputSchema: tool.inputSchema
2816
+ }, async (input) => handleNativeTool(connectionManager, name, input));
2817
+ }
364
2818
  }
365
2819
  async function startMcpServer(connectionManager) {
366
2820
  const server = new McpServer({
367
2821
  name: PACKAGE_NAME,
368
2822
  version: PACKAGE_VERSION
369
2823
  });
370
- server.registerTool(RUN_SCRIPT_TOOL_NAME, {
371
- title: "Run Script in Creator",
372
- description: RUN_SCRIPT_TOOL_DESCRIPTION,
373
- inputSchema: { script: z.string().describe("JavaScript code to execute. Use console.log() to output results.") },
374
- annotations: {
375
- readOnlyHint: false,
376
- destructiveHint: false,
377
- idempotentHint: false,
378
- openWorldHint: false
379
- }
380
- }, async ({ script }) => {
381
- return handleRunScript(connectionManager, script);
382
- });
383
- server.registerTool(GET_API_DOC_TOOL_NAME, {
384
- title: "Get Creator API Documentation",
385
- description: GET_API_DOC_TOOL_DESCRIPTION,
386
- inputSchema: { page: z.number().int().min(1).default(1).describe("Page number (1-indexed). Start with 1 and keep calling until all pages are read.") },
387
- annotations: {
388
- readOnlyHint: true,
389
- destructiveHint: false,
390
- idempotentHint: true,
391
- openWorldHint: false
392
- }
393
- }, async ({ page }) => {
394
- try {
395
- return paginateText(await fetchCreatorText(TYPES_RESOURCE.path), page, GET_API_DOC_TOOL_NAME);
396
- } catch (error) {
397
- return {
398
- content: [{
399
- type: "text",
400
- text: error instanceof Error ? error.message : String(error)
401
- }],
402
- isError: true
403
- };
404
- }
405
- });
406
- server.registerTool(GET_RULES_TOOL_NAME, {
407
- title: "Get Creator Script Rules",
408
- description: GET_RULES_TOOL_DESCRIPTION,
409
- annotations: {
410
- readOnlyHint: true,
411
- destructiveHint: false,
412
- idempotentHint: true,
413
- openWorldHint: false
414
- }
415
- }, async () => {
416
- try {
417
- return textResult(await fetchCreatorText(RULES_RESOURCE.path));
418
- } catch (error) {
419
- return {
420
- content: [{
421
- type: "text",
422
- text: error instanceof Error ? error.message : String(error)
423
- }],
424
- isError: true
425
- };
426
- }
427
- });
2824
+ registerCreatorTools(server, connectionManager);
428
2825
  const transport = new StdioServerTransport();
429
2826
  await server.connect(transport);
430
2827
  logger.info("MCP server started on stdio");