Skip to content

stone2000ca/tay

Repository files navigation

Tay — your own AI BDR agent

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.

Deploy with Vercel

What you'll need

  • 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/callback as redirect URI; scopes gmail.send + gmail.readonly. Power is required for Workspace accounts and passkey-only Google accounts.
  • 10 minutes for first-time setup

Install in 3 steps

  1. 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.
  2. 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.
  3. 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.

Why self-hosted?

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.

Status: v1.1.4 — reply notifications (v1.1 feature-complete)

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. only interested replies) for higher-signal pings.
  • Notification dispatcherlib/notify/dispatch.ts is the single chokepoint. Reuses the channel-aware send transports (lib/send/gmail.ts + lib/send/smtp.ts) for email; uses native fetch with 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 a reply.notified audit 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 /replies in 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.sql adds 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 (new getProspect() read in lib/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.

Earlier — v1.1.3: rubric preview, 4 voice calibration paths, test-send, prospect quick-add

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 same VoiceRubric and 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 full lib/send/orchestrate.ts chokepoint (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 extracts full_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_complete column (migration 0014); the home-page redirect chain skips the wizard once the user finishes prospect-quickadd. setup.completed and voice.calibrated audit 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-in fetch (no third-party HTTP client) and the URL is never echoed in error logs.

Earlier — v1.1.2.5: IMAP reply polling for SMTP mode

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 pollerlib/reply/imap-poll.ts uses imapflow to fetch new messages over implicit-TLS port 993. Cursor advances by UID (imap_poll_cursor table; single-row lock_col UNIQUE pattern). First poll seeds from uidNext - 1 without backfill — same "no historical replay" guarantee as the v0.9 Gmail History API path.
  • Channel dispatcherlib/reply/poll.ts adds pollReplies() which reads mailbox_credentials.kind and delegates to either pollGmail() (OAuth) or pollImapMailbox() (SMTP). The cron route (/api/cron/poll-gmail, name preserved for vercel.json cron-config stability) now calls the dispatcher.
  • Dual thread anchorlib/reply/handle.ts matches inbound replies via sent_messages.gmail_thread_id first (OAuth path) and falls back to gmail_message_id when the IMAP poller passes the parsed In-Reply-To header (SMTP path uses our generated Message-ID from lib/send/smtp.ts as the thread anchor).
  • Channel-tagged auditreply.received and reply.classified audit entries now carry channel: "oauth" | "app_password" so the gate F chain records which transport saw each reply.
  • Interim banner removed/queue and /replies no 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.

Earlier — v1.1.2: SMTP send (Easy mode) for 10-minute non-tech install

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 nodemailerlib/send/smtp.ts opens a single-shot STARTTLS connection to smtp.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 by In-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_credentials table replaces google_oauth as the primary read target. Backwards-compat fallback to google_oauth keeps existing v0.7+ OAuth installs working without forcing a reconnect.
  • App Password verificationlib/send/smtp-verify.ts runs 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).

Earlier — v1.1.1: secrets foundation

  • Derived per-purpose secretsTAY_OAUTH_SECRET is 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-purpose info) on every request. The salt is minted automatically on first cold start and lives in your own Supabase. (Legacy TAY_OAUTH_SECRET is still accepted as a fallback for v0.x installs upgrading in place.) CRON_SECRET is NOT derived — Vercel Cron's auth mechanism reads process.env.CRON_SECRET directly, and Vercel auto-sets it for any project with a vercel.json cron 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 in instance_secrets. Drafter / judge / reply / voice all use a provider-neutral chatComplete adapter.
  • VERCEL_URL auto-detectionNEXT_PUBLIC_SITE_URL is now optional. Tay falls through to VERCEL_PROJECT_PRODUCTION_URL and VERCEL_URL (both auto-set by Vercel) before defaulting to http://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 promotionlib/trust/tier.ts reads trust_events and computes a per-capability tier (tier_0 / tier_1 / tier_2 / tier_3). Auto-promotion stops at tier_2; tier_3 is manual-only. Thresholds default to 25 clean sends → tier_1, 250 clean / ≤2 incidents → tier_2 for the send capability. 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 historyId returned by the History API response itself (no second getProfile() call — eliminates the race window where new mail could arrive between list and profile and be marked already-seen). gmail_poll_cursor is constrained to a single row via a deterministic SINGLE_ROW_ID + lock_col UNIQUE 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.

Run the JOURNEYS suite

npm run test:journeys

Runs 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).

Env vars (v1.1.1)

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.

Local dev

git clone [email protected]:stone2000ca/tay.git
cd tay
npm install
cp .env.example .env.local   # Supabase + Google OAuth vars only
npm run dev

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

License

TBD.

About

Self-hosted AI BDR agent. One-click deploy to your own Vercel + Supabase. Your data stays yours.

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors