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), 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/__tests__/unit/docs-markdown.test.ts b/apps/web/__tests__/unit/docs-markdown.test.ts new file mode 100644 index 00000000000..b278ee5ad8d --- /dev/null +++ b/apps/web/__tests__/unit/docs-markdown.test.ts @@ -0,0 +1,156 @@ +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/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 { 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("/") }), + }); + +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/infra-agent-prompt.test.ts b/apps/web/__tests__/unit/infra-agent-prompt.test.ts index bf20bd1c6c8..713d19baf87 100644 --- a/apps/web/__tests__/unit/infra-agent-prompt.test.ts +++ b/apps/web/__tests__/unit/infra-agent-prompt.test.ts @@ -61,7 +61,12 @@ 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("Retry-After"); expect(CAP_INFRA_AGENT_PROMPT).toContain( `${DEVELOPER_KEY_REQUESTS_PER_MINUTE} requests a minute`, diff --git a/apps/web/__tests__/unit/proxy-infra-path.test.ts b/apps/web/__tests__/unit/proxy-infra-path.test.ts index 7ba7994e2d3..9a1d65e392e 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: "/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/docs-markdown/[...slug]/route.ts b/apps/web/app/docs-markdown/[...slug]/route.ts new file mode 100644 index 00000000000..93425840c9e --- /dev/null +++ b/apps/web/app/docs-markdown/[...slug]/route.ts @@ -0,0 +1,23 @@ +import { markdownHeaders, markdownNotFoundBody } from "@/lib/agent-markdown"; +import { docToMarkdown } from "@/lib/docs-markdown"; +import { getDocBySlug } from "@/utils/docs"; + +export async function GET( + request: Request, + props: RouteContext<"/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/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 39fe7aec3da..3ab271653ba 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", @@ -353,7 +354,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 bbefad8fa1f..d22aca1e2ae 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 300 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 300 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 @@ -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 @@ -807,7 +785,9 @@ 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`. + +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 845b97bc597..a3e76b8f689 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -566,7 +566,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 300 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 300 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. @@ -624,7 +624,9 @@ 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 300 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 300 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=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. @@ -862,7 +864,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. @@ -1480,6 +1482,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: @@ -1550,7 +1554,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 300 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/introduction.mdx b/apps/web/content/docs/introduction.mdx index 50f781acd5b..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 & Webhooks** - Integrate Cap into your workflow programmatically with a full API and real-time webhook notifications. +- **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/data/infra-agent-prompt.ts b/apps/web/data/infra-agent-prompt.ts index e048b24a621..25f8679ac4c 100644 --- a/apps/web/data/infra-agent-prompt.ts +++ b/apps/web/data/infra-agent-prompt.ts @@ -1,4 +1,8 @@ -import { DEVELOPER_KEY_REQUESTS_PER_MINUTE } from "@/lib/developer-rate-limits"; +import { + DEVELOPER_KEY_REQUESTS_PER_MINUTE, + DEVELOPER_TRANSCRIPT_POLL_SECONDS, + DEVELOPER_VIDEO_STATUS_POLL_SECONDS, +} from "@/lib/developer-rate-limits"; import { INFRA_MCP_PATH } from "@/lib/infra-mcp-path"; export const CAP_INFRA_POINTER_PROMPT = @@ -32,9 +36,9 @@ 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. +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. 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. 10. Use the MCP server if you want tools. Cap Infra has a hosted MCP server at ${CAP_INFRA_MCP_URL} that takes the same secret key as Authorization: Bearer. Its requests count toward the same limit. diff --git a/apps/web/lib/agent-markdown.ts b/apps/web/lib/agent-markdown.ts index 0c90947d4d1..44d855cad67 100644 --- a/apps/web/lib/agent-markdown.ts +++ b/apps/web/lib/agent-markdown.ts @@ -28,7 +28,24 @@ export const prefersMarkdown = (accept: string | null) => { return markdown > 0 && markdown >= html; }; -const markdownHeaders = (cacheControl: string) => ({ +export const DOCS_MARKDOWN_ROUTE = "/docs-markdown"; + +const MARKDOWN_EXTENSION = /\.mdx?$/; + +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/developer-openapi.ts b/apps/web/lib/developer-openapi.ts index 4eac42d79cc..f19dd6b727e 100644 --- a/apps/web/lib/developer-openapi.ts +++ b/apps/web/lib/developer-openapi.ts @@ -8,7 +8,9 @@ import { DEVELOPER_KEY_REQUESTS_PER_MINUTE, DEVELOPER_OWNER_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, @@ -542,7 +544,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.", "", @@ -698,8 +702,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], @@ -762,8 +765,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 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.", + 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], @@ -834,8 +836,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: [ @@ -1319,7 +1320,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"], diff --git a/apps/web/lib/developer-rate-limits.ts b/apps/web/lib/developer-rate-limits.ts index 5da9b83a798..4647cd94324 100644 --- a/apps/web/lib/developer-rate-limits.ts +++ b/apps/web/lib/developer-rate-limits.ts @@ -11,3 +11,9 @@ export const DEVELOPER_AUTH_FAILURES_PER_MINUTE = 30; // 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. +export const DEVELOPER_VIDEO_STATUS_POLL_SECONDS = 3; +export const DEVELOPER_TRANSCRIPT_POLL_SECONDS = 5; diff --git a/apps/web/lib/docs-markdown.ts b/apps/web/lib/docs-markdown.ts new file mode 100644 index 00000000000..de0e2f55af4 --- /dev/null +++ b/apps/web/lib/docs-markdown.ts @@ -0,0 +1,80 @@ +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}/`); + +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 a981d61155d..9b63e051e6c 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"], + "/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, { diff --git a/apps/web/public/llms-full.txt b/apps/web/public/llms-full.txt index f1a479742c3..e29f1de10a6 100644 --- a/apps/web/public/llms-full.txt +++ b/apps/web/public/llms-full.txt @@ -4,6 +4,31 @@ 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 +- 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. +- 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 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. +- 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. + +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 +134,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 +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`. -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. 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 @@ -199,8 +224,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 +263,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 +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 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), with webhooks for finished videos. diff --git a/apps/web/public/llms.txt b/apps/web/public/llms.txt index a7a34ef1c29..be633076217 100644 --- a/apps/web/public/llms.txt +++ b/apps/web/public/llms.txt @@ -8,6 +8,23 @@ 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 +- 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.** 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`. + ## When to Use Cap Reach for Cap when a task involves one of these jobs: @@ -16,7 +33,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 +41,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 @@ -45,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. @@ -61,9 +78,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 +102,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