A
?
← Handbook

Start here

Everything a new session needs before touching anything. Read this, then docs/register.md for what is built, then docs/working-with-agents.md for how the last session went wrong.

The repo

/Users/alex/projects/atlas-portal
github.com/alex-atlas-media/atlas-portal

Next.js 16 App Router, React 19, Tailwind 4, TypeScript strict, Node 22. Postgres 17 on Supabase, Singapore region. Two Supabase projects — dev and prod are separate databases, deliberately: a bad dev migration must not be able to reach live client data.

The two environments

Production Dev
URLapp.atlasconsulting.techdev.atlasconsulting.tech
Branchmaindev
Directory on the box/srv/atlas-portal/srv/atlas-portal-dev
Compose filedocker-compose.ymldocker-compose.dev.yml
Loginenforcedopen — ATLAS_OPEN_ACCESS=true, Alex's decision

Almost all work happens on dev. Production is many commits behind and is promoted deliberately, never by habit.

The box is a DigitalOcean droplet, 209.97.171.176, reached with ssh -i ~/.ssh/atlas_portal root@209.97.171.176. Docker Compose behind Caddy.

The plain docker-compose.yml in the dev directory is production's. Using it there starts a second set of containers against the wrong database. COMPOSE_FILE is pinned for this reason.

Secrets

They live in .env on the droplet, one per environment. Nothing real is ever committed; .env.example documents the names.

# Set one, in both environments, and verify it took. Reads stdin so it never hits shell history.
./scripts/set-secret.sh META_SYSTEM_USER_TOKEN     # paste, Enter, Ctrl-D

# List which names are set (values not printed)
ssh -i ~/.ssh/atlas_portal root@209.97.171.176 \
  'sed -n "s/^\([A-Z][A-Z0-9_]*\)=.*/\1/p" /srv/atlas-portal-dev/.env | sort'
.env.local points at the real dev database. It reads postgresql://postgres.kmxipgqwtvemezucecyh@aws-0-ap-southeast-1.pooler.supabase.com, which is Supabase dev — the same data dev.atlasconsulting.tech serves, not a local copy. This file used to say it pointed at localhost and was not the real database. Anything you run locally, including npm run dev and any script, reads and writes live dev client data.

Environments, 22 Sep 2026

Prod (app.atlasconsulting.tech, /srv/atlas-portal, branch main) and dev (dev.atlasconsulting.tech, /srv/atlas-portal-dev, branch dev) run the same code since the 22 Sep promotion; main is fast-forwarded from dev. Prod shows only the sections in PORTAL_MODULES (currently /pipeline,/finance); unset on dev means everything. To release a module on prod, add its section href to that variable in prod's .env and redeploy.

The commands that matter

./scripts/deploy.sh dev          # rebuild and restart the containers
./scripts/migrate.sh dev         # apply new migrations — PUSH FIRST, see below
./scripts/qa.sh dev              # everything that must pass before a change is done
./scripts/refresh.sh dev         # Meta spend, leads, daily rows, reconciliation — no model, free
./scripts/nightly.sh dev         # WhatsApp, Drive notes, extraction, supersession — costs money
npm test                         # vitest, 630 tests
npx tsc --noEmit && npm run lint

One build at a time, and let the box settle first. The droplet is 4 GB with no swap; a Next.js build takes 1.1 GB and most of both cores. On 21 Sep 2026 a second deploy started within a minute of the first finishing and, together with the 3-hourly refresh.sh, put the load average at 34 and froze dev for about ten minutes. Wait until uptime shows load under 2 before the next deploy.sh, and never deploy in the five minutes either side of a refresh run (every 3h) or the nightly (04:10 SGT).

migrate.sh resets the box to origin/$BRANCH before it looks for migrations. A migration that has not been pushed does not exist as far as it is concerned. Always: commit → push → migrate.

Scheduled work

On the droplet's crontab, all against dev:

When What
03:20 SGTbackup.sh — dump, restore into a throwaway container, compare row counts
04:10 SGTnightly.sh — ingestion and extraction. Costs a few dollars a night
Every 3hrefresh.sh — Meta reads and the reconciliation. Free

What is connected, and what it can do

Source State Write access
Meta ads13 client accounts, 495 monthly rows, daily rows since Maynone — ads_read, no ads_management
Meta Pages / Instagram14 Pages, 15 IG accounts, 6 months organicnone — ANALYZE / MANAGE_LEADS only
Meta leads903 leads with name, email, phone, custom fieldsnone
WhatsApp (Wassenger)35 client groups, 3,891 messagescannot send — enforced in code
Google Drive5 team Drives, domain-wide delegationread-only
ClickUp733 tasks, 2,035 comments, 1,437 channel messagesGET only — hard-coded, tested
Higgsfieldimage and video generationwrite, by design
Anthropicextraction, strategy, copy, the Brain—

Each read-only guarantee is structural and tested, not a policy someone remembers: src/lib/clickup/client.test.ts, purpose-rule.test.ts, and the Meta token's scope list.

Before changing anything

  • CLAUDE.md — the rules table, one line each, linking to the file that owns each concern
  • docs/register.md — every ask and its state. Entries 228–246 are the most recent work
  • docs/sop/ — the specifics that each cost an hour to learn: Meta access, campaign naming
  • docs/working-with-agents.md — why the last long session produced five faults, and the rule that followed

The four standing constraints

  1. Read-only where it says read-only. Not by restraint — the capability was never issued.
  2. Recruitment never averages into client results. Enforced across ten services by a test.
  3. Every number states its window and purpose. Three of five past faults were figures that were arithmetically correct and did not say what they were.
  4. Nothing guessed is written. A domain, client match or campaign purpose that cannot be evidenced stays empty and visible.