PIE + pieUI — Complete Agent Skill Guide
Full reference for AI agents and developers building projects with the PIE framework. Covers both CLIs, all commands, workflow recipes, envelope policy, and edge cases.
Table of Contents
- Architecture
- Hard Rules — Never Do Manually
- pieui CLI — All Commands
- pie CLI — All Commands
- Envelope Policy
- Workflow Recipes
- Edge Cases
- Project Setup & .gitignore
- check-sync Findings Guide
- Previewing & Rendering Cards
- Ajax Endpoints,
stored, and the pieui 3.0.0 Migration - Express Backend —
@swarm.ing/pieui/server - Realtime — Recovery, Link Status, Subscription Sharing
1. Architecture
PIE is a fullstack framework. Frontend and backend are strictly separated, each with its own CLI.
┌─────────────────────────────────────────────┐
│ Platform (pieui.swarm.ing) │
│ username/my-project/MyCard │
│ ├── python/ ← pushed by pie CLI │
│ └── typescript/ ← pushed by pieui CLI │
└─────────────────────────────────────────────┘
↑ push/pull ↑ push/pull
┌────────────────┐ ┌────────────────────────┐
│ Backend │ │ Frontend │
│ Python/FastAPI │ │ TypeScript/Next.js │
│ pie CLI │ │ pieui CLI │
│ pages/ │ │ piecomponents/ │
│ components/ │ │ app/ │
└────────────────┘ └────────────────────────┘
| Layer | Language | CLI | Key directories |
|---|---|---|---|
| Frontend | TypeScript / Next.js | pieui | piecomponents/, app/ |
| Backend | Python / FastAPI | pie | pages/, pages/components/ |
CLI invocation:
node /path/to/pieui/dist/cli.js <cmd>/path/to/pie/.venv/bin/pie <cmd>
2. Hard Rules — Never Do Manually
These actions must go through the CLI. Doing them manually breaks the registry or platform metadata.
| ❌ Never manually | ✅ Use instead |
|---|---|
Create a file in piecomponents/ | pieui card add ... |
Create app/<path>/page.tsx | pieui page add <path> |
Edit piecomponents/registry.ts | Updated automatically by any pieui card add/remove |
Create pages/components/*.py | pie card add ... |
Create pages/*.py | pie page add ... |
Add a methods/event key to <PieCard methods={...}> | pieui add-event <Card> <event> |
| Add an IO event to a Python card | pie card add-event <Card> <event> |
| Delete a piecomponent directory | pieui remove <ComponentName> |
Other hard rules:
"use client"— required at the top of every PIE card TSX file.<button type="button">— always set; without it the browser submits to/api/process/.is_typed=False— required in everyAsyncPagesubclass that does not overrideget_content.- Python
snake_case→ camelCase on frontend.send_labelbecomessendLabel. Match in both TS types and component code. - After editing
web.py(adding a new route) — restart the backend process. - Never commit
.env,.pie/,.claude/,node_modules/,.next/,__pycache__/,.venv/.
3. pieui CLI — All Commands
3.1 Auth
pieui login
Opens a browser URL for OAuth. On success writes { user_id, api_key, project } to .pie/config.json and appends vars to .env.
When: Before any card remote operation or on first project setup.
Note: Run from the project root so config is saved to the correct .pie/config.json.
3.2 Init
pieui init [--out-dir <dir>]
Creates piecomponents/registry.ts. Required once per new frontend project.
| Flag | Default | Description |
|---|---|---|
--out-dir, -o | . | Base directory for piecomponents |
pieui init
pieui init --out-dir packages/app # monorepo sub-package
3.3 Create project
pieui create <AppName> # scaffold Next.js app + run pieui init
pieui create-pie-app <AppName> # blank Next.js template only
pieui create-pieui <AppName> # alias for create-pie-app
When: Starting a new frontend project from scratch.
3.4 page add
pieui page add <path>
Generates app/<path>/page.tsx with the standard PIE Suspense wrapper.
pieui page add dashboard
pieui page add wallet/send
pieui page add chat/room
When: Every new route. One command per page. No flags.
3.5 card add
pieui card add [type] <ComponentName> [--io] [--ajax]
Scaffolds piecomponents/<ComponentName>/ with index.ts, types/index.ts, ui/<ComponentName>.tsx.
Updates piecomponents/registry.ts automatically.
Types:
| Type | Props signature | Use when |
|---|---|---|
simple | { data } | Card displays data, no children |
complex | { data, children } | Card wraps other components |
simple-container | { data, content } | Card has a single content slot |
complex-container | { data, content[] } | Card has array content slots (default) |
Flags:
| Flag | What it adds |
|---|---|
--ajax | pathname, depsNames, kwargs fields to the data interface |
--io | Realtime / websocket support fields |
pieui card add simple ProfileCard
pieui card add complex-container LayoutCard
pieui card add simple LiveTickerCard --io
pieui card add simple ContactFormCard --ajax
pieui card add simple ChatCard --io --ajax
3.6 card remove
pieui remove <ComponentName>
Deletes piecomponents/<ComponentName>/ and removes the entry from registry.ts.
When: Removing a component. Never delete manually.
3.7 list
pieui list [filter] [--src-dir <dir>]
Prints a table of all registered components: Name, Type, Data Type, Lazy, File.
| Filter | Shows |
|---|---|
| (none) | All |
simple | Simple only |
complex | Complex only |
simple-container | Simple containers |
complex-container | Complex containers |
pieui list
pieui list simple
pieui list complex-container --src-dir app
3.8 list-events
pieui list-events <ComponentName> [--src-dir <dir>]
Prints all methods keys declared in <PieCard card="X" methods={...} /> for the component.
When: Before adding a new event (to avoid duplicates); auditing a card's event surface.
3.9 add-event
pieui add-event <ComponentName> <event> [--src-dir <dir>]
Adds a new key with a default handler to <PieCard ... methods={{ <event>: handler }}> in the TSX.
pieui add-event ContactFormCard submit
pieui add-event ChatCard message
When: Adding event handlers to an existing card. Always use this — never edit methods manually.
3.10 postbuild
pieui postbuild [--out-dir <dir>] [--src-dir <dir>] [--append]
Scans piecomponents and generates a manifest JSON for SSR / production builds.
| Flag | Default | Description |
|---|---|---|
--out-dir, -o | public | Output directory for manifest |
--src-dir, -s | src | Source directory to scan |
--append | off | Include built-in pieui components in the manifest |
When: Part of the production build step (next build).
3.11 card remote push
pieui card remote push <ComponentName>
Uploads piecomponents/<ComponentName>/ to the platform under the typescript/ envelope. Assigns a new revision @N.
When: After implementing or updating a card.
Note: Each push creates a new immutable revision. Old revisions remain pullable by @N.
3.12 card remote pull
pieui card remote pull <ComponentName>[@rev]
pieui card remote pull <project>/<ComponentName>[@rev]
pieui card remote pull r/<user>/<ComponentName>
Downloads a component into piecomponents/. Overwrites local files.
| Form | Access |
|---|---|
MyCard | Latest from current project |
MyCard@7 | Specific revision from current project |
other-proj/MyCard | Another project of the same user |
r/username/MyCard | Public component by any user |
pieui card remote pull ProfileCard
pieui card remote pull ProfileCard@3
pieui card remote pull r/someuser/PriceTickerCard
3.13 card remote list
pieui card remote list [--user <U>] [--project <S>]
Lists component names stored on the platform.
Note: --user / --project only works if your API key has access to that project.
3.14 card remote history
pieui card remote history <ComponentName> [--page N] [--per-page N] [--from R] [--to R]
Shows revision history with per-file diffs (git-style). Includes both python/ and typescript/ envelope files.
| Flag | Default | Description |
|---|---|---|
--page | 1 | Page number |
--per-page | 10 | Revisions per page |
--from | — | Start revision number |
--to | — | End revision number |
pieui card remote history ProfileCard
pieui card remote history ProfileCard --from 3 --to 7
When: Auditing what changed between pushes; debugging regressions; verifying both envelopes were pushed.
3.15 card remote public / private
pieui card remote public <ComponentName> # accessible as r/username/MyCard
pieui card remote private <ComponentName> # revert to private
3.16 card remote remove
pieui card remote remove <ComponentName>
Deletes the component from the platform (all revisions). Does not touch local files.
4. pie CLI — All Commands
4.1 Auth
pie login
Same OAuth flow as pieui. Writes to .pie/config.json in CWD and appends to .env.
Critical: Run from the backend project root. pie reads .pie/config.json relative to CWD.
If env vars PIE_USER_ID, PIE_API_KEY, PIE_PROJECT are set, they take precedence over the config file.
4.2 page add
pie page add <path>
Creates pages/<slug>.py with an AsyncPage subclass.
pie page add dashboard
pie page add wallet/send
When: Every new backend route. Then register the page in web.py and restart the server.
4.3 card add
pie card add <type> <ComponentName> [--io] [--ajax] [--input] [--from REF]
Creates pages/components/<snake_name>.py with a Card dataclass.
pie card add simple ProfileCard
pie card add complex ChatCard
The type is required here. Unlike pieui card add, omitting it is an argparse
error — pie's choices are simple, complex, container, complex-container
(note container, where pieui says simple-container).
--from REF ports from the frontend: a .ts/.tsx file, a piecomponents dir, a
PieMetadata .json, or a bare card name resolved via frontendComponentsDir. Omit
it and it auto-resolves by name when a frontend project is configured.
When: Same time as pieui card add — both sides must exist.
4.4 card list
pie card list
Prints a table: Name, Type, Ajax, IO, File path.
4.5 card view
pie card view <ComponentName>
Pretty-prints a card's props table (field name, Python type, default value) plus Ajax / IO / Events flags.
pie card view ProfileCard
When: Quick inspection of a card's contract without opening the file.
4.6 card dump-metadata
pie card dump-metadata <ComponentName> [--out <file.json>]
Outputs full JSON metadata in the Python envelope format:
{
"python": {
"name": "MyCard",
"propsSchema": { "properties": { ... }, "type": "object" },
"propsCode": "@dataclass\nclass MyCard...",
"ajaxList": ["pathname"],
"events": [],
"eventsPropsCode": {},
"eventsPropsSchema": {},
"inputPropsCode": null,
"inputPropsSchema": null,
"files": [{ "path": "my_card.py", "content": "..." }],
"packages": ["pie"]
}
}
| Flag | Default | Description |
|---|---|---|
--out, -o | stdout | Write JSON to file |
pie card dump-metadata ProfileCard
pie card dump-metadata ProfileCard --out /tmp/profile_card_meta.json
Prop types imported from project-local modules are resolved. A prop typed with a value object defined in a sibling module no longer serializes as an opaque schema — resolution is pure AST + filesystem walking under the project root, and nothing is imported at runtime. Two limits: on a name clash the same-file class wins, and import chains are not followed (only the card file's direct dependencies are visible, so a dataclass imported by an imported module stays opaque).
4.7 card check-sync
pie card check-sync [ComponentName]
Compares the Python props schema (local) against the TypeScript props schema (from frontendProjectDir) field by field.
ComponentName is optional — omit it and every card in the components dir is
checked. Batch mode prints [pie] {n}/{total} card(s) out of sync: <names> or
[pie] All {n} card(s) aligned ✓. Exit 0 = all aligned, 1 = any finding or a
card that failed to build/dump.
It shells out to the frontend CLI — bun $PIE_CHECK_SYNC_PIEUI_CLI (env override
pointing at a local cli.ts/cli.js), then bunx pieui, then npx pieui — and
strictly requires a {"typescript": …} envelope back from pieui card dump-metadata.
A stale pieui that doesn't emit it fails every card, and with neither bunx nor npx
on PATH you get [pie] Neither bunx nor npx found in PATH.
Python int props no longer report a spurious type_mismatch against a TypeScript
number: JSON-Schema integer is canonicalized to number before comparing.
Requires frontendProjectDir in .pie/config.json:
{
"user_id": "...",
"api_key": "...",
"project": "...",
"frontendProjectDir": "/absolute/path/to/frontend-project"
}
When: After editing either side; before pushing to the platform; as part of CI. See section 9 for interpreting output.
4.8 card list-events / card add-event
pie card list-events <ComponentName> # list IO events (static parse)
pie card add-event <ComponentName> <event_name> # add event handler stub
4.9 card pull
pie card pull <REF>
Downloads a Python card from the platform into pages/components/.
| REF form | Access |
|---|---|
MyCard | Current project |
other-proj/MyCard | Another project of the same user |
r/username/MyCard | Public component by any user |
4.10 card remote push
pie card remote push <ComponentName>
Uploads the Python card to the platform under the python/ envelope.
Known issue: When PIE_USER_ID / PIE_API_KEY / PIE_PROJECT env vars are set, pie searches for config in parent directories instead of CWD. This causes "user_id required" errors even if .pie/config.json exists locally.
# Preferred: run from project root with no conflicting env vars
cd my-api-project && pie card remote push MyCard
# Fallback: pass all credentials explicitly
PIE_USER_ID=username PIE_API_KEY=rp-xxx PIE_PROJECT=my-project \
pie card remote push MyCard
4.11 web
web takes a module:attribute, not a bare verb:
pie web web:app # run the FastAPI server
pie web web:app verify # lint the Web instance
pie web web:app build # static JSON export
4.12 init
pie init
Sets up Pie in an existing directory (pages/, components/, web.py) and prompts
to link a frontend project, stored in .pie/config.json as frontendProjectDir /
frontendComponentsDir.
It also scaffolds <frontendComponentsDir>/preview-providers.tsx — the pass-through
wrapper the preview harness wraps around every previewed card. This happens on
re-runs too, so existing projects pick it up retroactively; an existing file is
never overwritten. See section 10.
4.13 self-upgrade
pie self-upgrade [--pm uv|poetry|pip]
Auto-detects the package manager from sys.executable's path (uv tool venv → uv,
poetry venv → poetry, else pip), and runs uv tool install --force pieui[cli],
poetry update pieui, or <python> -m pip install --upgrade pieui[cli].
The [cli] extra is always forced — a plain pip install pieui yields a CLI that
dies on import, so upgrades also repair such installs. It deliberately avoids
uv tool upgrade, which would re-resolve the original spec and leave a bare pieui
tool broken forever.
4.14 card channels / card emit — realtime
Backend-only; there is no pieui mirror.
pie card channels [web:app] [--live] [--json]
pie card emit <NAME> <EVENT> <CHANNEL> [--data '<json>'] [--web web:app]
channels lists Centrifuge-enabled cards across all pages with their channels —
static literals and f-string templates — and --live reconciles them against a
running Centrifugo. The composed channel is pie{event}_{name}_{channel}, so always
discover a valid CHANNEL with card channels before emitting.
4.15 taskrun
pie taskrun local <module:attr> <PAGE> <ACTION> [params…]
pie taskrun remote <module:attr> <PAGE> <ACTION> [params…]
remote supports several named backends. The target pseudo-args live in
params (they are popped from kwargs, not argparse flags):
pie taskrun remote web:app dashboard refresh --remote=staging
pie taskrun remote web:app dashboard refresh --remote-url=https://api.example.com
.pie/config.json gains a remotes ({name: url}) map and defaultRemote; the
legacy single-string remoteUrl still works. Env PIE_REMOTE is a name,
PIE_REMOTE_URL a full URL.
Precedence: --remote-url/--url → --remote/--remote-name → $PIE_REMOTE →
defaultRemote → remoteUrl → $PIE_REMOTE_URL → interactive prompt (saved back).
Gotchas: an unknown remote name is terminal — it errors listing the available
remotes rather than falling back to remoteUrl. Malformed remotes entries are
silently dropped. Task files generated before this change still print the old
precedence in their summary until regenerated.
4.16 db — Beanie/MongoDB layer
Backend-only by design (MongoDB has no frontend analog), so there is no pieui db.
pie db init | check | status | shell
pie db model add <NAME> [--field NAME:TYPE …] [--collection NAME] [--timestamps] [--index FIELD[,unique] …]
pie db model list | view <NAME> | remove <NAME> | add-field | rename
pie db card add <DOCUMENT> <CARD> # injects to_ui_<card>_card / list_ui_<card>_cards
pie db card list | remove
pie db index list [MODEL] | sync
pie db pull <COLLECTION> [--as NAME] [--limit 50]
pie db export <COLLECTION> [-o FILE] | import <COLLECTION> --in FILE
pie db seed new <NAME> | run [NAME]
pie db migration new <NAME> | list
pie db migrate [--distance N] | rollback [--distance N] # 0 = all
Beanie 2.x uses pymongo's AsyncMongoClient (no Motor). db model remove,
db import, and db rollback are destructive — require explicit user intent, and
remember --distance 0 means all migrations.
4.17 cloudflare — Pyodide Python Worker
Backend-only; no pieui mirror.
pie cloudflare init [--app MODULE:ATTR] [--name WORKER]
pie cloudflare dev [--port 8787]
pie cloudflare deploy
init writes src/worker.py + wrangler.jsonc and symlinks web.py / pages/
into src/ (no copies — single source of truth). Install with
pip install 'pieui[cloudflare-worker]' (Python ≥3.12). Deploy needs
CLOUDFLARE_API_TOKEN; account-scoped tokens also need CLOUDFLARE_ACCOUNT_ID,
otherwise wrangler's /memberships lookup fails with error 9106.
4.18 card show / show-mcp / show-set / show-emit
The whole show* family is backend-only. See
section 10 for the full architecture.
5. Envelope Policy
The platform stores each component in two separate envelopes:
username/my-project/MyCard/
├── python/my_card.py ← written by pie card remote push
└── typescript/
├── piecomponents/MyCard/index.ts
├── piecomponents/MyCard/types/index.ts
└── piecomponents/MyCard/ui/MyCard.tsx ← written by pieui card remote push
Rules:
pie card remote pushwrites onlypython/. It never touches TypeScript files.pieui card remote pushwrites onlytypescript/. It never touches Python files.pie card pullrestores only the Python file locally.pieui card remote pullrestores only the TypeScript files locally.card remote historyshows diffs for both envelopes in a unified revision timeline.check-syncreads both envelopes and compares their schemas.
A component is fully published only after both pie card remote push AND pieui card remote push have been called.
API key scope: Each project has its own API key. A key for project A cannot read project B even for the same user_id. Log in per-project to get the correct key.
6. Workflow Recipes
Recipe 1 — Create a page with an AJAX card
# Frontend
pieui page add my/route
pieui card add simple MyCard --ajax
# → implement piecomponents/MyCard/types/index.ts and ui/MyCard.tsx
# Backend
pie page add my/route
pie card add simple MyCard
# → implement pages/components/my_card.py and pages/my_route.py
# → register in web.py: "my/route": MyRoutePage()
# → restart backend
The --ajax flag adds pathname, deps_names, kwargs to both scaffolded files, enabling page-navigation callbacks from the card.
Recipe 2 — Create a realtime card with events
# Frontend
pieui card add simple LiveDataCard --io
pieui add-event LiveDataCard update # adds methods.update handler to TSX
# Backend
pie card add simple LiveDataCard
pie card add-event LiveDataCard update
# Implement both sides, then push
pieui card remote push LiveDataCard
pie card remote push LiveDataCard
Recipe 3 — Verify backend ↔ frontend contract
# 1. Dump Python schema for reference
pie card dump-metadata MyCard --out /tmp/my_card_meta.json
# 2. Run sync check
pie card check-sync MyCard
# 3. Fix any real mismatches, then re-verify
pie card check-sync MyCard
# 4. TypeScript compile check
npx tsc --noEmit
See section 9 to distinguish real bugs from expected differences.
Recipe 4 — Publish a card
# Push TypeScript side
pieui card remote push MyCard # → MyCard@1
# Push Python side
pie card remote push MyCard # → MyCard@1
# Verify both envelopes landed
pieui card remote history MyCard
# Confirm diff shows both python/ and typescript/ files
# Optional: make public
pieui card remote public MyCard
# Now accessible as r/username/MyCard by anyone
Recipe 5 — Port a card from the platform into a new project
# Pull TypeScript files
pieui card remote pull r/username/MyCard # public component
pieui card remote pull other-proj/MyCard # your other project
pieui card remote pull MyCard@5 # specific revision
# Pull Python file
pie card pull r/username/MyCard
pie card pull other-proj/MyCard
# Register page in web.py if needed, then implement business logic
Recipe 6 — Full project push
# 1. Type check
npx tsc --noEmit
# 2. Sync check all cards
for card in CardA CardB CardC; do
pie card check-sync $card
done
# 3. Push all frontend cards
for card in CardA CardB CardC; do
pieui card remote push $card
done
# 4. Push all backend cards
for card in CardA CardB CardC; do
pie card remote push $card
done
# 5. Commit
git add .
git commit -m "feat: ..."
git push
7. Edge Cases
pie card remote push ignores .pie/config.json when env vars are set
When PIE_USER_ID / PIE_API_KEY / PIE_PROJECT are exported in the shell, pie searches parent directories for config instead of CWD. Result: "user_id required" error even with a valid .pie/config.json in the project root.
Fix: Unset env vars and run from project root, or pass all three explicitly:
PIE_USER_ID=username PIE_API_KEY=rp-xxx PIE_PROJECT=my-project \
pie card remote push MyCard
check-sync requires frontendProjectDir
[pie] Frontend project path required to run check-sync.
Fix: Add to the backend project's .pie/config.json:
{ "frontendProjectDir": "/absolute/path/to/frontend-project" }
API key 403 on card remote list --project
Each project has a scoped API key. A key for project A cannot access project B, even for the same user.
Fix: Run pieui login / pie login for the target project to get its key.
Stale dev-server bundle after .env change
The Next.js dev server may cache an old SSR bundle after env changes.
Fix: Delete the build cache and restart:
rm -rf .next && bun run dev
Turbopack incompatible with certain native modules
Some npm packages ship native .node binaries that Turbopack cannot bundle.
Fix: Use webpack mode instead:
bun run dev # ensure next.config uses webpack, not --turbo
is_typed error on AsyncPage
Missing is_typed=False causes a runtime type error on page load.
Fix:
class MyPage(AsyncPage):
def __init__(self):
super().__init__(is_typed=False)
self.fields = UnionCard([MyCard(name="MyCard")])
Form submits to /api/process/ instead of handler
A <button> inside <PieCard> without type="button" is treated as a form submit button.
Fix:
<button type="button" onClick={handler}>Label</button>
Python snake_case props are undefined in TypeScript
PIE converts Python snake_case field names to camelCase when sending to the frontend.
Fix: Always use camelCase in TS interfaces:
// ❌ network_label: string
// ✅ networkLabel: string
8. Project Setup & .gitignore
Monorepo structure (frontend root + backend subdirectory)
my-project/ ← git root (Next.js frontend)
├── backend/ ← Python backend (any name)
│ ├── pages/
│ ├── web.py
│ ├── pyproject.toml
│ └── .pie/ ← gitignored
├── piecomponents/
├── app/
├── lib/
├── .env ← gitignored
├── .env.example ← committed (template, no secrets)
└── .pie/ ← gitignored
If the backend directory has its own .git, remove it before committing to avoid submodule issues:
rm -rf backend/.git
git add backend/
.gitignore
# Frontend
/node_modules
/.next/
*.tsbuildinfo
next-env.d.ts
/coverage
# Backend (adjust directory name as needed)
backend/.venv/
backend/**/__pycache__/
backend/**/*.pyc
backend/.env
backend/.pie/
# Secrets & credentials — never commit
.env
.env.*
!.env.example
.pie/
# Misc
.DS_Store
*.pem
npm-debug.log*
.claude/
9. check-sync Findings Guide
pie card check-sync MyCard diffs the Python and TypeScript prop schemas. Not all findings are bugs.
| Finding | Meaning | Action |
|---|---|---|
Python allows null, TS is required+non-null | Python dataclass defaults make fields nullable in JSON Schema; TS correctly marks them required | None — backend always sends a value |
depsNames / kwargs / flow / pathname in Python, not in TS | PIE Card base class internal fields | None — framework internals, frontend doesn't use them |
integer vs number | Python int → JSON Schema integer; TS number → number | None — integers are valid JS numbers |
array vs object for List[str] / string[] | Schema-generation difference between languages | None — runtime compatible |
| Field in TS but not in Python | Missing field in backend dataclass | Fix — add to pages/components/my_card.py |
| Field in Python but not in TS (non-framework field) | Missing field in TS interface | Fix — add to piecomponents/MyCard/types/index.ts |
Completely incompatible types (e.g. string vs number) | Real contract mismatch | Fix — align types on both sides |
10. Previewing & Rendering Cards
PIE can render one card in isolation — without wiring it into a full app — for visual review, screenshots, or agent-driven inspection. Three surfaces share one mechanism.
10.1 How it works
pie (backend) pieui (frontend)
┌────────────────────────┐ HTTP GET ┌───────────────────────────┐
│ ephemeral Web app │ ◀────────── │ registry-dev harness │
│ /api/content/ │ /api/... │ (PiePreviewRoot, │
│ /api/ajax_content/… │ ──────────▶ │ no app layout) │
│ serves ONE card JSON │ │ fetches /api/content/, │
└────────────────────────┘ │ renders card by name from │
│ piecomponents/registry.ts │
└───────────────────────────┘
- The backend (
pie) serves the card envelope{ "card": "<Name>", "data": { … } }at/api/content/, plus a print-and-echo stub for every ajaxpathnameat/api/ajax_content/<path>. - The frontend is the registry-dev harness: a standalone Next app (
pieui registry dev) generated under<frontend>/.pie/registry/. It mountsPiePreviewRoot(no app chrome), readsPIE_API_SERVER, fetches/api/content/, and renders the matching component frompiecomponents/registry.ts. - The card name in the content JSON must be registered on the frontend (created via
pieui card add), and thedatakeys must be camelCase matching the TS props.
10.2 pie card show — interactive preview (human-facing)
Serves one card from an ephemeral backend and opens it in the harness. Blocks until Ctrl+C; always tears down the frontend process.
pie card show 'ProfileCard(name="p", title="Hi")'
pie card show 'ColCard([ACard(), BCard(a=1)])' --frontend-port 3210 --route /
EXPR is a Python expression that evaluates to a Card. The eval namespace = framework Card subclasses plus every Card subclass in the backend's pages/components/*.py. Any card carrying a pathname gets a print-only ajax stub auto-registered, so ajax cards respond out of the box.
| Flag | Default | Description |
|---|---|---|
--frontend-dir | frontendProjectDir from .pie/config.json | Frontend project to render with |
--frontend-port | 3000 | Port for the registry-dev harness |
--backend-port | auto (free port) | Port for the ephemeral backend |
--route | / | Frontend route to open |
--pm | autodetect | Package manager (bun/pnpm/yarn/npm) |
--no-open | off | Do not open a browser automatically |
10.3 pieui registry dev|build — the harness itself
pieui registry dev --port 3939 --api-server http://127.0.0.1:8000/
pieui registry build --out public/pie-registry
registry dev [--port N] [--api-server URL]— runs the standalonePiePreviewRootharness pointed at a backend's/api/content/. This is whatpie card showand the MCP spawn internally; run it by hand only to debug the harness or to drive it from your own backend.registry build [--out DIR]— static-export the harness sopiecan serve it directly (disable_serving=False).- Generated under
<frontend>/.pie/registry/— a separate Next app with its own.nextbuild cache (see Troubleshooting).
10.4 pie card show-mcp — headless rendering for agents (MCP)
A FastMCP server that renders cards headlessly (JSON / HTML / screenshot) and exposes their ajax — so AI agents can inspect cards without a human browser. Install extras: pip install 'pieui[mcp]' (adds mcp + playwright).
pie card show-mcp # stdio transport (default)
pie card show-mcp --http 9009 # streamable-HTTP on :9009
pie card show-mcp --frontend-port 3939 # harness port
pie card show-mcp --mirror http://127.0.0.1:8000 # mirror a live `pie card show` backend
pie card show-mcp --no-frontend # json + ajax tools only (no browser)
| Flag | Default | Description |
|---|---|---|
--http PORT | stdio | Serve over streamable-HTTP instead of stdio |
--frontend-dir | config | Frontend project dir |
--frontend-port | 3000 | registry-dev harness port |
--backend-port PORT | auto | Pin the content backend's port so an external harness can target a stable URL |
--mirror URL | — | Mirror a running pie card show backend |
--no-frontend | off | Skip frontend + browser; only render_card(json) + ajax tools |
MCP tools (the server name is chosen at registration, e.g.
mcp__pie-show-<proj>__* — match on the tool suffix, not a fixed server name):
| Tool | Purpose |
|---|---|
list_cards(source?, contains?) | Start here — don't guess names. source = all | backend | frontend; a frontend_only bucket lists cards with no backend class (render those with a content dict). The first call can take ~20s: it imports every backend component module. |
describe_card(card_name) | Data shape without reading source: backend dataclass fields + the frontend <Name>Data interface + nested helpers, plus a render_with hint (expression vs content dict). |
render_card(card?, format) | Render a card. card accepts a Python expression, a content-JSON string, or a content dict. format = json (echo content) | html | screenshot. No arg → re-render the current source. |
list_ajax() | List ajax pathnames on the current card. |
call_ajax(pathname, data?) | POST to a running ajax endpoint and return its content. |
emit_event(method, card_name, payload?) | Push pie{method}_{card_name} to the harness Mitt bus. Returns the number of event streams that received it — 0 means nothing is connected. Needs the card rendered with useMittSupport and a pieui carrying the preview-events bridge. |
get_submissions(since?) | Ajax submissions the previewed card POSTed back, id-ordered; pass since = highest id already seen. Bounded at 128 entries. |
attach(base_url) / detach() | Mirror / stop mirroring a running pie card show backend. |
harness(action?, port?, api_server?) | Manage the human-facing harness: status | start | stop | restart. Note the default port 3940, distinct from --frontend-port's 3000. |
doctor() | Run this first when anything looks wrong — checks the frontend link, Tailwind resolution, public/ serving, the preview-events bridge, backend/harness liveness, and stale processes. Every failing check carries a fix. |
cleanup_orphans(scope?) | Kill stale show-mcp processes, never itself. orphaned (default) spares instances serving other projects; all clears every other instance. |
status() | pid, backend url/liveness, active source, subscriber and submission counts, browser state. |
Notes:
describe_cardparses the frontend side with regex, not a TypeScript parser — fine for the flat fields, unions, and arrays pie cards declare, but generics, mapped types, and inline nested interfaces parse poorly. It reads<frontend>/piecomponents/<CardName>/types/index.ts.format=jsononly echoes the content envelope (no browser needed).html/screenshotneed the running harness, a linked frontend (pie init), and Playwright.- Prefer the expression form for
card. A{ … }JSON string is frequently coerced to a dict by the MCP arg layer and rejected — pass an expression likeProfileCard(name="p", …)instead. The eval namespace includes anyCardsubclass in the backend'spages/components/. - Register it as an MCP server (Claude Code / Cursor / Codex) with a small launcher that pins the backend project and
pieonPYTHONPATH:#!/usr/bin/env bash cd /path/to/backend-project PYTHONPATH=/path/to/pie exec ./.venv/bin/python -m pie card show-mcp --frontend-port 3939 "$@"
10.5 Driving a running show backend — show-set / show-emit
Two thin HTTP clients against a running show backend's loopback control API. No MCP, no stdio — useful from a shell script or a non-MCP agent.
pie card show-set <CONTENT|-> [--api-server URL] # {card, data} JSON → /api/preview/content
pie card show-emit <METHOD> <CARD_NAME> [--payload '<json>'] [--api-server URL]
show-set takes a content JSON file path or - for stdin. show-emit publishes a
pie{method}_{cardName} event via /api/preview/emit. Both print the JSON response.
Backend discovery uses .pie/show-backend.json — written by the backend at
startup as {"url": …, "pid": …} — not .pie/config.json. Without it:
"no running show backend found — pass --api-server, or start pie card show-mcp
in this project first".
Security: both write routes are loopback-only. A client that isn't
127.0.0.1 / ::1 / localhost gets 403 {"error": "preview control is loopback-only"}, and this holds even under show-mcp --http. The read routes
(/api/preview/events, /api/preview/submissions) stay open.
Note the preview stack does not use Centrifugo: show-mcp fans events out
in-process over SSE with a bounded replay buffer, so a briefly disconnected harness
catches up via Last-Event-ID without double-firing.
10.6 Preview providers
Cards that depend on app context (a theme, a wallet, a store) crash when rendered bare. The harness therefore wraps every previewed card in a preview provider:
// <componentsDir>/preview-providers.tsx ('use client')
export default function PreviewProviders({ children }: { children: ReactNode }) {
return <>{children}</>
}
This is a convention, not a config key — the path is
<componentsDir>/preview-providers.tsx (PIE_COMPONENTS_DIR, default
piecomponents). Both pie init and pieui init scaffold the pass-through version
and never overwrite an existing file; add your real providers (or mocks) inside it.
Discovery is a bare existence check at scaffold time on each registry dev|build
run, so adding or removing the file requires a harness restart. A preview error
boundary sits outside the provider and pattern-matches missing-context errors to
print an "add the provider to …" hint.
10.7 Showing the harness in an agent's side panel
To display the live harness inside a host with a web-preview panel (e.g. Claude Code's preview_*):
- Set the card via
render_cardfirst, so the backend serves it at/api/content/. - Find the backend port — the
show-mcpprocess's listening port whose/api/content/returns your card. - Launch the harness as a managed preview server. Preview tools won't reuse an externally-started server, and launch configs may lack a
cwdfield — so use a shell wrapper and an auto-assigned port:{ "name": "pie-registry", "runtimeExecutable": "bash", "runtimeArgs": ["-lc", "cd <frontend> && exec pieui registry dev --port \"$PORT\" --api-server http://127.0.0.1:<backend>/"], "autoPort": true }
10.8 Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Persistent Parsing CSS source code failed / stale build error in the preview after the source is already fixed | The harness keeps its own Turbopack/Next cache at <frontend>/.pie/registry/.next; clearing the main app .next does nothing | rm -rf <frontend>/.pie/registry/.next, then restart the harness |
pieui registry … prints the general help / acts like an unknown command | The project-local node_modules/.bin/pieui predates registry; the harness resolves project-local first | Use the global pieui (e.g. ~/.bun/bin/pieui) or run pieui self-upgrade |
| Preview shows the full app or a loading splash instead of the bare card | The harness never started (often the missing registry command above) and something fell back to a plain next dev on that port | Confirm a pieui registry dev process is actually serving the port |
render_card rejects the card with a dict/validation error | A { … } JSON string was coerced to a dict | Pass a card expression instead |
html / screenshot returns "needs a linked frontend" | No Playwright, or no linked frontend | pip install 'pieui[mcp]' and pie init to link the frontend |
| Card renders blank / "unknown card" | Name not in frontend registry.ts, or data keys are snake_case | pieui card add <Name>; use camelCase data keys |
| Anything at all looks wrong with the preview | — | Run the MCP doctor tool first; every failing check carries its own fix |
emit_event returns 0 subscribers | No harness connected, or the installed pieui lacks the preview-events bridge | Start/restart the harness; doctor flags a missing bridge |
show-mcp respawn fails on a pinned --backend-port | Orphaned processes squatting the port | cleanup_orphans (scope orphaned spares sibling projects) |
| Card crashes with a missing-context / provider error | It needs app context the bare harness doesn't provide | Add the provider to <componentsDir>/preview-providers.tsx, then restart the harness |
| Unstyled preview on a Tailwind v3 project | Harness PostCSS config missing, or a tailwind.config.ts the harness cannot re-export | It is automatic on the next registry dev; for .ts configs make the content globs absolute yourself (the CLI warns) |
Preview hits the wrong backend despite .env.local | The CLI exports PIE_API_SERVER as '', and @next/env only fills missing vars, so the empty value wins | Pass --api-server explicitly |
registry build --out DIR wrote nothing to DIR | --out is currently log-line only | Read the export from .pie/registry/out |
Heads up: killing/clearing a preview harness restarts a next dev. If the user runs their own dev server on that port, confirm before replacing it.
11. Ajax Endpoints, stored, and the pieui 3.0.0 Migration
11.1 deps_names auto-derivation (backend)
register_ajax returns an AjaxEndpoint — a str subclass equal to the pathname
that also carries the handler. When a card field holds one, Card.generate()
overwrites that endpoint's deps key from the handler signature:
- plain params → DOM deps
Dep/Sourcesubclasses (Sid,LocalStorage, …) → sourced depsKwarg/ExtraKwarg→ excludedParsedPieField/RawPieFieldareDepsubclasses (DOM-only) controlling server-side parsing
A plain-string pathname keeps the card's manual deps_names. The decision is
per field, so one card can mix an auto-derived pathname with a manual plain-string
search_pathname.
AjaxEndpoint is a copy-leaf — it shares the handler reference instead of
deep-copying it, so cards holding endpoints are safe to deepcopy / pickle.
11.2 Multiple endpoints per card, paired by field name
A card may carry several endpoints. Pairing across the stack is purely by field-name prefix — no negotiation, no schema, no ordering.
| Backend card field | Wire key | Frontend endpoint key |
|---|---|---|
pathname | depsNames | ajaxSubmits.default |
search_pathname | searchDepsNames | ajaxSubmits.search |
filter_pathname | filterDepsNames | ajaxSubmits.filter |
foo (no pathname suffix) | fooDepsNames | ajaxSubmits.foo |
Declare several by giving the dataclass several <prefix>_pathname fields assigned
from register_ajax. The frontend discovers them by scanning data keys and reading
the sibling <x>DepsNames / <x>Kwargs (missing siblings default to [] / {}).
Gotchas on both sides:
- Backend: deps keys are assigned after the field loop, so a manually declared
search_deps_namesfield is silently clobbered whenever the paired endpoint field holds anAjaxEndpoint. Use a plain-string pathname if you need manual deps. - Frontend: the generated
*Datainterface still declares only the primary triple — named endpoints must be hand-added to the TS interface; codegen documents them in a comment only. useAjaxSubmitsmemoizes onJSON.stringify(data), so non-serializable values indatamis-memoize.- pieui metadata flags a card
ajax: trueon a loose/\b\w*(?:Pathname|DepsNames)\b/regex, which false-positives on unrelated identifiers ending inPathname.
11.3 The stored prop accepts a thunk
stored?: TStored | (() => TStored) // PieCardProps
stored: TStored | (() => TStored) // all four InputPie*ComponentProps
- Plain value → unchanged: a hidden
<input name={data.name} value={JSON.stringify(stored)}>. - Function → no hidden input; a resolver is registered under
data.nameand unregistered on unmount, so the submit path reads the current value at submit time.
Gotchas: the resolver map is keyed by data.name globally — two mounted cards
sharing a name clobber each other. And because this widens a required prop type,
consumer code doing const s: TStored = props.stored stops typechecking.
For native / global-form submits, submitGlobalForm() first flushes resolvers into
#piedata_global_form as hidden data-pie-stored="1" inputs (previous ones are
removed, so re-submits replace rather than accumulate) — because
HTMLFormElement.submit() fires no submit/formdata event. Async resolvers and
File values are skipped: a native submit cannot await, and a hidden input cannot
carry a File. The ajax path is unaffected and still resolves both.
11.4 Migrating to pieui 3.0.0
The pieui CLI surface did not change in 3.x. All three breaking changes are in
the runtime:
- Generated ajax scaffolds switched
useAjaxSubmit→useAjaxSubmits: call sites move fromajaxSubmit(extra)toajaxSubmits.default(extra), and the scaffold'sdatadestructure no longer pullspathname/depsNames/kwargs. Older hand-edited components still compile (useAjaxSubmitremains exported), but newly generated cards have a different shape. PieNativeRoottakesPieRootProps, notPieBaseRootProps. It is now a full fetching root: it fetchesUIConfigfrom/api/content{pathname}{search}itself, mounts its ownQueryClientProvider, and provides the Mitt/SocketIO/Centrifuge contexts. Callers passing a staticuiConfigmust switch tolocation+config(plus optionalqueryClient/fallback/piecache/onError/onNavigate/queryOptions). It appends__pieroot=<Platform.OS>(ios/android) to the query string — the native counterpart oftelegram/max— so a backend can branch on it.storedtype widened (§11.3).
12. Express Backend — @swarm.ing/pieui/server
A TypeScript/Express mirror of the Python pie FastAPI runtime. It is an alternative
for the HTTP/UIConfig serving layer only, and a partial one — it covers pie's
route surface and nothing else in the ecosystem: no CLI, no db layer, no hub, no
serverless, no pie card show.
import { Web, AsyncPage, UnionCard, HiddenCard } from '@swarm.ing/pieui/server'
class Home extends AsyncPage {
constructor() { super(true); this.fields = new UnionCard({ content: [new HiddenCard({ name: 'email' })] }) }
async getContent(ctx) { return this.fields!.fill(ctx) }
async process(data) { return `/welcome?e=${data.email}` } // string → 303
}
const web = new Web({ '': new Home(), home: '' /* string value = alias */ }, { enableCors: true })
web.getApp().listen(8000)
The page map is keyed by pathname-without-leading-slash; a string value is an alias to another key.
Routes (prefixed by adminSubdomain, default /):
| Method | Path | Notes |
|---|---|---|
| GET | api/content/{pathname} | UIConfig JSON; 404 {error:'page not found'} |
| POST | api/process/{pathname} | string return → 303 redirect; non-string → 204 |
| POST / GET | api/ajax_content/{pathname} | POST body via form2dict+aggregate; GET from query |
| GET | api/support/:name | a bare boolean body (true/false), not an object — pie parity |
| GET | /api/centrifuge/gen_token | { token } HS256; not prefixed by adminSubdomain (also parity) |
Cards bind 1:1 to the frontend *Data types via a generic auto-constructor, so
subclasses need no constructor, and field names are emitted verbatim — the old
camelCase transform was removed because *Data interfaces are already camelCase:
export class HiddenCard extends InputCard<HiddenCardData> {}
export class UnionCard extends Card<UnionCardData & { content: Card[] }> {}
Gotchas a developer will hit:
- Peer deps
express,jsonwebtoken,cookie-parserare declared optional, but the imports are static — importing any symbol from/server, even justCard, pulls in all three and fails withCannot find package 'express'. The optionality only spares frontend-only consumers of the root entry; this is not graceful degradation. - Props are
Partial<D>, so required*Datafields are not enforced — it is a compile-time type parameter only, with no runtime validation or codegen. - Ajax handlers from all pages are flattened into one global map at
getApp()time: the URL tail selects the handler, the page is never consulted, same-pathname registrations across pages silently collide, andregisterAjaxaftergetApp()is invisible. - The request body is not merged into
ctx(only cookies listed incookieKeysplus query params), despite thePageContextdocstring claiming otherwise. page.emit()throws'no publisher configured'unlessuseCentrifugeSupport+centrifugeUrl+centrifugeApiKeyare all set. No Socket.IO server is hosted —useSocketioSupportis only a flag reported by/api/support/socketio.- No auth of any kind: no login, session store, middleware, or route protection.
JWT appears only in
gen_token, TTL hardcoded to 1h. With nocentrifugeSubFnand nocookieKeys, every anonymous client gets the samesub, collapsing per-user channel scoping. - Not covered vs pie: static/SPA serving, a Socket.IO server, typed pages
(
isTypedis stored but never read;InputCard.parse()is never invoked by any route), non-stringprocessreturns, compiled-events endpoints, broker/cache hooks, and OpenAPI generation. Only 5 built-in cards vs ~11 frontend components.cookieOptionsandassetsPathare declared but never read.
13. Realtime — Recovery, Link Status, Subscription Sharing
Message recovery is delegated to centrifuge-js; there is no client-side history
fetching and no epoch/offset tracking. Every subscription is created
{ recoverable: true, positioned: true }, and recovered publications arrive through
the normal publication handler. On resubscribe after an unrecoverable gap:
onResync?: (info: { channel: string; reason: 'gap' }) => void // PieCard prop
A channel the server has no history for only logs a warning. Channel names are
pie${methodName}_${data.name}_${centrifugeChannel} — one per method key. The
server side (Centrifugo history_size / history_ttl / force_recovery) is a
documented prerequisite the client does not enforce.
Link status — PieConfig callbacks plus matching hooks (useOnLinkLost() /
useOnLinkRestored()):
type PieLinkSource = 'centrifuge' | 'socketio'
onLinkLost?: (source: PieLinkSource, detail?: unknown) => void
onLinkRestored?: (source: PieLinkSource) => void
The initial connect is suppressed and repeated same-direction transitions collapse,
so lost fires once and only after a real prior connection. No provider needs
mounting by hand — every root wires the contexts internally; the developer surface is
PieRootProps.config.
Subscription sharing hazard: cards sharing data.name + centrifugeChannel +
method key share a single Subscription, and ownership is tracked by a createdHere
flag rather than a refcount. If the creating card unmounts first while a reusing card
is still mounted, the shared subscription is torn down under the survivor, which then
silently stops receiving events. Subscription options are also ignored on reuse —
the first creator's win.
Backend note: the pie preview stack does not use Centrifugo at all (see §10.5),
and pie card channels / pie card emit (§4.14) are the backend-only tools for
discovering and firing real channels.
Do not document as features: lazy realtime transports and event coalescing. Both
exist only as planning documents — there is no useTransports hook and no
coalesceEvents prop, and getSocket / getCentrifuge remain synchronous.