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, and a renderer for its own session entries; a skill tells the model when to use the tool. A colleague wants to install the same setup. Sending a copy of .pi/ may leave them with project-specific paths, missing resources, or unmanaged dependencies. A package gives those resources a single distribution unit, but does not by itself guarantee compatibility or make untrusted code safe.
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. A single extension file can be loaded directly, but packaging becomes useful when several resources must arrive together. A skill can teach the model when to use a tool shipped by the same package. The tool may still be useful without the skill, and an active tool already has a model-facing description; packaging ensures that the intended resources can be distributed together, not that the model will always select them.
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
Put resources in the conventional directories and Pi can discover them without an explicit pi resource manifest. An npm package still needs the appropriate package metadata, and Pi must load the directory as a package; merely creating these folders does not install them.
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.
Two ways a package declares its resources
The choice is between convention and an explicit manifest, and it is worth choosing deliberately rather than defaulting.
flowchart TD
P["a package directory or npm package"] --> Q{"does package.json declare<br/>a `pi` key?"}
Q -->|"no"| CONV["conventional discovery<br/>pi finds extensions/, skills/, prompts/, themes/"]
Q -->|"yes"| EXPLICIT["the manifest is authoritative<br/>extensions, skills, prompts, themes,<br/>and optionally mcpServers"]
CONV --> LOAD["resources registered on discovery"]
EXPLICIT --> GATE{"filters needed?<br/>settings object form"}
GATE -->|yes| NARROW["only matching resources load"]
GATE -->|no| LOAD
NARROW --> LOAD
Without a manifest, discovery is by conventional directory names. With one, the manifest is authoritative — and that is the moment a resource that is not listed stops loading, which is the useful half of the feature.
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 provides several host libraries for extensions, including:
@earendil-works/pi-ai@earendil-works/pi-agent-core@earendil-works/pi-coding-agent@earendil-works/pi-tuitypebox
For host-provided packages your code actually imports, follow the documented peerDependencies rule with a "*" range rather than installing or bundling a second runtime copy. You do not need to declare all five as peers when your package uses only a subset.
{
"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 are not merely a size problem. JavaScript checks such as instanceof can fail across duplicated module instances, and separate registries or initialization state may produce inconsistent behaviour. This is why a package can appear to install correctly yet behave incorrectly at runtime.
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.
A dependency-resolution change in 1.0.1 alters how much of that is your problem. npm-shrinkwrap.json was removed from the published package, so “npm installations no longer pin transitive dependencies, and library consumers can now override them.” Pi’s own quickstart now contrasts the two paths explicitly: the pi.dev installer “pins all dependencies”, while installing from npm “does not pin transitive dependencies.”
For anyone shipping a permission gate, distinguish the dependencies of the Pi host from those of your package. A managed Pi installation may pin Pi’s own dependency tree, but that does not automatically pin the third-party packages your extension declares. A lockfile, explicit versions, and a repeatable installation test are still relevant to your package. Host-provided libraries should remain peers rather than bundled runtime dependencies. This supply-chain concern complements the authority boundaries discussed in chapter 42; neither replaces the other.
It also explains a behavioural change in the same release: pi update on a global npm installation now recommends migrating to the managed installation, precisely because that is the path that pins. If you deploy Pi itself, that recommendation is about your reproducibility, not about Pi.
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.
One possible host-compatibility policy is to provide neutral stubs for unsupported members and emit diagnostics:
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, the corresponding feature may be absent even if the extension loads. Do not assume its command is registered or its tool appears; those depend on which APIs the host actually implements. Treat a diagnostic as evidence of missing functionality, and check required capabilities explicitly. A security-critical extension should refuse to claim protection when the host has not installed its enforcement hooks.
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 42 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 the host-provided libraries it actually uses without bundling duplicate runtime copies.
- A different host must document which extension APIs it supports; loading, degradation, and diagnostics are not guaranteed without testing that host.
- A host should make its own explicit trust decision before executing third-party extension code. Packaging alone does not enforce that 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 publishing, check the host-provided libraries your code imports: declare those as peers rather than duplicating them in dependencies. If you use an explicit pi manifest, include every resource the package must load, including paths requiring explicit listing. Test pi -e from a different working directory; inspect the resulting settings.json entry after installation to confirm the intended scope; and use pi list to check that the package is registered as expected. Finally, exercise its actual commands, tools, and guards: appearing in a package list does not prove they work.
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 can distribute related resources. Packaging does not remove the model’s context-window limit. As a long session grows, Pi may need to compact older conversation history into a shorter summary. The limit comes from the selected model, while when and how compaction occurs are Pi runtime behaviours.
Compaction is what runs when you reach the wall, and what it replaces is not what most people expect.
What was run
examples/ch22-packages/ builds a real package directory with a prompt template, a skill and an extension, and drives the shipped binary against it:
pi install ./my-pkgrecords the package in the user settings relative to the settings file (../my-pkg) and copies nothing;pi listshows it,pi removetakes it away, and after removal its prompt template no longer expands;- with no manifest, the package’s template and extension load from the conventional directories, and the extension’s factory ran once;
pi -e ./my-pkgloads it for one invocation and writes nothing to settings;- the object form filters a package:
prompts: []loaded no prompts and the extension still loaded; - filters narrow and never expose: with a manifest that declares only
./elsewhere/*.md, naming the undeclaredprompts/greet.mdin a filter did not load it; - the same package listed twice loaded once, because identity is the resolved path;
- an explicit manifest can point at resources elsewhere in the package, and then replaces conventional discovery;
pi install --localwrites to the project’s.pi/settings.json, and the project’s package loaded only when the project was approved;- the dependency rule produces a warning. A package that listed
@earendil-works/pi-aiindependenciestriggered a warning about declaring host-provided libraries inpeerDependencieswith a"*"range; the peer-dependency variant did not trigger that warning. This demonstrates diagnostic detection, not proof that dependency duplication is impossible.
Not run: git and npm sources, pi update, and autoload: false.