Tutorial: A Paint App

Step 7: Build It

Turn eighteen files of Psy into artifacts an agent can actually load — and see what changes.

psy build
build: 18 artifacts across 1 target (18 updated, 0 unchanged)
  claude: 18 artifacts in .claude
.claude/
  .psy-manifest
  agents/
    build-paint.md
  skills/
    brush/SKILL.md
    canvas/SKILL.md
    canvas-rules/SKILL.md
    canvas-surface/SKILL.md
    color-picker/SKILL.md
    drawing-tool/SKILL.md
    eraser/SKILL.md
    file-store/SKILL.md
    files/SKILL.md
    implement-tool/SKILL.md
    naming/SKILL.md
    new-drawing/SKILL.md
    paint/SKILL.md
    pencil/SKILL.md
    save-drawing/SKILL.md
    stroke-model/SKILL.md
    tool-palette/SKILL.md
    tools/SKILL.md
    voice/SKILL.md
    write-interface-copy/SKILL.md

Every definition became a skill, so each one loads when it becomes relevant rather than all at once. Three of them are actual skills; the other fourteen are the description of the product.

Run it again:

psy build
build: 18 artifacts across 1 target (0 updated, 18 unchanged)
  claude: 18 artifacts in .claude

Nothing updated. Generation is deterministic — same source, byte-identical output, no timestamps anywhere. That is what makes .claude/ safe to commit.

Check it in CI

psy build --check
build: 18 artifacts across 1 target (0 stale)

That fails if anyone edits a .psy file and forgets to rebuild, or edits a generated file by hand. Put it next to your tests.

Inspect before you trust

The composed prompt on ImplementTool came from two files. You do not have to read the generated Markdown and work out which + super produced what:

psy inspect ImplementTool.prompt
ImplementTool.prompt (src/skills/ImplementTool.psy)
  = "Only CanvasSurface writes pixels. If a change seems to need a putPixel
     somewhere else, that is a design problem — say so rather than working
     around it.\n\nA stroke is one undo step. Snapshot before it starts, never
     during.\n\nA tool is a stamp, not a drawing loop. If you are writing
     interpolation code, StrokeModel already does it — use it.\n\nEvery tool
     keeps its own options. Adding a size to one tool does not touch another."

  declared in ImplementTool at src/skills/ImplementTool.psy
  provenance:
    block string in ImplementTool.prompt (src/skills/ImplementTool.psy:20:9)
      super CanvasRules.prompt in CanvasRules.prompt (src/skills/CanvasRules.psy:16:5)
        text in CanvasRules.prompt (src/skills/CanvasRules.psy:17:9)
      text in ImplementTool.prompt (src/skills/ImplementTool.psy:23:9)

Two contributors, in precedence order, each pointing at a line number.

And the whole picture for one declaration:

psy inspect BuildPaint
BuildPaint (src/agents/BuildPaint.psy)
  abstract: false
  keyword: agent
  precedence: src/agents/BuildPaint.psy#BuildPaint > @psy/morphic#Agent

  description = "Implement changes in the paint program."
  model = "opus"
  tools = ["Read", "Edit", "Bash"]
  use = [CanvasRules, ImplementTool, WriteInterfaceCopy]
  context = [Paint]

Note use and context printed as spec references, not strings. That is why a typo in a skill name is a compile error rather than a silently missing skill.

Now change something

This is the part that justifies the whole exercise. Suppose the Brush should accumulate opacity within a stroke after all.

Edit one line in src/features/Brush.psy:

    edgeCases:
        - A click without a drag stamps the brush once.
        - Overlapping passes within a single stroke accumulate, so a slow
          scribble is darker where it crosses itself.
psy build
build: 18 artifacts across 1 target (1 updated, 17 unchanged)
  claude: 18 artifacts in .claude

One file changed. git diff shows exactly the sentence you edited, in .claude/skills/brush/SKILL.md. The decision and the artifact an agent reads cannot drift apart, because one is generated from the other.

And rename something

Suppose ColorPicker becomes Palette:

psy build
build: 18 artifacts across 1 target (1 updated, 17 unchanged)
  claude: 18 artifacts in .claude
note: removed 1 stale artifact
  .claude/skills/color-picker/SKILL.md

The old artifact was pruned. That works because we set artifacts: { manifest: true } back in the setup — without a manifest or a header, a build cannot prove it wrote a file, so it refuses to delete it and says so.

Except we also have to fix the reference to it, which we get told about first:

src/skills/ImplementTool.psy:5:5 error PSY3002: cannot find name `ColorPicker`.

A renamed component in a Markdown file would have left a dangling mention nobody noticed for a year.

What you have

Eighteen .psy files. A compiled, checked description of a small program, and an agent that receives the relevant parts of it automatically.

More usefully: a place to put the next decision. When somebody asks for a fill-bucket tool, the work is a feature file and a line in ToolPalette.responsibilities — and ImplementTool will hand whoever builds it the stroke model, the palette rules and the reminder that a tool is a stamp.

And when somebody asks for layers, the answer is already written down, with the reason, in Paint.nonGoals.

Where to go next

  • Specifying Software — the practice, in general, including how to start on a codebase that already exists.
  • Shipping A Plugin — if this should be installable rather than project-local.
  • Compiling To Data — the same definitions as JSON or YAML, for something other than Claude Code.
  • Psy Describes Psy — the same mechanism, pointed at a compiler instead of a paint program.

On this page