+
+ );
+}
diff --git a/remotion/mcp/scenes/tools.tsx b/remotion/mcp/scenes/tools.tsx
new file mode 100644
index 0000000..8260443
--- /dev/null
+++ b/remotion/mcp/scenes/tools.tsx
@@ -0,0 +1,22 @@
+import { Scene, useSceneFrame } from "../../product-demo/stage";
+import { setup } from "../content";
+import { SceneHead } from "../ui/chrome";
+import { ToolGrid } from "../ui/tool-grid";
+
+const REF = 260;
+
+/** The payoff: the tools the client now lists, by their registered names. */
+export function ToolsScene({ durationInFrames }: { durationInFrames: number }) {
+ const frame = useSceneFrame(REF, durationInFrames);
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/remotion/mcp/spot/beat-card-opens.tsx b/remotion/mcp/spot/beat-card-opens.tsx
new file mode 100644
index 0000000..e288119
--- /dev/null
+++ b/remotion/mcp/spot/beat-card-opens.tsx
@@ -0,0 +1,342 @@
+import { GitForkIcon } from "lucide-react";
+import {
+ AbsoluteFill,
+ Easing,
+ interpolate,
+ interpolateColors,
+ useCurrentFrame,
+} from "remotion";
+import { clamp } from "../../product-demo/stage";
+import { displayTracking, light, MONO, radius, SANS } from "../../product-demo/theme";
+import { Kicker, RAIL, snap, SPOT } from "./kit";
+import { agentRun, noteLines, skillFileHeading } from "./spot-content";
+
+/**
+ * The card, opening into the file.
+ *
+ * This was two beats and a hard cut: the card the search came back with, then
+ * the page inside it. The cut was the weak seam in the film, because the two
+ * frames are one fact and the audience was made to take delivery of it twice
+ * (maintainer feedback, 31/08). So they are one beat now, and the join is a
+ * match on the thing both frames already had in common: the name of the skill.
+ * `release-notes` is a single element on a single trajectory. It sits in the
+ * card at title size and it arrives at the top of the page at heading size,
+ * green, with a hash in front of it. It is set in the mono face at both ends,
+ * which is a change to the card: the display face was tried first and had to
+ * cross over to mono somewhere in the middle of the move, and a face crossing
+ * over is two words in the same place, which is the one thing this beat is
+ * supposed to have got rid of. A skill name is a file name anyway, and the
+ * source and the tags on that card were already mono. Nothing swaps and nothing
+ * dissolves into a copy of itself: the object moves, the frame holds still, and
+ * everything that was card falls away around it.
+ *
+ * The layout is written out in absolute pixels rather than left to the browser,
+ * because a match cut is only as good as the two coordinates it joins, and a
+ * gap that resolves to a different number at render time would put the name
+ * somewhere the eye can watch it jump.
+ */
+
+const { hit } = agentRun;
+
+/* Phase A: the card, at the size and the rail it always had. */
+const CARD_TOP = 279;
+const CARD_PAD_TOP = 52;
+const CARD_PAD_X = 56;
+const CARD_WIDTH = 1440;
+/** The row above the name, so the name's own top is a number and not a guess. */
+const SOURCE_HEIGHT = 40;
+const TITLE_GAP = 20;
+const TITLE_SIZE = 82;
+/** The box the name is lifted out of, held open in the card while it travels. */
+const TITLE_BOX = 92;
+const TITLE_LEFT = RAIL + CARD_PAD_X;
+const TITLE_TOP = CARD_TOP + CARD_PAD_TOP + SOURCE_HEIGHT + TITLE_GAP;
+
+/* Phase B: the page, on the same rail, with the green rule for a margin. */
+const KICKER_TOP = 234;
+const HEAD_TOP = 296;
+const HEAD_SIZE = 44;
+const COLUMN_LEFT = RAIL + 50;
+/** Geist Mono is a 0.6em face, so `# ` is two of those in front of the name. */
+const HEAD_LEFT = COLUMN_LEFT + Math.round(2 * 0.6 * HEAD_SIZE);
+const LINES_TOP = 373;
+const RULE_HEIGHT = 485;
+const CAPTION_TOP = 811;
+
+/* The clock: the card, then the move, then the page. */
+/** The frame the card starts opening on. */
+const OPEN = 88;
+/** How long the name takes to cross: long enough to follow, short enough to cut. */
+const TRAVEL = 22;
+/**
+ * The first instruction. It starts before the name has finished landing, on
+ * purpose: a page that waits politely for the heading to park reads as two
+ * events, and this beat is one.
+ */
+const LINES_AT = 106;
+const CAPTION_AT = 142;
+
+/** The length of the whole beat, so the table upstairs and this clock agree. */
+export const CARD_OPENS_LENGTH = 210;
+
+/**
+ * The move: off one rest and onto another, decisive through the middle, no
+ * overshoot at either end. A spring here would read as a bounce, and nothing in
+ * this film bounces.
+ */
+const travel = { ...clamp, easing: Easing.bezier(0.5, 0, 0.12, 1) };
+/** Everything that leaves: slow to let go, then gone. */
+const leave = { ...clamp, easing: Easing.bezier(0.4, 0, 0.7, 1) };
+
+export function CardOpensBeat() {
+ const frame = useCurrentFrame();
+
+ /** The card landing, at the top of the beat. */
+ const enter = interpolate(frame, [5, 15], [0, 1], snap);
+ const drop = (1 - enter) * 30;
+
+ /** The one trajectory. Position, size, and colour all ride on it. */
+ const t = interpolate(frame, [OPEN, OPEN + TRAVEL], [0, 1], travel);
+ const nameLeft = interpolate(t, [0, 1], [TITLE_LEFT, HEAD_LEFT]);
+ const nameTop = interpolate(t, [0, 1], [TITLE_TOP, HEAD_TOP]);
+ const nameSize = interpolate(t, [0, 1], [TITLE_SIZE, HEAD_SIZE]);
+ const nameColour = interpolateColors(t, [0, 1], [light.foreground, light.primary]);
+ /** The hash arrives on the way, not on landing. */
+ const hash = interpolate(frame, [OPEN + 9, OPEN + 17], [0, 1], snap);
+
+ /** The card, going. The border and the paper go with it. */
+ const chrome = interpolate(frame, [OPEN, OPEN + 12], [1, 0], leave);
+ /**
+ * Its rows collapse rather than fading where they stand, and all of them go
+ * the same way the name is going, so nothing crosses the name's path.
+ */
+ const above = interpolate(frame, [OPEN, OPEN + 10], [0, 1], leave);
+ const below = interpolate(frame, [OPEN + 2, OPEN + 12], [0, 1], leave);
+
+ /** The marker sweeps in under the teammate, and sweeps back out to leave. */
+ const marker =
+ interpolate(frame, [40, 50], [0, 1], snap) *
+ interpolate(frame, [OPEN, OPEN + 10], [1, 0], leave);
+
+ /** The green the marker gave up, drawing down the margin of the page. */
+ const rule = interpolate(frame, [OPEN + 8, OPEN + 20], [0, 1], snap);
+
+ /** The teammate, named again at the bottom of her own page. */
+ const caption = interpolate(frame, [CAPTION_AT, CAPTION_AT + 9], [0, 1], snap);
+
+ return (
+
+
+
+
+
+
+ {hit.source}
+
+
+ {/* The name is drawn over the card, not in it. Its box is held open here. */}
+
+
+
+ {noteLines.map((line, index) => {
+ const at = LINES_AT + index * 5;
+ const landed = interpolate(frame, [at, at + 9], [0, 1], snap);
+ const gap = line.key ? 12 : 14;
+
+ return (
+
+ {line.text}
+
+ );
+ })}
+
+
+
+ written by {hit.savedBy}
+
+
+ );
+}
diff --git a/remotion/mcp/spot/beats-console.tsx b/remotion/mcp/spot/beats-console.tsx
new file mode 100644
index 0000000..b959bac
--- /dev/null
+++ b/remotion/mcp/spot/beats-console.tsx
@@ -0,0 +1,50 @@
+import { CheckIcon } from "lucide-react";
+import { interpolate, useCurrentFrame } from "remotion";
+import { clamp } from "../../product-demo/stage";
+import { light } from "../../product-demo/theme";
+import { Beat, Kicker, mono, snap } from "./kit";
+import { agentRun, spotClient } from "./spot-content";
+
+/**
+ * The beat cut out of the window.
+ *
+ * The window itself gets six and a half seconds in the middle of the film, at
+ * which point everything in it is the right size to follow and the wrong size to
+ * read from the far side of a room. So the beat after it takes one object out of
+ * that window, sets it at billboard size against the paper, and holds it for one
+ * idea's worth of time. The strings are the ones the session uses. The skill
+ * that came back with the search is lifted the same way, in `beat-card-opens`.
+ */
+
+const { output } = agentRun;
+
+/**
+ * The confirmation, alone on the frame. One line, and the whole point of the
+ * run, so it gets a cut of its own rather than a corner of the window it was
+ * lifted out of.
+ */
+export function DoneBeat() {
+ const frame = useCurrentFrame();
+ const t = interpolate(frame, [6, 14], [0, 1], snap);
+
+ return (
+
+ {spotClient}
+
+
+
+ {output.done}
+
+
+
+ );
+}
diff --git a/remotion/mcp/spot/beats-desktop.tsx b/remotion/mcp/spot/beats-desktop.tsx
new file mode 100644
index 0000000..95092c1
--- /dev/null
+++ b/remotion/mcp/spot/beats-desktop.tsx
@@ -0,0 +1,400 @@
+import { ArrowUpIcon, CheckIcon, WrenchIcon } from "lucide-react";
+import type { CSSProperties, ReactNode } from "react";
+import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
+import { clamp } from "../../product-demo/stage";
+import { light, MONO, SANS } from "../../product-demo/theme";
+import { Land, snap, TypedLines } from "./kit";
+import {
+ agentRun,
+ desktop,
+ libraryHeading,
+ promptLines,
+ toolArgs,
+ toolName,
+} from "./spot-content";
+
+/**
+ * The one beat that shows the whole window.
+ *
+ * Every other beat in the spot is a fragment: one object lifted out of a client
+ * and set at billboard size, which is how a spot buys legibility. That trade has
+ * a cost, and the cost is that a viewer never sees where any of it happens. So
+ * the middle of the film gives the window back, whole, for six and a half
+ * seconds: the app the skill arrives in, the message a person actually sends,
+ * the tool block the client draws when an MCP server answers, and the composer
+ * at the bottom, which is the single element that makes a screenshot of this app
+ * recognisable from across a room.
+ *
+ * It is the only place in the film that leaves the brand palette. A chat client
+ * painted cream and green would be a mock of nothing; painted in its own warm
+ * ivory and terracotta it is a place, and the cut back to cream afterwards is
+ * what tells you the film is not the app.
+ */
+
+/** The app's own colours, which are not the brand's. */
+const CLAUDE = {
+ surface: "#F4F3EE",
+ raised: "#FBFAF7",
+ sunken: "#EFEDE4",
+ composer: "#FFFFFF",
+ ink: "#1F1E1D",
+ muted: "#6E6B64",
+ faint: "#8D8981",
+ line: "#E3E0D6",
+ bubble: "#E9E6DC",
+ accent: "#D97757",
+} as const;
+
+/** The window, inset far enough that the cream reads as a desk. */
+const WINDOW = { left: 108, top: 44, width: 1704, height: 992 } as const;
+const TITLE_BAR = 78;
+/** The conversation column, centred in the window the way the app centres it. */
+const COLUMN = 1360;
+
+const TRAFFIC = ["#FF5F57", "#FEBC2E", "#28C840"] as const;
+
+/**
+ * The sparkle in the title bar. Eight tapered rays out of one centre, which is
+ * the shape the app puts next to its name.
+ */
+function ClaudeMark({ size = 34 }: { size?: number }) {
+ return (
+
+ );
+}
+
+function TitleBar() {
+ return (
+
+
+ {TRAFFIC.map((dot) => (
+
+ ))}
+
+
+
+
+
+ {desktop.app}
+
+
+
+ );
+}
+
+/** The pill the client puts around the server a tool belongs to. */
+function Chip({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+const row: CSSProperties = { display: "flex", alignItems: "center" };
+
+/**
+ * The tool block. Header with the tool, the server it came from and a state on
+ * the right; the arguments under it in a sunken row; the result once the state
+ * has flipped.
+ */
+function ToolCall({ done, spin }: { done: number; spin: number }) {
+ return (
+
+ );
+}
+
+/** Frames the three release note rows arrive on. */
+const ROW_AT = [102, 112, 122] as const;
+
+export function DesktopBeat() {
+ const frame = useCurrentFrame();
+ const done = interpolate(frame, [74, 82], [0, 1], clamp);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {agentRun.output.title}
+
+
+
+
+ {agentRun.output.rows.map((line, index) => {
+ const at = ROW_AT[index];
+ const t = interpolate(frame, [at, at + 7], [0, 1], snap);
+
+ return (
+
+
+
+ {line}
+
+
+ );
+ })}
+
+
+
+
+
+
+ {agentRun.output.done}
+
+
+
+
+
+
+
+
+
+ {desktop.placeholder}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/remotion/mcp/spot/beats-library.tsx b/remotion/mcp/spot/beats-library.tsx
new file mode 100644
index 0000000..4b305dd
--- /dev/null
+++ b/remotion/mcp/spot/beats-library.tsx
@@ -0,0 +1,119 @@
+import { interpolate, useCurrentFrame } from "remotion";
+import { displayTracking, light, MONO, radius } from "../../product-demo/theme";
+import { Beat, display, Kicker, Land, snap } from "./kit";
+import {
+ agentRun,
+ libraryHeading,
+ librarySkillCount,
+ librarySkills,
+} from "./spot-content";
+
+/**
+ * The shelf.
+ *
+ * Without this beat the film is about release notes, because release notes is
+ * the only thing it ever shows. Five cards fix that in three seconds and without
+ * a sentence: a grid reads as a plural before anyone has finished the heading
+ * over it, and every card after this one is understood as one entry off a shelf
+ * rather than as the product. The names are set in the same mono the client uses
+ * for tool names, because that is what they are on disk.
+ *
+ * The first card is the one the rest of the film follows. It arrives with the
+ * others and then, once the grid has settled, takes a green edge, which is the
+ * whole handoff into the next cut.
+ */
+
+/** Three across, so five entries leave one gap and the grid reads as unfinished. */
+const CARD = { width: 512, height: 168 } as const;
+
+export function LibraryBeat() {
+ const frame = useCurrentFrame();
+ const mark = interpolate(frame, [46, 56], [0, 1], snap);
+
+ return (
+
+ {agentRun.server}
+
+
+
+
{libraryHeading}
+
+
+
+ {librarySkillCount}
+
+
+
+
+
+ {librarySkills.map((skill, index) => {
+ const lead = index === 0;
+
+ return (
+
+
+
+ {skill.name}
+
+
+
+
+ {skill.tag}
+
+
+
+ {lead ? (
+
+ ) : null}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/remotion/mcp/spot/beats-type.tsx b/remotion/mcp/spot/beats-type.tsx
new file mode 100644
index 0000000..ed1653b
--- /dev/null
+++ b/remotion/mcp/spot/beats-type.tsx
@@ -0,0 +1,209 @@
+import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
+import { displayTracking, light, MONO, radius, SANS } from "../../product-demo/theme";
+import { BrandMark } from "../../product-demo/ui/atoms";
+import { Beat, display, Land, RAIL, snap, SPOT } from "./kit";
+import {
+ agentRun,
+ applyLines,
+ brand,
+ endpointHeading,
+ hookLines,
+ reachLines,
+} from "./spot-content";
+
+/**
+ * The five beats that are pure type.
+ *
+ * These are the posters between the fragments: one sentence, set as large as
+ * the frame allows, on the same paper and the same rail as everything else.
+ * They land in a third of a second and then do not move again, because the cut
+ * is what carries the rhythm from here on.
+ */
+
+/** The job, before any mention of the product. */
+export function HookBeat() {
+ return (
+
+ {hookLines.map((line, index) => (
+ segment.text).join("")}
+ over={11}
+ >
+
+
+ ))}
+
+ );
+}
+
+/** What the run just proved, in the product's own words. */
+export function ApplyBeat() {
+ const [first, second] = applyLines;
+ const rest = first.slice(brand.wordmark.length);
+
+ return (
+
+
+
+ {brand.wordmark}
+ {rest}
+
+
+
+
{second}
+
+
+ );
+}
+
+/** The same skill, from anywhere that speaks the protocol. */
+export function ReachBeat() {
+ const frame = useCurrentFrame();
+
+ return (
+
+
+ {reachLines.map((line, index) => (
+
+
{line}
+
+ ))}
+
+
+
+ {agentRun.reach.clients.map((client, index) => {
+ const at = 30 + index * 6;
+ const t = interpolate(frame, [at, at + 8], [0, 1], snap);
+
+ return (
+
+ {client}
+
+ );
+ })}
+
+
+ );
+}
+
+/**
+ * The product, which is a URL. The one inverted frame in the spot: a plate of
+ * brand green wipes across the lower half and the endpoint sits on it in paper
+ * white, big enough to read off a phone at arm's length.
+ */
+export function EndpointBeat() {
+ const frame = useCurrentFrame();
+ const heading = interpolate(frame, [0, 10], [0, 1], snap);
+ const plate = interpolate(frame, [4, 14], [0, 1], snap);
+ const url = interpolate(frame, [13, 22], [0, 1], snap);
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/remotion/mcp/spot/index.tsx b/remotion/mcp/spot/index.tsx
new file mode 100644
index 0000000..54227a0
--- /dev/null
+++ b/remotion/mcp/spot/index.tsx
@@ -0,0 +1,84 @@
+import type { ReactNode } from "react";
+import { AbsoluteFill, Sequence } from "remotion";
+import "../../product-demo/fonts";
+import { light } from "../../product-demo/theme";
+import { CARD_OPENS_LENGTH, CardOpensBeat } from "./beat-card-opens";
+import { DoneBeat } from "./beats-console";
+import { DesktopBeat } from "./beats-desktop";
+import { LibraryBeat } from "./beats-library";
+import {
+ ApplyBeat,
+ EndCardBeat,
+ EndpointBeat,
+ HookBeat,
+ ReachBeat,
+} from "./beats-type";
+import { GreenWipe } from "./kit";
+
+/**
+ * The thirty three second spot.
+ *
+ * The long agent video is a demonstration: one window, one continuous session,
+ * every step in order and at reading speed. This is the other job. It is cut,
+ * not filmed. There is no camera move anywhere in it, because a screen recording
+ * that sways is a screen recording with a problem, not a film. What carries it
+ * instead is the thing spots have always been carried by: the cut, the size of
+ * the type, and how long each idea is allowed to stay on screen.
+ *
+ * Nine beats, hard cut, one idea each, and they answer two questions in that
+ * order. What is the product? A shelf of skills a team wrote down, which is why
+ * the second beat is a grid of five and not a sentence about one. Where does it
+ * land? In the client, which is why the middle of the film hands back the whole
+ * window instead of another fragment of one. The example run is release notes,
+ * but it does not start until the shelf it came off is already on screen.
+ *
+ * Three of the cuts are made with a band of brand green crossing the frame. The
+ * fourth join is not a cut at all: the card the search came back with opens into
+ * the file it stands for, carried across by the name of the skill, which is the
+ * one object on screen in both halves of that beat. Every fact is a string out
+ * of `../content`.
+ */
+
+export const MCP_SPOT_FPS = 30;
+
+/** Frames per beat, in order. They sum to `MCP_AGENT_SPOT_DURATION`. */
+const BEATS = [
+ { at: 0, length: 86, node: },
+ { at: 86, length: 110, node: },
+ { at: 196, length: 200, node: },
+ { at: 396, length: CARD_OPENS_LENGTH, node: },
+ { at: 606, length: 46, node: },
+ { at: 652, length: 76, node: },
+ { at: 728, length: 96, node: },
+ { at: 824, length: 78, node: },
+ { at: 902, length: 112, node: },
+] as const satisfies readonly { at: number; length: number; node: ReactNode }[];
+
+/** 1014 frames at 30fps: thirty three seconds and four fifths. */
+export const MCP_AGENT_SPOT_DURATION = 1014;
+
+/** How long a wipe takes to cross, and where its midpoint sits. */
+const WIPE = 12;
+/** The three cuts the green band makes: into the client, into the payoff, into the endpoint. */
+const WIPE_CUTS = [196, 652, 824] as const;
+
+/** The frame the poster would be cut from: the whole window, answer and all. */
+export const MCP_AGENT_SPOT_POSTER_FRAME = 380;
+
+export function SkillsboardMcpAgentSpot() {
+ return (
+
+ {BEATS.map((beat) => (
+
+ {beat.node}
+
+ ))}
+
+ {WIPE_CUTS.map((cut) => (
+
+
+
+ ))}
+
+ );
+}
diff --git a/remotion/mcp/spot/kit.tsx b/remotion/mcp/spot/kit.tsx
new file mode 100644
index 0000000..87953b4
--- /dev/null
+++ b/remotion/mcp/spot/kit.tsx
@@ -0,0 +1,255 @@
+import type { CSSProperties, ReactNode } from "react";
+import { AbsoluteFill, Easing, interpolate, useCurrentFrame } from "remotion";
+import { clamp } from "../../product-demo/stage";
+import { displayTracking, light, MONO, SANS } from "../../product-demo/theme";
+
+/**
+ * The grammar of the spot.
+ *
+ * A spot is cut, not flown: there is no camera here, no push, no drift, and
+ * nothing that keeps moving after it has arrived. Everything on this page either
+ * lands inside a third of a second and then holds perfectly still, or wipes the
+ * frame clean for the next cut. Every beat is built on one left rail, so ten
+ * hard cuts read as one film instead of ten slides.
+ */
+
+/** The rail every beat aligns to, and the matching air on the right. */
+export const RAIL = 150;
+
+/** The frame the beats are composed inside. */
+export const SPOT = { width: 1920, height: 1080 } as const;
+
+/** Entrances: fast out of the gate, dead stop at the end, no overshoot. */
+export const snap = { ...clamp, easing: Easing.bezier(0.16, 1, 0.3, 1) };
+/** Wipes: symmetric, because a wipe is a machine, not a gesture. */
+export const wipeEase = { ...clamp, easing: Easing.bezier(0.4, 0, 0.2, 1) };
+
+export const display: CSSProperties = {
+ fontFamily: SANS,
+ fontWeight: 600,
+ letterSpacing: displayTracking,
+ lineHeight: 1.08,
+ color: light.foreground,
+};
+
+export const mono: CSSProperties = {
+ fontFamily: MONO,
+ fontWeight: 400,
+ color: light.foreground,
+};
+
+/** The cream stage. Fixed size, left rail, vertically centred column. */
+export function Beat({
+ children,
+ align = "flex-start",
+ background = light.background,
+ gap = 0,
+}: {
+ children: ReactNode;
+ align?: CSSProperties["alignItems"];
+ background?: string;
+ gap?: number;
+}) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+/**
+ * One element arriving. Twelve frames from invisible and slightly low to placed
+ * and still; after `at + over` the style is constant, which is what makes the
+ * hold read as a held frame rather than as a slow move.
+ */
+export function Land({
+ at,
+ children,
+ from = 26,
+ over = 10,
+ style,
+}: {
+ at: number;
+ children: ReactNode;
+ from?: number;
+ over?: number;
+ style?: CSSProperties;
+}) {
+ const frame = useCurrentFrame();
+ const t = interpolate(frame, [at, at + over], [0, 1], snap);
+
+ return (
+
+ {children}
+
+ );
+}
+
+/** The label that names which surface a fragment was cut out of. */
+export function Kicker({ at, children }: { at: number; children: ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
+
+/** A green rule that draws from the rail outwards and then stops. */
+export function Rule({
+ at,
+ color = light.primary,
+ height = 6,
+ over = 10,
+ width,
+}: {
+ at: number;
+ color?: string;
+ height?: number;
+ over?: number;
+ width: number;
+}) {
+ const frame = useCurrentFrame();
+ const t = interpolate(frame, [at, at + over], [0, 1], snap);
+
+ return (
+
+ );
+}
+
+const BLINK = 12;
+
+/** Solid while characters are landing, blinking while the line waits. */
+function caretOn(frame: number, typing: boolean) {
+ return typing || Math.floor(frame / BLINK) % 2 === 0;
+}
+
+function Caret({ color, size }: { color: string; size: number }) {
+ return (
+
+ );
+}
+
+/**
+ * A monospaced sentence arriving one character at a time across fixed rows.
+ * The rows are fixed on purpose: a real console reflows while you type, and a
+ * word jumping down a line mid take is the one thing a spot cannot afford.
+ */
+export function TypedLines({
+ at,
+ caret = light.primary,
+ frame,
+ lines,
+ perChar,
+ size,
+ style,
+}: {
+ at: number;
+ /** The block that follows the last character. Green on paper, warm in a client. */
+ caret?: string;
+ frame: number;
+ lines: readonly string[];
+ perChar: number;
+ size: number;
+ style?: CSSProperties;
+}) {
+ const total = lines.reduce((sum, line) => sum + line.length, 0);
+ const shown = Math.floor(
+ interpolate(frame, [at, at + total * perChar], [0, total], clamp),
+ );
+ const typing = frame >= at && shown < total;
+ let consumed = 0;
+
+ return (
+
+ );
+}
+
+/**
+ * A band of brand green that crosses the frame and takes the cut with it. It
+ * covers the whole frame for exactly one frame at its midpoint, which is where
+ * the beat underneath changes, so the audience never sees a dissolve.
+ */
+export function GreenWipe({ durationInFrames }: { durationInFrames: number }) {
+ const frame = useCurrentFrame();
+ const half = durationInFrames / 2;
+ const on = interpolate(frame, [0, half], [0, 1], wipeEase);
+ const off = interpolate(frame, [half, durationInFrames], [0, 1], wipeEase);
+
+ return (
+
+ );
+}
diff --git a/remotion/mcp/spot/spot-content.ts b/remotion/mcp/spot/spot-content.ts
new file mode 100644
index 0000000..c3e2f0a
--- /dev/null
+++ b/remotion/mcp/spot/spot-content.ts
@@ -0,0 +1,146 @@
+/**
+ * The strings the spot puts on screen.
+ *
+ * Almost everything is re-exported from `../content`, which is the file the two
+ * long MCP videos already read from, so the spot cannot claim anything the
+ * longer cuts do not. What is added here is line breaking: a spot sets type at
+ * billboard size, and a sentence that fits one console row has to be broken by
+ * hand to fit one frame. The breaks are computed from the source sentence, so a
+ * copy change upstream moves the break instead of silently going off screen.
+ */
+
+import { agentRun, brand, setup } from "../content";
+
+export { agentRun, brand };
+
+/** Breaks `text` at the last space at or before `at`, keeping both halves whole. */
+function splitLine(text: string, at: number): [string, string] {
+ const cut = text.lastIndexOf(" ", at);
+
+ return cut < 0 ? [text, ""] : [text.slice(0, cut), text.slice(cut + 1)];
+}
+
+/**
+ * The opening card. The one sentence written for the spot rather than lifted
+ * from the product: it names the habit the whole film is about and stays clear
+ * of the example, which does not arrive for another eight seconds.
+ */
+export interface Segment {
+ text: string;
+ accent?: boolean;
+}
+
+export const hookLines: Segment[][] = [
+ [{ text: "Every team has " }, { text: "its own way", accent: true }],
+ [{ text: "of doing things." }],
+];
+
+/**
+ * The library beat.
+ *
+ * The product is the shelf, not the one skill the example run pulls off it, so
+ * the shelf has to be on screen before the run starts. The team is the invented
+ * one the long videos already use, and the five entries under it are invented
+ * too: they are there to show a plural, and none of them carries a product
+ * claim. `release-notes` is the entry the rest of the film follows, which is why
+ * it is listed first and why it is the one that gets marked.
+ */
+export const libraryHeading = `${agentRun.hit.team} team library`;
+
+export const librarySkills = [
+ { name: agentRun.hit.title, tag: agentRun.hit.tags[0] },
+ { name: "pr-review", tag: "code" },
+ { name: "bug-triage", tag: "support" },
+ { name: "onboarding-guide", tag: "docs" },
+ { name: "customer-feedback", tag: "research" },
+] as const;
+
+/** The count under the heading, kept true to the grid above it. */
+export const librarySkillCount = `${librarySkills.length} skills saved by the team`;
+
+/** What the card fragment is a fragment of, once the library has been seen. */
+
+/**
+ * The client the spot films.
+ *
+ * The long agent video films `agentRun.client`; the spot films the desktop app,
+ * because a chat window reads at broadcast size in a way a shell prompt does
+ * not. It is the second name on the list the closing chips already carry, so the
+ * film never shows a client the product has not named.
+ */
+export const spotClient = agentRun.reach.clients[1];
+
+/** The furniture of that window: app chrome, not product copy. */
+export const desktop = {
+ app: "Claude",
+ placeholder: "Reply to Claude...",
+ running: "Running",
+ done: "Completed",
+} as const;
+
+const callOpens = agentRun.call.indexOf("(");
+
+/** `search_skills`, taken off the front of the call the long video prints. */
+export const toolName = agentRun.call.slice(0, callOpens);
+
+/** The argument of that same call, printed the way a client prints JSON. */
+export const toolArgs = agentRun.call.slice(callOpens + 1, -1).replace(/(\w+):/, '"$1":');
+
+/** The task, as the person at the keyboard types it, over two rows. */
+export const promptLines = splitLine(agentRun.prompt, 32);
+
+/** One instruction inside the skill file. `key` is one of the two the run used. */
+export interface NoteLine {
+ text: string;
+ key?: boolean;
+}
+
+/** The teammate's note, one sentence per row, as `../content` keeps it. */
+const savedNote = agentRun.note
+ .split(". ")
+ .map((part, index, all) => (index < all.length - 1 ? `${part}.` : part));
+
+/**
+ * The instructions inside the skill file.
+ *
+ * `../content` keeps the note at the two sentences the long videos print, and it
+ * stays that way: those two are the ones the run visibly obeys, so a longer
+ * string there would put words in the output the output never uses. A file with
+ * two lines in it is not a file anybody wrote, though, so the spot shows the
+ * rest of the page around them (maintainer feedback, 31/08). The two saved
+ * sentences come straight out of the note and keep their full size; the other
+ * three are set smaller and quieter, which is how a real page reads: you see the
+ * shape of a list and you read the lines that matter.
+ */
+export const noteLines: NoteLine[] = [
+ { text: "Start from the merged pull requests since the last tag." },
+ { key: true, text: savedNote[0] },
+ { key: true, text: savedNote[1] },
+ { text: "Keep every entry under twelve words." },
+ { text: "Skip internal refactors unless they change behavior." },
+];
+
+/**
+ * The thesis beat. The film says what the product is with the canonical
+ * definition from the marketing contract, not the "note" line: after the
+ * release-notes de-emphasis the word note made the product read as a
+ * note-taking tool (maintainer feedback, 31/08).
+ */
+export const applyLines = [
+ "Skills Board, the web app where a team",
+ "keeps and shares its AI skills.",
+] as const;
+
+/**
+ * The skill file heading, kept in two pieces. The name is the one object the
+ * fused beat moves: it starts as the title of the card and ends as the heading
+ * of the file, so it cannot be a single baked string with the hash already in
+ * it. The hash arrives on its own, while the name is still travelling.
+ */
+export const skillFileHeading = { hash: "# ", name: agentRun.hit.title } as const;
+
+/** `agentRun.reach.line`, broken after the comma. */
+export const reachLines = splitLine(agentRun.reach.line, 22);
+
+/** The instruction over the endpoint plate, the same one the setup video uses. */
+export const endpointHeading = setup.config.heading;
diff --git a/remotion/mcp/ui/agent-console.tsx b/remotion/mcp/ui/agent-console.tsx
new file mode 100644
index 0000000..b07adb8
--- /dev/null
+++ b/remotion/mcp/ui/agent-console.tsx
@@ -0,0 +1,417 @@
+import { CheckIcon, GitForkIcon } from "lucide-react";
+import type { CSSProperties, ReactNode } from "react";
+import { interpolate } from "remotion";
+import { clamp, outEasing } from "../../product-demo/stage";
+import { displayTracking, light, MONO, radius } from "../../product-demo/theme";
+import { agentRun } from "../content";
+
+/**
+ * The client window for the agent video, built on the same card the setup video
+ * uses for `mcp.json`: one hairline border, a bar with three dots, and the paper
+ * background. One session runs inside it from the first keystroke to the last
+ * line, so the window is measured rather than laid out loosely: every block
+ * declares its height, and the scene scrolls the column by the exact difference
+ * between the content and the viewport.
+ */
+export const CONSOLE_WIDTH = 1400;
+export const CONSOLE_VIEWPORT = 720;
+export const STREAM_GAP = 22;
+export const STREAM_PAD_TOP = 28;
+export const STREAM_PAD_BOTTOM = 30;
+
+export const BLOCK_HEIGHT = {
+ prompt: 42,
+ call: 42,
+ hit: 250,
+ note: 92,
+ output: 240,
+} as const;
+
+const BODY_SIZE = 27;
+/** Frames the caret spends on, then off, while nothing is being typed. */
+const BLINK = 15;
+
+/** How many characters of `text` have landed by `frame`. */
+export function typedCount(text: string, frame: number, at: number, perChar: number) {
+ return Math.floor(
+ interpolate(frame, [at, at + text.length * perChar], [0, text.length], clamp),
+ );
+}
+
+function Caret() {
+ return (
+
+ );
+}
+
+/**
+ * A caret is solid while the line it sits on is being typed and blinks when the
+ * session is waiting, which is how a client renders it.
+ */
+function caretVisible(frame: number, typing: boolean) {
+ return typing || Math.floor(frame / BLINK) % 2 === 0;
+}
+
+/** Text that lands one character at a time, with the caret riding the end. */
+export function Typed({
+ at,
+ caret = false,
+ frame,
+ perChar,
+ style,
+ text,
+}: {
+ at: number;
+ caret?: boolean;
+ frame: number;
+ perChar: number;
+ style?: CSSProperties;
+ text: string;
+}) {
+ const shown = typedCount(text, frame, at, perChar);
+ const typing = frame >= at && shown < text.length;
+
+ return (
+
+ {text.slice(0, shown)}
+ {caret && caretVisible(frame, typing) ? : null}
+
+ );
+}
+
+/** One measured row of the session, so the scroll arithmetic stays exact. */
+export function StreamBlock({
+ children,
+ height,
+ style,
+}: {
+ children: ReactNode;
+ height: number;
+ style?: CSSProperties;
+}) {
+ return
{children}
;
+}
+
+/** The bar names the connection the way a client does, endpoint included. */
+function ConsoleBar() {
+ return (
+
+ );
+}
+
+/** The window itself. `scroll` moves the column, never the frame. */
+export function Console({
+ children,
+ frame,
+ scroll,
+ width = CONSOLE_WIDTH,
+}: {
+ children: ReactNode;
+ frame: number;
+ scroll: number;
+ width?: number;
+}) {
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+}
+
+/** The task, typed by the person at the keyboard. */
+export function PromptRow({
+ at,
+ caret,
+ frame,
+ perChar,
+}: {
+ at: number;
+ caret: boolean;
+ frame: number;
+ perChar: number;
+}) {
+ return (
+
+ >{" "}
+
+
+ );
+}
+
+/** The call the agent types back, with the tool name lifted out of the arguments. */
+export function CallRow({
+ at,
+ caret,
+ frame,
+ perChar,
+}: {
+ at: number;
+ caret: boolean;
+ frame: number;
+ perChar: number;
+}) {
+ const call = agentRun.call;
+ const shown = typedCount(call, frame, at, perChar);
+ const typing = frame >= at && shown < call.length;
+ const split = call.indexOf("(");
+ const name = call.slice(0, Math.min(shown, split));
+ const args = shown > split ? call.slice(split, shown) : "";
+
+ return (
+ = at ? 1 : 0 }}>
+
+ {"· "}
+ {name}
+ {args}
+ {caret && caretVisible(frame, typing) ? : null}
+
+
+ );
+}
+
+/** The one skill the search returns, with the source it was saved from. */
+export function HitCard({ at, frame }: { at: number; frame: number }) {
+ const { hit } = agentRun;
+ const reveal = interpolate(frame, [at, at + 22], [0, 1], outEasing);
+
+ return (
+
+
+
+
+ {hit.source}
+
+
+ {hit.title}
+
+
+ {hit.description}
+
+
+ {hit.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ Saved by {hit.savedBy} in {hit.team}
+
+
+
+
+ );
+}
+
+/** The teammate's note, quoted the way the library stores it. */
+export function NoteRow({ at, frame }: { at: number; frame: number }) {
+ const reveal = interpolate(frame, [at, at + 18], [0, 1], outEasing);
+
+ return (
+
+
+ {agentRun.note}
+
+ note from {agentRun.hit.savedBy}
+
+
+
+ );
+}
+
+/** Offsets from the first output frame, so the scene can time its scroll. */
+export const OUTPUT_STEPS = {
+ title: 0,
+ rows: [26, 57, 88],
+ done: 126,
+} as const;
+
+/** What the agent hands back once it has followed the note. */
+export function OutputBlock({
+ at,
+ frame,
+ perChar,
+}: {
+ at: number;
+ frame: number;
+ perChar: number;
+}) {
+ const { output } = agentRun;
+ const doneAt = at + OUTPUT_STEPS.done;
+
+ return (
+
+
+
+ );
+}
diff --git a/remotion/mcp/ui/tool-grid.tsx b/remotion/mcp/ui/tool-grid.tsx
new file mode 100644
index 0000000..45968df
--- /dev/null
+++ b/remotion/mcp/ui/tool-grid.tsx
@@ -0,0 +1,75 @@
+import { interpolate } from "remotion";
+import { outEasing } from "../../product-demo/stage";
+import { light, MONO, radius } from "../../product-demo/theme";
+import { setup, tools } from "../content";
+
+/**
+ * The tool list as a client prints it after the connection is approved. Names
+ * are the ones registered in `app/api/[transport]/route.ts`; the badge marks
+ * the four that need `skills:write`.
+ */
+export function ToolGrid({ frame, at, width = 1320 }: { frame: number; at: number; width?: number }) {
+ return (
+