SLOPSHOPPER

nendo-planner

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

newbandcommandstatusnetworktimer
★ 1v0.1.0MITupdated 2026-10-09ThomasRohde/nendo/.claude/skills/nendo-planner
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · nendo-planner
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /planner ⎿ nendo-planner: Planner.nendo is not open. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ nendo-planner: Nendo: nothing open
README

<img src="docs/assets/brand/nendo.png" alt="Nendo" width="420">

Nendo

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.

How it works

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.

Start here

  • Vision — what malleable software means and what would falsify it
  • Architecture — the system as built
  • Roadmap — what's next, and what is honestly not yet true
  • Development planner — how this project plans its own work in a Nendo file. That file is the author's own data and is not in a clone; the document describes the workflow and the agent handoff
  • Decisions — the ADRs, which are the architecture authority
  • Contracts — behavioural detail: MCP interface, semantic surfaces, scalars, queries, CSV, relationships, reads and authority
  • Glossary — the vocabulary this repository uses precisely

State

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.

Build

Windows x64 only. Nendo.Engine is net10.0 and builds anywhere, but the Desktop host is WinUI 3 with WebView2.

Prerequisites

ToolVersionInstall
.NET SDKPinned in global.jsonwinget install Microsoft.DotNet.SDK.10
Node.js with npmThe engines range in src/Nendo.Workbench/package.jsonwinget install OpenJS.NodeJS.LTS
PowerShell 7pwsh; every script in tools/ assumes itwinget install Microsoft.PowerShell
GitAny current versionwinget install Git.Git
WebView2 Evergreen runtimeAlready present on Windows 11winget install Microsoft.EdgeWebView2Runtime
NSISOnly to build the installer; makensis on PATHwinget 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.

Build and run

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.

Verify

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.

Connect an agent

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.

For coding agents

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.

Repository layout

PathWhat is in it
src/Nendo.EngineThe typed core: storage, semantic operations, diff, behaviour. The only code that touches SQLite
src/Nendo.DesktopThe WinUI 3 host: window, native shell integration, serving custom views from the open file
src/Nendo.WorkbenchThe renderer — TypeScript and Vite, bundled into the Desktop output
src/Nendo.LocalMcpThe 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 and security

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.

Brand

Static mark · Animated mark

Licence

MIT. Vendored third-party material retains its own notices and licences.

Source 2 files
hooks/register.tsx 228 lines
1import 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}
228
types/index.d.ts 29 lines
1export 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