kaviri

kaviri as an MCP server

kaviri mcp is the op protocol as a Model Context Protocol server on stdio. An agent in Claude Code, Claude Desktop, Cursor or any other MCP client can open a page, find out what is on it, film it and look at the result, without writing a file or reading NDJSON off a pipe.

It is the same recorder. Each interactive tool is one op, run through the same session as kaviri serve, against one browser that stays open across calls. The whole-take tool runs kaviri record itself. A script filmed through MCP and the same script filmed on the command line are the same take.

Everything runs on your machine. There is no account, no hosted service and nothing to pay for: the browser and ffmpeg are the ones kaviri doctor finds.

Install

Build kaviri (see the README; the MCP server needs Rust 1.88 or newer, because the official Rust SDK, rmcp, does), put the binary on your PATH, then:

claude mcp add kaviri -- kaviri mcp

Or check it into a project as .mcp.json, so everyone working on the repo gets it:

{
  "mcpServers": {
    "kaviri": {
      "command": "kaviri",
      "args": ["mcp"],
      "env": { "KAVIRI_TAKES_DIR": "demos/takes" }
    }
  }
}

Any client that launches a stdio server takes the same command and arguments. kaviri mcp accepts the browser flags serve does (--chromium, --spool-dir, --keep-temp and the rest) as defaults for the browser it opens; --port is refused, because MCP here is stdio only, and so is --out, because each take names its own file (out on start and record_demo, or $KAVIRI_TAKES_DIR).

Takes land in kaviri-takes/ under the directory the client started the server in, which for a coding agent is the project. $KAVIRI_TAKES_DIR moves them. Add the directory to .gitignore; kaviri's own does.

Two ways to film

A whole take, in the background. Write the script as JSONL, exactly as for kaviri record, and call record_demo. It answers at once with a take id. Poll get_take until the status is done or failed, then call preview. Use this for anything longer than a few steps: a client gives up on a single tool call after a minute or two, and a take under --slowmo 8 can run for ten.

Step by step. open, navigate, inspect to find the selectors, start, the interactions, stop. The browser stays open between calls, and capture keeps running while the agent thinks, so the take is one continuous video rather than a set of clips.

The tools

tool what it does
open launch (or relaunch) the browser: url, preset, width, height, scale, frame, frame_style, background, cursor, slowmo, audio
navigate the navigate op
inspect the visible actionable elements: role, accessible name, a unique CSS selector, box
start start_recording; returns the take id and the MP4 path (out to choose it)
click, type, hover, press, scroll, wait, mark the op of the same name, same fields
say, voice narration at this moment, and a recorded clip; see audio.md
frame the frame op; before start
stop stop_recording; renders, returns path, duration, zoom_events, marks, take id
close close the browser, rendering any open take first
record_demo script_jsonl plus optional preset, frame, frame_style, background, slowmo, audio, the sound fields below, out; returns a take id
get_take status (queued, running, done, failed), path, duration, zoom_events, marks; no id lists every take
preview a contact sheet of keyframes and a poster, as images

open and record_demo also take the soundtrack, as the flags of the same name: tts, voice, voiceover, voiceover_gain, music, music_volume, music_fade, music_loop, duck, page_gain, narration_gain, loudness, captions, caption_style. get_take reports the take's soundtrack.

Every tool answers with the same envelope the NDJSON protocol uses, as the text of the result: {"ok":true,"result":{…}}, or {"ok":false,"error":"…"} with the result marked as an error. The fields of each op are documented in the script protocol. A tool called before open opens a browser with the flags the server was started with.

open takes the same values as the command-line flags and runs them through the same parser, so a bad preset is refused with the list of presets. Settings belong to the browser: to film in a different shape, call open again.

Takes run one at a time. A record_demo sent while another is filming is queued until the first finishes. The interactive browser is separate, so close it before a long record_demo if the machine is short of memory.

inspect

examples/demo.html under --preset readme:

{"ok":true,"result":{"event":"inspect","url":"file:///…/demo.html","viewport":[1100,620],
 "count":3,"elements":[
  {"role":"textbox","name":"Full name","selector":"#name","box":[360.0,258.5,380.0,39.0],"in_view":true},
  {"role":"textbox","name":"Email","selector":"#email","box":[360.0,333.5,380.0,39.0],"in_view":true},
  {"role":"button","name":"Sign up","selector":"#submit","box":[360.0,394.5,380.0,39.0],"in_view":true}]}}

The list comes from the browser's accessibility tree, so a <div role="button"> is a button and an input is named by its <label>. The selector is the shortest one that matches exactly that element, preferring an id, a test id, name, aria-label or placeholder over a structural path. The box is in CSS pixels, the coordinates every mark carries. covered names an element sitting on top of the target's centre, which click would refuse. Elements scrolled out of view are counted in offscreen and listed with "all": true; a script can still reach them, because click scrolls first. limit caps the list (default 150).

inspect is an op as well, so {"op":"inspect"} works in kaviri serve too.

preview

An agent cannot watch an MP4. preview takes a frame just after each interaction landed (when the camera has settled on it), tops a sparse take up to six with evenly spaced moments, thins a busy one to frames (default 9), and tiles them into one JPEG. The second image is the poster: the moment the last interaction landed, which is usually the payoff. Both are written beside the MP4 as <take>.sheet.jpg and <take>.poster.jpg.

Look at the sheet before handing a take on. A tile that shows the wide shot where you expected a close-up, or the wrong part of the page, means the script needs another look.

What it costs

Nothing. The server, the browser and ffmpeg all run locally, and kaviri makes no network request of its own. The only pages it loads are the ones you send it to.


This page is generated from docs/MCP.md in the repository, so it cannot drift from the code it documents without somebody noticing.