Interactive lab: panes, controls, notes, events, prompt recipes and a demo tool.

Learn Claude customization by changing one working example at a time.
A practical, MIT-licensed workshop for people who want to understand plugins and build their first Claude Code mod. Includes a Cowork workflow plugin, an interactive UI mod, and an eight-lesson course with four illustrated explainers, exercises, checkpoints, and progress saved in your browser.
Start the course → · Try the experiments · Read the source · Official references
Independent community project. Not affiliated with or endorsed by Anthropic.
Follow the event through a hook, then try all three choices in the browser explainer.
The course homepage lets you choose a focused path:
| Section | Lessons |
|---|---|
| Foundations | Building blocks, choose a surface |
| Hands-on | Cowork workflow, events and hooks, UI and state, plugin anatomy |
| Finish and share | Your first change, check and share |
Every lesson has goals, direct links to the exercise and optional checkpoint, and navigation that follows your chosen path. Complete & continue saves the current lesson and opens the next one. The homepage names the lesson you can resume. Switch paths without losing completion marks, or use Progress settings → Reset completion marks → Undo reset to recover a reset before leaving the page. Expand each visual explainer when you want the diagram alongside the text.
Only known lesson IDs, completion marks, and your selected path are saved in local browser storage. No account is needed. A ?path=cowork or ?path=code link carries the learning path for someone you share it with; it does not share your progress. If storage is blocked, navigation still works and the page explains that completion will not persist. The original guide remains a quick reference. Read the course UX notes for the design decisions and a focused browser-check checklist.
| You want to… | Start here | What you will see |
|---|---|---|
| Understand the idea before installing | Event-and-hook lesson | A labeled browser simulation of observing, rewriting and answering an event |
| Customize a workflow in Cowork | Cowork plugin | Reusable commands that turn supplied notes into an experiment card |
| Add buttons, tabs and a pane | Claude Code mod | An interactive lab inside a supported Claude Code surface |
| Build your own version | First-change tutorial | An explained change with an observable result |
Cowork and the desktop Code tab are different surfaces. The Cowork example adds workflow commands. The custom UI example targets Claude Code, including the desktop Code tab. This project does not claim that Cowork draws the mod’s pane. See the dated compatibility notes.
You need: Claude with plugin upload available on your plan/account. No terminal, Node, Python or connected service is needed if you download the prebuilt ZIP.
/kitchen-cowork:tour./kitchen-cowork:experiment.Expect: an experiment card with Goal, Smallest change, Observable result, and Next question. This is a model following a reusable prompt, so outputs can vary. It uses normal conversation-model usage.
Change it: edit the fields in experiment.md, rebuild with npm run package:cowork from the repository root, re-upload dist/kitchen-cowork.zip, and compare. Building the ZIP requires Python 3.9+; the npm wrapper also requires Node.
You need: Claude Code in a supported interactive surface. The documented terminal minimum is 2.1.287; desktop Code minimum is 2.1.286. Run claude --version in a terminal or /status inside the Code tab: the versions can differ. Newer API behavior can vary; use the generated declarations for your build.
Clone the repository, enter its root, and launch the mod:
git clone https://github.com/reachjalil/claude-mod-kitchen-sink.git
cd claude-mod-kitchen-sink
claude --plugin-dir ./plugins/kitchen-sink
At the Claude prompt, enter:
/kitchen
Expect: a pane with recipes, events, and notes tabs. Click the counter; the number changes without a model request. Use a draft button with an empty prompt box, review the text, then send it yourself. Run /kitchen-status to inspect counters and reported render surfaces.
No npm install or build step is needed to load the mod. Edit register.js while the --plugin-dir session is open; Claude watches that directory and reloads the module. Counters reset on reload; saved notes remain.
First clone or download this repository. In a local Code session in Claude Desktop, run the following. Replace the example absolute path with your clone’s path:
/plugin marketplace add /absolute/path/to/claude-mod-kitchen-sink
/plugin install kitchen-sink@claude-mod-kitchen-sink
/reload-plugins
/kitchen
Use the direct terminal directory for active editing; an installed marketplace copy can differ from your working tree. The mod passes native desktop element-contract tests. Final layout and installation in the desktop app still need the manual acceptance pass on your build.
| Recipe | Try it | Why it matters |
|---|---|---|
| Pane, tabs and button | /kitchen, then click the counter | Interactive UI can update without asking the model |
| Draft a prompt | Press a draft button with empty input | Fill a draft while the user controls sending; existing text is preserved |
| Rewrite one prompt | Send [ks:brief] Explain panes | An explicit prefix opts in to a changed event |
| Register a tool | Ask to mix 2 imaginary portions | A mod can provide a tool without a separate server process |
| Guard a tool | Read the 1–3 portion validation | Answer or deny only the plugin’s own imaginary operation |
| Observe events | Use a tool and open Events | Count calls without storing tool contents |
| Compose UI | Inspect the band above the prompt | Keep downstream drawings from other mods |
| Restyle a detail | Watch the spinner during activity | Extend the normal rendering instead of replacing the whole view |
| Save a note | Save, reopen, delete | Learn local persistence and its cross-session limits |
| Provide a fallback | /kitchen-status | A text command is useful when no UI renderer is available |
Read the annotated walkthrough for $, e, next, the event chain, and state lifetimes.
For the complete local check set: Node.js 20+, Python 3.9+, and a recent Claude Code. No dependency installation is required.
npm test # Behavioral regression tests; includes packaging checks
npm run validate # Real Claude static validator, both plugins + marketplace
npm run test:native # Real mod-host tests, terminal + desktop element contracts
npm run site:build # Builds the guide, current source excerpts, and download ZIP
npm run site:check # Checks guide links, snippet freshness, and download members
Native validation and host tests were run with Claude Code 2.1.301. Browser testing of the guide is separate from desktop-mod testing. See the validation record for exact scope and remaining gaps.
plugins/kitchen-sink/ Real mod: manifest, hooks module, native host test
plugins/kitchen-cowork/ Workflow commands and synthetic sample notes
site/ Course content, reference HTML, CSS, browser-only JS
scripts/ Course rendering, validation, packaging, site build/check
tests/ Behavioral and distribution tests
docs/ Explainers, tutorials, troubleshooting, references
The mod uses no network, process, automatic model, or session-messaging APIs. Only an explicitly saved scratch note persists in the plugin’s local store. Mods in general run with your machine access; inspect code before loading. Security and data.
Both plugin bundles and the guide use the MIT license. The self-hosted Manrope font uses the SIL Open Font License; see asset notices. The event-flow brief and course illustration briefs include reusable prompts and text explanations. Fork it, teach from it, or use a recipe as a starting point. Keep demonstrations synthetic and record the surface/version you actually checked.
hooks/register.js 150 lines1// No imports or build step. Keep $ API calls explicit for Claude's static validator.
2// UI callbacks run on a click; no background model requests or session messages.
3export function register(on) {
4 let tab = 'recipes';
5 let note = '';
6 let clicks = 0;
7 let tools = 0;
8 let turns = 0;
9 let events = [];
10 const record = (label) => { events = [...events, label].slice(-12); };
11
12 on('session.start', async ($, e, next) => {
13 // A damaged/unavailable note store should not prevent the lab from loading.
14 try {
15 const saved = await $.store.get('scratch-note');
16 note = typeof saved === 'string' ? saved.slice(0, 500) : '';
17 } catch {
18 record('note store unavailable; starting with an empty note');
19 }
20 record('session.start');
21 await $.tool.register({
22 name: 'mix',
23 description: 'Kitchen Sink demo only. Mix 1–3 imaginary portions; no real-world action.',
24 inputSchema: { type: 'object', properties: { portions: { type: 'integer', minimum: 1, maximum: 3 } }, required: ['portions'], additionalProperties: false },
25 });
26 await $.command.register({ name: 'kitchen', description: 'Open the Kitchen Sink learning pane', immediate: true });
27 await $.command.register({ name: 'kitchen-status', description: 'Print lab counters and available render surfaces', immediate: true });
28 return next(e);
29 });
30
31 on('command.run', { command: 'kitchen' }, async ($) => {
32 const placed = await $.ui.open({ id: 'kitchen-sink-lab', title: 'Kitchen Sink', focus: true, closeOnEscape: true });
33 return { text: placed.isPlaced ? 'Kitchen Sink opened. Try the tabs, then /kitchen-status.' : 'No pane was placed. Use /kitchen-status; try a local Desktop Code tab or interactive terminal.' };
34 });
35
36 on('command.run', { command: 'kitchen-status' }, async ($) => {
37 const surfaces = await $.session.surfaces();
38 return { text: `Kitchen Sink | tools: ${tools} | turns: ${turns} | clicks: ${clicks} | surfaces: ${surfaces.join(', ') || 'none (headless or no renderer)'}` };
39 });
40
41 // Observe a call without keeping arguments, prompts, results or file paths.
42 on('tool.call', async ($, e, next) => {
43 tools += 1;
44 record('tool.call');
45 $.ui.invalidate('ui.render');
46 return next(e);
47 });
48
49 // A deliberately narrow gate: it only handles this plugin's imaginary tool.
50 on('tool.call', { tool: 'mcp__kitchen-sink__mix' }, async ($, e) => {
51 if (!Number.isInteger(e.portions) || e.portions < 1 || e.portions > 3) {
52 record('mix: refused invalid portion count');
53 return { deny: 'Demo limit: choose an integer from 1 to 3 portions.' };
54 }
55 record('mix: completed');
56 return { result: `Mixed ${e.portions} imaginary portion(s). Nothing was written, purchased or sent.` };
57 }).catch(async () => ({ deny: 'Kitchen demo tool failed; no action was taken.' }));
58
59 // Opt in on one prompt only. Other prompts pass through byte for byte.
60 on('prompt.submit', async ($, e, next) => {
61 if (!e.text.startsWith('[ks:brief] ')) return next(e);
62 record('prompt.submit: brief recipe');
63 return next({ ...e, text: e.text.slice('[ks:brief] '.length) + '\n\nPlease answer in at most three concise bullet points.' });
64 });
65
66 on('turn.complete', async ($, e, next) => {
67 turns += 1;
68 record('turn.complete');
69 $.ui.invalidate('ui.render');
70 return next(e);
71 });
72
73 on('ui.render', { component: 'Spinner' }, async ($, e, next) =>
74 next({ ...e, props: { ...e.props, suffix: `${e.props.suffix || ''} · kitchen: ${tools} calls` } })
75 );
76
77 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
78 const { Box, Text, Button } = $.ui.resolve(e);
79 const others = await next(e);
80 return Box({ flexDirection: 'column', children: [
81 ...(others ? [others] : []),
82 Box({ flexDirection: 'row', columnGap: 2, children: [
83 Text({ dimColor: true, children: [`Kitchen Sink · ${tools} tools · ${turns} turns`] }),
84 Button({ key: 'open-lab', label: 'Open lab', onPress: async () => {
85 await $.ui.open({ id: 'kitchen-sink-lab', title: 'Kitchen Sink', focus: true, closeOnEscape: true });
86 } }),
87 ] }),
88 ] });
89 });
90
91 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
92 if (e.requestId !== 'kitchen-sink-lab') return next(e);
93 const { Box, Text, Button, Input } = $.ui.resolve(e);
94 const tabs = ['recipes', 'events', 'notes'].map((name) => Button({
95 key: `tab-${name}`, label: name === tab ? `[${name}]` : name,
96 onPress: () => { tab = name; $.ui.invalidate('ui.render'); },
97 }));
98 let body = [];
99 if (tab === 'recipes') body = [
100 Text({ children: ['Explore a real mod, one small interaction at a time.'] }),
101 Text({ dimColor: true, children: ['Start with an empty input. Draft buttons fill it; review, then send.'] }),
102 Button({ key: 'counter', label: `Click counter: ${clicks}`, onPress: () => { clicks += 1; $.ui.invalidate('ui.render'); } }),
103 Button({ key: 'draft-brief', label: 'Draft a brief prompt', onPress: async () => {
104 const draft = await $.prompt.read();
105 if (draft.text) {
106 await $.ui.toast('Your draft is still there. Clear or send it before loading this example.');
107 return;
108 }
109 await $.prompt.fill({ text: '[ks:brief] Explain how Claude Code mods work.', mode: 'append' });
110 } }),
111 Button({ key: 'draft-mix', label: 'Draft a demo tool request', onPress: async () => {
112 const draft = await $.prompt.read();
113 if (draft.text) {
114 await $.ui.toast('Your draft is still there. Clear or send it before loading this example.');
115 return;
116 }
117 await $.prompt.fill({ text: 'Use the kitchen-sink mix tool to mix 2 imaginary portions.', mode: 'append' });
118 } }),
119 Text({ children: ['The tool has a 1–3 portion guard. The note tab persists only what you explicitly save.'] }),
120 ];
121 if (tab === 'events') body = [
122 Text({ bold: true, children: [`${tools} tool calls · ${turns} turns · ${clicks} clicks`] }),
123 Text({ dimColor: true, children: ['Last 12 labels, in memory only. No prompt or tool contents are recorded.'] }),
124 ...events.map((label) => Text({ children: [label] })),
125 Button({ key: 'reset-events', label: 'Reset counters and event labels', onPress: () => {
126 tools = 0; turns = 0; clicks = 0; events = []; $.ui.invalidate('ui.render');
127 } }),
128 ];
129 if (tab === 'notes') body = [
130 Text({ children: ['One local scratch note, shared by this plugin across sessions. Last save wins.'] }),
131 Input({ key: 'scratch-note', label: 'Scratch note', value: note, placeholder: 'Synthetic notes only; 500 characters max', submitLabel: 'Save', onSubmit: async (value) => {
132 const text = value.slice(0, 500);
133 await $.store.set('scratch-note', text);
134 note = text;
135 $.ui.invalidate('ui.render');
136 } }),
137 Text({ children: [`Saved / loaded: ${note || '(empty)'}`] }),
138 Button({ key: 'forget-note', label: 'Delete saved note', onPress: async () => {
139 await $.store.delete('scratch-note'); note = ''; $.ui.invalidate('ui.render');
140 } }),
141 ];
142 return Box({ flexDirection: 'column', rowGap: 1, children: [
143 Text({ bold: true, children: ['CLAUDE MOD KITCHEN SINK'] }),
144 Text({ dimColor: true, children: [`Renderer: ${e.surface} · experimental learning lab`] }),
145 Box({ flexDirection: 'row', columnGap: 1, children: tabs }),
146 ...body,
147 ] });
148 });
149}
150