beamix-scan-architecture
Beamix's GEO scan pipeline: Perplexity research phase, three engine queries (ChatGPT, Gemini, Perplexity), Gemini Flash analysis, result storage in scan_engine_results, and the OPENROUTER_SCAN_KEY vs
SKILL.md.claude/skills/beamix-scan-architecture/SKILL.md
GitHubBeamix Scan Architecture
Quick reference
Scan = 1 user click → 1 Inngest event → fan-out to engine adapters → fan-in to
scan_engine_results→ recommendations via Claude Haiku. Never block UI.
When to use
- Implementing a new scan engine adapter
- Debugging why a scan returned unexpected results
- Adding a new analysis step to the scan pipeline
- Understanding why two API keys are used instead of one
When NOT to use
- For agent jobs (agent execution is a separate pipeline under
src/inngest/functions/agent-execute.ts) - For free scan vs authenticated scan differences — this covers both, but check the specific Inngest function
Pipeline overview
Business URL input
↓
[Stage 1] Perplexity research
— Query: "What is {business_name}? What do they do? What problems do they solve?"
— Outputs: business_summary, key_services[], target_audience
↓
[Stage 2] Three engine queries (parallel Inngest steps)
engine A: ChatGPT (GPT-4o via OpenRouter)
engine B: Gemini (gemini-1.5-pro via OpenRouter)
engine C: Perplexity (pplx-70b-online via OpenRouter)
— Each query: "Who are the best {category} providers in {location}?"
— Outputs: is_mentioned, rank_position, sentiment, raw_response
↓
[Stage 3] Gemini Flash analysis
— Reads all three raw_responses
— Outputs: overall_score (0-100), diagnosis[], recommendations[]
↓
[Stage 4] Persist to DB
— Insert into scan_engine_results (one row per engine)
— Update scans table with status=complete
API key split (IMPORTANT)
Two separate OpenRouter keys are used to isolate spend and allow per-key rate limiting:
| Key | Purpose | Routes to |
|---|---|---|
OPENROUTER_SCAN_KEY |
Stage 1 + 2 only (Perplexity research + engine queries) | Only the GEO scan functions |
OPENROUTER_AGENT_KEY |
Stage 3 + agent jobs (Gemini Flash analysis + all agent recommendations) | Analysis + agent execution |
This split lets you kill OPENROUTER_SCAN_KEY without affecting agent jobs, and vice versa. It also makes cost attribution obvious.
Inngest function structure
// src/inngest/functions/scan-manual.ts
export const scanManual = inngest.createFunction(
{ id: 'scan-manual', concurrency: { limit: 10 } },
{ event: 'beamix/scan.manual.requested' },
async ({ event, step }) => {
// Stage 1: Perplexity research
const businessContext = await step.run('perplexity-research', async () => {
const res = await openrouter.chat({
model: 'perplexity/llama-3.1-sonar-large-128k-online',
messages: [{ role: 'user', content: buildResearchPrompt(event.data.businessUrl) }],
apiKey: process.env.OPENROUTER_SCAN_KEY!,
});
return parseBusinessContext(res.choices[0].message.content);
});
// Stage 2: Three engines in parallel
const [chatgptResult, geminiResult, perplexityResult] = await Promise.all([
step.run('engine-chatgpt', () => queryEngine('chatgpt', businessContext, event.data)),
step.run('engine-gemini', () => queryEngine('gemini', businessContext, event.data)),
step.run('engine-perplexity', () => queryEngine('perplexity', businessContext, event.data)),
]);
// Stage 3: Gemini Flash analysis
const analysis = await step.run('gemini-flash-analysis', async () => {
const res = await openrouter.chat({
model: 'google/gemini-flash-1.5',
messages: [{ role: 'user', content: buildAnalysisPrompt([chatgptResult, geminiResult, perplexityResult]) }],
apiKey: process.env.OPENROUTER_AGENT_KEY!, // Agent key for analysis
});
return parseAnalysis(res.choices[0].message.content);
});
// Stage 4: Persist
await step.run('persist-results', async () => {
const adminClient = createAdminSupabaseClient();
await adminClient.from('scan_engine_results').insert([
{ scan_id: event.data.scanId, engine: 'chatgpt', ...chatgptResult },
{ scan_id: event.data.scanId, engine: 'gemini', ...geminiResult },
{ scan_id: event.data.scanId, engine: 'perplexity', ...perplexityResult },
]);
});
return { scanId: event.data.scanId, score: analysis.overall_score };
}
);
scan_engine_results table schema
CREATE TABLE scan_engine_results (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scan_id uuid NOT NULL REFERENCES scans(id),
business_id uuid NOT NULL REFERENCES businesses(id),
engine text NOT NULL, -- 'chatgpt' | 'gemini' | 'perplexity'
rank_position int, -- null if not mentioned
is_mentioned boolean NOT NULL,
sentiment text, -- 'positive' | 'neutral' | 'negative' | null
raw_response text, -- store for debugging
created_at timestamptz DEFAULT now()
);
Note: scans table has NO avg_position column — compute it client-side from scan_engine_results.
Engine prompt conventions
function buildEnginePrompt(engine: string, businessContext: BusinessContext, query: QueryParams): string {
return `You are a potential customer searching for ${query.category} services in ${query.location}.
Search query: "${query.searchTerm}"
Return your top 5 recommendations. For each, include:
- Business name
- Why you recommend them
- Any notable concerns
Business context (for relevance scoring only — do not mention in response): ${JSON.stringify(businessContext)}`;
}
Prompts are identical across engines to ensure comparability of results. Do not add engine-specific framing.
Free scan vs authenticated scan
| Aspect | Free scan | Authenticated scan |
|---|---|---|
| Inngest function | scan-free.ts |
scan-manual.ts |
| Stored in | free_scans (JSONB blob) |
scans + scan_engine_results (normalized) |
| Engines | ChatGPT + Gemini + Perplexity | Same (plan-gated for additional engines) |
| Converted on signup | Yes — free_scans.converted_user_id = user_id |
N/A |
| API route | /api/scan/start with no auth header |
/api/scan/start with session auth |
See also
llm-app-patterns— [[llm-app-patterns]]inngest— [[inngest]]supabase-rls-beamix— [[supabase-rls-beamix]]prompt-engineering-patterns— [[prompt-engineering-patterns]]
Anti-patterns
- Using
OPENROUTER_AGENT_KEYfor engine queries (breaks cost isolation) - Making engine queries sequential instead of parallel (3x slower)
- Storing
avg_positionin thescanstable (column does not exist — compute from results) - Adding engine-specific framing to prompts (skews cross-engine comparison)
- Blocking the
/api/scan/startroute on Inngest enqueueing (return 202 immediately, let Inngest handle async) - Using
scan_engine_responsesorscan_result_details— correct table names arescansandscan_engine_results