What Gets Generated
Sections, the validation rules morphic() enforces, and exactly where every property lands.
Morphic describes. Adapters generate.
psy buildThis chapter is the mapping — starting with the behaviour people do not expect and end up relying on most.
Anything else you declare
Morphic reserves nine property names. Everything else you declare becomes a section in the generated artifact, titled from the property name.
Nothing is silently dropped. If you declare it, it appears.
export skill ReviewDiff:
description: Review a diff.
when: Use before approving a change.
prompt:
Read the whole diff before commenting on any part of it.
rules:
- Comment on the change, not the author
- Quote the line you mean
escalation:
Stop and ask when the change touches authentication.becomes
# ReviewDiff
Read the whole diff before commenting on any part of it.
## Rules
- Comment on the change, not the author
- Quote the line you mean
## Escalation
Stop and ask when the change touches authentication.rules and escalation are not in any schema anywhere. I did not add support for
them. You made them up while writing that file, and they came out the other end.
Why it works this way
The alternative is a fixed schema — a set of fields the vocabulary knows about, and nothing else. Which means every new kind of thing you want to say requires a change to Morphic, a release, and an upgrade.
Sections invert that. The vocabulary fixes only what an adapter must understand to build a valid artifact: the name, the description, the prompt, the toolset. Everything else is yours, and it still ends up in the output.
It is not clever. It is just the right default, and I am mildly annoyed it is unusual.
The reserved names
| Name | Used by |
|---|---|
description | all eight |
prompt | skill, agent, command |
use | skill, agent, command |
context | skill, agent, command |
when | skill (required), definitions (optional) |
model | agent, command |
tools | agent |
argumentHint | command |
allowedTools | command |
The set is shared across every declaration, not per-keyword. Declaring tools
on a command produces no section, because tools is reserved somewhere — it is
simply ignored.
When a property you expected to render does not appear, check this table first. That is the failure mode, and it is the price of a flat reserved set.
Note what is not reserved: overview, behaviour, covers,
responsibilities and guidance — the properties each definition kind requires
— are ordinary properties that become sections like any other. See
Specifying Software.
Section titles
The property name is split on case boundaries and title-cased:
| Property | Section title |
|---|---|
rules | Rules |
escalation | Escalation |
readPsySpec | Read Psy Spec |
errorHandling | Error Handling |
What does and does not become one
| Value | Result |
|---|---|
| Non-empty string | a text section |
| Non-empty list of strings | a bulleted section |
| Empty or whitespace-only string | skipped |
| Empty list | skipped |
| List containing non-strings | only the strings are kept |
| Number, boolean, object, reference | no section |
A property that renders nothing is not an error. Worth knowing, though:
retries: 3 on a skill produces no output at all, because a number is not a
section. If you wanted the number in the artifact, write it as a string.
Sections appear in declaration order, after the prompt and before the skill list. Inherited properties keep the position they were first declared in, so a base spec's sections come before a subclass's own.
Inherited sections
Which is how you attach standing instructions to everything at once:
// src/framework.psy
import { Skill as MorphicSkill } from "@psy/morphic"
export abstract spec Skill extends MorphicSkill as skill:
contract:
The specification in `spec/` is authoritative. Read it before changing
behaviour, and do not invent behaviour that contradicts it.Every skill declared with the local skill keyword now carries a Contract
section. No per-skill edit. And composing rather than replacing works as usual:
export skill Careful:
contract:
+ super
Prefer the smallest change that satisfies the request.Validation
Registering morphic() adds the checks that run during psy check and
psy build, before any target generates anything.
frameworks: [morphic()]These are separate from the compiler's own. The vocabulary declares
abstract prompt: string, so the compiler already rejects a skill with no
prompt. Morphic adds the rules a type cannot state — that the prompt is not
empty, that two skills do not collide, that a use entry really is a skill.
Every finding is reported against the declaration that caused it, with the
framework diagnostic code PSY6001.
A prompt must be non-empty. Errors on skill, agent, command. A
declared-but-blank prompt satisfies the type and produces an artifact that
instructs nothing. Definitions have no prompt, so it does not apply to them.
A skill must say when it applies. Errors on skill. Omitting when entirely
is already a compile error; this catches the empty string.
Vaguemust declare a non-emptywhen: a skill nobody knows when to reach for is a skill nobody reaches for.
A definition must have a description. Errors on all five definition kinds. A definition with no description cannot be found, because the platform decides whether to load it from that line alone.
use must reference skills. The vocabulary types use as Skill[], so the
compiler already rejects an unrelated spec. This covers the case where the
reference is a Skill subtype but was declared with a different keyword.
context must reference definitions. The mirror of the same rule. use takes
skills, context takes definitions, and swapping them is caught.
Work.contextreferencesSomeSkill, which is not a definition (concept, domain, feature, component, reference).
A declaration must not use itself.
Names must not collide after slugging. Artifacts are named by kebab-case slug, so:
export skill ReviewDiff: // -> review-diff
export skill REVIEWDiff: // -> review-diff
REVIEWDiffandReviewDiffboth generate the artifact namereview-diff.
The check spans every declaration — all three working kinds and all five definition kinds — because definitions share the skills directory with skills, so a cross-family collision is just as destructive.
Activated but unused. A warning, once per program, when a module activates
skill, agent or command and the program declares none of them. Scoped so
that compiling a subset of a project is not flagged.
requireDescription
Off by default:
frameworks: [morphic({ requireDescription: true })]Warning:
ReviewDiffhas nodescription; adapters will generate one.
A warning rather than an error, because the build still succeeds — adapters fall
back to <Name> (generated from Psy).. The point is that a generated description
is rarely the one you wanted. Pair it with warningsAsErrors to make it binding:
export default defineConfig({
source: ["./src"],
frameworks: [morphic({ requireDescription: true })],
diagnostics: { warningsAsErrors: true },
});What it cannot check
Morphic receives resolved IR and a diagnostic sink, and nothing else. So it cannot
verify that a model names a real model, that a tools entry is a tool the
platform offers, or that a prompt is any good. Those strings pass through
untouched, and the platform will tell you.
The Claude adapter
import { claude } from "@psy/adapter-claude";
targets: [claude({ output: ".claude" })]| Declaration | Path |
|---|---|
skill | skills/<slug>/SKILL.md |
agent | agents/<slug>.md |
command | commands/<slug>.md |
| any definition | skills/<slug>/SKILL.md |
Skills
export skill ReviewDiff:
description: Review a diff for correctness.
when: Use before approving any change.
use:
- ProjectContext
prompt:
Read the whole diff first.
rules:
- Quote the line you meanskills/review-diff/SKILL.md:
---
name: review-diff
description: Review a diff for correctness. Use before approving any change.
---
# ReviewDiff
Read the whole diff first.
## Rules
- Quote the line you mean
## Builds on
- ProjectContextNote that description and when are joined into one frontmatter field,
separated by a space. Claude Code decides whether to load a skill from its
description, so the condition has to be there rather than in the body where
nothing would ever read it.
Agents
export agent BuildProject:
description: Implement changes in this project.
model: opus
tools:
- Read
- Edit
use:
- ImplementChange
prompt:
Implement the requested change.agents/build-project.md:
---
name: build-project
description: Implement changes in this project.
tools: Read, Edit
model: opus
---
# BuildProject
Implement the requested change.
## Skills
Use the following Psy-defined skills when carrying out this work:
- `implement-change` (ImplementChange)tools is comma-joined. The skill list carries a directing sentence that a
skill's "Builds on" list does not, and names each entry by both slug and spec
name.
Commands
export command Release:
description: Draft release notes.
argumentHint: "[version]"
allowedTools:
- Bash
use:
- DraftRelease
prompt:
Draft release notes for the given version.commands/release.md:
---
description: Draft release notes.
argument-hint: "[version]"
allowed-tools: Bash
---
Draft release notes for the given version.
## Skills
- `draft-release` (DraftRelease)Two differences from agents worth having in your head:
- No
namefield and no# Heading. A command's body is its prompt. - Property names are hyphenated:
argumentHint→argument-hint,allowedTools→allowed-tools.
Definitions
The five definition keywords render as skills, because progressive disclosure is exactly what reference material wants — loaded when relevant, not always.
export component LayerStack:
description: The ordered stack of layers in a document.
responsibilities:
- Maintain layer order and z-index
invariants:
- Order is stable across save and loadskills/layer-stack/SKILL.md:
---
name: layer-stack
description: "Component: The ordered stack of layers in a document."
---
# LayerStack
## Responsibilities
- Maintain layer order and z-index
## Invariants
- Order is stable across save and load
## Authority
This describes the product as it is meant to work. Where the implementation
disagrees, treat this as correct unless the task is explicitly to change it.The kind is prefixed onto the description, so a reader knows what they are looking
at without opening the file. when is appended when declared, exactly as for a
skill.
Because definitions share the skills directory, a definition and a skill that slug the same collide — which is why the name collision check spans both families.
Context lists
A declaration that names context gets a section pointing at those definitions:
## Context
Consult these definitions of how the product works:
- `rendering` (Rendering)
- `layer-stack` (LayerStack)That sentence is the mechanism by which specifying your software actually reaches the agent doing the work.
The whole property mapping
| Psy | Claude frontmatter | Notes |
|---|---|---|
description (skill) | description | joined with when |
when | description | appended to description |
description (agent) | description | |
description (command) | description | |
description (definition) | description | prefixed with the kind |
model | model | falls back to the target's model |
tools | tools | comma-joined; omitted when empty |
argumentHint | argument-hint | |
allowedTools | allowed-tools | comma-joined; omitted when empty |
prompt | body | |
use | "Builds on" / "Skills" | |
context | "Context" | definitions only |
| anything else | ## Section | see above |
An absent value omits its field rather than emitting it empty. Values that would change meaning as bare YAML are quoted automatically.
Target options
claude({
output: ".claude",
model: "sonnet",
modules: ["src/agents/"],
index: true,
plugin: { name: "my-plugin", version: "0.1.0" },
})| Option | Effect |
|---|---|
output | output directory; defaults to .claude |
model | default model frontmatter; a declared model wins |
modules | only emit declarations from modules under these prefixes |
index | emit psy-generated.md; opt-in |
plugin | emit a plugin manifest and use the plugin layout |
modules matters the moment one project builds two Claude outputs — without it,
each target emits every declaration in the program.
Inspect before you ship
psy inspect BuildFeature
psy inspect BuildFeature.promptprints the composed prompt together with its provenance — which parent supplied
each block, from which source range. That is reliably faster than reading the
generated Markdown and guessing which + super produced which paragraph.
Determinism
Generating twice produces identical bytes. Declarations are sorted by slug, frontmatter keys are emitted in a fixed order, and no timestamp is written anywhere.
psy build --checkfails when anything on disk differs from what would be generated. Wire it into CI
next to your tests — a clean --check means the committed artifacts match the
Psy sources exactly, which is what makes generated output safe to commit and
review.
Headers, manifests and how stale artifacts get pruned are shared with every other target — see Compiling To Data.
Next: Shipping A Plugin