Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ tests the previous build.
`VITE_ISKETCH_API` names a server.
- Per shape behaviour lives in `src/domain/nodeMeta.js`, and each outline in
`src/domain/shapes.js`. Add an entry there rather than branching on type in a component.
- `plugin/server/isketch-mcp.mjs` is a bundle of `src/mcp/server.js` and its `src/domain/`
dependencies, for the Claude Code plugin. After changing either, run `npm run plugin` and
commit the rebuilt bundle; CI fails if it is stale.
- State has three owners and no copies: TanStack Query owns the document, the route
owns which node is open, Pinia owns viewport, history, theme and toasts. Do not
mirror one in another.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Claude, Copilot or any coding agent reads without guessing, and can edit back.
[![CI](https://github.com/raj-khan/flow/actions/workflows/ci.yml/badge.svg)](https://github.com/raj-khan/flow/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-ff5a2c.svg)](LICENSE)

![Sketching in isketch, Copy for AI, and an agent building from the brief](docs/loop.gif)

https://github.com/user-attachments/assets/cbfbc844-7373-4891-a985-50e2870fe1b5

</div>
Expand Down
23 changes: 21 additions & 2 deletions backlog/tasks/fl-91 - Launch-kit.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
---
id: FL-91
title: Launch kit
status: To Do
status: In Progress
assignee:
- '@raj-khan'
created_date: '2026-09-25 17:39'
updated_date: '2026-09-27 14:52'
labels:
- launch
milestone: m-4
Expand All @@ -31,6 +32,24 @@ Mostly the owner's to do, listed so it is not forgotten.
- [ ] #1 A 60 second demo video (made with idemo.video) and a README hero GIF of the loop
- [ ] #2 Posts: Show HN, Product Hunt, r/ClaudeAI, r/ChatGPTCoding, dev.to, X
- [ ] #3 Listings: awesome-mcp-servers, awesome-claude-code, AlternativeTo against Excalidraw, draw.io and Eraser
- [ ] #4 An optional, removable Made with isketch mark on exported PNGs and hosted pages
- [x] #4 An optional, removable Made with isketch mark on exported PNGs and hosted pages

<!-- AC:END -->

## Implementation Plan

<!-- SECTION:PLAN:BEGIN -->

1. What lives in the repo, done here: a README hero GIF of the loop (recorded with the clip, encoded in the browser with gifenc, since there is no ffmpeg), the landing clip re-recorded with the current UI, and an optional Made with isketch mark on exported pictures (on by default, one checkbox to take it off, remembered) and hosted pages.
2. What is the owners: the 60 second demo video (idemo.video), the posts and the listings; drafted ready to post in docs/launch.md, with the registry and marketplace steps in docs/listings.md.

<!-- SECTION:PLAN:END -->

## Implementation Notes

<!-- SECTION:NOTES:BEGIN -->

Done in the repo: docs/loop.gif (92 frames, 3.1 MB, 720px) from npm run clip, which also re-records public/demo.webm; renderSvg credit option; Export checkbox; hosted page footer credit. Verified: renderSvg spec, export e2e (mark on by default, off when unticked and remembered), server test for the page footer; vitest 312, e2e 146, server 16.

Left for the owner: AC #1 needs the idemo.video demo (the GIF half is done); AC #2 posts and AC #3 listings are drafted in docs/launch.md and docs/listings.md and need the owners accounts, after isketch.online is live.
<!-- SECTION:NOTES:END -->
90 changes: 90 additions & 0 deletions docs/launch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Launch kit

Drafts to post, in the owner's voice. Each points at the README, whose hero GIF shows the loop.
Post after isketch.online serves the app and the hosted server, so every link works, and after the
60 second demo video (made with idemo.video) is uploaded to the README.

## Show HN

**Title:** Show HN: isketch, a sketchpad your coding agent reads exactly

**Text:**

I kept sketching architectures in Excalidraw, screenshotting them, and pasting the picture into
Claude. The agent then guessed at boxes and arrows from pixels: misread names, lost arrow
directions, and nothing it wrote back could go on the diagram.

isketch is a sketchpad where every sketch is also plain text, a `.flow` file with ids, kinds,
directions and notes. Copy for AI puts a Markdown brief on the clipboard; an MCP server lets
Claude Code or any agent list, read, validate, render and edit the diagrams in your repo; and when
the agent writes the file, the open canvas shows the change as it happens.

It imports draw.io, Excalidraw, Mermaid, docker-compose, OpenAPI, SQL, Prisma and Drizzle,
`isketch scan` drafts a diagram of a whole repository, and pull requests get a visual diff. It works
offline, needs no account, and is MIT licensed.

https://github.com/raj-khan/flow

## Product Hunt

- **Tagline:** Sketch it, hand it to your agent
- **Description:** A sketchpad whose output is agent-ready: every diagram is also text that Claude,
Copilot or any coding agent reads exactly and edits back. Hand-drawn look, draw.io and
Excalidraw import, an MCP server, and a Claude Code plugin.
- **First comment:** why screenshots fail agents (names misread, directions lost, nothing to write
back), the loop in one line, and one ask: which import should come next.

## r/ClaudeAI

**Title:** I stopped pasting diagram screenshots into Claude. Now it reads and edits the diagram itself.

**Body:** the loop (sketch, Copy for AI or the MCP server, Claude builds, Claude updates the
`.flow` file, the canvas shows it live), the plugin install lines, and a short clip. Ask what
people would want Claude to do with a diagram next.

```text
/plugin marketplace add raj-khan/flow
/plugin install isketch@isketch
```

## r/ChatGPTCoding

**Title:** Diagrams your coding agent can read: text, not pixels

**Body:** the same loop, told for any agent: Draft with your agent (describe, paste the answer),
Copy for AI, and the `.flow` file an agent edits in the repo. Lead with the before and after of a
screenshot against a brief.

## dev.to

**Title:** Stop screenshotting your Excalidraw for Claude

**Outline:**

1. The screenshot habit, and three ways it fails an agent.
2. What an agent needs instead: ids, kinds, directions, notes, as text.
3. The `.flow` format in ten lines.
4. The loop in practice: sketch, brief, build, the agent keeps the diagram true.
5. Bringing existing diagrams along: draw.io, Excalidraw, Mermaid, schemas, `isketch scan`.
6. Where it goes next.

## X

1. Pasting a diagram screenshot into Claude makes it guess. isketch makes every sketch text it
reads exactly. (GIF)
2. Copy for AI: a Markdown brief with every shape, what it means, and every connection in words.
3. Or skip the clipboard: the MCP server lets Claude Code read, write and render your diagrams,
and the open canvas shows its edits live.
4. Bring what you have: draw.io, Excalidraw, Mermaid, compose, OpenAPI, SQL, Prisma, Drizzle.
5. MIT, offline, no account: github.com/raj-khan/flow

## Listings

Ready to paste; the MCP registry and marketplaces are in [listings.md](listings.md).

- **awesome-mcp-servers:** `- [raj-khan/flow](https://github.com/raj-khan/flow) - isketch: read,
write, validate, render and diff .flow architecture diagrams an agent reads exactly.`
- **awesome-claude-code:** under plugins, `isketch: the .flow diagram format as a skill, with an MCP
server to read, write and render diagrams in your repo.`
- **AlternativeTo:** list isketch as an alternative to Excalidraw, draw.io and Eraser, with the
tagline and the GIF; tags: diagrams, whiteboard, AI, developer tools.
Binary file added docs/loop.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions e2e/export.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,16 @@ test('downloads a sketch as SVG, carrying its handwriting font', async ({ page }
expect(svg).toMatch(/^<svg xmlns="http:\/\/www.w3.org\/2000\/svg"/)
expect(svg).toContain("@font-face{font-family:'Patrick Hand';src:url(data:font/woff2;base64,")
})

test('marks a picture Made with isketch, unless that is taken off, which is remembered', async ({
page,
}) => {
expect((await exportAs(page, 'SVG')).bytes.toString()).toContain('Made with isketch')

await fromMenu(page, 'Export')
const dialog = page.getByRole('dialog', { name: 'Export' })
await dialog.getByLabel(/Made with isketch/).uncheck()
await page.keyboard.press('Escape')

expect((await exportAs(page, 'SVG')).bytes.toString()).not.toContain('Made with isketch')
})
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@vue/test-utils": "^2.5.1",
"eslint": "^10.11.0",
"eslint-plugin-vue": "^10.11.0",
"gifenc": "^1.0.3",
"happy-dom": "^20.14.5",
"husky": "^9.1.7",
"lint-staged": "^17.5.1",
Expand Down
8 changes: 5 additions & 3 deletions plugin/server/isketch-mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3191,12 +3191,13 @@ const GLYPH = .56;
* lays them out.
*
* @param {import('./types.js').FlowDocument} document
* @param {{ theme?: 'light' | 'dark', padding?: number, highlight?: Map<string, 'added' | 'removed' | 'changed'>, sketchFont?: string }} [options]
* @param {{ theme?: 'light' | 'dark', padding?: number, highlight?: Map<string, 'added' | 'removed' | 'changed'>, sketchFont?: string, credit?: boolean }} [options]
* `highlight` marks nodes and edges by id, for a diff; `sketchFont` is the handwriting
* font as a data URL, embedded in a sketch so it looks the same wherever it opens
* font as a data URL, embedded in a sketch so it looks the same wherever it opens;
* `credit` adds a small "Made with isketch" in the bottom right corner, linked
* @returns {string}
*/
function renderSvg(document, { theme = "light", padding = 32, highlight = /* @__PURE__ */ new Map(), sketchFont = "" } = {}) {
function renderSvg(document, { theme = "light", padding = 32, highlight = /* @__PURE__ */ new Map(), sketchFont = "", credit = false } = {}) {
const colours = SVG_THEMES[theme] ?? SVG_THEMES.light;
const nodes = document.nodes.map(normaliseNode);
const ids = new Set(nodes.map((node) => node.id));
Expand Down Expand Up @@ -3234,6 +3235,7 @@ function renderSvg(document, { theme = "light", padding = 32, highlight = /* @__
...sizes.get(edge.target)
}, colours, highlight.get(edge.id), sketch, document.lines)),
...nodes.filter((node) => node.type !== SHAPE.FRAME).map((node) => renderNode(node, at.get(node.id), colours, highlight.get(node.id), sketch)),
credit ? `<a href="https://isketch.online"><text x="${round(left + width - 10)}" y="${round(top + height - 10)}" font-size="11" fill="${colours.muted}" text-anchor="end">Made with isketch</text></a>` : "",
"</svg>"
].filter(Boolean).join("\n")}\n`;
}
Expand Down
Binary file modified public/demo.webm
Binary file not shown.
86 changes: 78 additions & 8 deletions scripts/make-demo-clip.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
* Records the landing clip: the real app sketching, Copy for AI, then an
* agent's answer building from the brief. One take, scripted, against the
* production build on a local server; the webm is committed as
* public/demo.webm. Run with `npm run clip`.
* public/demo.webm. The same take, as screenshots, becomes docs/loop.gif for
* the README, encoded in the browser with gifenc. Run with `npm run clip`.
*/
import { spawn } from 'node:child_process'
import { cp, mkdtemp, readdir, rm, readFile } from 'node:fs/promises'
import { cp, mkdtemp, readdir, rm, readFile, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

Expand Down Expand Up @@ -33,6 +35,18 @@ try {
// 1 · Sketch: the starter diagram, a shape moved, a title renamed in place.
await page.goto(`http://localhost:${PORT}/flow`)
await nodeAt(page, 'b6a0c1').waitFor({ state: 'visible' })
// The README's GIF: a screenshot every few frames, with when it was taken.
/** @type {{ at: number, jpeg: string }[]} */
const frames = []
let filming = true
const film = (async () => {
while (filming) {
const jpeg = await page.screenshot({ type: 'jpeg', quality: 85 }).catch(() => null)
if (jpeg) frames.push({ at: Date.now(), jpeg: jpeg.toString('base64') })
await pause(90)
}
})()

await pause(1200)

const card = nodeAt(page, 'b6a0c1')
Expand All @@ -47,6 +61,8 @@ try {
await pause(400)
await page.keyboard.type('Support bot', { delay: 45 })
await page.keyboard.press('Enter')
// The click that began the rename opened the details too; the take is about the canvas.
await page.keyboard.press('Escape')
await pause(1000)

// 2 · Hand it over: the brief goes onto the clipboard.
Expand All @@ -60,6 +76,9 @@ try {
await page.locator('.clip-code').waitFor({ state: 'visible' })
await pause(2800)

filming = false
await film
await writeGif(frames)
await context.close()
} finally {
await browser.close()
Expand All @@ -75,6 +94,54 @@ if (webm) {
}
await rm(take, { recursive: true, force: true })

/**
* The frames as a looping GIF, 720px wide: decoded and quantised in the
* browser (Node has no image decoder here), each shown for as long as it was
* on screen.
* @param {{ at: number, jpeg: string }[]} frames
*/
async function writeGif(frames) {
const encoder = await readFile(
createRequire(import.meta.url).resolve('gifenc/dist/gifenc.esm.js'),
'utf8',
)
const page = await browser.newPage()
await page.goto(`http://localhost:${PORT}/docs/format/`)
const bytes = await page.evaluate(
async ({ code, frames }) => {
const url = URL.createObjectURL(new Blob([code], { type: 'text/javascript' }))
const { GIFEncoder, quantize, applyPalette } = await import(url)
const width = 720
const height = 405
const canvas = new globalThis.OffscreenCanvas(width, height)
const context = canvas.getContext('2d', { willReadFrequently: true })
const gif = GIFEncoder()
for (let index = 0; index < frames.length; index++) {
const image = await globalThis.createImageBitmap(
await (await fetch(`data:image/jpeg;base64,${frames[index].jpeg}`)).blob(),
)
context.drawImage(image, 0, 0, width, height)
const { data } = context.getImageData(0, 0, width, height)
const palette = quantize(data, 256)
const next = frames[index + 1]?.at ?? frames[index].at + 1500
gif.writeFrame(applyPalette(data, palette), width, height, {
palette,
delay: Math.max(20, next - frames[index].at),
})
}
gif.finish()
return Array.from(gif.bytes())
},
{ code: encoder, frames },
)
await page.close()
const gif = new URL('../docs/loop.gif', import.meta.url).pathname
await writeFile(gif, Buffer.from(bytes))
console.log(
`docs/loop.gif (${frames.length} frames, ${(bytes.length / 1024 / 1024).toFixed(1)} MB)`,
)
}

/** @param {string} url */
async function waitUntil(url) {
for (let attempt = 0; attempt < 60; attempt++) {
Expand Down Expand Up @@ -127,14 +194,17 @@ function addChat() {
chat.innerHTML = `
<div class="who">You</div>
<div>Build this. Here is the diagram as a brief:</div>
<pre>**Support bot** → **Escalation** …
<pre>**Business Hours** → **Support bot**: Failure
**Support bot** → **Add Comment #1** …

The brief itself stays on your clipboard —
in the real thing you paste all of it here.</pre>
(In the real thing, the whole brief is pasted here.)</pre>
<div class="who" style="margin-top:12px">Claude</div>
<div><span class="dots">Scaffolding the services the sketch names</span></div>
<pre class="clip-code">const escalation = await page.getByRole('button', { name: 'Away' })
// support-bot/escalation.ts — from the sketch, not a guess</pre>
<div><span class="dots">Building the steps the sketch names</span></div>
<pre class="clip-code">// support-bot.ts: named from the sketch, not guessed
export async function handle(conversation) {
if (!isBusinessHours()) return supportBot(conversation)
return welcomeMessage(conversation)
}</pre>
`
document.body.append(chat)
setTimeout(() => chat.classList.add('typing'), 2400)
Expand Down
2 changes: 1 addition & 1 deletion server/src/diagrams/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function renderPage(input: {
<h2>Brief</h2>
<p>For people and AI agents alike: every shape, what it is for, every connection, and the source.</p>
<pre>${escape(brief)}</pre>
<footer>Updated ${escape(updatedAt.toISOString())}. Anyone with this link can read it.</footer>
<footer>Updated ${escape(updatedAt.toISOString())}. Anyone with this link can read it. Made with <a href="${escape(new URL(openUrl).origin)}">isketch</a>.</footer>
</main>
</body>
</html>
Expand Down
1 change: 1 addition & 0 deletions server/test/diagrams.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ describe('reading a link', () => {
assert.match(html, /<title>Shop · isketch<\/title>/)
assert.match(html, /\*\*API\*\* → \*\*Orders\*\*: SQL/)
assert.match(html, /href="https:\/\/app\.isketch\.test\/flow#flow=z[\w-]+"/)
assert.match(html, /Made with <a href="https:\/\/app\.isketch\.test">isketch<\/a>/)
assert.equal(page.headers.get('x-robots-tag'), 'noindex')
})

Expand Down
1 change: 1 addition & 0 deletions src/api/storageKeys.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const STORAGE_KEYS = Object.freeze({
THEME: 'flow:theme',
SNAP: 'flow:snap',
MINIMAP: 'flow:minimap',
CREDIT: 'flow:credit',
PUBLISHED: 'flow:published',
})

Expand Down
Loading
Loading