Morphic Agent Framework

What Gets Generated

Sections, the validation rules morphic() enforces, and exactly where every property lands.

Morphic describes. Adapters generate.

psy build

This 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

NameUsed by
descriptionall eight
promptskill, agent, command
useskill, agent, command
contextskill, agent, command
whenskill (required), definitions (optional)
modelagent, command
toolsagent
argumentHintcommand
allowedToolscommand

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:

PropertySection title
rulesRules
escalationEscalation
readPsySpecRead Psy Spec
errorHandlingError Handling

What does and does not become one

ValueResult
Non-empty stringa text section
Non-empty list of stringsa bulleted section
Empty or whitespace-only stringskipped
Empty listskipped
List containing non-stringsonly the strings are kept
Number, boolean, object, referenceno 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.

Vague must declare a non-empty when: 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.context references SomeSkill, 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

REVIEWDiff and ReviewDiff both generate the artifact name review-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: ReviewDiff has no description; 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" })]
DeclarationPath
skillskills/<slug>/SKILL.md
agentagents/<slug>.md
commandcommands/<slug>.md
any definitionskills/<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 mean

skills/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

- ProjectContext

Note 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 name field and no # Heading. A command's body is its prompt.
  • Property names are hyphenated: argumentHintargument-hint, allowedToolsallowed-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 load

skills/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

PsyClaude frontmatterNotes
description (skill)descriptionjoined with when
whendescriptionappended to description
description (agent)description
description (command)description
description (definition)descriptionprefixed with the kind
modelmodelfalls back to the target's model
toolstoolscomma-joined; omitted when empty
argumentHintargument-hint
allowedToolsallowed-toolscomma-joined; omitted when empty
promptbody
use"Builds on" / "Skills"
context"Context"definitions only
anything else## Sectionsee 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" },
})
OptionEffect
outputoutput directory; defaults to .claude
modeldefault model frontmatter; a declared model wins
modulesonly emit declarations from modules under these prefixes
indexemit psy-generated.md; opt-in
pluginemit 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.prompt

prints 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 --check

fails 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

On this page