Installation
Comms installs the same way every ARK product does. This page covers the Comms-specific steps. For the generic Supabase + Vercel setup, head to Installation.
Comms's database schema
After the shared platform schema, run Comms's schema:
# from your local Comms repo
supabase db push --file supabase/schema.sqlThis creates channels, messages, threads, mail_accounts, and
related tables. See Database Reference.
Environment variables
Standard ARK variables (see Environment Variables) plus:
VITE_COMMS_REALTIME_URL
What it is: the Supabase Realtime endpoint URL. Defaults to
deriving from VITE_SUPABASE_URL — set explicitly only if you
route realtime through a custom domain.
Required? No.
VITE_RESEND_API_KEY
What it is: Resend API key for outbound email (replies sent from Comms, digest emails, password resets).
Required? Yes, if you want to send email from Comms.
COMMS_INBOUND_EMAIL_DOMAIN
What it is: the domain that routes incoming email into Comms
inboxes. E.g., mail.acme.com.
Required? Only if you use the Mail feature.
COMMS_INBOUND_SIGNING_SECRET
What it is: HMAC secret used to verify inbound-email webhook payloads from your mail provider.
Generate: openssl rand -hex 32.
Required? Yes, if Mail is enabled. Never run inbound email without signature verification.
VITE_TURN_SERVER_URL / VITE_TURN_USERNAME / VITE_TURN_CREDENTIAL
What they are: coordinates for a TURN relay, used by huddles when peers can't connect directly.
Required? Only if your users are on restrictive networks. Defaults to STUN-only (works for most teams).
VITE_SLACK_BOT_TOKEN / COMMS_SLACK_SIGNING_SECRET
What they are: credentials for the optional Slack bridge (mirror a Slack channel into Comms or vice versa).
Required? Only if you're using the Slack bridge. Unrelated to the Slack import feature — the importer uses an admin-only edge function, not a bot token.
VITE_EXTENSIONS_ENABLED (frontend) / EXTENSIONS_ENABLED (Supabase secret)
What they are: the two-part feature gate for the Comms
Extensions framework — the connector system that brings external
sources (Gmail, Slack, etc.) into Comms. The frontend env var gates
the browser bundle; the Supabase secret gates Edge Functions. Both
must be true for any extension to render or run.
Required? Only if you want to enable any extension. With both
set to false (or unset), Comms behaves identically to a build
with no extension code present. v1 access is admin-only.
GMAIL_OAUTH_CLIENT_ID / GMAIL_OAUTH_CLIENT_SECRET (Supabase secrets)
What they are: Google Cloud OAuth 2.0 client credentials for
the Gmail extension. Generate them in the Google Cloud Console
(see the in-repo Comms src/extensions/gmail/README.md for the
full Google-side setup, including the required redirect URI
https://<comms-host>/ext/oauth/callback?extension=gmail and the
gmail.modify scope — broad enough to read threads/messages and
toggle the STARRED label so the star button on Comms's Home
inbox can star and unstar Gmail threads in real time).
Required? Only if you connect Gmail. Can be omitted while
EXTENSIONS_ENABLED=false.
SLACK_OAUTH_CLIENT_ID / SLACK_OAUTH_CLIENT_SECRET / SLACK_SIGNING_SECRET (Supabase secrets)
What they are: Slack app OAuth credentials and the HMAC
signing secret for the Slack live extension (read-only mirror of
Slack channels, DMs, mentions, threads, and reactions inside
Comms). Generate them at api.slack.com/apps
after creating the app — the in-repo src/extensions/slack/README.md
walks through the scope set, redirect URL, and Events API
configuration. Different feature than the
one-shot Slack import, which uses
the admin-only comms-import-slack Edge Function and doesn't
need a Slack app.
Required? Only if you connect Slack via the live extension.
Can be omitted while EXTENSIONS_ENABLED=false. The live
extension also needs COMMS_ALLOWED_ORIGIN set to your Comms
host.
OPENAI_API_KEY (Supabase secret)
Powers the embedding pipeline behind
cross-source semantic search.
Used by comms-embed-search-rows (worker) and comms-embed-query
(query path). Any paid OpenAI account key works.
supabase secrets set OPENAI_API_KEY=sk-...Required? Only if you enable cross-source semantic search.
Can be omitted otherwise — without it, comm_search_index rows
stay at embedding_status='pending' and search returns nothing.
Edge Functions
Comms ships with these Edge Functions:
| Function | Purpose |
|---|---|
comms-inbound-email | Receives webhook POSTs from your mail provider, verifies signatures, inserts messages. |
comms-notification-dispatcher | Fans out in-app + email + mobile-push notifications with per-user rules. |
comms-huddle-signaling | WebRTC signaling for huddles (SDP offer/answer exchange). |
comms-retention-sweeper | Nightly cron that enforces per-channel retention policies. |
comms-import-slack | Streams a one-shot Slack-export import. Writes an NDJSON progress event per 1,000-row batch so the Supabase gateway's 150 s idle timeout never trips. Admin-only (re-checks user_profiles.role). See Import from Slack. |
comms-ext-gmail-oauth | Two-mode OAuth function for the optional Gmail extension. mode=start returns a Google consent URL; mode=exchange consumes a one-shot state token, exchanges the code with Google, encrypts the tokens via Supabase Vault, and writes the connection row. Admin-only. Bails with 503 when EXTENSIONS_ENABLED is not set to true. |
comms-ext-gmail-sync | Gmail backfill, incremental sync via users.history.list, daily watch renewal, disconnect, and per-thread label modification. Five modes via the request body's mode field: backfill-start (paginated 30-day import — admin user JWT), sync-now (delta sync — admin user JWT or service role; applies messages added/deleted AND label changes via users.threads.modify), watch-renewal (service role only), disconnect (admin user JWT — also stops the Gmail watch and deletes the Vault secret), modify-thread (admin user JWT — calls users.threads.modify with addLabelIds / removeLabelIds; today this powers Comms's Home star/unstar button on Gmail rows, and the same shape extends to archive / mark-read / arbitrary label toggles when those features ship). Auto-refreshes Google access tokens; marks the connection state='disconnected' on a 400 invalid_grant or post-retry 401 so a revoked OAuth grant doesn't loop silently. |
comms-ext-gmail-webhook | Receives Cloud Pub/Sub push deliveries when new mail lands in a connected Gmail account. Verifies the Google-signed JWT (audience + service account email), looks up the connection by email_address, and fan-outs a fire-and-forget sync-now invocation. Returns 204 immediately so the 60-second Pub/Sub ack deadline isn't a constraint. |
comms-ext-gmail-watch-renewal-cron | Thin scheduled trigger. POSTs {"mode": "watch-renewal"} to comms-ext-gmail-sync once a day so each user's users.watch doesn't lapse (Google enforces a 7-day max). Designed for pg_cron invocation; safe to curl by hand for smoke tests. |
comms-embed-search-rows | Embedding pipeline worker for cross-source semantic search. Drains comm_search_index rows where embedding_status='pending' OR needs_reembed=true, batches them through OpenAI's text-embedding-3-small (64 inputs per call, 1536-dim vectors), and writes them back via bulk_set_embeddings. Runs on a 5-minute pg_cron schedule. Backfill mode (POST {"mode":"backfill"}) loops sweeps until queue empty or the 120 s wall-clock budget hits. |
comms-embed-query | Companion to comms-embed-search-rows. Vectorizes a single search-query string from the browser and returns the embedding so the frontend can call match_search_index for nearest-neighbor lookup. User JWT required. |
Deploy:
supabase functions deploy comms-inbound-email
supabase functions deploy comms-notification-dispatcher
supabase functions deploy comms-huddle-signaling
supabase functions deploy comms-retention-sweeper
supabase functions deploy comms-import-slack
# only if enabling the Gmail extension — see the Extensions section below
supabase functions deploy comms-ext-gmail-oauth
supabase functions deploy comms-ext-gmail-sync
supabase functions deploy comms-ext-gmail-webhook --no-verify-jwt
supabase functions deploy comms-ext-gmail-watch-renewal-cron --no-verify-jwt
# only if enabling cross-source semantic search — see the Search section below
supabase functions deploy comms-embed-search-rows --no-verify-jwt
supabase functions deploy comms-embed-querySchedule the retention sweeper (daily, 3 AM):
select cron.schedule(
'comms-retention-sweeper',
'0 3 * * *',
$$select net.http_post(
url := 'https://<project-ref>.functions.supabase.co/comms-retention-sweeper',
headers := jsonb_build_object('Authorization', 'Bearer ' || current_setting('app.settings.service_role_key'))
);$$
);Extensions (optional)
Comms's Extensions framework lets admins connect external
sources (Gmail today; Slack and others on the roadmap) and surface
their conversations inside Comms. The framework is gated by
VITE_EXTENSIONS_ENABLED (frontend) plus EXTENSIONS_ENABLED
(Supabase secret) — both must be true to enable any extension.
Gmail extension
End-to-end Gmail support is shipping. All five build phases are complete:
- Phase 1 ✅ Schema + framework scaffolding
- Phase 2 ✅ OAuth flow + encrypted token storage in Supabase Vault
- Phase 3 ✅ Sync engine, 30-day backfill, Pub/Sub real-time, inbox UI, thread view, "Reply in Gmail" deep link, disconnect flow
- Phase 4 ✅ Folder labels, search across the connected mailbox, multi-account switching for users with more than one connected Gmail
- Phase 5 ✅ Daily
users.watchrenewal cron, revoked-token auto-detection (invalid_grant→ connection marked disconnected), acceptance test plan - Star toggle ✅ OAuth scope upgraded to
gmail.modify; newmodify-threadmode oncomms-ext-gmail-synccallsusers.threads.modify; Home's star button on Gmail rows now rounds-trips through Gmail (Pub/Sub delivers the resulting label change so Comms-initiated and external stars produce identical realtime events). Existinggmail.readonlyconnections see an in-app reconnect banner until they re-OAuth.
Full per-extension setup notes — Google Cloud project, OAuth client,
redirect URI, Cloud Pub/Sub topic + push subscription, Supabase Vault
verification, per-phase deploy commands, and the full acceptance test
script — live in src/extensions/gmail/README.md and
docs/gmail-acceptance.md inside the Comms repo. Run the Vault smoke
test before deploying the OAuth function:
SELECT vault.create_secret('test-value', 'comms_vault_smoketest');
SELECT decrypted_secret FROM vault.decrypted_secrets
WHERE name = 'comms_vault_smoketest';
DELETE FROM vault.secrets WHERE name = 'comms_vault_smoketest';If the second SELECT returns 'test-value', Vault is enabled
and the extension's token storage will work as written. (Vault is
enabled by default on all Supabase paid plans as of mid-2025.)
INTERNAL_SYNC_FORWARD_JWT (only on new-key Supabase projects)
If your Supabase project uses the new sb_publishable_… /
sb_secret_… API key format (the default for projects created
mid-2025 onward), set this additional secret. It's the legacy
JWT-format service-role key that the Gmail webhook and watch-renewal
cron use when calling each other internally.
Symptoms if you skip this on a new-key project:
- Initial 30-day backfill writes thread rows but no message rows
(
SELECT count(*) FROM ext_gmail_messagesreturns 0 even though the UI says backfill is complete). - New emails never sync into Comms — the
comms-ext-gmail-webhookinvocation log shows 401 (or 204 with no follow-on sync activity). comms-ext-gmail-syncinvocation metadata shows"prefix": "sb_secret_…"and"jwt": []on the failing requests.
Why: Supabase's Edge Functions gateway requires JWT-format auth
on inbound requests, but the auto-injected SUPABASE_SERVICE_ROLE_KEY
env var on new-key projects is the opaque secret-format token, not a
JWT. The gateway 401s with UNAUTHORIZED_INVALID_JWT_FORMAT even
when "Verify JWT" is toggled off (that toggle disables verification
of an authenticated user's JWT, not the gateway's API-key check).
The dashboard rejects any custom secret with the SUPABASE_ prefix,
so we use a non-reserved name.
How to set it:
- Project Settings → API → Legacy API Keys → service_role → Reveal.
Copy the
eyJ…value. - Project Settings → Edge Functions → Manage Secrets → Add new secret.
- Name:
INTERNAL_SYNC_FORWARD_JWT - Value: the
eyJ…value you copied
- Name:
- Save. The cron and webhook will now use it for internal
forwarding to
comms-ext-gmail-sync.
Projects on legacy-JWT-only key formats (legacy keys still primary)
can omit this secret entirely — the auto-injected
SUPABASE_SERVICE_ROLE_KEY is already a JWT and the fallback inside
each function picks it up automatically.
Schedule the daily watch-renewal cron
After deploying comms-ext-gmail-watch-renewal-cron, schedule it
from the Supabase SQL Editor so each connected user's Gmail watch
doesn't lapse (Google enforces a 7-day max per watch):
CREATE EXTENSION IF NOT EXISTS pg_cron;
CREATE EXTENSION IF NOT EXISTS pg_net;
SELECT cron.schedule(
'comms-ext-gmail-watch-renewal',
'0 6 * * *', -- daily at 06:00 UTC (well outside any expiry window)
$$
SELECT net.http_post(
url := 'https://<project-ref>.supabase.co/functions/v1/comms-ext-gmail-watch-renewal-cron',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer <service-role-key>'
),
body := '{}'::jsonb
);
$$
);Smoke-test it once after scheduling (no need to wait for 06:00 UTC):
curl -X POST \
-H "Authorization: Bearer <service-role-key>" \
https://<project-ref>.supabase.co/functions/v1/comms-ext-gmail-watch-renewal-cronYou should get back { "ok": true, "worker_status": 200, "worker_response": { "renewed": N, "total_candidates": M } }.
To unschedule (e.g., if you disable the extension):
SELECT cron.unschedule('comms-ext-gmail-watch-renewal');Cross-source semantic search
Optional. Enables semantic search across every connected source (Gmail, Slack, and the rest as their ingest paths land) via a 1536-dim OpenAI embedding pipeline. See Search for the user-facing description.
OPENAI_API_KEY (Supabase secret)
Used by both comms-embed-search-rows (worker) and
comms-embed-query (frontend query path). Any paid OpenAI account
key works. Cost is text-embedding-3-small at $0.02 per 1M
tokens — single-user volume is essentially free.
supabase secrets set OPENAI_API_KEY=sk-...Migration
Run supabase/migrations/20260508_search_rpc.sql in the Supabase
SQL Editor. It creates match_search_index (read path —
SECURITY INVOKER, RLS-scoped per user, granted to authenticated)
plus bulk_set_embeddings and bulk_set_embedding_failures (write
helpers — SECURITY DEFINER, granted to service_role only).
The earlier migrations 20260506_comm_search_index.sql and
20260507_gmail_workspace_rules.sql must be in place first.
Schedule the embedding pipeline
-- pg_cron + pg_net are usually pre-enabled on Supabase projects.
CREATE EXTENSION IF NOT EXISTS pg_cron;
CREATE EXTENSION IF NOT EXISTS pg_net;
-- Replace <project-ref> and <service-role-jwt> with your values.
-- IMPORTANT: <service-role-jwt> must be the LEGACY JWT-format
-- service-role key (Project Settings → API → Legacy API Keys →
-- Reveal), not the new sb_secret_… opaque key. The Edge Functions
-- gateway only accepts JWT-format auth on inbound requests.
SELECT cron.schedule(
'comms-embed-search-rows',
'*/5 * * * *',
$$
SELECT net.http_post(
url := 'https://<project-ref>.supabase.co/functions/v1/comms-embed-search-rows',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer <service-role-jwt>'
),
body := '{}'::jsonb
);
$$
);Smoke test
From the Supabase SQL Editor, fire the worker once and read the
async response from pg_net:
SELECT net.http_post(
url := 'https://<project-ref>.supabase.co/functions/v1/comms-embed-search-rows',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer <service-role-jwt>'
),
body := '{}'::jsonb
) AS request_id;
-- 5-10 seconds later
SELECT status_code, content::jsonb, created
FROM net._http_response
ORDER BY created DESC
LIMIT 3;Expected response shape:
{ "ok": true, "processed": N, "succeeded": N, "failed": 0, "queue_remaining_estimate": 0, ... }.
To drain a large initial backlog (loops sweeps until queue empty or 120 s wall-clock budget):
curl -X POST \
-H "Authorization: Bearer <service-role-jwt>" \
-H "Content-Type: application/json" \
-d '{"mode":"backfill"}' \
https://<project-ref>.supabase.co/functions/v1/comms-embed-search-rowsTo check pipeline health any time:
SELECT embedding_status, count(*) FROM comm_search_index GROUP BY 1;Unschedule
SELECT cron.unschedule('comms-embed-search-rows');Third-party integrations
Email (Resend + inbound provider)
Outbound email is Resend. Inbound is any provider that can POST a parsed-email webhook — SendGrid Inbound Parse, Postmark, Mailgun Routes all work. Point their webhook at:
https://<your-comms-domain>/api/mail/inbound
Include the signing secret header x-comms-signature. The function
rejects anything without a valid signature.
Slack bridge (optional)
If you want to mirror a Slack channel into a Comms channel (e.g., during migration), install the Comms Slack app from Settings → Integrations → Slack, authorize the workspace, and pair channels from the bridge settings.
For a one-shot import of Slack history (not a live bridge), see Import from Slack.
Calendar (optional)
Huddle invites can create calendar events via Google Calendar or Microsoft 365 OAuth per user. Configured under Account → Integrations.
Verify your install
- Sign up as the first user — you become workspace admin.
- Create a channel; send a message.
- Invite a teammate; @-mention them and confirm the notification fires (check Notifications in the sidebar and the email if Resend is configured).
- Start a huddle. Two users should be able to connect.
- If Mail is enabled, send a test email to your configured inbound address and confirm it lands in the Mail inbox channel within a few seconds.