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>
Deploying PIG
PIG is an API/web container, Postgres, and — when you ask for it — a private Piggy worker/chat container, behind any reverse proxy that terminates TLS. Nothing here is specific to a particular host.
1. DNS
Point the apex and www at the machine. Both must resolve before the proxy can
obtain a certificate.
A primeintellectgrowth.com -> <your IP>
A www.primeintellectgrowth.com -> <your IP>
2. Configuration
cp .env.example .env # then edit
The values that must be set for a production start:
| Variable | Why |
|---|---|
POSTGRES_PASSWORD |
Generate a fresh one; never reuse another service's |
PIG_PUBLIC_URL |
The single origin the app is served from |
SUPABASE_URL / SUPABASE_ANON_KEY |
Authentication. The app refuses to start in production without a Supabase URL, because it would otherwise serve the whole CRM unauthenticated |
PIG_ADMIN_EMAILS |
Who may administer. Every address here must already have an account — an unregistered address listed as an admin is a standing offer of admin rights to whoever claims it first |
PIG_SETTINGS_ENCRYPTION_KEY |
Base64-encoded 32 bytes (openssl rand -base64 32). Only needed for the Notion and Google OAuth secrets typed into the admin UI, which the API refuses to store without it |
Optional: PRIME_API_KEY (scope it to Availability → Read only), the Slack
and Buzz credentials, and the whole Piggy block — the agent is off unless you
turn it on.
Piggy listens on piggy:8931 inside the Compose network. The port is exposed to
other containers but never published to the host, and Caddy must not route to
it. The CRM API authenticates the user, forwards only bounded chat context, and
uses PIGGY_INTERNAL_TOKEN in an Authorization header. Never put that token in
a query string, where proxies and access logs can retain it.
3. Start
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
docker compose -p pig run --rm --no-deps app pnpm exec tsx packages/db/src/seed/index.ts # optional
Migrate from a one-off container, before the app starts — not with exec.
exec needs a running app to attach to, and a release that queries a table its
migration has not yet created crash-loops before you can attach to it. You then
have a container restarting every few seconds and no way in. run --rm --no-deps uses the same image without the app, and without starting its
dependencies twice. This is what commit d4d7095 changed and it is what
scripts/deploy.sh does.
That starts the CRM alone: the agent is behind a Compose profile and stays
down. To run it, see Turning Piggy on — set the switch in
.env rather than starting the container by hand, because a hand-started Piggy
is one no later deploy knows to upgrade.
Use -p pig. A compose project that shares a name with a neighbouring stack
will adopt its volumes, which is a memorable way to lose a database.
4. Reverse proxy
See Caddyfile.example. Serve the app and API from the same origin.
Two things that will otherwise cost you an hour:
-
If other sites on the host use
bind <address>, yours must too. Caddy groups site blocks into servers by listen address. A block withoutbindlands in a separate server on:443, and the more specific listener wins for traffic arriving on that address — which is all public traffic after NAT. The symptom is a valid certificate, a 200 response, an empty body, and none of your headers. It looks like the app is broken; it is that the request never reached it. -
The CSP must carry the hash of the inline theme script in
index.html. That script sets light or dark before first paint so dark-mode users do not get a white flash. Editing it changes the hash and CSP will silently block it — the browser console prints the hash it expects.That hash exists in three places, and only two of them are checked.
Copy Checked by .gitea/workflows/ci.yml(theexpectedconstant)itself, on every run deploy/Caddyfile.examplenothing — it is an example the live Caddyfileon the hostnothing at all The live one is the only copy that decides whether a browser runs the script. Nothing in this repository can see it, CI cannot fail on it, and the failure is a white flash for dark-mode users with no error anywhere. Editing that script means editing all three by hand and reloading Caddy.
5. Verify
curl -s https://primeintellectgrowth.com/api/health
# {"ok":true,"service":"pig","version":"0.1.0"}
Check the public origin, not just 127.0.0.1:8920. The bind failure
above answers with a valid certificate, HTTP 200 and an empty body, which
satisfies every check that only asks whether something responded.
scripts/deploy.sh now asserts the body is non-empty and contains the
application's mount point for this reason.
Turning Piggy on
Piggy is the in-app agent: a queue worker and a private chat server, one image running a second command. It is off by default and nothing about it is configurable from the admin UI — every value below is read once, when the container boots.
Three keys in .env, and all three are needed:
PIGGY_ENABLED=true
PIGGY_INFERENCE_API_KEY=<an inference key from app.primeintellect.ai>
PIGGY_INTERNAL_TOKEN=<openssl rand -hex 32>
The fourth thing the API needs, PIGGY_INTERNAL_URL, is already set to
http://piggy:8931 by docker-compose.yml. Set it in .env only for a Piggy
running outside Compose.
Then deploy as usual:
bash scripts/deploy.sh
Do not start the container by hand. The service carries
profiles: ['piggy'], and compose skips a profile-gated service silently:
without the profile, pull, build and up behave as though it were not in
the file, with no warning and a zero exit.
deploy.sh reads PIGGY_ENABLED from .env and adds --profile piggy
to the pull, the build, the up and the rollback, so the agent moves with
the app. A Piggy started once with --profile piggy up -d and then forgotten is
not covered by any of that: it keeps running the image of the day it was
started, against a schema several migrations newer, which is the failure the
comment at the top of docker-compose.yml is about. deploy.sh therefore
compares the piggy container's image ID with the release's and rolls back if
they differ.
scripts/autodeploy.sh compares both containers' digests for the same reason.
Without that, an old Piggy is invisible to the poller: the app matches the
newest tag, the poller says "up to date" every five minutes, and the agent runs
last month's code indefinitely.
A missing API key crash-loops the worker
PIGGY_INFERENCE_API_KEY is required by apps/piggy/src/config.ts. Without it
the process exits at boot with Invalid Piggy configuration: PIGGY_INFERENCE_API_KEY is required., and restart: unless-stopped starts it
again — so the symptom is a container restarting every few seconds, not an error
anyone sees in the CRM. PIGGY_INTERNAL_TOKEN shorter than 32 characters fails
the same way. deploy.sh catches both: it waits for the container to report
healthy and exits 3 if it does not, deliberately without rolling back,
because the previous image reads the same .env and would fail identically.
Health
The piggy container has its own healthcheck, against the chat server's
unauthenticated GET /internal/health:
docker compose -p pig --profile piggy ps
# NAME STATUS
# pig-piggy-1 Up 2 minutes (healthy)
docker compose -p pig --profile piggy exec piggy \
node -e "fetch('http://127.0.0.1:8931/internal/health').then(r=>r.text()).then(console.log)"
# {"ok":true,"service":"piggy-chat","model":"nvidia/nemotron-3-nano-30b-a3b"}
It needs its own because the image's HEALTHCHECK asks for
127.0.0.1:8920/api/health — the API's port, which this container does not
serve. Inherited unchanged, Piggy reported unhealthy for ever while working
perfectly.
There is no published port and there must not be one. The listener is reachable only from inside the Compose network, the API authenticates the user before forwarding anything, and the bearer token goes in a header — never a query string, where a proxy or an access log would keep it.
Tuning
Everything else has a working default and exists to be lowered:
PIGGY_MAX_TOKENS (per queued task), PIGGY_CHAT_MAX_TOKENS (per interactive
answer), PIGGY_MAX_TURNS, PIGGY_POLL_INTERVAL_MS, PIGGY_LEASE_SECONDS,
PIGGY_REASONING_EFFORT and the two PIGGY_PRICE_*_CENTS_PER_MTOK values that
make the cost recorded against each run exact. .env.example lists them
commented out, and that is not decoration: an empty PIGGY_MAX_TOKENS= line is
passed to the container as the empty string, which coerces to 0 and refuses to
start. Leave a key commented to get its default; do not leave it blank.
Changing any of them means restarting the container — bash scripts/deploy.sh,
or docker compose -p pig --profile piggy up -d piggy if the release is
otherwise unchanged.
Learn videos
PIG hosts its own Learn videos. There is no video service to configure, no
embed host and — because a native <video> is not an iframe — nothing to add
to the proxy's frame-src. The files are covered by default-src 'self'.
Where they live
| Host directory | PIG_MEDIA_HOST_DIR, normally /opt/pig/media |
| Inside the container | /app/media, bind-mounted read-only |
| Read by the app from | PIG_MEDIA_DIR (compose sets it to /app/media) |
| Served at | /media/learn/<filename> |
| From source, no container | PIG_MEDIA_DIR=./media, relative to the repository root |
sudo mkdir -p /opt/pig/media
sudo chown "$USER" /opt/pig/media
# then in /opt/pig/.env
# PIG_MEDIA_HOST_DIR=/opt/pig/media
Create the directory before compose up. Docker creates a missing bind
source itself, as an empty directory owned by root — every video then 404s and
you cannot copy a file in without sudo.
The mount is read-only. PIG never writes a video: a file arrives by being copied onto the host, so the write path is not reachable over HTTP at all. Deploys do not touch the directory, and neither does a rollback — the videos outlive any particular release.
Naming: content-addressed, and why
A filename is <slug>.<hash>.<ext>, for example
pig-tour.7f3a91c2.mp4. Only [A-Za-z0-9._-] is accepted, with mp4, webm
or m4v as the extension; anything else is refused both as a stored source and
as a file read.
slug=pig-tour
hash=$(sha256sum "$slug.mp4" | cut -c1-8)
mv "$slug.mp4" "$slug.$hash.mp4"
The hash is load-bearing, not decoration:
The media FILES are served unauthenticated. The LISTING is not. Who learns
that a video exists — its title, its track, whether it is code-visible at all —
is decided by /api/learn and /api/learn/public. The bytes are handed to
anyone who can name the file. This is how every video platform works: a gated
manifest in front of segments on an open CDN. It is also what a <video>
element requires, since a media element re-requests byte ranges on every seek
and carries no bearer token while doing it.
What it costs. A URL, once shared, is a permanent public link to that video.
Someone given the share code can copy the src out of the page and post it,
and rotating the Learn access code does not close it. The remedies are to
rename the file (a new hash, therefore a new URL) or delete it. We accept that:
these are product demos meant to be shareable with the code. The material that
must never leak is the supply and demand concept tracks, and those are gated by
the listing — which is where the boundary genuinely is.
What the hash buys. There is no directory index and a wrong guess is a flat 404, so an unguessable name makes the exposure "whoever has the link" rather than "the internet". That is precisely an unlisted video.
Publishing one
cp pig-tour.7f3a91c2.mp4 /opt/pig/media/
pnpm db:demo -- --hosted # inserts rows for the files that are present
The seed discovers files by slug, so the hash never has to be written into the
manifest. It is idempotent — the unique key on (track, provider, external_id)
does that work — and it inserts a row only when the file is on disk, because
a Learn card whose video 404s reads as a broken product rather than a missing
one. Rows for files that have since disappeared are reported, never deleted:
deleting them would empty the curriculum the first time someone ran the seed
with the media directory unmounted.
pnpm db:demo -- --clear removes the DEMO — rows and leaves these alone.
pnpm db:demo -- --clear-hosted removes these and leaves the files on disk.
Checking it
curl -sI https://primeintellectgrowth.com/media/learn/pig-tour.7f3a91c2.mp4
# accept-ranges: bytes <- without this, the scrubber does nothing
curl -s -r 0-99 -o /dev/null -D - \
https://primeintellectgrowth.com/media/learn/pig-tour.7f3a91c2.mp4
# HTTP/2 206 ... content-range: bytes 0-99/<size>
A 200 where a 206 is expected means something in front of PIG is buffering the response and dropping the range — the video will play from the start and refuse to seek.
Upgrading
By hand
bash scripts/deploy.sh
It fetches origin/main, dumps the database, builds, migrates from a one-off
container, starts the app — and Piggy, when PIGGY_ENABLED is on — and refuses
to call the deploy done until the health endpoint, the unauthenticated-401 gate,
the piggy container's image and health, and the public origin all agree.
By tag — the normal path
Shipping is two steps and the second one is a human being:
git tag release-2026-08-13 && git push origin release-2026-08-13
That is the entire ship decision. What follows:
- CI runs the full
verifyjob against the tagged commit — the same job a push to main runs. A tag does not skip verification. - Only if that passes, the
publishjob builds and pushesgit.karti.ai/pig/pig:<tag>and:<short-sha>to the Gitea registry. - Within five minutes
pig-autodeploy.timeron the host notices that the newestrelease-*tag has a digest different from the running container, checks the tree out at that tag, and runsscripts/deploy.shwithPIG_IMAGEset — so it pulls the published image instead of rebuilding it.
Push to main deploys nothing. Tagging does.
The direction of travel is the point. No credential on the shared CI runner can
execute anything on this host; the host holds a pull-only registry token and
fetches. That preserves both halves of the constraint written at the top of
scripts/deploy.sh — no production key on the runner, and a human still
choosing when it ships.
Trap: sudo throws PIG_IMAGE away. The default sudoers policy sets
env_reset, so PIG_IMAGE=… sudo docker compose … hands compose an environment
without it and compose interpolates the pig:local fallback from
docker-compose.yml. The pull then dies with "pull access denied for pig" — and
if it had not died, the migrate, the up and the rollback would all have run
the stale local image while the log named the release tag. Every compose
invocation in deploy.sh therefore goes through the dc() wrapper, which uses
sudo env PIG_IMAGE=… COMPOSE_PROFILES=… docker compose …; sudo -E and bare
sudo VAR=val are both refused by that same policy. COMPOSE_PROFILES travels
the same way and for a sharper reason: dropped, compose does not error, it just
leaves Piggy out of whatever you asked for. Anything new that shells out to
compose must use the wrapper.
Rollback
scripts/deploy.sh records the image the app container was running before it
replaces it. If the health check, the unauthenticated-401 gate, the piggy image
assertion or the public-origin marker check fails, it re-tags that image,
restarts the app — and Piggy with it, since they are one image running two
commands — reports whether the restored version is healthy, and exits non-zero. Previously
those exits left the broken release live, which was fine when a human was
watching the terminal and an outage when the poller ran at 04:00.
The exit code says what is serving, because that is the one thing the
on-call needs at 04:00 and autodeploy.sh can see nothing else:
| Exit | Meaning |
|---|---|
| 0 | deployed |
| 1 | a gate failed and the previous image was restored |
| 3 | a gate failed and the release under test is still live |
3 covers the cases where a rollback was not attempted (the fault is not
attributable to the release), where there was no previous image to restore, and
where the restore itself did not come up. autodeploy.sh logs a different
sentence for each, so the journal never claims a rollback that did not happen.
Two things it deliberately does not do:
- It does not roll the database back. Migrations are additive, so the previous image runs against the new schema. The dump taken before the migration is the escape hatch for when that is not true.
- It does not roll back when the public origin answers with an EMPTY body.
Something terminated TLS and replied, so the fault is the proxy — see
bindbelow — and the previous image would fail the same check. Exit 3. - It does not roll back when Piggy is enabled but does not come up. Same
reasoning: the previous image reads the same
.env, so restoring it churns a healthy CRM without fixing the agent. Exit 3, and the log namesPIGGY_INFERENCE_API_KEYbecause that is nearly always the cause. This is the one exit-3 case where the site itself is fine.
It does roll back when the origin answers with a non-empty body that lacks
the marker. A proxy fault cannot serve a wrong-but-populated page for this
hostname; a bad release can — a changed Vite base, a Dockerfile step that
stopped copying apps/web/dist. Every earlier gate passes in that state, so
this is the only one that fires, and refusing to roll back would leave the
broken release facing the public.
If curl cannot reach the public origin at all (no hairpin for the public
name, egress to 443 filtered), that is not a failed deploy: it is a host that
cannot see itself from outside, and blacklisting the digest over it costs a
release. The script warns and exits 0. On a host where the public name really is
reachable from the host, set PIG_DEPLOY_REQUIRE_PUBLIC=1 to make it fatal
(exit 3).
scripts/autodeploy.sh writes the failed digest to
/var/lib/pig/failed-release and will not retry it, so one bad tag does not
become a five-minute restart loop. Delete that file, or publish a new tag, to
try again.
To pin a specific release by hand:
git checkout --detach refs/tags/release-2026-08-12
PIG_IMAGE=git.karti.ai/pig/pig:release-2026-08-12 bash scripts/deploy.sh
Check the tree out at the tag as well as setting PIG_IMAGE. The compose file
and the migrations must come from the same commit as the image; with
PIG_IMAGE set, deploy.sh deliberately does not touch git, precisely so it
cannot drag you back to main behind your back.
Migrations are additive and safe to re-run; Drizzle tracks what has been
applied. deploy.sh takes a dump before every deploy; take one by hand before
a major upgrade anyway:
docker compose -p pig exec db pg_dump -U pig pig | gzip > pig-$(date +%F).sql.gz
Installing the release poller
Only on the host that serves production, and only once.
# 1. A pull-only credential. read:package scope and NOTHING else — a token here
# that can write packages or push to the repository undoes the reason
# deployment is not automated from CI in the first place.
sudo install -d -m 0755 /etc/pig
printf '%s' 'gitea-token-here' | sudo tee /etc/pig/registry-token > /dev/null
sudo chmod 0600 /etc/pig/registry-token
sudo chown root:root /etc/pig/registry-token
# 2. Anything the defaults get wrong. Optional; the script assumes
# /opt/pig, git.karti.ai and pig/pig.
sudo tee /etc/pig/autodeploy.env > /dev/null <<'EOF'
PIG_REGISTRY_USER=pig-deploy
PIG_REPO_DIR=/opt/pig
EOF
sudo chmod 0600 /etc/pig/autodeploy.env
# 3. The units.
sudo cp /opt/pig/deploy/pig-autodeploy.service /etc/systemd/system/
sudo cp /opt/pig/deploy/pig-autodeploy.timer /etc/systemd/system/
sudo systemctl daemon-reload
# 4. Dry-run it once, in the foreground, before trusting a timer with it.
sudo systemctl start pig-autodeploy.service
sudo journalctl -u pig-autodeploy.service -n 50 --no-pager
# 5. Then arm it.
sudo systemctl enable --now pig-autodeploy.timer
systemctl list-timers pig-autodeploy.timer
The service is Type=oneshot with no Restart=, and the timer is not
Persistent=true: a missed poll is caught at the next tick rather than fired
at boot, which is when nobody is watching.
Watch a deploy:
journalctl -u pig-autodeploy.service -f
On-premises: using your own identity provider
PIG authenticates against any standards-compliant OIDC provider, which is how an install inside your own network works. Set:
PIG_OIDC_ISSUER=https://id.yourcompany.internal
PIG_OIDC_AUDIENCE=pig # the client/app id you registered for PIG
That is usually the whole configuration — the JWKS is discovered from the
issuer. On an air-gapped network, set PIG_OIDC_JWKS_URI too and no discovery
request is made.
PIG_OIDC_ISSUER takes precedence over SUPABASE_URL, so the hosted values
can stay in the environment file without quietly taking over.
Set the audience. Without it, any token your provider issued for any application in the same tenant verifies here — a token minted for an unrelated internal tool would be accepted as a PIG session. PIG warns about this at boot but cannot refuse, because some providers legitimately issue single-audience tokens.
Provisioning stays in PIG. Authenticating proves who someone is; it does not make them a member. They still need an invite, and their team and role live in PIG's database. That is deliberate — your directory should not have to model "supply lead versus demand member" for one application.
A note on the auth project
PIG verifies JWTs but authorizes from its own users table. If the Supabase
project is shared with another application, its users get nothing here until
they are explicitly invited. That is deliberate, and it is why a valid token can
still return 403 needs_profile.