You are going to add a File Stats screen: the user picks a text file, and the app counts its lines, words and characters. Small on purpose, and it touches every layer you will use for the rest of your project.
You will not type this feature. You will describe it, then check what came back. Everything below was run exactly this way before it was published.
Two commands, one prompt, four checks. The only thing you need to know before starting is that an Electron app is two programs, and file reading belongs in one of them: the main process, not your React code. Your agent already knows that rule because CLAUDE.md states it. The full version is at the bottom of this page, once you have something running to attach it to.
1. Let the generators lay the track
Run these two yourself. They take a second each, and they are what keep your agent on the same pattern every time.
npm run new:feature FileStats
npm run new:ipc filestats:analyzeThe first creates the screen folder and registers it, so a FileStats entry appears in the sidebar without anyone editing the sidebar. The second writes the channel name, the request and response types, a validation schema, an empty main-process handler, the preload bridge and the renderer types: eight files of wiring, no logic.
The channel name format is strict
It must be lowercasedomain:action. FileStats or fileStats:analyze both fail with Use lowercase domain:action format (e.g., demo:ping). Use filestats:analyze.
2. Ask for the feature
In this Electron template I just ran npm run new:feature FileStats and npm run new:ipc filestats:analyze. Fill them in so the screen has a button that opens a native file picker, and the main process reads the chosen text file and returns its line, word and character count plus the file size.
Follow the patterns in this repo rather than inventing new ones: real request and response types in src/shared/ipc/types.ts, a Zod schema in src/shared/validation/, the file read in the main process only, and the existing components from @shared/components for the UI. Cap the file size and handle a cancelled dialog.
Run npm run typecheck before you tell me it is done.
That last line matters. CLAUDE.md lists "claiming work is done before testing it" as mistake number 12, and asking for the typecheck is how you hold it to that.
3. Check what it did
You are looking for six files, and their locations are the point. If your agent produced a different shape, that is worth a follow-up prompt rather than a shrug.
| File | What belongs there |
|---|---|
src/shared/ipc/types.ts |
the request and response shape, replacing the generator's placeholder |
src/shared/validation/filestats-schema.ts |
the Zod schema that rejects a bad message |
src/main/ipc/filestats-handlers.ts |
the actual file reading and counting |
src/features/file-stats/components/FileStatsScreen.tsx |
the screen |
src/shared/ipc/channels.ts |
the channel name, added by the generator |
src/preload/preload.ts |
the bridge function, added by the generator |
Three things to look at specifically, because they are what a rushed answer gets wrong:
- Nothing in
src/features/orsrc/renderer/importsfs. If the file read happened in the UI, the pattern is broken even though the app appears to work. - The schema is not empty. The generator leaves
z.object({})behind; it should now describe the real payload. - Failures return a message rather than throwing. An unhandled throw in the main process takes the whole app down.
The file read has to happen in the main process only, the Zod schema must describe the real payload instead of an empty object, and every failure path should return { success: false, error } rather than throwing. Fix those and re-run npm run typecheck.
4. Run it
npm run dev is still running, so the screen is already there. Click FileStats in the sidebar, choose a text file, and read the numbers.

Check the numbers against a file you can count by hand. A file containing exactly alpha beta gamma, delta epsilon, zeta and a final newline is 6 words and 36 characters. Line count is legitimately ambiguous with a trailing newline, so 3 or 4 are both defensible; anything else is a bug.
Why that worked: an Electron app is two programs
Now that you have it running, the idea behind the checks is worth two minutes.
- The main process is Node.js. It can read files, open windows, talk to the operating system.
- The renderer is your React app. It runs in a locked-down browser window with no access to your disk.
They talk over named channels, and every message is checked on the way through by the Zod schema you looked at in step 3. That check is why a mistake in the UI cannot become a mistake that deletes files, and it is why "nothing in src/features/ imports fs" was worth verifying rather than trusting.
CLAUDE.md states this as the first of its 6 hard architecture rules, which is why your agent followed it without being told. In an empty Electron project there is nothing to state it, and the same request produces UI code that reads the disk directly: it works on your machine, and it is the wrong shape to build fifty features on.
What the guardrails will catch for you
These are real compiler errors this walkthrough produced, and each one is the strict setup refusing a shortcut. If your agent hits them, it will usually fix them itself. If you see them, this is what they mean.
Property 'variant' is missing ... but required in type 'ButtonProps'
Button requires variant. It is variant="primary" or variant="secondary".
Property 'title' does not exist on type 'PanelProps'
Panel calls it header. The agent guessed at an API instead of reading the existing one.
Type 'string | undefined' is not assignable to type 'string'
noUncheckedIndexedAccess is on, so filePaths[0] is possibly undefined even after a length check. Destructure it and check the value.
None of these reach a user. That is the whole point of them.
If you want to read the code
The main-process handler
import type { IpcMainInvokeEvent } from 'electron';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { logEngine } from '../../logging';
import { filestatsAnalyzeRequestSchema } from '@shared/validation/filestats-schema';
import type { FilestatsAnalyzeRequest, FilestatsAnalyzeResponse } from '@shared/ipc/types';
const log = logEngine.createLogger('FilestatsHandlers');
const MAX_BYTES = 5 * 1024 * 1024;
export async function handleFilestatsAnalyze(
_event: IpcMainInvokeEvent,
data: FilestatsAnalyzeRequest,
): Promise<FilestatsAnalyzeResponse> {
try {
const parsed = filestatsAnalyzeRequestSchema.parse(data);
const info = await stat(parsed.path);
if (!info.isFile()) return { success: false, error: 'That path is not a file.' };
if (info.size > MAX_BYTES) return { success: false, error: 'That file is larger than 5 MB.' };
const text = await readFile(parsed.path, 'utf8');
const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
return {
success: true,
stats: {
name: path.basename(parsed.path),
bytes: info.size,
lines: text.length === 0 ? 0 : normalized.split('\n').length,
words: text.split(/\s+/).filter(Boolean).length,
characters: text.length,
},
};
} catch (err) {
log.error('handleFilestatsAnalyze failed', err, { data });
return { success: false, error: String(err) };
}
}The schema and the types
// src/shared/validation/filestats-schema.ts
import { z } from 'zod';
export const filestatsAnalyzeRequestSchema = z.object({
path: z.string().min(1),
});// src/shared/ipc/types.ts
export interface FilestatsAnalyzeRequest {
path: string;
}
export interface FilestatsAnalyzeResponse {
success: boolean;
error?: string;
stats?: {
name: string;
bytes: number;
lines: number;
words: number;
characters: number;
};
}The screen
import { useState } from 'react';
import { Button, Panel, EmptyState } from '@shared/components';
import type { FilestatsAnalyzeResponse } from '@shared/ipc/types';
type Stats = NonNullable<FilestatsAnalyzeResponse['stats']>;
export function FileStatsScreen() {
const [stats, setStats] = useState<Stats | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function pickFile() {
setError(null);
const picked = await window.api.dialogOpenFile({
title: 'Choose a text file',
filters: [{ name: 'Text', extensions: ['txt', 'md', 'json', 'ts', 'tsx', 'js', 'csv'] }],
});
const [chosen] = picked.filePaths;
if (picked.canceled || !chosen) return;
setBusy(true);
const result = await window.api.filestatsAnalyze({ path: chosen });
setBusy(false);
if (!result.success || !result.stats) {
setError(result.error ?? 'Could not read that file.');
setStats(null);
return;
}
setStats(result.stats);
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center h-[40px] px-3 bg-app-surface shrink-0 border-b border-border">
<span className="text-[13px] font-medium text-text-secondary">File Stats</span>
</div>
<div className="flex-1 overflow-auto p-4 space-y-4">
<Button variant="primary" onClick={pickFile} disabled={busy}>
{busy ? 'Reading...' : 'Choose a file'}
</Button>
{error && <p className="text-[12px] text-red-400">{error}</p>}
{!stats && !error && (
<EmptyState title="No file yet" description="Pick a text file to count what is in it." />
)}
{stats && (
<Panel header={stats.name}>
<dl className="grid grid-cols-2 gap-y-2 p-3 text-[13px]">
<dt className="text-text-muted">Lines</dt>
<dd>{stats.lines.toLocaleString()}</dd>
<dt className="text-text-muted">Words</dt>
<dd>{stats.words.toLocaleString()}</dd>
<dt className="text-text-muted">Characters</dt>
<dd>{stats.characters.toLocaleString()}</dd>
<dt className="text-text-muted">Size on disk</dt>
<dd>{stats.bytes.toLocaleString()} bytes</dd>
</dl>
</Panel>
)}
</div>
</div>
);
}You have now used the whole pattern
Every future feature is these same steps: two generator commands, one prompt, four checks, one run. We tested that claim rather than making it. The same request, given to Claude Code three times in this repo, touched the same thirteen files every time and wrote a unit test each time without being asked. In an empty Electron scaffold the same agent invented a different layout on all three attempts and wrote no tests at all.
Following the exact pattern in src/features/file-stats/, add a feature that reads the user's clipboard and shows the ten most frequent words. Use npm run new:feature and npm run new:ipc first, keep the work in the main process, and run npm run typecheck before telling me it is done.
Next: Package a Real Installer.