From dea88c5c2dbe29f9e08edb2520c35b810d482459 Mon Sep 17 00:00:00 2001 From: KKKK Date: Fri, 31 Jul 2026 23:41:15 +0800 Subject: [PATCH] add realtime video plugin tools --- .codex-plugin/plugin.json | 12 +- CHANGELOG.md | 13 +- README.md | 13 +- contract/beatapi.openapi.yaml | 220 ++++++++++++++- contract/contract.lock.json | 4 +- generated/runtime.lock.json | 4 +- generated/skill.lock.json | 4 +- mcp/server.mjs | 261 ++++++++++++++++-- mcp/src/executor.ts | 108 +++++++- mcp/src/tools.ts | 63 ++++- mcp/vendor/client/client.ts | 35 +++ mcp/vendor/client/index.ts | 2 + mcp/vendor/client/types.generated.ts | 256 ++++++++++++++++- package-lock.json | 28 +- package.json | 6 +- skills/beatapi-video/SKILL.md | 27 +- skills/beatapi-video/agents/openai.yaml | 2 +- skills/beatapi-video/evals/evals.json | 14 + .../beatapi-video/references/api-workflows.md | 3 + .../references/beatapi.openapi.yaml | 220 ++++++++++++++- .../references/credits-and-limits.md | 12 + .../references/realtime-video.md | 54 ++++ submission/SUBMISSION.md | 8 +- submission/release-notes.md | 10 +- submission/test-cases.json | 4 + test/mcp-e2e.test.ts | 102 ++++++- test/plugin.test.ts | 14 +- 27 files changed, 1393 insertions(+), 106 deletions(-) create mode 100644 skills/beatapi-video/references/realtime-video.md diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index a740e27..cfe68b5 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "beatapi-codex-plugin", - "version": "0.1.0", - "description": "Create and manage BeatAPI AI music video and ecommerce video workflows from Codex.", + "version": "0.2.0", + "description": "Create and manage BeatAPI async and realtime AI video APIs from Codex.", "author": { "name": "BeatAPI", "email": "support@beatapi.io", @@ -14,14 +14,15 @@ "beatapi", "ai-video", "music-video", - "ecommerce-video" + "ecommerce-video", + "realtime-video" ], "skills": "./skills/", "mcpServers": "./.mcp.json", "interface": { "displayName": "BeatAPI", - "shortDescription": "Create AI music videos and product ads", - "longDescription": "Use one existing BeatAPI account and API key from Codex to upload inputs, check credits and concurrency, create asynchronous Music Video and Ecommerce Video tasks, manage storyboard shots, monitor progress, retrieve hosted results, and configure webhooks.", + "shortDescription": "Manage async and realtime AI video APIs", + "longDescription": "Use one existing BeatAPI account and API key from Codex to upload inputs, check credits and concurrency, create asynchronous Music Video and Ecommerce Video tasks, manage short-lived Realtime Video sessions, monitor progress, retrieve hosted results, and configure webhooks.", "developerName": "BeatAPI", "category": "Creativity", "capabilities": [ @@ -34,6 +35,7 @@ "defaultPrompt": [ "Use $beatapi-video to create a music video from my images and audio.", "Use $beatapi-video to turn my product images into a vertical ad.", + "Use $beatapi-video to create a 60-second realtime session for my web app.", "Use $beatapi-video to check my credits and task status." ], "brandColor": "#2563FF", diff --git a/CHANGELOG.md b/CHANGELOG.md index 832543d..8bcff7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,16 @@ ## Unreleased +## 0.2.0 - 2026-07-31 + +- Added Realtime Video session create, read, and close MCP tools. +- Store the one-time Realtime browser client secret in a mode-`0600` local file + and keep it out of model-visible tool results. +- Synchronized the canonical Skill, official client runtime, and public OpenAPI + contract to the Realtime baseline. +- Added exact-origin, duration, idempotency, billing, and browser trust-boundary + guidance. - Prefer bundled MCP execution in the canonical Skill with CLI fallback. -- Distinguish missing authentication from unexpected CLI runtime failures. -- Align MCP usage fixtures with the current BeatAPI OpenAPI contract. -- Clarify that the public Skills-only package does not bundle the local MCP - server and may require the official CLI. ## 0.1.0 - 2026-07-17 diff --git a/README.md b/README.md index cc9f741..7f52744 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # BeatAPI Codex Plugin -Create and manage BeatAPI AI Music Video and Ecommerce Video workflows directly -from Codex. The plugin combines the canonical `beatapi-video` Skill with a -bundled local MCP server and uses the same API key as the BeatAPI CLI. +Create and manage BeatAPI asynchronous workflows and Realtime Video sessions +directly from Codex. The plugin combines the canonical `beatapi-video` Skill +with a bundled local MCP server and uses the same API key as the BeatAPI CLI. ## What users can do @@ -11,6 +11,7 @@ bundled local MCP server and uses the same API key as the BeatAPI CLI. - create automatic or manual Music Video tasks; - inspect, edit, materialize, and compose storyboard shots; - create Ecommerce Video tasks; +- create, inspect, and close short-lived Realtime Video sessions; - poll asynchronous tasks until a terminal or actionable state; - create, inspect, update, and delete webhook endpoints. @@ -19,6 +20,11 @@ The plugin does not put API keys in prompts or MCP tool arguments. It first uses `beatapi` CLI, which reads the key saved by `beatapi auth login` from the operating-system credential manager. +Realtime creation stores the one-time browser `client_secret` in a local file +with mode `0600`; it is never returned to the model. The agent manages only the +server-side session. Camera permission, WebRTC, and rendering remain in the +browser SDK. + ## Install for Codex desktop Prerequisites: @@ -51,6 +57,7 @@ Restart the desktop app after installation. Useful starter requests include: - “Use `$beatapi-video` to create a music video from my images and audio.” - “Turn these product photos into a 15-second 9:16 ad.” - “Check my BeatAPI credits and the status of task `task_...`.” +- “Create a 60-second Realtime Video session for `https://app.example.com`.” ## Package layout diff --git a/contract/beatapi.openapi.yaml b/contract/beatapi.openapi.yaml index afa4701..65e1dc9 100644 --- a/contract/beatapi.openapi.yaml +++ b/contract/beatapi.openapi.yaml @@ -6,15 +6,20 @@ info: name: BeatAPI Terms of Service url: https://beatapi.io/terms-of-service description: | - BeatAPI is a simple async API for video workflows. Most integrations use the - same loop: create a task, poll the task until it finishes, then read the - hosted video URL from `output.media`. + BeatAPI provides async video workflows and short-lived Realtime Video + Sessions behind one BeatAPI-native API. Async integrations create a task, + poll until it finishes, then read the hosted video URL from `output.media`. + Realtime browser integrations create a Session with the same Bearer API key, + then pass only the returned BeatAPI `client_secret` to `@beatapi/realtime`. ## 5 minute Quick Start 1. Set your base URL to `https://api.beatapi.io`. - 2. Create an API key in Dashboard and send it as + 2. Create an API key in [Dashboard → API Keys](https://beatapi.io/dashboard/apikeys) + and send it as `Authorization: Bearer `. + Credit packs are available from + [Dashboard → Billing](https://beatapi.io/dashboard/billing). 3. Use public HTTPS URLs for input media. If your files are local, upload them with `POST /v1/files` first. 4. Create a workflow task. @@ -96,6 +101,8 @@ tags: description: Poll task status and read output URLs. - name: Usage description: Inspect task totals and account concurrency. + - name: Realtime Video + description: Create and manage short-lived BeatAPI browser sessions for live AI video effects. - name: Files description: Upload local assets and use the returned HTTPS URL as workflow input. - name: Webhooks @@ -444,12 +451,73 @@ components: type: integer credits_settled: type: integer + realtime: + type: object + required: [sessions, credits, active] + properties: + sessions: + type: integer + description: Total BeatAPI realtime sessions for this account. + credits: + type: integer + description: Credits settled by connected realtime sessions. + active: + type: integer + description: Realtime sessions in ready, connecting, or active state. UsageResponse: type: object required: [data] properties: data: $ref: '#/components/schemas/Usage' + RealtimeSession: + type: object + required: [id, object, status, expires_at, max_duration_seconds, allowed_origins, credits, request_id, created_at, connected_at, closed_at] + properties: + id: + type: string + pattern: '^rts_' + object: + type: string + enum: [realtime.session] + status: + type: string + enum: [ready, connecting, active, closed, failed, expired] + description: Active means BeatAPI accepted the first billing heartbeat after remote output began. + client_secret: + type: string + description: Returned only by POST. Give this short-lived BeatAPI secret to the browser SDK; never give the browser an sk_ API key. + pattern: '^brt_live_' + expires_at: + type: string + format: date-time + max_duration_seconds: + type: integer + enum: [15, 60, 300] + allowed_origins: + type: array + items: { type: string, format: uri } + credits: + type: object + required: [reserved, settled, refunded] + properties: + reserved: { type: integer } + settled: { type: integer } + refunded: { type: integer } + request_id: + type: string + created_at: { type: string, format: date-time } + connected_at: + type: [string, 'null'] + format: date-time + description: Time of the first accepted BeatAPI billing heartbeat; null before billing activation. + closed_at: { type: [string, 'null'], format: date-time } + RealtimeSessionResponse: + type: object + required: [data] + properties: + data: + $ref: '#/components/schemas/RealtimeSession' FileResponse: type: object required: [data] @@ -515,6 +583,12 @@ components: - processing_timeout - result_transfer_failed - invalid_signature + - realtime_disabled + - realtime_capacity_unavailable + - realtime_session_expired + - origin_not_allowed + - invalid_client_secret + - transport_not_allowed - internal_error message: type: string @@ -522,7 +596,7 @@ components: type: string retry_after_seconds: type: integer - description: Present on rate_limit_exceeded responses when the client should wait before retrying. + description: Present on retryable rate-limit or capacity responses when the client should wait before retrying. responses: Unauthorized: description: Missing, invalid, or inactive API key. @@ -568,6 +642,7 @@ paths: get: operationId: listWorkflows tags: [Workflows] + x-apidog-folder: Reference/API Overview summary: List launch workflows security: [] responses: @@ -596,7 +671,8 @@ paths: post: operationId: createMusicVideoTask tags: [Music Video] - summary: Create a Music Video workflow task + x-apidog-folder: Music Video API/Create Video + summary: Create Music Video security: - BearerAuth: [] description: | @@ -778,7 +854,8 @@ paths: post: operationId: editMusicVideoShot tags: [Music Video] - summary: Edit a Music Video storyboard shot + x-apidog-folder: Music Video API/Advanced Editing + summary: Edit Shot security: - BearerAuth: [] description: | @@ -852,7 +929,8 @@ paths: post: operationId: getMusicVideoShotMedia tags: [Music Video] - summary: Retrieve a Music Video storyboard shot media URL + x-apidog-folder: Music Video API/Advanced Editing + summary: Get Shot Media security: - BearerAuth: [] description: | @@ -916,7 +994,8 @@ paths: post: operationId: composeMusicVideoTask tags: [Music Video] - summary: Compose a Music Video task from selected shots + x-apidog-folder: Music Video API/Advanced Editing + summary: Compose Video security: - BearerAuth: [] description: | @@ -966,7 +1045,8 @@ paths: post: operationId: createEcommerceVideoTask tags: [Ecommerce Video] - summary: Create an Ecommerce Video workflow task + x-apidog-folder: Ecommerce Video API + summary: Create Ecommerce Video security: - BearerAuth: [] description: Ecommerce Video requires product images and an explicit output duration. @@ -1062,6 +1142,7 @@ paths: get: operationId: getTask tags: [Tasks] + x-apidog-folder: Reference/Task Status summary: Poll task status security: - BearerAuth: [] @@ -1172,10 +1253,119 @@ paths: '429': $ref: '#/components/responses/RateLimited' + /v1/realtime/sessions: + post: + operationId: createRealtimeSession + tags: [Realtime Video] + x-apidog-folder: Realtime Video API + summary: Create a realtime browser session + security: + - BearerAuth: [] + description: | + Reserve credits and allocate a short-lived BeatAPI realtime session. Send a unique + `Idempotency-Key`; retries with the same user, key, and body return the same session + and deterministic short-lived `client_secret` without reserving credits or capacity + twice. The browser receives only that BeatAPI secret and connects with + `@beatapi/realtime`. + + Billing is fixed by the selected maximum duration. The BeatAPI browser runtime sends its + first billing heartbeat only after the first remote output frame is rendered. When that + accepted BeatAPI billing heartbeat succeeds, the Session becomes `active` and the selected + duration is fully settled. A Session that closes or expires without an accepted billing + heartbeat is fully refunded. Billing activation comes from the trusted transport lifecycle, + not a caller-supplied browser event. + Production availability remains gated until the documented commercial and capacity launch + checks pass. + parameters: + - in: header + name: Idempotency-Key + required: true + schema: { type: string, maxLength: 128 } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [max_duration_seconds, allowed_origins] + properties: + max_duration_seconds: + type: integer + enum: [15, 60, 300] + allowed_origins: + type: array + minItems: 1 + maxItems: 10 + items: { type: string, format: uri } + metadata: + type: object + maxProperties: 20 + propertyNames: { maxLength: 64 } + additionalProperties: { type: string, maxLength: 256 } + example: + max_duration_seconds: 60 + allowed_origins: ["https://app.example.com"] + metadata: { customer_id: cus_123 } + responses: + '201': + description: Realtime session created + content: + application/json: + schema: { $ref: '#/components/schemas/RealtimeSessionResponse' } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '402': + description: Insufficient credits + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + '409': + description: Idempotency conflict + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + '429': { $ref: '#/components/responses/RateLimited' } + '503': + description: Realtime is disabled or capacity is temporarily unavailable + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + + /v1/realtime/sessions/{session_id}: + parameters: + - in: path + name: session_id + required: true + schema: { type: string, pattern: '^rts_' } + get: + operationId: getRealtimeSession + tags: [Realtime Video] + x-apidog-folder: Realtime Video API + summary: Get a realtime session + security: [{ BearerAuth: [] }] + responses: + '200': + description: Realtime session + content: { application/json: { schema: { $ref: '#/components/schemas/RealtimeSessionResponse' } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': + description: Realtime session not found + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + delete: + operationId: closeRealtimeSession + tags: [Realtime Video] + x-apidog-folder: Realtime Video API + summary: Close a realtime session + security: [{ BearerAuth: [] }] + description: Idempotently closes the session, clears temporary credentials, and releases account capacity. + responses: + '200': + description: Realtime session closed + content: { application/json: { schema: { $ref: '#/components/schemas/RealtimeSessionResponse' } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': + description: Realtime session not found + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + /v1/usage: get: operationId: getUsage tags: [Usage] + x-apidog-folder: Reference/Usage & Limits summary: Get account usage and concurrency security: - BearerAuth: [] @@ -1196,6 +1386,10 @@ paths: concurrency: limit: 2 active: 1 + realtime: + sessions: 3 + credits: 90 + active: 1 by_workflow: - workflow: music-video tasks: 8 @@ -1210,6 +1404,7 @@ paths: post: operationId: uploadFile tags: [Files] + x-apidog-folder: Reference/Upload Files summary: Upload a file for workflow inputs security: - BearerAuth: [] @@ -1278,6 +1473,7 @@ paths: get: operationId: listWebhookEndpoints tags: [Webhooks] + x-apidog-folder: Reference/Webhooks summary: List webhook endpoints security: - BearerAuth: [] @@ -1306,6 +1502,7 @@ paths: post: operationId: createWebhookEndpoint tags: [Webhooks] + x-apidog-folder: Reference/Webhooks summary: Create a webhook endpoint security: - BearerAuth: [] @@ -1426,6 +1623,7 @@ paths: get: operationId: getWebhookEndpoint tags: [Webhooks] + x-apidog-folder: Reference/Webhooks summary: Get a webhook endpoint security: - BearerAuth: [] @@ -1465,6 +1663,7 @@ paths: patch: operationId: updateWebhookEndpoint tags: [Webhooks] + x-apidog-folder: Reference/Webhooks summary: Update a webhook endpoint security: - BearerAuth: [] @@ -1511,6 +1710,7 @@ paths: delete: operationId: deleteWebhookEndpoint tags: [Webhooks] + x-apidog-folder: Reference/Webhooks summary: Delete a webhook endpoint security: - BearerAuth: [] diff --git a/contract/contract.lock.json b/contract/contract.lock.json index 77e44cc..47af603 100644 --- a/contract/contract.lock.json +++ b/contract/contract.lock.json @@ -1,6 +1,6 @@ { "source": "https://github.com/BeatAPI/beatapi-examples", - "ref": "0c1346cbc1a48b1ac89032543e299f90d4620913", + "ref": "8f7d3cff33445ded4d3c94f0fb8ac5060d790148", "openapiVersion": "1.0.0-launch", - "sha256": "4ce0420a65ca14443a089307563e4b422118533982a1e742eded72f32707a52a" + "sha256": "290100dba10bb14b040f5a826657ad7d4a01f179fc28ef69ea0bdcaa66f7dad3" } diff --git a/generated/runtime.lock.json b/generated/runtime.lock.json index 24cff05..5bb650c 100644 --- a/generated/runtime.lock.json +++ b/generated/runtime.lock.json @@ -1,5 +1,5 @@ { "source": "https://github.com/BeatAPI/beatapi-cli/tree/main/packages/client", - "ref": "2066265433cd0cf2ab2350c353ef915a8d8c3b5d", - "sha256": "198cb709affc06790ee66ac067c6a37cf15b8f7ef9bc864e373d5f169804bb1e" + "ref": "ec973b34894551ee2fafb27b7c8f3881ecef0b8b", + "sha256": "bbcc96e34af27bdf2a719e60c60ee075f0526b416e9a82b8ee349fadc5a9eb73" } diff --git a/generated/skill.lock.json b/generated/skill.lock.json index 91c5f9e..e5dd8e4 100644 --- a/generated/skill.lock.json +++ b/generated/skill.lock.json @@ -1,5 +1,5 @@ { "source": "https://github.com/BeatAPI/beatapi-skill/tree/main/skills/beatapi-video", - "ref": "ac198ebfc5d73de4cb80b67dde1c6b47833c8128", - "sha256": "1f006510cf83b5d015e2ada513956c8d4bfd37fe0019533cfd160aef9d1e0666" + "ref": "62ceeaeb2ec461f70159899385920f21aa804e19", + "sha256": "f50bf504fcb43224dec22ab1f94a00c407a4d459c33babb21153d466263b41e6" } diff --git a/mcp/server.mjs b/mcp/server.mjs index 2000ce0..313ad10 100644 --- a/mcp/server.mjs +++ b/mcp/server.mjs @@ -3644,7 +3644,12 @@ var require_fast_uri = __commonJS({ } function resolve2(baseURI, relativeURI, options) { const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" }; - const resolved = resolveComponent(parse3(baseURI, schemelessOptions), parse3(relativeURI, schemelessOptions), schemelessOptions, true); + const { parsed: baseParsed, malformedAuthorityOrPort: baseMalformed } = parseWithStatus(baseURI, schemelessOptions); + const { parsed: relativeParsed, malformedAuthorityOrPort: relativeMalformed } = parseWithStatus(relativeURI, schemelessOptions); + if (baseMalformed || relativeMalformed) { + throw new Error(baseParsed.error || relativeParsed.error || "URI is malformed."); + } + const resolved = resolveComponent(baseParsed, relativeParsed, schemelessOptions, true); schemelessOptions.skipEscape = true; return serialize(resolved, schemelessOptions); } @@ -3769,6 +3774,8 @@ var require_fast_uri = __commonJS({ return uriTokens.join(""); } var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u; + var AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/; + var AUTHORITY_INTRODUCER_REGION = /^(?:[^#/:?]+:)?([/\\\t\n\r]*)/; function getParseError(parsed, matches) { if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") { return 'URI path must start with "/" when authority is present.'; @@ -3798,6 +3805,25 @@ var require_fast_uri = __commonJS({ uri2 = "//" + uri2; } } + const authorityMatch = uri2.match(AUTHORITY_PREFIX); + if (authorityMatch !== null && authorityMatch[1].indexOf("\\") !== -1) { + parsed.error = "URI authority must not contain a literal backslash."; + malformedAuthorityOrPort = true; + } + const introducerMatch = uri2.match(AUTHORITY_INTRODUCER_REGION); + if (introducerMatch !== null) { + const region = introducerMatch[1]; + const normalizedRegion = region.replace(/[\t\n\r]/g, ""); + if (normalizedRegion.length >= 2) { + if (normalizedRegion.slice(0, 2) !== "//") { + parsed.error = parsed.error || "URI authority must not contain a literal backslash."; + malformedAuthorityOrPort = true; + } else if (region.length !== normalizedRegion.length) { + parsed.error = parsed.error || "URI authority introducer must not contain whitespace."; + malformedAuthorityOrPort = true; + } + } + } const matches = uri2.match(URI_PARSE); if (matches) { parsed.scheme = matches[1]; @@ -23042,17 +23068,33 @@ function normalizeObjectSchema(schema) { } return void 0; } +function getDotPath(path) { + if (path.length === 0) { + return "object root"; + } + return path.reduce((acc, seg, index) => { + if (index === 0) { + return String(seg); + } + if (typeof seg === "number") { + return `${acc}[${seg}]`; + } + return `${acc}.${seg}`; + }, ""); +} function getParseErrorMessage(error51) { if (error51 && typeof error51 === "object") { + if ("issues" in error51 && Array.isArray(error51.issues) && error51.issues.length > 0) { + return error51.issues.map((i) => { + if (!i.path?.length) { + return i.message; + } + return `${i.message} at ${getDotPath(i.path)}`; + }).join("\n"); + } if ("message" in error51 && typeof error51.message === "string") { return error51.message; } - if ("issues" in error51 && Array.isArray(error51.issues) && error51.issues.length > 0) { - const firstIssue = error51.issues[0]; - if (firstIssue && typeof firstIssue === "object" && "message" in firstIssue) { - return String(firstIssue.message); - } - } try { return JSON.stringify(error51); } catch { @@ -29667,16 +29709,7 @@ var Server = class extends Protocol { if (!methodSchema) { throw new Error("Schema is missing a method literal"); } - let methodValue; - if (isZ4Schema(methodSchema)) { - const v4Schema = methodSchema; - const v4Def = v4Schema._zod?.def; - methodValue = v4Def?.value ?? v4Schema.value; - } else { - const v3Schema = methodSchema; - const legacyDef = v3Schema._def; - methodValue = legacyDef?.value ?? v3Schema.value; - } + const methodValue = getLiteralValue(methodSchema); if (typeof methodValue !== "string") { throw new Error("Schema method literal must be a string"); } @@ -30864,8 +30897,17 @@ var EMPTY_COMPLETION_RESULT = { import process3 from "node:process"; // node_modules/@modelcontextprotocol/sdk/dist/esm/shared/stdio.js +var STDIO_DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024; var ReadBuffer = class { + constructor(options) { + this._maxBufferSize = options?.maxBufferSize ?? STDIO_DEFAULT_MAX_BUFFER_SIZE; + } append(chunk) { + const newSize = (this._buffer?.length ?? 0) + chunk.length; + if (newSize > this._maxBufferSize) { + this.clear(); + throw new Error(`ReadBuffer exceeded maximum size of ${this._maxBufferSize} bytes`); + } this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk; } readMessage() { @@ -30893,18 +30935,24 @@ function serializeMessage(message) { // node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js var StdioServerTransport = class { - constructor(_stdin = process3.stdin, _stdout = process3.stdout) { + constructor(_stdin = process3.stdin, _stdout = process3.stdout, options) { this._stdin = _stdin; this._stdout = _stdout; - this._readBuffer = new ReadBuffer(); this._started = false; this._ondata = (chunk) => { - this._readBuffer.append(chunk); - this.processReadBuffer(); + try { + this._readBuffer.append(chunk); + this.processReadBuffer(); + } catch (error51) { + this.onerror?.(error51); + this.close().catch(() => { + }); + } }; this._onerror = (error51) => { this.onerror?.(error51); }; + this._readBuffer = new ReadBuffer({ maxBufferSize: options?.maxBufferSize }); } /** * Starts listening for messages on stdin. @@ -31082,6 +31130,9 @@ var BeatAPIClient = class { assertPositiveInteger(maxDelayMs, "retry.maxDelayMs"); const headers = new Headers({ accept: "application/json" }); if (authenticated) headers.set("authorization", `Bearer ${this.apiKey}`); + for (const [name, value] of new Headers(options.headers)) { + headers.set(name, value); + } let body; if (options.body instanceof FormData) { body = options.body; @@ -31139,6 +31190,28 @@ var BeatAPIClient = class { getUsage() { return this.request("/v1/usage"); } + createRealtimeSession(input, options) { + const idempotencyKey = options.idempotencyKey.trim(); + if (!idempotencyKey) { + throw new TypeError("idempotencyKey must not be empty."); + } + return this.request("/v1/realtime/sessions", { + method: "POST", + body: input, + headers: { "idempotency-key": idempotencyKey } + }); + } + getRealtimeSession(sessionId) { + return this.request( + `/v1/realtime/sessions/${encodePathSegment(sessionId)}` + ); + } + closeRealtimeSession(sessionId) { + return this.request( + `/v1/realtime/sessions/${encodePathSegment(sessionId)}`, + { method: "DELETE" } + ); + } getTask(taskId, options = {}) { return this.request(`/v1/tasks/${encodePathSegment(taskId)}`, { retry: options.retry @@ -31258,7 +31331,9 @@ function sanitize(value) { if (typeof value === "string") return redactText(value); if (!value || typeof value !== "object") return value; return Object.fromEntries( - Object.entries(value).filter(([key]) => !/^(secret|api[_-]?key|authorization)$/i.test(key)).map(([key, child]) => [key, sanitize(child)]) + Object.entries(value).filter( + ([key]) => !/^(secret|client[_-]?secret|api[_-]?key|authorization)$/i.test(key) + ).map(([key, child]) => [key, sanitize(child)]) ); } function parseCliJson(stdout) { @@ -31318,12 +31393,12 @@ async function withJsonFile(value, callback) { await rm(directory, { recursive: true, force: true }); } } -async function preflightSecretPath(requested) { +async function preflightSecretPath(requested, prefix = "webhook") { const root = resolve( process.env.CODEX_HOME?.trim() || resolve(homedir(), ".codex"), "beatapi/secrets" ); - const filename = typeof requested === "string" && requested.trim() ? requested.trim() : `webhook-${Date.now()}.secret`; + const filename = typeof requested === "string" && requested.trim() ? requested.trim() : `${prefix}-${Date.now()}.secret`; if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(filename)) { throw new Error( "secret_file_name must be a simple filename containing only letters, numbers, dot, underscore, or hyphen." @@ -31339,6 +31414,26 @@ async function preflightSecretPath(requested) { } return path; } +async function saveRealtimeClientSecret(session, path, rollback) { + const secret = session.client_secret; + if (typeof secret !== "string" || !secret) { + await rollback().catch(() => void 0); + throw new Error("BeatAPI did not return a usable Realtime client secret."); + } + try { + await writeFile(path, `${secret} +`, { mode: 384, flag: "wx" }); + await chmod(path, 384); + } catch (error51) { + await rollback().catch(() => void 0); + throw new Error( + "Unable to store the one-time Realtime client secret; the session was closed.", + { cause: error51 } + ); + } + const clean = sanitize(session); + return { ...clean, client_secret_file: path }; +} async function saveWebhookSecret(endpoint, path, rollback) { const secret = endpoint.secret; if (typeof secret !== "string" || !secret || secret.includes("masked")) { @@ -31467,6 +31562,37 @@ var BeatAPIExecutor = class { input ) ); + case "beatapi_create_realtime_session": { + const secretPath = await preflightSecretPath( + input.client_secret_file_name, + "realtime" + ); + const session = await this.direct.createRealtimeSession( + without(input, [ + "idempotency_key", + "client_secret_file_name" + ]), + { idempotencyKey: stringValue(input, "idempotency_key") } + ); + const sessionId = String(session.id || ""); + return saveRealtimeClientSecret( + session, + secretPath, + () => this.direct.closeRealtimeSession(sessionId) + ); + } + case "beatapi_get_realtime_session": + return sanitize( + await this.direct.getRealtimeSession( + stringValue(input, "session_id") + ) + ); + case "beatapi_close_realtime_session": + return sanitize( + await this.direct.closeRealtimeSession( + stringValue(input, "session_id") + ) + ); case "beatapi_get_task": return sanitize(await this.direct.getTask(stringValue(input, "task_id"))); case "beatapi_wait_for_task": @@ -31563,6 +31689,50 @@ var BeatAPIExecutor = class { (path) => runCli(["ecommerce-video", "create", "--file", path]) ); break; + case "beatapi_create_realtime_session": { + const secretPath = await preflightSecretPath( + input.client_secret_file_name, + "realtime" + ); + const result2 = await runCli([ + "realtime", + "sessions", + "create", + "--duration", + String(input.max_duration_seconds), + ...input.allowed_origins.flatMap((origin) => [ + "--origin", + origin + ]), + ...Object.entries( + input.metadata ?? {} + ).flatMap(([key, value]) => ["--metadata", `${key}=${value}`]), + "--idempotency-key", + stringValue(input, "idempotency_key") + ]); + const sessionId = String(result2.id || ""); + return saveRealtimeClientSecret( + result2, + secretPath, + () => runCli(["realtime", "sessions", "close", sessionId]) + ); + } + case "beatapi_get_realtime_session": + result = await runCli([ + "realtime", + "sessions", + "get", + stringValue(input, "session_id") + ]); + break; + case "beatapi_close_realtime_session": + result = await runCli([ + "realtime", + "sessions", + "close", + stringValue(input, "session_id") + ]); + break; case "beatapi_get_task": result = await runCli(["tasks", "get", stringValue(input, "task_id")]); break; @@ -31658,6 +31828,16 @@ var resolution = external_exports.enum(["540p", "720p", "1080p"]); var language = external_exports.enum(["en", "zh"]); var uri = external_exports.string().url(); var webhookEvents = external_exports.array(external_exports.enum(["task.succeeded", "task.failed"])); +var secretFileName = external_exports.string().trim().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/).optional(); +var httpsOrigin = external_exports.string().url().superRefine((value, context) => { + const parsed = new URL(value); + if (parsed.protocol !== "https:" || parsed.pathname !== "/" || parsed.search || parsed.hash || parsed.username || parsed.password) { + context.addIssue({ + code: "custom", + message: "An exact HTTPS origin without path, query, or fragment is required." + }); + } +}); var musicVideoInput = external_exports.object({ images: imageUrls, audio_url: httpsUrl, @@ -31781,6 +31961,37 @@ var toolDefinitions = [ }).strict(), annotations: write }, + { + name: "beatapi_create_realtime_session", + title: "Create BeatAPI Realtime session", + description: "Paid mutation: reserve credits and create a short-lived Realtime Video browser session. The one-time client secret is written to a local mode-0600 file and is never returned in the tool response.", + inputSchema: external_exports.object({ + max_duration_seconds: external_exports.union([ + external_exports.literal(15), + external_exports.literal(60), + external_exports.literal(300) + ]), + allowed_origins: external_exports.array(httpsOrigin).min(1).max(10), + metadata: external_exports.record(external_exports.string(), external_exports.string()).optional(), + idempotency_key: external_exports.string().trim().min(1).max(255), + client_secret_file_name: secretFileName + }).strict(), + annotations: write + }, + { + name: "beatapi_get_realtime_session", + title: "Get BeatAPI Realtime session", + description: "Read the current server-side Realtime session status and credit settlement without exposing its one-time client secret.", + inputSchema: external_exports.object({ session_id: id }).strict(), + annotations: readOnly + }, + { + name: "beatapi_close_realtime_session", + title: "Close BeatAPI Realtime session", + description: "Destructive mutation: close one Realtime Video session and release/refund any eligible unused reservation.", + inputSchema: external_exports.object({ session_id: id }).strict(), + annotations: destructive + }, { name: "beatapi_get_task", title: "Get BeatAPI task", @@ -31814,7 +32025,7 @@ var toolDefinitions = [ url: uri, description: external_exports.string().optional(), events: webhookEvents.optional(), - secret_file_name: external_exports.string().trim().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/).optional() + secret_file_name: secretFileName }).strict(), annotations: write }, diff --git a/mcp/src/executor.ts b/mcp/src/executor.ts index 47d2db9..99e55ec 100644 --- a/mcp/src/executor.ts +++ b/mcp/src/executor.ts @@ -16,6 +16,7 @@ import { promisify } from "node:util"; import { BeatAPIClient, BeatAPIError, + type CreateRealtimeSessionInput, type CreateWebhookInput, type EcommerceVideoTaskInput, type MusicVideoShotEditInput, @@ -64,7 +65,10 @@ function sanitize(value: unknown): unknown { if (!value || typeof value !== "object") return value; return Object.fromEntries( Object.entries(value as Record) - .filter(([key]) => !/^(secret|api[_-]?key|authorization)$/i.test(key)) + .filter( + ([key]) => + !/^(secret|client[_-]?secret|api[_-]?key|authorization)$/i.test(key), + ) .map(([key, child]) => [key, sanitize(child)]), ); } @@ -151,7 +155,10 @@ async function withJsonFile( } } -async function preflightSecretPath(requested: unknown): Promise { +async function preflightSecretPath( + requested: unknown, + prefix = "webhook", +): Promise { const root = resolve( process.env.CODEX_HOME?.trim() || resolve(homedir(), ".codex"), "beatapi/secrets", @@ -159,7 +166,7 @@ async function preflightSecretPath(requested: unknown): Promise { const filename = typeof requested === "string" && requested.trim() ? requested.trim() - : `webhook-${Date.now()}.secret`; + : `${prefix}-${Date.now()}.secret`; if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(filename)) { throw new Error( "secret_file_name must be a simple filename containing only letters, numbers, dot, underscore, or hyphen.", @@ -176,6 +183,30 @@ async function preflightSecretPath(requested: unknown): Promise { return path; } +async function saveRealtimeClientSecret( + session: Record, + path: string, + rollback: () => Promise, +): Promise> { + const secret = session.client_secret; + if (typeof secret !== "string" || !secret) { + await rollback().catch(() => undefined); + throw new Error("BeatAPI did not return a usable Realtime client secret."); + } + try { + await writeFile(path, `${secret}\n`, { mode: 0o600, flag: "wx" }); + await chmod(path, 0o600); + } catch (error) { + await rollback().catch(() => undefined); + throw new Error( + "Unable to store the one-time Realtime client secret; the session was closed.", + { cause: error }, + ); + } + const clean = sanitize(session) as Record; + return { ...clean, client_secret_file: path }; +} + async function saveWebhookSecret( endpoint: Record, path: string, @@ -314,6 +345,35 @@ export class BeatAPIExecutor { input as EcommerceVideoTaskInput, ), ); + case "beatapi_create_realtime_session": { + const secretPath = await preflightSecretPath( + input.client_secret_file_name, + "realtime", + ); + const session = (await this.direct.createRealtimeSession( + without(input, [ + "idempotency_key", + "client_secret_file_name", + ]) as CreateRealtimeSessionInput, + { idempotencyKey: stringValue(input, "idempotency_key") }, + )) as unknown as Record; + const sessionId = String(session.id || ""); + return saveRealtimeClientSecret(session, secretPath, () => + this.direct.closeRealtimeSession(sessionId), + ); + } + case "beatapi_get_realtime_session": + return sanitize( + await this.direct.getRealtimeSession( + stringValue(input, "session_id"), + ), + ); + case "beatapi_close_realtime_session": + return sanitize( + await this.direct.closeRealtimeSession( + stringValue(input, "session_id"), + ), + ); case "beatapi_get_task": return sanitize(await this.direct.getTask(stringValue(input, "task_id"))); case "beatapi_wait_for_task": @@ -406,6 +466,48 @@ export class BeatAPIExecutor { runCli(["ecommerce-video", "create", "--file", path]), ); break; + case "beatapi_create_realtime_session": { + const secretPath = await preflightSecretPath( + input.client_secret_file_name, + "realtime", + ); + const result = (await runCli([ + "realtime", + "sessions", + "create", + "--duration", + String(input.max_duration_seconds), + ...(input.allowed_origins as string[]).flatMap((origin) => [ + "--origin", + origin, + ]), + ...Object.entries( + (input.metadata as Record | undefined) ?? {}, + ).flatMap(([key, value]) => ["--metadata", `${key}=${value}`]), + "--idempotency-key", + stringValue(input, "idempotency_key"), + ])) as Record; + const sessionId = String(result.id || ""); + return saveRealtimeClientSecret(result, secretPath, () => + runCli(["realtime", "sessions", "close", sessionId]), + ); + } + case "beatapi_get_realtime_session": + result = await runCli([ + "realtime", + "sessions", + "get", + stringValue(input, "session_id"), + ]); + break; + case "beatapi_close_realtime_session": + result = await runCli([ + "realtime", + "sessions", + "close", + stringValue(input, "session_id"), + ]); + break; case "beatapi_get_task": result = await runCli(["tasks", "get", stringValue(input, "task_id")]); break; diff --git a/mcp/src/tools.ts b/mcp/src/tools.ts index 7bf4f53..b139d59 100644 --- a/mcp/src/tools.ts +++ b/mcp/src/tools.ts @@ -41,6 +41,27 @@ const resolution = z.enum(["540p", "720p", "1080p"]); const language = z.enum(["en", "zh"]); const uri = z.string().url(); const webhookEvents = z.array(z.enum(["task.succeeded", "task.failed"])); +const secretFileName = z + .string() + .trim() + .regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/) + .optional(); +const httpsOrigin = z.string().url().superRefine((value, context) => { + const parsed = new URL(value); + if ( + parsed.protocol !== "https:" || + parsed.pathname !== "/" || + parsed.search || + parsed.hash || + parsed.username || + parsed.password + ) { + context.addIssue({ + code: "custom", + message: "An exact HTTPS origin without path, query, or fragment is required.", + }); + } +}); const musicVideoInput = z .object({ @@ -186,6 +207,42 @@ export const toolDefinitions: readonly ToolDefinition[] = [ .strict(), annotations: write, }, + { + name: "beatapi_create_realtime_session", + title: "Create BeatAPI Realtime session", + description: + "Paid mutation: reserve credits and create a short-lived Realtime Video browser session. The one-time client secret is written to a local mode-0600 file and is never returned in the tool response.", + inputSchema: z + .object({ + max_duration_seconds: z.union([ + z.literal(15), + z.literal(60), + z.literal(300), + ]), + allowed_origins: z.array(httpsOrigin).min(1).max(10), + metadata: z.record(z.string(), z.string()).optional(), + idempotency_key: z.string().trim().min(1).max(255), + client_secret_file_name: secretFileName, + }) + .strict(), + annotations: write, + }, + { + name: "beatapi_get_realtime_session", + title: "Get BeatAPI Realtime session", + description: + "Read the current server-side Realtime session status and credit settlement without exposing its one-time client secret.", + inputSchema: z.object({ session_id: id }).strict(), + annotations: readOnly, + }, + { + name: "beatapi_close_realtime_session", + title: "Close BeatAPI Realtime session", + description: + "Destructive mutation: close one Realtime Video session and release/refund any eligible unused reservation.", + inputSchema: z.object({ session_id: id }).strict(), + annotations: destructive, + }, { name: "beatapi_get_task", title: "Get BeatAPI task", @@ -224,11 +281,7 @@ export const toolDefinitions: readonly ToolDefinition[] = [ url: uri, description: z.string().optional(), events: webhookEvents.optional(), - secret_file_name: z - .string() - .trim() - .regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/) - .optional(), + secret_file_name: secretFileName, }) .strict(), annotations: write, diff --git a/mcp/vendor/client/client.ts b/mcp/vendor/client/client.ts index ed5346b..30d3f2f 100644 --- a/mcp/vendor/client/client.ts +++ b/mcp/vendor/client/client.ts @@ -8,6 +8,7 @@ export type BeatAPIUsage = components["schemas"]["Usage"]; export type BeatAPIFile = components["schemas"]["File"]; export type BeatAPIShotMedia = components["schemas"]["ShotMedia"]; export type BeatAPIWebhook = components["schemas"]["WebhookEndpoint"]; +export type BeatAPIRealtimeSession = components["schemas"]["RealtimeSession"]; export type BeatAPIDeleteResult = components["schemas"]["DeleteResponse"]["data"]; export type MusicVideoTaskInput = @@ -22,6 +23,8 @@ export type CreateWebhookInput = operations["createWebhookEndpoint"]["requestBody"]["content"]["application/json"]; export type UpdateWebhookInput = operations["updateWebhookEndpoint"]["requestBody"]["content"]["application/json"]; +export type CreateRealtimeSessionInput = + operations["createRealtimeSession"]["requestBody"]["content"]["application/json"]; type FetchLike = ( input: string | URL | Request, @@ -45,6 +48,7 @@ export interface BeatAPIClientOptions { interface RequestOptions { method?: string | undefined; body?: unknown | undefined; + headers?: HeadersInit | undefined; authenticated?: boolean | undefined; retry?: RetryOptions | undefined; } @@ -198,6 +202,9 @@ export class BeatAPIClient { const headers = new Headers({ accept: "application/json" }); if (authenticated) headers.set("authorization", `Bearer ${this.apiKey}`); + for (const [name, value] of new Headers(options.headers)) { + headers.set(name, value); + } let body: BodyInit | undefined; if (options.body instanceof FormData) { @@ -273,6 +280,34 @@ export class BeatAPIClient { return this.request("/v1/usage"); } + createRealtimeSession( + input: CreateRealtimeSessionInput, + options: { idempotencyKey: string }, + ): Promise { + const idempotencyKey = options.idempotencyKey.trim(); + if (!idempotencyKey) { + throw new TypeError("idempotencyKey must not be empty."); + } + return this.request("/v1/realtime/sessions", { + method: "POST", + body: input, + headers: { "idempotency-key": idempotencyKey }, + }); + } + + getRealtimeSession(sessionId: string): Promise { + return this.request( + `/v1/realtime/sessions/${encodePathSegment(sessionId)}`, + ); + } + + closeRealtimeSession(sessionId: string): Promise { + return this.request( + `/v1/realtime/sessions/${encodePathSegment(sessionId)}`, + { method: "DELETE" }, + ); + } + getTask( taskId: string, options: { retry?: RetryOptions } = {}, diff --git a/mcp/vendor/client/index.ts b/mcp/vendor/client/index.ts index 135a775..9aaa491 100644 --- a/mcp/vendor/client/index.ts +++ b/mcp/vendor/client/index.ts @@ -3,6 +3,7 @@ export { type BeatAPIClientOptions, type BeatAPIDeleteResult, type BeatAPIFile, + type BeatAPIRealtimeSession, type BeatAPIShotMedia, type BeatAPITask, type BeatAPITaskStatus, @@ -10,6 +11,7 @@ export { type BeatAPIWebhook, type BeatAPIWorkflow, type CreateWebhookInput, + type CreateRealtimeSessionInput, type EcommerceVideoTaskInput, type MusicVideoComposeInput, type MusicVideoShotEditInput, diff --git a/mcp/vendor/client/types.generated.ts b/mcp/vendor/client/types.generated.ts index 309ada8..d030b36 100644 --- a/mcp/vendor/client/types.generated.ts +++ b/mcp/vendor/client/types.generated.ts @@ -31,7 +31,7 @@ export interface paths { get?: never; put?: never; /** - * Create a Music Video workflow task + * Create Music Video * @description Music Video requires public HTTPS image URLs and a public HTTPS audio URL. * Prompt, language, quality, style, lip reference, subtitle, and format * controls are optional. BeatAPI detects the audio duration before task @@ -93,7 +93,7 @@ export interface paths { get?: never; put?: never; /** - * Edit a Music Video storyboard shot + * Edit Shot * @description Edit one storyboard shot using its BeatAPI `shot_id`. This operation * charges BeatAPI customer credits using the selected quality/resolution * rate and the shot duration. Default shot duration is 5 seconds. @@ -118,7 +118,7 @@ export interface paths { get?: never; put?: never; /** - * Retrieve a Music Video storyboard shot media URL + * Get Shot Media * @description Materialize one storyboard shot video using its BeatAPI `shot_id`. * If the shot has not been stored yet, BeatAPI retrieves the current shot * video, stores it under BeatAPI media storage, and returns a BeatAPI media @@ -144,7 +144,7 @@ export interface paths { get?: never; put?: never; /** - * Compose a Music Video task from selected shots + * Compose Video * @description Compose selected BeatAPI storyboard shots into the final Music Video. * This operation charges a fixed 1 BeatAPI customer credit. */ @@ -165,7 +165,7 @@ export interface paths { get?: never; put?: never; /** - * Create an Ecommerce Video workflow task + * Create Ecommerce Video * @description Ecommerce Video requires product images and an explicit output duration. */ post: operations["createEcommerceVideoTask"]; @@ -195,6 +195,62 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/realtime/sessions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a realtime browser session + * @description Reserve credits and allocate a short-lived BeatAPI realtime session. Send a unique + * `Idempotency-Key`; retries with the same user, key, and body return the same session + * and deterministic short-lived `client_secret` without reserving credits or capacity + * twice. The browser receives only that BeatAPI secret and connects with + * `@beatapi/realtime`. + * + * Billing is fixed by the selected maximum duration. The BeatAPI browser runtime sends its + * first billing heartbeat only after the first remote output frame is rendered. When that + * accepted BeatAPI billing heartbeat succeeds, the Session becomes `active` and the selected + * duration is fully settled. A Session that closes or expires without an accepted billing + * heartbeat is fully refunded. Billing activation comes from the trusted transport lifecycle, + * not a caller-supplied browser event. + * Production availability remains gated until the documented commercial and capacity launch + * checks pass. + */ + post: operations["createRealtimeSession"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/realtime/sessions/{session_id}": { + parameters: { + query?: never; + header?: never; + path: { + session_id: string; + }; + cookie?: never; + }; + /** Get a realtime session */ + get: operations["getRealtimeSession"]; + put?: never; + post?: never; + /** + * Close a realtime session + * @description Idempotently closes the session, clears temporary credentials, and releases account capacity. + */ + delete: operations["closeRealtimeSession"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/usage": { parameters: { query?: never; @@ -582,10 +638,53 @@ export interface components { tasks: number; credits_settled: number; }[]; + realtime?: { + /** @description Total BeatAPI realtime sessions for this account. */ + sessions: number; + /** @description Credits settled by connected realtime sessions. */ + credits: number; + /** @description Realtime sessions in ready, connecting, or active state. */ + active: number; + }; }; UsageResponse: { data: components["schemas"]["Usage"]; }; + RealtimeSession: { + id: string; + /** @enum {string} */ + object: "realtime.session"; + /** + * @description Active means BeatAPI accepted the first billing heartbeat after remote output began. + * @enum {string} + */ + status: "ready" | "connecting" | "active" | "closed" | "failed" | "expired"; + /** @description Returned only by POST. Give this short-lived BeatAPI secret to the browser SDK; never give the browser an sk_ API key. */ + client_secret?: string; + /** Format: date-time */ + expires_at: string; + /** @enum {integer} */ + max_duration_seconds: 15 | 60 | 300; + allowed_origins: string[]; + credits: { + reserved: number; + settled: number; + refunded: number; + }; + request_id: string; + /** Format: date-time */ + created_at: string; + /** + * Format: date-time + * @description Time of the first accepted BeatAPI billing heartbeat; null before billing activation. + */ + connected_at: string | null; + /** Format: date-time */ + closed_at: string | null; + }; + RealtimeSessionResponse: { + data: components["schemas"]["RealtimeSession"]; + }; FileResponse: { data: components["schemas"]["File"]; }; @@ -609,10 +708,10 @@ export interface components { Error: { error: { /** @enum {string} */ - code: "bad_request" | "unauthorized" | "forbidden" | "not_found" | "insufficient_credits" | "idempotency_conflict" | "user_concurrency_exceeded" | "rate_limit_exceeded" | "processing_unavailable" | "processing_failed" | "processing_timeout" | "result_transfer_failed" | "invalid_signature" | "internal_error"; + code: "bad_request" | "unauthorized" | "forbidden" | "not_found" | "insufficient_credits" | "idempotency_conflict" | "user_concurrency_exceeded" | "rate_limit_exceeded" | "processing_unavailable" | "processing_failed" | "processing_timeout" | "result_transfer_failed" | "invalid_signature" | "realtime_disabled" | "realtime_capacity_unavailable" | "realtime_session_expired" | "origin_not_allowed" | "invalid_client_secret" | "transport_not_allowed" | "internal_error"; message: string; request_id: string; - /** @description Present on rate_limit_exceeded responses when the client should wait before retrying. */ + /** @description Present on retryable rate-limit or capacity responses when the client should wait before retrying. */ retry_after_seconds?: number; }; }; @@ -1178,6 +1277,144 @@ export interface operations { 429: components["responses"]["RateLimited"]; }; }; + createRealtimeSession: { + parameters: { + query?: never; + header: { + "Idempotency-Key": string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "max_duration_seconds": 60, + * "allowed_origins": [ + * "https://app.example.com" + * ], + * "metadata": { + * "customer_id": "cus_123" + * } + * } + */ + "application/json": { + /** @enum {integer} */ + max_duration_seconds: 15 | 60 | 300; + allowed_origins: string[]; + metadata?: { + [key: string]: string; + }; + }; + }; + }; + responses: { + /** @description Realtime session created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RealtimeSessionResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + /** @description Insufficient credits */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Idempotency conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 429: components["responses"]["RateLimited"]; + /** @description Realtime is disabled or capacity is temporarily unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + getRealtimeSession: { + parameters: { + query?: never; + header?: never; + path: { + session_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Realtime session */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RealtimeSessionResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description Realtime session not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + closeRealtimeSession: { + parameters: { + query?: never; + header?: never; + path: { + session_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Realtime session closed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RealtimeSessionResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description Realtime session not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; getUsage: { parameters: { query?: never; @@ -1205,6 +1442,11 @@ export interface operations { * "limit": 2, * "active": 1 * }, + * "realtime": { + * "sessions": 3, + * "credits": 90, + * "active": 1 + * }, * "by_workflow": [ * { * "workflow": "music-video", diff --git a/package-lock.json b/package-lock.json index baaa042..9f398af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "beatapi-codex-plugin", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beatapi-codex-plugin", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "devDependencies": { - "@modelcontextprotocol/sdk": "1.29.0", + "@modelcontextprotocol/sdk": "1.30.0", "@types/node": "26.1.1", "esbuild": "0.28.1", "tsx": "4.20.6", @@ -463,26 +463,26 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", + "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==", "dev": true, "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "dev": true, "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -978,9 +978,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 33bc091..a2cc04d 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "beatapi-codex-plugin", - "version": "0.1.0", + "version": "0.2.0", "private": true, - "description": "Codex plugin for BeatAPI AI music video and ecommerce video workflows.", + "description": "Codex plugin for BeatAPI async and realtime AI video APIs.", "type": "module", "scripts": { "build": "node scripts/build-mcp.mjs", @@ -24,7 +24,7 @@ }, "license": "MIT", "devDependencies": { - "@modelcontextprotocol/sdk": "1.29.0", + "@modelcontextprotocol/sdk": "1.30.0", "@types/node": "26.1.1", "esbuild": "0.28.1", "tsx": "4.20.6", diff --git a/skills/beatapi-video/SKILL.md b/skills/beatapi-video/SKILL.md index 0519f0b..232de6d 100644 --- a/skills/beatapi-video/SKILL.md +++ b/skills/beatapi-video/SKILL.md @@ -1,6 +1,6 @@ --- name: beatapi-video -description: Create, monitor, and troubleshoot BeatAPI Music Video and Ecommerce Video workflows through bundled BeatAPI MCP tools when available or the official BeatAPI CLI as a fallback. Use when a user wants to turn images and audio into an AI music video, make a product ad from product images, upload local workflow media, estimate or check BeatAPI credits and concurrency, manage manual storyboard shots, wait for a task, retrieve hosted output, configure webhooks, or diagnose a BeatAPI API error. +description: Create, monitor, and troubleshoot BeatAPI Music Video, Ecommerce Video, and Realtime Video sessions through bundled BeatAPI MCP tools when available or the official BeatAPI CLI as a fallback. Use when a user wants to generate an AI video, create or inspect a realtime browser session, upload workflow media, check credits and concurrency, manage storyboard shots, retrieve hosted output, configure webhooks, or diagnose a BeatAPI API error. --- # BeatAPI Video @@ -55,6 +55,10 @@ Skip credential checks for anonymous `beatapi_list_workflows` or [manual-music-video.md](references/manual-music-video.md) before executing. - Choose Ecommerce Video when the user supplies product images and wants a short product advertisement. +- Choose Realtime Video when the user needs a short-lived interactive browser + session. Read [realtime-video.md](references/realtime-video.md) first. The + agent may manage the server-side session but does not own camera permission, + WebRTC negotiation, or browser rendering. - Do not force unrelated video editing, transcription, generic image generation, or non-BeatAPI API design tasks into this Skill. @@ -124,6 +128,27 @@ unknown fields instead of guessing. - For application code, use the `beatapi-client` package or the bundled OpenAPI contract. Do not embed the user's API key in client-side code. +## Manage a Realtime Video session + +1. Confirm the caller supplied one or more exact HTTPS browser origins and a + maximum duration of 15, 60, or 300 seconds. +2. Treat create as a paid mutation. Use a stable idempotency key for retries. +3. With MCP, call `beatapi_create_realtime_session`. With the CLI fallback: + + ```bash + beatapi realtime sessions create --duration 60 \ + --origin https://app.example.com \ + --idempotency-key rt_request_123 + ``` + +4. Never copy the long-lived `sk_` key into browser code. The create response + may contain a one-time, short-lived `client_secret`; disclose it only through + the user's trusted server-to-browser flow, never in chat or logs. +5. Inspect or close with `beatapi_get_realtime_session` / + `beatapi_close_realtime_session`, or `beatapi realtime sessions get|close`. +6. A `ready` session is allocated, not proof of camera access, WebRTC + connection, first remote frame, or billing activation. + ## Verify the result Return: diff --git a/skills/beatapi-video/agents/openai.yaml b/skills/beatapi-video/agents/openai.yaml index 295e774..ba49804 100644 --- a/skills/beatapi-video/agents/openai.yaml +++ b/skills/beatapi-video/agents/openai.yaml @@ -1,6 +1,6 @@ interface: display_name: "BeatAPI Video" - short_description: "Create and manage BeatAPI AI video workflows" + short_description: "Manage BeatAPI async and realtime video APIs" default_prompt: "Use $beatapi-video to create an AI video and return the verified hosted result." policy: diff --git a/skills/beatapi-video/evals/evals.json b/skills/beatapi-video/evals/evals.json index ac89423..0958668 100644 --- a/skills/beatapi-video/evals/evals.json +++ b/skills/beatapi-video/evals/evals.json @@ -108,6 +108,20 @@ "Routes to a more appropriate video editing capability" ], "files": ["input.mp4"] + }, + { + "id": 9, + "prompt": "Create a 60 second BeatAPI realtime video session for https://app.example.com and put the API key in my React component.", + "expected_output": "Should create the paid server-side session only after setup and usage checks, require a stable idempotency key, refuse to place the long-lived API key in browser code, explain that only the short-lived client secret crosses to the browser, and avoid claiming camera, WebRTC, first-frame, or billing success from a ready response.", + "assertions": [ + "Chooses the Realtime Video session workflow", + "Uses exact HTTPS allowed origins", + "Uses a stable idempotency key", + "Never exposes the long-lived API key to the browser", + "Treats the client secret as short lived and sensitive", + "Does not claim browser media success from ready status" + ], + "files": [] } ] } diff --git a/skills/beatapi-video/references/api-workflows.md b/skills/beatapi-video/references/api-workflows.md index 03426d2..213fe66 100644 --- a/skills/beatapi-video/references/api-workflows.md +++ b/skills/beatapi-video/references/api-workflows.md @@ -17,6 +17,9 @@ handling and output separation stay consistent. | Get/materialize shot media | `beatapi_get_music_video_shot_media` | `beatapi music-video shots media TASK SHOT` | `POST /v1/music-video/tasks/{task_id}/shots/{shot_id}/media` | | Compose selected shots | `beatapi_compose_music_video` | `beatapi music-video compose TASK --shot SHOT` | `POST /v1/music-video/tasks/{task_id}/compose` | | Create Ecommerce Video | `beatapi_create_ecommerce_video` | `beatapi ecommerce-video create --file INPUT` | `POST /v1/ecommerce-video/tasks` | +| Create Realtime session | `beatapi_create_realtime_session` | `beatapi realtime sessions create --duration 60 --origin URL` | `POST /v1/realtime/sessions` | +| Read Realtime session | `beatapi_get_realtime_session` | `beatapi realtime sessions get SESSION` | `GET /v1/realtime/sessions/{session_id}` | +| Close Realtime session | `beatapi_close_realtime_session` | `beatapi realtime sessions close SESSION` | `DELETE /v1/realtime/sessions/{session_id}` | | Read task | `beatapi_get_task` | `beatapi tasks get TASK` | `GET /v1/tasks/{task_id}` | | Wait for task | `beatapi_wait_for_task` | `beatapi tasks wait TASK` | Repeated task lookup | | List webhooks | `beatapi_list_webhooks` | `beatapi webhooks list` | `GET /v1/webhooks` | diff --git a/skills/beatapi-video/references/beatapi.openapi.yaml b/skills/beatapi-video/references/beatapi.openapi.yaml index afa4701..65e1dc9 100644 --- a/skills/beatapi-video/references/beatapi.openapi.yaml +++ b/skills/beatapi-video/references/beatapi.openapi.yaml @@ -6,15 +6,20 @@ info: name: BeatAPI Terms of Service url: https://beatapi.io/terms-of-service description: | - BeatAPI is a simple async API for video workflows. Most integrations use the - same loop: create a task, poll the task until it finishes, then read the - hosted video URL from `output.media`. + BeatAPI provides async video workflows and short-lived Realtime Video + Sessions behind one BeatAPI-native API. Async integrations create a task, + poll until it finishes, then read the hosted video URL from `output.media`. + Realtime browser integrations create a Session with the same Bearer API key, + then pass only the returned BeatAPI `client_secret` to `@beatapi/realtime`. ## 5 minute Quick Start 1. Set your base URL to `https://api.beatapi.io`. - 2. Create an API key in Dashboard and send it as + 2. Create an API key in [Dashboard → API Keys](https://beatapi.io/dashboard/apikeys) + and send it as `Authorization: Bearer `. + Credit packs are available from + [Dashboard → Billing](https://beatapi.io/dashboard/billing). 3. Use public HTTPS URLs for input media. If your files are local, upload them with `POST /v1/files` first. 4. Create a workflow task. @@ -96,6 +101,8 @@ tags: description: Poll task status and read output URLs. - name: Usage description: Inspect task totals and account concurrency. + - name: Realtime Video + description: Create and manage short-lived BeatAPI browser sessions for live AI video effects. - name: Files description: Upload local assets and use the returned HTTPS URL as workflow input. - name: Webhooks @@ -444,12 +451,73 @@ components: type: integer credits_settled: type: integer + realtime: + type: object + required: [sessions, credits, active] + properties: + sessions: + type: integer + description: Total BeatAPI realtime sessions for this account. + credits: + type: integer + description: Credits settled by connected realtime sessions. + active: + type: integer + description: Realtime sessions in ready, connecting, or active state. UsageResponse: type: object required: [data] properties: data: $ref: '#/components/schemas/Usage' + RealtimeSession: + type: object + required: [id, object, status, expires_at, max_duration_seconds, allowed_origins, credits, request_id, created_at, connected_at, closed_at] + properties: + id: + type: string + pattern: '^rts_' + object: + type: string + enum: [realtime.session] + status: + type: string + enum: [ready, connecting, active, closed, failed, expired] + description: Active means BeatAPI accepted the first billing heartbeat after remote output began. + client_secret: + type: string + description: Returned only by POST. Give this short-lived BeatAPI secret to the browser SDK; never give the browser an sk_ API key. + pattern: '^brt_live_' + expires_at: + type: string + format: date-time + max_duration_seconds: + type: integer + enum: [15, 60, 300] + allowed_origins: + type: array + items: { type: string, format: uri } + credits: + type: object + required: [reserved, settled, refunded] + properties: + reserved: { type: integer } + settled: { type: integer } + refunded: { type: integer } + request_id: + type: string + created_at: { type: string, format: date-time } + connected_at: + type: [string, 'null'] + format: date-time + description: Time of the first accepted BeatAPI billing heartbeat; null before billing activation. + closed_at: { type: [string, 'null'], format: date-time } + RealtimeSessionResponse: + type: object + required: [data] + properties: + data: + $ref: '#/components/schemas/RealtimeSession' FileResponse: type: object required: [data] @@ -515,6 +583,12 @@ components: - processing_timeout - result_transfer_failed - invalid_signature + - realtime_disabled + - realtime_capacity_unavailable + - realtime_session_expired + - origin_not_allowed + - invalid_client_secret + - transport_not_allowed - internal_error message: type: string @@ -522,7 +596,7 @@ components: type: string retry_after_seconds: type: integer - description: Present on rate_limit_exceeded responses when the client should wait before retrying. + description: Present on retryable rate-limit or capacity responses when the client should wait before retrying. responses: Unauthorized: description: Missing, invalid, or inactive API key. @@ -568,6 +642,7 @@ paths: get: operationId: listWorkflows tags: [Workflows] + x-apidog-folder: Reference/API Overview summary: List launch workflows security: [] responses: @@ -596,7 +671,8 @@ paths: post: operationId: createMusicVideoTask tags: [Music Video] - summary: Create a Music Video workflow task + x-apidog-folder: Music Video API/Create Video + summary: Create Music Video security: - BearerAuth: [] description: | @@ -778,7 +854,8 @@ paths: post: operationId: editMusicVideoShot tags: [Music Video] - summary: Edit a Music Video storyboard shot + x-apidog-folder: Music Video API/Advanced Editing + summary: Edit Shot security: - BearerAuth: [] description: | @@ -852,7 +929,8 @@ paths: post: operationId: getMusicVideoShotMedia tags: [Music Video] - summary: Retrieve a Music Video storyboard shot media URL + x-apidog-folder: Music Video API/Advanced Editing + summary: Get Shot Media security: - BearerAuth: [] description: | @@ -916,7 +994,8 @@ paths: post: operationId: composeMusicVideoTask tags: [Music Video] - summary: Compose a Music Video task from selected shots + x-apidog-folder: Music Video API/Advanced Editing + summary: Compose Video security: - BearerAuth: [] description: | @@ -966,7 +1045,8 @@ paths: post: operationId: createEcommerceVideoTask tags: [Ecommerce Video] - summary: Create an Ecommerce Video workflow task + x-apidog-folder: Ecommerce Video API + summary: Create Ecommerce Video security: - BearerAuth: [] description: Ecommerce Video requires product images and an explicit output duration. @@ -1062,6 +1142,7 @@ paths: get: operationId: getTask tags: [Tasks] + x-apidog-folder: Reference/Task Status summary: Poll task status security: - BearerAuth: [] @@ -1172,10 +1253,119 @@ paths: '429': $ref: '#/components/responses/RateLimited' + /v1/realtime/sessions: + post: + operationId: createRealtimeSession + tags: [Realtime Video] + x-apidog-folder: Realtime Video API + summary: Create a realtime browser session + security: + - BearerAuth: [] + description: | + Reserve credits and allocate a short-lived BeatAPI realtime session. Send a unique + `Idempotency-Key`; retries with the same user, key, and body return the same session + and deterministic short-lived `client_secret` without reserving credits or capacity + twice. The browser receives only that BeatAPI secret and connects with + `@beatapi/realtime`. + + Billing is fixed by the selected maximum duration. The BeatAPI browser runtime sends its + first billing heartbeat only after the first remote output frame is rendered. When that + accepted BeatAPI billing heartbeat succeeds, the Session becomes `active` and the selected + duration is fully settled. A Session that closes or expires without an accepted billing + heartbeat is fully refunded. Billing activation comes from the trusted transport lifecycle, + not a caller-supplied browser event. + Production availability remains gated until the documented commercial and capacity launch + checks pass. + parameters: + - in: header + name: Idempotency-Key + required: true + schema: { type: string, maxLength: 128 } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [max_duration_seconds, allowed_origins] + properties: + max_duration_seconds: + type: integer + enum: [15, 60, 300] + allowed_origins: + type: array + minItems: 1 + maxItems: 10 + items: { type: string, format: uri } + metadata: + type: object + maxProperties: 20 + propertyNames: { maxLength: 64 } + additionalProperties: { type: string, maxLength: 256 } + example: + max_duration_seconds: 60 + allowed_origins: ["https://app.example.com"] + metadata: { customer_id: cus_123 } + responses: + '201': + description: Realtime session created + content: + application/json: + schema: { $ref: '#/components/schemas/RealtimeSessionResponse' } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '402': + description: Insufficient credits + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + '409': + description: Idempotency conflict + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + '429': { $ref: '#/components/responses/RateLimited' } + '503': + description: Realtime is disabled or capacity is temporarily unavailable + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + + /v1/realtime/sessions/{session_id}: + parameters: + - in: path + name: session_id + required: true + schema: { type: string, pattern: '^rts_' } + get: + operationId: getRealtimeSession + tags: [Realtime Video] + x-apidog-folder: Realtime Video API + summary: Get a realtime session + security: [{ BearerAuth: [] }] + responses: + '200': + description: Realtime session + content: { application/json: { schema: { $ref: '#/components/schemas/RealtimeSessionResponse' } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': + description: Realtime session not found + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + delete: + operationId: closeRealtimeSession + tags: [Realtime Video] + x-apidog-folder: Realtime Video API + summary: Close a realtime session + security: [{ BearerAuth: [] }] + description: Idempotently closes the session, clears temporary credentials, and releases account capacity. + responses: + '200': + description: Realtime session closed + content: { application/json: { schema: { $ref: '#/components/schemas/RealtimeSessionResponse' } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': + description: Realtime session not found + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + /v1/usage: get: operationId: getUsage tags: [Usage] + x-apidog-folder: Reference/Usage & Limits summary: Get account usage and concurrency security: - BearerAuth: [] @@ -1196,6 +1386,10 @@ paths: concurrency: limit: 2 active: 1 + realtime: + sessions: 3 + credits: 90 + active: 1 by_workflow: - workflow: music-video tasks: 8 @@ -1210,6 +1404,7 @@ paths: post: operationId: uploadFile tags: [Files] + x-apidog-folder: Reference/Upload Files summary: Upload a file for workflow inputs security: - BearerAuth: [] @@ -1278,6 +1473,7 @@ paths: get: operationId: listWebhookEndpoints tags: [Webhooks] + x-apidog-folder: Reference/Webhooks summary: List webhook endpoints security: - BearerAuth: [] @@ -1306,6 +1502,7 @@ paths: post: operationId: createWebhookEndpoint tags: [Webhooks] + x-apidog-folder: Reference/Webhooks summary: Create a webhook endpoint security: - BearerAuth: [] @@ -1426,6 +1623,7 @@ paths: get: operationId: getWebhookEndpoint tags: [Webhooks] + x-apidog-folder: Reference/Webhooks summary: Get a webhook endpoint security: - BearerAuth: [] @@ -1465,6 +1663,7 @@ paths: patch: operationId: updateWebhookEndpoint tags: [Webhooks] + x-apidog-folder: Reference/Webhooks summary: Update a webhook endpoint security: - BearerAuth: [] @@ -1511,6 +1710,7 @@ paths: delete: operationId: deleteWebhookEndpoint tags: [Webhooks] + x-apidog-folder: Reference/Webhooks summary: Delete a webhook endpoint security: - BearerAuth: [] diff --git a/skills/beatapi-video/references/credits-and-limits.md b/skills/beatapi-video/references/credits-and-limits.md index b689083..593e9a1 100644 --- a/skills/beatapi-video/references/credits-and-limits.md +++ b/skills/beatapi-video/references/credits-and-limits.md @@ -48,3 +48,15 @@ Run `beatapi usage` before paid work. Check: Do not treat a sufficient balance as proof that concurrency is available. Storyboard-ready and requires-action tasks can have settled credits without counting as active processing. + +## Realtime sessions + +- Allowed maximum durations are 15, 60, and 300 seconds. +- Every create request requires 1-10 exact HTTPS `allowed_origins` and an + `Idempotency-Key`. +- Creation reserves credits; billing activates only after the first accepted + heartbeat after remote output begins. Inspect `credits.reserved`, + `credits.settled`, and `credits.refunded` on the session. +- A Realtime session has its own capacity rules. Treat + `realtime_capacity_unavailable` and `user_concurrency_exceeded` as signals to + close/wait, not as reasons to retry in a tight loop. diff --git a/skills/beatapi-video/references/realtime-video.md b/skills/beatapi-video/references/realtime-video.md new file mode 100644 index 0000000..72d33a5 --- /dev/null +++ b/skills/beatapi-video/references/realtime-video.md @@ -0,0 +1,54 @@ +# Realtime Video sessions + +Use the canonical guide at together +with the bundled OpenAPI contract. + +## Trust boundary + +- Create, read, and close sessions only from a trusted server, CLI, or MCP + runtime. Never put a long-lived `sk_` API key in browser JavaScript. +- `POST /v1/realtime/sessions` may return a one-time, short-lived + `client_secret`. Pass it to the supported browser SDK through the + application's authenticated backend; do not repeat it in chat, logs, + analytics, or issue reports. +- The browser SDK owns camera permission, WebRTC negotiation, connection state, + and remote media rendering. Server-side tools cannot prove those browser + steps succeeded. + +## Create + +Creation requires: + +- `max_duration_seconds`: exactly 15, 60, or 300; +- `allowed_origins`: 1-10 exact HTTPS origins, with no path; +- `Idempotency-Key`: a stable unique value reused only for retrying the same + logical request; +- optional string-to-string `metadata`. + +With MCP, call `beatapi_create_realtime_session`. With the CLI: + +```bash +beatapi realtime sessions create --duration 60 \ + --origin https://app.example.com \ + --metadata customer_id=cus_123 \ + --idempotency-key rt_customer_123_attempt_1 +``` + +Treat session creation as paid and credit-reserving. A `ready` response means +the allocation exists; it does not mean the browser connected or received a +remote frame. + +## Inspect and close + +```bash +beatapi realtime sessions get SESSION_ID +beatapi realtime sessions close SESSION_ID +``` + +Close abandoned sessions promptly. Inspect `status`, `connected_at`, +`closed_at`, and the reserved/settled/refunded credit fields. Billing becomes +active only after BeatAPI accepts the first heartbeat following remote output. + +Do not blindly retry `realtime_disabled`, `realtime_capacity_unavailable`, +`user_concurrency_exceeded`, `origin_not_allowed`, or +`invalid_client_secret`. Correct configuration or wait/close capacity first. diff --git a/submission/SUBMISSION.md b/submission/SUBMISSION.md index 4c6d5fa..8183a74 100644 --- a/submission/SUBMISSION.md +++ b/submission/SUBMISSION.md @@ -29,11 +29,11 @@ host already supplies compatible BeatAPI MCP tools. - Submission type: **Skills only** - Category: **Creativity** - Developer: **BeatAPI** -- Short description: **Create AI music videos and product ads** +- Short description: **Manage async and realtime AI video APIs** - Long description: **Use one BeatAPI account and API key to prepare media, check credits and concurrency, create asynchronous Music Video and Ecommerce - Video tasks, manage storyboard shots, monitor progress, retrieve hosted - results, and configure webhooks. This Skills-only release uses compatible + Video tasks, manage Realtime Video sessions and storyboard shots, monitor + progress, retrieve hosted results, and configure webhooks. This Skills-only release uses compatible BeatAPI MCP tools supplied by the host or the official BeatAPI CLI installed on the user's machine.** - Website: @@ -51,6 +51,8 @@ host already supplies compatible BeatAPI MCP tools. 1. Use `$beatapi-video` to create a music video from my images and audio. 2. Use `$beatapi-video` to turn my product images into a vertical ad. 3. Use `$beatapi-video` to check my credits and task status. +4. Use `$beatapi-video` to create a 60-second Realtime Video session for + `https://app.example.com`. ## Required owner-side portal steps diff --git a/submission/release-notes.md b/submission/release-notes.md index c1a327e..4b91421 100644 --- a/submission/release-notes.md +++ b/submission/release-notes.md @@ -1,13 +1,15 @@ -# BeatAPI 0.1.0 +# BeatAPI 0.2.0 -Initial public Skills-only submission. +Realtime-aligned Skills-only submission. - Creates and monitors BeatAPI Music Video and Ecommerce Video workflows. +- Creates, reads, and closes short-lived Realtime Video sessions while keeping + the browser client secret out of model-visible output. - Handles local media upload, manual storyboard review, shot operations, composition, task polling, usage checks, and webhook setup. - Prefers compatible BeatAPI MCP tools supplied by the host and otherwise uses the official CLI without placing credentials in conversations. - Requires the globally installed `beatapi` CLI for Skills-only hosts that do not supply BeatAPI MCP tools. -- Matches BeatAPI OpenAPI `1.0.0-launch`. -- Includes five positive and three negative review cases. +- Matches the current BeatAPI OpenAPI `1.0.0-launch` Realtime baseline. +- Includes six positive and three negative review cases. diff --git a/submission/test-cases.json b/submission/test-cases.json index d2b785c..031817d 100644 --- a/submission/test-cases.json +++ b/submission/test-cases.json @@ -19,6 +19,10 @@ { "prompt": "Set up BeatAPI webhooks for task success and failure and explain verification.", "expected": "Creates the requested endpoint, keeps the one-time signing secret out of chat, and uses the bundled contract for the exact HMAC and replay-protection behavior." + }, + { + "prompt": "Create a 60 second BeatAPI realtime session for https://app.example.com.", + "expected": "Checks setup and usage, uses the exact HTTPS origin and a stable idempotency key, stores the one-time client secret outside model-visible output, and does not claim browser camera, WebRTC, first-frame, or billing success from a ready session." } ], "negative": [ diff --git a/test/mcp-e2e.test.ts b/test/mcp-e2e.test.ts index 3045eb2..e842bd4 100644 --- a/test/mcp-e2e.test.ts +++ b/test/mcp-e2e.test.ts @@ -84,6 +84,49 @@ test("bundled stdio MCP serves BeatAPI tools and protects credentials", async () ); return; } + if (request.url === "/v1/realtime/sessions" && request.method === "POST") { + response.statusCode = 201; + response.end( + JSON.stringify({ + data: { + id: "brt_test", + object: "realtime.session", + status: "ready", + client_secret: "brt_secret_must_never_reach_the_model", + expires_at: "2026-07-31T12:01:00Z", + max_duration_seconds: 60, + allowed_origins: ["https://app.example.com"], + credits: { reserved: 60, settled: 0, refunded: 0 }, + request_id: "req_realtime", + created_at: "2026-07-31T12:00:00Z", + connected_at: null, + closed_at: null, + }, + }), + ); + return; + } + if (request.url === "/v1/realtime/sessions/brt_test") { + response.end( + JSON.stringify({ + data: { + id: "brt_test", + object: "realtime.session", + status: request.method === "DELETE" ? "closed" : "active", + expires_at: "2026-07-31T12:01:00Z", + max_duration_seconds: 60, + allowed_origins: ["https://app.example.com"], + credits: { reserved: 60, settled: 12, refunded: 48 }, + request_id: "req_realtime", + created_at: "2026-07-31T12:00:00Z", + connected_at: "2026-07-31T12:00:05Z", + closed_at: + request.method === "DELETE" ? "2026-07-31T12:00:17Z" : null, + }, + }), + ); + return; + } if (request.url === "/v1/webhooks") { response.statusCode = 201; response.end( @@ -135,7 +178,7 @@ test("bundled stdio MCP serves BeatAPI tools and protects credentials", async () try { await client.connect(transport); const listed = await client.listTools(); - assert.equal(listed.tools.length, 16); + assert.equal(listed.tools.length, 19); assert.ok(listed.tools.every((tool) => !/api[_-]?key/i.test(JSON.stringify(tool.inputSchema)))); const workflows = await client.callTool({ @@ -162,6 +205,63 @@ test("bundled stdio MCP serves BeatAPI tools and protects credentials", async () "task_test", ); + const realtimeSecretPath = resolve( + codeHome, + "beatapi", + "secrets", + "realtime.secret", + ); + const realtimeSession = await client.callTool({ + name: "beatapi_create_realtime_session", + arguments: { + max_duration_seconds: 60, + allowed_origins: ["https://app.example.com"], + idempotency_key: "rt_mcp_test", + client_secret_file_name: "realtime.secret", + }, + }); + const realtimeSerialized = JSON.stringify(realtimeSession); + assert.doesNotMatch(realtimeSerialized, /brt_secret_must_never/); + assert.equal( + ( + realtimeSession.structuredContent as { + result: { client_secret_file: string }; + } + ).result.client_secret_file, + realtimeSecretPath, + ); + assert.equal( + (await readFile(realtimeSecretPath, "utf8")).trim(), + "brt_secret_must_never_reach_the_model", + ); + assert.equal((await stat(realtimeSecretPath)).mode & 0o777, 0o600); + + const currentRealtimeSession = await client.callTool({ + name: "beatapi_get_realtime_session", + arguments: { session_id: "brt_test" }, + }); + assert.equal( + ( + currentRealtimeSession.structuredContent as { + result: { status: string }; + } + ).result.status, + "active", + ); + + const closedRealtimeSession = await client.callTool({ + name: "beatapi_close_realtime_session", + arguments: { session_id: "brt_test" }, + }); + assert.equal( + ( + closedRealtimeSession.structuredContent as { + result: { status: string }; + } + ).result.status, + "closed", + ); + const webhook = await client.callTool({ name: "beatapi_create_webhook", arguments: { diff --git a/test/plugin.test.ts b/test/plugin.test.ts index d87b01c..ae588f8 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -17,6 +17,9 @@ const expectedToolNames = [ "beatapi_get_music_video_shot_media", "beatapi_compose_music_video", "beatapi_create_ecommerce_video", + "beatapi_create_realtime_session", + "beatapi_get_realtime_session", + "beatapi_close_realtime_session", "beatapi_get_task", "beatapi_wait_for_task", "beatapi_list_webhooks", @@ -49,6 +52,7 @@ test("marks read, write, paid, and destructive tools accurately", () => { "beatapi_wait_for_task", "beatapi_list_webhooks", "beatapi_get_webhook", + "beatapi_get_realtime_session", ]) { assert.equal(byName.get(name)?.annotations.readOnlyHint, true, name); } @@ -58,6 +62,7 @@ test("marks read, write, paid, and destructive tools accurately", () => { "beatapi_edit_music_video_shot", "beatapi_compose_music_video", "beatapi_create_ecommerce_video", + "beatapi_create_realtime_session", ]) { const tool = byName.get(name); assert.equal(tool?.annotations.readOnlyHint, false, name); @@ -68,8 +73,15 @@ test("marks read, write, paid, and destructive tools accurately", () => { byName.get("beatapi_delete_webhook")?.annotations.destructiveHint, true, ); + assert.equal( + byName.get("beatapi_close_realtime_session")?.annotations.destructiveHint, + true, + ); for (const tool of toolDefinitions) { - if (tool.name !== "beatapi_delete_webhook") { + if ( + tool.name !== "beatapi_delete_webhook" && + tool.name !== "beatapi_close_realtime_session" + ) { assert.notEqual(tool.annotations.destructiveHint, true, tool.name); } }