d7e0cbeccc
The screenshot section had been a placeholder since the shell was rebuilt as three panes, because there was no cheap way to re-shoot and a stale image is worse than no image. So this ships the capture, not just the captures: scripts/screenshots.mjs takes all ten pages at 1440x900 and 393x852, in light and dark, and screenshots-encode.py halves and re-encodes them to WebP — 16MB of PNG becomes 1.9MB in the tree. Two things would silently ruin a run, and the script exists to encode both. Seeding localStorage['pig.themeMode'] is not enough: the appearance preference is authoritative server-side and adopted after hydration, so every dark capture snapped back to light a beat after first paint. The /api/me/profile response is rewritten instead. And pig.sidebarOpen / pig.piggyDockOpen are per-device, so whatever the last human left behind would otherwise leak in. The run also fails on a wrong theme, a horizontal scrollbar or a console error — three things a screenshot cannot show you. Piggy is not pictured mid-conversation. It is off by default and no inference credential exists, so such an image would be a staged transcript rather than a capture. The README says that rather than implying the feature is missing. Correcting what the README asserted while shooting against the running code: read authorisation IS enforced — createReadGuardRoutes is mounted ahead of the feature routes, and routes/learn.ts and routes/activities.ts are mounted too, so only the HubSpot pair is still unreachable. The remaining read gap is that a grant cannot be narrowed, there being no row-level team filter in the query layer. Counts refreshed against the tree: 275 tests, ~47k lines, 14 migrations. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
592 lines
30 KiB
Markdown
592 lines
30 KiB
Markdown
<div align="center">
|
||
|
||
# 🐷 PIG — Prime Intellect Growth
|
||
|
||
**An open-source, agent-native CRM for two-sided AI-compute companies.**
|
||
|
||
[](./LICENSE)
|
||
|
||
*Self-hostable. Auditable. Built for teams that buy GPU capacity on one side and sell it on the other.*
|
||
|
||
</div>
|
||
|
||
---
|
||
|
||
## Why this exists
|
||
|
||
A company that aggregates GPU capacity and resells it does not run one pipeline.
|
||
It runs two, and its business is the spread between them.
|
||
|
||
Today that spread is usually managed in a spreadsheet with a margin calculator
|
||
in column K, a document of supplier terms, and a general-purpose CRM that has
|
||
no idea what an H100-hour is. Salesforce, HubSpot and Attio model a single
|
||
pipeline of deals against companies. They have no concept of **inventory**, no
|
||
concept of a **commitment you already bought and are paying for whether or not
|
||
it sells**, and therefore no way to answer the question the business turns on:
|
||
|
||
> Which contracted capacity is sold, to whom, at what margin — and what is idle
|
||
> right now?
|
||
|
||
PIG is one ledger that knows the domain. The load-bearing table is
|
||
[`allocations`](./packages/db/src/schema/allocations.ts), which joins a
|
||
`capacity_commitment` (what you bought, at a known cost) to a `demand_deal`
|
||
(what you sold, at a known price). Margin, utilisation and idle capacity all
|
||
fall out of that one join. Everything else is plumbing that keeps the ledger
|
||
honest.
|
||
|
||
**Cost is charged against the full commitment, not only the hours that sold.**
|
||
Unsold hours are already paid for. Charging only the allocated share reports a
|
||
healthy margin on a block that is losing money, which is precisely the failure
|
||
PIG exists to prevent. There is a test pinning it.
|
||
|
||
## Who it is for
|
||
|
||
PIG models three teams, because two-sided compute companies have three
|
||
constituencies competing for the same scarce capacity.
|
||
|
||
| Team | Job to be done |
|
||
|---|---|
|
||
| **Supply** | Source, qualify, price and contract GPU capacity from providers |
|
||
| **Demand** | Sell compute and post-training; renew and expand accounts |
|
||
| **Research** | Consume capacity internally — real burn, no revenue |
|
||
|
||
Research is a first-class tenant rather than an afterthought: internal burn
|
||
competes with revenue for the same GPUs, and margin arithmetic that cannot see
|
||
it is wrong.
|
||
|
||
The team set is configurable in `packages/core/src/ontology.ts`. PIG ships with
|
||
these three because they match the structure of the company it was designed
|
||
for, not because they are universal.
|
||
|
||
## Screenshots
|
||
|
||
Captured against the current shell — header, collapsible sidebar rail, docked
|
||
Piggy — running locally on the seed plus demo book (`db:seed` and `db:demo`), so
|
||
every number below is computed by the code in this repository rather than drawn.
|
||
Records prefixed `DEMO —` are fictional; the rest are the sourced, cited seed.
|
||
|
||
Each image follows your own system theme. Both themes are shown explicitly
|
||
further down, and [the full gallery](docs/screenshots.md) has all ten pages at
|
||
1440px and 393px, in light and dark. Desktop captures are the 1440×900 viewport
|
||
rather than the full scroll height — what you see is what fits above the fold.
|
||
`node scripts/screenshots.mjs` re-shoots the set.
|
||
|
||
**Overview** — margin, sold ratio and idle capacity across the book, with the
|
||
commitments you are paying for and not selling ranked by cost exposure.
|
||
|
||
<picture>
|
||
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/overview-desktop-dark.webp">
|
||
<img src="docs/screenshots/overview-desktop-light.webp" alt="PIG Overview: gross margin $675,871.37, 79.1% sold ratio, 1.3M idle GPU-hours, and a ranked list of capacity bought and unsold.">
|
||
</picture>
|
||
|
||
**Margin** — revenue from what was sold against the *full* cost of what was
|
||
bought, per commitment. `Cost covered` and a break-even price are the two states
|
||
that matter; charging only the allocated share of cost would report a healthy
|
||
margin on a block that is losing money.
|
||
|
||
<picture>
|
||
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/margin-desktop-dark.webp">
|
||
<img src="docs/screenshots/margin-desktop-light.webp" alt="PIG Margin: revenue $12.4M against $11.7M of full committed cost, broken down by commitment with sold ratio, cost per hour and break-even price.">
|
||
</picture>
|
||
|
||
**Capacity → Match a requirement** — the matcher. Ask what a customer needs and
|
||
PIG scores it against capacity already under commitment, saying why each block
|
||
fits, and hands you straight to the allocation that records the sale.
|
||
|
||
<picture>
|
||
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/capacity-match-desktop-dark.webp">
|
||
<img src="docs/screenshots/capacity-match-desktop-light.webp" alt="PIG capacity matcher: a requirement for 64 H100_80GB with high-speed interconnect, scored against two commitments at 64% and 58% fit with an Allocate this capacity action on each.">
|
||
</picture>
|
||
|
||
**Growth** — deterministic attention scores over customer paper, deal activity
|
||
and sold or reserved capacity. Every point is an explained signal with its
|
||
sources named; nothing here is a model's guess at a win probability.
|
||
|
||
<picture>
|
||
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/growth-desktop-dark.webp">
|
||
<img src="docs/screenshots/growth-desktop-light.webp" alt="PIG Growth: accounts ranked by attention score, each tagged deployed, expansion candidate, at risk or coverage gap, with the scoring signals listed underneath.">
|
||
</picture>
|
||
|
||
**Calendar** — what closes, what renews, what expires and when capacity lands,
|
||
projected from the records that already carry the dates. Export authorisations
|
||
expire on this timeline too, because an expired one converts lawful business
|
||
into unlawful business.
|
||
|
||
<picture>
|
||
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/calendar-desktop-dark.webp">
|
||
<img src="docs/screenshots/calendar-desktop-light.webp" alt="PIG Calendar for 2026-Q3: weighted pipeline, deals closing, renewals, obligations due and authorisations expiring, above a quarter timeline with one lane per kind.">
|
||
</picture>
|
||
|
||
### Light and dark
|
||
|
||
Theme is a stored preference that follows a person between devices, resolved
|
||
before first paint by an inline script so dark-mode users never get a white
|
||
flash. Both tunings of the accent palette are defined in `@pig/core` and applied
|
||
as CSS variables at runtime, so there is one definition of each colour.
|
||
|
||
The demand pipeline, in both. Legal sits second rather than last, because MSA
|
||
and DPA execution gates delivery rather than closing the deal — most CRMs put
|
||
contracts at the end of the funnel and are wrong about it for this market.
|
||
|
||
*Light:*
|
||
|
||
<img src="docs/screenshots/demand-desktop-light.webp" alt="PIG demand pipeline in light mode: ten stages from qualification through legal, scoping, proposal, procurement, POC and deployment, with deal cards showing ACV, product line and MSA/DPA badges.">
|
||
|
||
*Dark:*
|
||
|
||
<img src="docs/screenshots/demand-desktop-dark.webp" alt="The same demand pipeline in dark mode.">
|
||
|
||
Side by side at 393px, where both tunings have to survive a smaller surface:
|
||
|
||
| Supply pipeline · light | Supply pipeline · dark |
|
||
| --- | --- |
|
||
| <img src="docs/screenshots/supply-mobile-light.webp" alt="Supply pipeline at 393px in light mode"> | <img src="docs/screenshots/supply-mobile-dark.webp" alt="Supply pipeline at 393px in dark mode"> |
|
||
|
||
### Mobile
|
||
|
||
PIG is responsive to 393px — the sidebar becomes a bottom tab bar, tables become
|
||
cards, and the safe-area insets are handled. It is not a native app.
|
||
|
||
| Overview · light | Overview · dark | Margin · dark |
|
||
| --- | --- | --- |
|
||
| <img src="docs/screenshots/overview-mobile-light.webp" alt="PIG Overview at 393px in light mode, with a bottom tab bar"> | <img src="docs/screenshots/overview-mobile-dark.webp" alt="PIG Overview at 393px in dark mode"> | <img src="docs/screenshots/margin-mobile-dark.webp" alt="PIG Margin at 393px in dark mode, the commitment table reflowed into cards"> |
|
||
|
||
Piggy is deliberately not pictured mid-conversation. It is off by default
|
||
(`PIGGY_ENABLED=false`, and the Compose service sits behind a profile), and
|
||
showing it answering would mean staging a transcript rather than capturing one.
|
||
What it may and may not do is described under [the agent
|
||
surface](#the-agent-surface).
|
||
|
||
## The two-sided data model
|
||
|
||
Forty-seven tables, but the shape is small. These are the ones that carry the
|
||
thesis:
|
||
|
||
| Table | What it holds | Why it is not in a generic CRM |
|
||
|---|---|---|
|
||
| `capacity_commitments` | What you bought: term, GPU-hours, cost per GPU-hour, floor and ceiling, and a **shape** (`{intervals[], quantities[]}`) | Real contracts ramp across tranches and step down at checkpoints; a single start/end/total reports availability that does not exist in the month someone wants it |
|
||
| `demand_deals` | What you are selling: ACV, product line, MSA/DPA state, stage | The paper state is a separate axis from the stage, because paper gates delivery |
|
||
| `supply_deals` | The other pipeline: sourcing a provider through diligence to live | Generic CRMs have one pipeline and call the supplier a vendor |
|
||
| **`allocations`** | **The join.** Commitment × deal × GPU-hours × window × status | This is the whole product. Margin, utilisation and idle all derive from it |
|
||
| `inventory_listings` | Market availability mirrored from the Prime Intellect API | Sync is a straight field mapping, not an ETL project |
|
||
| `capacity_requests` | What a customer asked for, whether or not it could be served | Unservable demand is the signal for what to buy next |
|
||
| `contracts` + `sla_terms` + `sla_metric_targets` + `contract_obligations` | Polymorphic over party and type — MSA, DPA, SLA, order form, capacity commitment — with negotiated SLA terms and dated obligations | The supply side negotiates heavyweight paper; the self-serve demand side runs on a reliability tier and a credits policy instead |
|
||
| `export_authorizations`, `compliance_artifacts`, `compliance_decisions` | Export-control determinations recorded **on the allocation edge**, with reasoning and rule version | US controls apply an ultimate-parent test that reaches through the corporate tree, so country of incorporation is not a valid key |
|
||
| `facts` | Every agent-derived claim, with score, band, evidence excerpt and source URL | An agent allowed to write unattributed claims will eventually write a wrong one and nobody will be able to tell which |
|
||
| `agent_tasks` / `agent_runs` / `agent_actions` | The queue the API writes to and the agent drains, plus what it did | The API never calls the model; it writes a row |
|
||
|
||
Two pipelines, with stages taken from how the market operates:
|
||
|
||
```
|
||
Demand: qualification → legal → scoping → proposal → procurement
|
||
→ POC → deployment → expansion (+ closed_won / closed_lost)
|
||
|
||
Supply: sourced → qualifying → technical diligence → financial diligence
|
||
→ pricing → contracting → onboarding → live → renewal
|
||
(+ churned / rejected)
|
||
```
|
||
|
||
**Legal sits second** in the demand pipeline. MSA and DPA execution gates the
|
||
deal rather than closing it. Most CRMs put contracts at the end of the funnel
|
||
and are wrong about it for this market.
|
||
|
||
Three further decisions worth knowing before you read the schema:
|
||
|
||
- **Holds reserve; they do not sell.** A live hold removes capacity from
|
||
everyone else's availability — otherwise two sellers promise the same GPUs —
|
||
but never counts toward utilisation or revenue.
|
||
- **Security tiers are ranked, not labelled.** `community_cloud` <
|
||
`secure_cloud` < `government`, and a requirement is satisfied only from at or
|
||
above its tier.
|
||
- **Money is integer cents**, rounded exactly once, at the boundary.
|
||
|
||
## Self-hosting
|
||
|
||
### Requirements
|
||
|
||
Node 22+, pnpm 11+ (pinned by `packageManager`; `corepack enable` installs it),
|
||
and a PostgreSQL 16 database that PIG owns exclusively.
|
||
|
||
### Development
|
||
|
||
```bash
|
||
corepack enable
|
||
pnpm install
|
||
|
||
docker run -d --name pig-dev -p 5432:5432 \
|
||
-e POSTGRES_USER=pig -e POSTGRES_PASSWORD=pig -e POSTGRES_DB=pig \
|
||
postgres:16-alpine
|
||
|
||
export DATABASE_URL=postgres://pig:pig@localhost:5432/pig
|
||
pnpm run db:migrate
|
||
pnpm run db:seed # optional — sourced, cited, confidence-graded people
|
||
pnpm run db:demo # optional — a plausible demo book, prefixed "DEMO — "
|
||
|
||
pnpm run dev:api # :8920
|
||
pnpm run dev:web # :5173, proxies /api to :8920
|
||
```
|
||
|
||
With no identity provider configured, **authentication is disabled in
|
||
development** and every request runs as the first user in the table.
|
||
`loadConfig` refuses to start with `NODE_ENV=production` in that state, so it
|
||
cannot leak into a deployment.
|
||
|
||
### Production
|
||
|
||
```bash
|
||
cp .env.example .env # then edit
|
||
docker compose -p pig up -d db
|
||
docker compose -p pig run --rm --no-deps app pnpm exec tsx packages/db/src/migrate.ts
|
||
docker compose -p pig up -d --build app
|
||
```
|
||
|
||
Migrate from a one-off container **before** the app starts, not with `exec`: a
|
||
release that queries a table its migration has not yet created crash-loops
|
||
before you can attach to it. Full deployment notes, including the reverse
|
||
proxy, the release poller and rollback semantics, are in
|
||
[`deploy/README.md`](./deploy/README.md).
|
||
|
||
### Every environment variable
|
||
|
||
Read from `apps/api/src/lib/config.ts` (API), `apps/piggy/src/config.ts`
|
||
(Piggy) and `docker-compose.yml`. **Bold** means no default.
|
||
|
||
#### Required
|
||
|
||
| Variable | Default | Notes |
|
||
|---|---|---|
|
||
| **`DATABASE_URL`** | — | The only unconditionally required value. PIG owns this database exclusively |
|
||
| **`POSTGRES_PASSWORD`** | — | Compose only; `docker-compose.yml` refuses to start without it |
|
||
|
||
In production you must additionally set **either** `SUPABASE_URL` **or**
|
||
`PIG_OIDC_ISSUER`. The API throws at boot with neither.
|
||
|
||
#### Identity
|
||
|
||
| Variable | Default | Notes |
|
||
|---|---|---|
|
||
| `SUPABASE_URL` | unset | Hosted path. Absent in development ⇒ auth disabled |
|
||
| `SUPABASE_ANON_KEY` | unset | Public by design; served to the browser via `/api/config` |
|
||
| `SUPABASE_SERVICE_KEY` | unset | Only for administrative provisioning and self-registration. Warns at boot when set |
|
||
| `PIG_OIDC_ISSUER` | unset | On-premises path. **Takes precedence over `SUPABASE_URL`** |
|
||
| `PIG_OIDC_JWKS_URI` | discovered | Set it to skip discovery on an air-gapped network |
|
||
| `PIG_OIDC_AUDIENCE` | unset | Strongly recommended: without it, any token your provider issued for any application in the same tenant is accepted here. Warns, does not refuse |
|
||
| `PIG_OIDC_EMAIL_CLAIMS` | provider defaults | Comma-separated, in preference order |
|
||
|
||
#### Server
|
||
|
||
| Variable | Default | Notes |
|
||
|---|---|---|
|
||
| `PIG_PORT` | `8920` | |
|
||
| `PIG_PUBLIC_URL` | `http://localhost:8920` | The single origin the app is served from; CORS and the Google redirect are validated against it |
|
||
| `NODE_ENV` | `development` | `production` activates the identity-provider guard |
|
||
| `PIG_ADMIN_EMAILS` | `''` | Comma-separated. Every address must already have an account — an unregistered address here is a standing offer of admin rights to whoever claims it first |
|
||
| `PIG_INVITE_CODE` | unset | Set it to gate signup |
|
||
| `PIG_SETTINGS_ENCRYPTION_KEY` | unset | Base64-encoded 32 bytes. Required for Notion and Google OAuth; secrets written in the admin UI need it |
|
||
|
||
#### Prime Intellect
|
||
|
||
| Variable | Default | Notes |
|
||
|---|---|---|
|
||
| `PRIME_API_KEY` | unset | Scope it to `Availability → Read` only |
|
||
| `PRIME_API_BASE` | `https://api.primeintellect.ai` | The compute/pods host. Inference is a *different* host — see below |
|
||
| `PRIME_SYNC_ENABLED` | `false` | Warns if on without a key |
|
||
| `PRIME_SYNC_INTERVAL_MINUTES` | `30` | |
|
||
|
||
#### Piggy
|
||
|
||
The API and the Piggy container read overlapping but distinct sets.
|
||
|
||
| Variable | Default | Read by | Notes |
|
||
|---|---|---|---|
|
||
| `PIGGY_ENABLED` | `false` | API | Gates the chat surface |
|
||
| **`PIGGY_INFERENCE_API_KEY`** | — | Piggy | Required by the Piggy process. The model credential never reaches the API container |
|
||
| `PIGGY_INFERENCE_BASE` | `https://api.pinference.ai/api/v1` | both | OpenAI-compatible |
|
||
| `PIGGY_MODEL` | `nvidia/nemotron-3-nano-30b-a3b` | both | Admin-selectable at runtime too |
|
||
| `PIGGY_LEASE_SECONDS` | `300` | both | Queue lease duration |
|
||
| `PIGGY_POLL_INTERVAL_MS` | `2000` | Piggy | |
|
||
| `PIGGY_MAX_TOKENS` | `1024` | Piggy | |
|
||
| `PIGGY_WORKER_ID` | `hostname:pid` | Piggy | |
|
||
| `PIGGY_INTERNAL_URL` | unset | API | `http://piggy:8931` under Compose |
|
||
| **`PIGGY_INTERNAL_TOKEN`** | — | both | Min 32 chars; required by the Piggy process. Never put it in a query string |
|
||
| `PIGGY_CHAT_HOST` | `127.0.0.1` | Piggy | |
|
||
| `PIGGY_CHAT_PORT` | `8931` | Piggy | Never published to the host |
|
||
| `PIGGY_CHAT_ALLOW_NON_LOOPBACK` | `false` | Piggy | Compose sets `true`, because the API reaches it across the Compose network |
|
||
|
||
#### Integrations — all optional, all validated as a group
|
||
|
||
Setting one member of a group without the others fails at boot rather than
|
||
half-working.
|
||
|
||
| Group | Variables |
|
||
|---|---|
|
||
| Slack | `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET` |
|
||
| Buzz | `BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG` |
|
||
| Notion import | `NOTION_CLIENT_ID`, `NOTION_CLIENT_SECRET`, `NOTION_REDIRECT_URI` (+ `PIG_SETTINGS_ENCRYPTION_KEY`) |
|
||
| Google Sheets import | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI` (+ `PIG_SETTINGS_ENCRYPTION_KEY`) |
|
||
|
||
`GOOGLE_REDIRECT_URI` must be exactly `<PIG_PUBLIC_URL origin>/oauth/google/callback`.
|
||
|
||
## Architecture
|
||
|
||
A pnpm monorepo. Around 47k lines of TypeScript including tests, 275 tests
|
||
across five packages, green CI.
|
||
|
||
```
|
||
apps/
|
||
web/ React 19 + Vite + Tailwind + shadcn-idiom components
|
||
api/ Hono HTTP API — auth, validation, capacity and contract services
|
||
piggy/ The agent: a lease-based queue worker plus a private chat server
|
||
mcp/ MCP server (stdio) — 9 tools
|
||
cli/ `pig`, the HTTP surface for scripts and agent kernels
|
||
packages/
|
||
core/ Ontology, permissions, margin arithmetic, palette — no I/O
|
||
db/ Drizzle schema (47 tables), 14 migrations, seed and demo data
|
||
prime/ Typed client for the Prime Intellect compute API
|
||
docs/ ontology.md, screenshots.md, build-plan.md, agents.md, seed-data.md
|
||
deploy/ Caddyfile example, autodeploy units, deployment notes
|
||
```
|
||
|
||
Three rules hold the shape:
|
||
|
||
**Intelligence never lives in the API.** Handlers validate, authorise, call a
|
||
service, serialise. Research, enrichment, scoring and matching heuristics live
|
||
in the service layer or in the agent. The API signals the agent by *writing a
|
||
row to `agent_tasks`*, never by calling it — so the queue survives the agent
|
||
being down and no request thread ever blocks on a model.
|
||
|
||
**Authentication is not authorisation.** A verified JWT proves someone has an
|
||
account in an identity provider PIG may share with another application. Access
|
||
additionally requires a row in PIG's own `users` table; a token without one
|
||
gets `403 needs_profile`, which the front end turns into a join flow rather
|
||
than a login screen they have already completed. Both providers reduce to
|
||
"verify a bearer token, return a subject and an email" behind
|
||
`apps/api/src/lib/auth-provider.ts`.
|
||
|
||
**Writes go through one chokepoint.** `apps/api/src/lib/mutation.ts` derives
|
||
zod schemas from the ontology, applies the capability check, runs the write and
|
||
its audit activity in one transaction, and returns a consistent error shape.
|
||
|
||
## The RBAC model, as it now stands
|
||
|
||
Eleven capabilities, in `packages/core/src/permissions.ts`, resolved from team
|
||
membership and role and shared by the API and the browser so a disabled button
|
||
and a 403 cannot disagree.
|
||
|
||
Roles are ranked, and every rule is "at or above": `viewer` < `member` <
|
||
`lead` < `admin`. A platform admin (an address in `PIG_ADMIN_EMAILS`) holds
|
||
everything, platform-wide.
|
||
|
||
**Writes are team-scoped:**
|
||
|
||
| Capability | Teams | Minimum role |
|
||
|---|---|---|
|
||
| `deal:write` | supply, demand | member |
|
||
| `commitment:write` | supply | lead |
|
||
| `contract:sign` | supply, demand | admin |
|
||
| `activity:write` | all | member |
|
||
| `data:import` | all | admin |
|
||
| `fact:review` | research | admin |
|
||
| `integration:connect` | all | admin |
|
||
| `settings:admin` | — | platform admin only |
|
||
|
||
**Reads are platform-wide, deliberately:**
|
||
|
||
| Capability | Teams | Minimum role | Covers |
|
||
|---|---|---|---|
|
||
| `book:read` | all | viewer | Accounts, contacts, both pipelines, contracts, growth, facts |
|
||
| `economics:read` | supply, demand | member | Supplier cost, break-even price, margin, idle, inventory, the dashboard |
|
||
| `team:read` | all | viewer | The roster |
|
||
|
||
Read grants are **not** team-scoped, and that is a decision rather than an
|
||
omission: no row-level team filter exists anywhere in the query layer, so a
|
||
"demand only" read grant would be a promise the guard could not keep. The
|
||
honest model is that a read capability is held or it is not, and the *role*
|
||
required to hold it is what separates the roster from the cost book.
|
||
`economics:read` is the one that matters — supplier cost per GPU-hour and
|
||
break-even price *are* the business.
|
||
|
||
The read half is enforced. The policy table lives in
|
||
`apps/api/src/routes/read-guards.ts` and `createReadGuardRoutes` is mounted in
|
||
`app.ts` **before** the feature routes — Hono runs matched handlers in
|
||
registration order, so a guard registered after its route would return 200 while
|
||
looking correct. `read-governance.test.ts` pins that ordering in both
|
||
directions, and fails when a GET appears that no rule covers, so a new read
|
||
endpoint cannot ship ungoverned by accident.
|
||
|
||
What it still cannot do is filter *within* a grant: see
|
||
[limitations](#what-is-not-built-yet).
|
||
|
||
## The agent surface
|
||
|
||
PIG is a first-class application for agents *and* for humans, and neither is a
|
||
degraded view of the other. There are two distinct surfaces.
|
||
|
||
### Piggy — the in-app agent
|
||
|
||
`apps/piggy` is one image running two processes' worth of behaviour:
|
||
|
||
- **The queue worker** claims a task with `SELECT … FOR UPDATE SKIP LOCKED`
|
||
inside a transaction, holds a renewable lease (default 300s, renewed at half
|
||
the interval), and aborts its own work if it ever loses that lease — so two
|
||
workers can never both be mid-flight on one task. Failures retry with
|
||
exponential backoff capped at one hour, up to the task's `maxAttempts`. Every
|
||
attempt writes an `agent_runs` row with the model, the input, the token
|
||
counts and either a summary or the error. Its tool set is exactly two:
|
||
`pig_get_subject` and `pig_record_fact`, and a fact is refused without both a
|
||
source URL and an evidence excerpt.
|
||
- **The chat server** listens on `127.0.0.1:8931` and is never published to the
|
||
host. The API authenticates the user, forwards bounded context, and calls it
|
||
with a shared internal bearer token. Chat is **read-only**: seven tools
|
||
(`pig_get_record`, `pig_get_account_lifecycle` and five page-scoped
|
||
summaries), each of which aggregates first and returns at most a handful of
|
||
exemplar rows, because interactive chat runs at 1024 max tokens across at
|
||
most four turns. Ambient coding tools are rejected before inference by an
|
||
explicit boundary check.
|
||
|
||
Piggy is off by default. `PIGGY_ENABLED` defaults to `false` and the Compose
|
||
service sits behind `profiles: ['piggy']`, so a default `docker compose up`
|
||
starts the CRM without it:
|
||
|
||
```bash
|
||
docker compose -p pig --profile piggy up -d --build
|
||
```
|
||
|
||
### The MCP server — for the agent you already use
|
||
|
||
`apps/mcp` speaks **stdio** and holds an API key. It calls the same HTTP API a
|
||
browser does: no database credentials, no privileged path, and deliberately no
|
||
tool that provisions infrastructure, spends money or emails a customer. Nine
|
||
tools, because a sprawling tool list measurably degrades model performance:
|
||
|
||
| Tool | What it answers |
|
||
|---|---|
|
||
| `pig_whoami` | Who am I acting for, and which teams am I on? |
|
||
| `pig_my_pipeline` | Where are we? What needs attention? |
|
||
| `pig_capacity_match` | What have we bought that would serve this customer? |
|
||
| `pig_margin_report` | What is each block earning against what it cost? |
|
||
| `pig_idle_capacity` | What are we paying for and not selling? |
|
||
| `pig_inventory_search` | What could we buy to cover demand we cannot serve? |
|
||
| `pig_search` | Find an account |
|
||
| `pig_get_account` | Everything about one account |
|
||
| `pig_log_activity` | Record a call, meeting or note |
|
||
|
||
Mint a key in **Settings → API keys** (shown once), then run it from a clone —
|
||
`@pig/mcp` is a workspace package and is not published to npm:
|
||
|
||
```bash
|
||
export PIG_URL=https://your-pig-host
|
||
export PIG_API_KEY=pig_...
|
||
claude mcp add pig -- pnpm --dir /path/to/pig exec tsx apps/mcp/src/stdio.ts
|
||
```
|
||
|
||
There is also a `pig` CLI with `--json` output for scripts and agent kernels;
|
||
see [docs/agents.md](./docs/agents.md).
|
||
|
||
## Shipping — tag to deploy
|
||
|
||
CI is Gitea Actions, one sequence, about two minutes. It typechecks every
|
||
package, applies the migration chain **twice** to a real empty Postgres, asserts
|
||
the seed is idempotent, runs 275 unit tests and the critical-path E2E, boots the
|
||
server and curls it, builds the front end, checks the inline theme script still
|
||
hashes to the value the proxy's CSP allows, and builds the Docker image.
|
||
|
||
Shipping is two steps and the second one is a human:
|
||
|
||
```bash
|
||
git tag release-2026-08-13 && git push origin release-2026-08-13
|
||
```
|
||
|
||
1. A push to `main` runs `verify` and stops. **Nothing deploys.**
|
||
2. A `release-*` tag runs the same `verify`, then `publish` pushes
|
||
`git.karti.ai/pig/pig:<tag>` and `:<short-sha>` to the registry.
|
||
3. Within five minutes `pig-autodeploy.timer` on the production host notices
|
||
the newest release tag has a different digest, checks the tree out at that
|
||
tag, and runs `scripts/deploy.sh` with `PIG_IMAGE` set.
|
||
|
||
The direction of travel is the point: no credential on the shared CI runner can
|
||
execute anything on the production host. The host holds a pull-only token and
|
||
fetches. `deploy.sh` dumps the database first, gates on health, the
|
||
unauthenticated-401 check and a public-origin body marker, and rolls back to
|
||
the previous image if a gate fails — exiting 1 when the previous image was
|
||
restored and 3 when the release under test is still live, because that is the
|
||
one thing an on-call needs at 04:00.
|
||
|
||
## What is not built yet
|
||
|
||
Said plainly, because you are going to grep the repo anyway.
|
||
|
||
**Read authorisation is enforced, but only at the grant.** A capability is held
|
||
or it is not. Once `economics:read` is held, it returns every commitment's cost
|
||
— there is no filter that narrows it to one team's book, because no row-level
|
||
team filter exists anywhere in the query layer. That is the gap to close before
|
||
PIG serves a company where "supply can see supply's costs" is a requirement.
|
||
|
||
**The HubSpot integration is written, tested and never mounted.**
|
||
`routes/hubspot.ts` and `routes/hubspot-webhook.ts` are both absent from
|
||
`app.ts`, so OAuth, connections, sync jobs, webhook verification and seven
|
||
`hubspot_*` tables are all unreachable from the running server.
|
||
|
||
**Six of the eight declared agent task kinds are never enqueued.** The worker
|
||
is complete and generic, but only `enrich_account` and `enrich_contact` are
|
||
ever written to `agent_tasks` (both from record creation). `write_brief`,
|
||
`match_capacity`, `detect_idle_capacity`, `summarise_pipeline`,
|
||
`watch_renewal` and `research_supplier` are declared in the ontology and
|
||
nothing produces them. Piggy therefore does far less than the queue implies —
|
||
not because the machinery is missing, but because nothing asks.
|
||
|
||
**Piggy chat cannot write.** By design for now, but worth stating: the
|
||
interactive agent reads and cites; it cannot create or update a CRM record.
|
||
|
||
**The MCP server is stdio only.** There is no Streamable HTTP transport and no
|
||
`/mcp` endpoint on the API, so remote MCP clients cannot connect over the
|
||
network — each user runs the server locally against their own API key. The
|
||
package is also not published to npm, so `npx @pig/mcp` does not work.
|
||
|
||
**No row-level or team-scoped read filtering exists** anywhere in the query
|
||
layer. Every read returns the whole book. This is why read capabilities are
|
||
platform-wide rather than per-team, and it is the thing to build before PIG
|
||
serves a company where that is not acceptable.
|
||
|
||
**`.env.example` is incomplete.** `POSTGRES_PASSWORD` and
|
||
`PIG_SETTINGS_ENCRYPTION_KEY` are both load-bearing and both missing from it;
|
||
the table above is authoritative. `ANTHROPIC_API_KEY` is declared in the API
|
||
config and read by nothing.
|
||
|
||
**Not started at all:** email or calendar ingestion, forecasting, quota and
|
||
attainment, invoicing or billing reconciliation, a public API beyond what the
|
||
MCP tools cover, multi-tenancy of any kind, and any mobile application. PIG is
|
||
responsive to 393px; it is not a native app.
|
||
|
||
## Documentation
|
||
|
||
- **[AGENTS.md](./AGENTS.md) — start here if you are joining this codebase.**
|
||
Architecture rules, the traps that have already bitten, and conventions.
|
||
- [Screenshots](./docs/screenshots.md) — every page, at 1440px and 393px, light and dark
|
||
- [Ontology](./docs/ontology.md) — the domain model, and why it is shaped this way
|
||
- [Build plan](./docs/build-plan.md) — what shipped, what remains, in dependency order
|
||
- [Agent integration](./docs/agents.md) — MCP clients and the CLI
|
||
- [Seed data provenance](./docs/seed-data.md) — every claim, graded and cited
|
||
- [Deployment](./deploy/README.md) — self-hosting, the release poller, rollback
|
||
|
||
## A note on seed data
|
||
|
||
PIG ships with a roster of publicly documented people so the application is
|
||
legible on first run. Every record carries a confidence grade and a source URL,
|
||
both shown in the interface. **No email addresses are included or inferred.**
|
||
Records that could not be independently sourced are marked as such rather than
|
||
quietly presented as fact, and people who are demonstrably *not* staff —
|
||
alumni, residency participants — are labelled accordingly. Seeding is opt-in
|
||
(`pnpm run db:seed`) and never automatic. See
|
||
[docs/seed-data.md](./docs/seed-data.md).
|
||
|
||
If you are seeded here and would rather not be, open an issue and the record
|
||
will be removed.
|
||
|
||
## Licence
|
||
|
||
Apache License 2.0 — see [LICENSE](./LICENSE) and [NOTICE](./NOTICE). The
|
||
architectural debts to [Comp AI CRM](https://github.com/trycompai/crm) (MIT)
|
||
and [Buzz](https://github.com/block/buzz) (Apache-2.0) are credited in NOTICE.
|
||
No source code was copied from either.
|