99d165b5e5
Piggy answered in raw markdown, threw away every tool result it streamed, and fought the reader's scroll on every token. The three surfaces that made it worth having — what it read, how it reasoned, what it cost — were all on the wire and none of them reached the screen. The transcript is now composed of five parts under components/piggy: answers render through streamdown, the container sticks to the bottom without pinning the reader there, tool steps say what they read and link to the record, and each turn carries its model and token count. Three lifecycle bugs went with them: Stop left a permanent spinner, a truncated stream was indistinguishable from thinking, and a failed send destroyed the message it failed to send. Underneath, the inference path grew timeouts, jittered retries on 429 and 5xx, tolerance of the malformed frames a 30B model emits, and an agent_runs row per turn so chat spend is observable. The system prompt now states that a field ending in Cents is cents — without it nemotron renders costPerGpuHourCents: 189 as "$189 per GPU-hour", which is a 100x error on the most scrutinised number in the room. The demo book was arithmetically incoherent: every deal's value contradicted its own allocation revenue by up to 3.6x, nothing had ever closed, no customer had any paper, and the marketplace was empty. Deal value is now derived from the allocation, the book clears 5.3% across five blocks with one deliberately underwater, and the renewal, compliance and agent-provenance machinery finally has rows to act on. A --clear that deleted every obligation, SLA term and capacity request in the database regardless of origin is scoped to the demo's own ids. Around that: accounts have a detail page, ⌘K searches the book, Settings can mint the API keys it always claimed to, and deploy.sh actually ships the agent instead of silently skipping its compose profile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
616 lines
32 KiB
Markdown
616 lines
32 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).
|
||
|
||
That starts the CRM without the agent, which is the default. Turning Piggy on is
|
||
a switch in `.env` and a run of `scripts/deploy.sh` — see
|
||
[Turning Piggy on](./deploy/README.md#turning-piggy-on).
|
||
|
||
### 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.
|
||
|
||
**Every one of these is read once, at boot.** None of Piggy's settings is
|
||
admin-selectable at runtime: `apps/piggy` reads `process.env` when the process
|
||
starts and never consults `platform_settings`, so changing the model or a budget
|
||
means editing `.env` and restarting the container.
|
||
|
||
| Variable | Default | Read by | Notes |
|
||
|---|---|---|---|
|
||
| `PIGGY_ENABLED` | `false` | API, `deploy.sh` | Gates the chat surface, and tells `scripts/deploy.sh` to ship the `piggy` Compose profile with the app |
|
||
| **`PIGGY_INFERENCE_API_KEY`** | — | Piggy | Required by the Piggy process. Missing, it exits at boot and crash-loops. 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 | The API reads it to display; Piggy reads it to call |
|
||
| `PIGGY_LEASE_SECONDS` | `300` | both | Queue lease duration |
|
||
| `PIGGY_POLL_INTERVAL_MS` | `2000` | Piggy | How often an idle worker looks for a task |
|
||
| `PIGGY_MAX_TOKENS` | `1024` | Piggy | Per queued task |
|
||
| `PIGGY_CHAT_MAX_TOKENS` | `2048` | Piggy | Per interactive answer. Separate from the queue's budget because chat tools return aggregates the answer has to quote, and 1024 truncated mid-table |
|
||
| `PIGGY_MAX_TURNS` | `4` | Piggy | Model calls per chat turn, tool round trips included |
|
||
| `PIGGY_REASONING_EFFORT` | `none` | Piggy | `none`, `low`, `medium`, `high`. Reasoning tokens bill like any other and the chat panel is on every page; raise it to debug, not in normal operation |
|
||
| `PIGGY_PRICE_INPUT_CENTS_PER_MTOK` | `5` | Piggy | Cents per million tokens, which keeps the recorded cost of a run exact in integers. Must be changed with the model — a stale price still looks like a measurement |
|
||
| `PIGGY_PRICE_OUTPUT_CENTS_PER_MTOK` | `20` | Piggy | As above |
|
||
| `PIGGY_WORKER_ID` | `hostname:pid` | Piggy | Lease identity. Only set it if you run two workers |
|
||
| `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 2048 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. Turning it on is three values in `.env` —
|
||
`PIGGY_ENABLED=true`, `PIGGY_INFERENCE_API_KEY` and a 32-character
|
||
`PIGGY_INTERNAL_TOKEN` — and then a deploy:
|
||
|
||
```bash
|
||
bash scripts/deploy.sh
|
||
```
|
||
|
||
`deploy.sh` reads `PIGGY_ENABLED` itself and adds the profile to the pull, the
|
||
build, the `up` and the rollback, so the agent is upgraded with the app and
|
||
never left behind on an older image. Starting it by hand
|
||
(`docker compose -p pig --profile piggy up -d --build`) works, but every later
|
||
deploy that does not know about it leaves old agent code running against a
|
||
newly migrated schema — so put the switch in `.env` instead. See
|
||
[`deploy/README.md`](./deploy/README.md#turning-piggy-on).
|
||
|
||
### 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.
|
||
|
||
**`ANTHROPIC_API_KEY` is declared in the API config and read by nothing.** The
|
||
rest of `.env.example` is now complete: `POSTGRES_PASSWORD` and
|
||
`PIG_SETTINGS_ENCRYPTION_KEY` were both load-bearing and both missing from it,
|
||
which made the documented `cp .env.example .env` fail at the first compose
|
||
command.
|
||
|
||
**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 debt to [Buzz](https://github.com/block/buzz) (Apache-2.0) is
|
||
credited in NOTICE. No source code was copied from it.
|