Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,7 @@ next-env.d.ts

# turbo
.turbo
.cursor
.cursor

# package build artifacts
packages/autogtm-core/dist/
69 changes: 67 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

# autogtm

**autogtm is an open-source AI GTM engine that runs cold outbound on autopilot.**
**autogtm is an open-source AI GTM engine that runs cold outbound and socials on autopilot.**

Describe your target audience in plain English with optional targeted briefs, and autogtm discovers leads daily, enriches them with AI, creates tailored email campaigns, and sends via Instantly. System on, autopilot on, you sleep.
Describe your target audience in plain English with optional targeted briefs, and autogtm discovers leads daily, enriches them with AI, creates tailored email campaigns, sends via Instantly, and can also draft/schedule social content through Postiz.

---

Expand Down Expand Up @@ -38,7 +38,10 @@ Describe your target audience in plain English with optional targeted briefs, an
| 8:30 AM | Generate queued search queries from briefs and company context |
| 9:00 AM | Run searches, discover and enrich leads |
| 10:00 AM ET | **Autopilot sweep** — auto-add top N Ready-to-Add leads + digest email (when enabled) |
| Sun 6:00 PM ET | **Social weekly planner** — maps themes to next week's slots from company schedule |
| Mon 8:00 AM ET | **Social auto-approve fallback** — drafts captions for untouched week plans |
| Hourly | Sync campaign status and analytics from Instantly |
| Every 5 min | **Social publish sweep** — pushes due approved posts to Postiz |
| 2:00 PM ET | Send daily discovery digest email |


Expand All @@ -55,6 +58,8 @@ Describe your target audience in plain English with optional targeted briefs, an
- **Exploration mode:** When no new briefs exist, AI generates creative queries to keep pipeline coverage fresh.
- **Daily digests:** Two summary emails — a per-company Autopilot digest (what was auto-added and to which campaigns) and a global discovery digest (leads found, emails sent, opens, replies).
- **Multi-company:** Manage multiple company profiles from a single dashboard.
- **Socials module (v1):** Company-level schedule presets + theme-based content generation from raw dumps, with two review gates (weekly plan, then captions) before publishing.
- **Postiz publishing:** Auto-uploads generated images and publishes approved Instagram posts via Postiz Public API.

## Stack

Expand All @@ -67,6 +72,7 @@ Describe your target audience in plain English with optional targeted briefs, an
| Background Jobs | [Inngest](https://inngest.com) |
| Lead Discovery | [Exa.ai](https://exa.ai) (Websets API) |
| Email Sending | [Instantly.ai](https://instantly.ai) |
| Social Publishing | [Postiz](https://docs.postiz.com/public-api/introduction) |
| AI | [OpenAI](https://openai.com) (GPT-4.1 / GPT-5-mini) |
| Digest Emails | [Resend](https://resend.com) |

Expand All @@ -82,6 +88,7 @@ Accounts needed:
- [Supabase](https://supabase.com) — database and authentication
- [Exa.ai](https://exa.ai) — lead discovery via Websets API
- [Instantly.ai](https://instantly.ai) — email campaign sending
- [Postiz](https://docs.postiz.com/public-api/introduction) — social account publishing
- [OpenAI](https://platform.openai.com) — AI enrichment and generation
- [Inngest](https://inngest.com) — background job scheduling
- [Resend](https://resend.com) — daily digest emails (optional)
Expand Down Expand Up @@ -124,6 +131,64 @@ This creates all required tables, indexes, RLS policies, and helper functions.

If you already have a Supabase project from an earlier version, apply incremental migrations from `[migrations/](./migrations/)` instead — they're safe to re-run (`IF NOT EXISTS` guarded).

## Socials Module (v1)

The Socials tab adds an Instagram-first content pipeline:

1. Define reusable **themes** (`Audition Wins`, `Breakdown Weekly`, etc.) with:
- caption prompt template
- image prompt template
- brand voice
- priority (how often it should win a slot)
2. Configure a company-wide **schedule preset** (`creator_mwf`, `brand_weekday`, etc.).
3. Paste or upload a raw **data dump** (CSV/text). The LLM auto-classifies rows into themes.
4. Every week, planner maps `themes -> slots -> data items` and creates `planned` posts.
5. You approve the week plan, then caption drafts move to `pending_review`.
6. Once approved, image generation runs close to publish time and post is pushed to Postiz.

### Supabase Storage bucket setup (required for socials images)

Create a bucket named `social-images` in Supabase Storage and keep `SUPABASE_STORAGE_BUCKET_SOCIAL=social-images` in env.

- **Public bucket (recommended for v1):**
- Mark bucket as public so `getPublicUrl()` links work directly in Postiz publish flow.
- **Access policy expectation:**
- Backend writes run with `SUPABASE_SERVICE_ROLE_KEY`.
- If you enforce storage policies manually, allow `service_role` to upload/update/list objects for `bucket_id = 'social-images'`.

The app also attempts to create the bucket on first image generation if it does not exist, but setting it up explicitly in Supabase avoids first-run surprises.

### Reels/video model switch (OpenRouter)

For reel-first workflows, set:

- `SOCIAL_MEDIA_ASSET_MODE=video`
- `OPENROUTER_API_KEY=...`
- `OPENROUTER_VIDEO_MODEL=google/veo-3.1-fast` or `bytedance/seedance-2.0-fast`

Defaults:

- `SOCIAL_MEDIA_ASSET_MODE=image` (OpenAI `gpt-image-1`)
- `OPENROUTER_VIDEO_MODEL=google/veo-3.1-fast`

When in `video` mode, the generation step requests video via OpenRouter, stores the generated media in Supabase Storage, and publish flow sends video payloads to connected Postiz channels.

### One-time Postiz setup

1. In Postiz: connect your Instagram channel.
2. Generate API key in **Settings > Developers > Public API**.
3. Set env vars:
- `POSTIZ_API_KEY`
- `POSTIZ_BASE_URL=https://api.postiz.com/public/v1`
- `POSTIZ_INSTAGRAM_INTEGRATION_ID` (optional override)
4. Optional: manually pin integration ID:

```bash
curl -H "Authorization: $POSTIZ_API_KEY" https://api.postiz.com/public/v1/integrations
```

If not set, autogtm auto-detects the first Instagram integration from `GET /integrations`.



## Deployment
Expand Down
14 changes: 14 additions & 0 deletions apps/autogtm/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
SUPABASE_STORAGE_BUCKET_SOCIAL=social-images

# Exa API (https://exa.ai)
EXA_API_KEY=
Expand All @@ -12,6 +13,19 @@ INSTANTLY_API_KEY=
# OpenAI
OPENAI_API_KEY=

# OpenRouter (video generation)
OPENROUTER_API_KEY=
# Switch socials media mode: image | video
SOCIAL_MEDIA_ASSET_MODE=image
# Video model switch when SOCIAL_MEDIA_ASSET_MODE=video:
# - google/veo-3.1-fast
# - bytedance/seedance-2.0-fast
OPENROUTER_VIDEO_MODEL=google/veo-3.1-fast

# Postiz (https://docs.postiz.com/public-api/introduction)
POSTIZ_API_KEY=
POSTIZ_BASE_URL=https://api.postiz.com/public/v1

# Inngest (https://inngest.com)
INNGEST_SIGNING_KEY=
INNGEST_EVENT_KEY=
Expand Down
20 changes: 20 additions & 0 deletions apps/autogtm/src/app/api/companies/[id]/social/_lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createClient } from '@supabase/supabase-js';

export function getServiceSupabase() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
}

export async function getCompanyIdFromParams(params: Promise<{ id: string }>): Promise<string> {
const { id } = await params;
return id;
}

export function badRequest(message: string, status = 400) {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
38 changes: 38 additions & 0 deletions apps/autogtm/src/app/api/companies/[id]/social/dumps/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSocialDataDump } from '@autogtm/core/db/socialsDbCalls';
import { inngest } from '@/inngest/client';

export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: companyId } = await params;
const body = await request.json() as {
raw_content?: string;
source?: 'csv' | 'paste' | 'url';
};

if (!body.raw_content || !body.raw_content.trim()) {
return NextResponse.json({ error: 'raw_content is required' }, { status: 400 });
}

const dump = await createSocialDataDump(companyId, {
raw_content: body.raw_content,
source: body.source || 'paste',
});

await inngest.send({
name: 'autogtm/social.dump-created',
data: {
companyId,
dumpId: dump.id,
},
});

return NextResponse.json({ dump });
} catch (error) {
console.error('Error creating social dump:', error);
return NextResponse.json({ error: 'Failed to create social dump' }, { status: 500 });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
import { updateSocialDataItem } from '@autogtm/core/db/socialsDbCalls';

export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string; itemId: string }> }
) {
try {
const { id: companyId, itemId } = await params;
const item = await updateSocialDataItem(companyId, itemId, { status: 'archived' });
return NextResponse.json({ item });
} catch (error) {
console.error('Error archiving social item:', error);
return NextResponse.json({ error: 'Failed to archive social item' }, { status: 500 });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { updateSocialDataItem } from '@autogtm/core/db/socialsDbCalls';

export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string; itemId: string }> }
) {
try {
const { id: companyId, itemId } = await params;
const body = await request.json() as {
theme_id?: string | null;
structured?: Record<string, unknown>;
status?: 'pending_classification' | 'classified' | 'reserved' | 'used' | 'archived';
classification_confidence?: number | null;
classification_reason?: string | null;
};

const item = await updateSocialDataItem(companyId, itemId, {
theme_id: body.theme_id,
structured: body.structured,
status: body.status,
classification_confidence: body.classification_confidence,
classification_reason: body.classification_reason,
});
return NextResponse.json({ item });
} catch (error) {
console.error('Error updating social item:', error);
return NextResponse.json({ error: 'Failed to update social item' }, { status: 500 });
}
}
29 changes: 29 additions & 0 deletions apps/autogtm/src/app/api/companies/[id]/social/items/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { listSocialDataItems } from '@autogtm/core/db/socialsDbCalls';

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: companyId } = await params;
const status = request.nextUrl.searchParams.get('status') as
| 'pending_classification'
| 'classified'
| 'reserved'
| 'used'
| 'archived'
| null;
const themeId = request.nextUrl.searchParams.get('theme_id');

const items = await listSocialDataItems(companyId, {
status: status || undefined,
themeId: themeId || undefined,
});

return NextResponse.json({ items });
} catch (error) {
console.error('Error listing social items:', error);
return NextResponse.json({ error: 'Failed to fetch social items' }, { status: 500 });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
import { updateSocialPost } from '@autogtm/core/db/socialsDbCalls';

export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string; postId: string }> }
) {
try {
const { id: companyId, postId } = await params;
const post = await updateSocialPost(companyId, postId, { status: 'approved' });
return NextResponse.json({ post });
} catch (error) {
console.error('Error approving social post:', error);
return NextResponse.json({ error: 'Failed to approve social post' }, { status: 500 });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
import { updateSocialPost } from '@autogtm/core/db/socialsDbCalls';

export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string; postId: string }> }
) {
try {
const { id: companyId, postId } = await params;
const post = await updateSocialPost(companyId, postId, { status: 'cancelled' });
return NextResponse.json({ post });
} catch (error) {
console.error('Error cancelling social post:', error);
return NextResponse.json({ error: 'Failed to cancel social post' }, { status: 500 });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
import { inngest } from '@/inngest/client';

export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string; postId: string }> }
) {
try {
const { id: companyId, postId } = await params;
const body = await request.json().catch(() => ({})) as { mode?: 'image' | 'video' };
const mode = body.mode === 'image' || body.mode === 'video' ? body.mode : undefined;
await inngest.send({
name: 'autogtm/social.image-gen',
data: { companyId, postId, mode },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error triggering social image generation:', error);
return NextResponse.json({ error: 'Failed to trigger image generation' }, { status: 500 });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { inngest } from '@/inngest/client';

export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string; postId: string }> }
) {
try {
const { id: companyId, postId } = await params;
await inngest.send({
name: 'autogtm/social.publish',
data: { companyId, postId },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error triggering social publish:', error);
return NextResponse.json({ error: 'Failed to trigger social publish' }, { status: 500 });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { inngest } from '@/inngest/client';

export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string; postId: string }> }
) {
try {
const { id: companyId, postId } = await params;
await inngest.send({
name: 'autogtm/social.draft-post',
data: { companyId, postId },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error regenerating social post:', error);
return NextResponse.json({ error: 'Failed to regenerate social post' }, { status: 500 });
}
}
Loading