Wrap an instruction in :: delimiters and it becomes a slot. The psl compiler fills the slot with code the model writes, in place, in the file you already have.
package mainimport "fmt"// :: one-line doc comment for Fib, Go style ::func Fib(n int) int {if n < 2 {return n}a, b := 0, 1:: fill in the iterative loop and return, using a and b ::}func main() {fmt.Println(Fib(10))}
One run, one slot. Run it again until the file has none left.
:: xxx ::That is all of it. No keywords, no imports, no config in the file. Whitespace inside the delimiters is optional, and a slot may span several lines.
The model is shown the whole file with the slot marked, so what comes back is written in the surrounding language and reuses the names already defined above it. A slot alone on its line is indented to that line's column.
C++'s std::cout, Rust's Foo::Bar, PHP's self::method — scope resolution always glues :: to an identifier, and a glued :: is never a delimiter. Existing code in the file keeps working the way it reads.
Each run resolves the first remaining slot and writes it back into the file. The file is always a real file, never a half-generated draft, and you can stop and read it after any run.
A bare :: xxx :: uses your default model. Name one in front of the instruction — :: gpt-5.6> xxx :: — and that slot alone goes to it.
A PSL file is fib.go.psl, bot.py.psl, Program.cs.psl — the extension in the middle is not decoration. C, C#, Go, JavaScript, Python, Rust, TypeScript and Macro PSL each have their own rules for which :: belong to the language; anything else compiles under the generic rules and says so.
A slot is resolved once and frozen into the file, so an instruction that turns on a current fact is only as good as what the model remembers. Put web_search=on in a model's .pslrc section and it goes and looks — the model decides whether a slot needs a search, a second model does the searching, and every query and the pages it found go into the log.
$ psl run bot.py.psl
psl run translates the PSL source into a Python file beside it, then runs that file. bot.py.psl stays untouched; bot.py is generated for Python to execute.
for i in range(3):
print(f":: give me a new greeting for iteration {i} ::")Run it again and all three lines come back different.
In Python, a slot is a runtime expression. If Python reaches it three times in a loop, psl resolves it three times and returns a new value on every iteration.
The instruction is evaluated as an f-string where it runs, so it sees current loop and function values. The model also receives the complete original source and the slot's exact position.
Use a runtime slot as an expression or as the whole contents of a string. A slot mixed with other string text, or placed in a comment, is rejected; use an f-string when it needs surrounding text.
The second extension picks the executor as well as the rules: a virtualenv for Python, node for JavaScript, tsx, bun or deno for TypeScript, go run, a temporary binary from cc or rustc. Arguments after -- belong to your program, not to psl.
The plain psl bot.py.psl command still compiles one slot into the source on each run. Python is the first language with live runtime slots; other languages keep compile-time slot semantics under psl run — all of their slots resolved in that one run, into the generated file beside the source.
With OPENAI_API_KEY or ANTHROPIC_API_KEY exported, psl works as it stands. Write a .pslrc when you want a different default, several models in one file, or an endpoint of your own — a section is just a name, a base_url and a key, so a local model is one section like any other.
Opens the .pslrc psl would actually read here, in your editor. With no config anywhere yet it writes the example out first, so the sections are already there and only the keys are blank; a typo is reported when you close the editor, not on your next compile.
Every request psl makes is recorded. psl usage adds it up and prints what each model has spent — requests, input, output — heaviest first. The table goes to stdout and nothing else does, so it pipes straight into whatever reads tables.
Every request lands as a JSON line in ~/.psl/psl.log — model, tokens, the instruction, and any pages a slot searched. Your key is never in it, and neither are image bytes; it lives outside your repositories, so there is nothing to accidentally commit.
One line, and nothing to install alongside it. The installer takes the build for your platform from the latest release, checks it against the release's checksums — one it cannot verify is never installed — and puts psl on your PATH. No Go toolchain involved.
curl -fsSL https://raw.githubusercontent.com/lhypds/psl/main/get.sh | sh
Goes to /usr/local/bin when you can write there, and ~/.local/bin otherwise, so no root is involved either way. Pass --prefix through the pipe to put it somewhere else.
psl update
Keeps itself current from there. Every download is checked against the release's checksums — one it cannot verify is never installed, and a failed update leaves the working psl exactly where it was.