Packages: Shipping an Agent
How a Pi package carries an extension, its skills, prompts and themes as one installable unit, and which dependencies must never be bundled.
You have an extension. It registers a tool, a /guard command, a renderer for its own session entries, and a skill that tells the model when to use the tool. A colleague wants it. You send them a zip of your .pi folder, and it breaks: their guard package is a different copy of the same dependencies, their skill paths resolve against their own project, and the entry renderer never fires because the custom entry types do not match.
This chapter is about making the agent shippable. A Pi package is not a wrapper around an extension — it is the distribution unit for everything you have written.
What a package is
From packages.md: “Pi packages install and distribute extensions, skills, prompt templates, and themes as one unit. Use a package when a customization should be shared through npm or git, or when several resources belong together.”
The second clause is the one people miss. You do not need a package for a single extension file. You need one when two resources must arrive together, because one references the other. A skill that instructs the model to call your tool is the clearest case: the tool is useless without the skill, and the skill misfires without the tool.
A package is “an ordinary directory or npm package.” That is deliberately loose. It can expose conventional resource directories, declare explicit paths under the pi key in package.json, and carry its own runtime dependencies.
The simplest package
Drop resources into the conventional directories and Pi discovers them. No manifest needed.
pi-guard/
├── package.json
├── extensions/
│ └── guard.ts
├── skills/
│ └── guarded-edit/
│ ├── SKILL.md
│ └── references/
│ └── policy.md
├── prompts/
│ └── review-change.md
└── themes/
└── guard-dark.json
“Without a pi manifest, Pi discovers TypeScript and JavaScript extensions, skill directories, Markdown prompts, and JSON themes from those directories.”
Add the keyword if you want it eligible for the Pi package gallery:
{
"name": "pi-guard",
"version": "0.3.0",
"keywords": ["pi-package"],
"pi": {
"image": "https://example.com/pi-guard.png",
"video": "https://example.com/pi-guard-demo.mp4"
}
}
The pi-package keyword is what makes an npm package discoverable in the gallery. The optional pi.image and pi.video fields add previews there.
When to write the manifest
Conventional directories are the wrong shape as soon as your resources are not siblings. Use the explicit form when resources live elsewhere or need filtering:
{
"name": "pi-guard",
"version": "0.3.0",
"keywords": ["pi-package"],
"pi": {
"extensions": ["./src/extension.ts"],
"skills": ["./resources/skills"],
"prompts": ["./resources/prompts/*.md"],
"themes": ["./resources/themes/*.json"]
}
}
Three rules here matter more than the example:
- “Paths are relative to the package root.”
- “Arrays accept glob patterns and exclusions.”
- “List dot-prefixed or symlinked resource roots directly when traversal through a glob would not discover them.”
That third rule catches people. A glob that walks into a directory starting with a dot, or one reached only through a symlink, finds nothing — so you name the path explicitly instead of hoping the pattern expands.
The dependency rule that breaks builds
This is the mistake worth learning before you publish. Pi supplies five packages to your extension and skill code:
@earendil-works/pi-ai@earendil-works/pi-agent-core@earendil-works/pi-coding-agent@earendil-works/pi-tuitypebox
Declare them as peer dependencies with a "*" range. Do not bundle them.
{
"name": "pi-guard",
"version": "0.3.0",
"keywords": ["pi-package"],
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-ai": "*",
"typebox": "*"
},
"dependencies": {
"fast-glob": "^3.3.2"
},
"pi": {
"extensions": ["./src/extension.ts"]
}
}
The reason is stated in the doc without hedging: “A physical copy can bypass Pi’s extension module mapping in compiled ESM and create duplicate classes, registries, and initialization work. Pi reports an extension warning when it detects this manifest configuration.”
Two copies of a class is not a size problem. It is a type-identity problem: an isinstance check fails, a registry lookup misses, and initialization runs twice. The manifest looks correct and the behaviour is incoherent.
Pi “suppresses automatic peer installation for managed npm packages and git packages installed with npm, pnpm, or Bun.” Local packages are “not installed or modified, so their dependency tree remains the package author’s responsibility.” If you pi install ./pi-guard, you own node_modules there.
One more isolation rule: “Installed packages load with separate module roots. Do not rely on two packages sharing one dependency instance or one package resolving another package’s undeclared dependency.” If package B needs package A’s types, declare them — do not assume the resolution works.
Installing and versioning
pi install npm:@example/pi-tools@1.0.0
pi install git:github.com/example/pi-tools@v1
pi install ./local-package
| Source | Behaviour |
|---|---|
| npm | Installed under the Pi npm directory |
| git | Cloned and reconciled to the selected ref |
| URL | Treated as a git source |
| Local | Loaded from the resolved path without copying |
Versioned npm specifications are pinned, and “Git tags and commits are also pinned; package updates reconcile the checkout but do not move a configured ref.” So pi update --extensions does not silently move your team onto a new tag. That is the property you want when the package contains a permission gate.
“A file path loads one extension. A directory follows normal package discovery rules.” That asymmetry catches people who pass ~/.pi/agent/extensions/guard.ts and expect the sibling skills.
Try a package for one invocation before you commit to it:
pi -e npm:@example/pi-tools
Management is the same four commands you would expect: pi list, pi remove <source>, pi update --extensions, and pi config for enabling and disabling individual discovered resources.
Filtering what loads
The object form in settings narrows which resources load from a package:
{
"packages": [
{
"source": "npm:@example/pi-tools",
"extensions": ["extensions/*.ts", "!extensions/legacy.ts"],
"skills": [],
"prompts": ["prompts/review.md"]
}
]
}
Per resource type: omit the property to load everything the package allows, use [] for none, !pattern to exclude matches, +path to include one exact path, -path to exclude one exact path.
The limiting sentence is this: “Filters narrow the package manifest. They do not expose resources that the package itself did not declare.” A filter can make your package smaller for someone. It cannot make it larger. If your guard needs the legacy extension to be safe, you cannot ship the safety and filter the dependency away.
Scope and identity
“The same package can appear in personal and project settings. A project entry normally replaces the personal entry. With autoload: false, the project entry instead acts as a filtering delta over the personal package.” So a project that wants a different version gets a replacement, and a project that wants the personal install with one file disabled gets a delta.
Identity is what stops double loading: “Pi identifies npm packages by package name, git packages by repository URL without the ref, and local packages by resolved absolute path.” git:github.com/example/pi-tools@v1 and git:github.com/example/pi-tools@v2 are the same package, so you get one instance, not two.
When your package meets a different host
Everything above assumes Pi loads the package. It increasingly will not be the only thing that does.
An extension written against the Pi API is a portable-looking object: it imports @earendil-works/pi-coding-agent, calls registerTool, renders a widget. The moment something other than the Pi CLI loads it — an editor integration, a desktop application, a CI wrapper — you are in territory Pi’s own documentation does not cover, because from Pi’s point of view nothing about that has happened.
This is worth planning for, because the failure modes are not the ones you would guess.
The subset problem
A different host almost certainly does not implement the whole API. It may implement a dozen members and stub the rest. The interesting question is what happens when your extension touches a stubbed one.
The good behaviour is the one that seems too lenient at first:
unsupported members still exist on the object
calling one does nothing and returns a neutral value
it never throws
the host emits one diagnostic per extension per member
Read that back and you see what it buys. An extension that happens to touch an unsupported member still works. A host that has never heard of a new upstream API degrades instead of breaking. And the author finds out, because there is a diagnostic — but the run does not die.
The alternative is throwing, and throwing is how you turn a partially compatible extension into a failed session. Chapter 30 is about recovery; this is its first case.
The corollary is uncomfortable for you as the package author: on a host that stubs a member, your feature is silently absent. The extension loads, the command appears, the tool is listed, and nothing happens. There is no error. Your diagnostic burden shifts from “does it crash” to “how does the person find out it did nothing”, which means the extension should surface its own unavailable state rather than assuming it ran.
Pin the surface you promise
If a host implements a subset, the subset is a contract and has to be pinned like one. A host that tracks the installed package version automatically will report its extension surface as whatever the newest release happens to be — which means an upstream release can silently add a member your gate does not handle.
The defensible move is to describe the surface you actually implement, name it, and change it deliberately:
implement a specific, enumerated set of members
freeze the described surface as its own named version
review changes to it explicitly, with a test
Chapter 16 and chapter 17 both say to check the exported type declarations before relying on a shape. This is the same discipline from the other side: the host is declaring a contract, and an unversioned contract is not one.
What a host must never infer
This is the rule with the highest consequence, and it applies to you even though you are not the host:
The presence of a tool name in an active tool list is not a permission. It is a declaration.
pi.getActiveTools() — and pi.setActiveTools() on the other side — describe the set of tools declared to the model. They are not a grant list. An extension that treats a denied-but-declared tool as available will eventually be wrong, and the failure is silent.
If your extension has a security consequence, the permission has to come from a source the model and the extension cannot both write to. Chapter 36 is entirely about that boundary; this is the same rule seen from the portable-package side.
Importing does not promise execution
One last thing worth stating plainly, because it is easy to over-promise in your README: being loadable is not the same as being able to run everything. An import may succeed while some of the package’s dependencies cannot execute in the importing environment, and no amount of manifest care will change that. So the portability contract you can actually offer a reader is three sentences:
- This package declares its resources and its host-provided dependencies, and does not bundle them.
- On a host that implements only part of the extension API, it loads and degrades, and the unsupported members report themselves.
- Nothing in it loads into a new host without that new host’s own trust decision.
That third sentence is the one that keeps you out of trouble, and it is exactly the separation chapter 8 drew for projects.
Shipping review
Before you publish, check five things. Peer dependencies for the five host-provided packages, no copies in dependencies. Every resource your code references declared in the pi manifest, including dot-prefixed and symlinked roots. pi -e tested from a directory that is not yours. One read of the installed settings.json entry to confirm the scope you intended. And pi list, to confirm the package resolves as one instance.
Packages “can execute extension code and can include skills that instruct the model to run programs.” Review third-party package source before installing it, and review project package declarations before granting project trust — the package entry is a gate on executable content, not a bookmark.
Part II ends here. You have tools, extensions, commands, MCP servers, and a package that ships all of them. What none of that solves is the wall you hit two hours into any long task: the context window fills, and the conversation the model was reasoning over is about to be replaced by a paragraph about it. No packaging decision changes that ceiling, because the ceiling belongs to the model rather than to Pi.
Compaction is what runs when you reach the wall, and what it replaces is not what most people expect.