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.sql

This 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:

FunctionPurpose
comms-inbound-emailReceives webhook POSTs from your mail provider, verifies signatures, inserts messages.
comms-notification-dispatcherFans out in-app + email + mobile-push notifications with per-user rules.
comms-huddle-signalingWebRTC signaling for huddles (SDP offer/answer exchange).
comms-retention-sweeperNightly cron that enforces per-channel retention policies.
comms-import-slackStreams 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-oauthTwo-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-syncGmail 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-webhookReceives 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-cronThin 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-rowsEmbedding 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-queryCompanion 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-query

Schedule 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.watch renewal cron, revoked-token auto-detection (invalid_grant → connection marked disconnected), acceptance test plan
  • Star toggle ✅ OAuth scope upgraded to gmail.modify; new modify-thread mode on comms-ext-gmail-sync calls users.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). Existing gmail.readonly connections 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_messages returns 0 even though the UI says backfill is complete).
  • New emails never sync into Comms — the comms-ext-gmail-webhook invocation log shows 401 (or 204 with no follow-on sync activity).
  • comms-ext-gmail-sync invocation 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:

  1. Project Settings → API → Legacy API Keys → service_role → Reveal. Copy the eyJ… value.
  2. Project Settings → Edge Functions → Manage Secrets → Add new secret.
    • Name: INTERNAL_SYNC_FORWARD_JWT
    • Value: the eyJ… value you copied
  3. 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-cron

You 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');

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-rows

To 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

  1. Sign up as the first user — you become workspace admin.
  2. Create a channel; send a message.
  3. Invite a teammate; @-mention them and confirm the notification fires (check Notifications in the sidebar and the email if Resend is configured).
  4. Start a huddle. Two users should be able to connect.
  5. 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.