Browse docs
Your First Project

Build Your First Screen

Add a real feature by describing it: the generators lay the track, your agent fills it in, and you check four things.

Updated Aug 2026
On this page

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:analyze

The 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

Claude CodeGive your agent this

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:

  1. Nothing in src/features/ or src/renderer/ imports fs. If the file read happened in the UI, the pattern is broken even though the app appears to work.
  2. The schema is not empty. The generator leaves z.object({}) behind; it should now describe the real payload.
  3. Failures return a message rather than throwing. An unhandled throw in the main process takes the whole app down.
Claude CodeIf any of those three are wrong

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.

The File Stats screen inside the running desktop app, showing lines, words, characters and size on disk for a chosen text file
The screen from this walkthrough, in the running app. Nobody typed the React for it; the sidebar entry came from the generator and the layout came from the components already in the repo.

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.

Claude CodeTry the pattern on something of your own

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.

Give your agent a codebase with opinions.

Electron 41 + React 19 + TypeScript desktop app boilerplate built for Claude Code, Cursor and Copilot. 950 lines of rules your agent reads first, so feature fifty looks like feature one.

Get it for $49 →