diff --git a/content/docs/flow/index.mdx b/content/docs/flow/index.mdx
new file mode 100644
index 0000000..54ccf5c
--- /dev/null
+++ b/content/docs/flow/index.mdx
@@ -0,0 +1,466 @@
+---
+title: Flow
+description: Reactive flow diagrams for Pyreon — signal-native nodes, edges, pan/zoom, auto-layout via elkjs.
+---
+
+`@pyreon/flow` provides reactive flow diagrams for Pyreon. Signal-native nodes and edges, pan/zoom without D3, auto-layout via elkjs, and per-node O(1) reactivity. Built from the ground up for signal-based frameworks.
+
+
+
+## Installation
+
+```package-install
+@pyreon/flow
+```
+
+## Quick Start
+
+```tsx
+import { createFlow, Flow, Background, MiniMap, Controls } from '@pyreon/flow'
+
+const flow = createFlow({
+ nodes: [
+ { id: '1', type: 'input', position: { x: 0, y: 0 }, data: { label: 'Start' } },
+ { id: '2', position: { x: 200, y: 100 }, data: { label: 'Process' } },
+ { id: '3', type: 'output', position: { x: 400, y: 0 }, data: { label: 'End' } },
+ ],
+ edges: [
+ { source: '1', target: '2' },
+ { source: '2', target: '3' },
+ ],
+})
+
+function WorkflowBuilder() {
+ return (
+
+
+
+
+
+ )
+}
+```
+
+No callbacks, no `applyNodeChanges`. The flow instance manages everything.
+
+## Creating a Flow
+
+`createFlow()` accepts a config object and returns a reactive `FlowInstance`:
+
+```tsx
+const flow = createFlow({
+ nodes: [...],
+ edges: [...],
+ snapToGrid: true,
+ snapGrid: 20,
+ connectionRules: { ... },
+ nodeExtent: { x: [0, 1000], y: [0, 800] },
+})
+```
+
+### Config Options
+
+| Option | Type | Default | Description |
+|---|---|---|---|
+| `nodes` | `FlowNode[]` | `[]` | Initial nodes |
+| `edges` | `FlowEdge[]` | `[]` | Initial edges |
+| `snapToGrid` | `boolean` | `false` | Snap node positions to grid |
+| `snapGrid` | `number` | `20` | Grid size in pixels |
+| `connectionRules` | `Record` | — | Connection validation rules by node type |
+| `nodeExtent` | `{ x: [min, max], y: [min, max] }` | — | Constrain node positions within bounds |
+| `minZoom` | `number` | `0.1` | Minimum zoom level |
+| `maxZoom` | `number` | `4` | Maximum zoom level |
+
+## Reactive Signals
+
+All state is exposed as reactive signals:
+
+```tsx
+flow.nodes() // Signal
+flow.edges() // Signal
+flow.viewport() // Signal — { x, y, zoom }
+flow.zoom() // Computed — just the zoom level
+flow.selectedNodes() // Computed
+flow.selectedEdges() // Computed
+```
+
+## Node Operations
+
+```tsx
+// Add a node
+flow.addNode({
+ id: '4',
+ position: { x: 300, y: 200 },
+ data: { label: 'New Node' },
+})
+
+// Remove a node (also removes connected edges)
+flow.removeNode('4')
+
+// Update node properties
+flow.updateNode('2', { data: { label: 'Updated' } })
+
+// Update position (respects snapToGrid and nodeExtent)
+flow.updateNodePosition('2', { x: 250, y: 150 })
+
+// Get a specific node
+const node = flow.getNode('2') // FlowNode | undefined
+```
+
+## Edge Operations
+
+```tsx
+// Add an edge (id auto-generated if not provided)
+flow.addEdge({ source: '1', target: '3' })
+
+// Add with type
+flow.addEdge({ source: '1', target: '3', type: 'smoothstep', label: 'yes' })
+
+// Remove an edge
+flow.removeEdge('e1-3')
+
+// Get a specific edge
+const edge = flow.getEdge('e1-3')
+
+// Duplicate edges are prevented automatically
+```
+
+### Edge Types
+
+Four built-in edge path algorithms:
+
+| Type | Description |
+|---|---|
+| `bezier` | Smooth cubic bezier curve (default) |
+| `smoothstep` | Right-angle path with rounded corners |
+| `step` | Right-angle path with sharp corners |
+| `straight` | Direct line between nodes |
+
+### Edge Waypoints
+
+Add bend points to edges:
+
+```tsx
+flow.addEdgeWaypoint('e1-2', { x: 150, y: 50 })
+flow.addEdgeWaypoint('e1-2', { x: 200, y: 75 }, 1) // at specific index
+flow.updateEdgeWaypoint('e1-2', 0, { x: 160, y: 60 })
+flow.removeEdgeWaypoint('e1-2', 0)
+```
+
+## Selection
+
+```tsx
+flow.selectNode('1') // select a node
+flow.selectNode('2', { additive: true }) // add to selection
+flow.selectEdge('e1-2') // select an edge
+flow.selectAll() // select all nodes
+flow.clearSelection() // deselect everything
+flow.deleteSelected() // remove selected nodes and edges
+flow.deselectNode('1') // remove from selection
+```
+
+## Viewport
+
+```tsx
+flow.zoomIn() // zoom in by 0.2
+flow.zoomOut() // zoom out by 0.2
+flow.zoomTo(1.5) // set exact zoom (clamped to min/max)
+flow.fitView() // fit all nodes in viewport
+flow.fitView(['1', '2']) // fit specific nodes
+flow.panTo({ x: 100, y: 200 }) // pan to position
+
+// Reactive zoom level
+flow.zoom() // Computed
+
+// Check if a node is visible
+flow.isNodeVisible('1') // boolean
+```
+
+## Auto-Layout
+
+Layout nodes automatically using elkjs (lazy-loaded — zero cost until called):
+
+```tsx
+// Layered layout (DAG/pipeline)
+await flow.layout('layered', { direction: 'RIGHT', spacing: 50 })
+
+// Tree layout
+await flow.layout('tree', { direction: 'DOWN', spacing: 40 })
+
+// Force-directed
+await flow.layout('force')
+
+// Available algorithms
+await flow.layout('stress')
+await flow.layout('radial')
+await flow.layout('box')
+```
+
+### Layout Options
+
+| Option | Type | Default | Description |
+|---|---|---|---|
+| `direction` | `'DOWN' \| 'RIGHT' \| 'UP' \| 'LEFT'` | `'DOWN'` | Layout direction |
+| `spacing` | `number` | `50` | Spacing between nodes |
+| `layerSpacing` | `number` | `spacing` | Spacing between layers |
+
+elkjs is loaded on demand — only imported when `flow.layout()` is first called.
+
+## Connection Rules
+
+Define type-safe rules for which node types can connect:
+
+```tsx
+const flow = createFlow({
+ nodes: [...],
+ edges: [...],
+ connectionRules: {
+ input: { allowedTargets: ['process'] },
+ process: { allowedTargets: ['process', 'output'] },
+ output: { allowedTargets: [] },
+ },
+})
+
+// Check if a connection is valid
+flow.isValidConnection({ source: '1', target: '2' }) // boolean
+```
+
+## Graph Queries
+
+```tsx
+// Get all edges connected to a node
+flow.getConnectedEdges('2') // FlowEdge[]
+
+// Get upstream nodes (nodes with edges pointing to this node)
+flow.getIncomers('2') // FlowNode[]
+
+// Get downstream nodes (nodes this node points to)
+flow.getOutgoers('2') // FlowNode[]
+```
+
+## Search and Filter
+
+```tsx
+// Find nodes by predicate
+flow.findNodes(n => n.type === 'process') // FlowNode[]
+
+// Search by label text (case-insensitive)
+flow.searchNodes('start') // FlowNode[]
+```
+
+## Undo / Redo
+
+```tsx
+flow.undo() // restore previous state
+flow.redo() // restore undone state
+```
+
+## Copy / Paste
+
+```tsx
+flow.copy() // copy selected nodes to clipboard
+flow.paste() // paste with offset, new IDs generated
+```
+
+## Collision Detection
+
+```tsx
+// Find nodes overlapping with a given node
+flow.getOverlappingNodes('2') // FlowNode[]
+
+// Resolve collisions — push overlapping nodes apart
+flow.resolveCollisions('2')
+```
+
+## Proximity Connect
+
+```tsx
+// Find nearest unconnected node within distance
+flow.findNearestNode('1', 200) // FlowNode | null
+```
+
+## Serialization
+
+```tsx
+// Export flow state as JSON
+const json = flow.toJSON()
+// { nodes: [...], edges: [...], viewport: { x, y, zoom } }
+
+// Import flow state
+flow.fromJSON(json)
+flow.fromJSON(json, { resetViewport: true })
+```
+
+## Listeners
+
+```tsx
+// Connection created
+flow.onConnect((edge) => {
+ console.log('Connected:', edge.source, '→', edge.target)
+})
+
+// Node changes (position, add, remove)
+flow.onNodesChange((change) => {
+ console.log(change.type, change.id)
+})
+
+// Click handlers
+flow.onNodeClick((nodeId) => { ... })
+flow.onEdgeClick((edgeId) => { ... })
+
+// All return unsubscribe functions
+const unsub = flow.onConnect(...)
+unsub()
+```
+
+## Batch Operations
+
+```tsx
+flow.batch(() => {
+ flow.addNode({ id: '10', position: { x: 0, y: 0 }, data: { label: 'A' } })
+ flow.addNode({ id: '11', position: { x: 200, y: 0 }, data: { label: 'B' } })
+ flow.addEdge({ source: '10', target: '11' })
+})
+// Single signal notification for all changes
+```
+
+## Components
+
+### ``
+
+The main container component:
+
+```tsx
+
+
+
+
+
+```
+
+### ``
+
+Decorative background pattern:
+
+```tsx
+
+
+
+```
+
+### ``
+
+Scaled overview with viewport indicator:
+
+```tsx
+ node.type === 'input' ? '#6366f1' : '#94a3b8'}
+ maskColor="rgba(0,0,0,0.2)"
+/>
+```
+
+### ``
+
+Zoom and fit controls:
+
+```tsx
+
+```
+
+### ``
+
+Connection points on nodes:
+
+```tsx
+import { Handle, Position } from '@pyreon/flow'
+
+function CustomNode({ data }) {
+ return (
+
+
+ {data.label}
+
+
+ )
+}
+```
+
+### ``
+
+Positioned overlay panels:
+
+```tsx
+
+
+
+
+
+
+```
+
+### ``
+
+Drag handles for resizing nodes:
+
+```tsx
+
+```
+
+### ``
+
+Floating toolbar that appears when a node is selected:
+
+```tsx
+
+
+
+
+```
+
+## Edge Path Utilities
+
+Pure functions for generating SVG paths:
+
+```tsx
+import {
+ getBezierPath,
+ getSmoothStepPath,
+ getStraightPath,
+ getStepPath,
+} from '@pyreon/flow'
+
+const [path, labelX, labelY] = getBezierPath({
+ sourceX: 0, sourceY: 0,
+ targetX: 200, targetY: 100,
+ sourcePosition: Position.Right,
+ targetPosition: Position.Left,
+})
+```
+
+## Position Enum
+
+```tsx
+import { Position } from '@pyreon/flow'
+
+Position.Top // 'top'
+Position.Right // 'right'
+Position.Bottom // 'bottom'
+Position.Left // 'left'
+```
+
+## Cleanup
+
+```tsx
+flow.dispose() // remove all listeners, clear state
+```
+
+## Comparison with React Flow
+
+| Feature | React Flow | @pyreon/flow |
+|---|---|---|
+| Update 1 of 1000 nodes | New array → diff all | 1 signal → 1 DOM update |
+| Bundle size | ~1.2MB (React + D3) | ~50KB + elkjs on demand |
+| State management | 3 callbacks + applyChanges | Automatic — zero boilerplate |
+| Auto-layout | Separate elkjs setup | `flow.layout('layered')` |
+| Undo/redo | DIY | Built-in |
+| Connection rules | `isValidConnection` callback | Declarative config |
diff --git a/content/docs/machine/index.mdx b/content/docs/machine/index.mdx
new file mode 100644
index 0000000..1410edd
--- /dev/null
+++ b/content/docs/machine/index.mdx
@@ -0,0 +1,425 @@
+---
+title: Machine
+description: Reactive state machines for Pyreon — constrained signals with type-safe transitions.
+---
+
+`@pyreon/machine` provides reactive state machines — constrained signals that can only hold specific values and transition between them via specific events. Replace nested booleans with explicit states and type-safe transitions.
+
+
+
+## Installation
+
+```package-install
+@pyreon/machine
+```
+
+## Quick Start
+
+```tsx
+import { createMachine } from '@pyreon/machine'
+
+const machine = createMachine({
+ initial: 'idle',
+ states: {
+ idle: { on: { FETCH: 'loading' } },
+ loading: { on: { SUCCESS: 'done', ERROR: 'error' } },
+ done: {},
+ error: { on: { RETRY: 'loading' } },
+ },
+})
+
+machine() // 'idle' — reads like a signal
+machine.send('FETCH')
+machine() // 'loading'
+```
+
+## Why State Machines?
+
+State machines prevent impossible states. Compare:
+
+```tsx
+// ❌ Nested booleans — 16 possible combinations, most invalid
+const isLoading = signal(false)
+const isError = signal(false)
+const isSuccess = signal(false)
+const isOpen = signal(false)
+// What does isLoading=true + isSuccess=true mean? 🤷
+
+// ✅ State machine — only valid states exist
+const dialog = createMachine({
+ initial: 'closed',
+ states: {
+ closed: { on: { OPEN: 'confirming' } },
+ confirming: { on: { CONFIRM: 'loading', CANCEL: 'closed' } },
+ loading: { on: { SUCCESS: 'success', ERROR: 'error' } },
+ success: { on: { CLOSE: 'closed' } },
+ error: { on: { RETRY: 'loading', CLOSE: 'closed' } },
+ },
+})
+```
+
+## Reading State
+
+The machine instance is callable — it reads like a signal and is reactive in effects, computeds, and JSX:
+
+```tsx
+const machine = createMachine({
+ initial: 'idle',
+ states: {
+ idle: { on: { START: 'running' } },
+ running: { on: { STOP: 'idle', PAUSE: 'paused' } },
+ paused: { on: { RESUME: 'running', STOP: 'idle' } },
+ },
+})
+
+// Read current state
+machine() // 'idle'
+
+// Reactive in JSX
+function StatusBadge() {
+ return {() => machine()}
+}
+```
+
+## Sending Events
+
+Transition between states by sending events:
+
+```tsx
+machine.send('START') // idle → running
+machine.send('PAUSE') // running → paused
+machine.send('RESUME') // paused → running
+machine.send('STOP') // running → idle
+
+// With payload
+machine.send('SELECT', { id: 42 })
+
+// Invalid events are silently ignored
+machine.send('PAUSE') // ignored when in 'idle' — no transition defined
+```
+
+## Guards
+
+Use guards for conditional transitions:
+
+```tsx
+const form = createMachine({
+ initial: 'editing',
+ states: {
+ editing: {
+ on: {
+ SUBMIT: { target: 'submitting', guard: () => isValid() },
+ SAVE_DRAFT: 'saving',
+ },
+ },
+ submitting: { on: { SUCCESS: 'done', ERROR: 'editing' } },
+ saving: { on: { SAVED: 'editing' } },
+ done: {},
+ },
+})
+
+// SUBMIT only transitions if guard returns true
+form.send('SUBMIT') // ignored if isValid() is false
+
+// Guards can also receive the event payload
+const transfer = createMachine({
+ initial: 'idle',
+ states: {
+ idle: {
+ on: {
+ SEND: { target: 'confirming', guard: (payload) => payload.amount > 0 },
+ },
+ },
+ confirming: { on: { CONFIRM: 'done', CANCEL: 'idle' } },
+ done: {},
+ },
+})
+
+transfer.send('SEND', { amount: 100 }) // guard passes → confirming
+transfer.send('SEND', { amount: 0 }) // guard fails → stays idle
+```
+
+## Checking State
+
+### `matches()`
+
+Check if the machine is in one or more states — reactive in JSX and effects:
+
+```tsx
+machine.matches('loading') // true if in 'loading'
+machine.matches('success', 'error') // true if in either
+
+// Reactive rendering
+function App() {
+ return () => {
+ if (machine.matches('idle'))
+ return
+ if (machine.matches('loading'))
+ return
+ if (machine.matches('error'))
+ return machine.send('RETRY')} />
+ if (machine.matches('done'))
+ return
+ return null
+ }
+}
+```
+
+### `can()`
+
+Check if an event would trigger a valid transition from the current state:
+
+```tsx
+machine.can('FETCH') // true if FETCH is defined in current state's transitions
+
+// Disable buttons for invalid actions
+
+```
+
+### `nextEvents()`
+
+Get all available events from the current state:
+
+```tsx
+machine.nextEvents() // ['FETCH', 'RESET'] — depends on current state
+
+// Useful for command palettes or help dialogs
+const availableActions = machine.nextEvents()
+```
+
+## Side Effects with `onEnter`
+
+Fire a callback when the machine enters a specific state:
+
+```tsx
+const fetchMachine = createMachine({
+ initial: 'idle',
+ states: {
+ idle: { on: { FETCH: 'loading' } },
+ loading: { on: { SUCCESS: 'done', ERROR: 'error' } },
+ done: {},
+ error: { on: { RETRY: 'loading' } },
+ },
+})
+
+const data = signal(null)
+const error = signal(null)
+
+// Side effect — fetch when entering 'loading'
+fetchMachine.onEnter('loading', async () => {
+ try {
+ const result = await fetch('/api/data').then(r => r.json())
+ data.set(result)
+ fetchMachine.send('SUCCESS')
+ } catch (e) {
+ error.set(e)
+ fetchMachine.send('ERROR')
+ }
+})
+```
+
+`onEnter` returns an unsubscribe function:
+
+```tsx
+const unsub = machine.onEnter('loading', () => { ... })
+unsub() // remove the listener
+```
+
+## Transition Listener
+
+React to any transition:
+
+```tsx
+machine.onTransition((from, to, event) => {
+ console.log(`${from} → ${to} via ${event.type}`)
+ analytics.track('state_change', { from, to, event: event.type })
+})
+```
+
+## Reset
+
+Return to the initial state:
+
+```tsx
+machine.reset() // back to 'idle' (or whatever initial was)
+```
+
+## Cleanup
+
+Remove all listeners:
+
+```tsx
+machine.dispose() // clears all onEnter and onTransition listeners
+```
+
+## Type Safety
+
+States and events are inferred from the definition — no manual type annotations needed:
+
+```tsx
+const machine = createMachine({
+ initial: 'idle',
+ states: {
+ idle: { on: { FETCH: 'loading', RESET: 'idle' } },
+ loading: { on: { SUCCESS: 'done', ERROR: 'error' } },
+ done: {},
+ error: { on: { RETRY: 'loading' } },
+ },
+})
+
+machine() // type: 'idle' | 'loading' | 'done' | 'error'
+machine.send('FETCH') // ✓ valid event
+machine.send('FLY') // TS error — not a valid event
+machine.matches('idle') // ✓ valid state
+machine.matches('x') // TS error — not a valid state
+```
+
+## Real-World Patterns
+
+### Multi-Step Wizard
+
+```tsx
+const wizard = createMachine({
+ initial: 'step1',
+ states: {
+ step1: { on: { NEXT: 'step2' } },
+ step2: { on: { NEXT: 'step3', BACK: 'step1' } },
+ step3: { on: { SUBMIT: 'submitting', BACK: 'step2' } },
+ submitting: { on: { SUCCESS: 'done', ERROR: 'step3' } },
+ done: {},
+ },
+})
+
+const formData = signal({ name: '', email: '' })
+
+wizard.onEnter('submitting', async () => {
+ try {
+ await submitData(formData())
+ wizard.send('SUCCESS')
+ } catch {
+ wizard.send('ERROR')
+ }
+})
+
+function WizardUI() {
+ return () => {
+ if (wizard.matches('step1'))
+ return wizard.send('NEXT')} />
+ if (wizard.matches('step2'))
+ return wizard.send('NEXT')} onBack={() => wizard.send('BACK')} />
+ if (wizard.matches('step3'))
+ return wizard.send('SUBMIT')} onBack={() => wizard.send('BACK')} />
+ if (wizard.matches('submitting'))
+ return
+ if (wizard.matches('done'))
+ return
+ return null
+ }
+}
+```
+
+### Auth Flow
+
+```tsx
+const auth = createMachine({
+ initial: 'idle',
+ states: {
+ idle: { on: { LOGIN: 'authenticating' } },
+ authenticating: { on: { SUCCESS: 'authenticated', ERROR: 'idle' } },
+ authenticated: { on: { LOGOUT: 'idle' } },
+ },
+})
+
+const user = signal(null)
+
+auth.onEnter('authenticating', async (event) => {
+ try {
+ const result = await login(event.payload.email, event.payload.password)
+ user.set(result)
+ auth.send('SUCCESS')
+ } catch {
+ auth.send('ERROR')
+ }
+})
+
+auth.onEnter('idle', () => user.set(null))
+```
+
+### File Upload
+
+```tsx
+const upload = createMachine({
+ initial: 'idle',
+ states: {
+ idle: { on: { SELECT: 'selected' } },
+ selected: { on: { UPLOAD: 'uploading', CANCEL: 'idle' } },
+ uploading: { on: { PROGRESS: 'uploading', SUCCESS: 'done', ERROR: 'error' } },
+ done: { on: { RESET: 'idle' } },
+ error: { on: { RETRY: 'uploading', CANCEL: 'idle' } },
+ },
+})
+
+const progress = signal(0)
+const file = signal(null)
+```
+
+## Data Alongside Machines
+
+Machines manage transitions, signals manage data. They compose naturally:
+
+```tsx
+// ✅ Signals for data, machine for state
+const count = signal(0)
+const error = signal(null)
+
+const machine = createMachine({
+ initial: 'idle',
+ states: {
+ idle: { on: { INCREMENT: 'idle', SUBMIT: 'submitting' } },
+ submitting: { on: { SUCCESS: 'done', ERROR: 'idle' } },
+ done: {},
+ },
+})
+
+machine.onEnter('idle', (event) => {
+ if (event.type === 'INCREMENT') count.update(n => n + 1)
+})
+```
+
+## API Reference
+
+### `createMachine(config)`
+
+| Property | Type | Description |
+|---|---|---|
+| `config.initial` | `string` | Initial state |
+| `config.states` | `Record` | State definitions with transitions |
+
+### `Machine` instance
+
+| Method | Returns | Description |
+|---|---|---|
+| `machine()` | `TState` | Read current state (reactive) |
+| `machine.send(event, payload?)` | `void` | Send event to trigger transition |
+| `machine.matches(...states)` | `boolean` | Check if in any of the given states (reactive) |
+| `machine.can(event)` | `boolean` | Check if event would trigger a transition |
+| `machine.nextEvents()` | `TEvent[]` | Available events from current state |
+| `machine.reset()` | `void` | Return to initial state |
+| `machine.onEnter(state, callback)` | `() => void` | Fire callback on state entry, returns unsubscribe |
+| `machine.onTransition(callback)` | `() => void` | Fire on any transition, returns unsubscribe |
+| `machine.dispose()` | `void` | Remove all listeners |
+
+### `StateConfig`
+
+```ts
+interface StateConfig {
+ on?: Record>
+}
+
+interface TransitionConfig {
+ target: TState
+ guard?: (payload?: unknown) => boolean
+}
+```