Tay finds prospects, writes them in your voice, and books meetings — running on your own Vercel + Supabase. No SaaS account. No shared data. No per-seat fees.
- An LLM API key — bring your own from Anthropic, OpenAI, or OpenRouter. The wizard auto-detects the provider; you paste the key into the in-app setup, not your env vars.
- A free Vercel account — hosts your Tay instance
- A free Supabase account — stores your prospects, drafts, and audit log
- A Gmail account — either (a) Easy mode (v1.1.2): personal Gmail with 2-Step Verification on, generate an App Password, takes ~2 minutes. Or (b) Power mode: a Google Cloud OAuth client — "Web application" with
${SITE_URL}/api/auth/google/callbackas redirect URI; scopesgmail.send+gmail.readonly. Power is required for Workspace accounts and passkey-only Google accounts. - 10 minutes for first-time setup
- Click the Deploy button above. Vercel forks this repo into your account and builds it. No env vars asked at deploy time — they're collected by the in-app wizard or auto-set by integrations.
- Connect Supabase via Vercel's Storage tab → Browse Marketplace → Supabase. Vercel auto-provisions a Supabase project and writes the database env vars for you. Trigger a redeploy.
- Open your Tay URL and walk the wizard: paste an LLM key (Anthropic / OpenAI / OpenRouter) → connect a mailbox (Easy = Gmail App Password, ~2 min) → calibrate voice → preview rubric → test-send to yourself → add first prospect.
No CLI. No Docker. No npm install on your machine. See DEPLOY.md for a screenshot-by-screenshot walkthrough.
Cold-outbound AI tools that run on someone else's servers see every prospect you target and every draft you write. That's a lot of trust to outsource. Tay keeps the same code, but the data lives in your Supabase, your Gmail, your Vercel. Tay-the-author never sees a byte.
v1.1.4 is the final v1.1 milestone. With it, the install path is feature-complete: Vercel Deploy → wizard (LLM key → mailbox → voice → test-send → first prospect → notification channel) → done. Inbound replies now ping you the way you want to be pinged.
- Reply notifications at
/settings/notifications— when an inbound reply lands, Tay fans out a heads-up via your configured channel. Three options: Email (default) sends through your already-connected mailbox (Gmail OAuth or SMTP App Password) so zero setup is required for non-tech users. Slack webhook (advanced) posts to an incoming-webhook URL you paste — linked guide for the 2-minute Slack setup. None suppresses notifications entirely. You can narrow to specific intents (e.g. onlyinterestedreplies) for higher-signal pings. - Notification dispatcher —
lib/notify/dispatch.tsis the single chokepoint. Reuses the channel-aware send transports (lib/send/gmail.ts+lib/send/smtp.ts) for email; uses nativefetchwith a 5s timeout for Slack. Bypasses the judge/disclosure gates with documented rationale: notifications are operator-bound (going to YOU), not prospect-bound. Best-effort like the audit and trust paths — never throws, never blocks the reply pipeline. Writes areply.notifiedaudit entry on every dispatch (gate F). - Privacy posture — notification payloads never include the reply body. Just the classification intent, sender (sanitized), classifier reasons (already-neutered by gate H), and a link to
/repliesin your Tay instance. The Slack webhook URL is encrypted at rest (same AES-256-GCM as OAuth tokens) and never logged. - Test notification button — sends a synthetic notification through your configured channel so you can verify it works without waiting for a real reply.
- Notification preferences schema — migration
0015_notification_preferences.sqladds a single-row table (lock_col UNIQUE DEFAULT 1, same pattern as the rest of Tay's singletons). - v1.1.3 carry-forwards from the judge:
/draft?prospectId=<uuid>pre-fills the form so the prospect-quickadd → draft handoff is one click, not three retypes (newgetProspect()read inlib/draft/persist.ts).- SSRF allowlist on the URL-fetch path in
lib/voice/calibrate-from-url.ts. Rejects loopback (127.x), RFC1918 private (10/8, 172.16/12, 192.168/16), link-local (169.254/16 — covers cloud metadata at 169.254.169.254), IPv6 loopback (::1) + unique-local (fc00::/7) + link-local (fe80::/10), and literal hosts (localhost,metadata.google.internal,instance-data). Pure string check — no DNS lookup latency; DNS-rebinding caveat documented in the source.
v1.1.3 turned the install experience from "you need 5 of your old sample emails" into "you can describe yourself, paste 1 email, OR write one on the spot, then see Tay actually work" before the first prospect even exists. Largest single milestone in v1.1.
- 4 voice-calibration paths at
/setup/voice— pick "Paste 1+ sample emails" (relaxed from 5 → 1), "Answer 3 quick questions", "Bootstrap from my company URL" (Tay scrapes your public site server-side; 8s timeout, 1 MB cap, content-type check), or "I've never sent a cold email" (Tay prompts you to write one on the spot). Every path produces the sameVoiceRubricand ends at the preview step. - Rubric preview & edit (
/setup/voice/preview) — Tay renders the rubric in plain English ("You write short, punchy sentences, casually. Openers tend to be first-name greeting + observation..."). You tweak any field (formality / sentence length / signature / common+avoid phrase pills / tone notes) before continuing. - Sample draft (
/setup/voice/sample) — Tay drafts an email against a canned fake prospect (Alex Chen, VP Sales, Acme Corp) using your just-confirmed rubric, runs the judge, and renders the result with the disclosure footer visible. The "see Tay actually work" moment. - Test-send to your own inbox (
/setup/voice/test-send) — Tay drafts + sends a real email to your connected mailbox through the fulllib/send/orchestrate.tschokepoint (suppression check, judge decision, audit row, trust event — every gate fires). You confirm it landed before adding a real prospect. - Prospect quick-add (
/setup/prospect-quickadd) — describe a prospect in 1-2 sentences ("I met Sarah at the Stripe event, she runs ops at a fintech in NYC"); a cheap LLM extractsfull_name/company/notes; you edit and confirm before save. The system prompt explicitly forbids inferring demographics (gate B defense-in-depth). LinkedIn URL was dropped from the v1.1 scope (LinkedIn 999-walls every serverless fetch). - Setup-complete tracking — new
app_config.setup_completecolumn (migration 0014); the home-page redirect chain skips the wizard once the user finishes prospect-quickadd.setup.completedandvoice.calibratedaudit actions added to the hash chain (gate F). - Gate H everywhere new — URL-fetched HTML, free-form descriptions, anchor emails, and prospect-quickadd inputs all wrap in
<untrusted_source>with literal close-tag neutering. URL content is fully attacker-controlled — fetched via Node's built-infetch(no third-party HTTP client) and the URL is never echoed in error logs.
v1.1.2.5 closes the reply-pipeline gap left by v1.1.2. SMTP App Password users now get the full reply pipeline (classify → trust → auto-draft) on the same 5-minute cadence as the OAuth path — no extra setup, no Vercel config to touch.
- IMAP poller —
lib/reply/imap-poll.tsusesimapflowto fetch new messages over implicit-TLS port 993. Cursor advances by UID (imap_poll_cursortable; single-rowlock_colUNIQUE pattern). First poll seeds fromuidNext - 1without backfill — same "no historical replay" guarantee as the v0.9 Gmail History API path. - Channel dispatcher —
lib/reply/poll.tsaddspollReplies()which readsmailbox_credentials.kindand delegates to eitherpollGmail()(OAuth) orpollImapMailbox()(SMTP). The cron route (/api/cron/poll-gmail, name preserved forvercel.jsoncron-config stability) now calls the dispatcher. - Dual thread anchor —
lib/reply/handle.tsmatches inbound replies viasent_messages.gmail_thread_idfirst (OAuth path) and falls back togmail_message_idwhen the IMAP poller passes the parsedIn-Reply-Toheader (SMTP path uses our generatedMessage-IDfromlib/send/smtp.tsas the thread anchor). - Channel-tagged audit —
reply.receivedandreply.classifiedaudit entries now carrychannel: "oauth" | "app_password"so the gate F chain records which transport saw each reply. - Interim banner removed —
/queueand/repliesno longer show the v1.1.2 "Reply polling activates in the next update" notice; SMTP users get the same replies surface as OAuth users today. - Gate H preserved — IMAP-fetched reply bodies flow through the existing
handleReply()→classifyReply()path, so the<untrusted_source>wrap is still the load-bearing defense against prompt injection from attacker-controlled inbound mail.
v1.1.2 ships the SMTP App Password path so non-technical users on personal Gmail can connect in ~2 minutes instead of doing the ~20-minute Google Cloud OAuth dance.
- Wizard mailbox step (
/setup/mailbox) — two-column choice: Easy (Gmail App Password) or Power (Google OAuth). Easy is recommended for personal Gmail; Power is required for Workspace and for passkey-only Google accounts (where App Passwords are no longer offered). - SMTP via nodemailer —
lib/send/smtp.tsopens a single-shot STARTTLS connection tosmtp.gmail.com:587, authenticates with the App Password, and sends. Message-ID is generated server-side so v1.1.2.5 can match IMAP replies byIn-Reply-To. The orchestrator (lib/send/orchestrate.ts) is now channel-aware: same suppression / judge / audit / trust gates on both transports. - Unified mailbox credentials — new
mailbox_credentialstable replacesgoogle_oauthas the primary read target. Backwards-compat fallback togoogle_oauthkeeps existing v0.7+ OAuth installs working without forcing a reconnect. - App Password verification —
lib/send/smtp-verify.tsruns an STARTTLS handshake at wizard time so wrong-password / wrong-host / TLS / passkey-only cases surface BEFORE the credentials are persisted. Auth failures route the user to the "try Power mode" suggestion (passkey-only Google accounts can't generate App Passwords).
- Derived per-purpose secrets —
TAY_OAUTH_SECRETis gone from your env. The OAuth-token AES key and the unsubscribe HMAC are derived via HKDF-SHA256(SUPABASE_SERVICE_ROLE_KEY,instance_secrets.salt, per-purposeinfo) on every request. The salt is minted automatically on first cold start and lives in your own Supabase. (LegacyTAY_OAUTH_SECRETis still accepted as a fallback for v0.x installs upgrading in place.)CRON_SECRETis NOT derived — Vercel Cron's auth mechanism readsprocess.env.CRON_SECRETdirectly, and Vercel auto-sets it for any project with avercel.jsoncron config. Non-Vercel deploys must set it manually like any other env var. - BYO LLM provider — Tay now supports Anthropic (
sk-ant-…), OpenAI (sk-…), and OpenRouter (sk-or-…) via auto-detection from the key prefix. The wizard collects your key in-app, encrypts it (AES-256-GCM via the derived OAuth secret), and stores it ininstance_secrets. Drafter / judge / reply / voice all use a provider-neutralchatCompleteadapter. VERCEL_URLauto-detection —NEXT_PUBLIC_SITE_URLis now optional. Tay falls through toVERCEL_PROJECT_PRODUCTION_URLandVERCEL_URL(both auto-set by Vercel) before defaulting tohttp://localhost:3000. The OAuth callback + unsubscribe links pick up the right host without manual configuration.
v1.0 (still in place) ships JOURNEYS eval suite + trust-tier promotion. Roadmap in PLAN.md.
v1.0 lands three things together:
- JOURNEYS eval suite — adversarial-scenario regression corpus that locks in the 7 Tay gates (B/C/D/E/F/H/I). 10 scenarios covering: cold-draft happy path, prompt injection in prospect notes, special-category mention (gate B), disclosure footer regression (gate C), rubric drift (gate D), send to suppressed prospect (gate E), audit hash chain integrity (gate F), two adversarial-reply variants (gate H), and trust-tier promotion (gate I). Run via
npm run test:journeys; on green the suite prints*** JOURNEYS GREEN ***. The suite is the regression contract for v1.x — break a gate, break a scenario. - Trust-tier promotion —
lib/trust/tier.tsreadstrust_eventsand computes a per-capability tier (tier_0/tier_1/tier_2/tier_3). Auto-promotion stops attier_2;tier_3is manual-only. Thresholds default to 25 clean sends →tier_1, 250 clean / ≤2 incidents →tier_2for thesendcapability. 5+ incidents in 30 days demote one tier. View and recompute per capability at/settings/trust. - v0.9 polling robustness fixes — Gmail poll cursor now advances using the
historyIdreturned by the History API response itself (no secondgetProfile()call — eliminates the race window where new mail could arrive between list and profile and be marked already-seen).gmail_poll_cursoris constrained to a single row via a deterministicSINGLE_ROW_ID+lock_colUNIQUE constraint. Reply handler: unmatched threads now persist a<unmatched-thread>sentinel body (privacy + storage); self-sent outbound short-circuits classifier; auto-draft reply hydrates the real prospect record into the drafter's prompt inputs.
npm run test:journeysRuns vitest run journeys — the 10 adversarial scenarios + an aggregated summary banner. Mocks the OpenAI SDK + Supabase server client + audit/trust writers; tests the PIPELINE WIRING, not the LLM itself. Green = the 7 Tay gates' wiring still holds.
npm test runs the full unit-test suite AND the JOURNEYS harness (both pick up *.test.ts).
| Var | What it's for | Required |
|---|---|---|
NEXT_PUBLIC_APP_NAME |
Display name shown in the Tay UI | optional |
NEXT_PUBLIC_SUPABASE_URL |
Auto-set by the Vercel + Supabase Marketplace integration | yes |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Auto-set by the integration | yes |
SUPABASE_SERVICE_ROLE_KEY |
Auto-set by the integration. v1.1.1: used as HKDF IKM for the OAuth/unsubscribe/cron secrets | yes |
POSTGRES_URL, POSTGRES_URL_NON_POOLING |
Auto-set by the integration. Used by the migration runner | yes |
GOOGLE_OAUTH_CLIENT_ID |
OAuth client ID from Google Cloud Console — Power mode only | optional (Easy mode skips this) |
GOOGLE_OAUTH_CLIENT_SECRET |
OAuth client secret paired with the ID above — Power mode only | optional (Easy mode skips this) |
NEXT_PUBLIC_SITE_URL |
Public URL of your Tay deploy. v1.1.1: falls back to VERCEL_PROJECT_PRODUCTION_URL / VERCEL_URL so it's optional on Vercel |
optional |
OPENROUTER_MODEL_CHEAP |
Override the default cheap OpenRouter model | optional |
OPENROUTER_MODEL_QUALITY |
Override the default quality OpenRouter model | optional |
TAY_OAUTH_SECRET |
DEPRECATED. v0.x env var; v1.1.1 derives this. Still honored as fallback while you migrate | optional |
CRON_SECRET |
Auto-set by Vercel when a vercel.json cron is configured. Non-Vercel deploys must set this manually |
auto on Vercel |
For local development, copy .env.example to .env.local. The LLM API key is collected by the in-app wizard, not env vars.
git clone [email protected]:stone2000ca/tay.git
cd tay
npm install
cp .env.example .env.local # Supabase + Google OAuth vars only
npm run devThen http://localhost:3000. The wizard at /setup walks you through naming the instance, pasting your LLM key (sk-ant-… / sk-… / sk-or-…), and calibrating voice.
TBD.