autokap
Screenshots produced by your agent, from a demo world it builds and cannot leave.
3.x is a different thing from 1.x and 2.x. Those were the CLI of a hosted service, which is shut down. There is no account, no dashboard, no API. This is a set of agent skills and a small CLI that live in your repository. Nothing here talks to a server.
The invariant, before anything else
No write can reach a row outside the demo world.
That is not advice given to a model. It is a refusal in code. An agent holding a service key on a production database makes mistakes that are silent and final, so every write goes through an engine that checks, before touching the network:
- the table is declared in
captures/scope.mjs— anything else is refused; - every row is anchored on the demo account, directly or through a parent that is already proven to be ours;
- no column pointing at a user points at a real one;
- no row would be picked up by a cron, a worker or a trigger;
- the rows a change targets have been read back and proven ours — the filter is never trusted;
- the number of rows outside the world is counted before and after. One disappearing stops everything, with no attempt to correct it.
And nothing is written at all until a human has read, in plain words, what is about to change and why.
There is no reset, no truncate, no reseed, no raw SQL, and no route through the application's HTTP API — going through the routes fires assignment, notifications, analytics, emails and billing.
The engine is the only part of this repository with tests. A failed screenshot is visible. A failed write is not.
What it is
A methodology, shipped as agent skills, with the minimum amount of code that cannot be prose.
The problem it solves: producing a product's screenshots, reliably and repeatably, without doing them by hand. What makes that hard is not photographing a page — it is that a useful screenshot needs controlled data, and that data has to survive six months of code changes.
The demo world is the product. The capture scripts are only the visible part.
The doctrine
- A screenshot has an intent, written before the program. Without it there is no criterion for saying whether the image is any good.
- The intent becomes executable checks. The program verifies BEFORE it photographs. A green-but-empty screenshot is the most common and most expensive failure mode; it has to turn red.
- Replaying does not cost an agent session. The CLI replays everything and only reports what broke. The agent goes in there, and nowhere else.
- The world is declared, versioned, and shared between shots. Each shot declares what it depends on, so a change of data says which other shots to replay.
- No write can reach a row outside the demo world.
- A demo row is inert. No cron, worker, trigger, admin counter or billing run ever sees it.
- You look at the images. Assertions catch the empty ones. They do not catch the ugly ones. The final verdict is human, or agentic.
Install
Whichever path you take, the second step is always npx autokap init.
Installing the plugin drops a doctrine. It drops no perimeter, no inertia, no
adapter and no world. init is the real installation.
| Client | Path |
|---|---|
| Codex, ChatGPT, Cursor, Copilot, Kiro, VS Code | Agent Plugins — plugin.json at the root |
| Claude Code | /plugin marketplace add mangue-dev/autokap then /plugin install autokap |
| Claude Code (dev) | claude --plugin-dir ./autokap, then /reload-plugins |
| Any agent at all | npx autokap init --vendor — copies the skills into .claude/skills/, depends on no ecosystem |
| No agent | read this file, run the CLI by hand |
In Claude Code the skills are namespaced: /autokap:init, /autokap:world,
/autokap:shot.
Quickstart
npx autokap init # probes the repo, writes the scaffold and one starter shot
npm run dev # your app, in another terminal
npx autokap run home # → captures/shots/home/out/home-en-light.png
That first image needs no account, no seed, no key and no database access. It is the most important design constraint in this project: nothing may ask you for anything before it has given you a picture.
Everything below is for screens that need data.
# in your agent
/autokap # equips the project: perimeter, inertia, adapter, world
/autokap the full Aurora board, 4 columns, fr+en, light+dark
/autokap change hero-board: 5 columns and one urgent ticket visible
/autokap drop feedback-inbox
/autokap refresh
The skills need Playwright in your project:
npm i -D playwright && npx playwright install chromium
It stays a peer dependency so npx autokap itself stays light. sharp is
optional too: present, publish converts to WebP; absent, it copies the PNG.
What lands in your repository
captures/
├── config.mjs targets, viewport, theme, locale, auth
├── adapter.mjs how this project talks to its database
├── scope.mjs perimeter + inertia + entitlements
├── world/
│ ├── world.md the readable registry: who, what, where
│ └── seed/
│ ├── 001-….mjs idempotent, ordered
│ └── …
└── shots/
└── <name>/
├── intent.md what the image must show, and why
├── shot.mjs the program and its assertions
├── out/ the PNGs
└── history.jsonl one line per run
Everything is versioned except captures/.auth/ (browser sessions) and secrets,
which live in .env.
The PNGs are versioned on purpose. That is what lets you compare a new image to the old one, and know what the shot used to show.
The CLI
npx autokap init probe the repo, write config/adapter/scope/world
npx autokap run [name…] replay, assertions included — without an agent
npx autokap status who drifted, and since which commit
npx autokap drop <name> remove the folder, the published images, the manifest
npx autokap session refresh the demo session
npx autokap publish [name…] deliver the PNGs already produced, without recapturing
The border that holds everything up: the agent does the judgement (create, change, repair, look at an image), the CLI does the mechanics (replay, check, deliver). That is what makes this sustainable over time, and it is also what stops the repository growing back — anything that would need a service falls on the agent side, where it is free.
$ npx autokap run
✓ hero-board 4 variant(s)
✓ issue-plan 4 variant(s)
✗ cycle « 0 × "[data-card]", expected at least 8 »
✗ feedback-board « anchor "[data-testid=board]" not found after 15 s »
2 shot(s) to redo. /autokap refresh cycle feedback-board
$ npx autokap status
hero-board 9 file(s) touched since the last run (a1b2c3d) → replay
issue-plan up to date (a1b2c3d)
cycle never run
No guessing, a git diff. If nothing moved on a screen, it says so and does not
replay it — an identical screenshot does not deserve a run.
Writing a shot
export const SLOT = "heroBoard";
export const WORLD = ["project:aurora", "user:alice"];
export const WATCH = ["app/(app)/projects/**", "components/board/**"];
export default async function shot({ page, visit, settle, expectShot, capture }) {
await visit("/projects/aurora");
await settle(page, { anchor: '[data-testid="board"]' });
await expectShot(page, {
visible: ["text=AUR-1"],
atLeast: { "main [data-card]": 12 },
notClipped: "main h2",
absent: ['[role="dialog"]'],
});
await capture();
}
WORLD is what lets "put this data here instead" know which other shots to
replay. WATCH is what lets status tell whether this screen moved, without
launching a browser.
The wait is domcontentloaded → a semantic anchor proving the intended screen is
there → no loading indicator → fonts loaded. Never a fixed delay — if the
screen is not ready, the anchor is the lever. Never networkidle — it does
not converge on an app holding a realtime connection open, which is every modern
app. And the anchor must not depend on language, or one variant fails in silence.
Writing a seed
import { openWorld, createPlan } from "autokap/guard";
const world = await openWorld();
const plan = createPlan(world);
plan.insert("issues", rows, "the tickets on Aurora's board");
plan.update("issues", { id }, { status: "in_review" }, "one ticket in review");
plan.remove("issues", { id }, "one ticket too many, it unbalances the column");
console.log(plan.describe()); // shown to the user
const result = await plan.apply({ confirmed: true }); // only after a real answer
if (result.report) console.log(result.report); // rows outside the world that moved
confirmed: true is not a code formality. It is the trace of consent, and it is
written only after a clear answer in the conversation about that plan.
A child can only be inserted after its parent has been applied. An apply()
refreshes the world. Inserting a post and its votes in the same plan fails, and it
is meant to — that is what makes the checking possible.
The perimeter
export const IDENTITY = {
emailPattern: /^autokap-demo(\+[a-z0-9-]+)?@example\.com$/,
};
export const SCOPE = {
// THE ORDER IS A DEPENDENCY ORDER: a parent before its children.
projects: {
writable: true,
anchors: { owner: "owner_id" },
userRefs: ["owner_id"],
},
issues: {
writable: true,
anchors: {
owner: "created_by",
parents: [{ column: "project_id", table: "projects" }],
},
userRefs: ["created_by", "assignee_id"],
},
};
A table with no anchor is refused at declaration: with no anchor, nothing tells a demo row apart from a real one.
Inertia — the part nobody else does
A demo row is perfectly legitimate as far as the schema is concerned, and that is exactly the problem: a cron will claim it.
agent_runs: {
writable: true,
anchors: { parents: [{ column: "issue_id", table: "issues" }] },
inert: {
status: {
notIn: ["queued", "running"],
why: "the drain cron claims `queued`, and requeueStuckRuns restarts anything " +
"`running` for more than 6 minutes — the agent would actually run: " +
"sandbox, billed LLM calls, a write to a repository.",
},
},
},
why is mandatory and the engine refuses to load without it, because that
sentence is the only thing that will let anyone, in six months, decide whether the
constraint still holds.
Inertia is not derived from the schema. It is derived from the code — cron routes,
workers, triggers, background jobs. That is init's job, and it is only doable
because the skill lives in the repository.
Entitlements
An account on the free plan photographs paywalls. ENTITLEMENTS declares what the
demo account must own, and which lever the product already has for granting it —
never a simulated payment state.
export const ENTITLEMENTS = {
billing_accounts: {
columns: { admin_override_plan_id: "<the Pro plan id>" },
why: "the free plan puts agents and pull requests behind a plan gate; no Stripe row is created.",
},
};
Granting is a write like any other, so it takes the same road — the same description, the same consent, the same refusals:
import { openWorld, createPlan, planEntitlements } from "autokap/guard";
const granted = await planEntitlements(world, plan); // one step per right still missing
There is deliberately no applyEntitlements() that writes on its own: a second
write path is a second hole. It is idempotent, the table has to be in SCOPE and
writable, and if the account owns no row to grant the right on, the engine refuses
rather than inventing one.
Direct and indirect
Two modes producing exactly the same artefacts: same seeds, same registry, same perimeter. Only the executor changes.
| direct | indirect | |
|---|---|---|
| The agent has a service key | yes | no |
| Who writes | the engine | you, running the file |
plan.apply() | executes | refuses |
plan.emit() | — | writes world/seed/NNN-….sql for you to run |
| Re-read before modifying | yes | impossible |
| Blast radius | measured | not measurable |
| Demo account created by | adapter.createUser() | by hand — the agent says what to create, you paste back the id |
There is no third mode where the agent creates the data by driving the UI. It was tempting, and it is a trap: learning an unknown interface is expensive exploration that does not compound, while the schema is already in the repo.
In indirect mode the guard rails protect nothing — there is no hand to hold back. The safety comes from you reading the file. The engine still validates the plan before emitting: better to refuse to write a file than to hand you one to run.
The adapter
The agent writes the adapter, the engine applies it. About thirty lines,
produced at init by looking at how the repo already talks to its database —
Supabase, Prisma, Drizzle, pg, it makes no difference. The engine itself knows
nothing about any provider.
export const mode = "direct"; // "direct" | "indirect"
export async function select(table, { where, columns, limit }) // → rows
export async function count(table, { where }) // → number
export async function insert(table, rows) // → rows
export async function update(table, { where }, patch) // → rows
export async function remove(table, { where }) // → number
// Optional. Absent, the matching capability becomes manual.
export async function findUsers({ emailPattern })
export async function createUser({ email, password, fullName, confirmed })
export async function deleteUser(id)
// Indirect mode only: render the plan as something runnable.
export function emit(plan) // → string
where is a simple equality ({ id: "…" }) or { column: { in: [...] } }.
Nothing else. It is all a seed needs, and it keeps adapters trivial.
An adapter that exports anything outside this contract — a sql(), an rpc(), a
query() — is refused at load. An escape hatch that exists is an escape hatch
that gets used.
A Supabase reference implementation ships in
templates/adapter.supabase.mjs. It is not
"Supabase support": it is an example to copy and adapt.
Delivery
Optional, and deliberately minimal. There is no default destination — autokap
does not guess where your images belong. Set one in captures/config.mjs:
publish: { dir: "public/screenshots", format: "webp", displayWidth: { heroBoard: 1200 } }
- The filename is the contract:
<slot>-<locale>-<theme>.<ext>. Nothing else to declare; the consumer derives the URL. - The manifest is generated from disk. A missing variant renders a placeholder frame, never a broken image. Deleting a file is enough to remove it.
- 2× the display width, at minimum. One file serves every screen.
publishreports the density it actually delivered; under 2×, interface text is interpolated and it shows.
Non-goals, settled
- No video, no clips, no GIFs.
- No hosted service, no dashboard, no account, no signup.
- No CI mode. It would need a running app, a seeded world and secrets — that is the spiral that killed the hosted product.
- No visual diffing between runs, no visual regression.
- No device matrix.
And a governance non-goal: this is not a company and will not become one. Order of magnitude: about 1,300 lines of code, three skills, one README. Any feature request that grows that gets the same answer — write it in your own repo, your agent knows how.
Later, if somebody asks for it: native levers (iOS, Android, desktop), additional adapters shipped as templates.
Adapting the doctrine
npx autokap init --vendor copies the skills into your project. That is the
fallback path, and it is also the path for people who want to bend the doctrine to
their own product — which is legitimate. The skills are markdown; edit them.
Tests
npm test
One test per refusal, against an in-memory adapter, with a suite that tries to
escape: aiming at a table outside the list, pointing at a real user in a
userRefs, referencing an unproven parent, breaking an inertia constraint,
modifying a row without re-reading it, applying without confirmed.
That is the only tested part, and it is on purpose.
Licence
MIT.