API & tooling reference

Every HTTP surface this app exposes — 81 endpoints — with who may call it and how. Generated from the codebase and checked against it by a test, so nothing here describes an endpoint that does not exist, and nothing that exists is missing.

Reading this as an AI agent?

Fetch soleos.app/docs.md instead. Same catalogue, one plaintext file, no HTML to parse. There is also /llms.txt for what the product is, rather than what it exposes.

Private Agent Room

Persistent goals, tasks and conversations for humans, Codex and Claude Code. This is separate from customer support. All room agents share read access; worker credentials allow writes only to selected tasks. Tokens never authorize external deployments, spending, source access or launching another agent. See docs/AGENT-ROOM.md and packages/agent-room/ for the stdio MCP bridge and CLI.

/api/agent-roomGET · POSTroom token / session

GET returns the private board, goals, identities and recent activity. GET ?after=cursor polls the durable inbox; ?view=thread&task_key=APP-01&before=id pages a task conversation (omit task_key for room chat). POST creates goals/tasks, claims or releases work, assigns agents, reports progress or posts messages.

Auth: Full-workspace session or room-only agent bearer token; worker task writes require an allowed task and current claim

GET /api/agent-room with Authorization: Bearer $SOLEOS_ROOM_TOKEN
POST {"action":"claim","task_key":"APP-01","expected_version":1,"request_id":"<UUID>"}
POST {"action":"update_task","task_key":"APP-01","expected_version":2,"request_id":"<UUID>","data":{"status":"review","evidence":"commit/build/test evidence","next_action":"Coordinator review"}}
ErrorStatusRetry?Meaning
invalid_request400neverInvalid command/body; see lib/agent-room/schema.ts for fields.
invalid_agent_token / sign_in_required401neverMissing, expired or revoked credentials.
forbidden / review_required403neverRole or task scope forbids the action; use the review workflow.
version_conflict / already_claimed / claim_required409neverReload the current task, respect its claim, then make a new request. Do not overwrite another worker.
evidence_required / blocker_required422neverThe new status requires a concrete handoff or blocker.
rate_limited429after Retry-After120 mutations per minute per caller per server instance. Honor Retry-After.
room_unavailable503with backoffDatabase/configuration temporarily unavailable; preserve your draft and retry with the same request_id.

Every write has a caller-generated request_id UUID. Reuse it only for the exact same retry. Claims last 20 minutes; renew with claim + the latest expected_version. Workers submit review with evidence; only a coordinator can mark done or verify milestones. GET after returns at most 100 events; continue with its cursor while has_more. Same-origin JSON required for session writes. Private no-store responses, no public demo access. This room coordinates active clients; it does not wake sessions.

/api/agent-room/agentsPOST · DELETEowner

Create or revoke a room credential. POST takes request_id, name, role (worker/coordinator), task_keys and days (1–90); DELETE takes request_id and id. Only a workspace owner using a same-origin session can manage access.

Auth: Signed-in session — workspace owner only

Raw tokens are returned once, stored as SHA-256 hashes, expire, and are never listed again. A repeated creation request returns token:null; revoke and replace a lost credential. All agents read the room; task_keys limits worker task writes. Revocation also releases that agent's claims and assignments.

Support API

Everything a shipped product talks to. Public by design: no cookies, no SDK, no auth library. The ingest key identifies an app and grants exactly two capabilities — open a conversation, and append to one whose end_user_ref the caller already knows.

/api/support/v1/messagesGET · POST · OPTIONSapp key

The whole public support surface. POST opens a conversation for an (app, end_user_ref) pair or appends to the open one; GET polls for new messages. CORS-open, no cookies, no SDK required.

Auth: Public ingest key (`app_key`) + the caller's own `end_user_ref`

curl -X POST https://soleos.app/api/support/v1/messages \
  -H 'Content-Type: application/json' \
  -d '{
    "app_key": "pk_...",
    "end_user_ref": "hy2Nq8Rf3vKp1sLd7wZx4tCb",
    "message": "The game crashes on level 3",
    "client_message_id": "aX9...",
    "metadata": { "app_version": "1.4.2", "os_version": "Android 15",
                  "device_model": "SM-A155F", "locale": "tr-TR" }
  }'

# → 201 {"conversation_id":"...","message_id":42,"status":"open","duplicate":false}
#   200 with "duplicate":true when this client_message_id already landed

curl "https://soleos.app/api/support/v1/messages?app_key=pk_...&end_user_ref=hy2N...&since=42"
# → {"status":"open","messages":[...],"cursor":43,"poll_after_ms":15000,"app":{...}}
ErrorStatusRetry?Meaning
invalid_request400 / 413neverMalformed JSON, or a body over 32KB. A client bug.
invalid_app_key403neverUnrecognised key. Check what you shipped.
app_key_revoked403neverThe key was turned off in SoleOS. Hide the feature rather than retrying.
invalid_end_user_ref400neverNot 16-200 chars of [A-Za-z0-9._:-]. Fix the generator; do not retry.
empty_message400neverNothing to send.
message_too_long413neverOver 4000 characters. Cap the input client-side so this cannot happen.
rate_limited429after Retry-AfterToo fast. `Retry-After` carries the wait in seconds.
server_error5xxwith backoffKeep the message queued and try again with backoff.

end_user_ref is a bearer secret: 16-200 chars of [A-Za-z0-9._:-], stored only as SHA-256. Send it as X-End-User-Ref where your client can set headers. Rate limited per ref, per IP and per app; ETag + since make a steady-state poll cost no rows and no body. `status` is only ever "open" or "resolved" (an answered thread still reads as open), and `conversation_id`/`status` are absent — not null — until the first message.

/api/widgetPOST · OPTIONSapp key

The v0 widget protocol (actions open/send/poll), kept as a translation layer over the v1 core so script tags already embedded on live sites keep working.

Auth: Public ingest key (`app_key`) + the caller's own `end_user_ref`

curl -X POST https://soleos.app/api/widget \
  -H 'Content-Type: application/json' \
  -d '{"action":"open","key":"pk_...","meta":{"page":"/pricing","lang":"en"}}'
# → {"token":"...","greeting":"...","accent":"#F2A93B","messages":[]}

Frozen on purpose. `token` here IS a v1 `end_user_ref` — same value, same hash — so a browser holding one keeps its thread across the migration. New integrations should use v1.

/widget.jsGETpublic

The v0 embeddable chat widget, served as dependency-free JS in a Shadow DOM.

Auth: None — public

<script src="https://soleos.app/widget.js" data-soleos="pk_..."></script>
/widget/v1.jsGETpublic

The same widget speaking v1, with a persisted outbox that retries rather than dropping messages.

Auth: None — public

<script src="https://soleos.app/widget/v1.js" data-soleos="pk_..."></script>

Migrating from /widget.js is a one-line src change: this script adopts the old script's stored token so returning visitors keep their history.

/api/supportPOSTsession

SoleOS's own in-dashboard feedback box — a signed-in SoleOS user messaging the founder. Stores to `soleos_feedback` and pings admins by email and Telegram.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

Not the customer support inbox. End users of your products come in through /api/support/v1/messages.

Support inbox (agent)

The signed-in half of the support inbox, behind /app/support. Reads go through the caller's RLS-scoped client so a teammate limited to one project cannot read another product's threads; writes go through the service role only after an RLS read has proved the caller may act.

/api/support/agentGET · PATCHsupport token / session

Inbox bootstrap: conversations, apps, canned replies, projects and settings in one request. PATCH updates the workspace's support settings (notification thresholds, agent locale, default reply mode).

Auth: Signed-in session (workspace-scoped), or a support agent token: `Authorization: Bearer sos_…` with scope support:read (GETs) or support:reply (POST action=reply only)

curl https://soleos.app/api/support/agent?status=open \
  -H "Authorization: Bearer $SOLEOS_SUPPORT_TOKEN"

GET accepts a support agent token (support:read). PATCH is session-only. A token never writes read state, so the owner's unread badges are untouched. Inbox lists exclude conversations flagged is_test (review accounts or bracketed integration-test markers such as [INTEGRATION TEST - please ignore]); explicit thread GETs still retrieve them. Classification is sticky and applies to new and existing threads during ingestion; see SUPPORT_NOISE_EMAILS, SUPPORT_NOISE_USER_IDS and SUPPORT_NOISE_REF_HASHES. Pending notifications are drained when ingestion classifies a thread. Apply 20261031120000_support_inbox_followups.sql before deploying this code; its backfill covers the default review email and historical subject/message markers. Extend the migration's blocklist arrays before applying for custom historical identities: SQL cannot read application env vars.

/api/support/agent/threadGET · POSTsupport token / session

One conversation. GET ?conversationId=… (or legacy ?id=…) returns the full history without changing read state. POST takes an `action`: reply, status, read, translate, preview, draft, approve_draft, discard_draft, relabel_language.

Auth: Signed-in session (workspace-scoped), or a support agent token: `Authorization: Bearer sos_…` with scope support:read (GETs) or support:reply (POST action=reply only)

curl -X POST https://soleos.app/api/support/agent/thread \
  -H 'Content-Type: application/json' \
  -d '{"conversationId":"...","action":"reply","body":"On it.","translate":true}'

GET prefers non-empty conversationId, falling back to id; two different non-empty values return 400. Neither GET parameter marks a thread read. Session clients opening a thread must POST {conversationId, action: "read", messageId: <latest non-draft id displayed>}; refreshes/polls must not do this. Flagged test threads remain accessible by explicit id. approve_draft atomically consumes a draft and inserts a fresh message with a new id/time so since-based customer polls receive it; success remains {ok:true}. A missing, already approved, discarded or otherwise unavailable draft returns 409; a missing or invalid messageId or invalid approval body returns 400; database failures return 503 (refresh before retrying). `translate: true` sends in the user's language and stores both texts — body is what went over the wire, body_en is what you typed. A support agent token may GET (support:read) and POST `action: "reply"` (support:reply) only; each token reply is audit-logged first (fail closed), rate-limited (fail closed), always flagged ai_generated, and may carry `clientMessageId` (8–90 chars) so a retry sends once. Token responses omit projects, ingest keys and workspace settings.

/api/support/agent/tokensGET · POST · DELETEowner

Support agent tokens: list, mint (name, scopes support:read/support:reply, optional days 1–365, optional non-empty app_ids) and revoke (id). Same-origin JSON writes only; at most 20 live tokens per workspace; mints and revokes are audit-logged.

Auth: Signed-in session — workspace owner only

The raw `sos_` token is returned once from POST; only its SHA-256 and a display prefix are stored. A token cannot manage tokens, and is revoked (by database trigger) if its minting owner is demoted, removed or given project-scoped access.

/api/support/agent/appsGET · POST · PATCHsession

Register a product for support, mint or rotate its public ingest key, revoke it, and set its reply mode (human / auto_ack / ai_draft / ai_auto).

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/support/agent/cannedGET · POST · PATCH · DELETEsession

Canned replies: list, create, edit, delete. Ordered by real use.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/support/agent/ticketsGET · POST · PATCHsession

Tickets, filed per project from a conversation. POST with `auto: true` asks Claude to summarise the thread in English; without it, pass your own title and body.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

Keys are per project and assigned by a database trigger under an advisory lock, e.g. PUFFZE-7.

/api/support/agent/countGETsession

Unread badge count for the sidebar, excluding flagged test/review conversations. One indexed count, no joins.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

Signal ingest & webhooks

How events reach SoleOS from your own backends and from providers. Every one of these is signature- or secret-verified, and each refuses unsigned input rather than degrading into trusting it.

/api/connections/webhookPOSTsession

Mint or rotate a project's webhook secret and hand back the ready-to-paste Supabase trigger SQL or Firebase function snippet.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

The secret is shown once and stored as a SHA-256 hash.

/api/webhooks/viral-loopPOSTsigned

Viral Loop lifecycle webhook: `post.published` and `run.failed`.

Auth: Provider signature / shared secret header

X-ViralLoop-Signature: <hmac>   # verified against VIRAL_LOOP_WEBHOOK_SECRET

Refuses everything with 503 when the secret is unset rather than degrading into trusting unsigned input.

/api/webhooks/resendPOSTsigned

Resend bounce and complaint webhook. Hard bounces and spam complaints are auto-suppressed so a dead list cannot poison the sending domain shared by every product.

Auth: Provider signature / shared secret header

Svix-Id / Svix-Timestamp / Svix-Signature   # verified against RESEND_WEBHOOK_SECRET
/api/billing/webhookPOSTsigned

Stripe → Supabase sync, keeping each workspace's plan and subscription status current so the paywall can trust the database.

Auth: Provider signature / shared secret header

Stripe-Signature: <sig>   # verified against STRIPE_WEBHOOK_SECRET
/api/telegramPOSTsigned

Telegram bot webhook. Handles /start <code> (links a chat to a workspace) and /stop.

Auth: Provider signature / shared secret header

X-Telegram-Bot-Api-Secret-Token: <secret>   # set when the webhook was registered

Public site

/api/newsletterPOSTpublic

Newsletter capture. Writes through the service role because the table has no anon policy. Rate limited per IP.

Auth: None — public

/api/unsubscribeGET · POSTpublic

One-click unsubscribe (RFC 8058). Deliberately unauthenticated — an opt-out that first asks you to sign in is not an opt-out.

Auth: None — public

GET  /api/unsubscribe?token=<signed>   # human-facing confirmation page
POST /api/unsubscribe                  # List-Unsubscribe-Post from the mail client

The token is HMAC-signed and scoped to one address and mail kind.

/docs.mdGETpublic

This reference, as one plaintext file — every endpoint, its auth and runnable examples in a single fetch. What an AI agent should be pointed at instead of crawling /docs.

Auth: None — public

curl https://soleos.app/docs.md

Rendered from the same lib/api-catalog.ts as the web page, so the two cannot say different things.

/llms.txtGETpublic

Product summary for AI assistants, generated from content/product-facts.json at build time so it cannot drift.

Auth: None — public

/feed.xmlGETpublic

RSS 2.0 for the blog and answers series, so readers, aggregators and AI crawlers get every post the moment the engine publishes it.

Auth: None — public

/auth/callbackGEToauth return

PKCE code exchange — the landing point for magic links.

Auth: Signed state / cookie from the matching `start` leg

Portfolio & projects

The dashboard's own API. All session-authenticated and workspace-scoped; a teammate's project allowlist is enforced by RLS, not by the route.

/api/projectsPOSTsession

Create a project. Enriches it with the site's declared theme colour, favicon, or an exact-name App Store artwork match — misses stay null rather than being invented.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/projects/[id]PATCH · DELETEsession

Update a project (name, platforms, website, status) or delete it.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/projects/[id]/refreshGET · POSTsession

Re-pull one project's connected sources on demand instead of waiting for the daily cron.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/projects/[id]/shareGET · PUTsession

Public-share settings for one project. Writes mint an unguessable share token.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/projects/[id]/site-auditGET · POSTsession

Audit the project's website for SEO and AI-answer readiness.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/projects/[id]/contentGETsession

Top content posts for one project — which hook actually earned the reach.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connectionsGETsession

Every connection in the workspace with its provider, health and last pull. Drives the sidebar Sources widget.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connections/disconnectPOSTsession

Remove a project's connection(s) for a provider. Snapshots already captured are left in place.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/insightsGET · POST · PATCH · DELETEsession

AI insights: GET the latest stored brief, POST to generate one, PATCH to mark an item done, DELETE to clear.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

Requires ANTHROPIC_API_KEY and is dormant without it. Respects the per-workspace AI opt-out.

/api/goalsGET · POST · DELETEsession

Goals CRUD. The arrival-date maths lives in lib/goals.ts and runs client-side against the same series the chart draws.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/inboxGET · POST · PUTsession

PUT provisions a project's widget key (used by the project page). GET and POST answer 410 — they moved to /api/support/agent/*.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

Connecting a data source

Two shapes: OAuth flows (start → consent → callback → pick → link) and key-paste connectors. Credentials go to Supabase Vault, never to a column.

/api/connect/stripe/startGETsession

Stripe Connect OAuth, leg 1. Read-only; state is HMAC-signed.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connect/stripe/callbackGEToauth return

Stripe Connect OAuth, leg 2. Verifies state, swaps the code, attaches the account.

Auth: Signed state / cookie from the matching `start` leg

/api/connect/revenuecat/startGETsession

RevenueCat OAuth, leg 1: send the user to RevenueCat's approval screen with read-only scopes. State is HMAC-signed so the callback can trust which workspace it belongs to.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connect/revenuecat/callbackGEToauth return

RevenueCat OAuth, leg 2. Stashes the token bundle in Vault.

Auth: Signed state / cookie from the matching `start` leg

/api/connect/posthog/startGETsession

PostHog OAuth, leg 1. PKCE S256 public client (CIMD — the client_id is a URL we host).

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connect/posthog/callbackGEToauth return

PostHog OAuth, leg 2: verify the PKCE verifier, exchange the code, and store the grant before redirecting back with an immediate first pull.

Auth: Signed state / cookie from the matching `start` leg

/api/connect/ga4/startGETsession

Google Analytics 4 OAuth, leg 1. offline + consent so a refresh token is issued.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connect/ga4/callbackGEToauth return

GA4 OAuth, leg 2: verify the cookie, exchange the code, stash the token in Vault on the project's ga4 connection, then return the user to pick a property.

Auth: Signed state / cookie from the matching `start` leg

/api/connect/search-console/startGETsession

Search Console OAuth, leg 1: Google consent for read-only Search Console, with offline + consent so a refresh token is issued.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connect/search-console/callbackGEToauth return

Search Console OAuth, leg 2: verify the cookie, exchange the code, stash the token in Vault, then return the user to pick a verified site.

Auth: Signed state / cookie from the matching `start` leg

/api/connect/supabase/startGETsession

Supabase Management OAuth, leg 1. PKCE plus a signed context cookie.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connect/supabase/callbackGEToauth return

Supabase OAuth, leg 2: verify the signed context cookie, exchange the code for a management token, and stash it in Vault on the project's supabase connection.

Auth: Signed state / cookie from the matching `start` leg

/api/connections/revenuecatPOSTsession

Workspace-level RevenueCat import from a v2 secret key; the key goes to Vault.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connections/revenuecat/projectsGETsession

List the RevenueCat projects the connected grant can see, so the user picks which one maps to this SoleOS project.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connections/revenuecat/pickPOSTsession

Link a chosen RevenueCat project to a SoleOS project.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connections/revenuecat/linkPOSTsession

Per-project RevenueCat link using whichever account grant applies.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connections/posthogPOSTsession

Validate a PostHog personal API key (query:read) and attach a traffic connection.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connections/ga4/propertiesGETsession

List GA4 properties the account can read, flattened across accounts.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connections/ga4/linkPOSTsession

Record which GA4 property this project tracks. The puller reads config.propertyId from here on every run.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connections/search-console/sitesGETsession

List the verified Search Console sites the account owns, so the user picks which property maps to this project.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connections/search-console/linkPOSTsession

Record which verified Search Console site this project tracks. The puller reads it from the connection config on every run.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connections/supabase/projectsGETsession

List Supabase projects the connected account can see.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connections/supabase/installPOSTsession

Install the signup trigger through the Management API — no copy-paste.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connections/app-storePOSTsession

App Store Connect via a pasted API key (issuer id, key id, .p8).

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connections/playPOSTsession

Google Play Console. Install counts come from the Play-managed GCS bucket.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connections/firebasePOSTsession

Firebase, connected by pasting a service-account key — no manual Cloud Function to deploy. The key goes to Vault.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connections/bingPOSTsession

Bing Webmaster Tools, connected by pasting an API key. Adds search impressions and clicks for Bing-family engines.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connections/viral-loop/appsGETsession

List Viral Loop apps the workspace key can see, auto-matched to SoleOS projects.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/connections/viral-loop/linkPOSTsession

Confirm the whole Viral Loop match set at once — the match is portfolio-wide.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

Billing, team & workspace

/api/billing/checkoutPOSTsession

Create or reuse the workspace's Stripe customer and open a Checkout Session with a 7-day trial. Returns a URL to redirect to.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/billing/portalPOSTsession

Open the Stripe billing portal. Returns a URL to redirect to.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/billing/statusGETsession

Current workspace plan and subscription status.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/teamGET · POST · PATCH · DELETEowner

List the team, invite by email, change a role, revoke. Every mutation is owner-only and re-checks the caller's role server-side.

Auth: Signed-in session — workspace owner only

/api/workspace/prefsGET · PATCHsession

Workspace-level notification preferences — currently whether integration health changes are worth telling you about.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/workspace/aiPOSTsession

The per-workspace AI opt-out promised on /ai. Turning it off makes every AI feature dormant rather than degraded.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/telegram/linkGET · POST · DELETEsession

Telegram link management: GET status, POST mints a one-time code and t.me deep link, DELETE unlinks.

Auth: Signed-in session (Supabase auth cookie), workspace-scoped

/api/admin/workspacesGET · POSTadmin

Platform admin: list and approve waitlisted workspaces. Defaults to the owner; override with SOLEOS_ADMIN_EMAILS.

Auth: Signed-in session — platform admin only

Scheduled & internal

Not for external callers. Authenticated with PULL_SECRET or CRON_SECRET; Vercel Cron invokes them on the schedule in vercel.json.

/api/pullGET · POSTsecret

The metric pullers. One route, one puller per provider, with per-connection error isolation so a failing connection is marked and the run continues.

Auth: `Authorization: Bearer $PULL_SECRET` (or `$CRON_SECRET`)

curl -X POST https://soleos.app/api/pull -H "Authorization: Bearer $PULL_SECRET"

Cron: daily 06:00 UTC.

/api/digestGET · POSTsecret

Weekly portfolio digest email: MRR and week delta, top movers, nearest milestone ETAs, signal counts.

Auth: `Authorization: Bearer $PULL_SECRET` (or `$CRON_SECRET`)

Cron: Mondays 08:00 UTC.

/api/retry-pendingGET · POSTsecret

Re-check connections that could not validate at connect time (e.g. a Play grant that had not propagated).

Auth: `Authorization: Bearer $PULL_SECRET` (or `$CRON_SECRET`)

Cron: every 6 hours.

/api/indexnowGET · POSTsecret

Submit every public URL to IndexNow so Bing-family engines pick up new pages in minutes.

Auth: `Authorization: Bearer $PULL_SECRET` (or `$CRON_SECRET`)

Cron: daily 07:30 UTC.

/api/cron/blog-generatorGET · POSTsecret

Answers-engine cron: at most one grounded post per run.

Auth: `Authorization: Bearer $PULL_SECRET` (or `$CRON_SECRET`)

Cron: 06:50 and 17:50 UTC.

/api/cron/site-auditGET · POSTsecret

Weekly Site Health sweep, so the score becomes a tracked series rather than a one-off number.

Auth: `Authorization: Bearer $PULL_SECRET` (or `$CRON_SECRET`)

Cron: Mondays 05:20 UTC.

/api/cron/support-digestGETsecret

The support notification batching tick. Sends nothing most runs; the threshold logic lives in lib/support/notify.ts.

Auth: `Authorization: Bearer $PULL_SECRET` (or `$CRON_SECRET`)

Cron: every 5 minutes. Flagged test/review conversations are excluded before the event limit and batching thresholds, and rechecked when building the email.

/api/cron/backtestGETsecret

Walk-forward backtest of the projection engine against every project's real MRR history.

Auth: `Authorization: Bearer $PULL_SECRET` (or `$CRON_SECRET`)

Run manually.

Signal ingest gateway

Callable surfaces that are not Next.js routes, and so are not covered by the route checks above.

Signal ingest gateway

POST {SUPABASE_URL}/functions/v1/ingest?source=<source>&project=<slug>

A Supabase Edge Function, not a Next.js route. One endpoint receives every lifecycle signal — signups from your own backend, purchases and churn from RevenueCat — and turns them into events, notifications and emails.

Auth: `Authorization: Bearer $SOLEOS_INGEST_SECRET`, or the per-project webhook secret minted by /api/connections/webhook

curl -X POST "$SUPABASE_URL/functions/v1/ingest?source=app&project=puff-zero" \
  -H "Authorization: Bearer $SOLEOS_INGEST_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{"type":"signup","payload":{"desc":"new user"}}'

Idempotent by construction: dedupe_key is the source plus the provider's event id (or a body hash), and a unique index makes a provider's retries no-ops. Source lives at supabase/functions/ingest/index.ts.

Client snippets

Copy-pasteable clients for the v1 support API. Each generates and persists a stable end_user_ref on first run and attaches app version, OS version, device model and locale automatically.

PlatformFileDependencies
GameMaker (GML)packages/support-v1/gamemaker/soleos_support.gmlnone — http_request + the Async HTTP event
Unity (C#)packages/support-v1/unity/SoleOSSupport.csnone — UnityWebRequest + JsonUtility
iOS / macOS (Swift)packages/support-v1/swift/SupportClient.swiftnone — Foundation + Security
Android / JVM (Kotlin)packages/support-v1/kotlin/SupportClient.ktnone beyond what an Android app ships
JavaScript / React Nativepackages/support-v1/javascript/support-client.jsnone
Web, as a script tagserved at /widget/v1.jsnone

Repo toolkit

What to run, and what each command is actually for.

Agent Room
npm ci --prefix packages/agent-room

Install the pinned, local stdio MCP bridge dependencies.

node packages/agent-room/mcp.mjs

Run the Codex/Claude-compatible MCP bridge using a room credential in the environment or an owner-only token file.

node packages/agent-room/cli.mjs snapshot

Read the current private room through the same API without any MCP dependencies.

node packages/agent-room/onboard.mjs discover /absolute/project

Inspect bounded repository/configuration hints without executing the project or reading environment secrets. Does not prove service access.

node packages/agent-room/onboard.mjs plan /private/project.json

Validate a project manifest and preview source, identity and MCP configuration blockers. See docs/PROJECT-ONBOARDING.md.

node packages/agent-room/onboard.mjs verify /private/project.json

Verify an existing worker credential against its exact live room, identity and task scope; does not prove the interactive MCP client or external service integrations.

node packages/agent-room/onboard.mjs apply /private/project.json

Verify and apply project onboarding files with private backups, preserving unrelated configuration and existing identities. Does not issue credentials or launch workers.

node packages/agent-room/supervisor.mjs /private/config.json --dry-run

Check a separately configured local coordinator supervisor. Existing ChatGPT CLI login, private room credential and usage limits required; does not install scheduling or start project workers. See docs/AGENT-ROOM.md.

node --test packages/agent-room/*.test.mjs

Check client transport safety, retries and MCP protocol compatibility.

ROOM_TEST_DATABASE_URL=postgresql://127.0.0.1:55439/soleos_agentroom_test node --test scripts/test-agent-room-db.mjs

Exercise real PostgreSQL claims, concurrency, review gates, idempotency and access controls in an isolated local database.

node scripts/mint-support-token.mjs --workspace=<uuid> --owner-email=<owner> --name=<label> --out=<absolute path> [--days=90]

Mint a support agent token with the service role (when the Support settings UI is not an option). Writes the raw token to a new mode-600 file and never prints it. Requires server credentials and the support_agent_tokens migration.

node scripts/seed-agent-room.mjs --workspace=<uuid> --checklist=<path> --owner-email=<email>

Import the Doozle checklist once into an explicitly verified owner's workspace; existing tasks are preserved. Requires server credentials. Optional --credentials-dir creates initial room-only agent identities.

Everyday
npm run dev

Next.js dev server (Turbopack by default in 16).

npm run build

Production build. The real gate — it typechecks and compiles every route.

npm run lint

ESLint, including the React hooks rules this codebase actually enforces.

npx tsc --noEmit

Typecheck without building.

npm run pull

Trigger the metric pullers against your local server.

Tests
node --experimental-strip-types --test lib/**/*.test.ts

The unit tests. No build step, no test framework — Node's own runner over TypeScript.

Test files import with explicit `.ts` specifiers so type-stripping works; that is why tsconfig excludes them.

psql "$DB_URL" -v ON_ERROR_STOP=1 -f supabase/tests/support_rls.sql

The RLS suite: proves anon cannot touch the support tables, that workspaces are isolated, that viewers cannot write, and that the rate limiter really runs out.

Plain SQL assertions, so running them adds no extension to the database. Verified non-vacuous: introducing a deliberate anon leak makes it fail.

Database
supabase start

Local Postgres + Auth + Storage + Realtime stack (needs Docker).

supabase db reset

Rebuild the local database from every migration, then seed.

supabase db push --dry-run

Show which migrations would be applied to the linked remote project.

supabase db push

Apply pending migrations to the remote project.

Apply the migration BEFORE deploying code that reads new tables — /api/widget is embedded on live customer sites and the reverse order breaks it for the length of a build.

Scripts
npx tsx scripts/sentry-coverage.ts

Which products report to Sentry, which are silent, and which are misnamed.

npx tsx scripts/stripe-setup.ts

Create the Stripe products and prices the pricing page promises.

npx tsx scripts/appstore-backfill.tsx

Backfill App Store Connect history for a project.

node scripts/backfill-icons.mjs

Fill in missing project icons from favicons and App Store artwork.

npx tsx scripts/email-preview.tsx

Render the transactional emails to HTML without sending them.

npx tsx scripts/gen-logos.ts

Regenerate the provider logo sprite.

psql "$DB_URL" -f scripts/seed-live.sql

Seed real projects and connections (generated from RevenueCat); see also seed-inventory.sql, seed-analytics-connections.sql, backfill-mrr.sql, backfill-traffic.sql.

Environment variables

Optional ones make a feature dormant rather than broken — that is deliberate, so a missing key never takes the app down with it.

VariableRequiredWhat it does
SOLEOS_ROOM_URLoptionalAgent client only: HTTPS origin of SoleOS (default https://soleos.app). HTTP is allowed only on loopback for development.
SOLEOS_ROOM_TOKEN / SOLEOS_ROOM_TOKEN_FILEoptionalAgent client only: individual room bearer credential, directly or via an owner-only file. Never configure this as a public Next.js variable or commit a value.
ROOM_TEST_DATABASE_URLoptionalLocal integration tests only: dedicated soleos_agentroom_test Postgres database on loopback. The test refuses any other target.
NEXT_PUBLIC_SUPABASE_URLrequiredSupabase project URL. Public.
NEXT_PUBLIC_SUPABASE_ANON_KEYrequiredSupabase anon key. Public, and assumed hostile — RLS and table grants are what protect data, not this key.
SUPABASE_SECRET_KEYrequiredService-role key. Server only. Bypasses RLS, so every route using it must do its own scoping.
PULL_SECRETrequiredBearer secret for the cron and pull endpoints. `requireSecret` throws in production if it is missing, so a misconfiguration is loud.
CRON_SECREToptionalAlternative bearer secret, sent by Vercel Cron when set.
SOLEOS_INGEST_SECREToptionalBearer secret for the signal ingest gateway.
ANTHROPIC_API_KEYoptionalClaude, for AI insights, support drafts, translation and ticket summaries. Every one of those is dormant without it rather than broken.
RESEND_API_KEYoptionalTransactional email. Sending is skipped rather than failed when unset.
RESEND_WEBHOOK_SECREToptionalSvix secret for the bounce/complaint webhook. Unset means the webhook refuses input rather than trusting it.
STRIPE_SECRET_KEYoptionalStripe API key for billing.
STRIPE_WEBHOOK_SECREToptionalVerifies Stripe webhook signatures.
STRIPE_CONNECT_CLIENT_IDoptionalStripe Connect OAuth client for connecting a founder's own Stripe account.
TELEGRAM_BOT_TOKENoptionalTelegram bot for signal notifications.
TELEGRAM_WEBHOOK_SECREToptionalEchoed by Telegram in X-Telegram-Bot-Api-Secret-Token; the only thing authenticating that webhook.
VIRAL_LOOP_API_KEYoptionalViral Loop content-engine connector.
VIRAL_LOOP_WEBHOOK_SECREToptionalHMAC secret for Viral Loop lifecycle webhooks.
GOOGLE_OAUTH_CLIENT_ID / _SECREToptionalShared Google client for GA4 and Search Console.
SUPABASE_OAUTH_CLIENT_ID / _SECREToptionalSupabase Management OAuth, for the native Supabase connector.
SOLEOS_ADMIN_EMAILSoptionalComma-separated platform admins. Defaults to the owner.
NOTIFY_EMAILoptionalFallback recipient for signal and support mail.
SUPPORT_NOISE_EMAILSoptionalServer only: comma-separated review-account emails, matched as whole values ignoring case/outer whitespace against the subject, a user message body, or support metadata contact_email/contactEmail/email/user_email/userEmail. Email mentions embedded in longer text do not match. Defaults to stepnscroll.review@gmail.com when unset; an empty value disables email matching; a configured list replaces the default. Ingestion flags matching conversations is_test permanently, hiding them from inbox lists, badges and digests while retaining explicit-id access and customer polling. A bracketed integration-test marker in the subject or a user body always flags a thread, ignoring case and allowing whitespace after [ and extra text before ], e.g. [INTEGRATION TEST - please ignore]. Metadata is an untrusted display hint, never authorization.
SUPPORT_NOISE_USER_IDSoptionalServer only: comma-separated review user ids, matched exactly (case-sensitive, outer whitespace trimmed) against support metadata user_id/userId/uid/end_user_id/endUserId, including numeric values. Empty by default. Each configured value is also hashed with the ingestion SHA-256 function and matched against the full end_user_ref_hash, so a configured end_user_ref works without metadata. Treat configured raw refs as bearer secrets; prefer SUPPORT_NOISE_REF_HASHES when only stored hashes are available. The 8-character end_user_label is never matched. Existing stored context is checked again on ingestion; historical custom identities need the migration's backfill blocklist arrays populated before application. Changing env lists never automatically clears an existing is_test flag.
SUPPORT_NOISE_REF_HASHESoptionalServer only: comma-separated full SHA-256 end_user_ref_hash values (64 hex characters, case-insensitive, outer whitespace trimmed). Invalid entries are ignored; empty by default. Matches full stored hashes, never the 8-character end_user_label. Flags are sticky; populate the migration ref_hashes array for custom historical matches before applying, since SQL cannot read env vars.
SENTRY_ORG / SENTRY_PROJECT / SENTRY_AUTH_TOKENoptionalSource-map upload. Only runs when the token is present, so local builds stay clean.
NEXT_PUBLIC_DEMOoptionalForces the public demo experience with sample data and no auth.

Source of truth: lib/api-catalog.ts, kept honest by lib/api-catalog.test.ts — it walks every route.ts and fails if this page and the codebase ever disagree.