skills/(demo-skills)/basic-custom-app-build-w-video/SKILL.md
Orchestrates a new Domo custom app build with Remotion-compatible styling, sample data generation, and automatic demo video creation. Use when you want to build a Domo app AND produce a polished 10-15 second demo video of it in one workflow.
npx skillsauth add stahura/domo-ai-vibe-rules basic-custom-app-build-w-videoInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
basic-custom-app-buildThis playbook wraps the standard Domo app build with three additions:
Copy this checklist and update it as you work.
Demo-Ready Build Progress:
- [ ] Phase 0 : Rules loaded (platform + Remotion styling constraints)
- [ ] Phase 1 : Manifest & contracts
- [ ] Phase 2 : App shell (domo.js)
- [ ] Phase 3 : Data access
- [ ] Phase 3A: Sample data generation
- [ ] Phase 4 : App storage (if needed)
- [ ] Phase 5 : Toolkit clients (if needed)
- [ ] Phase 6 : Feature skills (AI / Code Engine / Workflow — as needed)
- [ ] Phase 7 : UI build (Remotion-safe styling)
- [ ] Phase 8 : Performance review
- [ ] Phase 9 : Build & publish
- [ ] Phase 10: Demo video (Remotion)
- [ ] Phase 11: Verification
Apply before writing any code:
rules/core-custom-apps-rule.mdrules/custom-app-gotchas.mdThese rules ensure every UI component you build can be screenshot-captured or directly rendered inside a Remotion composition without rework.
| Rule | Why |
|---|---|
| No CSS transition-* or animation-* properties | Remotion cannot capture CSS-driven motion. All animation in the demo will be driven by useCurrentFrame(). Static styling (colors, borders, shadows, transforms at rest) is fine. |
| No Tailwind transition-* or animate-* utility classes | Same reason. All other Tailwind utilities are fine and encouraged. |
| Use inline style objects or Tailwind for layout | Remotion renders React components to frames; CSS modules and scoped styles work, but CSS-in-JS runtime libraries (styled-components, Emotion) may cause hydration issues in the Remotion render pipeline. Prefer inline styles or Tailwind. |
| Use <img> normally in the Domo app | In the app code, use standard <img> tags. The Remotion demo will use its own <Img> component when composing scenes. This constraint is about not relying on lazy-loading or intersection-observer patterns that won't fire in a Remotion frame capture. |
| Keep visual state deterministic | Every UI state the demo will show must be reproducible from props/data alone, not from user interaction history or timers. Build components so that passing in sample data produces the exact visual you want in the demo. |
| Design for 1920x1080 | Ensure the app layout looks good at 1920x1080, since this is the demo video's native resolution. Responsive design is still fine; just verify the 1080p breakpoint. |
Use manifest.
Define all external resource mappings first: datasets, collections, workflows, Code Engine packages. Everything else depends on this.
Use alias-safe names only (^[A-Za-z][A-Za-z0-9]*$), no spaces or special characters.
Check root folder for an existing thumbnail.png to copy into the app's public/ folder.
Existing-app takeover? Audit the current manifest.json against actual code usage before changing anything.
Use domo-js.
Set up the baseline: ryuu.js import, navigation via domo.navigate(), event listeners, environment info.
DA CLI users: Also use da-cli for scaffolding if the user explicitly requests it.
Use dataset-query (primary) and data-api (routing overview).
Build queries with @domoinc/query. Before writing UI/data-mapping logic, fetch the real schema for every dataset in manifest.json datasetsMapping and create an explicit field map.
Need raw SQL? Use sql-query, but know that SQL ignores page filters.
This is the critical addition for demo-readiness. Generate realistic sample data that:
Create a src/sample-data/ directory in the app with:
src/sample-data/
index.ts # Re-exports all sample datasets; single import point
datasets/
<alias>.ts # One file per manifest dataset alias, exporting typed arrays
README.md # Documents the shape and purpose of each sample dataset
manifest.json:// src/sample-data/datasets/sales.ts
export type SalesRow = {
Date: string;
Region: string;
Product: string;
Revenue: number;
Units: number;
};
export const salesData: SalesRow[] = [
{ Date: "2026-01-15", Region: "West", Product: "Widget A", Revenue: 12400, Units: 62 },
{ Date: "2026-01-16", Region: "East", Product: "Widget B", Revenue: 8750, Units: 35 },
// ... 20-100 rows with realistic distribution
];
Create a data-access layer that switches between live Domo queries and sample data:
// src/data/use-dataset.ts
import { salesData } from "../sample-data/datasets/sales";
const USE_SAMPLE_DATA = !window.__DOMO_ENV__; // true outside Domo runtime
export async function fetchSalesData(): Promise<SalesRow[]> {
if (USE_SAMPLE_DATA) {
return salesData;
}
// Real query via @domoinc/query
return new Query().select([...]).fetch("sales");
}
This ensures:
| Source | Sample strategy |
|---|---|
| AppDB collections | Export sample document arrays in src/sample-data/collections/<name>.ts |
| AI responses | Export canned response strings in src/sample-data/ai-responses.ts |
| User identity | Export a mock user object: { displayName: "Alex Demo", email: "[email protected]", role: "Admin" } |
| Workflow results | Export sample status objects in src/sample-data/workflows/<name>.ts |
Use appdb and appdb-collection-create when storage must be created.
Skip if the app only reads datasets.
Use toolkit.
Use typed @domoinc/toolkit clients where they add value.
Only load the skills your app actually requires:
| Feature needed | Skill |
|---|---|
| AI text generation or text-to-SQL | ai-service-layer |
| Server-side functions (secrets, external APIs) | code-engine + code-engine-create + code-engine-update |
| Triggering automation workflows | workflow |
Skip this phase if none of these features are needed.
Build the app's UI components. This is standard React development with the Remotion constraints from Phase 0 enforced:
style objects for all styling.transition-*, animate-*, or CSS keyframe animations.Structure components so each visual state is a function of data:
// Good: demo can render any state by passing props
<ChatPanel messages={sampleMessages} isTyping={false} />
<ChatPanel messages={sampleMessages} isTyping={true} />
// Bad: state is internal and requires user interaction to reach
<ChatPanel /> // internally manages messages via useState
You don't need to refactor the entire app this way — just the views you want in the demo. Expose the 3-5 key visual states as prop-driven variants.
Use performance.
Review all queries. Check for full-dataset fetches, missing aggregations, unnecessary columns.
Use publish.
npm run build -> cd dist -> domo publish. Copy generated id back to source manifest on first publish.
The demo video renders the actual app components directly inside Remotion — not screenshots, not title cards, not marketing slides. The viewer should see the real UI populated with sample data, with animated scrolling or view changes to show all parts of the app.
Create a demo/ directory alongside the app source:
my-app/
src/ # Domo app source
demo/ # Remotion demo project
src/
Root.tsx
DemoVideo.tsx
index.ts
remotion.config.ts # Webpack alias to import app components
package.json
The demo project must include the same UI dependencies the app uses (e.g., recharts) so Remotion can render the actual components:
{
"dependencies": {
"@remotion/cli": "^4.0.0",
"@remotion/transitions": "^4.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"recharts": "^2.12.0",
"remotion": "^4.0.0"
}
}
Add any other UI library the app uses (chart libraries, UI component kits, etc.).
This is critical. Configure a @app alias so demo code can import directly from the app's src/:
import { Config } from '@remotion/cli/config';
import path from 'path';
Config.overrideWebpackConfig((currentConfiguration) => ({
...currentConfiguration,
resolve: {
...currentConfiguration.resolve,
alias: {
...currentConfiguration.resolve?.alias,
'@app': path.resolve(process.cwd(), '..', 'src'),
},
},
}));
import { registerRoot } from 'remotion';
import { RemotionRoot } from './Root';
registerRoot(RemotionRoot);
{
"compilerOptions": {
"paths": { "@app/*": ["../src/*"] },
"baseUrl": "."
},
"include": ["src", "../src"]
}
Create a reusable cursor that moves between positions and shows click pulses. This sells the illusion of someone using the app.
Place in demo/src/components/AnimatedCursor.tsx:
import React from 'react';
import { useCurrentFrame, useVideoConfig, interpolate, spring, Easing } from 'remotion';
export type CursorWaypoint = {
x: number; // Pixel position from left
y: number; // Pixel position from top
atFrame: number; // Frame at which cursor should arrive here
click?: boolean; // Show a click pulse when arriving
};
type Props = {
waypoints: CursorWaypoint[];
size?: number; // Default 20
};
export const AnimatedCursor: React.FC<Props> = ({ waypoints, size = 20 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
if (waypoints.length === 0) return null;
// Find which two waypoints we're interpolating between
let fromWp = waypoints[0];
let toWp = waypoints[0];
for (let i = 0; i < waypoints.length - 1; i++) {
if (frame >= waypoints[i].atFrame) {
fromWp = waypoints[i];
toWp = waypoints[i + 1];
}
}
if (frame >= waypoints[waypoints.length - 1].atFrame) {
fromWp = waypoints[waypoints.length - 1];
toWp = fromWp;
}
const easing = Easing.inOut(Easing.quad);
const x = fromWp === toWp ? fromWp.x
: interpolate(frame, [fromWp.atFrame, toWp.atFrame], [fromWp.x, toWp.x],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing });
const y = fromWp === toWp ? fromWp.y
: interpolate(frame, [fromWp.atFrame, toWp.atFrame], [fromWp.y, toWp.y],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing });
// Click pulse at waypoints marked click: true
let pulseOpacity = 0;
let pulseScale = 0;
for (const wp of waypoints) {
if (wp.click && frame >= wp.atFrame && frame < wp.atFrame + 20) {
const p = spring({ frame: frame - wp.atFrame, fps, config: { damping: 20, stiffness: 200 } });
pulseScale = interpolate(p, [0, 1], [0.5, 1.5]);
pulseOpacity = interpolate(frame - wp.atFrame, [0, 20], [0.5, 0], { extrapolateRight: 'clamp' });
}
}
return (
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 9999 }}>
{pulseOpacity > 0 && (
<div style={{
position: 'absolute', left: x - 15, top: y - 15,
width: 30, height: 30, borderRadius: '50%',
border: '2px solid rgba(255,255,255,0.8)',
transform: `scale(${pulseScale})`, opacity: pulseOpacity,
}} />
)}
<svg style={{ position: 'absolute', left: x, top: y, width: size, height: size,
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.5))' }} viewBox="0 0 24 24" fill="none">
<path d="M5 3L19 12L12 12L8 21L5 3Z" fill="white" stroke="#1e293b"
strokeWidth="1.5" strokeLinejoin="round" />
</svg>
</div>
);
};
import { Composition } from 'remotion';
import { DemoVideo } from './DemoVideo';
export const RemotionRoot = () => (
<Composition
id="AppDemo"
component={DemoVideo}
durationInFrames={300} // 10s at 30fps
fps={30}
width={1920}
height={1080}
/>
);
Pick the pattern that matches your app and combine them if needed. All patterns import the real app components via @app/.
Render the component and animate translateY to scroll through it.
import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate, Easing } from 'remotion';
import { Dashboard } from '@app/App';
import { sampleData } from '@app/sample-data';
import { AnimatedCursor } from './components/AnimatedCursor';
export const DemoVideo: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const scrollY = interpolate(frame, [2 * fps, 6 * fps], [0, -500], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
easing: Easing.inOut(Easing.quad),
});
return (
<AbsoluteFill style={{ backgroundColor: '#0f172a', overflow: 'hidden' }}>
<div style={{ transform: `translateY(${scrollY}px)`, width: '100%' }}>
<Dashboard data={sampleData} />
</div>
<AnimatedCursor waypoints={[
{ x: 700, y: 400, atFrame: 0 },
{ x: 700, y: 500, atFrame: Math.round(3 * fps) },
{ x: 500, y: 450, atFrame: Math.round(6 * fps) },
]} />
</AbsoluteFill>
);
};
Use <Sequence> to switch between views. The cursor moves to the tab/button, a click pulse fires, and the next Sequence renders the new view.
import { AbsoluteFill, Sequence, useCurrentFrame, useVideoConfig, interpolate, Easing } from 'remotion';
import { Dashboard } from '@app/App';
import { sampleData } from '@app/sample-data';
import { AnimatedCursor } from './components/AnimatedCursor';
const OverviewScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const scrollY = interpolate(frame, [2 * fps, 4 * fps], [0, -350], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
easing: Easing.inOut(Easing.quad),
});
return (
<AbsoluteFill style={{ overflow: 'hidden' }}>
<div style={{ transform: `translateY(${scrollY}px)`, width: '100%' }}>
<Dashboard data={sampleData} activeTab="overview" />
</div>
<AnimatedCursor waypoints={[
{ x: 700, y: 400, atFrame: 0 },
{ x: 900, y: 450, atFrame: Math.round(3 * fps) },
// End near the target tab button
{ x: 1195, y: 48, atFrame: Math.round(4.5 * fps) },
]} />
</AbsoluteFill>
);
};
const DetailScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const scrollY = interpolate(frame, [2 * fps, 4 * fps], [0, -200], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
easing: Easing.inOut(Easing.quad),
});
return (
<AbsoluteFill style={{ overflow: 'hidden' }}>
<div style={{ transform: `translateY(${scrollY}px)`, width: '100%' }}>
<Dashboard data={sampleData} activeTab="detail" selectedItem={sampleData.items[0]} />
</div>
<AnimatedCursor waypoints={[
{ x: 1195, y: 48, atFrame: 0, click: true }, // Click the tab
{ x: 600, y: 300, atFrame: Math.round(0.8 * fps) },
{ x: 500, y: 400, atFrame: Math.round(3 * fps) },
]} />
</AbsoluteFill>
);
};
export const DemoVideo: React.FC = () => {
const { fps } = useVideoConfig();
return (
<AbsoluteFill>
<Sequence from={0} durationInFrames={5 * fps} premountFor={fps}>
<OverviewScene />
</Sequence>
<Sequence from={5 * fps} durationInFrames={5 * fps} premountFor={fps}>
<DetailScene />
</Sequence>
</AbsoluteFill>
);
};
Key: The cursor's last waypoint in Scene 1 and first waypoint in Scene 2 should be at the same position (the tab button) to create a seamless "click" effect across the cut.
Render the chat component with progressively more messages using frame-based array slicing. This shows messages "arriving" without needing actual interaction.
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from 'remotion';
import { ChatPanel } from '@app/components/ChatPanel';
import { sampleMessages } from '@app/sample-data';
import { AnimatedCursor } from './components/AnimatedCursor';
// Pre-define which messages are visible at which frame
const messageTimeline = [
{ visibleCount: 2, atFrame: 0 }, // Start with 2 messages already visible
{ visibleCount: 3, atFrame: 60 }, // User message appears at 2s
{ visibleCount: 3, typingIndicator: true, atFrame: 90 }, // AI typing at 3s
{ visibleCount: 4, atFrame: 120 }, // AI response appears at 4s
{ visibleCount: 5, atFrame: 180 }, // Another user message at 6s
{ visibleCount: 5, typingIndicator: true, atFrame: 210 },
{ visibleCount: 6, atFrame: 240 }, // Final AI response at 8s
];
export const DemoVideo: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Find current state based on frame
let currentState = messageTimeline[0];
for (const state of messageTimeline) {
if (frame >= state.atFrame) currentState = state;
}
const visibleMessages = sampleMessages.slice(0, currentState.visibleCount);
return (
<AbsoluteFill style={{ overflow: 'hidden' }}>
<ChatPanel
messages={visibleMessages}
isTyping={currentState.typingIndicator ?? false}
/>
<AnimatedCursor waypoints={[
{ x: 960, y: 800, atFrame: 0 },
// Cursor moves to input area before "sending" a message
{ x: 700, y: 950, atFrame: 50, click: true },
{ x: 960, y: 600, atFrame: 90 }, // Watches AI type
{ x: 700, y: 950, atFrame: 170, click: true }, // Sends another
{ x: 960, y: 500, atFrame: 240 }, // Reads response
]} />
</AbsoluteFill>
);
};
Key for chat apps: The ChatPanel component must accept messages and isTyping as props (Phase 7 enforces this). The sample data in Phase 3A should include a realistic conversation thread.
Render different prop states in sequence to simulate button clicks. Use the cursor to point at the button, fire a click pulse, and switch to the "after" state.
// Scene 1: Empty form state, cursor fills in a field
<Sequence from={0} durationInFrames={4 * fps}>
<FormView formData={emptyForm} />
<AnimatedCursor waypoints={[
{ x: 500, y: 300, atFrame: 0 },
{ x: 500, y: 300, atFrame: 30, click: true },
]} />
</Sequence>
// Scene 2: Filled form, cursor clicks submit
<Sequence from={4 * fps} durationInFrames={3 * fps}>
<FormView formData={filledForm} />
<AnimatedCursor waypoints={[
{ x: 500, y: 600, atFrame: 0 },
{ x: 500, y: 600, atFrame: 30, click: true }, // Click "Submit"
]} />
</Sequence>
// Scene 3: Success state
<Sequence from={7 * fps} durationInFrames={3 * fps}>
<FormView formData={filledForm} submitSuccess={true} />
</Sequence>
npx remotion studio, hover over the preview, and note the pixel coordinates where buttons/tabs/elements are rendered.cd demo
npx remotion studio # Preview in browser
npx remotion render AppDemo # Render to out/AppDemo.mp4
After publishing, confirm:
domo.navigate(), not <a href>public/ foldernpx remotion render AppDemo)base: './'.HashRouter unless rewrites are intentionally handled.domo.env.* as convenience only; use verified identity for trust decisions.tools
Step-by-step orchestrator for building Domo App Studio apps with native KPI cards via community-domo-cli. Sequences app creation, pages, theme, hero metrics, native charts, filter cards, layout assembly, and navigation. CLI-first — no raw API calls.
tools
Create, update, and execute Magic ETL dataflows programmatically via API and CLI. Covers DAG-based JSON dataflow definitions, input/transform/output node wiring, join operations, and execution lifecycle.
tools
Magic ETL dataflows via community-domo-cli — list, get-definition, create, update, run, execution status; JSON DAG actions, transforms, joins. Use when automating dataflows with the community Domo CLI end-to-end. For REST/Java-CLI–first flows or mixed API patterns, use magic-etl instead.
development
Clean, professional dashboard theme for Domo custom apps. CSS custom properties, layout patterns, typography, and design polish that feel native to the Domo platform. Includes OKLCH color palette, layered shadows, concentric border radius, tabular numbers, and micro-interaction patterns.