Your First Spec

Install Psy, write a module, and get the compiler to tell you things about it.

Nothing in this chapter is hard. The point is to get you to a working project fast enough that the next chapters have something to talk about.

Installing it

npm install -D @psy/cli

I would install it as a dev dependency rather than globally. You can go global, but npx psy exists and pinning a version per project has never once made me sad.

There is also a standalone binary that carries the entire toolchain — no Node, no node_modules, nothing installed in the project it compiles:

npm run compile:install     # writes ~/.local/bin/psy

That is the one I actually use. Installation in full covers building it, cross-compiling it, and what wins when a project has its own copy of the packages.

Starting from a template

psy init my-project

It asks which template you want. If you would rather not be asked:

psy init --list                        # see what's on offer
psy init my-project --template config

minimal is the default and is roughly what we are about to write by hand. There are five others, including one that scaffolds a whole Claude Code plugin — see the CLI.

Or just write one

Make a file called services.psy:

const DefaultRegion = eu-west-1

export spec BaseService:
    region: DefaultRegion
    replicas: 2

    resources:
        cpu: 500m
        memory: 512Mi

    tags:
        - managed

export spec Api extends BaseService:
    name: api
    replicas: 4

    tags:
        + super
        - public

Compile it to JSON and you can see what all of that resolved to:

{
  "BaseService": {
    "region": "eu-west-1",
    "replicas": 2,
    "resources": { "cpu": "500m", "memory": "512Mi" },
    "tags": ["managed"]
  },
  "Api": {
    "region": "eu-west-1",
    "replicas": 4,
    "resources": { "cpu": "500m", "memory": "512Mi" },
    "tags": ["managed", "public"],
    "name": "api"
  }
}

Four things are worth noticing, and three of them are probably not what you expect.

Strings do not need quotes. eu-west-1, 500m, api — all ordinary strings. Psy is a configuration language, and configuration is mostly strings, so strings got to be the cheap thing to write. Quotes are still there when you need them ("true" is how you write the text "true" rather than the boolean).

Indentation is the structure. No braces, no dashes-then-braces, no significant commas. Four spaces per level. Tabs are an error, and I am not sorry.

extends does what you think. Api inherits region, resources and tags from BaseService, and overrides replicas with 4.

+ super is the interesting one. In Api.tags, the + super line means "take everything tags inherited, then keep going" — so Api.tags ends up as [managed, public] rather than just [public]. That is composition, it is the feature I care most about, and it gets its own chapter.

Check it

psy check services.psy

That runs the whole pipeline — lexer, parser, semantic analysis, IR — and prints any problems with a stable code and an exact location:

services.psy:14:5 error PSY3015: `Api` does not define abstract property `name`.
    framework.psy:3:5: `name` is required here

Every error has a PSYxxxx code that does not change between versions, so you can search for one and find the same thing next year. Diagnostics lists them.

Inspect it

This is the command I reach for most, and the reason I bothered building the provenance machinery at all.

psy inspect Api
Api (services.psy)
  abstract: false
  precedence: services.psy#Api > services.psy#BaseService

  tags = [
    "managed",
    "public"
  ]
  declared in Api at services.psy
  provenance:
    block list in Api.tags (services.psy:19:9)
      super BaseService.tags in BaseService.tags (services.psy:8:5)
        block list in BaseService.tags (services.psy:9:9)
          literal in BaseService.tags (services.psy:9:11)
      literal in Api.tags (services.psy:21:11)

Read that from the outside in: the block in Api.tags produced the value; it was made of the inherited BaseService.tags followed by one literal; the inherited part came from line 9.

When a value in a deeply inherited config is not what you expected, this is the difference between "I know exactly which line did that" and forty minutes of grep. Every resolved value carries it. See Under The Hood.

You can also ask about one property:

psy inspect Api.tags

Format it

psy format services.psy
psy format --check

The formatter is canonical, deterministic and idempotent. It prints from the AST, so its output is by construction valid Psy with the same meaning, and formatting already-formatted output changes nothing at all.

There is one option worth setting and one that is a preference, and both live in psy.config.ts — see Day To Day.

What you have now

A file, a checker that has opinions about it, and a tool that can explain any value in it back to you.

What you cannot yet do is anything interesting with inheritance beyond "overriding one key" — which is where the next chapter starts.

Next: Building On What You Already Wrote

On this page