What it does
You type a prompt. The agent writes a valid Bodymovin JSON — shape layers, keyframed transforms, bezier paths. You get a live preview with a scrubbable timeline, hover-scrub, copy JSON, one-click download. A thinking banner shows live phase hints while the agent writes.
No DB. No auth. State in localStorage. The whole thing runs in 60 seconds after you clone.
How it works
The agent is a single tool: render_lottie. It takes a prompt, returns {animation, name, description} — animation being a zod-validated Bodymovin JSON. That's it. One tool, one responsibility.
The app around it is Next.js 16 + Tailwind + lottie-react for playback, plus a custom canvas timeline for the scrub interaction. Shipping state is in localStorage — no persistence layer, no auth flow, nothing to deploy besides the agent itself.
The trick: handing Claude the schema inline
The system prompt is 40% Lottie cheatsheet. Bodymovin is a big spec and Claude's default knowledge of it is incomplete — but hand it the schema rules inline and it writes valid JSON first-shot.
The important pieces:
ty: 4for shape layerskstransforms witha: 0(static) ora: 1(animated) on each property- bezier paths for custom shapes
flfor fill,stfor stroke- colors as
[r, g, b, a]in 0..1 space, not 0..255 ip/opframe windows for when a layer is in/out
That's the entire trick. No embeddings, no RAG, no vector DB. Just a well-written system prompt and zod validation on the way out. Bodymovin v5.7+ compatible output, first-shot, most of the time.
Run it in 60 seconds
Clone the template as a standalone repo and deploy:
npx degit 21st-dev/21st-sdk-examples/lottie-generator my-lottie-agent
cd my-lottie-agent
npm install
npx @21st-sdk/cli deploy
npm run dev
That's the full setup. The cli deploy command pushes the agent to the 21st runtime; npm run dev starts the Next.js app that talks to it.
Customize it
Fork points:
- System prompt — swap the Lottie cheatsheet for a different spec (e.g. SVG animations, Rive JSON).
- Validation schema — the zod schema in the tool catches malformed output before it gets rendered. Tighten or loosen as needed.
- Timeline UI — the canvas scrubber is ~150 lines, easy to style or replace with a library.
- Export options — right now it's copy JSON + download. Add dotLottie export, PNG sequence, or video render.
What's next
Rive support is the obvious next step (asked for it in the replies). The same pattern — spec-rich system prompt + schema-validated tool — should work for any JSON-based animation format.
Source: 21st-dev/21st-sdk-examples/lottie-generator SDK: 21st SDK
Agent Template
The full agent — one zod-validated tool plus the Lottie-cheatsheet system prompt. Fork and replace the schema for a different JSON format.
import { agent, tool } from "@21st-sdk/agent"
import { z } from "zod"
const lottieSchema = z.object({
v: z.string(),
ip: z.number(),
op: z.number(),
w: z.number(),
h: z.number(),
fr: z.number(),
layers: z.array(z.any()),
assets: z.array(z.any()).optional(),
}).passthrough()
export default agent({
model: "claude-sonnet-4-6",
runtime: "claude-code",
permissionMode: "bypassPermissions",
maxTurns: 10,
systemPrompt: `You generate valid Bodymovin JSON (Lottie v5.7+) for short animations.
SCHEMA RULES:
- Shape layers use ty: 4, with ks (transform) containing a, p, s, r, o.
- Each transform property has a: 0 (static value in k) or a: 1 (keyframed, k is array of {t, s, e}).
- Colors are [r, g, b, a] in 0..1, not 0..255.
- Bezier paths: {i: [...], o: [...], v: [...], c: boolean}.
- Fill: {ty: "fl", c: {a: 0, k: [r,g,b,a]}, o: {a: 0, k: 100}}.
- Stroke: {ty: "st", c: ..., o: ..., w: {a: 0, k: px}}.
- ip / op define when a layer is in/out (frames).
- Top-level: v (version), ip (0), op (total frames), w, h, fr (framerate), layers [].
OUTPUT:
Call render_lottie with a complete JSON that plays standalone.`,
tools: {
render_lottie: tool({
description: "Render the generated Lottie animation in the preview.",
inputSchema: z.object({
animation: lottieSchema,
name: z.string(),
description: z.string(),
}),
execute: async ({ animation, name, description }) => {
return {
content: [
{
type: "text",
text: JSON.stringify({ animation, name, description }),
},
],
}
},
}),
},
})