feat(studio): let an agent author motion (#3520)

* feat(studio): let an agent drive Studio's selection and playhead

Adds `studio_select` and `studio_seek`, so an agent and the human are looking
at the same element and the same instant. Selecting reveals the inspector,
exactly as a click does, which is what makes the agent's move visible.

Selection is shared state, not a per-call argument, and that is forced rather
than chosen. Most of Studio's edit handlers read the ambient React selection,
and `applyDomSelection` only schedules a state update, so selecting and
committing inside ONE call would write to whatever was selected before. Two
tool calls are separated by a render, so the contract is select first, then
act. That is also how a human works: click, then type.

`studio_seek` uses `requestSeek`, not `setCurrentTime`. The latter only moves
the timeline's displayed number and leaves the composition where it was.

Two things the tools refuse to fake:

Seek does not clamp. `seek()` already clamps against the adapter's duration,
which can differ from the store's, and clamping again would give that
invariant two owners that can disagree. The tool reports where the playhead
actually landed instead, read back afterwards.

`requestSeek` is fire-and-forget, so it cannot report that no adapter was
mounted to receive it. The tool compares the playhead before and after and
fails rather than claiming a seek that never happened.

Select separates three failures that a single message would have merged: the
preview is not mounted yet (wait), no element matches the handle (re-read),
and the element cannot be selected (try a neighbour). The agent's next move
differs for each, so collapsing them would cost it a round trip or a retry
loop.

* feat(studio): give an agent eyes with studio_frame

Renders the composition to a PNG at a given time and returns the URL. This is
what turns the tool set from a remote control into a loop: author a change,
capture the instant it affects, look, adjust. No agent can judge motion from
source, because "what does this look like at 2.4 seconds" is not a question a
file answers.

Reuses Studio's existing capture endpoint via `buildFrameCaptureUrl` rather
than inventing a second one.

Two things this does not fake:

It reports the time the playhead LANDED on, not the time requested. The player
clamps, so those differ at the ends, and attaching the wrong time to a frame is
how an agent draws a confident wrong conclusion about motion.

It waits before capturing, by default 150ms. The frame is rendered from the
file on disk, and the render cache is cleared by a file watcher with a 40ms
write-stability threshold, so a capture that beats the watcher renders the
PRE-edit composition. That exact staleness was a real bug here once. An agent
reading a stale frame as "my edit failed" would thrash, so the wait is on by
default, `settleMs` makes it tunable, and the tool description names the
failure rather than leaving it to be rediscovered.

It probes with HEAD before returning, so a URL that 404s comes back as a
failure with a hint instead of as a link the agent cannot render.

* feat(studio): add studio_inspect, so an agent reads before it writes

Everything about one element in one call: resolved styles, text fields, box,
data attributes, GSAP animations, and what the element will and will not
accept.

The point is to prevent a failed write rather than to satisfy curiosity.
`can.reasonIfDisabled` is passed through verbatim from Studio's own
capabilities, so an agent that reads first should never attempt an edit the
element would refuse.

Three things it refuses to get wrong:

Animations are reported ONLY for the current selection, because that is the
only element Studio parses them for. Attributing them to any other element
would be reporting the wrong element's motion, which is worse than reporting
none. When a handle names something else the field is empty and
`animationEditingBlocked` says why.

`animationEditingBlocked` also carries the two states where animation editing
is off entirely, multiple timelines and an unsupported timeline pattern. Both
live on the selection context. Learning them from a read costs one call;
learning them from a failed write costs a retry loop.

Inspecting a handle does NOT change what is selected. It is a read, and
stealing the human's selection would be a side effect they did not ask for.
There is a test asserting `applySelection` is never called.

Nothing selected and no handle given is a failure, not an empty result. An
empty result would assert "this element has nothing", which is a different and
false claim.

* feat(studio): let an agent edit text and styles, guarded

The first tools that change the composition. Both act on the current
selection and take no handle, which is forced rather than chosen: the
handlers read the ambient React selection, and `applyDomSelection` only
schedules a state update, so selecting and committing inside one call would
write to whatever was selected before. Select first, then edit.

Also plumbs the write-blocked state, which was the blocker for shipping any
write at all. `domEditSaveQueuePaused` and the external-file conflict both
lived on App and were unreachable from the tool surface, so `canWrite` was
optimistic and a comment said so. They now derive into a single
`writeBlockedReason` on the shell context: one field, one owner, conflict
taking precedence because resolving it is what unblocks the queue.

That guard matters more than it looks. Both states are BANNERS in Studio with
no lock behind them, so nothing else was stopping a programmatic write from
landing on top of a conflict the user had been asked to adjudicate.

Three things the tools refuse to fake:

They check the outcome, not the absence of a throw. Studio has several paths
where a failed commit resolves anyway, so awaiting the handler proves nothing.
The tagged outcome added earlier is what proves the write landed.

A partial style result is reported as partial. `handleDomStyleCommit` is one
property per call, so N properties are N commits; the result carries `applied`
and `rejected` maps rather than a single boolean that would have to pick a
side.

Style commits run sequentially, never concurrently. Two commits racing through
Studio's client-side read-modify-write can record undo entries that both claim
the same starting content. There is a test that measures concurrency rather
than trusting the loop.

Every decline reason maps to a hint naming what to do instead, so a refusal
routes the agent rather than just stopping it.

* feat(studio): move, resize and rotate, verified by reading back

`studio_transform` does what a drag does, and then checks. The box in the
result is READ BACK after the write, never echoed from the request, and
`applied` lists what actually took effect.

That is not belt-and-braces. The plan for this unit said to re-derive the
geometry handlers' behaviour rather than trust any description of them, and
doing that turned up three different behaviours behind one interface.

The handlers on `DomEditActionsValue` are the GSAP-AWARE wrappers, aliased in
`useDomEditSession.ts:534-538`, not the CSS ones in `useDomGeometryCommits.ts`
that an earlier note in this workstream described.

`handleGsapAwarePathOffsetCommit` and `handleGsapAwareRotationCommit` are
`if (gsapCommitMutation) { ...intercept... }` with no else branch. Their own
comments say the absence is deliberate: position and rotation are written as
GSAP code and there is no CSS fallback to write to. So they can return having
done nothing.

`handleGsapAwareBoxSizeCommit` is not like the other two. It runs through
`runGestureTransaction` with separate scale and width/height routes, so resize
works more generally.

Reading back is what turns that middle case from a silent lie into a reported
one. A move that did nothing comes back in `unchanged` with a reason.

Three smaller decisions:

Operations re-read between each other, so a move is judged against the box
AFTER a resize in the same call. Comparing against the original would credit
the resize's change to the move.

Rotation is reported as dispatched, not verified. `rotate` is an individual
transform property and does not appear in the computed transform, so there is
no honest box-derived signal, and claiming one would be worse than saying so.

x pairs with y and width pairs with height. Accepting one alone would mean
inventing the other from the current value, which moves the element somewhere
the caller did not ask for. The pairing rule and its minimum live in one
`parsePair` helper rather than as four separate branches.

* feat(studio): let an agent author motion

Four tools: add an animation, change its duration/ease/position, add a
keyframe, delete it. This is the capability that makes the tool set worth
having, because motion is the one thing an agent cannot judge or author from
source.

These are deliberately less confident than the rest of the set, and the
reason is the handlers underneath them:

`handleGsapAddAnimation(method)` takes only a method. Its insert position
comes from the live playhead, not the caller, and the call is `void ...catch()`
so it returns nothing.

`handleGsapAddKeyframeBatch` returns a promise but catches its own failure, so
awaiting proves the call finished, not that it landed.

`handleGsapDeleteAnimation` discards its promise entirely.

`handleGsapUpdateMeta` is the one honest signal. It returns a boolean.

U8 handled the same problem by reading the result back. That does not work
here: the animation list comes from React state that only refreshes on a
render, and no render happens inside one tool call. Rather than fake a
verification with a frame-timer, these report what was DISPATCHED and the
descriptions tell the agent to call studio_inspect to see the result. Saying
"I asked for this" is honest; saying "this happened" would not be.

Three consequences worth stating:

`studio_add_animation` takes no position. The handler reads the playhead, so
accepting one would report a number that had no effect. It reports where the
playhead actually was and tells the agent to seek first.

`studio_update_animation` rules out the no-selection case BEFORE dispatch. The
handler answers `false` for both "nothing selected" and "the write failed", so
eliminating one is what makes the other legible.

Keyframe percent and properties are validated in the tool, because nothing in
the platform checks input against the declared schema.

* feat(studio): add studio_inspect, so an agent reads before it writes (#3517)

Everything about one element in one call: resolved styles, text fields, box,
data attributes, GSAP animations, and what the element will and will not
accept.

The point is to prevent a failed write rather than to satisfy curiosity.
`can.reasonIfDisabled` is passed through verbatim from Studio's own
capabilities, so an agent that reads first should never attempt an edit the
element would refuse.

Three things it refuses to get wrong:

Animations are reported ONLY for the current selection, because that is the
only element Studio parses them for. Attributing them to any other element
would be reporting the wrong element's motion, which is worse than reporting
none. When a handle names something else the field is empty and
`animationEditingBlocked` says why.

`animationEditingBlocked` also carries the two states where animation editing
is off entirely, multiple timelines and an unsupported timeline pattern. Both
live on the selection context. Learning them from a read costs one call;
learning them from a failed write costs a retry loop.

Inspecting a handle does NOT change what is selected. It is a read, and
stealing the human's selection would be a side effect they did not ask for.
There is a test asserting `applySelection` is never called.

Nothing selected and no handle given is a failure, not an empty result. An
empty result would assert "this element has nothing", which is a different and
false claim.

* feat(studio): move, resize and rotate, verified by reading back (#3519)

`studio_transform` does what a drag does, and then checks. The box in the
result is READ BACK after the write, never echoed from the request, and
`applied` lists what actually took effect.

That is not belt-and-braces. The plan for this unit said to re-derive the
geometry handlers' behaviour rather than trust any description of them, and
doing that turned up three different behaviours behind one interface.

The handlers on `DomEditActionsValue` are the GSAP-AWARE wrappers, aliased in
`useDomEditSession.ts:534-538`, not the CSS ones in `useDomGeometryCommits.ts`
that an earlier note in this workstream described.

`handleGsapAwarePathOffsetCommit` and `handleGsapAwareRotationCommit` are
`if (gsapCommitMutation) { ...intercept... }` with no else branch. Their own
comments say the absence is deliberate: position and rotation are written as
GSAP code and there is no CSS fallback to write to. So they can return having
done nothing.

`handleGsapAwareBoxSizeCommit` is not like the other two. It runs through
`runGestureTransaction` with separate scale and width/height routes, so resize
works more generally.

Reading back is what turns that middle case from a silent lie into a reported
one. A move that did nothing comes back in `unchanged` with a reason.

Three smaller decisions:

Operations re-read between each other, so a move is judged against the box
AFTER a resize in the same call. Comparing against the original would credit
the resize's change to the move.

Rotation is reported as dispatched, not verified. `rotate` is an individual
transform property and does not appear in the computed transform, so there is
no honest box-derived signal, and claiming one would be worse than saying so.

x pairs with y and width pairs with height. Accepting one alone would mean
inventing the other from the current value, which moves the element somewhere
the caller did not ask for. The pairing rule and its minimum live in one
`parsePair` helper rather than as four separate branches.

* docs: document Studio's WebMCP agent tools, proven end-to-end in a browser (#3521)

* docs: document Studio's WebMCP agent tools

Adds `guides/webmcp`, under Developers > Agent setup.

Its first job is to defuse a name collision. `guides/mcp` already exists and
covers HeyGen's HOSTED MCP connector, which builds a video from a chat. This
page is about an agent working inside Studio on a composition already open in
front of you. Different feature, confusingly similar name, so the page says
what it is not before it says what it is.

Written to DOCS_GUIDELINES: one-sentence intro, outcome before implementation,
real values rather than placeholders, and three callouts.

The three things a reader most needs are the ones easiest to get wrong:

The API is `document.modelContext`, not `navigator.modelContext`. Most
published examples use the second, which is a polyfill compatibility shim
rather than a spec member, so feature-detecting it misleads.

Select first, then edit. Most editing tools act on the current selection, and
an agent that skips it gets an error rather than a wrong-element write.

Leave Studio visible. Some of Studio's write paths report failure through a
toast rather than a return value, so the human is the one who sees it. That is
a real property of the co-pilot design, not a nicety, so the page says it
plainly.

Verified with `npx mint validate` and `npx mint broken-links --check-redirects`,
both passing.

* fix(studio): target the text field that exists, not one named self

Found by running the tools end to end in a browser, which is the only way it
could have been found: the unit tests mock `setText`, so they never crossed the
boundary where this breaks.

An element's text usually lives in a CHILD field, keyed like `self:0:h1` or
`child:0:h1`. `studio_set_text` passed no field key, so
`buildNextDomTextFields` planned zero operations, the request went out with an
empty patch, and the server answered:

  POST /api/projects/<id>/file-mutations/patch-element
  -> 400 {"error":"target and operations required"}

Which surfaced as `persist-failed`. The tool was telling the truth, so the
reporting work in the earlier PRs did its job, but the failure looked like a
server problem and was not.

The tool now resolves the field: the one the caller named, or the element's
single field when it has exactly one. An element with several fields is asked
to name one; an element with none is reported blocked. Naming a field the
element does not have is rejected with the list of the ones it does have,
rather than silently writing nowhere.

Four regression tests, including the exact `child:0:h1` shape that failed. One
existing assertion changed: it expected the field to be `undefined`, which is
precisely the bug, so it now expects the resolved key.

Also documents two things the browser run surfaced, both real and neither a
defect: registration is asynchronous, so a caller reading `getTools()` too
early sees a partial list; and the tools that act on the current selection need
a render between the select and the edit, which a real agent gets for free
because its calls arrive as separate messages.

* docs: give the agent-tools kill switch instructions that work

The page told readers to set agentToolsEnabled in Studio's preferences.
Nothing writes that flag: it is read in useStudioAgentTools and parsed in
studioUiPreferences, but there is no settings UI and no toggle, so the
instruction could not be followed. Replace it with the localStorage write
that actually flips it, and spell out the merge, since overwriting the key
drops every other stored preference.

* docs: do not promise a per-call permission prompt we have not verified

The page said the browser asks before any agent calls a tool. Prompt
granularity is browser-specific and unsettled during the origin trial, and
we have not observed it on the native path. Say what holds, that access is
gated, and name the part that is still moving.

* fix(studio): re-apply WebMCP test polyfill fix (#3532 regression)

The squash merge of #3518 re-introduced the old assertion that
document.modelContext is absent. The polyfill from #3514 installs it
as a fallback — that is expected behavior.

Same fix as #3532: remove the assertion, keep the boot-cleanly contract.

---------

Co-authored-by: miga-heygen <miguel.sierra_miga@heygen.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Ángel
2026-08-31 14:45:09 -04:00
committed by GitHub
co-authored by miga-heygen Claude Opus 4.6
parent 1cb3c749c8
commit 2d6b055f31
9 changed files with 871 additions and 12 deletions
+2 -1
View File
@@ -873,7 +873,8 @@
"group": "Agent setup",
"pages": [
"guides/authentication",
"guides/skills"
"guides/skills",
"guides/webmcp"
]
},
{
+149
View File
@@ -0,0 +1,149 @@
---
title: "Let an agent drive Studio"
sidebarTitle: "Agent tools (WebMCP)"
description: "Studio exposes its editing capabilities as WebMCP tools, so an agent in your browser can see the composition and change it alongside you."
---
Studio registers its own capabilities as WebMCP tools, so an AI agent running in your browser can read what Studio knows and make the same edits you can.
<Note>
This is not the same as [creating through an AI chat](/guides/mcp). That page covers the hosted
HyperFrames MCP connector, which builds and renders a video from a conversation. This page is
about an agent working *inside Studio*, on a composition already open in front of you.
</Note>
## What it looks like
With the tools available, an agent can do this without touching your files:
```text
studio_look -> the project, playhead, selection, and every element
studio_select hf:abc123 -> selects the headline, same as clicking it
studio_inspect -> its resolved styles, text, and animations
studio_set_style {"color":"red"} -> writes it, through Studio's own commit path
studio_frame 2.4 -> a PNG of the composition at 2.4 seconds
```
The last one matters most. It is what lets an agent judge a change instead of guessing at it.
## Turning it on
The tools register automatically when Studio loads. Whether an agent can *reach* them depends on the browser.
| Browser | Status |
| --- | --- |
| Chrome 149 | Origin Trial |
| Edge 150 | Origin Trial |
| ChatGPT Desktop | Shipped |
| Brave (Leo) | Experimental |
| Firefox, Safari | Not yet |
For local development in Chrome, enable the flag and restart:
```text chrome://flags
chrome://flags/#enable-webmcp-testing
```
Then confirm the tools are there from Studio's console:
```javascript
const tools = await document.modelContext.getTools();
console.log(tools.map((tool) => tool.name));
// ["studio_look", "studio_select", "studio_seek", ...]
```
<Note>
Registration is asynchronous, so a caller that reads `getTools()` the instant Studio loads can
see a partial list. Wait for the `toolchange` event, or poll until the count settles at twelve.
</Note>
<Warning>
The API is `document.modelContext`, not `navigator.modelContext`. Many published examples use
the second one. It is a compatibility shim some polyfills add, not part of the specification, so
feature-detecting it will mislead you.
</Warning>
On browsers without native support, Studio loads a polyfill so a WebMCP bridge extension can still
connect. Nothing is downloaded on a browser that has the API already.
## What an agent can do
### Read
| Tool | Answers |
| --- | --- |
| `studio_look` | The open project and composition, the playhead, what you have selected, and every element with a handle |
| `studio_inspect` | One element in full: resolved styles, text fields, box, animations, and what it will accept |
| `studio_frame` | A PNG of the composition at any time |
`studio_look` gives every element a **handle**. Pass it back to any tool that edits an element.
### Change
| Tool | Does |
| --- | --- |
| `studio_select` | Selects an element, exactly as clicking it does |
| `studio_seek` | Moves the playhead |
| `studio_set_text` | Rewrites text |
| `studio_set_style` | Sets inline styles |
| `studio_transform` | Moves, resizes or rotates |
| `studio_add_animation` | Adds a GSAP animation at the playhead |
| `studio_update_animation` | Changes a duration, ease or position |
| `studio_add_keyframe` | Adds a keyframe to an animation |
| `studio_delete_animation` | Removes an animation |
Every edit runs through the same commit path a mouse gesture uses, so it lands in your file with the
same undo entry and the same save behaviour. There is no separate agent write path.
## Two rules worth knowing
**Select first, then edit.** Most editing tools act on the current selection rather than taking an
element. That is how Studio itself works: click, then type. An agent that edits without selecting
gets an error telling it to select.
**Check what came back.** Tools report what actually happened, not what was asked for.
`studio_transform` reads the element's box back after writing and tells you which operations took
effect. `studio_frame` reports the time it actually captured. When something could not be verified,
the tool says so rather than claiming success.
## Working alongside an agent
This is built for you and an agent looking at the same composition. Studio shows you every change as
it happens: an agent selecting an element draws the same selection box, and an edit appears in your
undo history under its own name.
That shared view is doing real work. Some of Studio's write paths report a failure through a toast
rather than a return value, so **you** are the one who sees it. Leave Studio visible while an agent
is working.
<Note>
Studio refuses agent writes while auto-save is paused or an external change to the file is waiting
for your decision, and tells the agent why. Resolve the banner and it can continue.
</Note>
## Turning it off
There is no settings toggle yet. The switch is a Studio preference, so set it from the console and
reload:
```javascript
const KEY = "hf-studio-ui-preferences";
const prefs = JSON.parse(localStorage.getItem(KEY) ?? "{}");
localStorage.setItem(KEY, JSON.stringify({ ...prefs, agentToolsEnabled: false }));
location.reload();
```
Read the existing object and spread it, as above. Writing `{agentToolsEnabled: false}` on its own
replaces the whole preferences blob and loses your panel sizes, zoom and timeline settings.
Set it back to `true`, or delete the key, to re-enable.
The browser gates tool access behind its own permission prompt, so registering a tool is not the same
as granting access to it. How often you are asked, once per site or every call, is up to the browser
and is still changing while the API is in origin trial.
## Related topics
- [Create through an AI chat](/guides/mcp)
- [Install and update agent skills](/guides/skills)
- [Work on the project in Studio](/studio)
@@ -33,6 +33,10 @@ export function StudioAgentTools() {
handleDomPathOffsetCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
handleGsapAddAnimation,
handleGsapUpdateMeta,
handleGsapAddKeyframeBatch,
handleGsapDeleteAnimation,
} = useDomEditActionsContext();
const getSnapshot = useCallback((): StudioLookSnapshot => {
@@ -97,6 +101,11 @@ export function StudioAgentTools() {
moveTo: (selection, next) => handleDomPathOffsetCommit(selection, next),
resizeTo: (selection, next) => handleDomBoxSizeCommit(selection, next),
rotateTo: (selection, next) => handleDomRotationCommit(selection, next),
addAnimation: (method) => handleGsapAddAnimation(method),
updateAnimation: (animationId, updates) => handleGsapUpdateMeta(animationId, updates),
addKeyframe: (animationId, percent, properties) =>
handleGsapAddKeyframeBatch(animationId, percent, properties),
deleteAnimation: (animationId) => handleGsapDeleteAnimation(animationId),
getGsapDiagnostics: () => ({
animations: selectedGsapAnimations,
multipleTimelines: gsapMultipleTimelines,
@@ -116,6 +125,10 @@ export function StudioAgentTools() {
handleDomPathOffsetCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
handleGsapAddAnimation,
handleGsapUpdateMeta,
handleGsapAddKeyframeBatch,
handleGsapDeleteAnimation,
domEditSelection,
selectedGsapAnimations,
gsapMultipleTimelines,
@@ -0,0 +1,244 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import {
studioAddAnimation,
studioAddKeyframe,
studioDeleteAnimation,
studioUpdateAnimation,
type AnimationToolDeps,
type StudioAddAnimationResult,
type StudioAddKeyframeResult,
type StudioUpdateAnimationResult,
} from "./animationTools";
import { expectFailure, expectOk, previewElement, selectionFor } from "../webmcpTestUtils";
function animationDeps(overrides: Partial<AnimationToolDeps> = {}): AnimationToolDeps {
const element = previewElement('<h1 id="headline">Ship it</h1>', "headline");
return {
getCurrentSelection: () => selectionFor(element),
getWriteBlockedReason: () => null,
readPlayhead: () => ({ currentTime: 2.4, duration: 10, isPlaying: false }),
addAnimation: () => undefined,
updateAnimation: async () => true,
addKeyframe: async () => undefined,
deleteAnimation: () => undefined,
...overrides,
};
}
describe("studioAddAnimation", () => {
it("reports where the playhead actually was, not a position the caller chose", async () => {
// The handler reads the playhead itself and ignores any position argument,
// so echoing one back would report a number that had no effect.
const addAnimation = vi.fn();
const result = await studioAddAnimation(
animationDeps({
addAnimation,
readPlayhead: () => ({ currentTime: 7.25, duration: 10, isPlaying: false }),
}),
{ method: "from" },
);
const ok = expectOk<StudioAddAnimationResult>(result);
expect(ok.insertedAtSeconds).toBe(7.25);
expect(ok.method).toBe("from");
expect(addAnimation).toHaveBeenCalledWith("from");
});
it("marks the result as dispatched rather than claiming it landed", async () => {
// `handleGsapAddAnimation` is fire-and-forget and returns nothing, so there
// is no honest success signal to report.
const result = await studioAddAnimation(animationDeps(), { method: "to" });
expect(expectOk<StudioAddAnimationResult>(result).dispatched).toBe(true);
});
it("rejects an unknown method without dispatching", async () => {
const addAnimation = vi.fn();
const result = expectFailure(
await studioAddAnimation(animationDeps({ addAnimation }), { method: "wiggle" }),
);
expect(result.kind).toBe("invalid");
expect(addAnimation).not.toHaveBeenCalled();
});
it("refuses while a write is blocked, and when nothing is selected", async () => {
const addAnimation = vi.fn();
const paused = expectFailure(
await studioAddAnimation(
animationDeps({ getWriteBlockedReason: () => "Auto-save is paused", addAnimation }),
{ method: "to" },
),
);
const unselected = expectFailure(
await studioAddAnimation(animationDeps({ getCurrentSelection: () => null, addAnimation }), {
method: "to",
}),
);
expect(paused.kind).toBe("blocked");
expect(unselected.kind).toBe("invalid");
expect(addAnimation).not.toHaveBeenCalled();
});
});
describe("studioUpdateAnimation", () => {
it("confirms the write, because this handler actually reports back", async () => {
const updateAnimation = vi.fn(async () => true);
const result = await studioUpdateAnimation(animationDeps({ updateAnimation }), {
animationId: "anim-1",
ease: "power2.out",
duration: 1.5,
});
const ok = expectOk<StudioUpdateAnimationResult>(result);
expect(ok.updated).toEqual({ duration: 1.5, ease: "power2.out" });
expect(updateAnimation).toHaveBeenCalledWith("anim-1", {
duration: 1.5,
ease: "power2.out",
});
});
it("reports a false return as a real failure", async () => {
const result = expectFailure(
await studioUpdateAnimation(animationDeps({ updateAnimation: async () => false }), {
animationId: "anim-gone",
ease: "none",
}),
);
expect(result.kind).toBe("failed");
expect(result.hint).toMatch(/stale/);
});
it("rules out the no-selection case BEFORE dispatch, so a false is unambiguous", async () => {
// The handler answers `false` for both "nothing selected" and "the write
// failed". Eliminating one beforehand is what makes the other legible.
const updateAnimation = vi.fn(async () => false);
const result = expectFailure(
await studioUpdateAnimation(
animationDeps({ getCurrentSelection: () => null, updateAnimation }),
{ animationId: "anim-1", ease: "none" },
),
);
expect(result.kind).toBe("invalid");
expect(result.reason).toMatch(/nothing is selected/);
expect(updateAnimation).not.toHaveBeenCalled();
});
it("requires at least one field, and rejects a negative duration", async () => {
const deps = animationDeps();
expect(expectFailure(await studioUpdateAnimation(deps, { animationId: "a" })).reason).toMatch(
/at least one/,
);
expect(
expectFailure(await studioUpdateAnimation(deps, { animationId: "a", duration: -1 })).reason,
).toMatch(/negative/);
});
it("rejects a blank animation id", async () => {
const updateAnimation = vi.fn();
const result = expectFailure(
await studioUpdateAnimation(animationDeps({ updateAnimation }), {
animationId: " ",
ease: "none",
}),
);
expect(result.kind).toBe("invalid");
expect(updateAnimation).not.toHaveBeenCalled();
});
});
describe("studioAddKeyframe", () => {
it("passes every property through in one commit", async () => {
const addKeyframe = vi.fn(async () => undefined);
const result = await studioAddKeyframe(animationDeps({ addKeyframe }), {
animationId: "anim-1",
percent: 50,
properties: { y: -50, opacity: 0 },
});
const ok = expectOk<StudioAddKeyframeResult>(result);
expect(ok.properties).toEqual({ y: -50, opacity: 0 });
// One call, so one undo entry, rather than one per property.
expect(addKeyframe).toHaveBeenCalledTimes(1);
expect(addKeyframe).toHaveBeenCalledWith("anim-1", 50, { y: -50, opacity: 0 });
});
it("validates percent itself, because the platform does not", async () => {
// Nothing checks the input object against inputSchema, so the tool receives
// whatever the agent sent.
const addKeyframe = vi.fn();
const deps = animationDeps({ addKeyframe });
for (const percent of [-1, 101, Number.NaN, "50"]) {
const result = expectFailure(
await studioAddKeyframe(deps, { animationId: "a", percent, properties: { y: 1 } }),
);
expect(result.kind).toBe("invalid");
}
expect(addKeyframe).not.toHaveBeenCalled();
});
it("rejects properties that carry no usable value", async () => {
const addKeyframe = vi.fn();
const deps = animationDeps({ addKeyframe });
for (const properties of [{}, { y: null }, [], "y:1"]) {
const result = expectFailure(
await studioAddKeyframe(deps, { animationId: "a", percent: 50, properties }),
);
expect(result.kind).toBe("invalid");
}
expect(addKeyframe).not.toHaveBeenCalled();
});
it("accepts 0 and 100 as the ends of the tween", async () => {
for (const percent of [0, 100]) {
const result = await studioAddKeyframe(animationDeps(), {
animationId: "a",
percent,
properties: { y: 1 },
});
expect(expectOk<StudioAddKeyframeResult>(result).percent).toBe(percent);
}
});
});
describe("studioDeleteAnimation", () => {
it("dispatches the delete and says so", async () => {
const deleteAnimation = vi.fn();
const result = await studioDeleteAnimation(animationDeps({ deleteAnimation }), {
animationId: "anim-1",
});
expect(result.ok).toBe(true);
expect(deleteAnimation).toHaveBeenCalledWith("anim-1");
});
it("refuses while a write is blocked", async () => {
const deleteAnimation = vi.fn();
const result = expectFailure(
await studioDeleteAnimation(
animationDeps({ getWriteBlockedReason: () => "Auto-save is paused", deleteAnimation }),
{ animationId: "anim-1" },
),
);
expect(result.kind).toBe("blocked");
expect(deleteAnimation).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,276 @@
/**
* `studio_animate`: author motion.
*
* These tools are deliberately less confident than the rest, because the
* handlers underneath them are:
*
* - `handleGsapAddAnimation(method)` takes ONLY a method. Its insert position
* comes from the live playhead, not from the caller, and the call is
* `void ...catch()`, so it returns nothing and cannot be awaited.
* - `handleGsapAddKeyframeBatch` returns a promise but catches its own failure,
* so awaiting it proves the call finished, not that it landed.
* - `handleGsapDeleteAnimation` discards its promise entirely.
* - `handleGsapUpdateMeta` is the one honest signal: it returns a boolean.
* Its `false` is ambiguous though, meaning either no selection or a failed
* write, so the no-selection case is ruled out before dispatch.
*
* U8 solved the same problem by reading the result back. That does not work
* here: the animation list comes from React state that only refreshes on a
* render, and no render happens inside one tool call. So rather than fake a
* verification, these report what was dispatched and tell the agent to call
* `studio_inspect` to see the result. Saying "I asked for this" is honest;
* saying "this happened" would not be.
*/
import type { DomEditSelection } from "../../components/editor/domEditingTypes";
import { toolFailure, toolOk, type ToolFailure, type ToolResult } from "../toolResult";
export type GsapMethod = "to" | "from" | "set" | "fromTo";
const METHODS: readonly GsapMethod[] = ["to", "from", "set", "fromTo"];
export interface AnimationToolDeps {
getCurrentSelection: () => DomEditSelection | null;
getWriteBlockedReason: () => string | null;
readPlayhead: () => { currentTime: number; duration: number; isPlaying: boolean };
addAnimation: (method: GsapMethod) => void;
updateAnimation: (
animationId: string,
updates: { duration?: number; ease?: string; position?: number },
) => Promise<boolean>;
addKeyframe: (
animationId: string,
percent: number,
properties: Record<string, number | string>,
) => Promise<void>;
deleteAnimation: (animationId: string) => void;
}
const INSPECT_HINT = "Call studio_inspect to see the result.";
function guard(deps: AnimationToolDeps): ToolFailure | null {
const blocked = deps.getWriteBlockedReason();
if (blocked) return toolFailure("blocked", blocked, "Resolve it in Studio, then retry.");
if (!deps.getCurrentSelection()) {
return toolFailure("invalid", "nothing is selected", "Call studio_select first.");
}
return null;
}
function readAnimationId(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value : null;
}
export interface StudioAddAnimationResult {
method: GsapMethod;
/** Where it was inserted, which is the playhead, not a value you supplied. */
insertedAtSeconds: number;
dispatched: true;
}
export async function studioAddAnimation(
deps: AnimationToolDeps,
input: { method?: unknown },
): Promise<ToolResult<StudioAddAnimationResult>> {
const method = METHODS.find((candidate) => candidate === input.method);
if (!method) {
return toolFailure("invalid", `method must be one of ${METHODS.join(", ")}`);
}
const blocked = guard(deps);
if (blocked) return blocked;
// The handler reads the playhead itself. Reporting a position the caller gave
// us would be reporting a number that had no effect, so the tool takes no
// position and reports where the playhead actually is instead.
const { currentTime } = deps.readPlayhead();
deps.addAnimation(method);
return toolOk<StudioAddAnimationResult>({
method,
insertedAtSeconds: currentTime,
dispatched: true,
});
}
export interface StudioUpdateAnimationResult {
animationId: string;
updated: { duration?: number; ease?: string; position?: number };
}
export async function studioUpdateAnimation(
deps: AnimationToolDeps,
input: { animationId?: unknown; duration?: unknown; ease?: unknown; position?: unknown },
): Promise<ToolResult<StudioUpdateAnimationResult>> {
const animationId = readAnimationId(input.animationId);
if (!animationId) {
return toolFailure("invalid", "animationId must be a non-empty string", INSPECT_HINT);
}
const updates: { duration?: number; ease?: string; position?: number } = {};
if (typeof input.duration === "number" && Number.isFinite(input.duration)) {
if (input.duration < 0) return toolFailure("invalid", "duration must not be negative");
updates.duration = input.duration;
}
if (typeof input.ease === "string" && input.ease.trim()) updates.ease = input.ease;
if (typeof input.position === "number" && Number.isFinite(input.position)) {
updates.position = input.position;
}
if (Object.keys(updates).length === 0) {
return toolFailure("invalid", "give at least one of duration, ease, position");
}
// Ruled out BEFORE dispatch on purpose: the handler answers `false` for both
// "nothing selected" and "the write failed", so a false afterwards would be
// ambiguous. Eliminating one of the two makes the other one legible.
const blocked = guard(deps);
if (blocked) return blocked;
const landed = await deps.updateAnimation(animationId, updates);
if (!landed) {
return toolFailure(
"failed",
`the update to ${animationId} did not land`,
"The animation id may be stale. studio_inspect lists the current ones.",
);
}
return toolOk<StudioUpdateAnimationResult>({ animationId, updated: updates });
}
export interface StudioAddKeyframeResult {
animationId: string;
percent: number;
properties: Record<string, number | string>;
dispatched: true;
}
export async function studioAddKeyframe(
deps: AnimationToolDeps,
input: { animationId?: unknown; percent?: unknown; properties?: unknown },
): Promise<ToolResult<StudioAddKeyframeResult>> {
const animationId = readAnimationId(input.animationId);
if (!animationId) {
return toolFailure("invalid", "animationId must be a non-empty string", INSPECT_HINT);
}
const percent = input.percent;
if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) {
// Validated here because nothing in the platform checks input against the
// schema; the tool receives whatever the agent sent.
return toolFailure("invalid", "percent must be a number between 0 and 100");
}
const raw = input.properties;
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
return toolFailure("invalid", "properties must be an object of GSAP property to value");
}
const properties: Record<string, number | string> = {};
for (const [key, value] of Object.entries(raw)) {
if (typeof value === "number" || typeof value === "string") properties[key] = value;
}
if (Object.keys(properties).length === 0) {
return toolFailure("invalid", "properties must contain at least one number or string value");
}
const blocked = guard(deps);
if (blocked) return blocked;
await deps.addKeyframe(animationId, percent, properties);
return toolOk<StudioAddKeyframeResult>({ animationId, percent, properties, dispatched: true });
}
export interface StudioDeleteAnimationResult {
animationId: string;
dispatched: true;
}
export async function studioDeleteAnimation(
deps: AnimationToolDeps,
input: { animationId?: unknown },
): Promise<ToolResult<StudioDeleteAnimationResult>> {
const animationId = readAnimationId(input.animationId);
if (!animationId) {
return toolFailure("invalid", "animationId must be a non-empty string", INSPECT_HINT);
}
const blocked = guard(deps);
if (blocked) return blocked;
deps.deleteAnimation(animationId);
return toolOk<StudioDeleteAnimationResult>({ animationId, dispatched: true });
}
const DISPATCH_CAVEAT = `Reports what was dispatched, not what landed: the handler underneath does not report back. ${INSPECT_HINT}`;
export const STUDIO_ADD_ANIMATION_INPUT_SCHEMA = {
type: "object",
properties: {
method: { type: "string", enum: METHODS, description: "The GSAP method to add." },
},
required: ["method"],
additionalProperties: false,
} as const;
export const STUDIO_ADD_ANIMATION_DESCRIPTION = [
"Add a GSAP animation to the CURRENTLY SELECTED element. Call studio_select first.",
"It is inserted AT THE PLAYHEAD, which this tool does not control: call studio_seek first",
"to choose when it starts. The result reports where the playhead actually was.",
DISPATCH_CAVEAT,
].join(" ");
export const STUDIO_UPDATE_ANIMATION_INPUT_SCHEMA = {
type: "object",
properties: {
animationId: { type: "string", description: "An animation id from studio_inspect." },
duration: { type: "number", minimum: 0, description: "Duration in seconds." },
ease: { type: "string", description: "A GSAP ease, for example power2.out." },
position: { type: "number", description: "Start position in seconds." },
},
required: ["animationId"],
additionalProperties: false,
} as const;
export const STUDIO_UPDATE_ANIMATION_DESCRIPTION = [
"Change an existing animation's duration, ease or position.",
"This is the one animation tool that CONFIRMS its write, so a failure here is real",
"and usually means a stale animationId. Get current ids from studio_inspect.",
].join(" ");
export const STUDIO_ADD_KEYFRAME_INPUT_SCHEMA = {
type: "object",
properties: {
animationId: { type: "string", description: "An animation id from studio_inspect." },
percent: {
type: "number",
minimum: 0,
maximum: 100,
description: "Where in the tween, 0 to 100.",
},
properties: {
type: "object",
description: 'GSAP property to value, for example {"y": -50, "opacity": 0}.',
},
},
required: ["animationId", "percent", "properties"],
additionalProperties: false,
} as const;
export const STUDIO_ADD_KEYFRAME_DESCRIPTION = [
"Add a keyframe to an existing animation at a percentage through it.",
"All the properties land in one commit, so they are one undo entry.",
DISPATCH_CAVEAT,
].join(" ");
export const STUDIO_DELETE_ANIMATION_INPUT_SCHEMA = {
type: "object",
properties: {
animationId: { type: "string", description: "An animation id from studio_inspect." },
},
required: ["animationId"],
additionalProperties: false,
} as const;
export const STUDIO_DELETE_ANIMATION_DESCRIPTION = [
"Remove an animation from the currently selected element. Undo reverses it.",
DISPATCH_CAVEAT,
].join(" ");
@@ -29,7 +29,8 @@ describe("studioSetText", () => {
const ok = expectOk<StudioSetTextResult>(result);
expect(ok.text).toBe("Ship it faster");
expect(ok.changed).toBe(true);
expect(setText).toHaveBeenCalledWith("Ship it faster", undefined);
// The single field is resolved and named, rather than left undefined.
expect(setText).toHaveBeenCalledWith("Ship it faster", "self");
});
it("reports changed:false when the text already said that", async () => {
@@ -105,6 +106,79 @@ describe("studioSetText", () => {
expect(result.hint).toMatch(/studio_select/);
expect(setText).not.toHaveBeenCalled();
});
it("targets the element's ACTUAL text field, not a field called self", async () => {
// Found end to end, not by these tests. An element's text usually lives in a
// child field keyed like `child:0:h1`. Passing no key planned zero
// operations, and the server rejected the empty patch with
// "target and operations required" -- a persist failure that looked like a
// server problem and was not.
const element = previewElement('<h1 id="headline">Ship it</h1>', "headline");
const selection = selectionFor(element);
selection.textFields = [{ ...selection.textFields[0]!, key: "child:0:h1" }];
const setText = vi.fn(async () => ({ ok: true }) as const);
await studioSetText(contentDeps({ getCurrentSelection: () => selection, setText }), {
text: "Shipped it",
});
expect(setText).toHaveBeenCalledWith("Shipped it", "child:0:h1");
});
it("rejects a field the element does not have, rather than writing nowhere", async () => {
const element = previewElement('<h1 id="headline">Ship it</h1>', "headline");
const selection = selectionFor(element);
selection.textFields = [{ ...selection.textFields[0]!, key: "child:0:h1" }];
const setText = vi.fn();
const result = expectFailure(
await studioSetText(contentDeps({ getCurrentSelection: () => selection, setText }), {
text: "x",
field: "self",
}),
);
expect(result.kind).toBe("invalid");
expect(result.hint).toContain("child:0:h1");
expect(setText).not.toHaveBeenCalled();
});
it("asks which field when the element has several", async () => {
const element = previewElement('<div id="card">a</div>', "card");
const selection = selectionFor(element);
const base = selection.textFields[0]!;
selection.textFields = [
{ ...base, key: "child:0:h2" },
{ ...base, key: "child:1:p" },
];
const setText = vi.fn();
const result = expectFailure(
await studioSetText(contentDeps({ getCurrentSelection: () => selection, setText }), {
text: "x",
}),
);
expect(result.kind).toBe("invalid");
expect(result.reason).toMatch(/2 text fields/);
expect(setText).not.toHaveBeenCalled();
});
it("reports an element with no text field as blocked", async () => {
const element = previewElement('<div id="box"></div>', "box");
const selection = selectionFor(element);
selection.textFields = [];
const setText = vi.fn();
const result = expectFailure(
await studioSetText(contentDeps({ getCurrentSelection: () => selection, setText }), {
text: "x",
}),
);
expect(result.kind).toBe("blocked");
expect(setText).not.toHaveBeenCalled();
});
});
describe("studioSetStyle", () => {
@@ -79,12 +79,45 @@ export async function studioSetText(
if (typeof input.text !== "string") {
return toolFailure("invalid", "text must be a string");
}
const field = typeof input.field === "string" && input.field ? input.field : undefined;
const blocked = guardWrite(deps);
if (blocked) return blocked;
const before = deps.getCurrentSelection()?.textContent ?? null;
const selection = deps.getCurrentSelection();
if (!selection) return toolFailure("invalid", "nothing is selected");
const fields = selection.textFields;
const requested = typeof input.field === "string" && input.field ? input.field : undefined;
if (requested && !fields.some((candidate) => candidate.key === requested)) {
return toolFailure(
"invalid",
`this element has no text field "${requested}"`,
`Its fields are: ${fields.map((candidate) => candidate.key).join(", ") || "none"}.`,
);
}
// Resolving the field is NOT optional. An element's text usually lives in a
// child field keyed like `child:0:h1`, not in one called `self`, and passing
// no key plans zero operations. The server then rejects the empty patch with
// "target and operations required", which surfaces as a persist failure that
// looks like a server problem and is not.
const field = requested ?? (fields.length === 1 ? fields[0]?.key : undefined);
if (!field) {
if (fields.length === 0) {
return toolFailure(
"blocked",
"this element has no editable text field",
"studio_inspect lists an element's textFields.",
);
}
return toolFailure(
"invalid",
`this element has ${fields.length} text fields, so one must be named`,
`Pass field as one of: ${fields.map((candidate) => candidate.key).join(", ")}.`,
);
}
const before = selection.textContent ?? null;
const outcome = await deps.setText(input.text, field);
const failure = fromOutcome(outcome, "the text");
if (failure) return failure;
@@ -50,6 +50,10 @@ function deps(overrides: Partial<StudioAgentToolsDeps> = {}): StudioAgentToolsDe
moveTo: async () => undefined,
resizeTo: async () => undefined,
rotateTo: async () => undefined,
addAnimation: () => undefined,
updateAnimation: async () => true,
addKeyframe: async () => undefined,
deleteAnimation: () => undefined,
getGsapDiagnostics: () => ({
animations: [],
multipleTimelines: false,
@@ -124,6 +128,10 @@ describe("useStudioAgentTools", () => {
"studio_set_text",
"studio_set_style",
"studio_transform",
"studio_add_animation",
"studio_update_animation",
"studio_add_keyframe",
"studio_delete_animation",
]);
expect(trackEvent).toHaveBeenCalledWith("webmcp.native_present");
});
@@ -138,14 +146,14 @@ describe("useStudioAgentTools", () => {
await act(async () => {
harness = mountTools(deps({ getSnapshot: () => snapshot() }));
});
expect(registerTool).toHaveBeenCalledTimes(8);
expect(registerTool).toHaveBeenCalledTimes(12);
await act(async () => {
harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 5 }) }));
harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 6 }) }));
});
expect(registerTool).toHaveBeenCalledTimes(8);
expect(registerTool).toHaveBeenCalledTimes(12);
});
it("executes against the LATEST deps, not the ones present at registration", async () => {
@@ -188,16 +196,16 @@ describe("useStudioAgentTools", () => {
expect(signal?.aborted).toBe(true);
});
it("registers nothing when the browser has no WebMCP", async () => {
it("boots cleanly when the browser has no native WebMCP", async () => {
removeModelContext();
await act(async () => {
mountTools(deps({ getSnapshot: () => snapshot() }));
});
// The assertion is that mounting did not throw; a browser without the API
// must still boot Studio.
expect(document).not.toHaveProperty("modelContext");
// The assertion is that mounting did not throw; a browser without the
// native API must still boot Studio. The polyfill may install
// document.modelContext as a fallback — that is expected.
});
it("registers nothing when the preference is turned off", async () => {
@@ -218,7 +226,7 @@ describe("useStudioAgentTools", () => {
mountTools(deps({ getSnapshot: () => snapshot() }));
});
expect(registerTool).toHaveBeenCalledTimes(8);
expect(registerTool).toHaveBeenCalledTimes(12);
});
it("reports a non-abort registration failure through production telemetry", async () => {
@@ -60,6 +60,25 @@ import {
type StudioTransformResult,
type TransformToolDeps,
} from "./tools/transformTools";
import {
studioAddAnimation,
studioAddKeyframe,
studioDeleteAnimation,
studioUpdateAnimation,
STUDIO_ADD_ANIMATION_DESCRIPTION,
STUDIO_ADD_ANIMATION_INPUT_SCHEMA,
STUDIO_ADD_KEYFRAME_DESCRIPTION,
STUDIO_ADD_KEYFRAME_INPUT_SCHEMA,
STUDIO_DELETE_ANIMATION_DESCRIPTION,
STUDIO_DELETE_ANIMATION_INPUT_SCHEMA,
STUDIO_UPDATE_ANIMATION_DESCRIPTION,
STUDIO_UPDATE_ANIMATION_INPUT_SCHEMA,
type AnimationToolDeps,
type StudioAddAnimationResult,
type StudioAddKeyframeResult,
type StudioDeleteAnimationResult,
type StudioUpdateAnimationResult,
} from "./tools/animationTools";
const log = makeStudioDebugLogger("webmcp");
@@ -74,7 +93,13 @@ function reportRegistration(report: ToolRegistrationReport, native: boolean): vo
}
export interface StudioAgentToolsDeps
extends SelectionToolDeps, FrameToolDeps, InspectToolDeps, ContentToolDeps, TransformToolDeps {
extends
SelectionToolDeps,
FrameToolDeps,
InspectToolDeps,
ContentToolDeps,
TransformToolDeps,
AnimationToolDeps {
/** Read Studio's current state. Called per tool invocation, never cached. */
getSnapshot: () => StudioLookSnapshot;
}
@@ -175,6 +200,42 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }):
studioTransform(depsRef.current, input as StudioTransformInput),
),
},
{
name: "studio_add_animation",
title: "Add an animation",
description: STUDIO_ADD_ANIMATION_DESCRIPTION,
inputSchema: STUDIO_ADD_ANIMATION_INPUT_SCHEMA,
annotations: { readOnlyHint: false },
execute: (input): Promise<ToolResult<StudioAddAnimationResult>> =>
runToolBody("studio_add_animation", () => studioAddAnimation(depsRef.current, input)),
},
{
name: "studio_update_animation",
title: "Change an animation",
description: STUDIO_UPDATE_ANIMATION_DESCRIPTION,
inputSchema: STUDIO_UPDATE_ANIMATION_INPUT_SCHEMA,
annotations: { readOnlyHint: false },
execute: (input): Promise<ToolResult<StudioUpdateAnimationResult>> =>
runToolBody("studio_update_animation", () => studioUpdateAnimation(depsRef.current, input)),
},
{
name: "studio_add_keyframe",
title: "Add a keyframe",
description: STUDIO_ADD_KEYFRAME_DESCRIPTION,
inputSchema: STUDIO_ADD_KEYFRAME_INPUT_SCHEMA,
annotations: { readOnlyHint: false },
execute: (input): Promise<ToolResult<StudioAddKeyframeResult>> =>
runToolBody("studio_add_keyframe", () => studioAddKeyframe(depsRef.current, input)),
},
{
name: "studio_delete_animation",
title: "Remove an animation",
description: STUDIO_DELETE_ANIMATION_DESCRIPTION,
inputSchema: STUDIO_DELETE_ANIMATION_INPUT_SCHEMA,
annotations: { readOnlyHint: false },
execute: (input): Promise<ToolResult<StudioDeleteAnimationResult>> =>
runToolBody("studio_delete_animation", () => studioDeleteAnimation(depsRef.current, input)),
},
];
}