From 58ef229fbed30577399e5b2fee3e98808f446f3d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:36:04 -0500 Subject: [PATCH 01/18] fix(web): one poll interval for Infra video status and transcripts Video status every 3 seconds and transcripts every 5 seconds, from shared constants in the OpenAPI document and the agent prompt, and the same numbers in the API docs and the /infra FAQ. The prompt now also gives the upload token limit. --- apps/web/__tests__/unit/infra-agent-prompt.test.ts | 8 +++++++- apps/web/components/pages/infra/content.ts | 2 +- apps/web/content/docs/api/get-started.mdx | 4 ++-- apps/web/content/docs/api/rest-api.mdx | 6 +++--- apps/web/content/docs/api/webhooks.mdx | 2 +- apps/web/data/infra-agent-prompt.ts | 13 ++++++++++--- apps/web/lib/developer-openapi.ts | 14 ++++++++------ apps/web/lib/developer-rate-limits.ts | 8 ++++++++ 8 files changed, 40 insertions(+), 17 deletions(-) diff --git a/apps/web/__tests__/unit/infra-agent-prompt.test.ts b/apps/web/__tests__/unit/infra-agent-prompt.test.ts index f542379374d..37e7230bb73 100644 --- a/apps/web/__tests__/unit/infra-agent-prompt.test.ts +++ b/apps/web/__tests__/unit/infra-agent-prompt.test.ts @@ -55,7 +55,13 @@ describe("Cap Infra agent prompt", () => { ]) { expect(named(CAP_INFRA_AGENT_PROMPT)).toContain(call); } - expect(CAP_INFRA_AGENT_PROMPT).toContain("every 2 seconds"); + expect(CAP_INFRA_AGENT_PROMPT).toContain( + "/videos/{id}/status every 3 seconds", + ); + expect(CAP_INFRA_AGENT_PROMPT).toContain( + "/transcripts/{id} every 5 seconds", + ); + expect(CAP_INFRA_AGENT_PROMPT).toContain("1,200 a minute"); expect(CAP_INFRA_AGENT_PROMPT).toContain("Retry-After"); expect(CAP_INFRA_AGENT_PROMPT).toContain("60 requests a minute"); }); diff --git a/apps/web/components/pages/infra/content.ts b/apps/web/components/pages/infra/content.ts index 39fe7aec3da..e6813b763c3 100644 --- a/apps/web/components/pages/infra/content.ts +++ b/apps/web/components/pages/infra/content.ts @@ -353,7 +353,7 @@ export const infraFaqs: readonly Faq[] = [ { question: "How do I know when a video is ready?", answer: - "Check the video's status from your server every few seconds until it is ready or errored. A video is ready as soon as its first output can be played, and the other output follows.", + "Check the video's status from your server every 3 seconds until it is ready or errored. A video is ready as soon as its first output can be played, and the other output follows.", }, { question: "How do API keys work?", diff --git a/apps/web/content/docs/api/get-started.mdx b/apps/web/content/docs/api/get-started.mdx index 313a8b7e2a7..7c59a643282 100644 --- a/apps/web/content/docs/api/get-started.mdx +++ b/apps/web/content/docs/api/get-started.mdx @@ -351,7 +351,7 @@ Add this to `index.html`, below the upload script: - **`ready`** means the video can be played. Processing has made at least one of `hlsUrl` and `mp4Url`, and the poster. - **`hlsUrl` can still be `null` for a moment after `ready`.** The MP4 is often finished first and the adaptive HLS renditions follow a few seconds later. The loop above keeps checking a few more times for `hlsUrl`, then plays the MP4. - **`errored`** is final. `errorReason` says why, for example that the file could not be read as a video. It is written for you and can mention your credit balance, so log it and show your users your own message. A failed video is not billed and cannot be processed again: create a new upload. -- **Pace the checks.** A secret key allows 60 requests per minute, and every status check your server makes for any user counts toward it. Leave at least 3 seconds between checks and slow down from there. On a `429`, wait for the `Retry-After` seconds and ask again. See [Rate Limits](/docs/api/rest-api#rate-limits). +- **Pace the checks.** A secret key allows 60 requests per minute, and every status check your server makes for any user counts toward it. Check every 3 seconds, which usually finds a short video ready on the first check, and slow down for longer ones as the loop above does. On a `429`, wait for the `Retry-After` seconds and ask again. See [Rate Limits](/docs/api/rest-api#rate-limits). ## 5. Play it @@ -807,7 +807,7 @@ The same secret key turns an audio or video file into text. It is a separate cal 1. Create the transcript with `POST /api/developer/v1/transcripts/uploads`, sending the file's exact size in bytes as `contentLength`. The response holds the transcript's `id` and a signed `upload.url`. 2. `PUT` the file to `upload.url`. -3. Start it with `POST /api/developer/v1/transcripts/:id/start`, then check `GET /api/developer/v1/transcripts/:id` until `status` is `complete` or `error`, leaving at least 3 seconds between checks. A complete transcript has its `text`. +3. Start it with `POST /api/developer/v1/transcripts/:id/start`, then check `GET /api/developer/v1/transcripts/:id` every 5 seconds until `status` is `complete` or `error`. A complete transcript has its `text`. A file that is already at a public URL needs no upload. Send it as `url` to `POST /api/developer/v1/transcripts` and check the transcript the same way. That includes a video you uploaded here: send its `mp4Url` as the `url` once the video is `ready`. diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index 974cb3997c8..85e1b1a1e57 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -564,7 +564,7 @@ The `status` field moves from `uploading` to `processing` when the upload comple A failed video is not billed. It stays in the list with its `errorReason`, and its uploaded file is removed 24 hours after processing started, after which `storedBytes` is `0`. It cannot be processed again, so create a new video and upload the file to that one. -Poll this endpoint every few seconds after completing an upload, and stop when `status` is `ready` or `errored`. Each call counts toward the key's 60 requests per minute. +Poll this endpoint every 3 seconds after completing an upload, and stop when `status` is `ready` or `errored`. Each call counts toward the key's 60 requests per minute. A video is `ready` as soon as its first output can be played, so playback can start while the rest is still encoding. The playback URLs are `null` until then. `hlsUrl` is an adaptive HLS master playlist, `mp4Url` is a progressive MP4 and `posterUrl` is a poster image. They need no API key and can be loaded from any origin. [Play a Video](/docs/api/rest-api#play-a-video) shows how to use them, and [Delete Video](/docs/api/rest-api#delete-video) says when they stop working. @@ -622,7 +622,7 @@ curl -X DELETE https://cap.so/api/developer/v1/videos/abc123def456 \ Send an audio or video file and get text back. Transcription costs $0.10 per audio hour, billed by the second from the app's credit balance. -A transcript moves through these statuses: `uploading` (waiting for its file), `queued`, `processing`, then `complete` or `error`. Poll [Get Transcript](/docs/api/rest-api#get-transcript) every few seconds until the status is `complete` or `error`. Each key allows 60 requests per minute. +A transcript moves through these statuses: `uploading` (waiting for its file), `queued`, `processing`, then `complete` or `error`. Poll [Get Transcript](/docs/api/rest-api#get-transcript) every 5 seconds until the status is `complete` or `error`. Each key allows 60 requests per minute. An app can have 20 transcripts `queued` or `processing` at once. Starting another answers `429` with the code `too_many_transcripts` and a `Retry-After` header of 15 seconds. Wait for one to finish, then send the request again. Transcripts in `uploading` do not count until they are started. @@ -1523,7 +1523,7 @@ The full video object returned by list and get endpoints: ## Best Practices - **Use secret keys server-side only.** The `csk_` key should never appear in client-side code. Create each upload on your server and send only its `cut_` upload token to the browser. -- **Poll video status after upload.** After completing a multipart upload, poll `GET /videos/:id/status` every few seconds until `status` is `ready` or `errored`, then play `hlsUrl` when it is present and `mp4Url` otherwise. Keep polling until `hlsUrl` is set if the player should use HLS. See [Play a Video](/docs/api/rest-api#play-a-video). +- **Poll video status after upload.** After completing a multipart upload, poll `GET /videos/:id/status` every 3 seconds until `status` is `ready` or `errored`, then play `hlsUrl` when it is present and `mp4Url` otherwise. Keep polling until `hlsUrl` is set if the player should use HLS. See [Play a Video](/docs/api/rest-api#play-a-video). - **Switch on `code`.** Every error carries a stable `code`. The `error` text can change. - **Watch the rate limit headers.** A secret key allows 60 requests per minute and an upload token 1,200. Slow down as `X-RateLimit-Remaining` nears zero, and wait for `Retry-After` seconds after a `429`. - **Store your keys securely.** Use environment variables or a secrets manager. diff --git a/apps/web/content/docs/api/webhooks.mdx b/apps/web/content/docs/api/webhooks.mdx index 584e604d872..51801fa12b7 100644 --- a/apps/web/content/docs/api/webhooks.mdx +++ b/apps/web/content/docs/api/webhooks.mdx @@ -10,7 +10,7 @@ The repository contains internal webhook endpoints for services such as Stripe a ## Poll supported API state -Use the [REST API](/docs/api/rest-api) to read the supported developer state. For an uploaded video, poll [Get Video Status](/docs/api/rest-api#get-video-status) until `status` is `ready` or `errored`. Present it as processed when it is `ready`. +Use the [REST API](/docs/api/rest-api) to read the supported developer state. For an uploaded video, poll [Get Video Status](/docs/api/rest-api#get-video-status) every 3 seconds until `status` is `ready` or `errored`. For a transcript, poll [Get Transcript](/docs/api/rest-api#get-transcript) every 5 seconds until `status` is `complete` or `error`. Present it as processed when it is `ready`. Keep polling bounded: diff --git a/apps/web/data/infra-agent-prompt.ts b/apps/web/data/infra-agent-prompt.ts index 90e370b8436..9a6b398a1aa 100644 --- a/apps/web/data/infra-agent-prompt.ts +++ b/apps/web/data/infra-agent-prompt.ts @@ -1,3 +1,10 @@ +import { + DEVELOPER_KEY_REQUESTS_PER_MINUTE, + DEVELOPER_TRANSCRIPT_POLL_SECONDS, + DEVELOPER_UPLOAD_TOKEN_REQUESTS_PER_MINUTE, + DEVELOPER_VIDEO_STATUS_POLL_SECONDS, +} from "@/lib/developer-rate-limits"; + export const CAP_INFRA_POINTER_PROMPT = "Read https://cap.so/infra and set up Cap Infra for this project."; @@ -27,10 +34,10 @@ Cap Infra is Cap's video and speech API. All you need is the app's secret key an 2. Keep the key on the server. Send it only from server code, as Authorization: Bearer followed by the key. It never goes into browser code, a client bundle or a public environment variable. 3. Create uploads on the server. For a signed-in user, call ${calls.createUpload} and give the browser only what it returns for that one video: the upload token (cut_), the video id and the embed URL. In production, set corsOrigin to the site's origin. 4. Upload from the browser. With the upload token as the Bearer credential, call ${calls.initiate}, then ${calls.presignPart} with up to 50 part numbers in one request. PUT each part to its signed URL and keep the ETag it answers with, then call ${calls.complete}. If a call answers 401 with the code upload_token_expired, get a new token from the server with ${calls.renewToken} and continue with the same uploadId and ETags. -5. Wait until it plays. From the server, poll ${calls.videoStatus} no faster than once every 2 seconds. Stop when status is ready or errored, and show errorReason if it failed. +5. Wait until it plays. From the server, poll ${calls.videoStatus} every ${DEVELOPER_VIDEO_STATUS_POLL_SECONDS} seconds. Stop when status is ready or errored, and show errorReason if it failed. 6. Play it. Show the video with its embedUrl, or build a player from hlsUrl and mp4Url in ${calls.video}. Use hlsUrl when it is present and mp4Url otherwise. shareUrl is the link to send to someone. -7. Transcribe when I ask. Transcripts are billed per audio hour, so confirm with me first. For a public URL call ${calls.transcribeUrl}. For a file call ${calls.transcribeFile}, PUT the file to the signed URL it returns, then call ${calls.startTranscript}. Poll ${calls.transcript} at the same pace until status is complete or error. -8. Stay inside the limit. A secret key allows 60 requests a minute across every endpoint. After a 429, wait for the number of seconds in the Retry-After header before you send the request again. +7. Transcribe when I ask. Transcripts are billed per audio hour, so confirm with me first. For a public URL call ${calls.transcribeUrl}. For a file call ${calls.transcribeFile}, PUT the file to the signed URL it returns, then call ${calls.startTranscript}. Poll ${calls.transcript} every ${DEVELOPER_TRANSCRIPT_POLL_SECONDS} seconds until status is complete or error. +8. Stay inside the limit. A secret key allows ${DEVELOPER_KEY_REQUESTS_PER_MINUTE} requests a minute across every endpoint it calls, and each upload token ${DEVELOPER_UPLOAD_TOKEN_REQUESTS_PER_MINUTE.toLocaleString("en-US")} a minute for its upload calls. After a 429, wait for the number of seconds in the Retry-After header before you send the request again. 9. Watch the credits. ${calls.usage} shows the balance and whether uploads are allowed. A 402 means the app needs credit, so tell me and I will add it in the console. Finish by uploading one short test video through the code you wrote. Ask me first, because stored video is billed daily. Then tell me what you changed and give me the video's shareUrl, and ask what I want to build next.`; diff --git a/apps/web/lib/developer-openapi.ts b/apps/web/lib/developer-openapi.ts index 084546754ae..56f5c9e3dd7 100644 --- a/apps/web/lib/developer-openapi.ts +++ b/apps/web/lib/developer-openapi.ts @@ -7,7 +7,9 @@ import { import { DEVELOPER_KEY_REQUESTS_PER_MINUTE, DEVELOPER_RATE_LIMIT_WINDOW_SECONDS, + DEVELOPER_TRANSCRIPT_POLL_SECONDS, DEVELOPER_UPLOAD_TOKEN_REQUESTS_PER_MINUTE, + DEVELOPER_VIDEO_STATUS_POLL_SECONDS, } from "@/lib/developer-rate-limits"; import { TRANSCRIPT_FAILURE_CODES, @@ -441,7 +443,9 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ "Use the REST operations (secret `csk_` key, server-side only) to create uploads, list, inspect and delete videos, transcribe audio and video, and read credit usage.", "To upload a video, call createUpload from your server. It creates the video and returns an upload token (`cut_`) for that one video. Send the token from the browser as the Bearer credential of the SDK operations: initiateSdkUpload, presignSdkUploadPart for up to 50 parts at a time, then completeSdkUpload. If the token expires before the upload completes, renewUploadToken returns a new one for the same video and the upload continues with the same uploadId.", "A public `cpk_` key cannot create or upload videos. Only an upload token made with the secret key can.", - "To transcribe a file at a public URL, call createTranscript. To transcribe a file you hold, call createTranscriptUpload, PUT the file to the URL it returns, then call startTranscript. Poll getTranscript until its status is `complete` or `error`.", + `To transcribe a file at a public URL, call createTranscript. To transcribe a file you hold, call createTranscriptUpload, PUT the file to the URL it returns, then call startTranscript. Poll getTranscript every ${DEVELOPER_TRANSCRIPT_POLL_SECONDS} seconds until its status is \`complete\` or \`error\`.`, + "", + `After completeSdkUpload, poll getDeveloperVideoStatus every ${DEVELOPER_VIDEO_STATUS_POLL_SECONDS} seconds until its status is \`ready\` or \`errored\`. A short video is usually ready on the first check.`, "", "Create a developer app and its keys at https://cap.so/dashboard/infra. A step by step guide with browser, Node and Python examples is at https://cap.so/docs/api/get-started.", "", @@ -561,8 +565,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ get: { operationId: "getDeveloperVideoStatus", summary: "Get video status", - description: - "Returns the processing status and, once ready, the playback URLs, with dimensions, duration and transcription state. Poll this after completeSdkUpload. A video can be ready while hlsUrl is still null, so a player that wants HLS keeps polling until hlsUrl is set. Send userId to return the status only when the video was created with that userId.", + description: `Returns the processing status and, once ready, the playback URLs, with dimensions, duration and transcription state. Poll this every ${DEVELOPER_VIDEO_STATUS_POLL_SECONDS} seconds after completeSdkUpload. A video can be ready while hlsUrl is still null, so a player that wants HLS keeps polling until hlsUrl is set. Send userId to return the status only when the video was created with that userId.`, tags: ["Videos"], security: secretAuth, parameters: [videoIdParam, ...videoUserScopeParams], @@ -626,7 +629,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ operationId: "createTranscript", summary: "Transcribe a URL", description: - "Creates a transcript from a public audio or video URL, or from a video the app already stores, and starts it. Send exactly one of url or videoId. The URL must use http or https on the default port and resolve to a public address, and the file must finish downloading within 15 minutes. With videoId the video must belong to this app, not be deleted and be ready: Cap reads its stored MP4 directly, so nothing is downloaded and the URL size limit does not apply. An unknown or deleted video returns 404 video_not_found, and one that is not ready returns 409 video_not_ready. A video transcript costs the transcript price only, $0.10 per audio hour. Requires an available credit balance of at least 1,000 micro-credits. Starting holds up to the cost of four hours ($0.40) until the audio is measured, then the hold drops to the real cost. A stored video is held for its known length. A transcript whose hold the available balance cannot cover ends with insufficient_credits before the file is downloaded. Poll getTranscript every few seconds until status is complete or error. An app can have 20 transcripts queued or processing at once: another is refused with 429 too_many_transcripts and a Retry-After header. A field the request does not know returns 400 with code invalid_request.", + `Creates a transcript from a public audio or video URL, or from a video the app already stores, and starts it. Send exactly one of url or videoId. The URL must use http or https on the default port and resolve to a public address, and the file must finish downloading within 15 minutes. With videoId the video must belong to this app, not be deleted and be ready: Cap reads its stored MP4 directly, so nothing is downloaded and the URL size limit does not apply. An unknown or deleted video returns 404 video_not_found, and one that is not ready returns 409 video_not_ready. A video transcript costs the transcript price only, $0.10 per audio hour. Requires an available credit balance of at least 1,000 micro-credits. Starting holds up to the cost of four hours ($0.40) until the audio is measured, then the hold drops to the real cost. A stored video is held for its known length. A transcript whose hold the available balance cannot cover ends with insufficient_credits before the file is downloaded. Poll getTranscript every ${DEVELOPER_TRANSCRIPT_POLL_SECONDS} seconds until status is complete or error. An app can have 20 transcripts queued or processing at once: another is refused with 429 too_many_transcripts and a Retry-After header. A field the request does not know returns 400 with code invalid_request.`, tags: ["Transcripts"], security: secretAuth, parameters: [idempotencyKeyParam], @@ -700,8 +703,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ get: { operationId: "getTranscript", summary: "Get a transcript", - description: - "Returns the transcript with its status and, once complete, its text. Audio with no speech is billed for its length and completes with an empty text and a wordCount of 0. A transcript that ends in error is never charged. Add include=words or include=segments for timings in seconds.", + description: `Returns the transcript with its status and, once complete, its text. Poll it every ${DEVELOPER_TRANSCRIPT_POLL_SECONDS} seconds until status is complete or error. Audio with no speech is billed for its length and completes with an empty text and a wordCount of 0. A transcript that ends in error is never charged. Add include=words or include=segments for timings in seconds.`, tags: ["Transcripts"], security: secretAuth, parameters: [ diff --git a/apps/web/lib/developer-rate-limits.ts b/apps/web/lib/developer-rate-limits.ts index 8579ba01009..1a6885d58ee 100644 --- a/apps/web/lib/developer-rate-limits.ts +++ b/apps/web/lib/developer-rate-limits.ts @@ -5,3 +5,11 @@ export const DEVELOPER_KEY_REQUESTS_PER_MINUTE = 60; // An upload signs one part per request and the smallest part is 5 MB, so this // is the ceiling on upload speed: 1,200 parts a minute is 6 GB a minute. export const DEVELOPER_UPLOAD_TOKEN_REQUESTS_PER_MINUTE = 1200; + +// The pace every doc, the OpenAPI document and the agent prompt recommend. A +// short video is playable about 3 seconds after its upload completes and a +// short transcript takes about 5 seconds, so one check usually finds it done, +// and one video and one transcript polled together use 32 of a key's 60 +// requests a minute. +export const DEVELOPER_VIDEO_STATUS_POLL_SECONDS = 3; +export const DEVELOPER_TRANSCRIPT_POLL_SECONDS = 5; From dd9a2e89deb9634b348c593bc75f51c8f331020d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:36:04 -0500 Subject: [PATCH 02/18] fix(web): point agents at Cap Infra in the llms files Add a Cap Infra section with the docs, OpenAPI, auth, rate limits and poll intervals, split the agent pointer by product, correct the upload token limit, and replace the stale media stack lines with what runs today. --- apps/web/public/llms-full.txt | 40 +++++++++++++++++++++++++++++------ apps/web/public/llms.txt | 36 ++++++++++++++++++++++++------- 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/apps/web/public/llms-full.txt b/apps/web/public/llms-full.txt index f1a479742c3..d82e55b9853 100644 --- a/apps/web/public/llms-full.txt +++ b/apps/web/public/llms-full.txt @@ -4,6 +4,30 @@ This is the full-detail reference for LLMs and agents. For a concise overview, see https://cap.so/llms.txt. +## Cap Infra (video and speech API) + +Cap Infra is Cap's usage-billed API for building video and speech into your own product. Upload a video and get adaptive HLS, an MP4, a poster, a share link and a hosted player back, or turn an audio or video file into a transcript with word and segment timings. It is a separate product from the Cap screen recorder: it needs no Cap Desktop, no `cap` CLI and no user sign-in, only an app's secret key. AI agents building with it should start here, not at https://cap.so/agents (that page sets up the screen recorder). + +- Overview and a copyable agent setup prompt: https://cap.so/infra +- Get started, from a new app to a playing video, with browser, Node and Python examples: https://cap.so/docs/api/get-started +- REST reference: https://cap.so/docs/api/rest-api +- OpenAPI 3.1 description (take every path and field name from it): https://cap.so/openapi.json +- Billing rules: https://cap.so/docs/api/billing +- Console, where an app and its keys are created: https://cap.so/dashboard/infra + +Auth: +- Send the app's secret key (`csk_`) as `Authorization: Bearer `, from server code only. It never goes into browser code, a client bundle or a public environment variable. +- To upload from a browser, the server calls `POST /api/developer/v1/uploads`, which creates the video and returns a short-lived upload token (`cut_`) for that one video. The browser sends the token as its Bearer credential to the multipart upload endpoints under `/api/developer/sdk/v1/upload/multipart/` (initiate, presign-part, complete). `POST /api/developer/v1/uploads/{id}/token` renews an expired token for the same video. +- A public `cpk_` key cannot create or upload videos. + +Limits and polling: +- A secret key allows 60 requests a minute across the REST endpoints, and each upload token 1,200 a minute across the upload endpoints. Responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. On a `429`, wait for the `Retry-After` seconds, then retry. +- There is no public webhook API. After an upload completes, poll `GET /api/developer/v1/videos/{id}/status` every 3 seconds until `status` is `ready` or `errored`; a short video is usually ready on the first check. A ready video can still have `hlsUrl` null for a moment while `mp4Url` plays. +- Transcripts: `POST /api/developer/v1/transcripts` with a public `url` or the `videoId` of a ready video the app stores, or `POST /api/developer/v1/transcripts/uploads`, PUT the file, then `POST /api/developer/v1/transcripts/{id}/start`. Poll `GET /api/developer/v1/transcripts/{id}` every 5 seconds until `status` is `complete` or `error`. +- Errors are JSON with `error`, `code`, `message`, `hint` and `docs` fields. A 4xx other than `429` and a `409` with code `completion_in_progress` will not succeed if repeated. + +Every page under https://cap.so/docs is available as Markdown: add `.md` to its URL (for example https://cap.so/docs/api/rest-api.md) or request it with `Accept: text/markdown`. + ## What is Cap? Cap (made by Cap Software, Inc.) is the open-source screen recording tool that combines Loom-style instant sharing with a professional local editor and a screenshot tool — all in one app. It is built for async communication, product demos, bug reports, tutorials, onboarding, code reviews, and customer support. @@ -109,7 +133,7 @@ Core principles: Cap ships a `cap` CLI designed to be driven by AI coding agents (Claude Code, Codex, Cursor, OpenCode) and scripts. -Start at https://cap.so/agents for the copyable setup prompt, per-agent install steps, and the safety rules; the full reference lives under https://cap.so/docs/agents. +For the screen recorder, start at https://cap.so/agents for the copyable setup prompt, per-agent install steps, and the safety rules; the full reference lives under https://cap.so/docs/agents. To build video or speech into an app with an API key, use Cap Infra instead (see the Cap Infra section above and https://cap.so/infra); it needs no CLI. Install: - `curl -fsSL https://cap.so/install-cli.sh | sh` (PowerShell: `irm https://cap.so/install-cli.ps1 | iex`) @@ -150,7 +174,7 @@ Environment variables: `CAP_API_KEY` (headless/CI upload auth), `CAP_SERVER_URL` Canonical workflow: `cap doctor --json` → `cap targets --json` → `cap record start --screen --json` (or `--detach`) → `cap record stop --id --json` → `cap project validate --json` → `cap export --json` → `cap upload --json`. -A developer REST API is also available for hosting video in your own product: start with the guide at https://cap.so/docs/api/get-started, then see the reference at https://cap.so/docs/api/rest-api and the OpenAPI description at https://cap.so/openapi.json. It is paid for from prepaid credits, with optional auto top-up. Uploading and encoding are free. Stored video is charged daily by length and resolution tier: $0.85 (1080p), $1.45 (1440p) or $2.80 (2160p) per 1,000 minutes per 30 day month. Transcription costs $0.10 per audio hour. `GET /api/developer/v1/usage` returns these rates in `rates`, and `GET /api/developer/v1/transactions` returns every credit and charge. The billing rules (daily storage charge, transcript rounding and holds, top-ups from $5 to $1,000, codes, the Cap Pro monthly credit, auto top-up and what happens when credit runs out) are at https://cap.so/docs/api/billing. There is no public webhook subscription API: poll the status endpoint instead (https://cap.so/docs/api/webhooks). +Cap Infra, the video and speech API for your own product, is separate from the CLI: start with the guide at https://cap.so/docs/api/get-started, then see the reference at https://cap.so/docs/api/rest-api and the OpenAPI description at https://cap.so/openapi.json. It is paid for from prepaid credits, with optional auto top-up. Uploading and encoding are free. Stored video is charged daily by length and resolution tier: $0.85 (1080p), $1.45 (1440p) or $2.80 (2160p) per 1,000 minutes per 30 day month. Transcription costs $0.10 per audio hour. `GET /api/developer/v1/usage` returns these rates in `rates`, and `GET /api/developer/v1/transactions` returns every credit and charge. The billing rules (daily storage charge, transcript rounding and holds, top-ups from $5 to $1,000, codes, the Cap Pro monthly credit, auto top-up and what happens when credit runs out) are at https://cap.so/docs/api/billing. There is no public webhook subscription API: poll the video status endpoint every 3 seconds and a transcript every 5 seconds instead (https://cap.so/docs/api/webhooks). ## Platforms @@ -199,8 +223,8 @@ Early-adopter pricing is locked in for the lifetime of the subscription. Trusted - Desktop: Rust with GPUI (native UI, no web view); WGPU rendering; FFmpeg; ScreenCaptureKit (macOS), Direct3D / Media Foundation (Windows) and PipeWire (Linux); whisper-rs and Parakeet for local captions. - Web: Next.js 16, React 19, TypeScript; Effect (`@effect/platform` HttpApi, no tRPC); Drizzle ORM on MySQL (PlanetScale); NextAuth + WorkOS auth; TanStack Query. -- Media: native hardware encoders, FFmpeg, WGPU compositor, WebCodecs; HLS playback via Mux. -- Infrastructure: AWS S3 + CloudFront + MediaConvert, Mux, PlanetScale, Vercel, Stripe, OpenPanel (self-hosted), Resend; AssemblyAI + Groq/OpenAI for AI. +- Media: native hardware encoders, FFmpeg, WGPU compositor, WebCodecs. Transcoding and HLS packaging run on Cap's own media server and GPU fleet (NVENC). +- Infrastructure: AWS S3 + CloudFront, Cap's own media server and GPU render farm, PlanetScale, Vercel, Stripe, OpenPanel (self-hosted), Resend; AssemblyAI + Groq/OpenAI for AI. - Runtime: Node 24, Bun 1.4.4, Rust 1.88+, Turborepo monorepo. ## Free Web Tools @@ -238,8 +262,10 @@ Cap offers free browser tools at https://cap.so/tools: WebM→MP4, MOV→MP4, AV - Analytics: https://cap.so/docs/sharing/analytics - Comments: https://cap.so/docs/sharing/comments - Embeds: https://cap.so/docs/sharing/embeds -- Developer API guide: https://cap.so/docs/api/get-started -- REST API: https://cap.so/docs/api/rest-api +- Cap Infra: https://cap.so/infra +- Cap Infra get started: https://cap.so/docs/api/get-started +- Cap Infra REST API: https://cap.so/docs/api/rest-api +- Cap Infra OpenAPI description: https://cap.so/openapi.json - Webhooks: https://cap.so/docs/api/webhooks - S3 config (AWS): https://cap.so/docs/s3-config/aws-s3 - S3 config (Cloudflare R2): https://cap.so/docs/s3-config/cloudflare-r2 @@ -269,4 +295,4 @@ Cap offers free browser tools at https://cap.so/tools: WebM→MP4, MOV→MP4, AV - **Does Cap work on Mac and Windows?** Yes — native apps for macOS (Apple Silicon & Intel) and Windows, plus a Linux .deb package and a browser viewer. - **Can I use my own storage?** Yes — connect your own Google Drive or any S3-compatible bucket, use Cap Cloud, or keep recordings entirely local. - **Does Cap record system audio and 4K?** Yes — system audio plus screen, camera, and mic, up to 4K at 60fps. -- **Is there an API/CLI?** Yes: a `cap` CLI built for AI agents (`--json`, `cap guide --json`) and a developer REST API. There is no public webhook subscription API. +- **Is there an API/CLI?** Yes: a `cap` CLI built for AI agents (`--json`, `cap guide --json`) and Cap Infra, a video and speech REST API (https://cap.so/infra). There is no public webhook subscription API. diff --git a/apps/web/public/llms.txt b/apps/web/public/llms.txt index a7a34ef1c29..497a29ef36a 100644 --- a/apps/web/public/llms.txt +++ b/apps/web/public/llms.txt @@ -8,6 +8,22 @@ Most of the repository is licensed under AGPLv3. The LICENSE file identifies a l For the expanded machine-readable reference, see https://cap.so/llms-full.txt. +## Cap Infra (video and speech API) + +Cap Infra is Cap's usage-billed API for building video and speech into your own product. Upload a video and get adaptive HLS, an MP4, a poster, a share link and a hosted player back, or turn an audio or video file into a transcript with word timings. It is separate from the Cap screen recorder: it needs no Cap Desktop, no `cap` CLI and no user sign-in, only an app's secret key. + +- Overview and a copyable agent setup prompt: https://cap.so/infra +- Get started, from a new app to a playing video: https://cap.so/docs/api/get-started +- REST reference: https://cap.so/docs/api/rest-api +- OpenAPI 3.1 description (take every path and field name from it): https://cap.so/openapi.json +- Billing: https://cap.so/docs/api/billing +- Console, where an app and its keys are created: https://cap.so/dashboard/infra +- **Auth.** Send the app's secret key (`csk_`) as `Authorization: Bearer `, from server code only. It never goes into browser code. For an upload from the browser, the server calls `POST /api/developer/v1/uploads` and gives the browser the short-lived upload token (`cut_`) it returns, which works for that one video. A public `cpk_` key cannot create or upload videos. +- **Rate limits.** A secret key allows 60 requests a minute across the REST endpoints, and each upload token 1,200 a minute across the upload endpoints. On a `429`, wait for the `Retry-After` seconds, then retry. +- **Polling.** There is no public webhook API. After an upload completes, poll `GET /api/developer/v1/videos/{id}/status` every 3 seconds until `status` is `ready` or `errored`; a short video is usually ready on the first check. Poll `GET /api/developer/v1/transcripts/{id}` every 5 seconds until `status` is `complete` or `error`. +- **Errors** are JSON with `error`, `code`, `message`, `hint` and `docs` fields. +- **Docs as Markdown.** Every page under https://cap.so/docs is available as Markdown: add `.md` to its URL (for example https://cap.so/docs/api/get-started.md) or request it with `Accept: text/markdown`. + ## When to Use Cap Reach for Cap when a task involves one of these jobs: @@ -16,7 +32,7 @@ Reach for Cap when a task involves one of these jobs: - **Read what a recording says.** Fetch the title, status, summary, and timestamped transcript of a recording the user owns, so you can answer questions, write notes, or file a bug from a walkthrough. Use `cap caps context --json`, `cap caps transcript`, or the `caps_context` MCP tool. - **Manage a video library.** List, search, move, retitle, share, password-protect, or delete Caps, and manage folders, spaces, members, and viewer settings with `cap caps ...` and `cap organizations ...`. - **Move videos off Loom.** Import single Loom links or a CSV of Loom links into Cap with `cap caps import loom`, then wait for the job with `cap jobs wait`. -- **Host video for your own product.** Use the Cap Developer API to create videos, upload them, and get HLS, MP4, poster, share and embed URLs. The guide at https://cap.so/docs/api/get-started goes from a new app to a playing video, and the reference at https://cap.so/docs/api/rest-api covers upload, playback, transcripts and billing. +- **Host video or transcribe speech in your own product.** Use Cap Infra, described in its own section above. Cap is not a live video calling, livestreaming, or general file hosting tool. @@ -24,8 +40,8 @@ How to call Cap: - **On the user's machine:** the `cap` CLI that ships with Cap Desktop. Run `cap guide --json` first for the installed command and output contract. `cap mcp serve` exposes the same actions to MCP clients over stdio. - **Remote MCP (read-only):** Streamable HTTP at https://cap.so/api/mcp with OAuth. Server card: https://cap.so/.well-known/mcp -- **Developer API (HTTP):** OpenAPI 3.1 description at https://cap.so/openapi.json. Create an app and keys at https://cap.so/dashboard/infra. Create each upload from your server with the secret key (`POST /api/developer/v1/uploads`) and upload the file from the browser with the upload token it returns. A public key cannot create or upload videos. Errors are JSON with `error`, `code`, `hint`, and `docs` fields. Each key and each upload token allows 60 requests per minute. `GET /api/developer/v1/usage` returns the current prices in `rates`, `GET /api/developer/v1/transactions` returns every credit and charge, and https://cap.so/docs/api/billing explains what is charged and when. -- **Markdown:** requesting https://cap.so with `Accept: text/markdown` returns this file, and missing pages return a Markdown 404. +- **Cap Infra API (HTTP):** see the Cap Infra section above. `GET /api/developer/v1/usage` returns the current prices in `rates`, `GET /api/developer/v1/transactions` returns every credit and charge, and https://cap.so/docs/api/billing explains what is charged and when. +- **Markdown:** requesting https://cap.so with `Accept: text/markdown` returns this file, every docs page is available as Markdown (add `.md` or send `Accept: text/markdown`), and missing pages return a Markdown 404. ## Recording Modes @@ -61,9 +77,12 @@ Cap has run organization migrations spanning hundreds of users and tens of thous ## Use Cap With an AI Agent -Cap can be operated through a shell-capable agent using the `cap` CLI or its MCP server. +There are two products, with a different starting point for each: + +- **Build video or speech into an app with an API key:** that is Cap Infra. Start at https://cap.so/infra, then https://cap.so/openapi.json. Do not install Cap Desktop or the `cap` CLI for this. +- **Record, share and manage a person's own Cap recordings:** that is the Cap screen recorder, driven through a shell-capable agent with the `cap` CLI or its MCP server. Start at https://cap.so/agents. The rest of this section is about this one. -- Cap for Agents (start here, includes the copyable setup prompt): https://cap.so/agents +- Cap for Agents (start here for the screen recorder, includes the copyable setup prompt): https://cap.so/agents - Overview and copyable prompt: https://cap.so/docs/agents - Setup: https://cap.so/docs/agents/setup - Workflows: https://cap.so/docs/agents/workflows @@ -82,8 +101,9 @@ For Loom imports, `--space` takes one Cap space name rather than a folder path. - Docs: https://cap.so/docs - Sharing: https://cap.so/docs/sharing/share-a-cap - Self-hosting: https://cap.so/docs/self-hosting -- Developer API guide: https://cap.so/docs/api/get-started -- REST API: https://cap.so/docs/api/rest-api -- Developer API OpenAPI description: https://cap.so/openapi.json +- Cap Infra: https://cap.so/infra +- Cap Infra get started: https://cap.so/docs/api/get-started +- Cap Infra REST API: https://cap.so/docs/api/rest-api +- Cap Infra OpenAPI description: https://cap.so/openapi.json - MCP server card: https://cap.so/.well-known/mcp - Source code: https://github.com/CapSoftware/Cap From 0b7e700e9b4c312eb264c9ae8188b5d5be407a28 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:36:04 -0500 Subject: [PATCH 03/18] feat(web): serve docs pages as Markdown to agents A docs URL ending in .md, or requested with Accept: text/markdown, is rewritten to a route that returns the page source as Markdown, with its components replaced by plain text and links made absolute. Docs pages also advertise the Markdown version with a link alternate. --- apps/web/__tests__/unit/docs-markdown.test.ts | 157 ++++++++++++++++++ .../__tests__/unit/proxy-infra-path.test.ts | 2 + apps/web/app/(docs)/docs/[[...slug]]/page.tsx | 5 +- .../app/api/docs/markdown/[...slug]/route.ts | 25 +++ apps/web/lib/agent-markdown.ts | 22 ++- apps/web/lib/docs-markdown.ts | 83 +++++++++ apps/web/next.config.mjs | 1 + apps/web/proxy.ts | 16 +- 8 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 apps/web/__tests__/unit/docs-markdown.test.ts create mode 100644 apps/web/app/api/docs/markdown/[...slug]/route.ts create mode 100644 apps/web/lib/docs-markdown.ts diff --git a/apps/web/__tests__/unit/docs-markdown.test.ts b/apps/web/__tests__/unit/docs-markdown.test.ts new file mode 100644 index 00000000000..c477397eb37 --- /dev/null +++ b/apps/web/__tests__/unit/docs-markdown.test.ts @@ -0,0 +1,157 @@ +import { NextRequest } from "next/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@cap/database", () => ({ + db: () => ({ + select: () => ({ from: () => ({ where: async () => [] }) }), + }), +})); +vi.mock("@cap/database/schema", () => ({ organizations: {} })); +vi.mock("@cap/env", () => ({ + buildEnv: { NEXT_PUBLIC_IS_CAP: "true" }, + serverEnv: () => ({ WEB_URL: "https://cap.so" }), +})); + +import { GET } from "@/app/api/docs/markdown/[...slug]/route"; +import { CAP_AGENT_PROMPT } from "@/data/agent-prompt"; +import { DOCS_MARKDOWN_ROUTE, docsMarkdownSlug } from "@/lib/agent-markdown"; +import { docToMarkdown } from "@/lib/docs-markdown"; +import { proxy } from "../../proxy"; +import { getAllDocs, getDocBySlug } from "@/utils/docs"; + +const getMarkdown = (slug: string, method = "GET") => + GET(new Request(`https://cap.so/docs/${slug}.md`, { method }), { + params: Promise.resolve({ slug: slug.split("/") }), + }); + +// Lines outside fenced code blocks, where MDX components would render. +const proseLines = (markdown: string) => { + const lines: string[] = []; + let fence: string | null = null; + for (const line of markdown.split("\n")) { + const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1]; + if (fence) { + if (marker && marker[0] === fence[0] && marker.length >= fence.length) + fence = null; + } else if (marker) fence = marker; + else lines.push(line); + } + return lines; +}; + +describe("docsMarkdownSlug", () => { + it.each([ + ["/docs/api/rest-api.md", "text/html", "api/rest-api"], + ["/docs/api/rest-api.mdx", null, "api/rest-api"], + ["/docs/api/rest-api", "text/markdown", "api/rest-api"], + ["/docs/api/rest-api/", "text/markdown", "api/rest-api"], + ["/docs", "text/markdown", "introduction"], + ["/docs.md", null, "introduction"], + ["/docs/api/rest-api", "text/html,*/*;q=0.8", null], + ["/docs/api/rest-api", null, null], + ["/pricing.md", null, null], + ["/docsearch", "text/markdown", null], + ])("%s with Accept %s gives %s", (path, accept, slug) => { + expect(docsMarkdownSlug(path, accept)).toBe(slug); + }); +}); + +describe("docToMarkdown", () => { + const docs = getAllDocs(); + + it.each(docs.map((doc) => [doc.slug, doc] as const))( + "turns %s into plain Markdown", + (_slug, doc) => { + const markdown = docToMarkdown(doc, "https://cap.so"); + expect(markdown.startsWith(`# ${doc.metadata.title}\n`)).toBe(true); + expect(markdown).not.toMatch(/^---\n/); + for (const line of proseLines(markdown)) { + expect(line).not.toMatch(/^\s*<\/?[A-Z]/); + expect(line).not.toMatch(/\]\(\/[^/]/); + } + }, + ); + + it("keeps every code block exactly as written", () => { + const doc = getDocBySlug("api/get-started"); + if (!doc) throw new Error("missing get-started doc"); + const markdown = docToMarkdown(doc, "https://cap.so"); + const blocks = doc.content.match(/```[\s\S]*?\n```/g) ?? []; + expect(blocks.length).toBeGreaterThan(5); + for (const block of blocks) expect(markdown).toContain(block); + }); + + it("shows the copyable prompt and warnings as text", () => { + const agents = getDocBySlug("agents"); + const selfHosting = getDocBySlug("self-hosting"); + if (!agents || !selfHosting) throw new Error("missing docs"); + expect(docToMarkdown(agents, "https://cap.so")).toContain(CAP_AGENT_PROMPT); + const hosting = docToMarkdown(selfHosting, "https://cap.so"); + expect(hosting).toContain( + "> **Security Notice for Production Deployments**", + ); + expect(hosting).toContain( + "[Watch the video](https://cap.so/embed/k310v2vf41d6ffp)", + ); + }); +}); + +describe("docs Markdown route", () => { + it("serves a docs page as Markdown", async () => { + const response = await getMarkdown("api/rest-api"); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "text/markdown; charset=utf-8", + ); + expect(response.headers.get("vary")).toBe("Accept"); + const body = await response.text(); + expect(body.startsWith("# REST API\n")).toBe(true); + expect(body).toContain("every 3 seconds"); + expect(body).not.toContain(" { + const response = await getMarkdown("api/nope"); + expect(response.status).toBe(404); + expect(response.headers.get("content-type")).toBe( + "text/markdown; charset=utf-8", + ); + expect(await response.text()).toContain("/docs/api/nope"); + }); + + it("refuses a path outside the docs folder", async () => { + const response = await getMarkdown("../../package"); + expect(response.status).toBe(404); + }); +}); + +describe("proxy routes docs Markdown requests", () => { + const send = (path: string, accept: string, method = "GET") => + proxy( + new NextRequest(`https://cap.so${path}`, { + method, + headers: { accept }, + }), + ); + const rewriteOf = (response: Response) => + response.headers.get("x-middleware-rewrite"); + + it.each([ + ["/docs/api/get-started", "text/markdown"], + ["/docs/api/get-started.md", "text/html,*/*;q=0.8"], + ])("rewrites %s (Accept %s)", async (path, accept) => { + const response = await send(path, accept); + expect(rewriteOf(response)).toBe( + `https://cap.so${DOCS_MARKDOWN_ROUTE}/api/get-started`, + ); + }); + + it("leaves the HTML page alone for a browser", async () => { + const response = await send( + "/docs/api/get-started", + "text/html,application/xhtml+xml,*/*;q=0.8", + ); + expect(rewriteOf(response)).toBeNull(); + expect(response.headers.get("x-middleware-next")).toBe("1"); + }); +}); diff --git a/apps/web/__tests__/unit/proxy-infra-path.test.ts b/apps/web/__tests__/unit/proxy-infra-path.test.ts index 7ba7994e2d3..82b3849fb0d 100644 --- a/apps/web/__tests__/unit/proxy-infra-path.test.ts +++ b/apps/web/__tests__/unit/proxy-infra-path.test.ts @@ -22,6 +22,8 @@ vi.mock("@cap/env", () => ({ vi.mock("@/lib/agent-markdown", () => ({ agentMarkdownResponse: async () => null, + docsMarkdownSlug: () => null, + DOCS_MARKDOWN_ROUTE: "/api/docs/markdown", })); const FORWARDED = "x-middleware-request-x-cap-infra-path"; diff --git a/apps/web/app/(docs)/docs/[[...slug]]/page.tsx b/apps/web/app/(docs)/docs/[[...slug]]/page.tsx index 28dc276a968..16c22f2769f 100644 --- a/apps/web/app/(docs)/docs/[[...slug]]/page.tsx +++ b/apps/web/app/(docs)/docs/[[...slug]]/page.tsx @@ -43,7 +43,10 @@ export async function generateMetadata( return { title: pageTitle, description, - alternates: { canonical: `https://cap.so${docPath}` }, + alternates: { + canonical: `https://cap.so${docPath}`, + types: { "text/markdown": `https://cap.so${docPath}.md` }, + }, openGraph: { title: pageTitle, description, diff --git a/apps/web/app/api/docs/markdown/[...slug]/route.ts b/apps/web/app/api/docs/markdown/[...slug]/route.ts new file mode 100644 index 00000000000..a58ca6964cd --- /dev/null +++ b/apps/web/app/api/docs/markdown/[...slug]/route.ts @@ -0,0 +1,25 @@ +import { markdownHeaders, markdownNotFoundBody } from "@/lib/agent-markdown"; +import { docToMarkdown } from "@/lib/docs-markdown"; +import { getDocBySlug } from "@/utils/docs"; + +// The proxy rewrites a docs page here when an agent asks for Markdown, with +// `Accept: text/markdown` or a `.md` URL. +export async function GET( + request: Request, + props: RouteContext<"/api/docs/markdown/[...slug]">, +) { + const { slug } = await props.params; + const { origin } = new URL(request.url); + const doc = getDocBySlug(slug.join("/")); + + if (!doc) { + return new Response( + markdownNotFoundBody(origin, `/docs/${slug.join("/")}`), + { status: 404, headers: markdownHeaders("private, no-store") }, + ); + } + + return new Response(docToMarkdown(doc, origin), { + headers: markdownHeaders("public, max-age=300"), + }); +} diff --git a/apps/web/lib/agent-markdown.ts b/apps/web/lib/agent-markdown.ts index 0c90947d4d1..c8422821200 100644 --- a/apps/web/lib/agent-markdown.ts +++ b/apps/web/lib/agent-markdown.ts @@ -28,7 +28,27 @@ export const prefersMarkdown = (accept: string | null) => { return markdown > 0 && markdown >= html; }; -const markdownHeaders = (cacheControl: string) => ({ +export const DOCS_MARKDOWN_ROUTE = "/api/docs/markdown"; + +const MARKDOWN_EXTENSION = /\.mdx?$/; + +// The docs path an agent asked for as Markdown, as the slug of its source +// file, or null when the request should get the HTML page. "/docs" is the +// Introduction page, as it is in HTML. +export const docsMarkdownSlug = ( + pathname: string, + accept: string | null, +): string | null => { + const explicit = MARKDOWN_EXTENSION.test(pathname); + if (!explicit && !prefersMarkdown(accept)) return null; + const path = pathname.replace(MARKDOWN_EXTENSION, "").replace(/\/+$/, ""); + if (path === "/docs") return "introduction"; + if (!path.startsWith("/docs/")) return null; + const slug = path.slice("/docs/".length); + return slug ? slug : null; +}; + +export const markdownHeaders = (cacheControl: string) => ({ "Content-Type": `${MARKDOWN_TYPE}; charset=utf-8`, Vary: "Accept", "Cache-Control": cacheControl, diff --git a/apps/web/lib/docs-markdown.ts b/apps/web/lib/docs-markdown.ts new file mode 100644 index 00000000000..3ea8275da6e --- /dev/null +++ b/apps/web/lib/docs-markdown.ts @@ -0,0 +1,83 @@ +import { CAP_AGENT_PROMPT } from "@/data/agent-prompt"; + +const fenceOf = (text: string) => { + const longest = Math.max( + 2, + ...[...text.matchAll(/`+/g)].map(([run]) => run.length), + ); + return "`".repeat(longest + 1); +}; + +const COPYABLE_PROMPT = /^$/; +const CAP_EMBED = /^$/; +const WARNING_OPEN = /^$/; +const WARNING_CLOSE = /^<\/Warning>$/; +const COMPONENT_TAG = /^<\/?[A-Z][\w.]*(?:\s[^>]*)?\/?>$/; +const MDX_COMMENT = /^\{\/\*[\s\S]*\*\/\}$/; +const FENCE = /^\s*(`{3,}|~{3,})/; + +const absoluteLinks = (line: string, origin: string) => + line.replace(/\]\(\/(?!\/)/g, `](${origin}/`); + +// Docs are MDX. An agent gets the source with its frontmatter turned into a +// title and summary, the site's components replaced by what they show, and +// site-relative links made absolute. Code blocks are passed through as written. +export function docToMarkdown( + doc: { metadata: { title: string; summary?: string }; content: string }, + origin: string, +) { + const out: string[] = [`# ${doc.metadata.title}`, ""]; + if (doc.metadata.summary) out.push(doc.metadata.summary, ""); + + let fence: string | null = null; + let inWarning = false; + const push = (line: string) => + out.push(inWarning ? (line ? `> ${line}` : ">") : line); + + for (const line of doc.content.split("\n")) { + const marker = FENCE.exec(line)?.[1]; + if (fence) { + push(line); + if (marker && marker[0] === fence[0] && marker.length >= fence.length) + fence = null; + continue; + } + if (marker) { + fence = marker; + push(line); + continue; + } + + const trimmed = line.trim(); + if (COPYABLE_PROMPT.test(trimmed)) { + const ticks = fenceOf(CAP_AGENT_PROMPT); + push("Copy this setup prompt into your agent:"); + push(""); + push(`${ticks}text`); + for (const promptLine of CAP_AGENT_PROMPT.split("\n")) push(promptLine); + push(ticks); + continue; + } + const embed = CAP_EMBED.exec(trimmed); + if (embed) { + push(`[Watch the video](${embed[1]})`); + continue; + } + const warning = WARNING_OPEN.exec(trimmed); + if (warning) { + inWarning = true; + push(`**${warning[1] || "Warning"}**`); + push(""); + continue; + } + if (WARNING_CLOSE.test(trimmed)) { + inWarning = false; + continue; + } + if (MDX_COMMENT.test(trimmed) || COMPONENT_TAG.test(trimmed)) continue; + + push(absoluteLinks(line, origin)); + } + + return `${out.join("\n").trim()}\n`; +} diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 3acae75b012..9fd8926530f 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -39,6 +39,7 @@ const nextConfig = { ], "/dashboard/settings/organization/billing": ["./lib/baa/*.pdf"], "/api/settings/billing/baa/download": ["./lib/baa/*.pdf"], + "/api/docs/markdown/**": ["./content/docs/**/*.mdx"], }, transpilePackages: [ "@cap/ui", diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 7a2382d7e68..721e8a751f8 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -11,7 +11,11 @@ import { buildEnv, serverEnv } from "@cap/env"; import { eq } from "drizzle-orm"; import { notFound } from "next/navigation"; import { type NextRequest, NextResponse, userAgent } from "next/server"; -import { agentMarkdownResponse } from "@/lib/agent-markdown"; +import { + agentMarkdownResponse, + DOCS_MARKDOWN_ROUTE, + docsMarkdownSlug, +} from "@/lib/agent-markdown"; import { INFRA_PATH_HEADER, isInfraPathname } from "@/lib/infra-routes"; import { getShareIframeRedirectUrl } from "@/lib/share-iframe-navigation"; @@ -211,6 +215,16 @@ async function route(request: NextRequest) { } if (mainOrigins.some((d) => url.origin.startsWith(d))) { + const docsSlug = + request.method === "GET" || request.method === "HEAD" + ? docsMarkdownSlug(path, request.headers.get("accept")) + : null; + if (docsSlug) { + return NextResponse.rewrite( + new URL(`${DOCS_MARKDOWN_ROUTE}/${docsSlug}`, url.origin), + ); + } + const markdown = await agentMarkdownResponse(request, { isPublicAsset }); if (!markdown) return serve(request); return new NextResponse(markdown.body, { From 28edfea4b3a83bdd6422c5c2ee864049da0c16e7 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:49:42 -0500 Subject: [PATCH 04/18] docs(web): drop the webhook claim and the hls.js start level setup The introduction no longer promises webhooks, which do not exist yet. The Get Started player no longer picks a start level: the master playlist already leads with a rendition that starts quickly, as the reference says. --- apps/web/content/docs/api/get-started.mdx | 30 +++-------------------- apps/web/content/docs/introduction.mdx | 2 +- 2 files changed, 5 insertions(+), 27 deletions(-) diff --git a/apps/web/content/docs/api/get-started.mdx b/apps/web/content/docs/api/get-started.mdx index 7c59a643282..96489dd61a0 100644 --- a/apps/web/content/docs/api/get-started.mdx +++ b/apps/web/content/docs/api/get-started.mdx @@ -395,28 +395,7 @@ Add this to `index.html`, below the other scripts: if (width && height) player.style.aspectRatio = `${width} / ${height}`; if (hlsUrl && window.Hls && Hls.isSupported()) { - const megabits = navigator.connection?.downlink; - const estimate = megabits > 0 ? megabits * 800000 : 1500000; - hls = new Hls({ - autoStartLoad: false, - abrEwmaDefaultEstimate: estimate, - abrEwmaDefaultEstimateMax: estimate, - startFragPrefetch: true, - capLevelToPlayerSize: true, - }); - hls.on(Hls.Events.MANIFEST_PARSED, (_event, { levels }) => { - const wanted = player.clientWidth * window.devicePixelRatio; - let level = 0; - while ( - levels[level + 1] && - levels[level + 1].bitrate <= estimate && - levels[level].width < wanted - ) { - level++; - } - hls.startLevel = level; - hls.startLoad(); - }); + hls = new Hls({ capLevelToPlayerSize: true }); hls.on(Hls.Events.ERROR, (_event, data) => { if (data.fatal || data.response?.code === 403) playMp4(); }); @@ -442,14 +421,13 @@ Safari plays HLS by itself. Every other browser needs [hls.js](https://github.co | Setting | Why | |---------|-----| -| `autoStartLoad: false`, then `startLevel` and `startLoad()` | Start on a rendition the viewer's connection can carry. Without it hls.js starts on the first rendition the playlist lists, which is too large for a slow connection | -| `abrEwmaDefaultEstimate` and `abrEwmaDefaultEstimateMax` | Give hls.js the browser's own estimate of the connection. Safari and Firefox do not give one, so the page assumes a slow connection there: the video starts on the smallest rendition and steps up once hls.js has measured a download | -| `startFragPrefetch` | Fetch the first segment while the video element is still being set up | | `capLevelToPlayerSize` | Never load a rendition larger than the player on the page | | The `403` check | Media links inside the playlist expire after 12 hours. A `403` means one has expired, so switch to the MP4 at once instead of letting hls.js retry | | `playMp4` on a fatal error | Keep playing from the same position if HLS fails for any other reason | | `stopHls` at the start of `playVideo` | Stop the previous video's player before starting the next one on the same page | +There is no start level to set. The master playlist lists a rendition that starts quickly first, and players begin with the first one listed. + [Play a Video](/docs/api/rest-api#play-a-video) covers a Content Security Policy, bitrates and what happens when playback stops. ### Use the hosted player instead @@ -464,7 +442,7 @@ Every upload also returns `shareUrl` and `embedUrl`. They are Cap's hosted playe src="EMBED_URL" title="Product demo" style="width: 100%; aspect-ratio: 16 / 9; border: 0" - allow="fullscreen; picture-in-picture" + allow="fullscreen; picture-in-picture; autoplay; encrypted-media" loading="lazy" > ``` diff --git a/apps/web/content/docs/introduction.mdx b/apps/web/content/docs/introduction.mdx index 50f781acd5b..d54b1a3617a 100644 --- a/apps/web/content/docs/introduction.mdx +++ b/apps/web/content/docs/introduction.mdx @@ -42,7 +42,7 @@ Cap is built for AI agents too. Codex, Claude Code, Cursor, OpenCode, or any she - **Embeds** - Embed your recordings in websites, documentation, and apps with a simple iframe snippet. -- **REST API & Webhooks** - Integrate Cap into your workflow programmatically with a full API and real-time webhook notifications. +- **REST API** - Host video and transcribe speech in your own product with [Cap Infra](/docs/api/get-started), Cap's video and speech API. ## Getting Started From 1fe9aa14ed0488ffa476c002cf7a0725f127cb14 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:49:42 -0500 Subject: [PATCH 05/18] fix(web): one embed snippet and Get Started as the Infra docs link Every Infra embed snippet, including the console's, now uses allow="fullscreen; picture-in-picture; autoplay; encrypted-media". The /infra page's Docs and Read the docs links open Get Started, and API reference stays on the reference. --- apps/web/__tests__/unit/infra-video-view.test.ts | 2 +- apps/web/components/pages/infra/Agent.tsx | 2 +- apps/web/components/pages/infra/Faq.tsx | 2 +- apps/web/components/pages/infra/content.ts | 10 +++++----- apps/web/content/docs/api/rest-api.mdx | 3 +-- apps/web/lib/infra-video-view.ts | 2 +- 6 files changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/web/__tests__/unit/infra-video-view.test.ts b/apps/web/__tests__/unit/infra-video-view.test.ts index e1bca97e9dc..ae947ecbbb6 100644 --- a/apps/web/__tests__/unit/infra-video-view.test.ts +++ b/apps/web/__tests__/unit/infra-video-view.test.ts @@ -242,7 +242,7 @@ describe("console video payload", () => { expect(detail.shareUrl).toBe(PAGE); expect(detail.embedCode).toBe( - ``, + ``, ); }); diff --git a/apps/web/components/pages/infra/Agent.tsx b/apps/web/components/pages/infra/Agent.tsx index 22838004b34..2c10efce563 100644 --- a/apps/web/components/pages/infra/Agent.tsx +++ b/apps/web/components/pages/infra/Agent.tsx @@ -32,7 +32,7 @@ export const Agent = () => (
API reference diff --git a/apps/web/components/pages/infra/Faq.tsx b/apps/web/components/pages/infra/Faq.tsx index 3c7b3559956..b7932697804 100644 --- a/apps/web/components/pages/infra/Faq.tsx +++ b/apps/web/components/pages/infra/Faq.tsx @@ -9,7 +9,7 @@ export const Faq = () => (

The full reference is in the{" "} - + docs . diff --git a/apps/web/components/pages/infra/content.ts b/apps/web/components/pages/infra/content.ts index e6813b763c3..ef93986da5b 100644 --- a/apps/web/components/pages/infra/content.ts +++ b/apps/web/components/pages/infra/content.ts @@ -92,11 +92,12 @@ export const BAA = "BAA available."; export const TRUST = [COMPLIANCE, BAA, "Open source."] as const; -const DOCS = "/docs/api/rest-api"; +const REFERENCE = "/docs/api/rest-api"; export const LINKS = { - docs: DOCS, - uploadDocs: `${DOCS}#multipart-upload`, + docs: "/docs/api/get-started", + reference: REFERENCE, + uploadDocs: `${REFERENCE}#multipart-upload`, pro: "/pricing", trust: "https://trust.cap.so", signIn: "/login", @@ -254,8 +255,7 @@ export const PLAYER: readonly CodeSample[] = [ src="${SAMPLE_PLAYBACK.embed}" title="Product Demo" style="width: 100%; aspect-ratio: 16 / 9; border: 0" - allow="fullscreen; picture-in-picture" - allowfullscreen + allow="fullscreen; picture-in-picture; autoplay; encrypted-media" loading="lazy" >`, }, diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index 85e1b1a1e57..e522e2c42d0 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -1461,8 +1461,7 @@ Every video has a hosted player, returned as `shareUrl` and `embedUrl` when you src="https://cap.so/dev/Zk3vQ9s2Lm8Rt1Xy7Bc4Df6G/embed" title="Product Demo" style="width: 100%; aspect-ratio: 16 / 9; border: 0" - allow="fullscreen; picture-in-picture" - allowfullscreen + allow="fullscreen; picture-in-picture; autoplay; encrypted-media" loading="lazy" > ``` diff --git a/apps/web/lib/infra-video-view.ts b/apps/web/lib/infra-video-view.ts index 05b5eee0aba..83924e2256e 100644 --- a/apps/web/lib/infra-video-view.ts +++ b/apps/web/lib/infra-video-view.ts @@ -189,7 +189,7 @@ export function infraVideoEmbedCode( video.width > 0 && video.height > 0; const shape = measured ? `${video.width} / ${video.height}` : "16 / 9"; - return ``; + return ``; } export function toInfraVideoDetail( From c2e1a5fb16b505f91cf8eff9ee60791cdb6cfc62 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:56:14 -0500 Subject: [PATCH 06/18] fix(web): give the final Infra rate limits in the llms files 300 requests a minute per secret key, 1,200 for all the keys of one account, 1,200 per upload token and 30 rejected credentials a minute per address. --- apps/web/lib/developer-rate-limits.ts | 4 +--- apps/web/public/llms-full.txt | 2 +- apps/web/public/llms.txt | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/web/lib/developer-rate-limits.ts b/apps/web/lib/developer-rate-limits.ts index 1a6885d58ee..e192c00c385 100644 --- a/apps/web/lib/developer-rate-limits.ts +++ b/apps/web/lib/developer-rate-limits.ts @@ -8,8 +8,6 @@ export const DEVELOPER_UPLOAD_TOKEN_REQUESTS_PER_MINUTE = 1200; // The pace every doc, the OpenAPI document and the agent prompt recommend. A // short video is playable about 3 seconds after its upload completes and a -// short transcript takes about 5 seconds, so one check usually finds it done, -// and one video and one transcript polled together use 32 of a key's 60 -// requests a minute. +// short transcript takes about 5 seconds, so one check usually finds it done. export const DEVELOPER_VIDEO_STATUS_POLL_SECONDS = 3; export const DEVELOPER_TRANSCRIPT_POLL_SECONDS = 5; diff --git a/apps/web/public/llms-full.txt b/apps/web/public/llms-full.txt index d82e55b9853..afddf6c9b08 100644 --- a/apps/web/public/llms-full.txt +++ b/apps/web/public/llms-full.txt @@ -21,7 +21,7 @@ Auth: - A public `cpk_` key cannot create or upload videos. Limits and polling: -- A secret key allows 60 requests a minute across the REST endpoints, and each upload token 1,200 a minute across the upload endpoints. Responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. On a `429`, wait for the `Retry-After` seconds, then retry. +- A secret key allows 300 requests a minute across the REST endpoints, and all the keys of one account together 1,200 a minute. Each upload token allows 1,200 a minute across the upload endpoints. Requests with a rejected key or token are limited to 30 a minute per address. Responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. On a `429`, wait for the `Retry-After` seconds, then retry. - There is no public webhook API. After an upload completes, poll `GET /api/developer/v1/videos/{id}/status` every 3 seconds until `status` is `ready` or `errored`; a short video is usually ready on the first check. A ready video can still have `hlsUrl` null for a moment while `mp4Url` plays. - Transcripts: `POST /api/developer/v1/transcripts` with a public `url` or the `videoId` of a ready video the app stores, or `POST /api/developer/v1/transcripts/uploads`, PUT the file, then `POST /api/developer/v1/transcripts/{id}/start`. Poll `GET /api/developer/v1/transcripts/{id}` every 5 seconds until `status` is `complete` or `error`. - Errors are JSON with `error`, `code`, `message`, `hint` and `docs` fields. A 4xx other than `429` and a `409` with code `completion_in_progress` will not succeed if repeated. diff --git a/apps/web/public/llms.txt b/apps/web/public/llms.txt index 497a29ef36a..4639ce92e9e 100644 --- a/apps/web/public/llms.txt +++ b/apps/web/public/llms.txt @@ -19,7 +19,7 @@ Cap Infra is Cap's usage-billed API for building video and speech into your own - Billing: https://cap.so/docs/api/billing - Console, where an app and its keys are created: https://cap.so/dashboard/infra - **Auth.** Send the app's secret key (`csk_`) as `Authorization: Bearer `, from server code only. It never goes into browser code. For an upload from the browser, the server calls `POST /api/developer/v1/uploads` and gives the browser the short-lived upload token (`cut_`) it returns, which works for that one video. A public `cpk_` key cannot create or upload videos. -- **Rate limits.** A secret key allows 60 requests a minute across the REST endpoints, and each upload token 1,200 a minute across the upload endpoints. On a `429`, wait for the `Retry-After` seconds, then retry. +- **Rate limits.** A secret key allows 300 requests a minute across the REST endpoints, and all the keys of one account together 1,200 a minute. Each upload token allows 1,200 a minute across the upload endpoints. Requests with a rejected key or token are limited to 30 a minute per address. On a `429`, wait for the `Retry-After` seconds, then retry. - **Polling.** There is no public webhook API. After an upload completes, poll `GET /api/developer/v1/videos/{id}/status` every 3 seconds until `status` is `ready` or `errored`; a short video is usually ready on the first check. Poll `GET /api/developer/v1/transcripts/{id}` every 5 seconds until `status` is `complete` or `error`. - **Errors** are JSON with `error`, `code`, `message`, `hint` and `docs` fields. - **Docs as Markdown.** Every page under https://cap.so/docs is available as Markdown: add `.md` to its URL (for example https://cap.so/docs/api/get-started.md) or request it with `Accept: text/markdown`. From ea58c7517303116a21d10bedc0ff2d1d982f2856 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:00:12 -0500 Subject: [PATCH 07/18] refactor(web): serve docs Markdown outside /api and drop narration comments The /api routes follow the HttpApi pattern, and this one only returns a file as text, so it moves to /docs-markdown behind the same proxy rewrite. --- apps/web/__tests__/unit/docs-markdown.test.ts | 5 ++--- apps/web/__tests__/unit/proxy-infra-path.test.ts | 2 +- .../{api/docs/markdown => docs-markdown}/[...slug]/route.ts | 4 +--- apps/web/lib/agent-markdown.ts | 5 +---- apps/web/lib/docs-markdown.ts | 3 --- apps/web/next.config.mjs | 2 +- 6 files changed, 6 insertions(+), 15 deletions(-) rename apps/web/app/{api/docs/markdown => docs-markdown}/[...slug]/route.ts (78%) diff --git a/apps/web/__tests__/unit/docs-markdown.test.ts b/apps/web/__tests__/unit/docs-markdown.test.ts index c477397eb37..b278ee5ad8d 100644 --- a/apps/web/__tests__/unit/docs-markdown.test.ts +++ b/apps/web/__tests__/unit/docs-markdown.test.ts @@ -12,19 +12,18 @@ vi.mock("@cap/env", () => ({ serverEnv: () => ({ WEB_URL: "https://cap.so" }), })); -import { GET } from "@/app/api/docs/markdown/[...slug]/route"; +import { GET } from "@/app/docs-markdown/[...slug]/route"; import { CAP_AGENT_PROMPT } from "@/data/agent-prompt"; import { DOCS_MARKDOWN_ROUTE, docsMarkdownSlug } from "@/lib/agent-markdown"; import { docToMarkdown } from "@/lib/docs-markdown"; -import { proxy } from "../../proxy"; import { getAllDocs, getDocBySlug } from "@/utils/docs"; +import { proxy } from "../../proxy"; const getMarkdown = (slug: string, method = "GET") => GET(new Request(`https://cap.so/docs/${slug}.md`, { method }), { params: Promise.resolve({ slug: slug.split("/") }), }); -// Lines outside fenced code blocks, where MDX components would render. const proseLines = (markdown: string) => { const lines: string[] = []; let fence: string | null = null; diff --git a/apps/web/__tests__/unit/proxy-infra-path.test.ts b/apps/web/__tests__/unit/proxy-infra-path.test.ts index 82b3849fb0d..9a1d65e392e 100644 --- a/apps/web/__tests__/unit/proxy-infra-path.test.ts +++ b/apps/web/__tests__/unit/proxy-infra-path.test.ts @@ -23,7 +23,7 @@ vi.mock("@cap/env", () => ({ vi.mock("@/lib/agent-markdown", () => ({ agentMarkdownResponse: async () => null, docsMarkdownSlug: () => null, - DOCS_MARKDOWN_ROUTE: "/api/docs/markdown", + DOCS_MARKDOWN_ROUTE: "/docs-markdown", })); const FORWARDED = "x-middleware-request-x-cap-infra-path"; diff --git a/apps/web/app/api/docs/markdown/[...slug]/route.ts b/apps/web/app/docs-markdown/[...slug]/route.ts similarity index 78% rename from apps/web/app/api/docs/markdown/[...slug]/route.ts rename to apps/web/app/docs-markdown/[...slug]/route.ts index a58ca6964cd..93425840c9e 100644 --- a/apps/web/app/api/docs/markdown/[...slug]/route.ts +++ b/apps/web/app/docs-markdown/[...slug]/route.ts @@ -2,11 +2,9 @@ import { markdownHeaders, markdownNotFoundBody } from "@/lib/agent-markdown"; import { docToMarkdown } from "@/lib/docs-markdown"; import { getDocBySlug } from "@/utils/docs"; -// The proxy rewrites a docs page here when an agent asks for Markdown, with -// `Accept: text/markdown` or a `.md` URL. export async function GET( request: Request, - props: RouteContext<"/api/docs/markdown/[...slug]">, + props: RouteContext<"/docs-markdown/[...slug]">, ) { const { slug } = await props.params; const { origin } = new URL(request.url); diff --git a/apps/web/lib/agent-markdown.ts b/apps/web/lib/agent-markdown.ts index c8422821200..44d855cad67 100644 --- a/apps/web/lib/agent-markdown.ts +++ b/apps/web/lib/agent-markdown.ts @@ -28,13 +28,10 @@ export const prefersMarkdown = (accept: string | null) => { return markdown > 0 && markdown >= html; }; -export const DOCS_MARKDOWN_ROUTE = "/api/docs/markdown"; +export const DOCS_MARKDOWN_ROUTE = "/docs-markdown"; const MARKDOWN_EXTENSION = /\.mdx?$/; -// The docs path an agent asked for as Markdown, as the slug of its source -// file, or null when the request should get the HTML page. "/docs" is the -// Introduction page, as it is in HTML. export const docsMarkdownSlug = ( pathname: string, accept: string | null, diff --git a/apps/web/lib/docs-markdown.ts b/apps/web/lib/docs-markdown.ts index 3ea8275da6e..de0e2f55af4 100644 --- a/apps/web/lib/docs-markdown.ts +++ b/apps/web/lib/docs-markdown.ts @@ -19,9 +19,6 @@ const FENCE = /^\s*(`{3,}|~{3,})/; const absoluteLinks = (line: string, origin: string) => line.replace(/\]\(\/(?!\/)/g, `](${origin}/`); -// Docs are MDX. An agent gets the source with its frontmatter turned into a -// title and summary, the site's components replaced by what they show, and -// site-relative links made absolute. Code blocks are passed through as written. export function docToMarkdown( doc: { metadata: { title: string; summary?: string }; content: string }, origin: string, diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 9fd8926530f..82ccb61712a 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -39,7 +39,7 @@ const nextConfig = { ], "/dashboard/settings/organization/billing": ["./lib/baa/*.pdf"], "/api/settings/billing/baa/download": ["./lib/baa/*.pdf"], - "/api/docs/markdown/**": ["./content/docs/**/*.mdx"], + "/docs-markdown/**": ["./content/docs/**/*.mdx"], }, transpilePackages: [ "@cap/ui", From 3b4b8488fdd3e8af64122a6693bab8461ac8d10a Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:00:12 -0500 Subject: [PATCH 08/18] style(web): format the createTranscript description --- apps/web/lib/developer-openapi.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/lib/developer-openapi.ts b/apps/web/lib/developer-openapi.ts index 56f5c9e3dd7..7813b874ca6 100644 --- a/apps/web/lib/developer-openapi.ts +++ b/apps/web/lib/developer-openapi.ts @@ -628,8 +628,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ post: { operationId: "createTranscript", summary: "Transcribe a URL", - description: - `Creates a transcript from a public audio or video URL, or from a video the app already stores, and starts it. Send exactly one of url or videoId. The URL must use http or https on the default port and resolve to a public address, and the file must finish downloading within 15 minutes. With videoId the video must belong to this app, not be deleted and be ready: Cap reads its stored MP4 directly, so nothing is downloaded and the URL size limit does not apply. An unknown or deleted video returns 404 video_not_found, and one that is not ready returns 409 video_not_ready. A video transcript costs the transcript price only, $0.10 per audio hour. Requires an available credit balance of at least 1,000 micro-credits. Starting holds up to the cost of four hours ($0.40) until the audio is measured, then the hold drops to the real cost. A stored video is held for its known length. A transcript whose hold the available balance cannot cover ends with insufficient_credits before the file is downloaded. Poll getTranscript every ${DEVELOPER_TRANSCRIPT_POLL_SECONDS} seconds until status is complete or error. An app can have 20 transcripts queued or processing at once: another is refused with 429 too_many_transcripts and a Retry-After header. A field the request does not know returns 400 with code invalid_request.`, + description: `Creates a transcript from a public audio or video URL, or from a video the app already stores, and starts it. Send exactly one of url or videoId. The URL must use http or https on the default port and resolve to a public address, and the file must finish downloading within 15 minutes. With videoId the video must belong to this app, not be deleted and be ready: Cap reads its stored MP4 directly, so nothing is downloaded and the URL size limit does not apply. An unknown or deleted video returns 404 video_not_found, and one that is not ready returns 409 video_not_ready. A video transcript costs the transcript price only, $0.10 per audio hour. Requires an available credit balance of at least 1,000 micro-credits. Starting holds up to the cost of four hours ($0.40) until the audio is measured, then the hold drops to the real cost. A stored video is held for its known length. A transcript whose hold the available balance cannot cover ends with insufficient_credits before the file is downloaded. Poll getTranscript every ${DEVELOPER_TRANSCRIPT_POLL_SECONDS} seconds until status is complete or error. An app can have 20 transcripts queued or processing at once: another is refused with 429 too_many_transcripts and a Retry-After header. A field the request does not know returns 400 with code invalid_request.`, tags: ["Transcripts"], security: secretAuth, parameters: [idempotencyKeyParam], From 1f3e55e3dc5ed7e0e393cd86b16c9078ebe3aa1e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:00:12 -0500 Subject: [PATCH 09/18] fix(web): tell agents which Infra errors to wait out A 409 video_not_ready succeeds once the video finishes processing, so it joins 429 and completion_in_progress, and agents are told to follow each error's hint. --- apps/web/public/llms-full.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/public/llms-full.txt b/apps/web/public/llms-full.txt index afddf6c9b08..502ecca3f71 100644 --- a/apps/web/public/llms-full.txt +++ b/apps/web/public/llms-full.txt @@ -24,7 +24,7 @@ Limits and polling: - A secret key allows 300 requests a minute across the REST endpoints, and all the keys of one account together 1,200 a minute. Each upload token allows 1,200 a minute across the upload endpoints. Requests with a rejected key or token are limited to 30 a minute per address. Responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. On a `429`, wait for the `Retry-After` seconds, then retry. - There is no public webhook API. After an upload completes, poll `GET /api/developer/v1/videos/{id}/status` every 3 seconds until `status` is `ready` or `errored`; a short video is usually ready on the first check. A ready video can still have `hlsUrl` null for a moment while `mp4Url` plays. - Transcripts: `POST /api/developer/v1/transcripts` with a public `url` or the `videoId` of a ready video the app stores, or `POST /api/developer/v1/transcripts/uploads`, PUT the file, then `POST /api/developer/v1/transcripts/{id}/start`. Poll `GET /api/developer/v1/transcripts/{id}` every 5 seconds until `status` is `complete` or `error`. -- Errors are JSON with `error`, `code`, `message`, `hint` and `docs` fields. A 4xx other than `429` and a `409` with code `completion_in_progress` will not succeed if repeated. +- Errors are JSON with `error`, `code`, `message`, `hint` and `docs` fields. Follow each error's `hint`. A 4xx will not succeed if repeated unchanged, except a `429`, a `409` with code `completion_in_progress`, and a `409` with code `video_not_ready` for a video that is still processing. Every page under https://cap.so/docs is available as Markdown: add `.md` to its URL (for example https://cap.so/docs/api/rest-api.md) or request it with `Accept: text/markdown`. From 9cbeedd653951361555510263460082083c4ea61 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:03:32 -0500 Subject: [PATCH 10/18] docs(web): poll several transcripts with one list call per status An app has at most 20 transcripts in flight. Getting each one every 5 seconds takes 240 requests a minute, and listing by status takes 24. --- apps/web/content/docs/api/get-started.mdx | 2 ++ apps/web/content/docs/api/rest-api.mdx | 4 +++- apps/web/public/llms-full.txt | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/content/docs/api/get-started.mdx b/apps/web/content/docs/api/get-started.mdx index 96489dd61a0..21cad1b4557 100644 --- a/apps/web/content/docs/api/get-started.mdx +++ b/apps/web/content/docs/api/get-started.mdx @@ -787,6 +787,8 @@ The same secret key turns an audio or video file into text. It is a separate cal 2. `PUT` the file to `upload.url`. 3. Start it with `POST /api/developer/v1/transcripts/:id/start`, then check `GET /api/developer/v1/transcripts/:id` every 5 seconds until `status` is `complete` or `error`. A complete transcript has its `text`. +To wait on several transcripts, check them as a batch with one list call per status instead of one call each: `GET /api/developer/v1/transcripts?status=processing&limit=100`, plus `status=queued` while any are queued. A transcript that has left both lists has finished, so get it once for its `text`. An app has at most 20 transcripts in flight, and checking each of 20 every 5 seconds takes 240 requests a minute, most of a key's limit, while the two list calls take 24. + A file that is already at a public URL needs no upload. Send it as `url` to `POST /api/developer/v1/transcripts` and check the transcript the same way. That includes a video you uploaded here: send its `mp4Url` as the `url` once the video is `ready`. [Transcripts](/docs/api/rest-api#transcripts) is the reference, with a request for each step, word and segment timings and every error code. diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index e522e2c42d0..08e3a262453 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -624,6 +624,8 @@ Send an audio or video file and get text back. Transcription costs $0.10 per aud A transcript moves through these statuses: `uploading` (waiting for its file), `queued`, `processing`, then `complete` or `error`. Poll [Get Transcript](/docs/api/rest-api#get-transcript) every 5 seconds until the status is `complete` or `error`. Each key allows 60 requests per minute. +To wait on several transcripts, poll them as a batch with one [List Transcripts](/docs/api/rest-api#list-transcripts) call per status instead of one Get Transcript each: `GET /api/developer/v1/transcripts?status=processing&limit=100`, plus `status=queued` while any are queued. A transcript that has left both lists has finished, so read it once with Get Transcript for its text. An app has at most 20 transcripts in flight, and getting each of 20 every 5 seconds takes 240 requests a minute, most of a key's limit, while the two list calls take 24. + An app can have 20 transcripts `queued` or `processing` at once. Starting another answers `429` with the code `too_many_transcripts` and a `Retry-After` header of 15 seconds. Wait for one to finish, then send the request again. Transcripts in `uploading` do not count until they are started. A transcript request can be refused. The response carries a `code` and a `message`, and these are all the codes the transcript routes return besides the ones every route shares (see [Error Handling](/docs/api/rest-api#error-handling)): @@ -860,7 +862,7 @@ With `?include=words,segments` a complete transcript also carries: } ``` -`start` and `end` are seconds from the start of the audio, in steps of 0.08 seconds. A word's start is usually within 0.1 seconds of where it is spoken. Its end can run up to 0.4 seconds late and is often the start of the next word, so the timings suit captions and seeking rather than tight cuts at word ends. Timings do not drift on long files. +`start` and `end` are seconds from the start of the audio. A word's start is usually within 0.1 seconds of where it is spoken. Its end can run up to 0.4 seconds late and is often the start of the next word, so the timings suit captions and seeking rather than tight cuts at word ends. Timings do not drift on long files. Audio with no speech is not a failure. The transcript completes with `text` set to `""` and `wordCount` set to `0`, and it is billed for its length like any other transcript. A complete transcript with a `wordCount` of `0` is how you tell that no speech was found. diff --git a/apps/web/public/llms-full.txt b/apps/web/public/llms-full.txt index 502ecca3f71..b817e982e36 100644 --- a/apps/web/public/llms-full.txt +++ b/apps/web/public/llms-full.txt @@ -23,7 +23,7 @@ Auth: Limits and polling: - A secret key allows 300 requests a minute across the REST endpoints, and all the keys of one account together 1,200 a minute. Each upload token allows 1,200 a minute across the upload endpoints. Requests with a rejected key or token are limited to 30 a minute per address. Responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. On a `429`, wait for the `Retry-After` seconds, then retry. - There is no public webhook API. After an upload completes, poll `GET /api/developer/v1/videos/{id}/status` every 3 seconds until `status` is `ready` or `errored`; a short video is usually ready on the first check. A ready video can still have `hlsUrl` null for a moment while `mp4Url` plays. -- Transcripts: `POST /api/developer/v1/transcripts` with a public `url` or the `videoId` of a ready video the app stores, or `POST /api/developer/v1/transcripts/uploads`, PUT the file, then `POST /api/developer/v1/transcripts/{id}/start`. Poll `GET /api/developer/v1/transcripts/{id}` every 5 seconds until `status` is `complete` or `error`. +- Transcripts: `POST /api/developer/v1/transcripts` with a public `url` or the `videoId` of a ready video the app stores, or `POST /api/developer/v1/transcripts/uploads`, PUT the file, then `POST /api/developer/v1/transcripts/{id}/start`. Poll `GET /api/developer/v1/transcripts/{id}` every 5 seconds until `status` is `complete` or `error`. For several at once, list them with `GET /api/developer/v1/transcripts?status=processing&limit=100` (and `status=queued`) instead of getting each one. - Errors are JSON with `error`, `code`, `message`, `hint` and `docs` fields. Follow each error's `hint`. A 4xx will not succeed if repeated unchanged, except a `429`, a `409` with code `completion_in_progress`, and a `409` with code `video_not_ready` for a video that is still processing. Every page under https://cap.so/docs is available as Markdown: add `.md` to its URL (for example https://cap.so/docs/api/rest-api.md) or request it with `Accept: text/markdown`. From 095731ddf22d97d127d1ecfadd7203299ae26a4d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:03:32 -0500 Subject: [PATCH 11/18] docs(web): stop promising 0.08 second word timing steps Words in a file long enough to be transcribed in chunks are offset off that grid, so the reference and the OpenAPI words description no longer claim it. --- apps/web/__tests__/unit/developer-transcript-docs.test.ts | 3 ++- apps/web/lib/developer-openapi.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/__tests__/unit/developer-transcript-docs.test.ts b/apps/web/__tests__/unit/developer-transcript-docs.test.ts index 52c08be6e8e..326417a6a58 100644 --- a/apps/web/__tests__/unit/developer-transcript-docs.test.ts +++ b/apps/web/__tests__/unit/developer-transcript-docs.test.ts @@ -251,7 +251,8 @@ describe("transcript docs keep up with the API", () => { expect(JSON.stringify(schemas.TranscriptDetail)).not.toContain( "confidence", ); - expect(transcriptDocs).toContain("steps of 0.08 seconds"); + expect(transcriptDocs).not.toContain("steps of 0.08 seconds"); + expect(JSON.stringify(schemas.TranscriptDetail)).not.toContain("0.08"); }); it("states the download limit and the upload expiry", () => { diff --git a/apps/web/lib/developer-openapi.ts b/apps/web/lib/developer-openapi.ts index 7813b874ca6..0fc5a55a9d1 100644 --- a/apps/web/lib/developer-openapi.ts +++ b/apps/web/lib/developer-openapi.ts @@ -1051,7 +1051,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ words: { type: ["array", "null"], description: - "Each word with its start and end in seconds. Timings are in steps of 0.08 seconds. A start is usually within 0.1 seconds, and an end can run up to 0.4 seconds late, often to the start of the next word.", + "Each word with its start and end in seconds. A start is usually within 0.1 seconds, and an end can run up to 0.4 seconds late, often to the start of the next word.", items: { type: "object", required: ["text", "start", "end"], From 7e50b96ef26819ab4340d4b67b496268484e19fb Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:08:58 -0500 Subject: [PATCH 12/18] docs(web): list queued transcripts before processing ones when batch polling --- apps/web/content/docs/api/get-started.mdx | 2 +- apps/web/content/docs/api/rest-api.mdx | 2 +- apps/web/public/llms-full.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/content/docs/api/get-started.mdx b/apps/web/content/docs/api/get-started.mdx index 21cad1b4557..27fb7b2c4a0 100644 --- a/apps/web/content/docs/api/get-started.mdx +++ b/apps/web/content/docs/api/get-started.mdx @@ -787,7 +787,7 @@ The same secret key turns an audio or video file into text. It is a separate cal 2. `PUT` the file to `upload.url`. 3. Start it with `POST /api/developer/v1/transcripts/:id/start`, then check `GET /api/developer/v1/transcripts/:id` every 5 seconds until `status` is `complete` or `error`. A complete transcript has its `text`. -To wait on several transcripts, check them as a batch with one list call per status instead of one call each: `GET /api/developer/v1/transcripts?status=processing&limit=100`, plus `status=queued` while any are queued. A transcript that has left both lists has finished, so get it once for its `text`. An app has at most 20 transcripts in flight, and checking each of 20 every 5 seconds takes 240 requests a minute, most of a key's limit, while the two list calls take 24. +To wait on several transcripts, check them as a batch with one list call per status instead of one call each: `GET /api/developer/v1/transcripts?status=queued&limit=100`, then `status=processing`. Ask for `queued` first: a transcript only moves from `queued` to `processing` to a final status, so one missing from both lists has finished. Get it for its `text`, and keep checking it if its `status` is not yet `complete` or `error`. An app has at most 20 transcripts in flight, and checking each of 20 every 5 seconds takes 240 requests a minute, most of a key's limit, while the two list calls take 24. A file that is already at a public URL needs no upload. Send it as `url` to `POST /api/developer/v1/transcripts` and check the transcript the same way. That includes a video you uploaded here: send its `mp4Url` as the `url` once the video is `ready`. diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index 08e3a262453..00e8dc06341 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -624,7 +624,7 @@ Send an audio or video file and get text back. Transcription costs $0.10 per aud A transcript moves through these statuses: `uploading` (waiting for its file), `queued`, `processing`, then `complete` or `error`. Poll [Get Transcript](/docs/api/rest-api#get-transcript) every 5 seconds until the status is `complete` or `error`. Each key allows 60 requests per minute. -To wait on several transcripts, poll them as a batch with one [List Transcripts](/docs/api/rest-api#list-transcripts) call per status instead of one Get Transcript each: `GET /api/developer/v1/transcripts?status=processing&limit=100`, plus `status=queued` while any are queued. A transcript that has left both lists has finished, so read it once with Get Transcript for its text. An app has at most 20 transcripts in flight, and getting each of 20 every 5 seconds takes 240 requests a minute, most of a key's limit, while the two list calls take 24. +To wait on several transcripts, poll them as a batch with one [List Transcripts](/docs/api/rest-api#list-transcripts) call per status instead of one Get Transcript each: `GET /api/developer/v1/transcripts?status=queued&limit=100`, then `status=processing`. Ask for `queued` first: a transcript only moves from `queued` to `processing` to a final status, so one missing from both lists has finished. Read it with Get Transcript for its text, and keep it in the batch if the status it returns is not yet `complete` or `error`. An app has at most 20 transcripts in flight, and getting each of 20 every 5 seconds takes 240 requests a minute, most of a key's limit, while the two list calls take 24. An app can have 20 transcripts `queued` or `processing` at once. Starting another answers `429` with the code `too_many_transcripts` and a `Retry-After` header of 15 seconds. Wait for one to finish, then send the request again. Transcripts in `uploading` do not count until they are started. diff --git a/apps/web/public/llms-full.txt b/apps/web/public/llms-full.txt index b817e982e36..bc4ec482ab4 100644 --- a/apps/web/public/llms-full.txt +++ b/apps/web/public/llms-full.txt @@ -23,7 +23,7 @@ Auth: Limits and polling: - A secret key allows 300 requests a minute across the REST endpoints, and all the keys of one account together 1,200 a minute. Each upload token allows 1,200 a minute across the upload endpoints. Requests with a rejected key or token are limited to 30 a minute per address. Responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. On a `429`, wait for the `Retry-After` seconds, then retry. - There is no public webhook API. After an upload completes, poll `GET /api/developer/v1/videos/{id}/status` every 3 seconds until `status` is `ready` or `errored`; a short video is usually ready on the first check. A ready video can still have `hlsUrl` null for a moment while `mp4Url` plays. -- Transcripts: `POST /api/developer/v1/transcripts` with a public `url` or the `videoId` of a ready video the app stores, or `POST /api/developer/v1/transcripts/uploads`, PUT the file, then `POST /api/developer/v1/transcripts/{id}/start`. Poll `GET /api/developer/v1/transcripts/{id}` every 5 seconds until `status` is `complete` or `error`. For several at once, list them with `GET /api/developer/v1/transcripts?status=processing&limit=100` (and `status=queued`) instead of getting each one. +- Transcripts: `POST /api/developer/v1/transcripts` with a public `url` or the `videoId` of a ready video the app stores, or `POST /api/developer/v1/transcripts/uploads`, PUT the file, then `POST /api/developer/v1/transcripts/{id}/start`. Poll `GET /api/developer/v1/transcripts/{id}` every 5 seconds until `status` is `complete` or `error`. For several at once, list them with `GET /api/developer/v1/transcripts?status=queued&limit=100`, then `status=processing`, instead of getting each one; one missing from both lists has finished. - Errors are JSON with `error`, `code`, `message`, `hint` and `docs` fields. Follow each error's `hint`. A 4xx will not succeed if repeated unchanged, except a `429`, a `409` with code `completion_in_progress`, and a `409` with code `video_not_ready` for a video that is still processing. Every page under https://cap.so/docs is available as Markdown: add `.md` to its URL (for example https://cap.so/docs/api/rest-api.md) or request it with `Accept: text/markdown`. From 8506f858c76380329bb36956190a2e1a23bca220 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:15:17 -0500 Subject: [PATCH 13/18] test(web): count h4 headings as link targets in the API docs --- apps/web/__tests__/unit/developer-billing-docs.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/__tests__/unit/developer-billing-docs.test.ts b/apps/web/__tests__/unit/developer-billing-docs.test.ts index 525d0799846..d9ce25b1e9f 100644 --- a/apps/web/__tests__/unit/developer-billing-docs.test.ts +++ b/apps/web/__tests__/unit/developer-billing-docs.test.ts @@ -135,9 +135,11 @@ describe("the Billing page", () => { compileMDX({ source: rest, components: {} }), ).resolves.toBeDefined(); + const slugsOf = (source: string) => + extractHeadings(source.replace(/^####\s/gm, "### ")).map((h) => h.slug); const anchors = new Map([ - ["/docs/api/rest-api", extractHeadings(rest).map((h) => h.slug)], - ["/docs/api/billing", extractHeadings(billing).map((h) => h.slug)], + ["/docs/api/rest-api", slugsOf(rest)], + ["/docs/api/billing", slugsOf(billing)], ]); const links = [ ...billing.matchAll(/\]\((\/docs\/api\/rest-api)#([\w-]+)\)/g), From d97f9a41e2ecb077bfae9a13a104595da3a61147 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:15:17 -0500 Subject: [PATCH 14/18] fix(web): keep the agent prompt's rate limit sentence as #4160 writes it --- apps/web/__tests__/unit/infra-agent-prompt.test.ts | 1 - apps/web/data/infra-agent-prompt.ts | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/__tests__/unit/infra-agent-prompt.test.ts b/apps/web/__tests__/unit/infra-agent-prompt.test.ts index 37e7230bb73..76f740e6c3c 100644 --- a/apps/web/__tests__/unit/infra-agent-prompt.test.ts +++ b/apps/web/__tests__/unit/infra-agent-prompt.test.ts @@ -61,7 +61,6 @@ describe("Cap Infra agent prompt", () => { expect(CAP_INFRA_AGENT_PROMPT).toContain( "/transcripts/{id} every 5 seconds", ); - expect(CAP_INFRA_AGENT_PROMPT).toContain("1,200 a minute"); expect(CAP_INFRA_AGENT_PROMPT).toContain("Retry-After"); expect(CAP_INFRA_AGENT_PROMPT).toContain("60 requests a minute"); }); diff --git a/apps/web/data/infra-agent-prompt.ts b/apps/web/data/infra-agent-prompt.ts index 9a6b398a1aa..e25936e7ad1 100644 --- a/apps/web/data/infra-agent-prompt.ts +++ b/apps/web/data/infra-agent-prompt.ts @@ -1,7 +1,6 @@ import { DEVELOPER_KEY_REQUESTS_PER_MINUTE, DEVELOPER_TRANSCRIPT_POLL_SECONDS, - DEVELOPER_UPLOAD_TOKEN_REQUESTS_PER_MINUTE, DEVELOPER_VIDEO_STATUS_POLL_SECONDS, } from "@/lib/developer-rate-limits"; @@ -37,7 +36,7 @@ Cap Infra is Cap's video and speech API. All you need is the app's secret key an 5. Wait until it plays. From the server, poll ${calls.videoStatus} every ${DEVELOPER_VIDEO_STATUS_POLL_SECONDS} seconds. Stop when status is ready or errored, and show errorReason if it failed. 6. Play it. Show the video with its embedUrl, or build a player from hlsUrl and mp4Url in ${calls.video}. Use hlsUrl when it is present and mp4Url otherwise. shareUrl is the link to send to someone. 7. Transcribe when I ask. Transcripts are billed per audio hour, so confirm with me first. For a public URL call ${calls.transcribeUrl}. For a file call ${calls.transcribeFile}, PUT the file to the signed URL it returns, then call ${calls.startTranscript}. Poll ${calls.transcript} every ${DEVELOPER_TRANSCRIPT_POLL_SECONDS} seconds until status is complete or error. -8. Stay inside the limit. A secret key allows ${DEVELOPER_KEY_REQUESTS_PER_MINUTE} requests a minute across every endpoint it calls, and each upload token ${DEVELOPER_UPLOAD_TOKEN_REQUESTS_PER_MINUTE.toLocaleString("en-US")} a minute for its upload calls. After a 429, wait for the number of seconds in the Retry-After header before you send the request again. +8. Stay inside the limit. A secret key allows ${DEVELOPER_KEY_REQUESTS_PER_MINUTE} requests a minute across every endpoint. After a 429, wait for the number of seconds in the Retry-After header before you send the request again. 9. Watch the credits. ${calls.usage} shows the balance and whether uploads are allowed. A 402 means the app needs credit, so tell me and I will add it in the console. Finish by uploading one short test video through the code you wrote. Ask me first, because stored video is billed daily. Then tell me what you changed and give me the video's shareUrl, and ask what I want to build next.`; From fdcf48bac30d1e41c18d51f4578282571435ae95 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:25:48 -0500 Subject: [PATCH 15/18] docs(web): point agents at the hosted Cap Infra MCP server in the llms files --- apps/web/public/llms-full.txt | 1 + apps/web/public/llms.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/web/public/llms-full.txt b/apps/web/public/llms-full.txt index bc4ec482ab4..05a255dc31f 100644 --- a/apps/web/public/llms-full.txt +++ b/apps/web/public/llms-full.txt @@ -14,6 +14,7 @@ Cap Infra is Cap's usage-billed API for building video and speech into your own - OpenAPI 3.1 description (take every path and field name from it): https://cap.so/openapi.json - Billing rules: https://cap.so/docs/api/billing - Console, where an app and its keys are created: https://cap.so/dashboard/infra +- Hosted MCP server for agents: `https://cap.so/api/developer/mcp` (Streamable HTTP, `Authorization: Bearer `). Setup: https://cap.so/docs/api/get-started#10-use-with-agents-mcp Auth: - Send the app's secret key (`csk_`) as `Authorization: Bearer `, from server code only. It never goes into browser code, a client bundle or a public environment variable. diff --git a/apps/web/public/llms.txt b/apps/web/public/llms.txt index 4639ce92e9e..4cc835a1576 100644 --- a/apps/web/public/llms.txt +++ b/apps/web/public/llms.txt @@ -18,6 +18,7 @@ Cap Infra is Cap's usage-billed API for building video and speech into your own - OpenAPI 3.1 description (take every path and field name from it): https://cap.so/openapi.json - Billing: https://cap.so/docs/api/billing - Console, where an app and its keys are created: https://cap.so/dashboard/infra +- Hosted MCP server for agents: `https://cap.so/api/developer/mcp` (Streamable HTTP, `Authorization: Bearer `). Setup: https://cap.so/docs/api/get-started#10-use-with-agents-mcp - **Auth.** Send the app's secret key (`csk_`) as `Authorization: Bearer `, from server code only. It never goes into browser code. For an upload from the browser, the server calls `POST /api/developer/v1/uploads` and gives the browser the short-lived upload token (`cut_`) it returns, which works for that one video. A public `cpk_` key cannot create or upload videos. - **Rate limits.** A secret key allows 300 requests a minute across the REST endpoints, and all the keys of one account together 1,200 a minute. Each upload token allows 1,200 a minute across the upload endpoints. Requests with a rejected key or token are limited to 30 a minute per address. On a `429`, wait for the `Retry-After` seconds, then retry. - **Polling.** There is no public webhook API. After an upload completes, poll `GET /api/developer/v1/videos/{id}/status` every 3 seconds until `status` is `ready` or `errored`; a short video is usually ready on the first check. Poll `GET /api/developer/v1/transcripts/{id}` every 5 seconds until `status` is `complete` or `error`. From 337c23f0e8874eb70d3c67bdb9ab03e16cff1eeb Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 02:12:58 -0500 Subject: [PATCH 16/18] revert(web): leave the Infra embed snippets as main has them --- apps/web/__tests__/unit/infra-video-view.test.ts | 2 +- apps/web/components/pages/infra/content.ts | 3 ++- apps/web/content/docs/api/get-started.mdx | 2 +- apps/web/content/docs/api/rest-api.mdx | 3 ++- apps/web/lib/infra-video-view.ts | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/web/__tests__/unit/infra-video-view.test.ts b/apps/web/__tests__/unit/infra-video-view.test.ts index ae947ecbbb6..e1bca97e9dc 100644 --- a/apps/web/__tests__/unit/infra-video-view.test.ts +++ b/apps/web/__tests__/unit/infra-video-view.test.ts @@ -242,7 +242,7 @@ describe("console video payload", () => { expect(detail.shareUrl).toBe(PAGE); expect(detail.embedCode).toBe( - ``, + ``, ); }); diff --git a/apps/web/components/pages/infra/content.ts b/apps/web/components/pages/infra/content.ts index ef93986da5b..3ab271653ba 100644 --- a/apps/web/components/pages/infra/content.ts +++ b/apps/web/components/pages/infra/content.ts @@ -255,7 +255,8 @@ export const PLAYER: readonly CodeSample[] = [ src="${SAMPLE_PLAYBACK.embed}" title="Product Demo" style="width: 100%; aspect-ratio: 16 / 9; border: 0" - allow="fullscreen; picture-in-picture; autoplay; encrypted-media" + allow="fullscreen; picture-in-picture" + allowfullscreen loading="lazy" >`, }, diff --git a/apps/web/content/docs/api/get-started.mdx b/apps/web/content/docs/api/get-started.mdx index 27fb7b2c4a0..ebdbfaa9cc1 100644 --- a/apps/web/content/docs/api/get-started.mdx +++ b/apps/web/content/docs/api/get-started.mdx @@ -442,7 +442,7 @@ Every upload also returns `shareUrl` and `embedUrl`. They are Cap's hosted playe src="EMBED_URL" title="Product demo" style="width: 100%; aspect-ratio: 16 / 9; border: 0" - allow="fullscreen; picture-in-picture; autoplay; encrypted-media" + allow="fullscreen; picture-in-picture" loading="lazy" > ``` diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index 00e8dc06341..2f1f3efabb3 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -1463,7 +1463,8 @@ Every video has a hosted player, returned as `shareUrl` and `embedUrl` when you src="https://cap.so/dev/Zk3vQ9s2Lm8Rt1Xy7Bc4Df6G/embed" title="Product Demo" style="width: 100%; aspect-ratio: 16 / 9; border: 0" - allow="fullscreen; picture-in-picture; autoplay; encrypted-media" + allow="fullscreen; picture-in-picture" + allowfullscreen loading="lazy" > ``` diff --git a/apps/web/lib/infra-video-view.ts b/apps/web/lib/infra-video-view.ts index 83924e2256e..05b5eee0aba 100644 --- a/apps/web/lib/infra-video-view.ts +++ b/apps/web/lib/infra-video-view.ts @@ -189,7 +189,7 @@ export function infraVideoEmbedCode( video.width > 0 && video.height > 0; const shape = measured ? `${video.width} / ${video.height}` : "16 / 9"; - return ``; + return ``; } export function toInfraVideoDetail( From 86f4b523e747606e761927ea7ac19243694ff0d2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 02:12:58 -0500 Subject: [PATCH 17/18] docs(web): say what the hosted player records --- apps/web/content/docs/api/rest-api.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index 2f1f3efabb3..2478cddc314 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -1455,6 +1455,8 @@ When the upload is already H.264 and fits the tier, Cap can keep its video as up Every video has a hosted player, returned as `shareUrl` and `embedUrl` when you create an upload and when you get or list a video. Both are built from the video's `playbackId`, need no API key or sign-in, set no cookies and are kept out of search engines. +The player sets no cookies. Each time a page with the player loads, Cap records one anonymous view with the page's address, so plays can be counted. + - `shareUrl` is a page with the player and the video's name. Send it to a viewer as a link. - `embedUrl` is the player alone, filling whatever frame you give it. Any site can load it in an iframe: From 4b9868257bb1928fd2e0207c0da8a2a7bf43da93 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:50:42 -0500 Subject: [PATCH 18/18] docs(web): mention Infra webhooks now that they have shipped --- apps/web/content/docs/introduction.mdx | 2 +- apps/web/public/llms-full.txt | 6 +++--- apps/web/public/llms.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/content/docs/introduction.mdx b/apps/web/content/docs/introduction.mdx index d54b1a3617a..6e68648ce4b 100644 --- a/apps/web/content/docs/introduction.mdx +++ b/apps/web/content/docs/introduction.mdx @@ -42,7 +42,7 @@ Cap is built for AI agents too. Codex, Claude Code, Cursor, OpenCode, or any she - **Embeds** - Embed your recordings in websites, documentation, and apps with a simple iframe snippet. -- **REST API** - Host video and transcribe speech in your own product with [Cap Infra](/docs/api/get-started), Cap's video and speech API. +- **REST API & Webhooks** - Host video and transcribe speech in your own product with [Cap Infra](/docs/api/get-started), Cap's video and speech API, and get a [webhook](/docs/api/webhooks) when a video is ready or has failed. ## Getting Started diff --git a/apps/web/public/llms-full.txt b/apps/web/public/llms-full.txt index 05a255dc31f..e29f1de10a6 100644 --- a/apps/web/public/llms-full.txt +++ b/apps/web/public/llms-full.txt @@ -23,7 +23,7 @@ Auth: Limits and polling: - A secret key allows 300 requests a minute across the REST endpoints, and all the keys of one account together 1,200 a minute. Each upload token allows 1,200 a minute across the upload endpoints. Requests with a rejected key or token are limited to 30 a minute per address. Responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. On a `429`, wait for the `Retry-After` seconds, then retry. -- There is no public webhook API. After an upload completes, poll `GET /api/developer/v1/videos/{id}/status` every 3 seconds until `status` is `ready` or `errored`; a short video is usually ready on the first check. A ready video can still have `hlsUrl` null for a moment while `mp4Url` plays. +- A webhook (`video.ready`, `video.errored`) can tell your server when a video is done instead: https://cap.so/docs/api/webhooks. Without one, after an upload completes, poll `GET /api/developer/v1/videos/{id}/status` every 3 seconds until `status` is `ready` or `errored`; a short video is usually ready on the first check. A ready video can still have `hlsUrl` null for a moment while `mp4Url` plays. - Transcripts: `POST /api/developer/v1/transcripts` with a public `url` or the `videoId` of a ready video the app stores, or `POST /api/developer/v1/transcripts/uploads`, PUT the file, then `POST /api/developer/v1/transcripts/{id}/start`. Poll `GET /api/developer/v1/transcripts/{id}` every 5 seconds until `status` is `complete` or `error`. For several at once, list them with `GET /api/developer/v1/transcripts?status=queued&limit=100`, then `status=processing`, instead of getting each one; one missing from both lists has finished. - Errors are JSON with `error`, `code`, `message`, `hint` and `docs` fields. Follow each error's `hint`. A 4xx will not succeed if repeated unchanged, except a `429`, a `409` with code `completion_in_progress`, and a `409` with code `video_not_ready` for a video that is still processing. @@ -175,7 +175,7 @@ Environment variables: `CAP_API_KEY` (headless/CI upload auth), `CAP_SERVER_URL` Canonical workflow: `cap doctor --json` → `cap targets --json` → `cap record start --screen --json` (or `--detach`) → `cap record stop --id --json` → `cap project validate --json` → `cap export --json` → `cap upload --json`. -Cap Infra, the video and speech API for your own product, is separate from the CLI: start with the guide at https://cap.so/docs/api/get-started, then see the reference at https://cap.so/docs/api/rest-api and the OpenAPI description at https://cap.so/openapi.json. It is paid for from prepaid credits, with optional auto top-up. Uploading and encoding are free. Stored video is charged daily by length and resolution tier: $0.85 (1080p), $1.45 (1440p) or $2.80 (2160p) per 1,000 minutes per 30 day month. Transcription costs $0.10 per audio hour. `GET /api/developer/v1/usage` returns these rates in `rates`, and `GET /api/developer/v1/transactions` returns every credit and charge. The billing rules (daily storage charge, transcript rounding and holds, top-ups from $5 to $1,000, codes, the Cap Pro monthly credit, auto top-up and what happens when credit runs out) are at https://cap.so/docs/api/billing. There is no public webhook subscription API: poll the video status endpoint every 3 seconds and a transcript every 5 seconds instead (https://cap.so/docs/api/webhooks). +Cap Infra, the video and speech API for your own product, is separate from the CLI: start with the guide at https://cap.so/docs/api/get-started, then see the reference at https://cap.so/docs/api/rest-api and the OpenAPI description at https://cap.so/openapi.json. It is paid for from prepaid credits, with optional auto top-up. Uploading and encoding are free. Stored video is charged daily by length and resolution tier: $0.85 (1080p), $1.45 (1440p) or $2.80 (2160p) per 1,000 minutes per 30 day month. Transcription costs $0.10 per audio hour. `GET /api/developer/v1/usage` returns these rates in `rates`, and `GET /api/developer/v1/transactions` returns every credit and charge. The billing rules (daily storage charge, transcript rounding and holds, top-ups from $5 to $1,000, codes, the Cap Pro monthly credit, auto top-up and what happens when credit runs out) are at https://cap.so/docs/api/billing. Webhooks tell your server when a video is ready or has failed (https://cap.so/docs/api/webhooks). Without one, poll the video status endpoint every 3 seconds, and poll a transcript every 5 seconds. ## Platforms @@ -296,4 +296,4 @@ Cap offers free browser tools at https://cap.so/tools: WebM→MP4, MOV→MP4, AV - **Does Cap work on Mac and Windows?** Yes — native apps for macOS (Apple Silicon & Intel) and Windows, plus a Linux .deb package and a browser viewer. - **Can I use my own storage?** Yes — connect your own Google Drive or any S3-compatible bucket, use Cap Cloud, or keep recordings entirely local. - **Does Cap record system audio and 4K?** Yes — system audio plus screen, camera, and mic, up to 4K at 60fps. -- **Is there an API/CLI?** Yes: a `cap` CLI built for AI agents (`--json`, `cap guide --json`) and Cap Infra, a video and speech REST API (https://cap.so/infra). There is no public webhook subscription API. +- **Is there an API/CLI?** Yes: a `cap` CLI built for AI agents (`--json`, `cap guide --json`) and Cap Infra, a video and speech REST API (https://cap.so/infra), with webhooks for finished videos. diff --git a/apps/web/public/llms.txt b/apps/web/public/llms.txt index 4cc835a1576..be633076217 100644 --- a/apps/web/public/llms.txt +++ b/apps/web/public/llms.txt @@ -21,7 +21,7 @@ Cap Infra is Cap's usage-billed API for building video and speech into your own - Hosted MCP server for agents: `https://cap.so/api/developer/mcp` (Streamable HTTP, `Authorization: Bearer `). Setup: https://cap.so/docs/api/get-started#10-use-with-agents-mcp - **Auth.** Send the app's secret key (`csk_`) as `Authorization: Bearer `, from server code only. It never goes into browser code. For an upload from the browser, the server calls `POST /api/developer/v1/uploads` and gives the browser the short-lived upload token (`cut_`) it returns, which works for that one video. A public `cpk_` key cannot create or upload videos. - **Rate limits.** A secret key allows 300 requests a minute across the REST endpoints, and all the keys of one account together 1,200 a minute. Each upload token allows 1,200 a minute across the upload endpoints. Requests with a rejected key or token are limited to 30 a minute per address. On a `429`, wait for the `Retry-After` seconds, then retry. -- **Polling.** There is no public webhook API. After an upload completes, poll `GET /api/developer/v1/videos/{id}/status` every 3 seconds until `status` is `ready` or `errored`; a short video is usually ready on the first check. Poll `GET /api/developer/v1/transcripts/{id}` every 5 seconds until `status` is `complete` or `error`. +- **Polling.** A webhook (`video.ready`, `video.errored`) can tell your server when a video is done instead: https://cap.so/docs/api/webhooks. Without one, after an upload completes, poll `GET /api/developer/v1/videos/{id}/status` every 3 seconds until `status` is `ready` or `errored`; a short video is usually ready on the first check. Transcripts have no webhook: poll `GET /api/developer/v1/transcripts/{id}` every 5 seconds until `status` is `complete` or `error`. - **Errors** are JSON with `error`, `code`, `message`, `hint` and `docs` fields. - **Docs as Markdown.** Every page under https://cap.so/docs is available as Markdown: add `.md` to its URL (for example https://cap.so/docs/api/get-started.md) or request it with `Accept: text/markdown`. @@ -62,7 +62,7 @@ Cap does not assign global recording shortcuts by default. Users configure suppo - Organization roles, members, spaces, folders, branding, viewer settings, and aggregate analytics. - Cap-managed storage, Google Drive for new uploads, and S3-compatible storage. Provider changes do not automatically move existing files. - Single Loom imports, organization CSV imports up to 500 rows per self-serve batch, and a durable per-video agent import workflow. -- A developer REST API for hosting video in your own product, with adaptive HLS and MP4 playback and a hosted player. There are no published SDK packages, and a public customer webhook subscription API is not currently documented. +- A developer REST API for hosting video in your own product, with adaptive HLS and MP4 playback and a hosted player. There are no published SDK packages. Webhooks tell your server when a video is ready or has failed. Captions, transcripts, titles, summaries, and chapters depend on the recording, processing state, plan, and effective organization settings. Do not promise that every recording has every AI field immediately.