harmar-ai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js ADDED
@@ -0,0 +1,207 @@
1
+ // `harmar mcp` — Model Context Protocol server over stdio.
2
+ //
3
+ // Gives any MCP host (Claude Code, Claude Desktop, Cursor, Windsurf, …)
4
+ // the Harmar subtitle API as tools. One process per host, authenticated
5
+ // by HARMAR_API_KEY in its environment:
6
+ //
7
+ // claude mcp add harmar -e HARMAR_API_KEY=hk_live_… -- npx -y harmar-ai mcp
8
+ //
9
+ // Every tool is a thin wrapper over HarmarClient — the MCP layer adds
10
+ // schemas and text rendering, never logic.
11
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
+ import { z } from "zod";
14
+ import { HarmarClient, HarmarError } from "./client.js";
15
+ const server = new McpServer({ name: "harmar", version: "0.1.0" });
16
+ let clientInstance = null;
17
+ function client() {
18
+ if (!clientInstance)
19
+ clientInstance = new HarmarClient();
20
+ return clientInstance;
21
+ }
22
+ const text = (s) => ({ content: [{ type: "text", text: s }] });
23
+ const json = (v) => text(JSON.stringify(v, null, 2));
24
+ // Errors go back as tool results, not protocol errors, so the model can
25
+ // read the code (insufficient_credits carries seconds_needed) and act.
26
+ async function guarded(fn) {
27
+ try {
28
+ return await fn();
29
+ }
30
+ catch (e) {
31
+ if (e instanceof HarmarError) {
32
+ return {
33
+ isError: true,
34
+ ...json({ error: { code: e.code, message: e.message, status: e.status, ...e.params } }),
35
+ };
36
+ }
37
+ return { isError: true, ...text(`Error: ${e?.message ?? String(e)}`) };
38
+ }
39
+ }
40
+ // A completed transcript can be tens of thousands of words. The status
41
+ // tool returns the compact form by default; words come on request.
42
+ function compact(t, includeWords) {
43
+ if (t.status !== "completed")
44
+ return t;
45
+ const { words, translation, ...rest } = t;
46
+ const out = {
47
+ ...rest,
48
+ word_count: words?.length,
49
+ ...(includeWords ? { words } : {}),
50
+ };
51
+ if (translation) {
52
+ out.translation = includeWords
53
+ ? translation
54
+ : { text: translation.text, segments: translation.segments, word_count: translation.words?.length };
55
+ }
56
+ return out;
57
+ }
58
+ const OPTIONS_SHAPE = {
59
+ timestamps: z.enum(["word", "segment", "none"]).optional().describe("Timing granularity in the result (default word)."),
60
+ punctuation: z.boolean().optional().describe("Keep punctuation (default true)."),
61
+ speakers: z.boolean().optional().describe("Keep dialogue dashes and speaker ids (default true)."),
62
+ lyrics: z.enum(["exclude", "include"]).optional().describe("Whether sung lines appear (default exclude)."),
63
+ };
64
+ server.registerTool("harmar_transcribe", {
65
+ title: "Transcribe a media file",
66
+ description: "Upload a local audio/video file (MP4, MOV, WebM, M4A, MP3, WAV; ≤60 min) to Harmar and get a word-timed transcript. " +
67
+ "Best for Armenian and mixed Armenian/Russian/English speech; 50+ other languages supported (see harmar_languages). " +
68
+ "Charged per second of media from a prepaid balance, refunded if the job fails. " +
69
+ "Waits for completion by default; if it times out the job keeps running — poll with harmar_get_transcript.",
70
+ inputSchema: {
71
+ file_path: z.string().describe("Absolute path to the media file on this machine."),
72
+ source_lang: z
73
+ .string()
74
+ .optional()
75
+ .describe('Language of the speech, e.g. "hy", "ru", "en", "kk". "auto" identifies it from the audio. Default "hy".'),
76
+ translate_to: z.string().optional().describe("Also produce a translated subtitle track in this language (no surcharge)."),
77
+ script_text: z.string().optional().describe("If you already have the exact spoken text, align it instead of transcribing."),
78
+ webhook_url: z.string().optional().describe("HTTPS URL to notify on completion (Harmar-Signature HMAC header)."),
79
+ options: z.object(OPTIONS_SHAPE).optional(),
80
+ keep_media: z
81
+ .boolean()
82
+ .optional()
83
+ .describe("Keep the source video after transcription so harmar_export can burn styled captions into it. Set true whenever a video export is wanted. Default false (media deleted right after the transcript)."),
84
+ wait: z.boolean().optional().describe("Wait for the result (default true)."),
85
+ timeout_seconds: z.number().optional().describe("Max seconds to wait (default 900). The job continues server-side after."),
86
+ include_words: z.boolean().optional().describe("Include the per-word array in the result (default false — text and segments only)."),
87
+ },
88
+ }, async (a) => guarded(async () => {
89
+ const result = await client().transcribe(a.file_path, {
90
+ sourceLang: a.source_lang,
91
+ translateTo: a.translate_to,
92
+ scriptText: a.script_text,
93
+ webhookUrl: a.webhook_url,
94
+ keepMedia: a.keep_media,
95
+ options: a.options,
96
+ wait: a.wait ?? true,
97
+ timeoutMs: (a.timeout_seconds ?? 900) * 1000,
98
+ });
99
+ return json(compact(result, a.include_words ?? false));
100
+ }));
101
+ server.registerTool("harmar_get_transcript", {
102
+ title: "Get a transcript / job status",
103
+ description: "Status of a Harmar job by id. While processing: progress 0–100 and, for auto jobs, the detected language. " +
104
+ "When completed: text, sentence segments, and optionally every word with start/end seconds.",
105
+ inputSchema: {
106
+ id: z.string().describe("Transcript id returned by harmar_transcribe."),
107
+ include_words: z.boolean().optional().describe("Include the per-word array (default false)."),
108
+ },
109
+ }, async (a) => guarded(async () => json(compact(await client().get(a.id), a.include_words ?? false))));
110
+ server.registerTool("harmar_get_subtitles", {
111
+ title: "Get SRT or VTT",
112
+ description: "Subtitle file text for a completed job, one cue per sentence. `lang` picks the track: omit for the source language, " +
113
+ "or name the translate_to language for the translated track.",
114
+ inputSchema: {
115
+ id: z.string(),
116
+ format: z.enum(["srt", "vtt"]),
117
+ lang: z.string().optional().describe("Track language; omit for the source track."),
118
+ },
119
+ }, async (a) => guarded(async () => text(await client().subtitles(a.id, a.format, a.lang))));
120
+ server.registerTool("harmar_languages", {
121
+ title: "List supported languages",
122
+ description: "Languages accepted for source_lang and translate_to, live from the API. `auto_detectable: false` means the language works but must be named explicitly.",
123
+ inputSchema: {},
124
+ }, async () => guarded(async () => json({ auto: "identify from the audio", languages: await client().languages() })));
125
+ server.registerTool("harmar_balance", { title: "Prepaid balance", description: "Seconds and minutes of media the account can still transcribe.", inputSchema: {} }, async () => guarded(async () => json(await client().balance())));
126
+ server.registerTool("harmar_usage", { title: "Usage ledger", description: "The last 100 credit ledger entries (grants, charges, refunds).", inputSchema: {} }, async () => guarded(async () => json(await client().usage())));
127
+ server.registerTool("harmar_pricing", { title: "Pricing", description: "Live per-minute rates and credit packs, so a margin can be computed without hard-coding a number.", inputSchema: {} }, async () => guarded(async () => json(await client().pricing())));
128
+ server.registerTool("harmar_delete_transcript", {
129
+ title: "Delete a transcript",
130
+ description: "Purge a finished job's transcript and media now instead of waiting for retention. Idempotent; 409 while still processing.",
131
+ inputSchema: { id: z.string() },
132
+ }, async (a) => guarded(async () => json(await client().delete(a.id))));
133
+ // ── styles & export ────────────────────────────────────────────────────
134
+ const STYLE_SHAPE = z
135
+ .object({
136
+ preset: z.enum(["karaoke", "pill", "popin", "classic", "reveal", "stack", "carousel"]).optional(),
137
+ font: z.string().optional().describe("A font key from harmar_list_styles — check it renders the track's language."),
138
+ fontByLang: z.record(z.string(), z.string()).optional(),
139
+ fontSizePct: z.number().optional().describe("Font size as % of video width (0.5–30)."),
140
+ color: z.string().optional(),
141
+ accentColor: z.string().optional(),
142
+ bgColor: z.string().optional(),
143
+ bgOpacity: z.number().optional(),
144
+ position: z.enum(["top", "center", "bottom"]).optional(),
145
+ posX: z.number().optional(),
146
+ posY: z.number().optional(),
147
+ subtitleWidth: z.number().optional(),
148
+ })
149
+ .passthrough()
150
+ .describe("A style object. Any field from harmar_list_styles is accepted; unknown fields are rejected by the API with the field list.");
151
+ server.registerTool("harmar_list_styles", {
152
+ title: "Style catalog",
153
+ description: "Everything a subtitle style can be: the caption presets (pill, karaoke, popin, …), every font with the languages it actually renders, " +
154
+ "each style field with its range, and the default style per video orientation. Read this before composing a style.",
155
+ inputSchema: {},
156
+ }, async () => guarded(async () => json(await client().styles())));
157
+ server.registerTool("harmar_list_style_presets", { title: "Saved styles", description: "The account's saved style presets — pass a preset's id to harmar_export as style_preset_id.", inputSchema: {} }, async () => guarded(async () => json(await client().stylePresets())));
158
+ server.registerTool("harmar_save_style", {
159
+ title: "Save a style preset",
160
+ description: "Save a style under a name so every later export uses exactly it (harmar_export with style_preset_id). Up to 10 per account. " +
161
+ "Compose the style from harmar_list_styles; the API validates it and names any field it rejects.",
162
+ inputSchema: { name: z.string().min(1).max(60), style: STYLE_SHAPE },
163
+ }, async (a) => guarded(async () => json(await client().saveStylePreset(a.name, a.style))));
164
+ server.registerTool("harmar_delete_style_preset", { title: "Delete a style preset", description: "Remove a saved style by id.", inputSchema: { id: z.string() } }, async (a) => guarded(async () => json(await client().deleteStylePreset(a.id))));
165
+ server.registerTool("harmar_export", {
166
+ title: "Export a captioned video",
167
+ description: "Burn styled subtitles into the video of a completed transcript and get an MP4 (no watermark, up to 1080p). " +
168
+ "Pass exactly one of style_preset_id (a saved style — the reliable way to reuse a specific look) or style (inline). " +
169
+ "The transcript must have been created with keep_media: true; otherwise the API answers media_purged and the file must be transcribed again with keep_media. " +
170
+ "Charged per second of media at the transcription rate, refunded if the render fails. Waits by default; pass save_to to download the MP4 to a local path.",
171
+ inputSchema: {
172
+ id: z.string().describe("Transcript id."),
173
+ style_preset_id: z.string().optional(),
174
+ style: STYLE_SHAPE.optional(),
175
+ lang: z.string().optional().describe("Track to burn: the source language (default) or the translate_to language."),
176
+ platform: z.enum(["instagram", "youtube", "tiktok"]).optional(),
177
+ save_to: z.string().optional().describe("Absolute local path for the MP4. Without it the result carries a signed download_url valid for an hour."),
178
+ wait: z.boolean().optional().describe("Wait for the render (default true)."),
179
+ timeout_seconds: z.number().optional().describe("Max seconds to wait (default 1800). The render continues server-side after."),
180
+ },
181
+ }, async (a) => guarded(async () => {
182
+ const state = await client().exportVideo(a.id, {
183
+ stylePresetId: a.style_preset_id,
184
+ style: a.style,
185
+ lang: a.lang,
186
+ platform: a.platform,
187
+ wait: a.wait ?? true,
188
+ timeoutMs: (a.timeout_seconds ?? 1800) * 1000,
189
+ outPath: a.save_to,
190
+ });
191
+ return json(a.save_to && state.status === "completed" ? { ...state, file: a.save_to } : state);
192
+ }));
193
+ server.registerTool("harmar_get_export", {
194
+ title: "Export status",
195
+ description: "State of a transcript's export: queued / rendering (with progress) / completed (with a signed download_url) / failed (with the reason and the refund).",
196
+ inputSchema: { id: z.string(), save_to: z.string().optional().describe("If completed, download the MP4 to this local path.") },
197
+ }, async (a) => guarded(async () => {
198
+ const state = await client().getExport(a.id);
199
+ if (a.save_to && state.status === "completed") {
200
+ await client().downloadExport(state, a.save_to);
201
+ return json({ ...state, file: a.save_to });
202
+ }
203
+ return json(state);
204
+ }));
205
+ export async function runMcp() {
206
+ await server.connect(new StdioServerTransport());
207
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,111 @@
1
+ // The MCP server over a real stdio transport, driven by the SDK's own
2
+ // client. Offline half: the server starts, lists its tools, refuses a
3
+ // tool call without a key as a TOOL error (not a crash), and rejects a
4
+ // bad enum at the schema. Online half (HARMAR_API_KEY + HARMAR_API_URL
5
+ // set): balance, a transcript, subtitles, and a 404 as a tool error.
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
9
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
10
+ const CLI = new URL("../cli.js", import.meta.url).pathname;
11
+ async function connect(env) {
12
+ const c = new Client({ name: "harmar-test", version: "0" });
13
+ const clean = {};
14
+ for (const [k, v] of Object.entries(env))
15
+ if (v !== undefined)
16
+ clean[k] = v;
17
+ await c.connect(new StdioClientTransport({ command: process.execPath, args: [CLI, "mcp"], env: clean }));
18
+ return c;
19
+ }
20
+ const textOf = (r) => r.content[0].text;
21
+ test("lists the fourteen tools", async () => {
22
+ const c = await connect({ PATH: process.env.PATH });
23
+ const { tools } = await c.listTools();
24
+ assert.deepEqual(tools.map((t) => t.name).sort(), [
25
+ "harmar_balance",
26
+ "harmar_delete_style_preset",
27
+ "harmar_delete_transcript",
28
+ "harmar_export",
29
+ "harmar_get_export",
30
+ "harmar_get_subtitles",
31
+ "harmar_get_transcript",
32
+ "harmar_languages",
33
+ "harmar_list_style_presets",
34
+ "harmar_list_styles",
35
+ "harmar_pricing",
36
+ "harmar_save_style",
37
+ "harmar_transcribe",
38
+ "harmar_usage",
39
+ ]);
40
+ await c.close();
41
+ });
42
+ test("a missing key is a tool error, not a crash", async () => {
43
+ const c = await connect({ PATH: process.env.PATH, HARMAR_API_KEY: undefined });
44
+ const r = await c.callTool({ name: "harmar_balance", arguments: {} });
45
+ assert.equal(r.isError, true);
46
+ assert.match(textOf(r), /missing_api_key/);
47
+ // The server is still alive after the error.
48
+ const { tools } = await c.listTools();
49
+ assert.equal(tools.length, 14);
50
+ await c.close();
51
+ });
52
+ test("a bad enum is rejected by the schema", async () => {
53
+ const c = await connect({ PATH: process.env.PATH, HARMAR_API_KEY: "hk_live_x" });
54
+ const r = await c
55
+ .callTool({ name: "harmar_get_subtitles", arguments: { id: "x", format: "pdf" } })
56
+ .catch((e) => ({ isError: true, content: [{ type: "text", text: e.message }] }));
57
+ assert.equal(r.isError, true);
58
+ assert.match(textOf(r), /format|invalid|enum/i);
59
+ await c.close();
60
+ });
61
+ const online = Boolean(process.env.HARMAR_API_KEY && process.env.HARMAR_API_URL);
62
+ test("online: balance, transcript, subtitles, 404", { skip: !online && "set HARMAR_API_KEY + HARMAR_API_URL" }, async () => {
63
+ const c = await connect(process.env);
64
+ const bal = JSON.parse(textOf(await c.callTool({ name: "harmar_balance", arguments: {} })));
65
+ assert.equal(typeof bal.seconds_remaining, "number");
66
+ const notFound = await c.callTool({
67
+ name: "harmar_get_transcript",
68
+ arguments: { id: "00000000-0000-0000-0000-000000000000" },
69
+ });
70
+ assert.equal(notFound.isError, true);
71
+ assert.match(textOf(notFound), /not_found/);
72
+ const styles = JSON.parse(textOf(await c.callTool({ name: "harmar_list_styles", arguments: {} })));
73
+ assert.equal(styles.presets.length, 7);
74
+ assert.ok(styles.fonts.some((f) => f.key === "noto" && f.languages.includes("hy")));
75
+ assert.ok("accentColor" in styles.fields);
76
+ const presets = JSON.parse(textOf(await c.callTool({ name: "harmar_list_style_presets", arguments: {} })));
77
+ assert.ok(Array.isArray(presets));
78
+ const rejected = await c.callTool({ name: "harmar_save_style", arguments: { name: "x", style: { preset: "pill", accent_color: "#fff" } } });
79
+ assert.equal(rejected.isError, true, "unknown style field must be a tool error");
80
+ assert.match(textOf(rejected), /invalid_style/);
81
+ // A real export through the MCP tool, saved to disk. Needs a transcript
82
+ // that was submitted with keep_media (HARMAR_TEST_EXPORT_ID).
83
+ const exportId = process.env.HARMAR_TEST_EXPORT_ID;
84
+ if (exportId) {
85
+ const { mkdtempSync, statSync } = await import("node:fs");
86
+ const { tmpdir } = await import("node:os");
87
+ const { join } = await import("node:path");
88
+ const out = join(mkdtempSync(join(tmpdir(), "harmar-sdk-")), "captioned.mp4");
89
+ const r = await c.callTool({
90
+ name: "harmar_export",
91
+ arguments: { id: exportId, style: { preset: "popin", font: "noto", accentColor: "#D4F25A" }, save_to: out, timeout_seconds: 600 },
92
+ });
93
+ assert.notEqual(r.isError, true, textOf(r));
94
+ const state = JSON.parse(textOf(r));
95
+ assert.equal(state.status, "completed");
96
+ assert.equal(state.file, out);
97
+ assert.ok(statSync(out).size > 10_000, "mp4 written");
98
+ }
99
+ const id = process.env.HARMAR_TEST_TRANSCRIPT_ID;
100
+ if (id) {
101
+ const tr = JSON.parse(textOf(await c.callTool({ name: "harmar_get_transcript", arguments: { id } })));
102
+ assert.equal(tr.status, "completed");
103
+ assert.ok(!("words" in tr), "words omitted by default");
104
+ assert.equal(typeof tr.word_count, "number");
105
+ const full = JSON.parse(textOf(await c.callTool({ name: "harmar_get_transcript", arguments: { id, include_words: true } })));
106
+ assert.equal(full.words.length, tr.word_count);
107
+ const srt = textOf(await c.callTool({ name: "harmar_get_subtitles", arguments: { id, format: "srt" } }));
108
+ assert.match(srt, /^1\r?\n\d\d:\d\d:\d\d,\d\d\d --> /);
109
+ }
110
+ await c.close();
111
+ });
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "harmar-ai",
3
+ "version": "0.1.0",
4
+ "description": "Harmar subtitle API for agents and scripts — CLI, MCP server and typed client. Word-timed transcripts, SRT/VTT and styled captioned videos for Armenian, Russian, English and 50+ more languages, code-switching included.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "engines": { "node": ">=20" },
8
+ "bin": { "harmar": "./dist/cli.js" },
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }
13
+ },
14
+ "files": ["dist", "skills", "README.md"],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.json && chmod +x dist/cli.js",
17
+ "test": "node --test dist/test/*.test.js",
18
+ "prepare": "npm run build",
19
+ "prepublishOnly": "npm test"
20
+ },
21
+ "keywords": ["subtitles", "transcription", "srt", "vtt", "armenian", "mcp", "cli", "speech-to-text", "captions"],
22
+ "repository": { "type": "git", "url": "https://github.com/fulfilledbyai/harmar-sdk.git" },
23
+ "homepage": "https://harmar.ai/developers",
24
+ "dependencies": {
25
+ "@modelcontextprotocol/sdk": "^1.30.0",
26
+ "zod": "^4.6.5"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^22.0.0",
30
+ "typescript": "^5.9.0"
31
+ }
32
+ }
@@ -0,0 +1,139 @@
1
+ ---
2
+ name: harmar
3
+ description: Transcribe audio/video, and produce styled captioned videos (burned-in subtitles as MP4) or SRT/VTT files with the Harmar API. Use when asked to transcribe, caption or subtitle a media file, to make a "video with captions" in a specific look (font, colours, pill/karaoke style), to save a subtitle style and reuse it, or for "what language is this recording" — especially Armenian, Russian, English and code-switched speech, plus 50+ other languages.
4
+ ---
5
+
6
+ # Harmar — subtitles from speech, captioned videos from subtitles
7
+
8
+ Harmar (harmar.ai) transcribes speech into word-level timestamps, writes
9
+ SRT/VTT, and burns styled captions into the video (MP4, no watermark, up to
10
+ 1080p). It is the reference engine for Armenian and for Armenian/Russian/
11
+ English code-switching, and serves 50+ other languages. Prepaid credits,
12
+ charged per second of media for a transcript and again for an export,
13
+ refunded on failure. 10 free minutes on the first API key.
14
+
15
+ ## Setup (once)
16
+
17
+ 1. Key: https://harmar.ai/app/api → create an API key (`hk_live_…`).
18
+ 2. `export HARMAR_API_KEY=hk_live_…`
19
+ 3. The CLI runs without install: `npx -y harmar-ai <command>`.
20
+
21
+ If `HARMAR_API_KEY` is unset, stop and ask the user for it — do not guess.
22
+
23
+ ## Commands
24
+
25
+ ```bash
26
+ # Armenian speech (the default) → JSON with text, segments and every word's start/end
27
+ npx -y harmar-ai transcribe video.mp4
28
+
29
+ # Don't know the language? Let it identify the language from the audio.
30
+ npx -y harmar-ai transcribe video.mp4 --lang auto --srt --out video.srt
31
+
32
+ # Russian speech, plus an Armenian subtitle track (translation is free)
33
+ npx -y harmar-ai transcribe talk.mp4 --lang ru --translate-to hy --vtt --out talk.hy.vtt
34
+
35
+ # Long file: submit, then poll
36
+ npx -y harmar-ai transcribe long.mp4 --no-wait # prints {"id": …}
37
+ npx -y harmar-ai status <id> # progress 0–100, then the transcript
38
+ npx -y harmar-ai srt <id> --out long.srt
39
+
40
+ npx -y harmar-ai languages # what --lang / --translate-to accept, live
41
+ npx -y harmar-ai balance # minutes left
42
+ ```
43
+
44
+ `--lang` takes an ISO code (`hy`, `ru`, `en`, `kk`, `ka`, `uk`, …) or `auto`.
45
+ Output is JSON unless `--srt`, `--vtt` or `--text` is given. Progress goes to
46
+ stderr; the result goes to stdout or `--out`.
47
+
48
+ ## Captioned video (styled export)
49
+
50
+ The video is only exportable if the transcript was made with `--keep-media`
51
+ (or `--export`, which implies it) — by default the source is deleted the
52
+ moment the transcript exists.
53
+
54
+ ```bash
55
+ # 1. see what a style can be: presets, fonts + the languages each renders, fields
56
+ npx -y harmar-ai styles
57
+
58
+ # 2. save the look once, by name — then every export uses exactly it
59
+ npx -y harmar-ai save-style "Brand" --style '{"preset":"pill","font":"montserrat","accentColor":"#D4F25A","bgOpacity":0.6,"posY":78}'
60
+ # → prints the preset id
61
+
62
+ # 3a. one shot: transcribe + export
63
+ npx -y harmar-ai transcribe reel.mp4 --lang auto --export --preset <preset id> --out reel-captioned.mp4
64
+
65
+ # 3b. or export an existing transcript (made with --keep-media)
66
+ npx -y harmar-ai export <id> --preset <preset id> --out reel-captioned.mp4
67
+ npx -y harmar-ai export <id> --style-file brand.json --track en --platform instagram --out out.mp4
68
+
69
+ npx -y harmar-ai presets # saved styles
70
+ npx -y harmar-ai export-status <id> # queued / rendering N% / completed (download_url) / failed
71
+ ```
72
+
73
+ Rules the agent should follow:
74
+ - **Ask the user for the look if none was given** (colours, font, pill vs
75
+ karaoke, position), or use the platform defaults from `harmar styles --json`.
76
+ - **Check the font renders the language**: `fonts[].languages` in the
77
+ catalog. A font without the track's language falls back to Noto.
78
+ - **Save the style as a preset** when the user will want the same look again,
79
+ and export by `--preset <id>` — that is the reliable way to reproduce it.
80
+ - `--track` picks which subtitle track to burn: the source language
81
+ (default) or the `--translate-to` language.
82
+ - One export runs at a time per account; a second request answers
83
+ `too_many_exports` — wait, then retry.
84
+ - Exit 1 with `media_purged` means the transcript was made without
85
+ `--keep-media`: transcribe the file again with `--keep-media` (or `--export`).
86
+
87
+ ## Options that change the transcript
88
+
89
+ | Flag | Effect |
90
+ |---|---|
91
+ | `--timestamps word\|segment\|none` | granularity in the JSON (default word) |
92
+ | `--no-punctuation` | strip punctuation |
93
+ | `--no-speakers` | drop dialogue dashes and speaker ids |
94
+ | `--lyrics include` | keep sung lines (excluded by default) |
95
+ | `--script file.txt` | you already have the exact words — align them, no transcription |
96
+ | `--webhook https://…` | POST on completion, `Harmar-Signature` HMAC header |
97
+
98
+ ## Limits and errors
99
+
100
+ - Formats: MP4, MOV, WebM, M4A, MP3, WAV. Up to 60 minutes per file, 5 GB.
101
+ - Exit 1 with `insufficient_credits` carries `seconds_needed` and
102
+ `seconds_available` — tell the user how many minutes to buy at
103
+ https://harmar.ai/app/api; do not retry.
104
+ - Exit 3 means the wait timed out; the job is still running — `harmar status <id>`
105
+ (or `harmar export-status <id>` for an export).
106
+ - A failed job refunds its charge automatically.
107
+ - Transcripts and media are purged after the retention window; `harmar delete <id>` purges now.
108
+
109
+ ## Raw HTTP (if the CLI can't be run)
110
+
111
+ Base `https://api.harmar.ai`, header `Authorization: Bearer $HARMAR_API_KEY`.
112
+
113
+ ```bash
114
+ # 1. get a presigned upload
115
+ curl -s -X POST https://api.harmar.ai/v1/uploads -H "Authorization: Bearer $HARMAR_API_KEY" \
116
+ -H 'Content-Type: application/json' -d '{"filename":"video.mp4","file_size":12345678}'
117
+ # → {"media_id":…,"upload_url":…,"content_type":"video/mp4"}
118
+
119
+ # 2. PUT the bytes (Content-Type must match)
120
+ curl -s -X PUT "$UPLOAD_URL" -H 'Content-Type: video/mp4' --data-binary @video.mp4
121
+
122
+ # 3. submit
123
+ curl -s -X POST https://api.harmar.ai/v1/transcripts -H "Authorization: Bearer $HARMAR_API_KEY" \
124
+ -H 'Content-Type: application/json' -d '{"media_id":"…","source_lang":"auto"}'
125
+ # → 202 {"id":…,"status":"processing","seconds_charged":…}
126
+
127
+ # 4. poll, then fetch
128
+ curl -s https://api.harmar.ai/v1/transcripts/$ID -H "Authorization: Bearer $HARMAR_API_KEY"
129
+ curl -s https://api.harmar.ai/v1/transcripts/$ID/srt -H "Authorization: Bearer $HARMAR_API_KEY"
130
+
131
+ # 5. styled export (the transcript must have been submitted with "keep_media": true)
132
+ curl -s https://api.harmar.ai/v1/styles -H "Authorization: Bearer $HARMAR_API_KEY"
133
+ curl -s -X POST https://api.harmar.ai/v1/transcripts/$ID/export -H "Authorization: Bearer $HARMAR_API_KEY" \
134
+ -H 'Content-Type: application/json' -d '{"style":{"preset":"pill","font":"montserrat","accentColor":"#D4F25A"}}'
135
+ curl -s https://api.harmar.ai/v1/transcripts/$ID/export -H "Authorization: Bearer $HARMAR_API_KEY"
136
+ # → {"status":"completed","download_url":"https://…"} (signed, 1 hour)
137
+ ```
138
+
139
+ Full reference: https://harmar.ai/developers