
Quick Voice
- 12 installs
- 44 repo stars
- Updated July 8, 2026
- aviz85/claude-skills-library
quick-voice is a Claude skill that spins up a per-session browser voice app over OpenAI Realtime so an agent can close a topic in a spoken conversation instead of editing documents.
About
quick-voice generates a per-session browser web app that opens a WebRTC voice channel to OpenAI Realtime (gpt-realtime-2) with context-specific instructions, tools, and canvas behavior. A developer uses it to close a topic in a short spoken conversation instead of editing documents, either mutating real data live or producing a structured deliverable. It runs a local Node server, opens the browser, and writes the session output to output.md.
- Spins up a per-session browser voice app over OpenAI Realtime (gpt-realtime-2)
- Two modes: live (mutates real files/JSON via tools) and distill (structured deliverable, no data mutation)
- Generic canvas can display image, markdown, html, code, json, video, audio while you talk
Quick Voice by the numbers
- 12 all-time installs (skills.sh)
- Ranked #11,546 of 16,556 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
quick-voice capabilities & compatibility
Requires an OpenAI API key (OPENAI_API_KEY) for the Realtime session.
- Capabilities
- speech generator · transcribe
- Works with
- openai
- Use cases
- orchestration
- Runs
- Runs locally
- Pricing
- Bring your own API key
What quick-voice says it does
White-label voice session generator. Each invocation produces a per-session web app that opens a WebRTC voice channel to OpenAI Realtime (`gpt-realtime-2`)
The canvas is available in **both** modes.
Verifies `OPENAI_API_KEY` (from env or `~/.claude/skills/quick-voice/.env`)
npx skills add https://github.com/aviz85/claude-skills-library --skill quick-voiceAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 12 |
|---|---|
| repo stars | ★ 44 |
| Last updated | July 8, 2026 |
| Repository | aviz85/claude-skills-library ↗ |
What it does
Run a short voice conversation with an agent to walk through a list of items or close a topic faster than editing documents.
Who is it for?
Going through a list of items or closing a topic that needs a few decisions and updates faster by talking than by reading and clicking.
Skip if: Producing a long-form plan or working through a topic that is genuinely a document edit.
When should I use this skill?
User says let's close this in a voice call, run a quick voice session about X, or let's go over the images/leads/PRs/files/notes.
What you get
A structured session output (decisions, action items, notes) written to output.md after a short voice call.
- Per-session web app
- output.md session summary
- server.log
By the numbers
- Finds a free port in the 3031-3040 range
- Two modes (live, distill)
Files
quick-voice
White-label voice session generator. Each invocation produces a per-session web app that opens a WebRTC voice channel to OpenAI Realtime (gpt-realtime-2) with context-specific instructions, tools, and canvas behavior.
When to use
- "תפעיל איתי שיחה קולית על X" / "let's have a quick voice call about X"
- Going through a list of items where speaking is faster than reading + clicking
- Closing a topic that needs a few decisions + updates (not a long-form plan)
- Producing a structured output (decisions / action items / notes) from a free-form discussion
Two modes
| Mode | Tools | Output |
|---|---|---|
| live | File / JSON / bash tools available — agent updates real data during the call | A summary of changes made (in output.md) |
| distill | No data-mutation tools; canvas + save_note + end_session only | A long structured deliverable (decisions, notes, action items) in output.md |
The canvas is available in both modes.
How to run a session
Step 1 — figure out context
Look at the conversation. The user said one of:
- Explicit: "let's close the freelancer reviews" → topic = freelancer reviews
- Implicit: earlier in the conversation we generated 10 images → topic = "go over generated images"
- Vague: "תפעיל שיחה קולית" → call AskUserQuestion (single Q) to clarify topic + mode
Step 2 — pick a runtime directory + generate the session config
Each session has its own runtime directory holding config.json, output.md, server.log, done.flag. The runtime dir lives outside the skill so the skill itself stays code-only and project data stays with the project.
Pick a session id: id=$(date +%Y%m%d-%H%M%S).
Choose the runtime directory:
- Inside a project (git repo / codebase you're working in):
put it at <project-root>/.quick-voice/<id>/. Add .quick-voice/ to the project's .gitignore so session data never gets committed.
- No project context:
use /tmp/quick-voice-$USER/<id>/.
Create the directory and write config.json into it:
{
"mode": "live",
"topic": "Short Hebrew topic title",
"instructions": "Full Hebrew system prompt for the realtime agent. Tell it what to do, what to ask, when to use the canvas, when to call save_note, when to call end_session. Be specific about the flow.",
"voice": "ash",
"cwd": "/absolute/path/used/as/root/for/relative/file/ops",
"tools": ["canvas_show", "canvas_clear", "save_note", "end_session", "read_file", "list_dir", "update_json"],
"canvas_hints": [
{ "type": "image", "source": "/abs/path/to/image1.png", "title": "Image 1" },
{ "type": "image", "source": "/abs/path/to/image2.png", "title": "Image 2" }
],
"output_template": "# Session output\n\n## Decisions\n\n## Action items\n\n## Notes\n"
}Fields:
mode:"live"or"distill".topic: shown in the page header.instructions: the system prompt. Write it in Hebrew (Aviz prefers Hebrew). Be specific — describe the flow you want the agent to follow.voice:"ash"(default),"alloy","cedar", etc.cwd: directory the file tools are scoped to. Required if any file tool is enabled.tools: whitelist of tool names from the full set (seelib/tool-defs.js). For distill mode use only:canvas_show,canvas_clear,save_note,end_session. For live mode add file / JSON / bash tools as needed.canvas_hints: optional. If you pre-load items the agent should walk through, list them here. Otherwise the agent decides what to show.output_template: seedsoutput.mdso the agent has a structure to fill in viasave_note.
Step 3 — launch
node ~/.claude/skills/quick-voice/scripts/launch.js <runtime-dir><runtime-dir> is the absolute path to the directory you created in Step 2. The launcher reads <runtime-dir>/config.json and writes output.md, server.log, done.flag back into the same directory.
Cross-platform (macOS / Linux / Windows). This: 1. Verifies OPENAI_API_KEY (from env or ~/.claude/skills/quick-voice/.env) 2. Runs npm install once if node_modules is missing 3. Finds a free port in 3031-3040 (uses net.createServer — no shell needed) 4. Spawns server.js, polls /config until ready 5. Opens the default browser at http://localhost:<port> (open on macOS, xdg-open on Linux, start on Windows) 6. Waits for the user to end the session (close browser → /done is hit, or the agent calls end_session) 7. Prints output.md and exits
Step 4 — surface the output
After the launcher returns, read runtime/<id>/output.md and present it to the user. Do NOT delete the runtime dir automatically — the user may want to re-open or audit it. The session log is in runtime/<id>/server.log.
Available tools (full set)
See lib/tool-defs.js for OpenAI Realtime tool definitions and lib/tools.js for implementations. Whitelist via config.json.tools.
Canvas (both modes):
canvas_show({ type, source, title?, content? })— display in canvas.type∈image|markdown|html|code|json|video|audio|text|url.- For media (
image,video,audio): passsource(file path or URL). - For text-like (
markdown,html,code,json,text): pass eithercontent(inline string) ORsource(file path — the client fetches the file via/fileand renders it). If you have a long block already on disk, prefersource; if you're generating short content inline, usecontent. - For
url: passsource(iframe src). canvas_clear()— clear canvas.
Output / control (both modes):
save_note({ heading, content })— append a section tooutput.md.end_session({ summary? })— finalize and close.summaryis appended to output.md.
Data (live mode only):
read_file({ path })— read file undercwd.write_file({ path, content })— write file undercwd.append_file({ path, content })— append.update_json({ path, patch })— shallow-mergepatchinto a JSON file (object root only).list_dir({ path })— list directory contents.run_bash({ cmd })— run a shell command incwd. Use sparingly.
Examples
Example 1 — "let's go over the images you just created" (distill)
{
"mode": "distill",
"topic": "סקירת תמונות",
"instructions": "אתה מציג לאביץ תמונות אחת אחת. עבור כל תמונה: 1) קרא ל-canvas_show עם הנתיב מ-canvas_hints, 2) שאל 'מה דעתך?', 3) הקשב לתגובה, 4) קרא ל-save_note עם heading='[שם תמונה]' ו-content=[התגובה של אביץ]. כשמסיימים את כל התמונות — קרא ל-end_session.",
"voice": "ash",
"tools": ["canvas_show", "canvas_clear", "save_note", "end_session"],
"canvas_hints": [
{ "type": "image", "source": "/Users/aviz/aviz-crm/output/img-001.png", "title": "1" },
{ "type": "image", "source": "/Users/aviz/aviz-crm/output/img-002.png", "title": "2" }
],
"output_template": "# פידבק על תמונות\n\n"
}Example 2 — "review pending freelancer scores" (live)
{
"mode": "live",
"topic": "סקירת פרילנסרים",
"instructions": "פתח בקריאה ל-read_file({path: 'data/freelancers.json'}). הצג כל פרילנסר בקנבס (canvas_show type=json). שאל את אביץ לעדכון ציון. עדכן עם update_json. תעד ב-save_note. סיים עם end_session.",
"voice": "ash",
"cwd": "/Users/aviz/aviz-crm",
"tools": ["canvas_show", "canvas_clear", "save_note", "end_session", "read_file", "update_json", "list_dir"],
"output_template": "# Freelancer review session\n\n## Updates made\n\n"
}Example 3 — vague invocation
User says only "תפעיל שיחה קולית". Call AskUserQuestion once:
Question: "על מה השיחה?"
Options: [explicit topic the user types in via 'Other'], "סקירה חופשית — distill בלבד"
Then build the config from the answer.
Anti-patterns
- Don't bake secrets.
OPENAI_API_KEYcomes from~/.claude/skills/quick-voice/.env(or the project's.env). Never inline. - Don't generate huge instructions. Keep
instructions≤ 2KB. The agent needs to act fast. - Don't skip `cwd` when enabling file tools — it scopes the blast radius.
- Don't enable `run_bash` unless really needed. Prefer specific tools.
- Don't auto-delete `runtime/<id>/`. The user may want to re-open or audit.
Files in this skill
server.js— Express server. Reads$QV_RUNTIME_DIR/config.json, vends OpenAI ephemeral tokens, executes tool calls server-side, serves files via/file?path=....public/index.html,public/app.js— WebRTC client + generic canvas renderer (markdown/html/code/json/text can be loaded fromsourcepaths in addition to inlinecontent).lib/tool-defs.js— OpenAI Realtime tool schemas.lib/tools.js— server-side tool implementations.scripts/launch.js— cross-platform Node launcher: takes an absolute<runtime-dir>path, finds free port, spawns server, opens browser, waits for done.
Per-session files (config.json, output.md, server.log, done.flag) live in the runtime directory you picked — typically <project>/.quick-voice/<id>/ or /tmp/quick-voice-$USER/<id>/.
OPENAI_API_KEY=your_openai_api_key_here
runtime/*/
node_modules/
.env
!runtime/.gitkeep
// OpenAI Realtime tool definitions. The server hands a whitelisted subset to the
// client based on config.tools, and the client passes them into session.update().
const ALL_TOOLS = {
canvas_show: {
type: "function",
name: "canvas_show",
description:
"Display content in the canvas area. Use this to show the user something visual — an image, a markdown doc, a code snippet, a JSON object, a video, etc. The user sees it in real time. Use for 'let's go over X' flows: call canvas_show for item 1, wait for the user's reaction, then canvas_show for item 2, etc.",
parameters: {
type: "object",
properties: {
type: {
type: "string",
enum: ["image", "markdown", "html", "code", "json", "video", "audio", "text", "url"],
description: "Content type. image/video/audio use `source` (path or URL). markdown/html/code/json/text use `content` inline. url embeds a webpage in an iframe.",
},
source: { type: "string", description: "File path (absolute or relative to cwd) or URL. Used for image, video, audio, url types." },
content: { type: "string", description: "Inline content. Used for markdown, html, code, json, text types." },
title: { type: "string", description: "Optional title shown above the content." },
language: { type: "string", description: "Language for code blocks (e.g. 'javascript', 'python')." },
},
required: ["type"],
},
},
canvas_clear: {
type: "function",
name: "canvas_clear",
description: "Clear the canvas. Use between topics or when starting fresh.",
parameters: { type: "object", properties: {} },
},
save_note: {
type: "function",
name: "save_note",
description:
"Append a section to the session output document (output.md). Use this to record decisions, notes, action items, the user's reaction to something, or any takeaway that should survive the session. The final output.md is returned to the parent Claude session.",
parameters: {
type: "object",
properties: {
heading: { type: "string", description: "Short heading for this note (e.g. 'Image 3 — feedback', 'Decision: pricing')." },
content: { type: "string", description: "The note body. Plain text or markdown." },
},
required: ["heading", "content"],
},
},
end_session: {
type: "function",
name: "end_session",
description:
"End the voice session. Call this when the conversation is complete. Optionally pass a final summary that gets appended to output.md.",
parameters: {
type: "object",
properties: {
summary: { type: "string", description: "Optional final summary appended to output.md." },
},
},
},
read_file: {
type: "function",
name: "read_file",
description: "Read a file from disk (under cwd). Returns its content as a string. Use to pull in data the user wants to discuss.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "File path, absolute or relative to cwd." },
},
required: ["path"],
},
},
write_file: {
type: "function",
name: "write_file",
description: "Write a file (overwrites existing). Use when the user explicitly wants to save something.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "File path, absolute or relative to cwd." },
content: { type: "string", description: "File content." },
},
required: ["path", "content"],
},
},
append_file: {
type: "function",
name: "append_file",
description: "Append text to a file. Creates the file if missing.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "File path, absolute or relative to cwd." },
content: { type: "string", description: "Text to append." },
},
required: ["path", "content"],
},
},
update_json: {
type: "function",
name: "update_json",
description:
"Shallow-merge a patch object into the top-level object of a JSON file. The file's root must be a JSON object. Useful for surgically updating fields without rewriting whole files. For arrays-of-records, use array_update_json instead.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "JSON file path." },
patch: { type: "object", description: "Object whose keys/values are merged into the JSON root." },
},
required: ["path", "patch"],
},
},
array_update_json: {
type: "function",
name: "array_update_json",
description:
"Update one record in a JSON file whose root is either an array OR an object containing one array under a known key (like {customers: [...]} or {interactions: [...]}). Finds the record by `match_field` = `match_value` and merges `patch` into it.",
parameters: {
type: "object",
properties: {
path: { type: "string" },
array_key: { type: "string", description: "If root is {x: [...]}, the key name 'x'. Omit if root itself is an array." },
match_field: { type: "string", description: "Field name to match on (e.g. 'id')." },
match_value: { type: "string", description: "Field value to match." },
patch: { type: "object", description: "Fields to merge into the matched record." },
},
required: ["path", "match_field", "match_value", "patch"],
},
},
list_dir: {
type: "function",
name: "list_dir",
description: "List contents of a directory. Returns names + types (file/dir).",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Directory path, absolute or relative to cwd." },
},
required: ["path"],
},
},
run_bash: {
type: "function",
name: "run_bash",
description:
"Run a shell command in cwd. Use sparingly — prefer specific tools. Output is truncated to 4KB. Useful for git status, grep, find, jq, etc.",
parameters: {
type: "object",
properties: {
cmd: { type: "string", description: "Shell command (bash)." },
},
required: ["cmd"],
},
},
};
function selectTools(names) {
if (!Array.isArray(names) || names.length === 0) return [];
return names.map((n) => ALL_TOOLS[n]).filter(Boolean);
}
module.exports = { ALL_TOOLS, selectTools };
// Server-side tool implementations. Each takes (args, ctx) where ctx contains:
// - cwd: working directory for relative file paths
// - outputPath: absolute path to runtime/<id>/output.md
// - signalDone: callback to mark the session ended
// - log: function for writing to server.log
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
function resolvePath(p, cwd) {
if (!p) throw new Error("Missing path");
return path.isAbsolute(p) ? p : path.join(cwd, p);
}
function truncate(s, n) {
if (typeof s !== "string") s = String(s);
if (s.length <= n) return s;
return s.slice(0, n) + "\n…[truncated " + (s.length - n) + " chars]";
}
const HANDLERS = {
async canvas_show(args) {
return { ok: true, ...args };
},
async canvas_clear() {
return { ok: true };
},
async save_note(args, ctx) {
const heading = args.heading || "Note";
const content = args.content || "";
const ts = new Date().toISOString().replace("T", " ").slice(0, 19);
const section = `\n## ${heading}\n_${ts}_\n\n${content}\n`;
fs.appendFileSync(ctx.outputPath, section);
ctx.log(`save_note: ${heading} (${content.length} chars)`);
return { ok: true, heading };
},
async end_session(args, ctx) {
if (args.summary) {
const section = `\n## Final summary\n\n${args.summary}\n`;
fs.appendFileSync(ctx.outputPath, section);
}
ctx.log("end_session called");
ctx.signalDone();
return { ok: true, message: "Session ending. Goodbye." };
},
async read_file(args, ctx) {
const p = resolvePath(args.path, ctx.cwd);
const content = fs.readFileSync(p, "utf8");
return { ok: true, path: p, content: truncate(content, 50_000) };
},
async write_file(args, ctx) {
const p = resolvePath(args.path, ctx.cwd);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, args.content || "");
ctx.log(`write_file: ${p} (${(args.content || "").length} chars)`);
return { ok: true, path: p, bytes: (args.content || "").length };
},
async append_file(args, ctx) {
const p = resolvePath(args.path, ctx.cwd);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.appendFileSync(p, args.content || "");
return { ok: true, path: p };
},
async update_json(args, ctx) {
const p = resolvePath(args.path, ctx.cwd);
const raw = fs.readFileSync(p, "utf8");
const data = JSON.parse(raw);
if (typeof data !== "object" || Array.isArray(data) || data === null) {
throw new Error("update_json requires root to be a JSON object");
}
Object.assign(data, args.patch || {});
fs.writeFileSync(p, JSON.stringify(data, null, 2) + "\n");
ctx.log(`update_json: ${p} keys=${Object.keys(args.patch || {}).join(",")}`);
return { ok: true, path: p, patched_keys: Object.keys(args.patch || {}) };
},
async array_update_json(args, ctx) {
const p = resolvePath(args.path, ctx.cwd);
const raw = fs.readFileSync(p, "utf8");
const data = JSON.parse(raw);
let arr;
if (Array.isArray(data)) {
arr = data;
} else if (args.array_key && Array.isArray(data[args.array_key])) {
arr = data[args.array_key];
} else {
throw new Error("array_update_json: could not locate the array. Pass array_key for {key:[...]} layouts.");
}
const idx = arr.findIndex((r) => r && r[args.match_field] === args.match_value);
if (idx === -1) {
throw new Error(`No record where ${args.match_field}=${args.match_value}`);
}
Object.assign(arr[idx], args.patch || {});
fs.writeFileSync(p, JSON.stringify(data, null, 2) + "\n");
ctx.log(`array_update_json: ${p} ${args.match_field}=${args.match_value}`);
return { ok: true, path: p, updated_index: idx, record: arr[idx] };
},
async list_dir(args, ctx) {
const p = resolvePath(args.path, ctx.cwd);
const entries = fs.readdirSync(p, { withFileTypes: true }).map((e) => ({
name: e.name,
type: e.isDirectory() ? "dir" : "file",
}));
return { ok: true, path: p, entries };
},
async run_bash(args, ctx) {
const cmd = args.cmd;
if (!cmd) throw new Error("Missing cmd");
ctx.log(`run_bash: ${cmd}`);
const result = spawnSync("bash", ["-c", cmd], {
cwd: ctx.cwd,
encoding: "utf8",
maxBuffer: 4 * 1024 * 1024,
timeout: 60_000,
});
if (result.error) {
return { ok: false, stderr: String(result.error.message) };
}
return {
ok: result.status === 0,
exit_code: result.status,
stdout: truncate(result.stdout || "", 4000),
stderr: truncate(result.stderr || "", 2000),
};
},
};
async function executeTool(name, args, ctx) {
const fn = HANDLERS[name];
if (!fn) throw new Error("Unknown tool: " + name);
return await fn(args || {}, ctx);
}
module.exports = { executeTool, HANDLERS };
{
"name": "quick-voice",
"version": "1.0.0",
"description": "Instant browser voice session generator (OpenAI Realtime v2) - white-label, per-invocation config",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"dotenv": "^17.4.2",
"express": "^4.21.0"
}
}
// quick-voice client — WebRTC to OpenAI Realtime, generic canvas, tool dispatch.
// NOTE: innerHTML is intentionally used for markdown/html canvas types — that
// IS their purpose. Content originates from a Realtime agent the user
// configured locally; this app is loopback-only and never exposed to the web.
let pc = null;
let dc = null;
let micTrack = null;
let isConnected = false;
let isMuted = false;
let config = null;
const statusDot = document.getElementById("statusDot");
const statusText = document.getElementById("statusText");
const statusMode = document.getElementById("statusMode");
const connectBtn = document.getElementById("connectBtn");
const muteBtn = document.getElementById("muteBtn");
const doneBtn = document.getElementById("doneBtn");
const transcript = document.getElementById("transcript");
const audioOutput = document.getElementById("audioOutput");
const topicTitle = document.getElementById("topicTitle");
const topicSub = document.getElementById("topicSub");
const canvasEl = document.getElementById("canvas");
const canvasBody = document.getElementById("canvasBody");
const canvasTitle = document.getElementById("canvasTitle");
const canvasToggle = document.getElementById("canvasToggle");
const canvasEmpty = document.getElementById("canvasEmpty");
let canvasCollapsed = false;
document.querySelector(".canvas-header").addEventListener("click", () => {
canvasCollapsed = !canvasCollapsed;
canvasEl.classList.toggle("collapsed", canvasCollapsed);
canvasToggle.textContent = canvasCollapsed ? "◀" : "▼";
});
connectBtn.addEventListener("click", toggleConnection);
muteBtn.addEventListener("click", toggleMute);
doneBtn.addEventListener("click", endSession);
function setStatus(state) {
statusDot.className = "status-dot " + state;
const labels = { "": "מנותק", connecting: "מתחבר…", connected: "מחובר — מקשיב" };
statusText.textContent = labels[state] || state;
}
function addMessage(role, text) {
const div = document.createElement("div");
div.className = "msg " + role;
const roleEl = document.createElement("div");
roleEl.className = "role";
roleEl.textContent = { user: "אתה", assistant: "סוכן", tool: "כלי" }[role] || role;
div.appendChild(roleEl);
div.appendChild(document.createTextNode(text));
transcript.appendChild(div);
transcript.scrollTop = transcript.scrollHeight;
}
function updateLastAssistant(delta) {
const msgs = transcript.querySelectorAll(".msg.assistant");
if (!msgs.length) return;
const last = msgs[msgs.length - 1];
if (!last._fullText) last._fullText = "";
last._fullText += delta;
while (last.childNodes.length > 1) last.removeChild(last.lastChild);
last.appendChild(document.createTextNode(last._fullText));
transcript.scrollTop = transcript.scrollHeight;
}
function setEmptyState(text) {
while (canvasBody.firstChild) canvasBody.removeChild(canvasBody.firstChild);
const e = document.createElement("div");
e.className = "canvas-empty";
e.textContent = text;
canvasBody.appendChild(e);
}
function resolveSource(src) {
if (!src) return "";
if (/^https?:\/\//i.test(src) || src.startsWith("data:") || src.startsWith("blob:")) return src;
return "/file?path=" + encodeURIComponent(src);
}
// Fetch text content from a source path (e.g. /tmp/x.md). Used by canvas types
// that take raw text (markdown, html, code, json, text).
async function loadSourceText(src) {
if (!src) return "";
const url = resolveSource(src);
const r = await fetch(url);
if (!r.ok) throw new Error("fetch " + url + " -> " + r.status);
return await r.text();
}
function showInCanvas({ type, source, content, title, language }) {
while (canvasBody.firstChild) canvasBody.removeChild(canvasBody.firstChild);
const wrap = document.createElement("div");
wrap.className = "canvas-content";
if (title) {
const t = document.createElement("div");
t.className = "item-title";
t.textContent = title;
wrap.appendChild(t);
canvasTitle.textContent = title;
} else {
canvasTitle.textContent = "קנבס";
}
switch (type) {
case "image": {
const img = document.createElement("img");
img.className = "canvas-image";
img.src = resolveSource(source);
img.alt = title || "";
wrap.appendChild(img);
break;
}
case "video": {
const v = document.createElement("video");
v.className = "canvas-video";
v.src = resolveSource(source);
v.controls = true;
wrap.appendChild(v);
break;
}
case "audio": {
const a = document.createElement("audio");
a.className = "canvas-audio";
a.src = resolveSource(source);
a.controls = true;
wrap.appendChild(a);
break;
}
case "url": {
const f = document.createElement("iframe");
f.className = "canvas-iframe";
f.src = source;
wrap.appendChild(f);
break;
}
case "markdown": {
const md = document.createElement("div");
md.className = "canvas-markdown";
if (content) {
md.innerHTML = renderMarkdown(content);
} else if (source) {
md.textContent = "Loading…";
loadSourceText(source)
.then((t) => { md.innerHTML = renderMarkdown(t); })
.catch((e) => { md.textContent = "Failed to load: " + e.message; });
} else {
md.textContent = "";
}
wrap.appendChild(md);
break;
}
case "html": {
const h = document.createElement("div");
if (content) {
h.innerHTML = content;
} else if (source) {
h.textContent = "Loading…";
loadSourceText(source)
.then((t) => { h.innerHTML = t; })
.catch((e) => { h.textContent = "Failed to load: " + e.message; });
}
wrap.appendChild(h);
break;
}
case "code": {
const c = document.createElement("pre");
c.className = "canvas-code";
if (language) c.dataset.lang = language;
if (content) {
c.textContent = content;
} else if (source) {
c.textContent = "Loading…";
loadSourceText(source)
.then((t) => { c.textContent = t; })
.catch((e) => { c.textContent = "Failed to load: " + e.message; });
}
wrap.appendChild(c);
break;
}
case "json": {
const j = document.createElement("pre");
j.className = "canvas-json";
const renderJson = (raw) => {
try {
const obj = typeof raw === "string" ? JSON.parse(raw) : raw;
j.textContent = JSON.stringify(obj, null, 2);
} catch (e) {
j.textContent = String(raw);
}
};
if (content !== undefined) {
renderJson(content);
} else if (source) {
j.textContent = "Loading…";
loadSourceText(source).then(renderJson)
.catch((e) => { j.textContent = "Failed to load: " + e.message; });
}
wrap.appendChild(j);
break;
}
case "text":
default: {
const tx = document.createElement("div");
tx.className = "canvas-text";
if (content) {
tx.textContent = content;
} else if (source) {
tx.textContent = "Loading…";
loadSourceText(source)
.then((t) => { tx.textContent = t; })
.catch((e) => { tx.textContent = "Failed to load: " + e.message; });
}
wrap.appendChild(tx);
}
}
canvasBody.appendChild(wrap);
}
function clearCanvas() {
setEmptyState("קנבס נוקה");
canvasTitle.textContent = "קנבס";
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function renderMarkdown(md) {
let h = escapeHtml(md);
// GFM tables: header row + separator row + data rows
h = h.replace(
/(?:^\|.+\|[ \t]*\n)(?:^\|[\s\-:|]+\|[ \t]*\n)(?:^\|.+\|[ \t]*\n?)+/gm,
(block) => {
const lines = block.trim().split("\n").filter(Boolean);
const parseRow = (line) =>
line.replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
const header = parseRow(lines[0]);
const rows = lines.slice(2).map(parseRow);
let out = '<table class="md-table"><thead><tr>';
for (const c of header) out += "<th>" + c + "</th>";
out += "</tr></thead><tbody>";
for (const row of rows) {
out += "<tr>";
for (const c of row) out += "<td>" + c + "</td>";
out += "</tr>";
}
out += "</tbody></table>";
return out;
}
);
h = h.replace(/^### (.*$)/gm, "<h3>$1</h3>");
h = h.replace(/^## (.*$)/gm, "<h2>$1</h2>");
h = h.replace(/^# (.*$)/gm, "<h1>$1</h1>");
h = h.replace(/```([\s\S]*?)```/g, (_, code) => "<pre>" + code + "</pre>");
h = h.replace(/`([^`]+)`/g, "<code>$1</code>");
h = h.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
h = h.replace(/\*([^*]+)\*/g, "<em>$1</em>");
h = h.replace(/^\- (.*$)/gm, "<li>$1</li>");
h = h.replace(/(<li>.*<\/li>)/gs, "<ul>$1</ul>");
h = h.replace(/\n\n/g, "</p><p>");
return "<p>" + h + "</p>";
}
async function executeTool(name, args) {
if (name === "canvas_show") { showInCanvas(args); return { ok: true }; }
if (name === "canvas_clear") { clearCanvas(); return { ok: true }; }
const r = await fetch("/tool/" + encodeURIComponent(name), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(args),
});
const result = await r.json();
if (name === "end_session") setTimeout(() => endSession(), 1200);
return result;
}
async function handleFunctionCall(item) {
let args = {};
try { args = JSON.parse(item.arguments || "{}"); } catch {}
const preview = JSON.stringify(args).slice(0, 120);
addMessage("tool", item.name + "(" + preview + ")");
const result = await executeTool(item.name, args);
const resultStr = JSON.stringify(result);
addMessage("tool", "← " + resultStr.slice(0, 200) + (resultStr.length > 200 ? "…" : ""));
if (!dc || dc.readyState !== "open") return;
dc.send(JSON.stringify({
type: "conversation.item.create",
item: {
type: "function_call_output",
call_id: item.call_id,
output: resultStr,
},
}));
dc.send(JSON.stringify({ type: "response.create" }));
}
function handleServerEvent(event) {
switch (event.type) {
case "session.created":
setStatus("connected");
isConnected = true;
break;
case "conversation.item.input_audio_transcription.completed": {
const t = event.transcript || "[לא ברור]";
if (t.trim()) addMessage("user", t);
break;
}
case "response.audio_transcript.delta": {
const last = transcript.querySelector(".msg.assistant:last-of-type");
if (!last || last.dataset.done === "true") addMessage("assistant", event.delta || "");
else updateLastAssistant(event.delta || "");
break;
}
case "response.output_item.done": {
const msgs = transcript.querySelectorAll(".msg.assistant");
if (msgs.length) msgs[msgs.length - 1].dataset.done = "true";
break;
}
case "response.done": {
if (event.response && event.response.output) {
for (const out of event.response.output) {
if (out.type === "function_call" && out.name) handleFunctionCall(out);
}
}
break;
}
}
}
async function toggleConnection() {
if (isConnected) { endSession(); return; }
connectBtn.disabled = true;
setStatus("connecting");
connectBtn.textContent = "מתחבר…";
try {
const tokenRes = await fetch("/token");
if (!tokenRes.ok) throw new Error("token http " + tokenRes.status);
const tokenData = await tokenRes.json();
const ek = tokenData.value || (tokenData.client_secret ? tokenData.client_secret.value : null);
if (!ek) throw new Error("no ephemeral key");
pc = new RTCPeerConnection();
pc.ontrack = (e) => { audioOutput.srcObject = e.streams[0]; };
const ms = await navigator.mediaDevices.getUserMedia({ audio: true });
micTrack = ms.getTracks()[0];
pc.addTrack(micTrack);
dc = pc.createDataChannel("oai-events");
dc.addEventListener("open", () => {
dc.send(JSON.stringify({
type: "session.update",
session: {
type: "realtime",
instructions: config.instructions,
tools: config.tools,
tool_choice: "auto",
audio: {
input: {
transcription: { model: "whisper-1" },
turn_detection: { type: "server_vad" },
},
},
},
}));
if (config.canvas_hints && config.canvas_hints.length) {
const hintsText = config.canvas_hints
.map((h, i) => `${i + 1}. type=${h.type} title="${h.title || ""}" source="${h.source || ""}"`)
.join("\n");
dc.send(JSON.stringify({
type: "conversation.item.create",
item: {
type: "message",
role: "user",
content: [{
type: "input_text",
text: "(System note — items available to display via canvas_show:\n" + hintsText + "\n)",
}],
},
}));
}
dc.send(JSON.stringify({ type: "response.create" }));
});
dc.addEventListener("message", (e) => {
try {
const event = JSON.parse(e.data);
if (event.type === "error") console.error("Realtime error:", event);
handleServerEvent(event);
} catch (err) {
console.error("parse error", err);
}
});
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const sdpRes = await fetch("https://api.openai.com/v1/realtime/calls", {
method: "POST",
body: offer.sdp,
headers: { Authorization: "Bearer " + ek, "Content-Type": "application/sdp" },
});
if (!sdpRes.ok) throw new Error("sdp " + sdpRes.status + ": " + (await sdpRes.text()).slice(0, 200));
const answerSdp = await sdpRes.text();
await pc.setRemoteDescription({ type: "answer", sdp: answerSdp });
connectBtn.textContent = "סיים שיחה";
connectBtn.className = "connect-btn stop";
connectBtn.disabled = false;
muteBtn.style.display = "block";
doneBtn.style.display = "block";
} catch (err) {
console.error("connect failed", err);
addMessage("tool", "שגיאת חיבור: " + err.message);
setStatus("");
connectBtn.textContent = "התחל שיחה";
connectBtn.className = "connect-btn start";
connectBtn.disabled = false;
}
}
function toggleMute() {
if (!micTrack) return;
isMuted = !isMuted;
micTrack.enabled = !isMuted;
muteBtn.textContent = isMuted ? "🔇" : "🎙️";
muteBtn.classList.toggle("muted", isMuted);
}
async function endSession() {
try {
if (dc) dc.close();
if (pc) {
pc.getSenders().forEach((s) => { if (s.track) s.track.stop(); });
pc.close();
}
} catch {}
pc = null; dc = null; micTrack = null;
isConnected = false; isMuted = false;
setStatus("");
connectBtn.textContent = "השיחה הסתיימה";
connectBtn.disabled = true;
muteBtn.style.display = "none";
doneBtn.style.display = "none";
addMessage("tool", "השיחה הסתיימה. הפלט נשמר.");
try { await fetch("/done", { method: "POST" }); } catch {}
}
window.addEventListener("beforeunload", () => {
navigator.sendBeacon && navigator.sendBeacon("/done");
});
async function init() {
try {
const r = await fetch("/config");
config = await r.json();
topicTitle.textContent = config.topic || "Quick voice";
topicSub.textContent = config.mode === "distill"
? "מצב distill — שיחה → סיכום מובנה"
: "מצב live — עבודה על דאטה בזמן אמת";
statusMode.textContent = config.mode;
setStatus("");
} catch (err) {
statusText.textContent = "שגיאת טעינה: " + err.message;
}
}
init();
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quick Voice Session</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="header">
<h1 id="topicTitle">Quick Voice</h1>
<p id="topicSub">טוען…</p>
</div>
<div class="main">
<div class="status-bar">
<div class="status-dot" id="statusDot"></div>
<span class="status-text" id="statusText">מנותק</span>
<span class="status-mode" id="statusMode"></span>
</div>
<div class="action-row">
<button class="connect-btn start" id="connectBtn">התחל שיחה</button>
<button class="mute-btn" id="muteBtn" title="השתק">🎙️</button>
<button class="mute-btn" id="doneBtn" title="סיים שיחה">✓</button>
</div>
<div class="canvas" id="canvas">
<div class="canvas-header">
<span class="canvas-title" id="canvasTitle">קנבס</span>
<span class="canvas-toggle" id="canvasToggle">▼</span>
</div>
<div class="canvas-body" id="canvasBody">
<div class="canvas-empty" id="canvasEmpty">התוכן יופיע כאן במהלך השיחה</div>
</div>
</div>
<div class="transcript" id="transcript"></div>
</div>
<audio id="audioOutput" autoplay></audio>
<script src="app.js"></script>
</body>
</html>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0a0a0a;
color: #e0e0e0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
.header {
padding: 20px 24px 16px;
text-align: center;
border-bottom: 1px solid #222;
width: 100%;
}
.header h1 { font-size: 1.3rem; color: #fff; }
.header p { font-size: 0.85rem; color: #888; margin-top: 4px; }
.main {
flex: 1;
width: 100%;
max-width: 900px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.status-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: #151515;
border-radius: 10px;
border: 1px solid #222;
}
.status-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: #555;
transition: background 0.3s;
}
.status-dot.connected { background: #4ade80; }
.status-dot.connecting { background: #fbbf24; animation: pulse 1s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.status-text { font-size: 0.9rem; }
.status-mode { font-size: 0.75rem; color: #8b5cf6; margin-right: auto; padding: 2px 8px; background: #1e1033; border-radius: 8px; }
.action-row { display: flex; gap: 8px; }
.connect-btn {
padding: 14px 28px;
font-size: 1.05rem;
font-weight: 600;
border: none;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
flex: 1;
}
.connect-btn.start {
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
color: #fff;
}
.connect-btn.start:hover { transform: scale(1.02); opacity: 0.9; }
.connect-btn.stop { background: #dc2626; color: #fff; }
.connect-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.mute-btn {
padding: 14px;
font-size: 1.1rem;
border: 2px solid #333;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
background: #151515;
color: #ccc;
min-width: 56px;
display: none;
}
.mute-btn:hover { border-color: #555; }
.mute-btn.muted { border-color: #dc2626; background: #1a0a0a; color: #f87171; }
.transcript {
background: #111;
border: 1px solid #222;
border-radius: 10px;
padding: 14px;
overflow-y: auto;
max-height: 28vh;
min-height: 80px;
}
.msg {
padding: 7px 12px;
margin-bottom: 7px;
border-radius: 8px;
font-size: 0.9rem;
line-height: 1.5;
}
.msg.user { background: #1e293b; text-align: right; }
.msg.assistant { background: #1a1a2e; border-right: 3px solid #8b5cf6; }
.msg.tool { background: #0d1117; color: #7dd3fc; font-size: 0.78rem; font-family: 'SF Mono', Consolas, monospace; }
.msg .role { font-weight: 600; font-size: 0.72rem; color: #888; margin-bottom: 2px; }
/* Canvas */
.canvas {
background: #111;
border: 1px solid #222;
border-radius: 10px;
overflow: hidden;
transition: max-height 0.3s ease;
display: flex;
flex-direction: column;
flex: 1;
min-height: 320px;
}
.canvas.collapsed .canvas-body { display: none; }
.canvas.collapsed { min-height: auto; }
.canvas-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
background: #161616;
cursor: pointer;
user-select: none;
}
.canvas-header:hover { background: #1a1a1a; }
.canvas-title { font-size: 0.9rem; font-weight: 600; color: #ccc; }
.canvas-toggle { font-size: 0.8rem; color: #666; }
.canvas-body {
padding: 16px;
overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
}
.canvas-empty {
text-align: center;
padding: 40px 16px;
color: #555;
font-size: 0.9rem;
margin: auto;
}
/* Canvas content types */
.canvas-content { width: 100%; }
.canvas-content > .item-title {
font-size: 0.9rem;
font-weight: 600;
color: #fff;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid #222;
}
.canvas-image {
max-width: 100%;
max-height: 60vh;
border-radius: 8px;
display: block;
margin: 0 auto;
background: #050505;
}
.canvas-video {
max-width: 100%;
max-height: 60vh;
border-radius: 8px;
display: block;
margin: 0 auto;
}
.canvas-audio {
width: 100%;
margin: 12px 0;
}
.canvas-iframe {
width: 100%;
height: 60vh;
border: 1px solid #222;
border-radius: 8px;
background: #fff;
}
.canvas-markdown {
font-size: 0.95rem;
line-height: 1.7;
color: #e0e0e0;
text-align: right;
direction: rtl;
}
.canvas-markdown h1, .canvas-markdown h2, .canvas-markdown h3 { color: #fff; margin: 12px 0 6px; }
.canvas-markdown p { margin-bottom: 8px; }
.canvas-markdown ul, .canvas-markdown ol { padding-right: 20px; margin-bottom: 8px; }
.canvas-markdown code { background: #1a1a2e; padding: 2px 6px; border-radius: 4px; font-size: 0.85em; }
.canvas-markdown pre {
background: #0d0d1a;
padding: 12px;
border-radius: 8px;
overflow-x: auto;
direction: ltr;
text-align: left;
font-size: 0.85em;
margin: 8px 0;
}
.canvas-markdown .md-table {
border-collapse: collapse;
margin: 12px 0;
width: 100%;
font-size: 0.9em;
overflow-x: auto;
display: table;
}
.canvas-markdown .md-table th,
.canvas-markdown .md-table td {
border: 1px solid #2a2a3a;
padding: 6px 10px;
text-align: start;
vertical-align: top;
}
.canvas-markdown .md-table th {
background: #1a1a2e;
color: #fff;
font-weight: 600;
}
.canvas-markdown .md-table tbody tr:nth-child(even) {
background: #15151f;
}
.canvas-code {
font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
font-size: 0.85rem;
line-height: 1.5;
background: #0d0d1a;
padding: 14px;
border-radius: 8px;
overflow-x: auto;
white-space: pre;
direction: ltr;
text-align: left;
color: #e0e0e0;
}
.canvas-json {
font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
font-size: 0.85rem;
line-height: 1.55;
background: #0d0d1a;
padding: 14px;
border-radius: 8px;
overflow-x: auto;
white-space: pre;
direction: ltr;
text-align: left;
color: #a5d8ff;
}
.canvas-text {
white-space: pre-wrap;
font-size: 0.95rem;
line-height: 1.6;
color: #e0e0e0;
}
.canvas-hint {
font-size: 0.75rem;
color: #666;
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid #1a1a1a;
direction: ltr;
text-align: left;
}
#!/usr/bin/env node
// quick-voice launcher (cross-platform: macOS, Linux, Windows).
//
// Usage: node scripts/launch.js <runtime-dir>
//
// <runtime-dir> is the absolute path to a directory containing config.json.
// The launcher writes output.md, server.log, done.flag into the SAME directory.
// Pick the location per-session — typical choices:
// * Project-local: /Users/.../<project>/.quick-voice/<id>/
// * Temp (no project): /tmp/quick-voice-<user>/<id>/
//
// 1. Verifies OPENAI_API_KEY (env or .env)
// 2. Runs `npm install` once if node_modules is missing
// 3. Finds a free port in 3031..3040
// 4. Spawns server.js, waits for /config to respond
// 5. Opens the default browser at http://localhost:<port>
// 6. Waits until done.flag appears OR the server process exits
// 7. Prints <runtime-dir>/output.md and exits
const fs = require("fs");
const net = require("net");
const path = require("path");
const http = require("http");
const { spawn, spawnSync } = require("child_process");
const SKILL_DIR = path.resolve(__dirname, "..");
const arg = process.argv[2];
if (!arg) {
console.error("Usage: node scripts/launch.js <runtime-dir>");
console.error(" <runtime-dir> must be an absolute path containing config.json.");
console.error(" Place it inside your project (e.g. <project>/.quick-voice/<id>/)");
console.error(" or under /tmp if no project is involved.");
process.exit(1);
}
if (!path.isAbsolute(arg)) {
console.error("ERROR: runtime-dir must be an absolute path. Got:", arg);
process.exit(1);
}
const RUNTIME_DIR = arg;
const sessionId = path.basename(RUNTIME_DIR);
const CONFIG = path.join(RUNTIME_DIR, "config.json");
const OUTPUT = path.join(RUNTIME_DIR, "output.md");
const DONE_FLAG = path.join(RUNTIME_DIR, "done.flag");
const LOG = path.join(RUNTIME_DIR, "server.log");
if (!fs.existsSync(CONFIG)) {
console.error("Missing config:", CONFIG);
process.exit(1);
}
// --- Resolve OPENAI_API_KEY ---
function loadEnvFile(p) {
if (!fs.existsSync(p)) return {};
const out = {};
for (const line of fs.readFileSync(p, "utf8").split(/\r?\n/)) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/);
if (!m) continue;
let v = m[2];
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.slice(1, -1);
}
out[m[1]] = v;
}
return out;
}
let OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const envCandidates = [
path.join(SKILL_DIR, ".env"),
path.join(process.env.HOME || "", "aviz-crm", "voice-app", ".env"),
];
const envFromFile = {};
for (const ec of envCandidates) {
Object.assign(envFromFile, loadEnvFile(ec));
}
if (!OPENAI_API_KEY) OPENAI_API_KEY = envFromFile.OPENAI_API_KEY;
if (!OPENAI_API_KEY) {
console.error("ERROR: OPENAI_API_KEY not set. Put it in", path.join(SKILL_DIR, ".env"));
process.exit(1);
}
// --- Install deps if missing ---
if (!fs.existsSync(path.join(SKILL_DIR, "node_modules"))) {
console.log("Installing dependencies (one-time)...");
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
const r = spawnSync(npmCmd, ["install", "--silent", "--no-audit", "--no-fund"], {
cwd: SKILL_DIR,
stdio: "inherit",
shell: process.platform === "win32",
});
if (r.status !== 0) {
console.error("npm install failed");
process.exit(r.status || 1);
}
}
// --- Find a free port in 3031..3040 ---
function isPortFree(port) {
return new Promise((resolve) => {
const srv = net.createServer();
srv.once("error", () => resolve(false));
srv.once("listening", () => srv.close(() => resolve(true)));
srv.listen(port, "127.0.0.1");
});
}
async function findFreePort() {
for (let p = 3031; p <= 3040; p++) {
if (await isPortFree(p)) return p;
}
throw new Error("No free port in 3031-3040");
}
// --- Open default browser cross-platform ---
function openBrowser(url) {
const platform = process.platform;
try {
if (platform === "darwin") {
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
} else if (platform === "win32") {
spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore", shell: true }).unref();
} else {
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
}
} catch (err) {
console.warn("Could not open browser automatically:", err.message);
console.warn("Open this URL manually:", url);
}
}
// --- Wait for HTTP endpoint to respond (server readiness) ---
function waitForServer(port, maxMs = 8000) {
return new Promise((resolve) => {
const start = Date.now();
function attempt() {
const req = http.get({ host: "127.0.0.1", port, path: "/config", timeout: 500 }, (res) => {
res.resume();
if (res.statusCode === 200) return resolve(true);
retry();
});
req.on("error", retry);
req.on("timeout", () => { req.destroy(); retry(); });
}
function retry() {
if (Date.now() - start > maxMs) return resolve(false);
setTimeout(attempt, 200);
}
attempt();
});
}
// --- Main ---
(async () => {
// Reset done flag from any previous run
try { fs.unlinkSync(DONE_FLAG); } catch {}
const port = await findFreePort();
console.log(`Starting quick-voice session on port ${port}...`);
console.log(` session: ${sessionId}`);
console.log(` runtime: ${RUNTIME_DIR}`);
console.log(` output: ${OUTPUT}`);
// Log file gets stdout/stderr from server
const logStream = fs.openSync(LOG, "a");
fs.writeSync(logStream, `\n--- launch ${new Date().toISOString()} ---\n`);
const child = spawn(process.execPath, [path.join(SKILL_DIR, "server.js")], {
cwd: SKILL_DIR,
env: {
...process.env,
OPENAI_API_KEY,
QV_PORT: String(port),
QV_SESSION_ID: sessionId,
QV_RUNTIME_DIR: RUNTIME_DIR,
},
stdio: ["ignore", logStream, logStream],
});
let serverExited = false;
let serverExitCode = null;
child.on("exit", (code) => {
serverExited = true;
serverExitCode = code;
});
// Graceful shutdown if the launcher is killed
const shutdown = () => {
if (!serverExited) {
try { child.kill("SIGTERM"); } catch {}
}
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
const ready = await waitForServer(port);
if (!ready) {
console.error("Server did not become ready in 8s. Check", LOG);
try { child.kill("SIGTERM"); } catch {}
process.exit(1);
}
const url = `http://localhost:${port}`;
console.log(`Opening ${url}`);
openBrowser(url);
// Poll for done.flag or server exit
const pollMs = 1000;
while (!serverExited) {
if (fs.existsSync(DONE_FLAG)) {
// Give the server a moment to finish writing output.md, then stop it.
await new Promise((r) => setTimeout(r, 1200));
try { child.kill("SIGTERM"); } catch {}
break;
}
await new Promise((r) => setTimeout(r, pollMs));
}
// Wait briefly for clean exit
for (let i = 0; i < 20 && !serverExited; i++) {
await new Promise((r) => setTimeout(r, 100));
}
console.log("");
console.log(`===== Session output: ${OUTPUT} =====`);
if (fs.existsSync(OUTPUT)) {
process.stdout.write(fs.readFileSync(OUTPUT, "utf8"));
} else {
console.log("(no output written)");
}
console.log(`===== End =====`);
process.exit(serverExitCode === null ? 0 : (serverExitCode || 0));
})().catch((err) => {
console.error("Launcher failed:", err);
process.exit(1);
});
require("dotenv").config();
const express = require("express");
const fs = require("fs");
const path = require("path");
const { selectTools } = require("./lib/tool-defs");
const { executeTool } = require("./lib/tools");
const SESSION_ID = process.env.QV_SESSION_ID;
const RUNTIME_DIR = process.env.QV_RUNTIME_DIR;
if (!RUNTIME_DIR) {
console.error("Missing QV_RUNTIME_DIR. The launcher must pass an absolute runtime-dir path.");
process.exit(1);
}
const CONFIG_PATH = path.join(RUNTIME_DIR, "config.json");
const OUTPUT_PATH = path.join(RUNTIME_DIR, "output.md");
const LOG_PATH = path.join(RUNTIME_DIR, "server.log");
const DONE_FLAG = path.join(RUNTIME_DIR, "done.flag");
if (!process.env.OPENAI_API_KEY) {
console.error("Missing OPENAI_API_KEY. Set it in ~/.claude/skills/quick-voice/.env");
process.exit(1);
}
if (!fs.existsSync(CONFIG_PATH)) {
console.error("Missing config:", CONFIG_PATH);
process.exit(1);
}
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
function log(line) {
const ts = new Date().toISOString();
fs.appendFileSync(LOG_PATH, `[${ts}] ${line}\n`);
}
if (!fs.existsSync(OUTPUT_PATH)) {
const header = `# ${config.topic || "Quick voice session"}\n\n_Session ${SESSION_ID} — ${new Date().toISOString()}_\n`;
const template = config.output_template ? "\n" + config.output_template : "";
fs.writeFileSync(OUTPUT_PATH, header + template);
}
let doneSignaled = false;
function signalDone() {
if (!doneSignaled) {
doneSignaled = true;
fs.writeFileSync(DONE_FLAG, new Date().toISOString());
log("done.flag written");
setTimeout(() => process.exit(0), 1500);
}
}
const toolDefs = selectTools(config.tools || []);
log(`Loaded config: topic="${config.topic}" mode=${config.mode} tools=[${(config.tools || []).join(",")}]`);
const ctx = {
cwd: config.cwd || process.cwd(),
outputPath: OUTPUT_PATH,
signalDone,
log,
};
const app = express();
app.use(express.json({ limit: "10mb" }));
app.use(express.static(path.join(__dirname, "public"), {
setHeaders: (res) => res.set("Cache-Control", "no-store"),
}));
app.get("/config", (req, res) => {
res.json({
topic: config.topic || "Voice session",
mode: config.mode || "live",
instructions: config.instructions || "You are a helpful voice assistant.",
voice: config.voice || "ash",
tools: toolDefs,
canvas_hints: config.canvas_hints || [],
session_id: SESSION_ID,
});
});
app.get("/token", async (req, res) => {
try {
const r = await fetch("https://api.openai.com/v1/realtime/client_secrets", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
session: {
type: "realtime",
model: "gpt-realtime-2",
output_modalities: ["audio"],
instructions: config.instructions || "You are a helpful voice assistant.",
audio: {
output: { voice: config.voice || "ash" },
input: { transcription: { model: "whisper-1" } },
},
},
}),
});
if (!r.ok) {
const text = await r.text();
log(`token error ${r.status}: ${text}`);
return res.status(r.status).json({ error: text });
}
const data = await r.json();
res.json(data);
} catch (err) {
log(`token exception: ${err.message}`);
res.status(500).json({ error: err.message });
}
});
app.post("/tool/:name", async (req, res) => {
const name = req.params.name;
try {
log(`tool call: ${name} ${JSON.stringify(req.body).slice(0, 200)}`);
const result = await executeTool(name, req.body || {}, ctx);
res.json(result);
} catch (err) {
log(`tool error ${name}: ${err.message}`);
res.status(400).json({ ok: false, error: err.message });
}
});
// Serve a local file (image, video, etc.) from disk. Scoped to cwd for safety.
app.get("/file", (req, res) => {
const p = req.query.path;
if (!p) return res.status(400).send("missing path");
const absolute = path.isAbsolute(p) ? p : path.join(ctx.cwd, p);
// Light-weight scope check: must live under cwd or be in a public-ish location.
if (!fs.existsSync(absolute)) return res.status(404).send("not found");
res.sendFile(absolute);
});
app.post("/done", (req, res) => {
signalDone();
res.json({ ok: true });
});
const PORT = process.env.QV_PORT || 3031;
app.listen(PORT, () => {
console.log(`quick-voice session "${config.topic}" — http://localhost:${PORT}`);
console.log(`runtime: ${RUNTIME_DIR}`);
log(`server listening on ${PORT}`);
});
Related skills
FAQ
What two modes does quick-voice support?
Live mode, where file/JSON/bash tools mutate real data during the call, and distill mode, which has no data-mutation tools and ends with a structured deliverable in output.md.
What can the canvas display?
The canvas is available in both modes and can display image, markdown, html, code, json, video, audio, text, and url content.