skills/daily-co/SKILL.md
--- name: daily-co description: Wire Daily.co managed WebRTC video into a Next.js (App Router) app, including private rooms, owner/participant meeting tokens, the React iframe embed, and first-class live transcription (Deepgram-backed). Covers the REST API (create-room, create-meeting-token), the @daily-co/daily-js iframe embed (with the browser-only dynamic-import to avoid SSR window errors), the @daily-co/daily-react custom-UI alternative, and the transcription event lifecycle (transcription-s
npx skillsauth add RonanCodes/ronan-skills skills/daily-coInstall 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.
Wire Daily.co managed WebRTC video into a Next.js (App Router) app. Daily runs the SFU, TURN, and recording/transcription infrastructure; you call a REST API to create rooms and mint tokens, then drop an iframe (or custom React UI) into the page. Live transcription is first-class and speaker-attributed.
This is the easiest managed video option with first-class transcription, and a sibling to JaaS (hosted Jitsi). Reach for it when you want managed WebRTC video without standing up your own SFU.
Do NOT reach for Daily when EU data residency is a hard requirement and you must control where media is processed (self-host LiveKit instead), or when you specifically want hosted Jitsi (use JaaS / 8x8).
# .env.local — server-side only, never exposed to the client
DAILY_API_KEY=... # from the Daily dashboard → Developers
The API key is used only in server code (route handlers). The client never sees it — it receives a short-lived meeting token and the room URL instead.
pnpm add @daily-co/daily-js
# optional, for the custom-UI (hooks) path instead of the iframe:
pnpm add @daily-co/daily-react @daily-co/daily-js jotai
Every REST call authenticates with a bearer token:
Authorization: Bearer ${DAILY_API_KEY}
POST https://api.daily.co/v1/roomsBody properties:
| Property | Notes |
|----------|-------|
| privacy | "private" so only token-holders can join |
| exp | Unix seconds; room auto-expires |
| enable_prejoin_ui | hair-check / device-select lobby before join |
| enable_chat | in-call text chat |
| enable_transcription_storage | saves transcripts as WebVTT (separate from live transcription) |
There is NO room-level enable_transcription. Live transcription is controlled by the token (auto_start_transcription) or at runtime via call.startTranscription(). enable_transcription_storage only governs whether transcripts are saved.
POST https://api.daily.co/v1/meeting-tokensBody properties:
| Property | Notes |
|----------|-------|
| room_name | the room this token grants access to |
| user_name | display name shown to others (and used for speaker attribution) |
| is_owner | owner can start/stop transcription, recording, eject |
| exp | Unix seconds; token auto-expires |
| eject_at_token_exp | force the participant out when the token expires |
| auto_start_transcription | begin transcription automatically on join (owner token) |
| enable_live_captions_ui | show the in-call captions toggle |
Returns { token }.
Copy-pasteable App Router route. POST /api/daily/room creates a private room (or reuses one you pass) and returns a join URL plus a fresh meeting token.
// app/api/daily/room/route.ts
import { NextResponse } from "next/server";
const DAILY = "https://api.daily.co/v1";
function daily(path: string, body: unknown) {
return fetch(`${DAILY}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DAILY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
}
export async function POST(req: Request) {
const { userName, isOwner = false } = await req.json();
const oneHour = Math.floor(Date.now() / 1000) + 60 * 60;
// 1. Create a private room (omit `name` to let Daily generate one).
const roomRes = await daily("/rooms", {
properties: {
privacy: "private",
exp: oneHour,
enable_prejoin_ui: true,
enable_chat: true,
enable_transcription_storage: true, // save WebVTT; NOT the same as live transcription
},
});
if (!roomRes.ok) {
return NextResponse.json({ error: await roomRes.text() }, { status: 502 });
}
const room = (await roomRes.json()) as { name: string; url: string };
// 2. Mint a meeting token scoped to that room.
const tokenRes = await daily("/meeting-tokens", {
properties: {
room_name: room.name,
user_name: userName,
is_owner: isOwner,
exp: oneHour,
eject_at_token_exp: true,
auto_start_transcription: isOwner, // owner token can auto-start live transcription
enable_live_captions_ui: true,
},
});
if (!tokenRes.ok) {
return NextResponse.json({ error: await tokenRes.text() }, { status: 502 });
}
const { token } = (await tokenRes.json()) as { token: string };
return NextResponse.json({ url: room.url, token });
}
A client component. @daily-co/daily-js touches window, so it is dynamically imported inside the effect to avoid Next.js SSR window is not defined errors.
// app/components/VideoCall.tsx
"use client";
import { useEffect, useRef } from "react";
import type { DailyCall } from "@daily-co/daily-js";
export function VideoCall({ url, token }: { url: string; token: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const callRef = useRef<DailyCall | null>(null);
useEffect(() => {
if (!containerRef.current) return;
let call: DailyCall | null = null;
(async () => {
// Browser-only import — keeps `window` access out of the server bundle.
const DailyIframe = (await import("@daily-co/daily-js")).default;
call = DailyIframe.createFrame(containerRef.current!, {
iframeStyle: {
width: "100%",
height: "100%",
border: "0",
borderRadius: "12px",
},
showLeaveButton: true,
});
callRef.current = call;
await call.join({ url, token });
})();
return () => {
call?.destroy(); // tear down on unmount
callRef.current = null;
};
}, [url, token]);
return <div ref={containerRef} style={{ width: "100%", height: "70vh" }} />;
}
@daily-co/daily-react)For a fully custom layout instead of the iframe, wrap the tree in DailyProvider and use the hooks (useParticipantIds, useDailyEvent, useDaily, useMediaTrack). You render your own video tiles and controls; the provider manages the underlying call object. Same join/token model — only the UI surface differs.
Live transcription is Deepgram-backed and emits events on the call object. Start it from an owner token (auto_start_transcription: true) or at runtime (call.startTranscription(...), owner required).
Events:
transcription-started — transcription is now running.transcription-message — an incremental/final transcript chunk.transcription-error — failed to start or dropped.The transcription-message payload:
{
action: string;
participantId: string; // map to a name via call.participants()
text: string;
timestamp: string;
instanceId?: string;
trackType?: string;
rawResponse?: unknown; // raw Deepgram response when present
}
// inside the same client component, after `call.join(...)`
type TranscriptLine = { name: string; text: string; timestamp: string };
// Owner-only; or set auto_start_transcription:true on the token to skip this call.
await call.startTranscription({
language: "en",
model: "nova-2",
punctuate: true,
});
call.on("transcription-started", () => {
console.log("transcription running");
});
call.on("transcription-message", (ev) => {
// Resolve participantId -> display name from the live participant map.
const participants = call.participants();
const match = Object.values(participants).find(
(p) => p.session_id === ev.participantId,
);
const line: TranscriptLine = {
name: match?.user_name ?? "Unknown",
text: ev.text,
timestamp: ev.timestamp,
};
// append `line` to your transcript state / store
});
call.on("transcription-error", (ev) => {
console.error("transcription error", ev);
});
Live transcription (the Deepgram-backed startTranscription / transcription-message flow) is a paid-plan feature and will not start on a free plan — you will see a transcription-error rather than transcription-started. This is separate from enable_transcription_storage, which is the room flag for saving transcripts as WebVTT. Saved-storage and live-streaming transcription are two different switches; do not assume one enables the other.
DAILY_API_KEY is server-side only (route handlers), never shipped to the client.privacy: "private" and clients join with a short-lived meeting token, not the API key.@daily-co/daily-js is dynamically imported inside the effect (no top-level import in a component that can SSR).call.destroy() runs on unmount.transcription-error listener to surface the free-plan failure.enable_transcription_storage (save WebVTT) is set independently of live transcription.testing
--- name: linear-pipeline description: The Fable orchestrator for a single dispatched Linear ticket. Holds almost no context itself; it receives `--issue <ID> --detached`, decides the stage sequence, and fans out a sub-agent per stage, passing forward only each stage's artifact (never re-derived, never inlined into its own context). Step zero, before any planning or stage routing, is a boundary triage against `canon/security-boundary.md` (#199): a match tags Ronan Connolly and stops the run, no
development
--- name: in-your-face description: Capture a chat-only answer into a durable artifact (markdown + HTML, PDF when cheap) and launch it automatically so the user cannot miss it. Use when user says "in your face", "don't let me lose this", "save that answer", "make that durable", or right after answering a substantive side question (a recipe, comparison, how-to, or generated prompt) that would otherwise die with the context. category: workflow argument-hint: [--no-open] [--vault <short>] [hint of
tools
One-shot headless OpenAI Codex CLI calls for background/admin AI tasks — summaries, classification, extraction, admin glue. The default engine for anything that runs AI constantly in the background (daemon-driven, per-event), because it bills the flat ChatGPT subscription instead of Claude usage or per-token API spend, and it keeps working while Claude is rate-limited. NEVER for coding — coding stays Claude. Use when a skill or daemon needs a cheap always-on AI call, when the user says "use codex", "ask codex", "codex as backup", or when building a background summarizer/classifier into a listener or loop. Reads auth from ~/.codex/auth.json (ChatGPT account, no API key).
research
Turn a warranty rejection, repair quote, or RMA email into a cited decision brief — legal read (NL/EU consumer law), is the part user-serviceable, live part and new-unit prices, repair-vs-DIY-vs-new economics, before-you-send-it checklist, deadlines. Use when the user pastes or screenshots a repair quote, warranty rejection, "not covered" email, onderzoekskosten fee, or asks "should I repair or replace this".