diff --git a/README.md b/README.md
index 938b362..591968d 100644
--- a/README.md
+++ b/README.md
@@ -74,6 +74,9 @@ nobody hosts it yet: run it yourself, or hand over the `.flow` file or the brief
- **Frames.** A frame (from the library) is a named region behind the shapes inside it: dragging
it carries them along, in one undo step. From its menu, copy just that frame for AI or export it
on its own. The brief groups shapes by frame, and Mermaid draws frames as subgraphs.
+- **Draft with your agent.** Describe a diagram in words, and isketch writes a prompt that teaches
+ the `.flow` format: copy it, or open it straight in Claude or ChatGPT, then paste the answer back
+ and it is drawn, undoably. Your own agent does the drafting; isketch sends nothing anywhere.
- **Quick to type.** On a blank diagram, just start typing: the first shape takes the words.
Double-click empty canvas for a shape there. `Tab` adds the next step below the selected shape,
connected and ready to name, as in Whimsical. `Ctrl+K` finds any action or shape by name.
diff --git a/backlog/tasks/fl-90 - Diagram-from-words.md b/backlog/tasks/fl-90 - Diagram-from-words.md
index 355cc63..4010161 100644
--- a/backlog/tasks/fl-90 - Diagram-from-words.md
+++ b/backlog/tasks/fl-90 - Diagram-from-words.md
@@ -1,9 +1,11 @@
---
id: FL-90
title: Diagram from words
-status: To Do
-assignee: []
+status: In Progress
+assignee:
+ - '@raj-khan'
created_date: '2026-09-25 17:39'
+updated_date: '2026-09-27 14:40'
labels:
- ai
milestone: m-3
@@ -24,7 +26,37 @@ Describe a system in a sentence and get a .flow diagram, built on the text forma
-- [ ] #1 A Draft with your agent prompt, copyable from the app, that makes any agent return valid .flow
+- [x] #1 A Draft with your agent prompt, copyable from the app, that makes any agent return valid .flow
- [ ] #2 A built-in version is decided by the owner (cost of an API key or server)
+
+## Implementation Plan
+
+
+
+1. src/domain/draft.js: draftPrompt(description) teaches the .flow format (rules, every shape from the registry, a valid example) and asks for one fenced block; readDraft(answer) takes the first fenced block or the whole answer, with line errors; AGENT_LINKS opens the prompt filled in at Claude and ChatGPT.
+2. DraftDialog: describe, copy or open the prompt, paste the answer (live status), Use this diagram as one undoable change. In the menu and the command palette.
+3. The built-in version (AC #2) is the owners decision on cost; asked, not built.
+
+
+
+## Implementation Notes
+
+
+
+Verified: draft.spec.js (6: description, every shape and the rules in the prompt; its example parses; agent links; fenced and unfenced answers; errors by line; an answer with no shapes); e2e/draft.spec.js (copy, link, a wrong answer blocked with its line, a real-looking answer drawn, undo). Tried on a real model: claude -p --model haiku with the prompt for a URL shortener returned valid .flow at once (8 shapes, 7 connections, every part named). vitest and e2e green.
+
+
+## Comments
+
+
+
+author: @claude
+created: 2026-09-27 14:40
+---
+
+For the owner: a built-in "Describe and draw" needs either the persons own API key in the browser (no cost to you, a little setup for them) or a call through the isketch server with your key (simplest for them, costs you per diagram, needs rate limits). Which, if either?
+---
+
+
diff --git a/e2e/draft.spec.js b/e2e/draft.spec.js
new file mode 100644
index 0000000..403d4d6
--- /dev/null
+++ b/e2e/draft.spec.js
@@ -0,0 +1,64 @@
+import { expect, test } from '@playwright/test'
+
+import { fromMenu, history } from './helpers.js'
+
+const shapes = (page) => page.locator('.vue-flow__node')
+
+const ANSWER = `Here is your diagram:
+
+\`\`\`flow
+title: Checkout
+note: Failed payments retry
+
+web = screen "Web app" -- /checkout
+pay = process "Payments API"
+stripe = terminal "Stripe"
+db = database "Orders"
+
+web -> pay : HTTPS
+pay -> stripe : charge
+pay -> db : write order
+pay --> web : retry
+\`\`\`
+
+Tell me if you want the refunds flow too.`
+
+test('drafts a diagram with your agent: copy the prompt, paste the answer, undo', async ({
+ page,
+ context,
+}) => {
+ await context.grantPermissions(['clipboard-read', 'clipboard-write'])
+ await page.goto('/new')
+ await expect(shapes(page)).toHaveCount(5)
+
+ await fromMenu(page, 'Draft with your agent')
+ const dialog = page.getByRole('dialog', { name: 'Draft with your agent' })
+ const copy = dialog.getByRole('button', { name: 'Copy prompt' })
+ await expect(copy).toBeDisabled()
+
+ await dialog
+ .getByLabel('1. Describe the diagram')
+ .fill('A checkout that charges Stripe and writes orders.')
+ await copy.click()
+ const prompt = await page.evaluate(() => navigator.clipboard.readText())
+ expect(prompt).toContain('A checkout that charges Stripe and writes orders.')
+ expect(prompt).toContain('Reply with only the diagram')
+ await expect(dialog.getByRole('link', { name: 'Open in Claude' })).toHaveAttribute(
+ 'href',
+ /^https:\/\/claude\.ai\/new\?q=Draw%20this/,
+ )
+
+ const answer = dialog.getByLabel('2. Paste the answer')
+ await answer.fill('```flow\ntitle: X\na = blob "A"\n```')
+ await expect(dialog.getByRole('status')).toContainText('Line 2:')
+ await expect(dialog.getByRole('button', { name: 'Use this diagram' })).toBeDisabled()
+
+ await answer.fill(ANSWER)
+ await expect(dialog.getByRole('status')).toHaveText('4 shapes and 4 connections, ready.')
+ await dialog.getByRole('button', { name: 'Use this diagram' }).click()
+
+ await expect(shapes(page)).toHaveCount(4)
+ await expect(page.locator('.vue-flow__node[data-id="stripe"]')).toContainText('Stripe')
+ await history(page).getByRole('button', { name: 'Undo' }).click()
+ await expect(shapes(page)).toHaveCount(5)
+})
diff --git a/src/components/draft/DraftDialog.vue b/src/components/draft/DraftDialog.vue
new file mode 100644
index 0000000..d72ad1f
--- /dev/null
+++ b/src/components/draft/DraftDialog.vue
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+ The prompt teaches the agent the .flow format and asks for your diagram in it. Your own
+ Claude, ChatGPT or coding agent drafts it; isketch sends nothing anywhere.
+
+
+
diff --git a/src/components/shell/MainMenu.vue b/src/components/shell/MainMenu.vue
index 85b6e46..ff0bbdb 100644
--- a/src/components/shell/MainMenu.vue
+++ b/src/components/shell/MainMenu.vue
@@ -17,7 +17,7 @@ import { useToastStore } from '@/stores/toasts.js'
* The top-left island: the product, the open file, and a menu of everything
* that is not drawing. It owns Ctrl+O and Ctrl+S, bound once here.
*/
-const emit = defineEmits(['help', 'import', 'compare', 'export', 'commands'])
+const emit = defineEmits(['help', 'import', 'compare', 'export', 'commands', 'draft'])
const canvas = useCanvasStore()
const file = useFileStore()
@@ -52,6 +52,7 @@ const groups = computed(
{ label: 'Save', run: save, hint: comboLabel(COMBO.SAVE, isMac.value) },
],
[
+ { label: 'Draft with your agent', run: () => emit('draft') },
{ label: 'Import', run: () => emit('import') },
{ label: 'Export', run: () => emit('export') },
{ label: 'Compare', run: () => emit('compare') },
diff --git a/src/composables/useCommands.js b/src/composables/useCommands.js
index 2e24806..1f8136d 100644
--- a/src/composables/useCommands.js
+++ b/src/composables/useCommands.js
@@ -20,7 +20,7 @@ import { useToastStore } from '@/stores/toasts.js'
* the view's and one "Add" for each shape in the registry, so a new shape or
* tool shows up here without a line of its own.
*
- * @param {{ open: (dialog: 'import' | 'export' | 'compare' | 'share' | 'help') => void }} shell
+ * @param {{ open: (dialog: 'import' | 'export' | 'compare' | 'share' | 'help' | 'draft') => void }} shell
*/
export function useCommands(shell) {
const canvas = useCanvasStore()
@@ -66,6 +66,13 @@ export function useCommands(shell) {
},
{ id: 'open', label: 'Open file', group: 'File', hint: key(COMBO.OPEN), run: open },
{ id: 'save', label: 'Save', group: 'File', hint: key(COMBO.SAVE), run: save },
+ {
+ id: 'draft',
+ label: 'Draft with your agent',
+ group: 'File',
+ keywords: 'describe words ai generate claude chatgpt prompt',
+ run: () => shell.open('draft'),
+ },
{
id: 'import',
label: 'Import',
diff --git a/src/domain/__tests__/draft.spec.js b/src/domain/__tests__/draft.spec.js
new file mode 100644
index 0000000..30a5055
--- /dev/null
+++ b/src/domain/__tests__/draft.spec.js
@@ -0,0 +1,58 @@
+import { describe, expect, it } from 'vitest'
+
+import { AGENT_LINKS, draftPrompt, readDraft } from '../draft.js'
+import { parseFlow } from '../flowText.js'
+import { SHAPE_OPTIONS } from '../nodeMeta.js'
+
+describe('draftPrompt', () => {
+ const prompt = draftPrompt(
+ ' A checkout: the cart calls a payment API, which writes to an orders database. ',
+ )
+
+ it('carries the description, every shape and the rules that make valid .flow', () => {
+ expect(prompt).toContain(
+ 'What to draw:\nA checkout: the cart calls a payment API, which writes to an orders database.\n',
+ )
+ for (const option of SHAPE_OPTIONS) expect(prompt).toContain(`- \`${option.value}\`: `)
+ expect(prompt).toContain('Leave out any @layout block')
+ expect(prompt).toContain('Reply with only the diagram, in one ```flow code block.')
+ })
+
+ it('teaches with an example that is itself valid', () => {
+ const example = /```flow\n([\s\S]*?)```/.exec(prompt)[1]
+ const { document, errors } = parseFlow(example)
+ expect(errors).toEqual([])
+ expect(document.nodes).toHaveLength(4)
+ })
+
+ it('opens in Claude and ChatGPT, filled in', () => {
+ expect(AGENT_LINKS.map((link) => link.url('a b&c'))).toEqual([
+ 'https://claude.ai/new?q=a%20b%26c',
+ 'https://chatgpt.com/?q=a%20b%26c',
+ ])
+ })
+})
+
+describe('readDraft', () => {
+ it('reads the fenced block of an answer, and ignores the talk around it', () => {
+ const answer =
+ 'Here is the diagram:\n\n```flow\ntitle: Checkout\ncart = screen "Cart"\npay = process "Payments"\ncart -> pay : checkout\n```\n\nLet me know!'
+ const { document, errors } = readDraft(answer)
+ expect(errors).toEqual([])
+ expect(document.title).toBe('Checkout')
+ expect(document.edges).toHaveLength(1)
+ })
+
+ it('reads an answer with no fence, and says where one is wrong', () => {
+ expect(readDraft('a = process "A"').document.nodes).toHaveLength(1)
+ const wrong = readDraft('```\ntitle: X\na = blob "A"\n```')
+ expect(wrong.document).toBeNull()
+ expect(wrong.errors[0].line).toBe(2)
+ })
+
+ it('refuses an answer with no shapes in it', () => {
+ expect(readDraft('```flow\ntitle: Nothing\n```').errors).toEqual([
+ { line: 1, message: 'There are no shapes in this answer.' },
+ ])
+ })
+})
diff --git a/src/domain/draft.js b/src/domain/draft.js
new file mode 100644
index 0000000..870ee18
--- /dev/null
+++ b/src/domain/draft.js
@@ -0,0 +1,84 @@
+import { parseFlow } from './flowText.js'
+import { SHAPE_OPTIONS } from './nodeMeta.js'
+
+/**
+ * A diagram from words, with any agent: a prompt that teaches the `.flow`
+ * format and asks for one, and a reader for the answer. Nothing goes to a
+ * server of ours; the person takes the prompt to the agent they already use.
+ */
+
+/** Where a prompt can be opened already filled in. */
+export const AGENT_LINKS = Object.freeze([
+ {
+ id: 'claude',
+ label: 'Open in Claude',
+ url: (/** @type {string} */ prompt) => `https://claude.ai/new?q=${encodeURIComponent(prompt)}`,
+ },
+ {
+ id: 'chatgpt',
+ label: 'Open in ChatGPT',
+ url: (/** @type {string} */ prompt) => `https://chatgpt.com/?q=${encodeURIComponent(prompt)}`,
+ },
+])
+
+/**
+ * @param {string} description what the person wants drawn, in their words
+ * @returns {string}
+ */
+export function draftPrompt(description) {
+ const shapes = SHAPE_OPTIONS.map(
+ (option) => `- \`${option.value}\`: ${option.hint.toLowerCase()}`,
+ ).join('\n')
+ return `Draw this as an isketch diagram, in the .flow text format described below.
+
+What to draw:
+${description.trim()}
+
+The .flow format, one declaration per line:
+
+- \`title: ...\` first.
+- \`id = shape "Name" -- description\`: a shape. Ids are short, lowercase, letters, digits, _ and -. The description is optional and short.
+- \`a -> b : label\`: a connection, label optional. \`-->\` is dashed (optional or asynchronous), \`<->\` goes both ways.
+- \`note: ...\` under the title for an instruction to whoever builds it; \`id note: ...\` for one shape.
+- A \`table\` lists its columns in its description: \`id PK, email, user_id FK\`.
+- \`id = frame "Name"\` groups the shapes inside it, but only with positions; leave frames out here.
+- Leave out any @layout block: isketch lays the diagram out.
+
+Shapes:
+${shapes}
+
+Example:
+
+\`\`\`flow
+title: Shop
+note: Every write must be idempotent
+
+web = screen "Storefront" -- /shop
+api = process "API" -- REST
+paid = decision "Paid?"
+db = database "Orders"
+
+web -> api : HTTPS
+api -> paid
+paid -> db : yes
+paid --> web : no, retry
+\`\`\`
+
+Reply with only the diagram, in one \`\`\`flow code block. Use the shapes that fit best, name things as the description does, and include every part and connection it mentions.`
+}
+
+/**
+ * The diagram in an agent's answer: the first fenced block, or the whole
+ * answer when it has none.
+ * @param {string} answer
+ * @returns {{ document: import('./types.js').FlowDocument | null, errors: { line: number, message: string }[] }}
+ */
+export function readDraft(answer) {
+ const text = String(answer ?? '')
+ const fenced = /```[\w-]*\s*\n([\s\S]*?)```/.exec(text)
+ const { document, errors } = parseFlow((fenced?.[1] ?? text).trim())
+ if (document && !document.nodes.length) {
+ return { document: null, errors: [{ line: 1, message: 'There are no shapes in this answer.' }] }
+ }
+ return { document, errors }
+}
diff --git a/src/views/FlowView.vue b/src/views/FlowView.vue
index ac83f7c..3b5b4b7 100644
--- a/src/views/FlowView.vue
+++ b/src/views/FlowView.vue
@@ -14,6 +14,7 @@ import MainMenu from '@/components/shell/MainMenu.vue'
import ToolBar from '@/components/shell/ToolBar.vue'
import CommandPalette from '@/components/shell/CommandPalette.vue'
import FileConflictDialog from '@/components/shell/FileConflictDialog.vue'
+import DraftDialog from '@/components/draft/DraftDialog.vue'
import HelpDialog from '@/components/ui/HelpDialog.vue'
import IconButton from '@/components/ui/IconButton.vue'
import ToastHost from '@/components/ui/ToastHost.vue'
@@ -39,6 +40,7 @@ const isImporting = ref(false)
const isComparing = ref(false)
const isExporting = ref(false)
const isSharing = ref(false)
+const isDrafting = ref(false)
useOpenSharedLink()
useLaunch()
// An agent writing to the open file shows up here as it happens.
@@ -67,6 +69,7 @@ const commands = useCommands({
else if (dialog === 'export') isExporting.value = true
else if (dialog === 'compare') isComparing.value = true
else if (dialog === 'share') isSharing.value = true
+ else if (dialog === 'draft') isDrafting.value = true
else help.open()
},
})
@@ -155,6 +158,7 @@ onBeforeUnmount(() => window.removeEventListener('pointermove', onPointerMove))
@compare="isComparing = true"
@export="isExporting = true"
@commands="isPaletteOpen = true"
+ @draft="isDrafting = true"
/>
@@ -217,6 +221,7 @@ onBeforeUnmount(() => window.removeEventListener('pointermove', onPointerMove))
+