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.
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 / sessionGET 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"}}| Error | Status | Retry? | Meaning |
|---|---|---|---|
| invalid_request | 400 | never | Invalid command/body; see lib/agent-room/schema.ts for fields. |
| invalid_agent_token / sign_in_required | 401 | never | Missing, expired or revoked credentials. |
| forbidden / review_required | 403 | never | Role or task scope forbids the action; use the review workflow. |
| version_conflict / already_claimed / claim_required | 409 | never | Reload the current task, respect its claim, then make a new request. Do not overwrite another worker. |
| evidence_required / blocker_required | 422 | never | The new status requires a concrete handoff or blocker. |
| rate_limited | 429 | after Retry-After | 120 mutations per minute per caller per server instance. Honor Retry-After. |
| room_unavailable | 503 | with backoff | Database/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 · DELETEownerCreate 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 keyThe 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":{...}}| Error | Status | Retry? | Meaning |
|---|---|---|---|
| invalid_request | 400 / 413 | never | Malformed JSON, or a body over 32KB. A client bug. |
| invalid_app_key | 403 | never | Unrecognised key. Check what you shipped. |
| app_key_revoked | 403 | never | The key was turned off in SoleOS. Hide the feature rather than retrying. |
| invalid_end_user_ref | 400 | never | Not 16-200 chars of [A-Za-z0-9._:-]. Fix the generator; do not retry. |
| empty_message | 400 | never | Nothing to send. |
| message_too_long | 413 | never | Over 4000 characters. Cap the input client-side so this cannot happen. |
| rate_limited | 429 | after Retry-After | Too fast. `Retry-After` carries the wait in seconds. |
| server_error | 5xx | with backoff | Keep 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 keyThe 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.jsGETpublicThe 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.jsGETpublicThe 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/supportPOSTsessionSoleOS'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 / sessionInbox 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 / sessionOne 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 · DELETEownerSupport 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 · PATCHsessionRegister 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 · DELETEsessionCanned replies: list, create, edit, delete. Ordered by real use.
Auth: Signed-in session (Supabase auth cookie), workspace-scoped
/api/support/agent/ticketsGET · POST · PATCHsessionTickets, 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/countGETsessionUnread 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/webhookPOSTsessionMint 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-loopPOSTsignedViral Loop lifecycle webhook: `post.published` and `run.failed`.
Auth: Provider signature / shared secret header
X-ViralLoop-Signature: <hmac> # verified against VIRAL_LOOP_WEBHOOK_SECRETRefuses everything with 503 when the secret is unset rather than degrading into trusting unsigned input.
/api/webhooks/resendPOSTsignedResend 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/webhookPOSTsignedStripe → 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/telegramPOSTsignedTelegram 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 registeredPublic site
/api/newsletterPOSTpublicNewsletter capture. Writes through the service role because the table has no anon policy. Rate limited per IP.
Auth: None — public
/api/unsubscribeGET · POSTpublicOne-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 clientThe token is HMAC-signed and scoped to one address and mail kind.
/docs.mdGETpublicThis 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.mdRendered from the same lib/api-catalog.ts as the web page, so the two cannot say different things.
/llms.txtGETpublicProduct summary for AI assistants, generated from content/product-facts.json at build time so it cannot drift.
Auth: None — public
/feed.xmlGETpublicRSS 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 returnPKCE 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/projectsPOSTsessionCreate 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 · DELETEsessionUpdate a project (name, platforms, website, status) or delete it.
Auth: Signed-in session (Supabase auth cookie), workspace-scoped
/api/projects/[id]/refreshGET · POSTsessionRe-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 · PUTsessionPublic-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 · POSTsessionAudit the project's website for SEO and AI-answer readiness.
Auth: Signed-in session (Supabase auth cookie), workspace-scoped
/api/projects/[id]/contentGETsessionTop content posts for one project — which hook actually earned the reach.
Auth: Signed-in session (Supabase auth cookie), workspace-scoped
/api/connectionsGETsessionEvery 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/disconnectPOSTsessionRemove 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 · DELETEsessionAI 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 · DELETEsessionGoals 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 · PUTsessionPUT 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/startGETsessionStripe Connect OAuth, leg 1. Read-only; state is HMAC-signed.
Auth: Signed-in session (Supabase auth cookie), workspace-scoped
/api/connect/stripe/callbackGEToauth returnStripe Connect OAuth, leg 2. Verifies state, swaps the code, attaches the account.
Auth: Signed state / cookie from the matching `start` leg
/api/connect/revenuecat/startGETsessionRevenueCat 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 returnRevenueCat OAuth, leg 2. Stashes the token bundle in Vault.
Auth: Signed state / cookie from the matching `start` leg
/api/connect/posthog/startGETsessionPostHog 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 returnPostHog 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/startGETsessionGoogle 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 returnGA4 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/startGETsessionSearch 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 returnSearch 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/startGETsessionSupabase Management OAuth, leg 1. PKCE plus a signed context cookie.
Auth: Signed-in session (Supabase auth cookie), workspace-scoped
/api/connect/supabase/callbackGEToauth returnSupabase 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/revenuecatPOSTsessionWorkspace-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/projectsGETsessionList 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/pickPOSTsessionLink a chosen RevenueCat project to a SoleOS project.
Auth: Signed-in session (Supabase auth cookie), workspace-scoped
/api/connections/revenuecat/linkPOSTsessionPer-project RevenueCat link using whichever account grant applies.
Auth: Signed-in session (Supabase auth cookie), workspace-scoped
/api/connections/posthogPOSTsessionValidate a PostHog personal API key (query:read) and attach a traffic connection.
Auth: Signed-in session (Supabase auth cookie), workspace-scoped
/api/connections/ga4/propertiesGETsessionList GA4 properties the account can read, flattened across accounts.
Auth: Signed-in session (Supabase auth cookie), workspace-scoped
/api/connections/ga4/linkPOSTsessionRecord 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/sitesGETsessionList 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/linkPOSTsessionRecord 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/projectsGETsessionList Supabase projects the connected account can see.
Auth: Signed-in session (Supabase auth cookie), workspace-scoped
/api/connections/supabase/installPOSTsessionInstall the signup trigger through the Management API — no copy-paste.
Auth: Signed-in session (Supabase auth cookie), workspace-scoped
/api/connections/app-storePOSTsessionApp Store Connect via a pasted API key (issuer id, key id, .p8).
Auth: Signed-in session (Supabase auth cookie), workspace-scoped
/api/connections/playPOSTsessionGoogle Play Console. Install counts come from the Play-managed GCS bucket.
Auth: Signed-in session (Supabase auth cookie), workspace-scoped
/api/connections/firebasePOSTsessionFirebase, 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/bingPOSTsessionBing 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/appsGETsessionList 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/linkPOSTsessionConfirm 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/checkoutPOSTsessionCreate 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/portalPOSTsessionOpen the Stripe billing portal. Returns a URL to redirect to.
Auth: Signed-in session (Supabase auth cookie), workspace-scoped
/api/billing/statusGETsessionCurrent workspace plan and subscription status.
Auth: Signed-in session (Supabase auth cookie), workspace-scoped
/api/teamGET · POST · PATCH · DELETEownerList 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 · PATCHsessionWorkspace-level notification preferences — currently whether integration health changes are worth telling you about.
Auth: Signed-in session (Supabase auth cookie), workspace-scoped
/api/workspace/aiPOSTsessionThe 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 · DELETEsessionTelegram 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 · POSTadminPlatform 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 · POSTsecretThe 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 · POSTsecretWeekly 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 · POSTsecretRe-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 · POSTsecretSubmit 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 · POSTsecretAnswers-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 · POSTsecretWeekly 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-digestGETsecretThe 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/backtestGETsecretWalk-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.
| Platform | File | Dependencies |
|---|---|---|
| GameMaker (GML) | packages/support-v1/gamemaker/soleos_support.gml | none — http_request + the Async HTTP event |
| Unity (C#) | packages/support-v1/unity/SoleOSSupport.cs | none — UnityWebRequest + JsonUtility |
| iOS / macOS (Swift) | packages/support-v1/swift/SupportClient.swift | none — Foundation + Security |
| Android / JVM (Kotlin) | packages/support-v1/kotlin/SupportClient.kt | none beyond what an Android app ships |
| JavaScript / React Native | packages/support-v1/javascript/support-client.js | none |
| Web, as a script tag | served at /widget/v1.js | none |
Repo toolkit
What to run, and what each command is actually for.
npm ci --prefix packages/agent-roomInstall the pinned, local stdio MCP bridge dependencies.
node packages/agent-room/mcp.mjsRun 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 snapshotRead the current private room through the same API without any MCP dependencies.
node packages/agent-room/onboard.mjs discover /absolute/projectInspect 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.jsonValidate 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.jsonVerify 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.jsonVerify 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-runCheck 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.mjsCheck 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.mjsExercise 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.
npm run devNext.js dev server (Turbopack by default in 16).
npm run buildProduction build. The real gate — it typechecks and compiles every route.
npm run lintESLint, including the React hooks rules this codebase actually enforces.
npx tsc --noEmitTypecheck without building.
npm run pullTrigger the metric pullers against your local server.
node --experimental-strip-types --test lib/**/*.test.tsThe 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.sqlThe 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.
supabase startLocal Postgres + Auth + Storage + Realtime stack (needs Docker).
supabase db resetRebuild the local database from every migration, then seed.
supabase db push --dry-runShow which migrations would be applied to the linked remote project.
supabase db pushApply 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.
npx tsx scripts/sentry-coverage.tsWhich products report to Sentry, which are silent, and which are misnamed.
npx tsx scripts/stripe-setup.tsCreate the Stripe products and prices the pricing page promises.
npx tsx scripts/appstore-backfill.tsxBackfill App Store Connect history for a project.
node scripts/backfill-icons.mjsFill in missing project icons from favicons and App Store artwork.
npx tsx scripts/email-preview.tsxRender the transactional emails to HTML without sending them.
npx tsx scripts/gen-logos.tsRegenerate the provider logo sprite.
psql "$DB_URL" -f scripts/seed-live.sqlSeed 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.
| Variable | Required | What it does |
|---|---|---|
| SOLEOS_ROOM_URL | optional | Agent client only: HTTPS origin of SoleOS (default https://soleos.app). HTTP is allowed only on loopback for development. |
| SOLEOS_ROOM_TOKEN / SOLEOS_ROOM_TOKEN_FILE | optional | Agent 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_URL | optional | Local integration tests only: dedicated soleos_agentroom_test Postgres database on loopback. The test refuses any other target. |
| NEXT_PUBLIC_SUPABASE_URL | required | Supabase project URL. Public. |
| NEXT_PUBLIC_SUPABASE_ANON_KEY | required | Supabase anon key. Public, and assumed hostile — RLS and table grants are what protect data, not this key. |
| SUPABASE_SECRET_KEY | required | Service-role key. Server only. Bypasses RLS, so every route using it must do its own scoping. |
| PULL_SECRET | required | Bearer secret for the cron and pull endpoints. `requireSecret` throws in production if it is missing, so a misconfiguration is loud. |
| CRON_SECRET | optional | Alternative bearer secret, sent by Vercel Cron when set. |
| SOLEOS_INGEST_SECRET | optional | Bearer secret for the signal ingest gateway. |
| ANTHROPIC_API_KEY | optional | Claude, for AI insights, support drafts, translation and ticket summaries. Every one of those is dormant without it rather than broken. |
| RESEND_API_KEY | optional | Transactional email. Sending is skipped rather than failed when unset. |
| RESEND_WEBHOOK_SECRET | optional | Svix secret for the bounce/complaint webhook. Unset means the webhook refuses input rather than trusting it. |
| STRIPE_SECRET_KEY | optional | Stripe API key for billing. |
| STRIPE_WEBHOOK_SECRET | optional | Verifies Stripe webhook signatures. |
| STRIPE_CONNECT_CLIENT_ID | optional | Stripe Connect OAuth client for connecting a founder's own Stripe account. |
| TELEGRAM_BOT_TOKEN | optional | Telegram bot for signal notifications. |
| TELEGRAM_WEBHOOK_SECRET | optional | Echoed by Telegram in X-Telegram-Bot-Api-Secret-Token; the only thing authenticating that webhook. |
| VIRAL_LOOP_API_KEY | optional | Viral Loop content-engine connector. |
| VIRAL_LOOP_WEBHOOK_SECRET | optional | HMAC secret for Viral Loop lifecycle webhooks. |
| GOOGLE_OAUTH_CLIENT_ID / _SECRET | optional | Shared Google client for GA4 and Search Console. |
| SUPABASE_OAUTH_CLIENT_ID / _SECRET | optional | Supabase Management OAuth, for the native Supabase connector. |
| SOLEOS_ADMIN_EMAILS | optional | Comma-separated platform admins. Defaults to the owner. |
| NOTIFY_EMAIL | optional | Fallback recipient for signal and support mail. |
| SUPPORT_NOISE_EMAILS | optional | Server 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_IDS | optional | Server 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_HASHES | optional | Server 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_TOKEN | optional | Source-map upload. Only runs when the token is present, so local builds stay clean. |
| NEXT_PUBLIC_DEMO | optional | Forces 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.