Skip to content

mangue-dev/autokap

v3.0.0MIT

Screenshots produced by your agent, from a demo world it builds and cannot leave.

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

  1. A screenshot has an intent, written before the program. Without it there is no criterion for saying whether the image is any good.
  2. 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.
  3. Replaying does not cost an agent session. The CLI replays everything and only reports what broke. The agent goes in there, and nowhere else.
  4. 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.
  5. No write can reach a row outside the demo world.
  6. A demo row is inert. No cron, worker, trigger, admin counter or billing run ever sees it.
  7. 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.

ClientPath
Codex, ChatGPT, Cursor, Copilot, Kiro, VS CodeAgent 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 allnpx autokap init --vendor — copies the skills into .claude/skills/, depends on no ecosystem
No agentread 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.

directindirect
The agent has a service keyyesno
Who writesthe engineyou, running the file
plan.apply()executesrefuses
plan.emit()writes world/seed/NNN-….sql for you to run
Re-read before modifyingyesimpossible
Blast radiusmeasurednot measurable
Demo account created byadapter.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. publish reports 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.