Demo — Browser Runtime
@absolutejs/demo is the orchestration layer for enterprise-grade AI demos: drive the product, narrate with AI voiceover, record the screen, and draw presenter-style highlights over the UI. It is intentionally adapter-first — Playwright is excellent for web apps, but real demos often need Discord, Google Meet, native apps, screen switching, and OS-level focus control, so every capability sits behind a stable driver interface.
#Quick Start
Install Bun and FFmpeg (the ffmpeg binary must be on PATH), then run bun add @absolutejs/demo @absolutejs/sync, bun add -d playwright, and bunx playwright install chromium. The root export loads the sync-plan helper, so install sync too. Set ELEVENLABS_API_KEY in your environment or local .env file. Save this as demo.ts and run bun demo.ts (or DEMO_HEADLESS=1 bun demo.ts on a server). The example hosts its own fixture, uses your ElevenLabs account for narration, and writes .demo-artifacts/quick-start/crm-demo.mp4 and its manifest. No product login is needed.
Render narration before recording to avoid network pauses. The runner waits for its duration; it does not play audio through your speakers. Close the session to finalize its video, attach that recording to the report, compose the MP4, and only then write the manifest. For your own product, replace the fixture URL and selectors and add a sign-in profile below.
import { mkdir } from "node:fs/promises";
import { createDemoRunner, goto, narrate, spotlight, writeDemoManifest } from "@absolutejs/demo";
import { createPlaywrightDemoSession } from "@absolutejs/demo/playwright";
import { createElevenLabsVoiceover } from "@absolutejs/demo/voiceover";
import { composeDemoWithFFmpeg } from "@absolutejs/demo/composition";
const apiKey = process.env.ELEVENLABS_API_KEY;
if (!apiKey) throw new Error("Set ELEVENLABS_API_KEY in your environment or .env file.");
const outputDir = ".demo-artifacts/quick-start";
await mkdir(outputDir, { recursive: true });
// Render before recording so network latency does not leave dead air.
const text = "Here is the live pipeline view. Revenue at risk is forty-two thousand dollars.";
const voiceover = createElevenLabsVoiceover({ apiKey, outputDir });
const narration = await voiceover.speak({ text });
// A local fixture makes this runnable without an existing app or login.
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: () => new Response(
'<!doctype html><html lang="en"><title>CRM demo</title><body style="font:24px sans-serif;padding:80px"><h1>Pipeline</h1><p id="total">Revenue at risk: $42,000</p></body></html>',
{ headers: { "content-type": "text/html" } }
)
});
try {
const session = await createPlaywrightDemoSession({
headless: process.env.DEMO_HEADLESS === "1",
recordVideoDir: outputDir,
contextOptions: { viewport: { width: 1280, height: 720 } }
});
let closed = false;
try {
const runner = createDemoRunner({
browser: session.browserDriver,
annotations: session.annotations,
voiceover: { speak: async () => narration },
voiceoverPlayback: "wait-for-duration"
});
const report = await runner.run({
id: "crm-demo",
steps: [
goto(server.url.href),
spotlight({ selector: "#total", label: "Revenue at risk" }),
narrate(text)
]
});
// Closing finalizes the video; the session does not attach it for you.
const recording = await session.close();
closed = true;
if (recording) report.artifacts.push(recording);
if (report.status === "completed") {
report.artifacts.push(await composeDemoWithFFmpeg(report, {
outputPath: outputDir + "/crm-demo.mp4",
voiceoverTiming: "timeline"
}));
}
await writeDemoManifest(report, outputDir + "/crm-demo.manifest.json");
if (report.status !== "completed") throw new Error(report.error?.message ?? "Demo failed");
console.log("Created", outputDir + "/crm-demo.mp4");
} finally {
if (!closed) await session.close();
}
} finally {
await server.stop(true);
}
runner.run() never throws for a failed demo — it returns a report with status: 'failed' and the error, after stopping the recorder so the partial recording survives. writeDemoManifest() then turns the report into a proof-of-run JSON document.
#Playwright Session
createPlaywrightDemoSession() (from @absolutejs/demo/playwright) launches the browser, opens a context + page, and hands back the two drivers the runner needs: a DemoBrowserDriver and an annotation driver. Playwright itself is loaded dynamically — it stays an optional dependency.
| Option | Type | Description |
|---|---|---|
headless | boolean | Defaults to false — a demo is meant to be seen (and recorded). |
browser | 'chromium' | 'firefox' | 'webkit' | Engine to launch. Defaults to chromium. |
channel | string | Branded browser channel (e.g. "chrome") instead of the bundled build. |
browserInstance | Browser | Reuse an already-launched Playwright browser; close() then leaves it running. |
contextOptions | Record<string, unknown> | Passed straight to browser.newContext() — viewport, locale, permissions. |
recordVideoDir | string | Enables Playwright video for the context; close() returns it as a recording artifact. |
screenshotDir | string | Where screenshot() steps write their PNG files. |
fullPageScreenshots | boolean | Capture the full scroll height (default true) instead of the viewport. |
account | DemoCredentialProfile | A storage-state profile applied when the context is created — start signed in. |
import { createPlaywrightDemoSession } from '@absolutejs/demo/playwright';
const session = await createPlaywrightDemoSession({
browser: 'chromium', // 'chromium' | 'firefox' | 'webkit'
channel: 'chrome', // branded Chrome instead of the bundled build
headless: false,
recordVideoDir: '.demo-video', // Playwright video for this context
screenshotDir: '.demo-shots',
fullPageScreenshots: true,
contextOptions: { viewport: { width: 1600, height: 900 } },
// A storage-state profile applied when the context is created.
account: {
id: 'returning-user',
kind: 'storage-state',
storageState: '.demo-auth/ae.storage-state.json'
}
});
// The session exposes everything the runner needs:
session.browserDriver; // DemoBrowserDriver — goto/click/fill/type/press
session.annotations; // overlay annotation driver for this page
session.page; // the raw Playwright page for anything bespoke
const recording = await session.close(); // finalize video and close browser
if (recording) report.artifacts.push(recording); // attach before composing
// Lower-level pieces are exported too:
import {
createPlaywrightAnnotationDriver,
createPlaywrightDemoBrowser,
launchPlaywright
} from '@absolutejs/demo/playwright';
const browser = await launchPlaywright({ headless: true });
const driver = createPlaywrightDemoBrowser({ page, screenshotDir: 'x' });
const annotations = createPlaywrightAnnotationDriver(page);#Runner & Script Steps
createDemoRunner() holds the drivers; demoScript() declares what happens. Every driver is optional — a script that only narrates needs only a voiceover, and a step whose driver is missing fails with an error naming it.
| Option | Type | Description |
|---|---|---|
browser | DemoBrowserDriver | Executes goto/click/fill/type/press/waitFor/screenshot steps. |
auth | DemoAuthDriver | Resolves signIn("<id>") steps against the script’s profiles. |
annotations | DemoAnnotationDriver | Draws spotlight / circle / highlight overlays. |
desktop | DemoDesktopDriver | Executes openApp / focusApp / hotkey / typeText / click steps. |
recorder | DemoRecorder | Started before step one; stopped (and collected as an artifact) after the last step or on failure. |
voiceover | DemoVoiceover | speak() target for narrate steps; returned artifacts join the report. |
annotationFailure | 'throw' | 'continue' | With "continue", a failed overlay becomes a log artifact and the run keeps going. |
onEvent | (event) => void | Promise<void> | Streams run.started, step.started, step.completed, artifact, run.completed, run.failed. |
voiceoverPlayback | 'continue' | 'wait-for-duration' | Block each narration for its rendered audio duration so live playback stays in sync. |
idFactory | () => string | Custom run ids; defaults to a timestamped random id. |
import { createDemoRunner } from '@absolutejs/demo';
const runner = createDemoRunner({
annotations: session.annotations, // overlay callout driver
auth: createDemoAuthDriver(), // resolves signIn('<id>') steps
browser: session.browserDriver, // goto/click/fill/press/waitFor
desktop: createMacDesktopDriver(), // native-app steps
recorder: screenRecorder, // started before step 1, stopped after
voiceover: elevenLabs, // narrate(...) target
// 'throw' (default) or 'continue' — a failed overlay is logged as
// an artifact and the run keeps going.
annotationFailure: 'continue',
// Stream run/step/artifact events as they happen.
onEvent: (event) => sendToDashboard(event),
// Block each narrate() until the rendered audio duration elapses,
// so on-screen action stays in sync with live playback.
voiceoverPlayback: 'wait-for-duration',
idFactory: () => 'run-' + Date.now().toString(36)
});
const report = await runner.run(script);
// report — the DemoRunReport:
// {
// id, scriptId, startedAt, endedAt, durationMs,
// status: 'completed' | 'failed',
// error?: { message, stack? },
// artifacts: DemoArtifact[], // recordings, voiceovers, screenshots
// events: DemoRunnerEvent[] // run.started, step.started,
// } // step.completed, artifact, run.failedSteps are plain JSON-friendly objects; the builders below construct them. Each accepts a trailing { id?, name? } options object that labels the step in the event log and timeline.
import {
click,
demoScript,
fill,
goto,
markRecording,
narrate,
press,
screenshot,
signIn,
spotlight,
wait,
waitFor
} from '@absolutejs/demo';
const script = demoScript({
id: 'crm-demo',
title: 'CRM demo',
metadata: { audience: 'enterprise', owner: 'sales-eng' },
profiles: [
/* credential profiles — see Authentication */
],
steps: [
signIn('ae'), // run a credential profile by id
goto('http://localhost:3000/pipeline'),
waitFor("[data-demo='pipeline-total']"), // selector or ms
click('#quarter-toggle'),
fill('#search', 'Acme'),
press('#search', 'Enter'),
narrate({
text: 'Here is the live pipeline view.',
emotion: 'confident'
}),
spotlight({
selector: "[data-demo='pipeline-total']",
label: 'Revenue at risk',
durationMs: 1800
}),
markRecording('chapter: pipeline'), // recorder chapter marker
screenshot('pipeline'),
wait(1200),
// Escape hatch — custom logic with the full DemoContext.
{
name: 'custom',
run: async (context) => {
await context.narrate('And one more thing.');
await context.annotate({ type: 'clear' });
context.addArtifact({ id: 'note', kind: 'log' });
}
}
]
});
const report = await runner.run(script);#Authentication
Sign-in is profile-based: declare named credential profiles on the script and trigger them with signIn("<id>") steps. Three profile kinds cover the common cases — your own AbsoluteJS app, any third-party login form, and a saved browser session.
| Capability | absolute | form | storage-state |
|---|---|---|---|
| Works on sites you don’t control | |||
| Drives the real login UI Navigates, types into fields, clicks submit | |||
| Posts to the @absolutejs/auth login route /auth/login by default, or routes.login | |||
| Multi-step flows username → Next → password via a steps array | |||
| Human-style typing typeDelayMs types key-by-key; pressEnter submits | |||
| Success confirmation success.selector and/or success.url | afterLoginUrl | n/a | |
| Reuses a saved Playwright session Applied when the browser context is created | |||
| Secrets resolved from env at sign-in | n/a |
{ env: "VAR_NAME" }) — the runner resolves them at sign-in time, keeping credential values out of the script. This does not redact the screen: use demo accounts and review recordings and reports before sharing. A missing env var throws an error naming the variable, never its value. The sign-in log artifact records only which profile ran and whether it succeeded.import { createDemoRunner, signIn } from '@absolutejs/demo';
import { createDemoAuthDriver } from '@absolutejs/demo/auth';
const runner = createDemoRunner({
// Default driver — handles all three profile kinds. routes.login
// overrides the '/auth/login' default for absolute profiles.
auth: createDemoAuthDriver({ routes: { login: '/api/auth/login' } }),
browser: session.browserDriver
});
await runner.run({
id: 'signin-demo',
profiles: [
// absolute — a site you own that uses @absolutejs/auth. Posts
// email + password to the auth login route.
{
id: 'ae',
kind: 'absolute',
baseUrl: 'http://localhost:3000',
email: { env: 'DEMO_EMAIL' },
password: { env: 'DEMO_PASSWORD' },
afterLoginUrl: 'http://localhost:3000/pipeline'
},
// storage-state — reuse a saved Playwright session; applied
// when the browser context is created.
{
id: 'returning-user',
kind: 'storage-state',
storageState: '.demo-auth/ae.storage-state.json'
}
],
// Trigger a profile from a step by id:
steps: [signIn('ae')]
});form profiles drive the real login UI of any site, including ones you don't control: navigate to loginUrl, fill fields, click submitSelector, and confirm via a success selector and/or URL. For bespoke auth screens, implement your own DemoAuthDriver.
// A third-party site you do NOT control — drives the real login UI.
{
id: 'saucedemo',
kind: 'form',
loginUrl: 'https://www.saucedemo.com/',
fields: [
{ selector: '#user-name', value: { env: 'SAUCE_USERNAME' } },
{
selector: '#password',
value: { env: 'SAUCE_PASSWORD' },
typeDelayMs: 60 // type key-by-key, like a human
}
],
submitSelector: '#login-button',
// Confirm the login worked — an element that appears and/or a URL.
success: { selector: '.inventory_list' }
}
// Multi-step flows (username → Next → password) use a steps array:
{
id: 'workspace',
kind: 'form',
steps: [
{ action: 'goto', url: 'https://accounts.example.com/signin' },
{
action: 'fill',
selector: '#identifier',
value: { env: 'DEMO_EMAIL' }
},
{ action: 'click', selector: '#identifier-next' },
{ action: 'waitFor', target: '#password' },
{
action: 'fill',
selector: '#password',
value: { env: 'DEMO_PASSWORD' },
pressEnter: true // submit with Enter instead of a click
}
],
success: { url: '/dashboard' }
}
// Bespoke auth screens: implement DemoAuthDriver yourself.
const customAuth: DemoAuthDriver = {
signIn: async (profile, context) => {
await context.browser?.goto('https://app.example.com/sso');
await context.browser?.click('#continue-with-okta');
await context.browser?.waitFor('.dashboard');
}
};#Annotations
Annotations are presenter-style callouts drawn as a fixed overlay above the page (max z-index, pointer events off — they never intercept the demo's own clicks). The Playwright annotation driver renders them in-page; any environment can implement DemoAnnotationDriver with a single show() method.
import {
circle,
clearAnnotations,
highlight,
spotlight
} from '@absolutejs/demo';
// Spotlight — dims the rest of the page and glows around the target.
spotlight({
selector: "[data-demo='pipeline-total']",
label: 'Revenue at risk',
durationMs: 1800 // auto-clears; omit to keep it up
});
// Circle — a rounded ring around an element, no page dimming.
circle({ selector: '#submit', color: '#ff3b30' });
// Highlight — border-only emphasis; the page stays fully visible.
// Targets are a selector OR an explicit x/y/width/height rect.
highlight({ x: 120, y: 240, width: 320, height: 48, label: 'Drop zone' });
// Remove whatever is currently drawn.
clearAnnotations();#Desktop Control
Native-app automation goes through createCommandDesktopDriver() (from @absolutejs/demo/desktop) — each capability maps to a CLI command. On macOS, createMacDesktopDriver() ships ready-made: it opens and focuses apps with open -a and sends keystrokes via osascript. Linux and Windows provide equivalent factories with xdotool, wmctrl, PowerShell, or a UIA bridge.
import { createDemoRunner, focusApp, openApp, wait } from '@absolutejs/demo';
import {
createCommandDesktopDriver,
createMacDesktopDriver
} from '@absolutejs/demo/desktop';
// macOS: open/focus apps and send keystrokes via osascript.
const runner = createDemoRunner({ desktop: createMacDesktopDriver() });
await runner.run({
id: 'discord-demo',
steps: [openApp('Discord'), wait(1000), focusApp('Discord')]
});
// Linux / Windows: map each capability to a command — xdotool, wmctrl,
// PowerShell, or a UIA bridge. Unconfigured capabilities throw with a
// message naming the missing capability.
const linuxDesktop = createCommandDesktopDriver({
open: (target) => ['xdg-open', String(target)],
focus: (target) => ['wmctrl', '-a', String(target)],
hotkey: (keys) => ['xdotool', 'key', keys.join('+')],
typeText: (text) => ['xdotool', 'type', text],
click: (x, y) => [
'xdotool', 'mousemove', String(x), String(y), 'click', '1'
]
});