Your Own Keywords
Abstract specs, contracts, and how a framework teaches Psy to speak your domain without forking the parser.
Everything so far has been spec. Which is fine, and also a bit flat — a CI
pipeline and a deployment target and an AI agent are all spec, and nothing in
the file says otherwise.
This chapter is where that changes:
use "./framework"
export stage Install:
description: Install dependencies
command: npm install
export pipeline Ci:
description: The continuous integration pipeline
stages:
- Installpsy inspect CiCi (main.psy)
abstract: false
keyword: pipeline
precedence: main.psy#Ci > framework.psy#Pipeline
description = "The continuous integration pipeline"
stages = [Install]stage and pipeline are not Psy keywords. I did not add them. You added
them, in about nine lines, in ordinary Psy, and the compiler now enforces that
every stage has a command and that stages really contains stages.
No parser fork. No plugin API. No grammar extension. Here is how.
Abstract specs
An abstract spec is one that exists to be inherited from.
abstract spec Base:
retries: 3It may be inherited from, referenced, and may declare contracts. It may not be exported as a concrete compiled declaration:
export abstract spec Base: // error PSY2006
retries: 3There is nothing for export to export — it is not a thing, it is a shape for
things. Unless it exposes a keyword, which is coming in two sections.
Abstract specs are otherwise completely ordinary. In particular they can carry concrete values that every descendant inherits:
export abstract spec CompilerStage extends LanguageConcept as compiler-stage:
abstract input: string
abstract output: string
abstract responsibilities: string[]
layer: compiler
invariants:
- consumes only the declared input
- produces only the declared outputEvery descendant gets layer and invariants for free, and can compose with
them:
export compiler-stage Lexer:
input:
Psy source text
output:
Token stream
responsibilities:
- tokenize syntax
invariants:
+ super
- the indentation stack is always balanced at end of fileThat is a framework attaching shared truth to everything it defines, and it is the single most useful thing about the whole mechanism.
Contracts
Psy has no separate schema language. There is no .schema.json sitting beside
your config, drifting out of sync with it. Contracts are declared right on the
abstract spec, with abstract:
export abstract spec Skill as skill:
abstract description: string?
abstract prompt: string
abstract use: Skill[]?Every concrete descendant must define prompt, and the value must be a string.
description and use may be omitted, because ? means optional.
export skill ReadPsySpec:
description: Read the authoritative Psy specification.
prompt:
Read spec/index.psy.The rules:
- An abstract property may only be declared on an abstract spec —
PSY3016. - A concrete spec missing a required one is
PSY3015. - A value of the wrong kind is
PSY4001. T?is satisfied by omitting the property or by declaring itnull.- Abstract specs are exempt from their own contracts. That is the point of them.
- Contracts inherit through the whole chain; the most specific declaration of a given name wins.
The type grammar
Ordinary properties infer their type from their value. Explicit types exist only for contracts, and this is all of them:
string | number | boolean | null | Foo | T[] | T?| Type | Matches |
|---|---|
string | a string value |
number | a number value |
boolean | a boolean value |
null | a null value |
Foo | a spec reference whose spec inherits Foo |
T[] | a list whose every item matches T |
T? | T, null, or an absent property |
No generics. No unions. No intersections. This grammar exists to state what a framework needs, and it turns out that what frameworks need is "a string", "some strings", and "references to things of this kind".
The Foo case is the one doing real work:
export abstract spec Skill as skill:
abstract use: Skill[]?
export skill Base:
prompt:
base
export skill Uses:
prompt:
uses
use:
- Base # fine, Base is a Skill
spec Unrelated:
foo: 1
export skill Broken:
prompt:
broken
use:
- Unrelated # error PSY4001A named type must name a spec, resolved in the module where the abstract property
was declared. An unknown name is PSY4005.
Psy never coerces between kinds. A property that resolves to a different kind than its contract requires is an error, not a conversion.
Exposing a keyword with as
abstract spec Skill as skill:
abstract prompt: stringThat is the whole feature. as skill says: anyone who activates this vocabulary
may write skill Foo:.
Rules, briefly:
- Only abstract specs may use
as.PSY2005otherwise. - Keywords must be lowercase, and may use kebab-case:
skill,build-agent,compiler-stage. PSY2004covers the rest —as Skill,as buildAgentandas build_agentare all rejected.- A keyword may not shadow a reserved word.
PSY3021.
And an abstract spec that exposes a keyword may be exported, because now there is something to export: the keyword.
export abstract spec Skill as skill: // allowed
abstract prompt: stringWhat a framework declaration actually means
Given abstract spec Skill as skill:, this:
skill ReadSpec:
prompt:
Read the specification.spec ReadSpec extends Skill:
prompt:
Read the specification.That is not an analogy. It is the definition, and psy inspect cannot tell them
apart except by the recorded keyword:
ReadSpec (skills.psy)
abstract: false
keyword: skill
precedence: skills.psy#ReadSpec > framework.psy#SkillWhich is why framework declarations have every normal spec behaviour — they export, they extend, they get referenced, they compose:
agent BuildLexer extends BuildPsy:
prompt:
+ super
Focus on the lexer.When a framework declaration also has explicit parents, the explicit parents
outrank the keyword's abstract spec, which gets appended last. BuildLexer
linearizes as BuildLexer, BuildPsy, Agent.
(That rule has a name in the specification — KeywordBaseOrder — because it is
exactly the sort of thing that would otherwise get decided twice, differently.)
Activating with use
use "./framework"If the target module exports keyword-exposing abstract specs, those keywords
become valid here. That is all use does, and the "all" is important:
-
useimports only exposedaskeywords. -
usedoes not import symbols. If you need the nameSkillitself — to writeabstract use: Skill[], say — import it separately:use "./framework" import { Skill } from "./framework" -
useis all-or-nothing per module. Every keyword the target exports, or none. -
Activating the same keyword from two different specs is
PSY3009. Reaching the same spec by two paths is fine. -
Using a keyword nothing activated is
PSY3008.
A module's active keywords are the ones its own abstract specs expose, plus the
ones exported by everything it uses. Discovery follows re-export chains, so an
index module is a perfectly good framework entry point:
// framework/index.psy
export { Skill, Agent } from "./vocabulary"use "./framework"use takes a quoted specifier and resolves it like any other module — see
More Than One File. The noUnusedUse lint
rule will tell you when a module activates keywords it never writes.
Building one, end to end
A framework has two halves. The Psy half declares the vocabulary. The TypeScript half — which is optional — validates meaning and generates output.
The Psy half is the one that changes what the language looks like. The TypeScript half never can, and I will come back to why that matters.
1. Declare the vocabulary
// framework.psy
export abstract spec Pipeline as pipeline:
abstract description: string
abstract stages: Stage[]
export abstract spec Stage as stage:
abstract description: string
abstract command: string
retries: 0Nine lines. Give the abstract spec concrete defaults where it helps — retries: 0
is inherited by every stage and can be composed with + super.
2. Use it
use "./framework"
export stage Install:
description: Install dependencies
command: npm install
export stage Test:
description: Run the test suite
command: npm test
retries: 1
export pipeline Ci:
description: The continuous integration pipeline
stages:
- Install
- Testpsy checkcheck: no problems found in 2 file(s)At this point, with no TypeScript written at all, psy check already
enforces the contracts.
main.psy:4:1 error PSY3015: `Install` does not define abstract property `command`.
framework.psy:8:5: `command` is required here
main.psy:16:11 error PSY4001: `Ci.stages` must be `Stage[]` but `Deploy` is a Pipeline.
framework.psy:3:5: `stages` is declared here
main.psy:15:11 error PSY3002: cannot find name `Instal`.A stage without a command is PSY3015. A stages entry that is not a Stage
is PSY4001. A typo in Install is PSY3002.
For a lot of frameworks, this is where you stop.
3. Validate what a type cannot say
Contracts cover shape. A framework covers meaning — that a command is not the empty string, that two things do not collide, that a reference points somewhere sensible.
import type { Framework } from "@psy/framework";
import { getString, getSpecRefs } from "@psy/framework";
export function pipelines(): Framework {
return {
name: "pipelines",
keywords: ["pipeline", "stage"],
validate(context) {
const stageIds = new Set(context.specsByKeyword("stage").map((s) => s.id));
for (const pipeline of context.specsByKeyword("pipeline")) {
const stages = getSpecRefs(pipeline, "stages");
if (stages.length === 0) {
context.report({
severity: "warning",
message: `${pipeline.name} has no stages.`,
spec: pipeline,
});
}
for (const ref of stages) {
if (!stageIds.has(ref.specId)) {
context.report({ message: `${ref.name} is not a stage.`, spec: pipeline });
}
}
}
for (const stage of context.specsByKeyword("stage")) {
if ((getString(stage, "command") ?? "").trim() === "") {
context.report({ message: `${stage.name} needs a command.`, spec: stage });
}
}
},
};
}A framework receives resolved IR and a diagnostic sink. That is the entire
surface. getSpecRefs is the helper that matters most for vocabularies — it turns
stages: [Install, Test] into actual referenced specs rather than a list of
strings that might be names.
Full type definitions are in the reference.
4. Generate something
import type { Target } from "@psy/framework";
import { PSY_GENERATED_MARKER, getString, getSpecRefs } from "@psy/framework";
export function githubActions(options: { output?: string } = {}): Target {
const output = options.output ?? ".github/workflows";
return {
name: "github-actions",
output,
generate(context) {
return context
.specsByKeyword("pipeline")
.map((pipeline) => ({
path: `${pipeline.name.toLowerCase()}.yml`,
contents: render(context, pipeline),
}))
.sort((a, b) => (a.path < b.path ? -1 : 1));
},
};
}Three rules for a target that will not annoy you later:
- Be deterministic. Sort everything. Never read the clock, the environment, or
the filesystem.
psy buildrun twice must produce no diff, andpsy build --checkwill hold you to it. - Carry the marker. Put
PSY_GENERATED_MARKERin every file, ideally with the source.psypath. That is howpsy buildrecognises — and prunes — artifacts it wrote last time, so a renamed spec does not leave an orphan behind. - Consume IR only. Never re-parse
.psyfiles. If you need something that is not in the IR, the IR is what should change.
And one rule that is more of a plea: do not silently drop information. The Claude adapter renders every declared string or string-list property that it does not already place somewhere, which means adding a property to a declaration in Psy adds a section to the output with no adapter change. That behaviour is worth copying.
5. Wire it up
import { defineConfig } from "@psy/config";
import { pipelines } from "./framework/pipelines";
import { githubActions } from "./framework/github-actions";
export default defineConfig({
source: ["./pipelines"],
frameworks: [pipelines()],
targets: [githubActions({ output: ".github/workflows" })],
});psy check
psy buildcheck: no problems found in 2 file(s)
build: 1 artifact across 1 target (1 updated, 0 unchanged)
github-actions: 1 artifact in .github/workflows.github/workflows/ci.yml:
# Generated by Psy from pipelines/main.psy. Do not edit by hand.
name: Ci
on: [push]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- run: npm install
name: Install dependencies
- run: npm test
name: Run the test suitepsy build --checkbuild: 1 artifact across 1 target (0 stale)Done.
What a framework is not allowed to do
This is the boundary the whole design hangs off, so let me be blunt about it.
A framework may: validate domain semantics, inspect exported specs, generate output, produce diagnostics.
A framework may not: alter how Psy parses, alter core resolution semantics, introduce declaration keywords, or inject symbols.
Declaration keywords come from Psy source alone — an abstract spec with as,
activated by use. The TypeScript half only ever knows about keywords; it can
never create one.
That is enforced by construction rather than by policy: @psy/framework exposes
no route to the lexer, the parser or the resolver. There is no API to abuse. A
framework physically cannot change what a Psy program means, which is why you can
read any .psy file without first finding out what plugins the project installed.
The layering rule
Psy a general declarative language
your framework a domain vocabulary
your adapter concrete outputNothing in a lower layer may depend on a higher one. Psy itself must never learn what a pipeline is, or a skill, or a syntax rule.
I hold to this even where it would be convenient not to. Psy's own AI vocabulary, its own language specification, and its own build agents are all built as ordinary frameworks on top of an unmodified core compiler — the core has never heard of any of them. If the layering ever broke, it would break there first, which is precisely why I put them there.
The next chapter is that vocabulary.
Next: What Morphic Is