Status line for which .nendo files are open, and a band showing the planner's Now lane

<img src="docs/assets/brand/nendo.png" alt="Nendo" width="420">
Malleable software in a single file. One portable SQLite file starts empty and is shaped — by people and by coding agents — into a working application: schema, data, forms, boards, record pages and revision history. No generated project. No build step. No regeneration when you want a change.
Nendo (粘土) is Japanese for clay.
Experimental. Nendo is a research prototype exploring whether malleable software works, not a product. It runs as an unsigned per-user Windows x64 install. Read State before you rely on anything here.
thomasrohde.github.io/nendo — the concept, how it works, how to use it, the honest status, and this repository's documentation rendered as a site.
<img src="docs/reviews/2026-09-12-mcp-vocabulary/production-board-dark.png" alt="A Nendo board surface grouped by a choice field, with column totals, in the dark theme" width="860">
Captured from the 2026-09-12 review build (0.18.0 is current), with the rest of that review in <a href="docs/reviews/README.md">docs/reviews/</a>.</sub></p>
The installed host always provides Nendo Studio, a high-quality database explorer and editor. Custom surfaces add focused experiences on top, but never become the only route to the data — if a surface breaks, the data is still there, and still editable.
Create empty file
→ add or import schema and data
→ work in the default Studio table
→ ask an agent to create or reshape a form, board or record page
→ review the semantic diff
→ accept, observe, and compensate where the operation supports it
Agents connect over a local MCP interface. They never get SQL, a database path or filesystem access — only typed semantic operations. Their changes are validated on a physical clone and previewed as a readable diff; accepting replays the exact validated operations against your file. Nothing an agent gets wrong can reach your data before you say yes.
The MVP loop works end to end, delivered as a local unsigned per-user Windows x64 install. Four reference applications falsify the hypothesis from different shapes — Idea Garden, Decision Log, the Axiom Register and Nendo Station — and the last two were built from an empty file through the MCP interface alone.
Public distribution, signing, ARM64, cloud sync and cross-platform support are not qualified, and the independent human evaluation has not been run. The roadmap states each gap plainly.
Windows x64 only. Nendo.Engine is net10.0 and builds anywhere, but the Desktop host is WinUI 3 with WebView2.
| Tool | Version | Install |
|---|---|---|
| .NET SDK | Pinned in global.json | winget install Microsoft.DotNet.SDK.10 |
| Node.js with npm | The engines range in src/Nendo.Workbench/package.json | winget install OpenJS.NodeJS.LTS |
| PowerShell 7 | pwsh; every script in tools/ assumes it | winget install Microsoft.PowerShell |
| Git | Any current version | winget install Git.Git |
| WebView2 Evergreen runtime | Already present on Windows 11 | winget install Microsoft.EdgeWebView2Runtime |
| NSIS | Only to build the installer; makensis on PATH | winget install NSIS.NSIS, or the NuGet route below |
Open a new shell after installing so PATH picks the tools up.
Where Group Policy disables winget, or only an internal package mirror is an approved source, NSIS is also published on NuGet as the portable package NSIS-Tool (a third-party repackaging; its makensis.exe is not Authenticode-signed, the package carries NuGet.org's repository signature). Restore it through your mirror and put it on PATH for the build session only; nothing is installed. The steps are in architecture.md. The Windows App SDK is bundled into the build output (self-contained), so it needs no separate runtime. global.json rolls forward to the latest feature band, so a newer 10.0 SDK that is already installed wins the pin.
cd src/Nendo.Workbench; npm ci; npm run build; cd ../..
dotnet build Nendo.slnx
./artifacts/bin/Nendo.Desktop/debug_win-x64/Nendo.Desktop.exe
Do not skip the Workbench step. The Desktop project copies src/Nendo.Workbench/dist into its output but does not build it, so dotnet build on a fresh clone succeeds and produces a host with no interface to show. All build output goes under artifacts/, which is git-ignored.
Started with no argument, the app opens without a file; create one from there. Pass the path of a .nendo file to open it instead. The files in workspace/ are tracked demos: open a copy, not the original.
pwsh ./tools/Test-Repository.ps1 # fast invariant check
pwsh ./tools/Test-Production.ps1 # full gate; includes the above
Test-Production.ps1 does the whole build itself (npm ci, Workbench type check, tests and build, then .NET restore, build and tests), so on a fresh clone it is also the one-command build. It takes about a minute once packages are restored. Some Desktop tests open real windows and use the clipboard, so run the gate in an unlocked, interactive desktop session. On a locked workstation or a headless session those tests fail without any product defect.
Packaging, the installer and the native review lanes outside the gate are documented in architecture.md.
While a file is open with Agent access on, Nendo listens on a loopback port that the file keeps on this computer. The first file you switch access on for keeps 41763, and each further file keeps the next free port. The address is the whole client configuration — there is no credential to find or paste:
claude mcp add --transport http nendo http://127.0.0.1:41763/mcp
codex mcp add nendo --url http://127.0.0.1:41763/mcp
Agent → Connection shows the address a file uses and copies either command.
The registrations checked into this repository (.mcp.json and .codex/config.toml) point at port 41766. That is the port the author's planner file keeps on their machine, not a default, so on a fresh machine they connect to nothing. To use them, set Port for this file to 41766 in Agent → Connection for the file you want an agent in this checkout to reach; otherwise register the address that panel shows.
Anything running on this computer can connect at the chosen access level, so leave access Off when no agent is working.
Codex reads AGENTS.md; CLAUDE.md imports that same file for Claude Code. Both use Nendo Development as the primary work planner through the repository's existing MCP registrations. Read the live work item and its acceptance criteria before implementation, and record outcomes and remaining work at handoff. Accepted ADRs retain architecture authority.
The planner file is the author's own data and is not in a clone. Without it there is no live work item to read: follow the planner-unavailable rule in AGENTS.md, work from the repository instructions and the task in hand, and do not create a replacement planner.
A curated, pinned set of first-party .NET agent skills is vendored under .agents/skills/ — use the matching skill rather than guessing current SDK, template, MSBuild or test behaviour.
| Path | What is in it |
|---|---|
src/Nendo.Engine | The typed core: storage, semantic operations, diff, behaviour. The only code that touches SQLite |
src/Nendo.Desktop | The WinUI 3 host: window, native shell integration, serving custom views from the open file |
src/Nendo.Workbench | The renderer — TypeScript and Vite, bundled into the Desktop output |
src/Nendo.LocalMcp | The loopback MCP server: tools, resources, leases and access modes |
tests/ | MSTest suites for the Engine, the Desktop host and the MCP adapter |
tools/ | Build, packaging, gate and review scripts (PowerShell and Node) |
extensions/ | Source for the four example custom-view packages; a file carries a package's code once it is imported |
fixtures/ | Reference-application seed data |
workspace/ | Tracked .nendo demo files |
docs/ | Vision, architecture, ADRs, contracts and reviews |
site/ | The public website (ADR-0018) — Astro, outside the product boundary |
CONTRIBUTING.md has the loop, the gate to run and the house rules. SECURITY.md states the trust boundary and how to report a vulnerability.
MIT. Vendored third-party material retains its own notices and licences.
hooks/register.tsx 228 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Planner, Snapshot, WorkItem } from '../types'
4
5const SNAPSHOT = { plugin: 'nendo-planner', key: 'snapshot' } as const
6
7const PLANNER = 'Planner.nendo'
8const EVERY_MS = 60_000
9const SHOWN = 3
10const CLOSED = new Set(['Done', 'Dropped'])
11
12type $ = EngineInterface
13
14// One stateless JSON-RPC call on the 2026-07-28 discover path, as tools/Nendo-McpClient.mjs makes it.
15// Deliberately not an import of that client (W-159): a hook runs on the hook engine's $.http.fetch,
16// which returns a whole response, while the client is built on Node's fetch and AbortSignal; the
17// two cannot share a transport, and a listen stream (W-151) is out of this engine's reach.
18async function rpc($: $, endpoint: string, method: string, params: Record<string, unknown>) {
19 const name = (params.name ?? params.uri) as string | undefined
20 const response = await $.http.fetch(endpoint, {
21 method: 'POST',
22 headers: {
23 Accept: 'application/json, text/event-stream',
24 'Content-Type': 'application/json',
25 'MCP-Protocol-Version': '2026-07-28',
26 'Mcp-Method': method,
27 ...(name ? { 'Mcp-Name': name } : {}),
28 },
29 body: JSON.stringify({
30 jsonrpc: '2.0',
31 id: 1,
32 method,
33 params: {
34 ...params,
35 _meta: {
36 'io.modelcontextprotocol/protocolVersion': '2026-07-28',
37 'io.modelcontextprotocol/clientCapabilities': {},
38 'io.modelcontextprotocol/clientInfo': { name: 'nendo-planner-mod', version: '0.1.0' },
39 },
40 },
41 }),
42 })
43 if (!response.ok) throw Error(`${method}: HTTP ${response.status}`)
44 const messages = (response.headers['content-type'] ?? '').includes('text/event-stream')
45 ? response.text.split(/\r?\n/).filter(l => l.startsWith('data:')).map(l => JSON.parse(l.slice(5)))
46 : [JSON.parse(response.text)]
47 const reply = messages.find(m => m.id === 1)
48 if (!reply || reply.error) throw Error(`${method}: ${reply?.error?.message ?? 'no reply'}`)
49 return reply.result
50}
51
52async function openFiles($: $, workspace: string): Promise<string[]> {
53 const entries = await $.fs.list(workspace).catch(() => [])
54 return entries
55 .map(entry => entry.name)
56 .filter(name => name.endsWith('.nendo.write-owner'))
57 .map(name => name.slice(0, -'.write-owner'.length))
58 .sort((a, b) => (a === PLANNER ? -1 : b === PLANNER ? 1 : a.localeCompare(b)))
59}
60
61async function plannerEndpoint($: $): Promise<string | null> {
62 const local = await $.env.get('LOCALAPPDATA')
63 if (!local) return null
64 const folder = `${local}/Nendo/Mcp/active`
65 const entries = await $.fs.list(folder).catch(() => [])
66 let best: { endpoint: string; createdAt: string } | null = null
67 for (const entry of entries) {
68 if (!entry.name.endsWith('.json')) continue
69 const found = await $.fs.read(`${folder}/${entry.name}`).then(t => JSON.parse(t as string)).catch(() => null)
70 if (found?.displayName !== PLANNER || typeof found.endpoint !== 'string') continue
71 if (!best || found.createdAt > best.createdAt) best = found
72 }
73 return best?.endpoint ?? null
74}
75
76async function readPlanner($: $, endpoint: string): Promise<Planner> {
77 const lease = await rpc($, endpoint, 'tools/call', { name: 'nendo.lease.status', arguments: {} })
78 const holder = lease.structuredContent?.hasLease ? lease.structuredContent.clientDisplayName ?? 'someone' : null
79
80 const records: any[] = []
81 let cursor: string | null = null
82 do {
83 const uri = 'nendo://application/entity/nd.work/records?'
84 + (cursor ? `cursor=${encodeURIComponent(cursor)}&` : '') + 'limit=100'
85 const result = await rpc($, endpoint, 'resources/read', { uri })
86 const page = JSON.parse(result.contents[0].text)
87 records.push(...page.items)
88 cursor = page.nextCursor ?? null
89 } while (cursor)
90
91 const open = records.filter(r => !CLOSED.has(r.values['nd.work.status']))
92 const lane = (horizon: string): WorkItem[] => open
93 .filter(r => r.values['nd.work.horizon'] === horizon)
94 .sort((a, b) => (a.values['nd.work.order'] ?? 0) - (b.values['nd.work.order'] ?? 0))
95 .map(r => ({
96 ref: r.values['nd.work.ref'] ?? '?',
97 title: r.values['nd.work.title'] ?? '',
98 status: r.values['nd.work.status'] ?? '',
99 isBlocked: r.calculations?.find((c: any) => c.fieldId === 'nd.work.isBlocked')?.value === true,
100 }))
101
102 return {
103 now: lane('Now'),
104 next: lane('Next'),
105 inbox: open.filter(r => r.values['nd.work.status'] === 'Inbox').length,
106 leaseHolder: holder,
107 }
108}
109
110function statusLine(s: Snapshot): string {
111 if (s.open.length === 0) return 'Nendo: nothing open'
112 const names = s.open.map(n => n.replace(/\.nendo$/, '')).join(', ')
113 const lease = s.planner ? (s.planner.leaseHolder ? ` · lease: ${s.planner.leaseHolder}` : ' · lease free') : ''
114 return `Nendo: ${names} open${lease}`
115}
116
117let isRefreshing = false
118
119async function refresh($: $) {
120 if (isRefreshing) return
121 isRefreshing = true
122 try {
123 const root = await $.session.root()
124 const open = await openFiles($, `${root}/workspace`)
125 let planner: Planner | null = null
126 let problem: string | null = null
127 if (open.includes(PLANNER)) {
128 const endpoint = await plannerEndpoint($)
129 if (!endpoint) problem = 'Planner is open but has no MCP endpoint'
130 else planner = await readPlanner($, endpoint).catch(error => {
131 problem = `Planner did not answer (${String(error?.message ?? error).slice(0, 60)})`
132 return null
133 })
134 }
135 const next: Snapshot = { open, planner, problem, checkedAt: await $.clock.now() }
136 await $.state.set(SNAPSHOT, next)
137 $.ui.status(statusLine(next))
138 } finally {
139 isRefreshing = false
140 }
141}
142
143function summary(s: Snapshot | null): string {
144 if (!s) return 'Not checked yet.'
145 if (!s.planner) return s.problem ?? 'Planner.nendo is not open.'
146 const line = (w: WorkItem) => `${w.ref} ${w.status}${w.isBlocked ? ' (blocked)' : ''}: ${w.title}`
147 const parts = [
148 `Now: ${s.planner.now.length ? '' : 'empty'}`, ...s.planner.now.map(line),
149 `Next: ${s.planner.next.length ? '' : 'empty'}`, ...s.planner.next.map(line),
150 `Inbox: ${s.planner.inbox}`,
151 `Lease: ${s.planner.leaseHolder ?? 'free'}`,
152 ]
153 return parts.join('\n')
154}
155
156export const register: Register = on => {
157 on('session.start', async ($, e, next) => {
158 await $.command.register({
159 name: 'planner',
160 description: 'Refresh and print the Nendo planner\'s Now and Next lanes',
161 })
162 void refresh($)
163 $.clock.every(EVERY_MS, () => void refresh($))
164
165 return next(e)
166 })
167
168 on('command.run', { command: 'planner' }, async $ => {
169 await refresh($)
170
171 return { text: summary((await $.state.get(SNAPSHOT)).value ?? null) }
172 })
173
174 on('turn.complete', async ($, e, next) => {
175 void refresh($)
176
177 return next(e)
178 })
179
180 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
181 const s = (await $.state.get(SNAPSHOT)).value ?? null
182 if (e.props.hasSurvey || !s || (!s.planner && !s.problem)) return next(e)
183
184 const { Box, Button, Text } = $.ui.resolve(e)
185 const width = e.props.bodyColumns
186
187 if (!s.planner) {
188 return (
189 <Box>
190 <Text dimColor wrap="truncate">{s.problem}</Text>
191 </Box>
192 )
193 }
194
195 const { now, next: upNext, inbox } = s.planner
196 const item = (w: WorkItem, label: string) => (
197 <Box key={`${label}-${w.ref}`} width={width}>
198 <Text color="cyan">{label} </Text>
199 <Text bold>{w.ref} </Text>
200 <Text color={w.isBlocked || w.status === 'Blocked' ? 'red' : 'yellow'}>{w.status} </Text>
201 <Text wrap="truncate">{w.title}</Text>
202 </Box>
203 )
204
205 const rows = now.length > 0
206 ? now.slice(0, SHOWN).map(w => item(w, 'Now '))
207 : upNext.length > 0
208 ? [item(upNext[0], 'Next')]
209 : [<Text key="empty" dimColor>Now and Next are empty · {inbox} in the Later inbox</Text>]
210
211 const more = now.length > SHOWN ? now.length - SHOWN : now.length === 0 && upNext.length > 1 ? upNext.length - 1 : 0
212
213 return (
214 <Box flexDirection="column">
215 {rows}
216 <Box>
217 <Text dimColor>
218 {now.length === 0 && upNext.length > 0 ? 'Now is empty · ' : ''}
219 {more > 0 ? `+${more} more · ` : ''}
220 {inbox} in inbox · /planner for the list{' '}
221 </Text>
222 <Button key="refresh" label="Refresh" onPress={() => refresh($)} />
223 </Box>
224 </Box>
225 )
226 })
227}
228types/index.d.ts 29 lines1export type WorkItem = {
2 ref: string
3 title: string
4 status: string
5 isBlocked: boolean
6}
7
8export type Planner = {
9 now: WorkItem[]
10 next: WorkItem[]
11 inbox: number
12 leaseHolder: string | null
13}
14
15export type Snapshot = {
16 // .nendo files in workspace/ that Nendo holds open (a .write-owner sidecar beside them)
17 open: string[]
18 // null when the planner is closed or did not answer
19 planner: Planner | null
20 problem: string | null
21 checkedAt: number
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 'nendo-planner': { snapshot: Snapshot | null }
27 }
28}
29