)}
+
+ {/* DM Sent Confirmation Dialog */}
+
+
+
+ Did you send the DM?
+
+ Confirm that you pasted and sent the message to{" "}
+ {currentDm?.creator_name} ({currentDm?.creator_handle}). This
+ will mark the outreach as sent and advance to the next creator.
+
+
+
+ Not yet
+
+ Yes, I sent it
+
+
+
+
)
}
diff --git a/apps/command-center/src/components/ui/alert-dialog.tsx b/apps/command-center/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..25e7b47
--- /dev/null
+++ b/apps/command-center/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,141 @@
+"use client"
+
+import * as React from "react"
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+const AlertDialog = AlertDialogPrimitive.Root
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+))
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogHeader.displayName = "AlertDialogHeader"
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogFooter.displayName = "AlertDialogFooter"
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/apps/command-center/src/components/ui/button.tsx b/apps/command-center/src/components/ui/button.tsx
index 208363e..36496a2 100644
--- a/apps/command-center/src/components/ui/button.tsx
+++ b/apps/command-center/src/components/ui/button.tsx
@@ -1,17 +1,21 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
+
import { cn } from "@/lib/utils"
const buttonVariants = cva(
- "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
- destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
- outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
- secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
diff --git a/apps/command-center/src/components/ui/textarea.tsx b/apps/command-center/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..4d858bb
--- /dev/null
+++ b/apps/command-center/src/components/ui/textarea.tsx
@@ -0,0 +1,22 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Textarea = React.forwardRef<
+ HTMLTextAreaElement,
+ React.ComponentProps<"textarea">
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+Textarea.displayName = "Textarea"
+
+export { Textarea }
diff --git a/apps/command-center/src/lib/gmail.ts b/apps/command-center/src/lib/gmail.ts
index 1b2bb3b..64ed795 100644
--- a/apps/command-center/src/lib/gmail.ts
+++ b/apps/command-center/src/lib/gmail.ts
@@ -1,8 +1,16 @@
/**
- * Gmail utility — MVP approach using mailto: links
- * For full OAuth integration, replace with Gmail API calls.
+ * Gmail utilities — mailto: fallback + Gmail API sending
+ *
+ * When Gmail OAuth is connected, emails are sent server-side via the
+ * Gmail API. Otherwise, falls back to mailto: links (client-side).
*/
+import { createServerClient } from "./supabase-server"
+
+// ---------------------------------------------------------------------------
+// mailto: fallback (client-side only)
+// ---------------------------------------------------------------------------
+
export function generateMailtoUrl({
to,
subject,
@@ -18,40 +26,181 @@ export function generateMailtoUrl({
return `mailto:${encodeURIComponent(to)}?${params.toString()}`
}
+// ---------------------------------------------------------------------------
+// Gmail API sending (server-side only)
+// ---------------------------------------------------------------------------
+
+export interface GmailSendResult {
+ success: boolean
+ messageId?: string
+ error?: string
+}
+
/**
- * Opens a mailto: link to send an email via the user's default mail client.
- * Also calls the approve API to mark the outreach as sent.
+ * Refresh an expired Gmail access token using the refresh token.
*/
-export async function sendViaGmail({
- outreachId,
+async function refreshGmailToken(refreshToken: string): Promise {
+ const response = await fetch("https://oauth2.googleapis.com/token", {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ client_id: process.env.GOOGLE_CLIENT_ID!,
+ client_secret: process.env.GOOGLE_CLIENT_SECRET!,
+ refresh_token: refreshToken,
+ grant_type: "refresh_token",
+ }),
+ })
+
+ if (!response.ok) {
+ throw new Error(`Token refresh failed: ${await response.text()}`)
+ }
+
+ const data = await response.json()
+ const newAccessToken = data.access_token
+ const expiresIn = data.expires_in
+
+ // Update stored token
+ const supabase = createServerClient()
+ await supabase
+ .from("oauth_tokens")
+ .update({
+ access_token: newAccessToken,
+ expires_at: new Date(Date.now() + expiresIn * 1000).toISOString(),
+ updated_at: new Date().toISOString(),
+ })
+ .eq("platform", "gmail")
+
+ return newAccessToken
+}
+
+/**
+ * Get a valid Gmail access token, refreshing if expired.
+ */
+export async function getGmailToken(): Promise<{ accessToken: string } | null> {
+ const supabase = createServerClient()
+ const { data: token } = await supabase
+ .from("oauth_tokens")
+ .select("*")
+ .eq("platform", "gmail")
+ .single()
+
+ if (!token) return null
+
+ // Check if token is expired (with 5-minute buffer)
+ const isExpired = token.expires_at
+ ? new Date(token.expires_at).getTime() < Date.now() + 5 * 60 * 1000
+ : false
+
+ if (isExpired && token.refresh_token) {
+ const newAccessToken = await refreshGmailToken(token.refresh_token)
+ return { accessToken: newAccessToken }
+ }
+
+ return { accessToken: token.access_token }
+}
+
+/**
+ * Build an RFC 2822 MIME message and base64url-encode it for the Gmail API.
+ */
+function buildRawMessage({
to,
subject,
body,
+ from,
}: {
- outreachId: string
to: string
subject: string
body: string
-}): Promise<{ success: boolean; error?: string }> {
- // Open mailto link
- const url = generateMailtoUrl({ to, subject, body })
- window.open(url, "_blank")
-
- // Mark as sent in the database
- try {
- const res = await fetch("/api/outreach/approve", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ outreach_id: outreachId, action: "send" }),
- })
+ from?: string
+}): string {
+ const lines = [
+ from ? `From: ${from}` : "",
+ `To: ${to}`,
+ `Subject: ${subject}`,
+ "MIME-Version: 1.0",
+ 'Content-Type: text/plain; charset="UTF-8"',
+ "",
+ body,
+ ].filter(Boolean)
- if (!res.ok) {
- const data = await res.json()
- return { success: false, error: data.error || "Failed to update status" }
+ const message = lines.join("\r\n")
+
+ // Base64url encode (standard base64 with +/= replaced)
+ const encoded = Buffer.from(message)
+ .toString("base64")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/, "")
+
+ return encoded
+}
+
+/**
+ * Send an email via the Gmail API.
+ * Server-side only — requires valid OAuth token in oauth_tokens table.
+ */
+export async function sendEmailViaAPI({
+ to,
+ subject,
+ body,
+}: {
+ to: string
+ subject: string
+ body: string
+}): Promise {
+ const tokenResult = await getGmailToken()
+ if (!tokenResult) {
+ return { success: false, error: "Gmail not connected. Connect via Settings." }
+ }
+
+ const raw = buildRawMessage({ to, subject, body })
+
+ const response = await fetch(
+ "https://gmail.googleapis.com/gmail/v1/users/me/messages/send",
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${tokenResult.accessToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ raw }),
}
+ )
- return { success: true }
- } catch {
- return { success: false, error: "Network error" }
+ if (!response.ok) {
+ const errorBody = await response.text()
+ console.error("[gmail] Send failed:", response.status, errorBody)
+ return { success: false, error: `Gmail API error: ${response.status}` }
}
+
+ const data = await response.json()
+ return { success: true, messageId: data.id }
+}
+
+/**
+ * Check if a creator has replied to our emails.
+ * Server-side only — uses Gmail readonly scope.
+ */
+export async function checkForReply({
+ fromEmail,
+ afterDate,
+}: {
+ fromEmail: string
+ afterDate: string
+}): Promise {
+ const tokenResult = await getGmailToken()
+ if (!tokenResult) return false
+
+ const afterEpoch = Math.floor(new Date(afterDate).getTime() / 1000)
+ const query = `from:${fromEmail} after:${afterEpoch}`
+
+ const response = await fetch(
+ `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${encodeURIComponent(query)}&maxResults=1`,
+ { headers: { Authorization: `Bearer ${tokenResult.accessToken}` } }
+ )
+
+ if (!response.ok) return false
+
+ const data = await response.json()
+ return (data.messages?.length ?? 0) > 0
}
diff --git a/apps/command-center/src/lib/outreach-lifecycle.ts b/apps/command-center/src/lib/outreach-lifecycle.ts
new file mode 100644
index 0000000..d2dd1d4
--- /dev/null
+++ b/apps/command-center/src/lib/outreach-lifecycle.ts
@@ -0,0 +1,142 @@
+/**
+ * Outreach Lifecycle — centralized status state machine
+ *
+ * All creator stage/dm_status transitions flow through advanceStatus()
+ * so the rules are consistent across: approve API, send API, cron jobs.
+ */
+
+import { createServerClient } from "./supabase-server"
+
+// ---------------------------------------------------------------------------
+// Event types that trigger status transitions
+// ---------------------------------------------------------------------------
+
+export type LifecycleEvent =
+ | "draft_created"
+ | "initial_sent"
+ | "follow_up_1_sent"
+ | "follow_up_2_sent"
+ | "reply_detected"
+ | "sequence_complete"
+
+// ---------------------------------------------------------------------------
+// Transition rules
+// ---------------------------------------------------------------------------
+
+interface StatusUpdate {
+ stage?: string
+ dm_status?: string
+ next_follow_up?: string | null
+ dm_sent_date?: string
+}
+
+function computeUpdate(event: LifecycleEvent, now: string): StatusUpdate {
+ switch (event) {
+ case "draft_created":
+ return { dm_status: "Draft" }
+
+ case "initial_sent":
+ return {
+ stage: "Contacted",
+ dm_status: "Sent",
+ dm_sent_date: now,
+ next_follow_up: addDays(now, 4),
+ }
+
+ case "follow_up_1_sent":
+ return {
+ dm_status: "Follow Up 1",
+ next_follow_up: addDays(now, 8),
+ }
+
+ case "follow_up_2_sent":
+ return {
+ dm_status: "Follow Up 2",
+ next_follow_up: null,
+ }
+
+ case "reply_detected":
+ return {
+ stage: "Replied",
+ dm_status: "Completed",
+ next_follow_up: null,
+ }
+
+ case "sequence_complete":
+ return {
+ dm_status: "Completed",
+ next_follow_up: null,
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/**
+ * Advance a creator's status based on an outreach lifecycle event.
+ * Returns the fields that were updated.
+ */
+export async function advanceStatus(
+ creatorId: string,
+ event: LifecycleEvent
+): Promise {
+ const now = new Date().toISOString()
+ const update = computeUpdate(event, now)
+
+ // For draft_created, only update if currently "Not Started"
+ if (event === "draft_created") {
+ const supabase = createServerClient()
+ await supabase
+ .from("creators")
+ .update({ dm_status: "Draft", updated_at: now })
+ .eq("id", creatorId)
+ .in("dm_status", ["Not Started", "not_started"])
+
+ return update
+ }
+
+ const supabase = createServerClient()
+ const updatePayload: Record = { updated_at: now }
+
+ if (update.stage) updatePayload.stage = update.stage
+ if (update.dm_status) updatePayload.dm_status = update.dm_status
+ if (update.dm_sent_date) updatePayload.dm_sent_date = update.dm_sent_date
+ if ("next_follow_up" in update) updatePayload.next_follow_up = update.next_follow_up
+
+ const { error } = await supabase
+ .from("creators")
+ .update(updatePayload)
+ .eq("id", creatorId)
+
+ if (error) {
+ console.error(`[lifecycle] Failed to advance status for ${creatorId}:`, error.message)
+ }
+
+ return update
+}
+
+/**
+ * Determine the lifecycle event from an outreach_log message_type.
+ */
+export function eventFromMessageType(messageType: string | null): LifecycleEvent {
+ switch (messageType) {
+ case "follow-up-1":
+ return "follow_up_1_sent"
+ case "follow-up-2":
+ return "follow_up_2_sent"
+ default:
+ return "initial_sent"
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function addDays(isoDate: string, days: number): string {
+ const date = new Date(isoDate)
+ date.setDate(date.getDate() + days)
+ return date.toISOString()
+}
diff --git a/apps/command-center/src/lib/types.ts b/apps/command-center/src/lib/types.ts
index b20e7a6..d2a46c6 100644
--- a/apps/command-center/src/lib/types.ts
+++ b/apps/command-center/src/lib/types.ts
@@ -88,6 +88,18 @@ export interface RateLimitState {
updated_at: string
}
+export interface RateLimitStatus {
+ platform: string
+ allowed: boolean
+ waitMs: number
+ dailySent: number
+ dailyCap: number
+ warmupDay: number
+ paused: boolean
+ pausedUntil: string | null
+ failuresRecent: number
+}
+
export interface OAuthToken {
platform: string
access_token: string
diff --git a/packages/db/migrations/002_outreach_automation.sql b/packages/db/migrations/002_outreach_automation.sql
new file mode 100644
index 0000000..4477a03
--- /dev/null
+++ b/packages/db/migrations/002_outreach_automation.sql
@@ -0,0 +1,24 @@
+-- 002_outreach_automation.sql
+-- Adds columns and indexes for follow-up scheduling, Gmail message tracking,
+-- and outreach chain linking.
+
+-- Follow-up scheduling: when this outreach should be sent
+ALTER TABLE outreach_log ADD COLUMN IF NOT EXISTS scheduled_for TIMESTAMPTZ;
+
+-- Link follow-ups to their parent outreach entry
+ALTER TABLE outreach_log ADD COLUMN IF NOT EXISTS parent_outreach_id UUID REFERENCES outreach_log(id);
+
+-- Store Gmail message ID for reply detection threading
+ALTER TABLE outreach_log ADD COLUMN IF NOT EXISTS gmail_message_id TEXT;
+
+-- Index for the follow-up cron: find creators due for follow-up
+CREATE INDEX IF NOT EXISTS idx_creators_next_follow_up
+ ON creators(next_follow_up) WHERE next_follow_up IS NOT NULL;
+
+-- Index for filtering by message type (cold, follow-up-1, follow-up-2)
+CREATE INDEX IF NOT EXISTS idx_outreach_log_message_type
+ ON outreach_log(message_type);
+
+-- Index for finding outreach chains by parent
+CREATE INDEX IF NOT EXISTS idx_outreach_log_parent
+ ON outreach_log(parent_outreach_id) WHERE parent_outreach_id IS NOT NULL;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bd3fcd5..4c992ad 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -13,6 +13,9 @@ importers:
'@radix-ui/react-accordion':
specifier: ^1.2.12
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-alert-dialog':
+ specifier: ^1.1.15
+ version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -638,6 +641,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-alert-dialog@1.1.15':
+ resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies:
@@ -3069,6 +3085,20 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
+ '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ optionalDependencies:
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3(@types/react@19.2.14)
+
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
diff --git a/scout/dm-automator.ts b/scout/dm-automator.ts
new file mode 100644
index 0000000..e42c471
--- /dev/null
+++ b/scout/dm-automator.ts
@@ -0,0 +1,814 @@
+/**
+ * DM Automator — Playwright-based browser automation for Instagram & TikTok DMs
+ *
+ * Sends personalized DMs to creators using a persistent browser session.
+ * Pulls pending drafts from the Command Center API, respects rate limits,
+ * and marks messages as sent after delivery.
+ *
+ * USAGE:
+ * # First time: install Playwright browsers
+ * npx playwright install chromium
+ *
+ * # Install dependencies
+ * npm install
+ *
+ * # Send Instagram DMs (opens browser, log in once — session persists)
+ * npx tsx dm-automator.ts --platform instagram
+ *
+ * # Send TikTok DMs
+ * npx tsx dm-automator.ts --platform tiktok
+ *
+ * # Dry run — opens profiles but does not type or send messages
+ * npx tsx dm-automator.ts --platform instagram --dry-run
+ *
+ * # Custom API URL (defaults to https://anything-command-center.vercel.app)
+ * npx tsx dm-automator.ts --platform instagram --api-url http://localhost:3000
+ *
+ * SESSION PERSISTENCE:
+ * Browser cookies/session are stored in ~/.playwright-dm-session/
+ * Log into Instagram or TikTok once, and the session is reused on subsequent runs.
+ * To reset the session, delete that directory.
+ *
+ * SAFETY:
+ * - Rate limits are checked before each DM via the Command Center API
+ * - Human-like typing delays (30-80ms per character)
+ * - Random wait between DMs (8-15 seconds)
+ * - Pauses on CAPTCHA detection for manual resolution
+ * - Graceful shutdown on Ctrl+C
+ */
+
+import { chromium, type BrowserContext, type Page } from "playwright";
+import * as path from "path";
+import * as os from "os";
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface OutreachDraft {
+ id: string;
+ creator_id: string;
+ creator_name: string | null;
+ creator_handle: string | null;
+ channel: "instagram" | "tiktok" | "email" | "twitter";
+ message_type: string;
+ subject: string | null;
+ content: string;
+ status: string;
+ sent_at: string | null;
+ created_at: string;
+}
+
+interface RateLimitStatus {
+ platform: string;
+ allowed: boolean;
+ waitMs: number;
+ dailySent: number;
+ dailyCap: number;
+ warmupDay: number;
+ paused: boolean;
+ pausedUntil: string | null;
+ failuresRecent: number;
+}
+
+interface CLIArgs {
+ platform: "instagram" | "tiktok";
+ apiUrl: string;
+ dryRun: boolean;
+}
+
+// ---------------------------------------------------------------------------
+// ANSI color helpers
+// ---------------------------------------------------------------------------
+
+const c = {
+ reset: "\x1b[0m",
+ bold: "\x1b[1m",
+ dim: "\x1b[2m",
+ red: "\x1b[31m",
+ green: "\x1b[32m",
+ yellow: "\x1b[33m",
+ blue: "\x1b[34m",
+ magenta: "\x1b[35m",
+ cyan: "\x1b[36m",
+ gray: "\x1b[90m",
+};
+
+function log(msg: string): void {
+ const ts = new Date().toISOString().slice(11, 19);
+ console.log(`${c.gray}[${ts}]${c.reset} ${msg}`);
+}
+
+function logSuccess(msg: string): void {
+ log(`${c.green}[OK]${c.reset} ${msg}`);
+}
+
+function logWarn(msg: string): void {
+ log(`${c.yellow}[WARN]${c.reset} ${msg}`);
+}
+
+function logError(msg: string): void {
+ log(`${c.red}[ERR]${c.reset} ${msg}`);
+}
+
+function logInfo(msg: string): void {
+ log(`${c.blue}[INFO]${c.reset} ${msg}`);
+}
+
+function logDry(msg: string): void {
+ log(`${c.magenta}[DRY]${c.reset} ${msg}`);
+}
+
+// ---------------------------------------------------------------------------
+// CLI argument parsing
+// ---------------------------------------------------------------------------
+
+function parseArgs(): CLIArgs {
+ const args = process.argv.slice(2);
+ let platform: "instagram" | "tiktok" | null = null;
+ let apiUrl = "https://anything-command-center.vercel.app";
+ let dryRun = false;
+
+ for (let i = 0; i < args.length; i++) {
+ switch (args[i]) {
+ case "--platform":
+ const p = args[++i];
+ if (p !== "instagram" && p !== "tiktok") {
+ console.error(`${c.red}Error: --platform must be "instagram" or "tiktok"${c.reset}`);
+ process.exit(1);
+ }
+ platform = p;
+ break;
+ case "--api-url":
+ apiUrl = args[++i];
+ break;
+ case "--dry-run":
+ dryRun = true;
+ break;
+ case "--help":
+ case "-h":
+ console.log(`
+${c.bold}DM Automator${c.reset} — Playwright-based DM sending for Anything Command Center
+
+${c.bold}Usage:${c.reset}
+ npx tsx dm-automator.ts --platform [options]
+
+${c.bold}Options:${c.reset}
+ --platform Required. "instagram" or "tiktok"
+ --api-url API base URL (default: https://anything-command-center.vercel.app)
+ --dry-run Open profiles but don't type or send messages
+ --help, -h Show this help
+`);
+ process.exit(0);
+ default:
+ console.error(`${c.red}Unknown argument: ${args[i]}${c.reset}`);
+ process.exit(1);
+ }
+ }
+
+ if (!platform) {
+ console.error(`${c.red}Error: --platform is required. Use --help for usage.${c.reset}`);
+ process.exit(1);
+ }
+
+ return { platform, apiUrl, dryRun };
+}
+
+// ---------------------------------------------------------------------------
+// API client
+// ---------------------------------------------------------------------------
+
+async function fetchDrafts(apiUrl: string, channel: string): Promise {
+ const url = `${apiUrl}/api/outreach?status=Draft`;
+ const res = await fetch(url);
+
+ if (!res.ok) {
+ throw new Error(`Failed to fetch drafts: ${res.status} ${res.statusText}`);
+ }
+
+ const json = (await res.json()) as { data: OutreachDraft[]; count: number };
+ // Filter to only the requested channel
+ return json.data.filter((d) => d.channel === channel);
+}
+
+async function checkRateLimit(apiUrl: string, platform: string): Promise {
+ const url = `${apiUrl}/api/outreach/check-rate?platform=${platform}`;
+ const res = await fetch(url);
+
+ if (!res.ok) {
+ throw new Error(`Failed to check rate limit: ${res.status} ${res.statusText}`);
+ }
+
+ return (await res.json()) as RateLimitStatus;
+}
+
+async function markAsSent(apiUrl: string, outreachId: string): Promise {
+ const url = `${apiUrl}/api/outreach/approve`;
+ const res = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ outreach_id: outreachId, action: "send" }),
+ });
+
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`Failed to mark as sent: ${res.status} — ${body}`);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Human-like typing
+// ---------------------------------------------------------------------------
+
+async function humanType(page: Page, selector: string, text: string): Promise {
+ await page.click(selector);
+ for (const char of text) {
+ await page.keyboard.type(char, { delay: 30 + Math.random() * 50 });
+ // Occasional micro-pause to mimic human hesitation
+ if (Math.random() < 0.05) {
+ await sleep(200 + Math.random() * 300);
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Sleep / countdown helpers
+// ---------------------------------------------------------------------------
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+async function countdown(ms: number, label: string): Promise {
+ const totalSec = Math.ceil(ms / 1000);
+ for (let remaining = totalSec; remaining > 0; remaining--) {
+ process.stdout.write(
+ `\r${c.yellow} ${label}: ${remaining}s remaining...${c.reset} `
+ );
+ await sleep(1000);
+ }
+ process.stdout.write(`\r${" ".repeat(60)}\r`);
+}
+
+function randomDelay(): number {
+ // 8-15 seconds between DMs
+ return 8000 + Math.random() * 7000;
+}
+
+// ---------------------------------------------------------------------------
+// CAPTCHA / login detection
+// ---------------------------------------------------------------------------
+
+async function checkForCaptcha(page: Page): Promise {
+ const pageContent = await page.content();
+ const captchaIndicators = [
+ "recaptcha",
+ "captcha",
+ "challenge_required",
+ "checkpoint",
+ "verify",
+ "suspicious",
+ "unusual login",
+ ];
+ const lowerContent = pageContent.toLowerCase();
+ return captchaIndicators.some((indicator) => lowerContent.includes(indicator));
+}
+
+async function checkLoginRequired(page: Page, platform: string): Promise {
+ if (platform === "instagram") {
+ // Check for login page indicators
+ const loginForm = await page.$('input[name="username"]');
+ const loginButton = await page.$('button:has-text("Log in")');
+ return !!(loginForm && loginButton);
+ }
+ if (platform === "tiktok") {
+ const loginModal = await page.$('[class*="login"]');
+ const loginText = await page.textContent("body").catch(() => "");
+ return (loginText || "").toLowerCase().includes("log in to tiktok");
+ }
+ return false;
+}
+
+async function waitForManualAction(page: Page, reason: string): Promise {
+ logWarn(`${c.bold}MANUAL ACTION REQUIRED:${c.reset} ${reason}`);
+ logWarn("Complete the action in the browser, then press Enter here to continue...");
+ await new Promise((resolve) => {
+ process.stdin.once("data", () => resolve());
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Instagram DM logic
+// ---------------------------------------------------------------------------
+
+async function sendInstagramDM(
+ page: Page,
+ handle: string,
+ message: string,
+ dryRun: boolean
+): Promise<{ success: boolean; error?: string }> {
+ const cleanHandle = handle.replace(/^@/, "").trim();
+ const profileUrl = `https://www.instagram.com/${cleanHandle}/`;
+
+ logInfo(`Navigating to ${c.cyan}${profileUrl}${c.reset}`);
+ await page.goto(profileUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
+ await sleep(2000 + Math.random() * 1000);
+
+ // Check for login
+ if (await checkLoginRequired(page, "instagram")) {
+ await waitForManualAction(page, "Please log in to Instagram in the browser");
+ await page.goto(profileUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
+ await sleep(2000);
+ }
+
+ // Check for CAPTCHA
+ if (await checkForCaptcha(page)) {
+ await waitForManualAction(page, "CAPTCHA detected — please solve it in the browser");
+ await sleep(2000);
+ }
+
+ // Check if profile exists (404 page)
+ const pageText = await page.textContent("body").catch(() => "");
+ if (
+ (pageText || "").includes("Sorry, this page isn't available") ||
+ (pageText || "").includes("This page isn't available")
+ ) {
+ return { success: false, error: "Profile not found (404)" };
+ }
+
+ if (dryRun) {
+ logDry(`Would send DM to @${cleanHandle}: "${message.slice(0, 50)}..."`);
+ return { success: true };
+ }
+
+ // Click the "Message" button on the profile
+ // Instagram uses various selectors — try multiple approaches
+ const messageButtonSelectors = [
+ // Role-based (most stable)
+ 'div[role="button"]:has-text("Message")',
+ // Text content match
+ 'button:has-text("Message")',
+ // Link-based fallback
+ 'a:has-text("Message")',
+ // data-testid based (Instagram sometimes uses these)
+ '[data-testid="message-button"]',
+ ];
+
+ let messageButtonClicked = false;
+ for (const selector of messageButtonSelectors) {
+ try {
+ const btn = await page.$(selector);
+ if (btn) {
+ const btnText = await btn.textContent();
+ // Make sure it says "Message" and not "Messages" (nav link)
+ if (btnText?.trim() === "Message") {
+ await btn.click();
+ messageButtonClicked = true;
+ break;
+ }
+ }
+ } catch {
+ // Try next selector
+ }
+ }
+
+ if (!messageButtonClicked) {
+ // Broader fallback: find any element with exact "Message" text
+ try {
+ await page.click('text="Message"', { timeout: 5000 });
+ messageButtonClicked = true;
+ } catch {
+ // Last resort
+ }
+ }
+
+ if (!messageButtonClicked) {
+ return {
+ success: false,
+ error: "Could not find Message button — DMs may be disabled or profile layout changed",
+ };
+ }
+
+ // Wait for DM composer to load
+ await sleep(3000 + Math.random() * 1000);
+
+ // Find the message input area
+ const messageInputSelectors = [
+ 'div[role="textbox"][contenteditable="true"]',
+ 'textarea[placeholder*="Message"]',
+ 'div[contenteditable="true"][aria-label*="Message"]',
+ 'textarea[aria-label*="Message"]',
+ 'div[contenteditable="true"]',
+ ];
+
+ let inputFound = false;
+ for (const selector of messageInputSelectors) {
+ try {
+ const el = await page.$(selector);
+ if (el) {
+ await humanType(page, selector, message);
+ inputFound = true;
+ break;
+ }
+ } catch {
+ // Try next selector
+ }
+ }
+
+ if (!inputFound) {
+ return {
+ success: false,
+ error: "Could not find message input area — DM composer layout may have changed",
+ };
+ }
+
+ await sleep(500 + Math.random() * 500);
+
+ // Send the message — try button first, fall back to Enter
+ const sendButtonSelectors = [
+ 'button:has-text("Send")',
+ 'div[role="button"]:has-text("Send")',
+ '[data-testid="send-button"]',
+ ];
+
+ let sent = false;
+ for (const selector of sendButtonSelectors) {
+ try {
+ const btn = await page.$(selector);
+ if (btn) {
+ await btn.click();
+ sent = true;
+ break;
+ }
+ } catch {
+ // Try next
+ }
+ }
+
+ if (!sent) {
+ // Fall back to pressing Enter
+ await page.keyboard.press("Enter");
+ sent = true;
+ }
+
+ await sleep(2000);
+
+ // Verify send — check if the message input is now empty (indicating successful send)
+ logSuccess(`DM sent to @${cleanHandle}`);
+ return { success: true };
+}
+
+// ---------------------------------------------------------------------------
+// TikTok DM logic
+// ---------------------------------------------------------------------------
+
+async function sendTikTokDM(
+ page: Page,
+ handle: string,
+ message: string,
+ dryRun: boolean
+): Promise<{ success: boolean; error?: string }> {
+ const cleanHandle = handle.replace(/^@/, "").trim();
+ const profileUrl = `https://www.tiktok.com/@${cleanHandle}`;
+
+ logInfo(`Navigating to ${c.cyan}${profileUrl}${c.reset}`);
+ await page.goto(profileUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
+ await sleep(2000 + Math.random() * 1000);
+
+ // Check for login
+ if (await checkLoginRequired(page, "tiktok")) {
+ await waitForManualAction(page, "Please log in to TikTok in the browser");
+ await page.goto(profileUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
+ await sleep(2000);
+ }
+
+ // Check for CAPTCHA
+ if (await checkForCaptcha(page)) {
+ await waitForManualAction(page, "CAPTCHA detected — please solve it in the browser");
+ await sleep(2000);
+ }
+
+ // Check if profile exists
+ const pageText = await page.textContent("body").catch(() => "");
+ if (
+ (pageText || "").includes("Couldn't find this account") ||
+ (pageText || "").includes("This account doesn't exist")
+ ) {
+ return { success: false, error: "Profile not found (404)" };
+ }
+
+ if (dryRun) {
+ logDry(`Would send DM to @${cleanHandle}: "${message.slice(0, 50)}..."`);
+ return { success: true };
+ }
+
+ // Click the message/envelope icon on the profile
+ const messageIconSelectors = [
+ // TikTok's message icon on profile pages
+ '[data-e2e="message-icon"]',
+ 'a[href*="/messages"]',
+ 'button[aria-label*="Message"]',
+ 'button[aria-label*="message"]',
+ // SVG envelope icon container
+ '[class*="message"] button',
+ '[class*="Message"] button',
+ // Fallback: look for an envelope/mail icon near the follow button
+ 'svg[class*="message"]',
+ ];
+
+ let messageClicked = false;
+ for (const selector of messageIconSelectors) {
+ try {
+ const el = await page.$(selector);
+ if (el) {
+ await el.click();
+ messageClicked = true;
+ break;
+ }
+ } catch {
+ // Try next
+ }
+ }
+
+ if (!messageClicked) {
+ // Try clicking by visible text
+ try {
+ await page.click('text="Messages"', { timeout: 3000 });
+ messageClicked = true;
+ } catch {
+ // Try envelope icon via aria
+ try {
+ await page.click('[aria-label="Send message"]', { timeout: 3000 });
+ messageClicked = true;
+ } catch {
+ // Give up
+ }
+ }
+ }
+
+ if (!messageClicked) {
+ return {
+ success: false,
+ error:
+ "Could not find message button — DMs may be disabled or TikTok layout changed",
+ };
+ }
+
+ // Wait for DM page/modal to load
+ await sleep(3000 + Math.random() * 1000);
+
+ // Find the message input
+ const inputSelectors = [
+ '[data-e2e="message-input"]',
+ 'div[contenteditable="true"][role="textbox"]',
+ 'div[contenteditable="true"]',
+ 'textarea[placeholder*="Send a message"]',
+ 'div[class*="editor"]',
+ ];
+
+ let inputFound = false;
+ for (const selector of inputSelectors) {
+ try {
+ const el = await page.$(selector);
+ if (el) {
+ await humanType(page, selector, message);
+ inputFound = true;
+ break;
+ }
+ } catch {
+ // Try next
+ }
+ }
+
+ if (!inputFound) {
+ return {
+ success: false,
+ error: "Could not find message input — TikTok DM layout may have changed",
+ };
+ }
+
+ await sleep(500 + Math.random() * 500);
+
+ // Send the message
+ const sendSelectors = [
+ '[data-e2e="send-message"]',
+ 'button:has-text("Send")',
+ 'div[role="button"]:has-text("Send")',
+ '[class*="send"] button',
+ ];
+
+ let sent = false;
+ for (const selector of sendSelectors) {
+ try {
+ const btn = await page.$(selector);
+ if (btn) {
+ await btn.click();
+ sent = true;
+ break;
+ }
+ } catch {
+ // Try next
+ }
+ }
+
+ if (!sent) {
+ await page.keyboard.press("Enter");
+ sent = true;
+ }
+
+ await sleep(2000);
+ logSuccess(`DM sent to @${cleanHandle}`);
+ return { success: true };
+}
+
+// ---------------------------------------------------------------------------
+// Main loop
+// ---------------------------------------------------------------------------
+
+let shuttingDown = false;
+
+async function main(): Promise {
+ const args = parseArgs();
+
+ console.log(`
+${c.bold}${c.cyan}========================================${c.reset}
+${c.bold} DM Automator — Anything Command Center${c.reset}
+${c.bold}${c.cyan}========================================${c.reset}
+
+ Platform: ${c.bold}${args.platform}${c.reset}
+ API URL: ${c.dim}${args.apiUrl}${c.reset}
+ Dry Run: ${args.dryRun ? `${c.magenta}YES${c.reset}` : `${c.dim}no${c.reset}`}
+ Session: ${c.dim}~/.playwright-dm-session/${c.reset}
+`);
+
+ // Graceful shutdown
+ process.on("SIGINT", () => {
+ if (shuttingDown) {
+ logWarn("Force quitting...");
+ process.exit(1);
+ }
+ shuttingDown = true;
+ logWarn("Shutting down gracefully (Ctrl+C again to force)...");
+ });
+
+ // Setup persistent browser context
+ const sessionDir = path.join(os.homedir(), ".playwright-dm-session");
+ logInfo(`Launching browser with persistent session at ${c.dim}${sessionDir}${c.reset}`);
+
+ const context: BrowserContext = await chromium.launchPersistentContext(sessionDir, {
+ headless: false,
+ viewport: { width: 1280, height: 900 },
+ userAgent:
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
+ locale: "en-US",
+ timezoneId: "America/Los_Angeles",
+ // Avoid detection
+ args: [
+ "--disable-blink-features=AutomationControlled",
+ ],
+ });
+
+ const page = context.pages()[0] || (await context.newPage());
+
+ // Remove webdriver flag to reduce detection
+ await page.addInitScript(() => {
+ Object.defineProperty(navigator, "webdriver", { get: () => false });
+ });
+
+ try {
+ // Fetch pending drafts
+ logInfo("Fetching pending DM drafts from API...");
+ const drafts = await fetchDrafts(args.apiUrl, args.platform);
+
+ if (drafts.length === 0) {
+ logInfo(`No pending ${args.platform} DM drafts found. Nothing to do.`);
+ await context.close();
+ return;
+ }
+
+ logInfo(
+ `Found ${c.bold}${drafts.length}${c.reset} pending ${args.platform} DM draft(s)`
+ );
+ console.log();
+
+ let sentCount = 0;
+ let failCount = 0;
+
+ for (let i = 0; i < drafts.length; i++) {
+ if (shuttingDown) {
+ logWarn("Shutdown requested — stopping.");
+ break;
+ }
+
+ const draft = drafts[i];
+ const handle = draft.creator_handle || "unknown";
+ const name = draft.creator_name || handle;
+
+ console.log(
+ `${c.bold}[${i + 1}/${drafts.length}]${c.reset} ${c.cyan}@${handle}${c.reset} (${name})`
+ );
+
+ // Check rate limit
+ logInfo("Checking rate limit...");
+ try {
+ const rateStatus = await checkRateLimit(args.apiUrl, args.platform);
+
+ logInfo(
+ `Rate limit: ${rateStatus.dailySent}/${rateStatus.dailyCap} today (warmup day ${rateStatus.warmupDay})`
+ );
+
+ if (!rateStatus.allowed) {
+ if (rateStatus.waitMs > 0) {
+ logWarn(`Rate limited — waiting ${Math.ceil(rateStatus.waitMs / 1000)}s`);
+ await countdown(rateStatus.waitMs, "Rate limit cooldown");
+ } else {
+ logWarn("Daily cap reached. Stopping for today.");
+ break;
+ }
+
+ // Re-check after waiting
+ const recheck = await checkRateLimit(args.apiUrl, args.platform);
+ if (!recheck.allowed) {
+ logWarn("Still rate limited after wait. Stopping.");
+ break;
+ }
+ }
+ } catch (err) {
+ logWarn(
+ `Could not check rate limit (${(err as Error).message}) — proceeding with caution`
+ );
+ }
+
+ // Send the DM
+ let result: { success: boolean; error?: string };
+
+ try {
+ if (args.platform === "instagram") {
+ result = await sendInstagramDM(page, handle, draft.content, args.dryRun);
+ } else {
+ result = await sendTikTokDM(page, handle, draft.content, args.dryRun);
+ }
+ } catch (err) {
+ result = { success: false, error: (err as Error).message };
+ }
+
+ if (result.success) {
+ sentCount++;
+
+ // Mark as sent in API (skip for dry run)
+ if (!args.dryRun) {
+ try {
+ await markAsSent(args.apiUrl, draft.id);
+ logSuccess(`Marked outreach ${c.dim}${draft.id}${c.reset} as sent in API`);
+ } catch (err) {
+ logError(`Failed to mark as sent in API: ${(err as Error).message}`);
+ }
+ }
+ } else {
+ failCount++;
+ logError(`Failed for @${handle}: ${result.error}`);
+ }
+
+ // Progress summary
+ console.log(
+ ` ${c.dim}Progress: ${c.green}${sentCount} sent${c.reset}${c.dim}, ${c.red}${failCount} failed${c.reset}${c.dim}, ${drafts.length - i - 1} remaining${c.reset}`
+ );
+
+ // Wait between DMs (unless it's the last one)
+ if (i < drafts.length - 1 && !shuttingDown) {
+ const delayMs = randomDelay();
+ await countdown(delayMs, "Waiting before next DM");
+ }
+
+ console.log();
+ }
+
+ // Final summary
+ console.log(`
+${c.bold}${c.cyan}========================================${c.reset}
+${c.bold} Session Complete${c.reset}
+${c.bold}${c.cyan}========================================${c.reset}
+
+ Platform: ${c.bold}${args.platform}${c.reset}
+ Total: ${drafts.length}
+ Sent: ${c.green}${sentCount}${c.reset}
+ Failed: ${failCount > 0 ? `${c.red}${failCount}${c.reset}` : `${c.dim}0${c.reset}`}
+ Dry Run: ${args.dryRun ? "yes" : "no"}
+`);
+ } catch (err) {
+ logError(`Fatal error: ${(err as Error).message}`);
+ console.error(err);
+ } finally {
+ logInfo("Closing browser...");
+ await context.close();
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Entry point
+// ---------------------------------------------------------------------------
+
+main().catch((err) => {
+ logError(`Unhandled error: ${err.message}`);
+ process.exit(1);
+});
diff --git a/scout/package.json b/scout/package.json
new file mode 100644
index 0000000..686f430
--- /dev/null
+++ b/scout/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "dm-automator",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Playwright-based DM automation for Anything Command Center",
+ "scripts": {
+ "dm": "tsx dm-automator.ts",
+ "dm:instagram": "tsx dm-automator.ts --platform instagram",
+ "dm:tiktok": "tsx dm-automator.ts --platform tiktok",
+ "dm:dry": "tsx dm-automator.ts --platform instagram --dry-run"
+ },
+ "dependencies": {
+ "playwright": "^1.52.0"
+ },
+ "devDependencies": {
+ "tsx": "^4.19.0",
+ "typescript": "^5.7.0",
+ "@types/node": "^22.0.0"
+ }
+}
diff --git a/scout/tsconfig.json b/scout/tsconfig.json
new file mode 100644
index 0000000..03c72d3
--- /dev/null
+++ b/scout/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "esModuleInterop": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "outDir": "dist",
+ "rootDir": ".",
+ "resolveJsonModule": true,
+ "declaration": false,
+ "sourceMap": false
+ },
+ "include": ["*.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/vercel.json b/vercel.json
index 0967ef4..7cf7dbd 100644
--- a/vercel.json
+++ b/vercel.json
@@ -1 +1,8 @@
-{}
+{
+ "crons": [
+ {
+ "path": "/api/cron/follow-ups",
+ "schedule": "0 9 * * *"
+ }
+ ]
+}