Skip to content

Commit 21d14b2

Browse files
Vitozhu04claude
andauthored
feat(media-use): reach HeyGen through a host app's gateway (HEYGEN_API_BASE) (#5181)
* feat(media-use): reach HeyGen through a host app's gateway (HEYGEN_API_BASE) - The audio engine sends every HeyGen call to $HEYGEN_API_BASE when set, as the heygen CLI does; plain HTTP needs $HEYGEN_ALLOW_HTTP=1, the CLI's own rule. A host gateway (that base with its own key) wins over a host OAuth token. - The skill says: inside a host that gives HeyGen access, no CLI install or sign-in, prefer the host's own HeyGen tools, relay a refused call's message as written and do not switch providers on your own. HyperFrames Desktop sets these variables when a HeyGen API key is saved in its Settings, so media-use's TTS, catalog search and avatar calls are paid by that key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(media-use): format the edited tables and regenerate the skills manifest Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(media-use): host access applies only when HEYGEN_API_BASE is set; HeyGen credentials stay on HeyGen hosts - The "use the host's own tools first / no CLI sign-in" guidance is now conditional on HEYGEN_API_BASE alone, so an agent outside such a host (Claude Code with a HeyGen MCP connector, say) keeps today's media-use journey. The compressed copies in README, CLAUDE.md, the prompting overview, general-video and the capability menu are back to main's wording. - A base outside heygen.com gets no stored or host OAuth credential, only a key set for it (review on #5181). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(media-use): a project's .env cannot set the HeyGen base or allow HTTP loadEnvFromDir copied HEYGEN_API_BASE from a project file into process.env, and the host gateway path then sent the person's shell HEYGEN_API_KEY to that host. A host app sets HEYGEN_API_BASE and HEYGEN_ALLOW_HTTP in the environment it spawns, never in a project file, so the .env loader skips both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d2a3c07 commit 21d14b2

5 files changed

Lines changed: 192 additions & 27 deletions

File tree

‎skills-manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
"files": 1
5555
},
5656
"media-use": {
57-
"hash": "b07cf8ba10a8ac7b",
57+
"hash": "d0875c7f8bc28d51",
5858
"files": 109
5959
},
6060
"motion-graphics": {

‎skills/media-use/SKILL.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: media-use
3-
description: Agent Media OS for a HyperFrames project. Resolve BGM, SFX, image, icon, brand logo, voice, color grade, or LUT into a frozen local file or paste-ready block + ledger record (one verb, `resolve`); generate via TTS / music / image models when the catalog misses; produce voiceover, transcription, captions, and background removal through one shared audio engine; operate on media (cut / reframe / transform); and reuse assets across projects. Also use for vague feedback that real footage looks dark, flat, boring, should feel retro/camcorder/print/ASCII, needs privacy, or needs a media reveal. When the host app provides its own music or sound-effect tools, use those for music and sound effects; `resolve --type bgm|sfx` needs the heygen CLI.
3+
description: Agent Media OS for a HyperFrames project. Resolve BGM, SFX, image, icon, brand logo, voice, color grade, or LUT into a frozen local file or paste-ready block + ledger record (one verb, `resolve`); generate via TTS / music / image models when the catalog misses; produce voiceover, transcription, captions, and background removal through one shared audio engine; operate on media (cut / reframe / transform); and reuse assets across projects. Also use for vague feedback that real footage looks dark, flat, boring, should feel retro/camcorder/print/ASCII, needs privacy, or needs a media reveal. When the host app provides its own music or sound-effect tools, use those for music and sound effects; `resolve --type bgm|sfx` needs the heygen CLI. When `HEYGEN_API_BASE` is set, HeyGen calls go through that host with no CLI sign-in.
44
---
55

66
**Plugin installs:** Before setup or freshness commands, follow [plugin execution rules](../hyperframes/references/plugin-installation.md) when this skill is inside a HyperFrames plugin. Standalone installs keep the update instructions below.
@@ -9,11 +9,13 @@ description: Agent Media OS for a HyperFrames project. Resolve BGM, SFX, image,
99

1010
The media OS for HyperFrames: resolve · generate · operate · remember — every media type, one skill, zero context noise.
1111

12-
First run, when you will use HeyGen media (catalog search, TTS, avatar video): install and sign in to the `heygen` CLI (the free-usage path), then verify with `npx hyperframes media-use resolve --doctor`. Setup and providers: `references/setup-providers.md`.
12+
Only when `HEYGEN_API_BASE` is set in your environment (a host app set it and pays for HeyGen with its own key): HeyGen media is already paid for. Do not ask the person to install or sign in to the `heygen` CLI and do not offer its OAuth allowance; catalog search, TTS and avatar calls here go through the host. When that same host also gives you its own HeyGen tools, use those first. When a call through the host is refused, tell the person the host's message as written (it names the fix, such as adding or replacing the key in the app's Settings) and stop; do not switch to another provider unless they ask.
1313

14-
Music and sound effects inside a host app: when the app you run in gives you its own music or sound-effect tools, use those. `resolve --type bgm` and `--type sfx` search the HeyGen catalog through the `heygen` CLI; without it they fail and say so (`sfx` still answers from its bundled library).
14+
First run otherwise (no `HEYGEN_API_BASE`), when you will use HeyGen media (catalog search, TTS, avatar video): install and sign in to the `heygen` CLI (the free-usage path), then verify with `npx hyperframes media-use resolve --doctor`. Setup and providers: `references/setup-providers.md`.
1515

16-
Before generating a voiceover or an avatar video, tell the person: signing in to the heygen CLI with OAuth (`heygen auth login --oauth`) gives a free allowance for TTS voiceover and avatar videos, while an API key bills API credits.
16+
Music and sound effects inside a host app: when the app you run in gives you its own music or sound-effect tools, use those. Without `HEYGEN_API_BASE`, `resolve --type bgm` and `--type sfx` search the HeyGen catalog through the `heygen` CLI; without it they fail and say so (`sfx` still answers from its bundled library).
17+
18+
Without `HEYGEN_API_BASE`, before generating a voiceover or an avatar video, tell the person: signing in to the heygen CLI with OAuth (`heygen auth login --oauth`) gives a free allowance for TTS voiceover and avatar videos, while an API key bills API credits.
1719

1820
## Resolve — the one verb
1921

‎skills/media-use/audio/scripts/lib/heygen.mjs‎

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,51 @@
11
import { fetchMedia } from "../../../scripts/lib/media-fetch.mjs";
22
// heygen.mjs — vendored HeyGen REST helpers (auth + transport) for the audio
33
// pipeline. The credential resolver matches the hyperframes CLI auth: first
4-
// usable source wins — a host-injected OAuth $HEYGEN_ACCESS_TOKEN (Bearer) →
4+
// usable source wins — a host gateway ($HEYGEN_API_BASE with its own
5+
// $HEYGEN_API_KEY) → a host-injected OAuth $HEYGEN_ACCESS_TOKEN (Bearer) →
56
// $HEYGEN_API_KEY / $HYPERFRAMES_API_KEY → a nearby .env → ~/.heygen/
67
// credentials (oauth → Bearer, else api_key → X-Api-Key; $HEYGEN_CONFIG_DIR
7-
// overrides the dir). Vendored so the skill ships standalone. Pure node.
8+
// overrides the dir). $HEYGEN_API_BASE moves every request to that host, as it
9+
// does for the heygen CLI; plain HTTP needs $HEYGEN_ALLOW_HTTP=1, as there.
10+
// Vendored so the skill ships standalone. Pure node.
811

912
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1013
import { homedir } from "node:os";
1114
import { dirname, join, resolve } from "node:path";
1215

1316
export const HEYGEN_BASE = "https://api.heygen.com/v3";
17+
18+
// The v3 base every request goes to: $HEYGEN_API_BASE when a host app names its own gateway (HyperFrames Desktop
19+
// forwards it to HeyGen with the API key saved in its Settings), else HeyGen's public API. Plain HTTP carries the key
20+
// in the clear, so it needs $HEYGEN_ALLOW_HTTP=1, the heygen CLI's own rule.
21+
export function heygenBase() {
22+
const host = process.env.HEYGEN_API_BASE?.trim().replace(/\/+$/, "");
23+
if (!host) return HEYGEN_BASE;
24+
if (host.startsWith("http://") && process.env.HEYGEN_ALLOW_HTTP !== "1")
25+
throw new Error(
26+
`HEYGEN_API_BASE (${host}) uses HTTP, which sends the key in plaintext. Set HEYGEN_ALLOW_HTTP=1 to allow it.`,
27+
);
28+
return `${host}/v3`;
29+
}
30+
31+
// No base override, or one on HeyGen's own hosts (a canary or dev API).
32+
function heygenOwnBase() {
33+
const host = process.env.HEYGEN_API_BASE?.trim();
34+
if (!host) return true;
35+
try {
36+
const name = new URL(host).hostname;
37+
return name === "heygen.com" || name.endsWith(".heygen.com");
38+
} catch {
39+
return false;
40+
}
41+
}
42+
43+
// A host gateway: the host named its own API base and the key that base accepts. It pays for every call, so it wins
44+
// over any other credential the environment carries.
45+
const hostGatewayKey = () =>
46+
process.env.HEYGEN_API_BASE?.trim() && process.env.HEYGEN_API_KEY
47+
? process.env.HEYGEN_API_KEY
48+
: null;
1449
export const HEYGEN_CLI_SOURCE_HEADERS = { "X-HeyGen-Source": "cli" };
1550
// Tool-attribution sent on EVERY media-use HeyGen call regardless of auth type, so
1651
// the backend can isolate media-use consumption from other free TTS / avatar video.
@@ -29,6 +64,10 @@ function envFileText(path) {
2964
}
3065
}
3166

67+
// A host app sets these in the environment it spawns, never in a project file: a project's .env naming its own base
68+
// would send the person's shell HEYGEN_API_KEY to that host.
69+
const HOST_ONLY = new Set(["HEYGEN_API_BASE", "HEYGEN_ALLOW_HTTP"]);
70+
3271
// Walk up ≤5 dirs from startDir; load the first .env (shell env always wins).
3372
export function loadEnvFromDir(startDir) {
3473
let dir = resolve(startDir);
@@ -48,7 +87,7 @@ export function loadEnvFromDir(startDir) {
4887
const end = val.indexOf(q, 1);
4988
val = end > 0 ? val.slice(1, end) : val.slice(1);
5089
}
51-
if (!(key in process.env)) process.env[key] = val;
90+
if (!HOST_ONLY.has(key) && !(key in process.env)) process.env[key] = val;
5291
}
5392
return;
5493
}
@@ -68,6 +107,10 @@ export function heygenCredential() {
68107
// (a folder, a locked ~/.heygen), so heygenAuthHeaders can say to fix that path: logging in again would fail there too.
69108
// Read without checking first, so the file cannot change between a check and the read.
70109
function resolveCredential() {
110+
const gatewayKey = hostGatewayKey();
111+
if (gatewayKey) return { headers: { "X-Api-Key": gatewayKey } };
112+
// Every other credential belongs to HeyGen: a base on any other host gets none of them.
113+
if (!heygenOwnBase()) return null;
71114
const accessToken = process.env.HEYGEN_ACCESS_TOKEN;
72115
if (accessToken) return { headers: { Authorization: `Bearer ${accessToken}` } };
73116
const envKey = process.env.HEYGEN_API_KEY || process.env.HYPERFRAMES_API_KEY;
@@ -144,7 +187,7 @@ export async function heygenJSON(path, { method = "GET", headers = {}, body } =
144187
opts.headers["Content-Type"] = "application/json";
145188
opts.body = JSON.stringify(body);
146189
}
147-
const res = await fetch(`${HEYGEN_BASE}${path}`, opts);
190+
const res = await fetch(`${heygenBase()}${path}`, opts);
148191
if (!res.ok) {
149192
const detail = await res.text().catch(() => "");
150193
const message = `HeyGen ${method} ${path} → HTTP ${res.status}${detail ? `\n${detail.slice(0, 300)}` : ""}`;

‎skills/media-use/audio/scripts/lib/heygen.test.mjs‎

Lines changed: 125 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,47 @@
11
import { test } from "node:test";
22
import assert from "node:assert/strict";
33
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4+
import { createServer } from "node:http";
45
import { tmpdir } from "node:os";
56
import { join } from "node:path";
67
import {
78
heygenAuthHeaders,
89
heygenAuthMethod,
10+
heygenBase,
911
heygenCredential,
12+
heygenJSON,
1013
loadEnvFromDir,
1114
} from "./heygen.mjs";
1215

16+
const HEYGEN_ENV = [
17+
"HEYGEN_ACCESS_TOKEN",
18+
"HEYGEN_API_KEY",
19+
"HYPERFRAMES_API_KEY",
20+
"HEYGEN_CONFIG_DIR",
21+
"HEYGEN_API_BASE",
22+
"HEYGEN_ALLOW_HTTP",
23+
];
24+
25+
// Runs fn with every HeyGen variable unset, then puts them back; an async fn restores once it settles.
1326
function withCleanHeygenEnv(fn) {
14-
const previousAccessToken = process.env.HEYGEN_ACCESS_TOKEN;
15-
const previousApiKey = process.env.HEYGEN_API_KEY;
16-
const previousHyperframesApiKey = process.env.HYPERFRAMES_API_KEY;
17-
const previousConfigDir = process.env.HEYGEN_CONFIG_DIR;
27+
const previous = Object.fromEntries(HEYGEN_ENV.map((name) => [name, process.env[name]]));
28+
const restore = () => {
29+
for (const [name, value] of Object.entries(previous)) {
30+
if (value === undefined) delete process.env[name];
31+
else process.env[name] = value;
32+
}
33+
};
34+
for (const name of HEYGEN_ENV) delete process.env[name];
35+
let result;
1836
try {
19-
delete process.env.HEYGEN_ACCESS_TOKEN;
20-
delete process.env.HEYGEN_API_KEY;
21-
delete process.env.HYPERFRAMES_API_KEY;
22-
delete process.env.HEYGEN_CONFIG_DIR;
23-
return fn();
24-
} finally {
25-
if (previousAccessToken === undefined) delete process.env.HEYGEN_ACCESS_TOKEN;
26-
else process.env.HEYGEN_ACCESS_TOKEN = previousAccessToken;
27-
if (previousApiKey === undefined) delete process.env.HEYGEN_API_KEY;
28-
else process.env.HEYGEN_API_KEY = previousApiKey;
29-
if (previousHyperframesApiKey === undefined) delete process.env.HYPERFRAMES_API_KEY;
30-
else process.env.HYPERFRAMES_API_KEY = previousHyperframesApiKey;
31-
if (previousConfigDir === undefined) delete process.env.HEYGEN_CONFIG_DIR;
32-
else process.env.HEYGEN_CONFIG_DIR = previousConfigDir;
37+
result = fn();
38+
} catch (error) {
39+
restore();
40+
throw error;
3341
}
42+
if (result && typeof result.then === "function") return result.finally(restore);
43+
restore();
44+
return result;
3445
}
3546

3647
test("heygenAuthHeaders does not tag API-key requests as CLI traffic, but still carries the media-use tool tag", () => {
@@ -138,6 +149,27 @@ test("loadEnvFromDir skips a .env folder and loads the .env file above it", () =
138149
}
139150
});
140151

152+
test("a project's .env cannot name the HeyGen base, so a shell key never leaves for its host", () => {
153+
withCleanHeygenEnv(() => {
154+
const project = mkdtempSync(join(tmpdir(), "heygen-env-"));
155+
writeFileSync(
156+
join(project, ".env"),
157+
"HEYGEN_API_BASE=https://proxy.example.com\nHEYGEN_ALLOW_HTTP=1\nMEDIA_USE_ENV_BASE_TEST=loaded\n",
158+
);
159+
try {
160+
process.env.HEYGEN_API_KEY = "hg_shell_real";
161+
loadEnvFromDir(project);
162+
assert.equal(process.env.MEDIA_USE_ENV_BASE_TEST, "loaded");
163+
assert.equal(process.env.HEYGEN_API_BASE, undefined);
164+
assert.equal(process.env.HEYGEN_ALLOW_HTTP, undefined);
165+
assert.equal(heygenBase(), "https://api.heygen.com/v3");
166+
} finally {
167+
delete process.env.MEDIA_USE_ENV_BASE_TEST;
168+
rmSync(project, { recursive: true, force: true });
169+
}
170+
});
171+
});
172+
141173
test("heygenAuthMethod returns null when the credentials path is a folder", () => {
142174
withCleanHeygenEnv(() => {
143175
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
@@ -183,3 +215,78 @@ test("heygenAuthHeaders says to fix an unreadable credentials path, and to log i
183215
}
184216
});
185217
});
218+
219+
test("heygenBase is HeyGen's public API unless a host names another", () => {
220+
withCleanHeygenEnv(() => {
221+
assert.equal(heygenBase(), "https://api.heygen.com/v3");
222+
process.env.HEYGEN_API_BASE = "https://api-canary.heygen.com/";
223+
assert.equal(heygenBase(), "https://api-canary.heygen.com/v3");
224+
});
225+
});
226+
227+
test("heygenBase refuses a plain-HTTP host unless HEYGEN_ALLOW_HTTP is set, as the heygen CLI does", () => {
228+
withCleanHeygenEnv(() => {
229+
process.env.HEYGEN_API_BASE = "http://127.0.0.1:4100";
230+
assert.throws(() => heygenBase(), /HEYGEN_ALLOW_HTTP=1/);
231+
process.env.HEYGEN_ALLOW_HTTP = "1";
232+
assert.equal(heygenBase(), "http://127.0.0.1:4100/v3");
233+
});
234+
});
235+
236+
test("a host gateway (HEYGEN_API_BASE with its own HEYGEN_API_KEY) wins over a host OAuth token", () => {
237+
withCleanHeygenEnv(() => {
238+
process.env.HEYGEN_API_BASE = "http://127.0.0.1:4100";
239+
process.env.HEYGEN_ALLOW_HTTP = "1";
240+
process.env.HEYGEN_API_KEY = "gateway-token";
241+
process.env.HEYGEN_ACCESS_TOKEN = "at_host";
242+
assert.deepEqual(heygenAuthHeaders(), {
243+
"X-Api-Key": "gateway-token",
244+
"X-HeyGen-Client-Source": "media-use",
245+
});
246+
assert.equal(heygenAuthMethod(), "api_key");
247+
});
248+
});
249+
250+
test("heygenJSON sends its request to the host's HEYGEN_API_BASE with the host's key", async () => {
251+
/** @type {{ url?: string, key?: string | string[] }} */
252+
const seen = {};
253+
const server = createServer((req, res) => {
254+
seen.url = req.url;
255+
seen.key = req.headers["x-api-key"];
256+
res.writeHead(200, { "content-type": "application/json" }).end('{"data":[]}');
257+
});
258+
await new Promise((done) => server.listen(0, "127.0.0.1", done));
259+
const { port } = /** @type {import("node:net").AddressInfo} */ (server.address());
260+
try {
261+
await withCleanHeygenEnv(async () => {
262+
process.env.HEYGEN_API_BASE = `http://127.0.0.1:${port}`;
263+
process.env.HEYGEN_ALLOW_HTTP = "1";
264+
process.env.HEYGEN_API_KEY = "gateway-token";
265+
const reply = await heygenJSON("/voices?limit=1", { headers: heygenAuthHeaders() });
266+
assert.deepEqual(reply, { data: [] });
267+
});
268+
assert.equal(seen.url, "/v3/voices?limit=1");
269+
assert.equal(seen.key, "gateway-token");
270+
} finally {
271+
server.close();
272+
}
273+
});
274+
275+
test("a base that isn't HeyGen's gets no stored or host OAuth credential, only a key named for it", () => {
276+
withCleanHeygenEnv(() => {
277+
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
278+
try {
279+
process.env.HEYGEN_CONFIG_DIR = dir;
280+
writeFileSync(join(dir, "credentials"), JSON.stringify({ api_key: "hg_stored" }));
281+
process.env.HEYGEN_ACCESS_TOKEN = "at_host";
282+
process.env.HEYGEN_API_BASE = "https://proxy.example.com";
283+
assert.equal(heygenCredential(), null);
284+
assert.throws(() => heygenAuthHeaders(), /no HeyGen credentials/);
285+
// HeyGen's own hosts keep every credential source.
286+
process.env.HEYGEN_API_BASE = "https://api-canary.heygen.com";
287+
assert.equal(heygenAuthMethod(), "oauth");
288+
} finally {
289+
rmSync(dir, { recursive: true, force: true });
290+
}
291+
});
292+
});

‎skills/media-use/references/setup-providers.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ This unlocks the FREE path for bgm/sfx/image/icon catalog search, TTS (voice), a
1515
npx hyperframes media-use resolve --doctor
1616
```
1717

18+
## Host HeyGen access (no CLI sign-in)
19+
20+
A host app can give media-use HeyGen access of its own by setting
21+
`HEYGEN_API_BASE` (its gateway), `HEYGEN_API_KEY` (the token that gateway
22+
accepts) and, for a loopback gateway, `HEYGEN_ALLOW_HTTP=1`. The gateway adds
23+
the host's key, so it never enters the agent's environment, and every
24+
call is charged to that key's API credits. The `heygen` CLI honours the same
25+
variables, so `resolve` (bgm/sfx/image/icon/voice/avatar-video) and the audio
26+
engine's TTS all go through the host. With host access, skip CLI install and
27+
sign-in, prefer the host's own HeyGen tools where it has them, never fall back
28+
to a local or third-party generator on your own, and relay a refused call's
29+
message (for example "add a key in Settings > Account") as written.
30+
1831
## Providers
1932

2033
media-use holds no keys; every external tool owns its auth. Generation is

0 commit comments

Comments
 (0)