Search for a command to run...
Compiled from 27 nodes · est. 50 min read
Updated
OpenSEO is an open-source, self-hostable SEO platform — a pay-as-you-go alternative to Semrush and Ahrefs — covering keyword research, rank tracking, backlinks, site audits, Google Search Console, AI visibility, and Local SEO. It ships as both a web app and an MCP (Model Context Protocol) server, so AI agents like Claude Code, Codex, and Cursor can drive the same SEO workflows that human users see in the UI. The runtime is a Cloudflare Worker with Durable Objects, R2, and a dual database backend — D1 (SQLite) by default, Postgres via Hyperdrive as an opt-in for larger installs — with Docker as the alternative self-host path. Nearly all SEO data comes from DataForSEO, metered in credits; a managed hosted version runs at app.openseo.so alongside the self-hostable code in this repo.
Start with Orientation for the one-page picture of the project, then Version history if you want to know how it got here. The runtime and platform are covered by Agent workflows (which contains Worker entry and routing, the SAM chat agent and Onboarding chat agent Durable Objects, Rank tracking, and the Site audit pipeline), Auth modes and gating, Database and schema, and Costs, billing, and credits. Product surfaces live under SEO features (Rank tracking, Keyword research and saved keywords, DataForSEO client and metering) and MCP server (server entry, MCP transport and OAuth, Research and SERP tools, Search Console tools, Site audit tools, and Agent Skills and Cursor plugin). The Development and ops section groups Local development, Engineering conventions, Self-hosting, and Contributing and CI, with Playwright automation snippets at the top level as a reference for agent-driven browser tasks.
If you want to understand the architecture end-to-end, read Orientation, then Worker entry and routing, then Database and schema and Auth modes and gating. If you came to build or debug an MCP tool, start at MCP server, then MCP transport and OAuth, then the specific tool section (Research and SERP tools, Search Console tools, or Site audit tools). If you're adding a backend feature to the app, read Engineering conventions for the server-function → service → repository layering, then the closest existing feature under SEO features as a template. If you're self-hosting or setting up the repo locally, read Local development first, then Self-hosting for Docker or the Alchemy-based pnpm deploy:selfhost Cloudflare path, and Costs, billing, and credits to wire up DataForSEO.
Updated
Pages in this section:
Updated
OpenSEO's entry point is src/server.ts, a Cloudflare Worker that routes fetch requests to OAuth (hosted mode) or the MCP endpoint (self-hosted), dispatches agent requests to Durable Objects with auth checks, and runs cron tasks for rank checks, audit reconciliation, and KV cleanup. Request handling flows through multiple auth and validation gates: organization customer setup before Durable Objects, per-connection and per-request auth checks before Durable Object dispatch, and middleware-enforced user and project context in server functions. A Durable Object is a Cloudflare Workers primitive that provides a single-threaded, stateful compute instance with its own isolated storage, guaranteeing serialized execution for a given entity.
src/server.ts is the Cloudflare Worker entry point, handling all incoming fetch requests and scheduled cron events for the OpenSEO application.[1] Two cron triggers are configured in wrangler.jsonc: "*/5 * * * *" (every 5 minutes, for rank checks and stale-audit reconciliation) and "17 3 * * *" (daily, for OAuth KV garbage collection).[2]
Hosted-mode requests (auth mode HOSTED) in src/server.ts are processed by the openSeoOAuthProvider (a Cloudflare OAuth provider), with the Autumn billing webhook at AUTUMN_WEBHOOK_PATH handled first before handing off to OAuth.[1] Self-hosted instances using cloudflare_access or local_noauth auth modes serve the MCP endpoint at MCP_ROUTE via handleSelfHostedOpenSeoMcpRequest in src/server.ts.[1]
Requests to /agents/* in src/server.ts are dispatched to the onboarding and SAM chat Durable Objects via routeAgentRequest, with per-connection and per-request auth checks applied before reaching the DO.[1] authorizeChatAgent in src/server.ts dispatches on lobby.className — "SAM_CHAT" routes to authorizeSamChat, "ONBOARDING_CHAT" to authorizeOnboardingChat, and any unrecognized class name returns 403 Forbidden (fail-closed).[1] Before a brand-new organization's first onboarding or SAM chat message, src/server.ts calls getOrCreateOrganizationCustomer (hosted mode only) to ensure the Autumn billing customer and its default credits exist before the Durable Object's credit-balance gate runs — preventing a false "out of credits" rejection.[1] OpenSEO uses Cloudflare Durable Objects to maintain per-chat session state, ensuring each onboarding or SAM chat conversation is isolated and consistent.
The scheduled cron handler in src/server.ts runs stale-audit reconciliation (reconcileStaleAudits) before rank checks so a slow watchdog tick cannot delay or starve the rank check loop; watchdog errors are held and re-thrown after rank checks complete so they do not suppress the rank run.[1] The MCP_OAUTH_PURGE_CRON scheduled job in src/server.ts runs at "17 3 * * *" (daily at 03:17) and purges expired OAuth KV data only in hosted mode; an incomplete sweep logs a warning that the KV keyspace outgrew the batch size.[1]
src/start.ts configures the TanStack Start instance with a CSRF middleware that applies only to serverFn handler types, and wires in globalServerFunctionMiddleware as function-level middleware.[3] src/serverFunctions/middleware.ts exports globalServerFunctionMiddleware, a tuple of [errorHandlingMiddleware, ensureUserMiddleware], as the base middleware stack applied to all server functions.[4] requireProjectContext middleware in src/serverFunctions/middleware.ts throws an AppError('INTERNAL_ERROR', ...) if authenticatedContext.project is absent, then forwards project, projectId, and the full authenticated context to the next handler.[4]
Sources
Updated
SAM's chat agent is a Cloudflare Durable Object backed by the Think framework—one DO per session—that loads project context, enforces credit budgets in hosted mode, gates tool access through MCP adapters, and manages all WebSocket connections and cleanup for account erasure. The agent wires two context blocks (identity prompt and read-only project context) into Think, scopes tools with Postgres clients to prevent connection leaks, and tracks skill activations via PostHog while adapting MCP tools to prevent cross-project targeting and meter credits consistently.
SamChatAgent in src/server/features/sam/SamChatAgent.ts is a Cloudflare Durable Object backed by the Think framework, with one DO instance per chat session; the DO instance name is the session id.[1] SamChatAgent sets workspaceBash = false to disable Think's bash workspace tool; the underlying dependency is also stubbed out of the bundle to keep ~30 MB of eagerly-evaluated source off the isolate's baseline heap.[1] SamChatAgent.fetch in src/server/features/sam/SamChatAgent.ts persists the public origin to Durable Object storage on every request so the value survives hibernation and is available on WebSocket-only wake-ups where fetch() did not run first.[1] SamChatAgent.destroyForErasure in src/server/features/sam/SamChatAgent.ts closes all WebSockets with code 1000, cancels all chats, waits for stability, then deletes all alarm and storage data — used for account erasure.[1]
SamChatAgent.loadSamContext in src/server/features/sam/SamChatAgent.ts calls SamSessionRepository.getSessionById, then ProjectRepository.getProjectById, then queries the user table for the creator's email — resolving the normalized session row into a full SamContext once per DO lifetime.[1] SamChatAgent.configureSession in src/server/features/sam/SamChatAgent.ts attaches two context blocks to every Think session: "soul" (identity/system prompt) and "project_context" (project shared memory).[1] The "project_context" Think context block in SamChatAgent is read-only from SAM's perspective — no set provider is registered — so Think does not expose a set_context tool; writes to project context go through the update_project_context tool, shared with MCP clients and the settings UI.[1] SamChatAgent.renderProjectContext in src/server/features/sam/SamChatAgent.ts calls ProjectContextService.renderProjectContextMarkdown inside its own withPgClient scope because Think's context-block providers have no ambient Postgres client.[1] SamChatAgent.buildSoulPrompt in src/server/features/sam/SamChatAgent.ts activates intake mode — triggering SAM's onboarding flow — when the project context is missing the "business_overview" section.[1]
SamChatAgent.beforeTurn in src/server/features/sam/SamChatAgent.ts gates every turn on credit availability in hosted mode; if the session row is not found, it returns a refusalTurn with a canned message instead of executing the model.[1] In self-hosted mode, SamChatAgent skips the credit check entirely because self-hosters supply their own provider keys and carry no Autumn balance.[1]
SamChatAgent.getModel in src/server/features/sam/SamChatAgent.ts builds the LLM via buildChatAgentModel using OPENROUTER_API_KEY (required) and the optional OPENROUTER_MODEL env var.[1] SamChatAgent.afterToolCall in src/server/features/sam/SamChatAgent.ts captures a "sam:skill_activated" PostHog event for Think-internal activate_skill tool calls, using ctx.waitUntil to ensure the flush completes before the DO shuts down.[1]
adaptMcpTool in src/server/features/sam/samChatTools.ts strips projectId from the schema exposed to the model and injects the session's projectId at call time, preventing the model from targeting another project or hallucinating a wrong ID.[2] adaptMcpTool also wraps each tool handler with instrumentMcpToolHandler, so project scoping, credit metering, and mcp:tool_call telemetry (source "in_app_agent", null clientId) all match the external MCP path.[2] Each SAM tool call in src/server/features/sam/samChatTools.ts wraps its handler in withPgClient(...) so it scopes its own Postgres client per execution rather than holding an ambient connection across the inference loop.[2] toModelOutput in src/server/features/sam/samChatTools.ts flattens an MCP CallToolResult into { summary, data } (or { summary } when no structured content exists), combining text parts and the structured payload for the model.[2]
scrapeTools in src/server/features/sam/samChatTools.ts exposes two credit-free tools: map_links (discovers homepage + sitemap URLs, up to 60) and read_pages (fetches up to 10 pages as plain text); both can target competitor domains, not just the project's own site.[2] The map_links tool in src/server/features/sam/samChatTools.ts calls discoverSiteUrls and returns { blocked: true, urls: [], note } when the site is unreachable, or { blocked: false, urls } on success; when the project has no domain set it returns { error: "This project has no website set — ask the user for their site first." } rather than throwing.[2] The waitingAuditStatusTool in src/server/features/sam/samChatTools.ts performs server-side polling of get_audit_status: it polls every 2 seconds (up to a 50-second budget) and returns as soon as the progress summary line changes, so one model tool call covers roughly one minute of quiet waiting instead of forcing the model to spin-poll.[2]
Sources
Updated
OnboardingChatAgent is a Durable Object that manages a real-time chat loop between the client-side useChat hook and an LLM, persisting conversation history while enforcing at-most-once execution of expensive onboarding tasks via atomic SQL markers. The agent resolves its project and organization context from its instance name, handles streaming tool calls for project updates, and implements retry logic and GDPR erasure for reliable operation across transient failures and data deletion requests. A Durable Object is a Cloudflare Workers primitive that provides a single-instance, globally consistent execution context with built-in SQLite storage — enabling atomic state, coordination, and persistence without external databases.
OnboardingChatAgent.onChatMessage in OnboardingChatAgent.ts calls ProjectRepository.getProjectById(this.name) to resolve the project (and its organizationId) from the Durable Object instance name before processing any message.[1]
The client-side onboarding chat is wired with AI SDK useChat pointed at the onboarding API route
useChat({ api: '/api/onboarding/chat' })
The onboarding chat route uses Vercel AI SDK streamText with an update_project_context tool, returned as a UI message stream
streamText({
model: openrouter(MODEL),
system: seededWithContext,
messages,
tools: { update_project_context }
})
The onboarding seed function uses an atomic SQL admission marker to enforce at-most-once execution before running paid DataForSEO and LLM calls
UPDATE projects
SET onboarding_run_status = 'running'
WHERE id = ? AND onboarding_run_status IS NULL
-- proceed only if one row changed
OnboardingChatAgent persists conversation history automatically in its Durable Object's SQLite storage (this.messages) and caps stored history at 60 messages via maxPersistedMessages = 60.[1] OnboardingChatAgent.persistMessages in OnboardingChatAgent.ts retries up to 3 times with a 50 ms × attempt backoff on transient Durable Object SQLite errors (code 10001 / 'internal error'), re-throwing on non-transient errors or after the final attempt.[1]
OnboardingChatAgent.destroyForErasure in OnboardingChatAgent.ts closes all WebSockets, aborts all in-flight requests, waits up to 5 seconds for stability, then deletes all Durable Object storage — implementing a GDPR account erasure path.[1]
Sources
Updated
The site audit pipeline orchestrates concurrent crawling, HTML analysis, and status tracking: SiteAuditWorkflow wraps D1 reads in replay-safe steps, delegates crawl execution to runAuditPhases, and marks audit completion or failure with telemetry; AuditService exposes a public API (startAudit, getStatus, getCrawlProgress, getResults, getHistory, remove) that handles redirect resolution, concurrency limits, workflow termination races, and self-heals stale running audits via reconcileRunningAudit. HTML analysis in page-analyzer.ts uses streaming tokenization instead of DOM parsing to avoid 5–10× memory overhead; analyzeHtml extracts title, headings, links (capped at 1,000), images (capped at 1,000), metadata, OG tags, and structured data presence, with anchor text capped at 200 characters and non-content tags (<script>, <style>, <noscript>, <svg>) excluded from word count.
The site audit crawl architecture ADR (specs/0009-site-audit-crawl-architecture.md) has status Accepted.[1] AuditScratchpad stores the crawl frontier, link edges, and a page mirror in the DO's SQLite; it is destroyed at finalize and self-cleans via an alarm if the audit dies, per wrangler.jsonc comments.[2] AuditService.ts exports AuditService, a const object exposing seven methods: resolveAuditLimitTier, startAudit, getStatus, getCrawlProgress, getResults, getHistory, and remove.[3]
SiteAuditWorkflow in src/server/workflows/SiteAuditWorkflow.ts wraps the initial DB read in a pgStep step ("validate-context") so that D1 reads are retried and replay-cached; a bare read outside a step would re-execute on every replay, and a transient failure would kill the workflow instance before the catch handler runs.[4] SiteAuditWorkflow in src/server/workflows/SiteAuditWorkflow.ts validates the workflow context by checking both that the audit record exists for the workflow instance ID and that its projectId matches the parameter — mismatches throw immediately, preventing phantom workflow runs.[4] SiteAuditWorkflow in src/server/workflows/SiteAuditWorkflow.ts delegates the main crawl execution to runAuditPhases, passing auditId, workflowInstanceId, billingCustomer, projectId, startUrl, and config.[4] On audit failure, SiteAuditWorkflow in src/server/workflows/SiteAuditWorkflow.ts reads currentPhase before calling AuditRepository.failAudit (which stamps currentPhase = "failed"), preserving the phase in which the failure occurred as failedPhase.[4] After marking an audit as failed, SiteAuditWorkflow fires a site_audit:complete PostHog event with status: "failed", error_code, failed_phase, pages_crawled, pages_total, and run_lighthouse properties.[4] The error handler in src/server/workflows/SiteAuditWorkflow.ts explicitly skips PostHog capture for "Durable Object reset because its code was updated" errors, treating deploy-time resets as expected churn rather than actionable errors.[4]
startAudit in AuditService.ts resolves redirect chains on the startUrl before storing it and launching the workflow, so that a domain that 301s to another origin does not dead-end after one page at the same-origin crawl boundary.[3] startAudit in AuditService.ts inserts the audit row first and enforces concurrency and capacity limits after the insert, deliberately avoiding a check-then-act race; if the post-insert check fails, the workflow is terminated and the row is deleted via a rollback path.[3] A 'running' audit row with a workflowInstanceId but no live workflow can occur legitimately — if startAudit's rollback delete fails after the workflow was never created, the row persists as stale. remove in AuditService.ts accounts for this by catching a null instance and skipping terminate().[3] getStatus in AuditService.ts self-heals audits stuck in 'running' status by calling reconcileRunningAudit when the workflow died without reaching the mark-failed step (terminated, errored, or expired from retention) — without this, stale running audits would hold capacity forever.[3] getCrawlProgress in AuditService.ts delegates to AuditProgressKV.getCrawledUrls(auditId) to return the list of crawled URLs for an in-progress audit.[3] getHistory in AuditService.ts derives a ranLighthouse boolean for each audit entry by checking whether parsedConfig.lighthouseStrategy !== 'none', avoiding a separate database column for this flag.[3] remove in AuditService.ts handles the race where terminate() throws because the workflow reached a terminal state between the user clicking stop and the termination call: it re-checks live workflow status and only raises AppError('CONFLICT') if the workflow is genuinely still in a running-like state (queued, running, paused, waiting, waitingForPause).[3] remove in AuditService.ts calls getAuditScratchpad(auditId).destroy() as a best-effort cleanup after deleting the audit row; a missed destroy self-cleans via the Durable Object's 7-day alarm.[3]
src/server/lib/audit/page-analyzer.ts uses htmlparser2's streaming tokenizer instead of a DOM parser (previously cheerio) because building a full DOM consumed 5–10× the HTML size per page, which was the dominant OOM cause when 25 pages were parsed concurrently on a 128 MB isolate.[5] analyzeHtml in page-analyzer.ts accepts statusCode, responseTimeMs, and an optional redirectUrl (defaults to null), and extracts: title, meta description, headings (H1–H6 with order), images, internal/external links, canonical URL, OG tags (og:title, og:description, og:image), structured data presence, robots meta, word count, hreflang tags, and redirect URL — returning all fields in the PageAnalysis result.[5] analyzeHtml uses a ??= first-write-wins pattern for metaDescription, canonical, robotsMeta, and OG tags, so only the first occurrence of each in the document is recorded.[5] analyzeHtml caps extracted links at MAX_EXTRACTED_LINKS (1,000) and images at MAX_EXTRACTED_IMAGES (1,000) per page to prevent uncapped collections from causing OOM in the 25-page persist batches of the audit crawl.[5] Anchor text in extracted links is capped at MAX_ANCHOR_CHARS (200 characters) via slice(0, MAX_ANCHOR_CHARS) in page-analyzer.ts.[5] Links with javascript:, mailto:, tel:, or # protocols are skipped during extraction in page-analyzer.ts via the SKIPPED_LINK_PROTOCOLS regex.[5] Text inside <script>, <style>, <noscript>, and <svg> subtrees is excluded from visible-content word-count accumulation in page-analyzer.ts via NON_CONTENT_TAGS suppression.[5] analyzeHtml preferentially collects visible text from inside <body>; for HTML fragments that never open a <body>, it falls back to all non-<head> text.[5] Word count in analyzeHtml is computed by splitting the normalized visible-body text on whitespace (/\s+/); an empty body returns 0.[5] analyzeHtml handles implicit nested <a> tags by closing any open anchor before opening a new one, mirroring browser behavior since HTML forbids nested anchors and the streaming tokenizer has no tree correction.[5] analyzeHtml ignores <title> elements inside <svg> (tracked by suppressDepth) and only records the first document-level <title> text via a titleDone flag.[5] analyzeHtml detects structured data by checking for <script type="application/ld+json"> and returns a boolean hasStructuredData field rather than parsing the JSON content.[5] analyzeHtml treats an <img> tag without an alt attribute differently from one with alt="": the alt field is null when the attribute is absent, and an empty string when it is explicitly set.[5]
Sources
Updated
Pages in this section:
Updated
RankCheckWorkflow orchestrates rank-check runs across guard checks, keyword preparation, SERP fetching, and finalization, with guards against archived configs and stale-cleanup races via status idempotence and eager nextCheckAt advancement. Rank tracking enforces per-project config limits, preserves history through reactivation rather than duplication, scopes keyword metrics to location for local configs, and reports staleness without mutating on read paths.
RankCheckWorkflow is a Cloudflare Workflow (WorkflowEntrypoint) that orchestrates the full rank-check lifecycle: guard checks, keyword preparation, SERP fetching, and run finalization.[1] RankCheckWorkflow.run scopes a per-request Postgres client for the workflow invocation by calling withPgClient, which is a no-op in D1 mode.[1] All workflow steps in RankCheckWorkflow that must not be retried use SINGLE_ATTEMPT_STEP_CONFIG (retries: { limit: 0 }, timeout '2 minutes'), defined as a module-level constant.[1] Runs carry a trigger field typed as 'manual' | 'scheduled' in the RankCheckParams interface.[1]
At the very start of runScoped, RankCheckWorkflow calls RankTrackingRepository.getConfigById and immediately calls failRunIfActive then returns if isActive is false, guarding against archived configs.[1] prepareRankCheckKeywords throws a NonRetryableError (preventing Cloudflare Workflow retries) if the run is already 'failed' or 'completed', guarding against stale-cleanup resurrection of a superseded run.[1] prepareRankCheckKeywords filters the full config keyword list down to only the requested keywordIds when that parameter is provided, enabling subset and manual re-check runs.[1]
finalizeRankCheckRun counts completed snapshots directly from the DB via RankTrackingRepository.getSnapshotsForRun, rather than trusting the in-memory count, making it the authoritative keyword count for run finalization.[1] finalizeRankCheckRun does NOT update nextCheckAt on the config — the cron handler advances it eagerly before starting the workflow to prevent retry storms.[1] finalizeRankCheckRun skips finalization silently (with a warning log) if the run is already 'failed' or 'completed' when it runs, preventing it from overwriting a status set by stale-cleanup while a replacement run may already be underway.[1] Completion logging in finalizeRankCheckRun includes queue stats (queue_tasks, queue_collected, fallback_tasks, fallback_checked) when available, and caps error text at 200 characters with whitespace collapsed to prevent multi-line log entries from embedding vendor or user content.[1]
markRankCheckRunFailed sets lastSkipReason: 'insufficient_credits' on the config when the failure is an INSUFFICIENT_CREDITS error, enabling the UI to show why a scheduled check was skipped.[1] Both run completion and run failure emit a rank_tracking:check_complete PostHog event via captureServerEvent, distinguished by a status property ('completed' or 'failed').[1]
RankTrackingService.ts enforces a cap of MAX_CONFIGS_PER_PROJECT active rank-tracking configs per project, applied to both new configs and reactivations of archived ones, throwing a VALIDATION_ERROR AppError when exceeded.[2] Archiving a rank-tracking domain (setting isActive to false) does not delete the config row; re-adding the same domain reactivates the existing row — preserving keyword and ranking history — rather than creating a duplicate, as long as the project is below MAX_CONFIGS_PER_PROJECT.[2] When reactivating an archived config, RankTrackingService.createConfig clears lastSkipReason to prevent surfacing outdated skip warnings.[2] When scheduleInterval is "manual", nextCheckAt is set to null (no scheduled run); any other interval computes a future timestamp via computeNextCheckAt.[2]
getLatestRun reads but does not mutate stale workflow state — it calls reconcileActiveRankCheckRun only to report staleness, because mutating on this read path caused a race where the original workflow kept running while a replacement was started.[2] RankTrackingService.getTracker aggregates a config and its latest results into a single object by calling getValidatedConfig and getLatestResults together.[2]
refreshKeywordMetrics scopes keyword volume and CPC data to the tracked city (locationName) for local configs, because national numbers can overstate local demand by orders of magnitude.[2] refreshKeywordMetrics uses resolveKeywordDataLanguage to translate SERP language codes to keyword-data API language codes, because keyword-data APIs only serve a country's own languages even when the SERP tracker pairs any language with any country.[2]
Local rank tracking location search flows through searchSerpLocations server function, reading per-country lists from KV with a 30-day TTL and returning top 10 substring-matched results
// combobox (350 ms debounce) → server fn → KV cache → substring filter
searchSerpLocations(iso, query) // returns top 10 matching canonical location names
Selecting Local mode fires prewarmSerpLocations as a query with staleTime: Infinity to preload country location data before the first keystroke
useQuery({
queryFn: () => prewarmSerpLocations(country),
staleTime: Infinity,
})
Sources
Updated
OpenSEO's keyword research system uses a thin service facade (KeywordResearchService.ts) that orchestrates research, SERP analysis, and saved-keyword management, with all server functions protected by project-context middleware and normalized with project market settings before execution. Keyword data sourcing is abstracted by location—resolved at runtime via getKeywordDataProvider(locationCode)—while e2e tests can override live calls with fixtures when VITE_E2E_KEYWORD_FIXTURES=1.
KeywordResearchService.ts at src/server/features/keywords/services/KeywordResearchService.ts is a thin re-export facade: it assembles the KeywordResearchService object by collecting individual functions (research, getSerpAnalysis, saveKeywords, getSavedKeywords, exportSavedKeywords, updateSavedKeywordTags, updateSavedKeywordTag, deleteSavedKeywordTag, removeSavedKeywords, refreshSavedKeywordMetrics) from src/server/features/keywords/services/research.[1]
All keyword server functions in src/serverFunctions/keywords.ts use the requireProjectContext middleware, which gates access to a project and supplies context.projectId and context.project before any handler runs.[2] The researchKeywords server function in src/serverFunctions/keywords.ts merges resolveMarket(data, context.project) into the input before calling KeywordResearchService.research, normalising locale and market fields from the project settings when the caller omits them.[2] When VITE_E2E_KEYWORD_FIXTURES=1, the researchKeywords server function short-circuits to return fixture data from e2e/fixtures/keyword-research-fixtures instead of calling the live KeywordResearchService.[2]
Keyword data provider is resolved by getKeywordDataProvider(locationCode), defaulting to Labs with Google-Ads-only fallback for unsupported countries
getKeywordDataProvider(locationCode)
Sources
Updated
DataForSEO's SDK is lazily loaded on first API call to avoid blocking isolate startup, and all calls are metered via credit checks before execution and spend tracking after completion, with callers able to override the billing feature per-call. The metering system tracks spend under a "dataforseo" provider, handles validation errors without charging when DataForSEO incurs no cost, bypasses all billing in self-hosted mode, and batches related task submissions (like rank checks) under a single charge. A credit feature is a billing category label (e.g. "rank_tracking") that determines which usage bucket a DataForSEO API call is charged against; incorrect feature selection misattributes costs to the wrong product area.
In src/server/lib/dataforseo/client.ts, the DataForSEO sections module (and the ~3 MB dataforseo-client SDK it imports) is lazily loaded via a single loadDataforseoSections() call, deferred until the first API call, to keep it out of the eager isolate startup graph.[1]
meterDataforseoCall in src/server/lib/dataforseo/client.ts calls assertUsageCreditsAvailable before executing the DataForSEO API call, and then calls trackUsageCreditSpend (via trackDataforseoCost) after a successful call or a billed error.[1] The meter helper in src/server/lib/dataforseo/client.ts accepts a defaultFeature credit feature, but callers can override it per-call by passing creditFeature in the input object; the extra field is ignored by the underlying section fetchers.[1] trackDataforseoCost in src/server/lib/dataforseo/client.ts falls back to mapDataforseoPathToCreditFeature(billing.path) when no explicit creditFeature is provided, deriving the billing feature from the DataForSEO API path.[1] On every billing event, trackDataforseoCost in src/server/lib/dataforseo/client.ts records the provider as "dataforseo" and sets fromCache: false unconditionally.[1] When a DataforseoChargedTaskError is thrown in src/server/lib/dataforseo/client.ts and the error is an invalid-field type with zero cost, it is re-thrown as a non-reportable VALIDATION_ERROR AppError without charging the customer; if DataForSEO still billed (costUsd > 0), the spend is tracked before re-throwing.[1] meterDataforseoCall in src/server/lib/dataforseo/client.ts bypasses all billing checks and credit gating in non-hosted (self-hosted) mode, executing the API call and returning the result directly.[1]
In src/server/lib/dataforseo/client.ts, task_post endpoints (reviewsTaskPost, updatesTaskPost, rankCheckTaskPost) are metered at post time; collection endpoints run unmetered because DataForSEO only bills at task submission.[1] The serp.rankCheckTaskPost entry in src/server/lib/dataforseo/client.ts posts up to 100 queued rank-check tasks in one call, and one metered charge covers the entire batch.[1]
In src/server/lib/dataforseo/client.ts, labs.keywordOverview defaults to "rank_tracking" as its credit feature, but callers (e.g. the keyword-metrics MCP tool) can override it by passing creditFeature in the input.[1]
Sources
Updated
Pages in this section:
Updated
OpenSEO self-hosting supports both Docker (local and self-hosted) and Cloudflare deployments; Docker uses local environment files and ephemeral storage or volumes, while Cloudflare self-hosting deploys via Alchemy with stage-specific provisioning and never reads local resource IDs. Self-hosted instances run with authentication disabled by default and operate without tier gating; they collect anonymized telemetry (heartbeats with feature counts, no user data or URLs) that can be disabled via environment flags.
All Cloudflare deployments — previews, prod, and self-host — go through Alchemy (alchemy.run.ts), which provisions real resources per stage and never reads the wrangler.jsonc resource IDs; wrangler.jsonc serves local dev and Docker self-host only.[1] The Cloudflare self-host deployment uses pnpm deploy:selfhost, which runs a preflight check, builds in selfhost mode, type-checks, then deploys with Alchemy using .env.selfhost and the selfhost stage.[2] DATAFORSEO_API_KEY is placed in .env for Docker self-hosting, in .env.selfhost for Cloudflare self-hosting, and in .env.local for local development.[3] Alchemy is a code-first infrastructure-provisioning tool for Cloudflare that creates and wires real resources (KV namespaces, Workers, etc.) per deployment stage from alchemy.run.ts, replacing the role wrangler.jsonc plays in local development.
Docker self-hosting runs with AUTH_MODE=local_noauth, disabling all auth checks and using a local admin user admin@localhost; the docs explicitly warn to only expose it behind an auth-protected reverse proxy, tunnel, or private network.[4] For Docker self-hosting, DATAFORSEO_API_KEY must be the base64-encoded value of email:password — the DataForSEO email and API password concatenated with a colon.[4] To build and run a local Docker image from source changes, use docker build -f Dockerfile.selfhost -t open-seo:local . then OPEN_SEO_IMAGE=open-seo:local docker compose up -d.[4] The self-hosted Docker instance health can be checked at /api/health, which reports configuration and database status; docker compose ps reports container health and docker compose logs shows startup checks.[4]
The KV namespace IDs in wrangler.jsonc (KV and OAUTH_KV) must NOT be changed: miniflare (and the Docker self-host persistent volume) derives its on-disk storage filenames from an HMAC of the id, so changing them orphans existing local and self-hosted databases.[1] Miniflare is the local Cloudflare Workers runtime that emulates Workers APIs — including KV storage — on the local machine; it is used during development and Docker self-hosting.
OpenSEO Docker self-hosting collects anonymized telemetry: heartbeats with aggregate counts (installs, users, projects, feature usage) tied to a random install ID, sent every 5 minutes during the first two hours after install, then at most once daily. No URLs, keywords, prompts, emails, or IP-derived location are collected. To disable, set OPENSEO_TELEMETRY_DISABLED=1 or DO_NOT_TRACK=1.[4] src/server.ts fires maybeSendSelfHostHeartbeat() on every request using ctx.waitUntil, so telemetry does not block the response.[5]
resolveAuditLimitTier in AuditService.ts always returns 'self_hosted' without consulting billing when the server is not in hosted auth mode, meaning self-hosted deployments are never gated by plan tier.[6] For self-hosted OpenSEO deployments, GSC tools check for GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and BETTER_AUTH_SECRET before attempting a token lookup; missing config returns a setup nudge with a link to the setup docs rather than a token error.[7] The missingSelfHostedGoogleClientResponse helper checks both isHostedServerAuthMode() and hasSelfHostedGoogleOAuthConfig() in parallel; only when neither condition is true does it return the setup-nudge response.[7]
Sources
Updated
Local development in OpenSEO requires Node.js 20+, pnpm 10.30.1, and configuration of .env.local with API credentials; pnpm install --frozen-lockfile and pnpm run db:migrate:local set up the database, then pnpm dev:agents runs the dev server at http://open-seo.localhost:1355. Vite config manages the dev server's port (default 3001, overridable via PORT), exposes select environment variables to the client bundle, and permits reverse-proxy and tunnel requests via ALLOWED_HOST and BETTER_AUTH_URL. Portless is a local reverse-proxy tool that maps hostnames such as open-seo.localhost:1355 to the Vite dev server port, allowing the app to be accessed without specifying the port in the browser URL.
Local development requires Node.js 20+ and Corepack (bundled through Node.js 24; install it separately on Node.js 25+).[1] package.json declares pnpm@10.30.1 as the required package manager; run corepack enable to activate the exact declared version.[2]
The canonical local setup sequence is: corepack enable, then pnpm install --frozen-lockfile, then pnpm run db:migrate:local once per fresh local DB.[1] .env.local is configured by copying .env.example, setting DATAFORSEO_API_KEY as a base64-encoded login:password value via printf '%s' 'YOUR_LOGIN:YOUR_PASSWORD' | base64, and setting AUTH_MODE=local_noauth for normal local development.[1] Local Postgres development uses a throwaway Docker Postgres instance at postgres://openseo:openseo@localhost:5433/openseo and requires DATABASE_PROVIDER=postgres in .env.local; full details are in docs/LOCAL_POSTGRES.md.[3]
pnpm dev:agents serves the app via portless at http://open-seo.localhost:1355; in a git worktree the URL is prefixed with the branch name, e.g. http://feature-name.open-seo.localhost:1355.[1] The dev:agents script runs the dev server via portless and tees output to .logs/dev-server.log, making logs accessible to coding agents for debugging.[2] In vite.config.ts, the dev server and preview server default to port 3001, overridable via the PORT environment variable (process or .env).[4]
vite.config.ts exposes non-VITE_-prefixed env vars to the client-side bundle via envPrefix: AUTH_MODE, BYPASS_EMAIL_VERIFICATION, POSTHOG_PUBLIC_KEY, POSTHOG_HOST, and TURNSTILE_SITE_KEY.[4] vite.config.ts allows requests from hostnames derived from ALLOWED_HOST and the hostname of BETTER_AUTH_URL (if set) on both the dev and preview servers, supporting reverse-proxy and tunnel setups.[4]
Sources
Updated
OpenSEO backend code follows a three-layer architecture (TanStack server function → service → repository) with Zod validation at trust boundaries, idiomatic TypeScript throughout, and strict rules against mocking ORM chains and re-declaring production classes in tests. Test modules must import real classes statically, mock return values only in beforeEach, and use real SQL or service-level testing to catch refactors that break behavior; developers must log friction—retry loops, confusing setup, flaky commands—to .agents/PAPERCUTS.md immediately. A trust boundary is any point where data enters OpenSEO from an untrusted source (user input, external APIs, etc.); Zod validation at these boundaries prevents malformed or malicious data from propagating into service and repository layers.
For new application-backed backend functionality, the default layering convention is: TanStack server function → service → repository.[1] Idiomatic TypeScript is required throughout, and Zod must be used to validate untrusted data and narrow runtime values at trust boundaries.[1]
Tests must never re-declare a production class — the real class must be imported; if the module is too heavy to import, the class should be moved to a leaf module first (see ga4Errors.ts, gscErrors.ts).[1] ORM builder-chain mocking is banned in tests; repositories must be tested through services or real SQL evaluation, because chain mocks break on refactors that change no behavior.[1] Modules under test must be imported statically. vi.mock is hoisted, so per-test await import() and vi.resetModules() are banned unless module-level state must reset — and the reason must be commented.[1] beforeEach sets only default mock return values; Vitest's clearMocks already resets call state, so mockReset/mockClear ceremonies are banned.[1]
Any small, non-blocking repository friction encountered during development — retried tool calls, confusing setup steps, flaky commands, stale caches, or misleading errors — must be logged immediately to .agents/PAPERCUTS.md using the papercuts skill, without interrupting the current task.[1]
Sources
Updated
OpenSEO maintains a tightly controlled contribution model: external code is not merged; instead, maintainers act on well-filed issues (using the /simple-issue-description skill for consistency), while changes to CI configuration, control-plane files, and agent skills trigger explicit review gates. The CI pipeline (ci:check) enforces code quality across formatting, linting, type-safety, and plugin sync before merge; deployments (deploy script) atomically run migrations then build, ensuring database state stays ahead of application code.
open-seo does not accept external pull requests for merging; the preferred contribution method is filing clear issues.[1] The /simple-issue-description agent skill formats issue reports in a consistent, succinct voice and is installed with npx skills add every-app/open-seo --skill simple-issue-description.[1][2]
Changes to .greptile/**, AGENTS.md, CLAUDE.md, .agents/skills/**, and .github/** alter the review control plane and require explicit maintainer review.[3]
The ci:check script runs prettier, knip, TypeScript checks (main and badseo tsconfigs), oxlint, plugin skill sync, and asserts that plugins/openseo/skills has no uncommitted changes — all must pass before merging.[4] The deploy script runs database migrations before building and deploying: npm run db:migrate:prod && npm run build && wrangler deploy.[4] The plugin skill sync step in ci:check ensures that agent skills defined inside plugins are reflected in the committed plugins/openseo/skills directory; any mismatch between generated and committed files causes CI to fail.
Sources
Updated
OpenSEO is an open-source, self-hostable SEO platform covering keyword research, rank tracking, competitor insights, backlinks, and site audits — deployed as a Cloudflare Worker ("open-seo" in wrangler.jsonc with entry at "src/server.ts") backed by two long-running Workflows for audits and rank checks. The deployment wires SSR compilation via Vite's Cloudflare plugin directly to the Worker runtime, using Node.js compatibility and public fetch for the Worker's feature set. A Cloudflare Workflow is a durable, long-running execution primitive managed by Cloudflare's platform that survives beyond the time limits of a standard Worker request.
OpenSEO is an open-source, self-hostable SEO platform — a pay-as-you-go alternative to Semrush and Ahrefs — covering keyword research, rank tracking, competitor insights, backlinks, site audits, and AI visibility workflows.[1]
The Cloudflare Worker is named "open-seo" with its entry point at "src/server.ts", as declared in wrangler.jsonc.[2] In wrangler.jsonc, the Worker's compatibility_date is "2025-09-02" and its compatibility_flags are ["nodejs_compat", "global_fetch_strictly_public"].[2] Two Cloudflare Workflows are declared in wrangler.jsonc: SiteAuditWorkflow bound as SITE_AUDIT_WORKFLOW and RankCheckWorkflow bound as RANK_CHECK_WORKFLOW.[2] vite.config.ts configures the Cloudflare plugin with inspectorPort: false and viteEnvironment: { name: "ssr" }, wiring the SSR environment to the Cloudflare Worker runtime.[3]
Sources
Updated
OpenSEO supports three AUTH_MODE values—cloudflare_access (default, via CF Access JWTs), local_noauth (dev-only, no auth), and hosted (Better Auth email/password)—each configuring session identity and token encryption. Route protection chains from session validation through context middleware to per-resource checks: handlers verify the authenticated user's organization owns the requested project or session before granting Durable Object access.
Three AUTH_MODE values are supported: cloudflare_access (default, validates CF Access JWTs using TEAM_DOMAIN + POLICY_AUD), local_noauth (no auth, injects admin@localhost), and hosted (Better Auth email/password, requires BETTER_AUTH_SECRET and BETTER_AUTH_URL).[1]
In src/lib/auth-config.ts, advanced.ipAddress.ipAddressHeaders is set to ["cf-connecting-ip"] because Cloudflare Workers deliver the client IP in CF-Connecting-IP, not x-forwarded-for (better-auth's default); without this override, getIp() returns null and rate limiting is silently skipped on every /api/auth endpoint.[2] src/lib/auth-config.ts enables encryptOAuthTokens: true to encrypt OAuth access and refresh tokens at rest in D1; the encryption key derives from BETTER_AUTH_SECRET and also covers Google social-login tokens.[2] src/lib/auth-config.ts registers two genericOAuth providers — GSC_OAUTH_PROVIDER_ID (Google Search Console) and GA4_OAUTH_PROVIDER_ID (Google Analytics 4) — both using accessType: "offline", prompt: "select_account consent", and pkce: true to request refresh tokens via Google's OpenID Connect discovery URL.[2] src/lib/auth-config.ts sets accountLinking.allowDifferentEmails: true to allow connecting a Google account whose email differs from the logged-in user's, supporting agency and freelancer use cases where a user manages a client's property.[2] src/lib/auth-config.ts configures the organization plugin with allowUserToCreateOrganization: false, invitationLimit: 0, and disableOrganizationDeletion: true to enforce the billing invariant of one user per workspace; server-side bootstrap still works because better-auth exempts system actions (no session + userId in body) from the creation flag.[2]
getAuthenticatedContext in src/serverFunctions/middleware.ts validates the raw server-function context against ensuredUserContextSchema (Zod) and throws AppError('INTERNAL_ERROR', ...) if validation fails, preventing unauthenticated context from reaching handlers.[3] authorizeOnboardingChat in src/server.ts resolves the user context from request headers, then verifies the caller's organization owns the given projectId via ProjectRepository.getProjectForOrganization before allowing the Durable Object connection.[4] authorizeSamChat in src/server.ts validates the session via SamSessionRepository.getActiveSession (scoped to userId) and then confirms the session's project belongs to the caller's organization before permitting the Durable Object connection.[4] MCP tool handlers are wrapped with withMcpProjectAuth, which gates execution with project-level auth before calling the underlying service — see MCP transport and OAuth for the full MCP auth flow.[5]
Sources
Updated
OpenSEO exposes an MCP server so AI agents (Claude Code, OpenClaw, Hermes, etc.) can consume SEO data directly; Agent Skills are reusable, markdown-defined workflows that guide agents through SEO tasks via the MCP.[1]
src/server/mcp/server.ts defines createOpenSeoMcpServer, which instantiates an McpServer and registers the full set of OpenSEO MCP tools (keyword research, SERP, backlinks, rank tracking, GSC, GA4, local SEO, site audit, project management, and more).[2] The OpenSEO MCP server instructs agents to proceed normally with focused research, but to ask the user for confirmation before planned batches over 2,000 credits.[2]
registerOpenSeoTool in src/server/mcp/server.ts wraps every tool handler with instrumentMcpToolHandler before registering it with the underlying McpServer, normalizing both inputSchema and outputSchema via objectSchema.[2] MCP tool inputSchema can be declared as either a raw Zod shape (z.ZodRawShape) or a full z.ZodType; objectSchema in src/server/mcp/server.ts normalizes both forms before tool registration.[2] All MCP tools declare annotations of readOnlyHint: false, openWorldHint: false, and destructiveHint: false, signaling to MCP clients that these tools consume credits but do not mutate or call external open-world APIs in a write sense.[3]
Sources
Updated
OpenSEO's MCP transport routes requests by type—preflight CORS, legacy JSON-RPC, or modern MCP—with separate handlers for hosted (OAuth-authenticated) and self-hosted deployments; hosted requests enforce strict origin validation plus OAuth scope gates, while self-hosted requests rely on local identity resolution. The OAuth provider issues MCP access tokens (24-hour TTL) and refresh tokens (30-day TTL), grants all configured scopes by default, protects the consent endpoint against CSRF, and logs authorization to PostHog after successful consent.
createRequestHandler in src/server/mcp/transport.ts routes OPTIONS preflight requests to an immediate CORS-headers response, returns 404 for paths other than MCP_ROUTE, delegates modern requests to the agents SDK handler, and falls through to handleLegacyJsonRequest for detected legacy requests. The modern MCP handler in src/server/mcp/transport.ts is created with legacy: "reject", so createMcpHandler from the agents SDK never falls back to legacy behaviour — legacy requests are handled entirely by handleLegacyJsonRequest in the same file. handleLegacyJsonRequest in src/server/mcp/transport.ts rejects non-POST methods with a JSON-RPC 2.0 error (code: -32000, status: 405) before processing legacy MCP requests.
The MCP CORS headers in src/server/mcp/transport.ts mirror the agents SDK's DEFAULT_CORS_OPTIONS, including Access-Control-Allow-Origin: *, Access-Control-Max-Age: 86400, and the exposed header mcp-session-id. The Surfmind Chrome extension (chrome-extension://pghallcbnfabbgfijhbcldaapmgidnaa) is explicitly allowlisted as an accepted origin for hosted MCP requests in src/server/mcp/transport.ts.
Hosted MCP requests in src/server/mcp/transport.ts enforce exact-origin validation against the hosted base URL and the Surfmind Chrome extension origin; any other Origin header returns 403. Self-hosted requests leave allowedOriginHostnames unset, relying on the SDK's localhost-class default — an explicit per-request Host-derived allowlist is deliberately avoided to prevent DNS-rebinding attacks. validateLegacyRequest in src/server/mcp/transport.ts applies host-header validation only for localhost and .workers.dev hostnames; for other hosts (production deployments), host validation is skipped and only origin validation is applied against the caller-supplied allowedOriginHostnames.
handleAuthenticatedOpenSeoMcpRequest in src/server/mcp/transport.ts enforces two hard gates before serving: the props must parse against hostedWorkersOAuthMcpPropsSchema (403 if not), and the auth context must include MCP_SCOPE (403 if missing). Only then does it proceed to host/origin validation. handleSelfHostedOpenSeoMcpRequest in src/server/mcp/transport.ts resolves identity via resolveLocalNoAuthContext for local_noauth mode or resolveCloudflareAccessContext for cloudflare_access mode, then calls createRequestHandler without an origin allowlist. The mcpApiHandler in src/server/mcp/oauth-provider.ts calls handleAuthenticatedOpenSeoMcpRequest with ctx.props as the OAuth props — the props originate from the Cloudflare Workers OAuth provider's execution context, not from the request body.
The OAuth provider in src/server/mcp/oauth-provider.ts sets MCP access token TTL to 24 hours (60 * 60 * 24 seconds) and refresh token TTL to 30 days (60 * 60 * 24 * 30 seconds). DCR (Dynamic Client Registration) records in src/server/mcp/oauth-provider.ts expire after 1 year (60 * 60 * 24 * 365 seconds); the rationale is that 30-day rolling refresh tokens already reap inactive clients' sessions, so a 1-year client registration TTL keeps the invalid_client cliff rare for actively-used clients. DCR (Dynamic Client Registration) is the OAuth mechanism by which an MCP client automatically registers itself with the provider at runtime; without a valid DCR record, the provider rejects the client with an invalid_client error.
getGrantedMcpScopes in src/server/mcp/oauth-provider.ts grants all MCP_OAUTH_SCOPES when the client requests no specific scopes, and throws if the filtered result does not include MCP_SCOPE (the mcp scope is required in all grants). The consent endpoint (/api/oauth/consent) in src/server/mcp/oauth-provider.ts is CSRF-protected: it checks that the Origin header matches the public origin of the request, returning 403 for mismatches. The OAuth authorize flow in src/server/mcp/oauth-provider.ts redirects unauthenticated users to /sign-in?redirect=<original-path> rather than returning an error, and returns 500 for missing Better Auth hosted configuration. After a successful OAuth consent, handleOAuthConsentResponse in src/server/mcp/oauth-provider.ts calls recordMcpAuthorized and fires a mcp:authorize_success PostHog event (via waitUntil) with client_id and scopes properties before returning the redirect URL.
Updated
OpenSEO's research and SERP tools expose DataForSEO APIs through MCP (Model Context Protocol) handlers that query ranked keywords, domain metrics, keyword research seeds, backlinks, and local business listings—each tool validates location/language consistency and manages legacy parameter deprecation (scope vs. includeSubdomains, locationCode vs. market). Tools return results in dual format (Markdown text for readability, structured JSON for programmatic use) and handle batch operations where individual item failures don't abort the whole request, with pricing controlled by optional features like clickstream data and backlink deduplication.
In src/server/mcp/tools/dataforseo-research-tools.ts, the getRankedKeywords tool accepts a target that is either a domain (no protocol, no www) or an absolute page URL, validated by rankedTargetSchema.[1] The rankedResultTypeSchema in src/server/mcp/tools/dataforseo-research-tools.ts recognizes five SERP result types: organic, paid, featured_snippet, local_pack, and ai_overview_reference.[1] The includeSubdomains parameter in getRankedKeywords (within src/server/mcp/tools/dataforseo-research-tools.ts) is deprecated; callers should use the scope parameter with values 'subdomains' or 'domain' instead.[1]
The marketSchema in src/server/mcp/tools/dataforseo-research-tools.ts is a legacy US-only selector; callers should prefer locationCode/languageCode for any Labs market, and an explicit locationCode takes precedence over the legacy market object.[1] The get_domain_overview tool (src/server/mcp/tools/get-domain-overview.ts) resolves its market via resolveLabsMarket and additionally calls assertLabsLocationCode to validate that the resolved location code is supported by DataForSEO Labs, followed by assertLanguageForLocation for language/location consistency.[2] The research_keywords tool resolves the market per seed by calling resolveMarket(item, context.project), then validates language/location consistency with assertLanguageForLocation before calling the research service.[3]
getKeywordMetrics in src/server/mcp/tools/dataforseo-research-tools.ts accepts 1–700 keywords; enabling includeClickstreamData doubles the credit cost of the call and has no effect for countries served from Google Ads data.[1] searchLocalBusinesses in src/server/mcp/tools/dataforseo-research-tools.ts supports filtering by isClaimed: false to surface unclaimed listings as outreach prospects.[1]
The research_keywords MCP tool (src/server/mcp/tools/research-keywords.ts) accepts 1–5 seed keywords per call via a seeds array; each seed is researched independently so a single failing seed does not abort the entire batch.[3] The tool's resultLimit parameter accepts only the literal values 150, 300, or 500 (maximum keywords returned per seed) and defaults to 150 when omitted.[3] Per seed, the research_keywords handler calls KeywordResearchService.research with mode: "auto" and the resolved locationCode/languageCode, returning results keyed by ok: true or ok: false.[3] The research_keywords tool returns both a Markdown table per seed (via formatMcpTable) in its text output and full structured content including trend data rows in structuredContent, ensuring MCP clients that surface only text still see every keyword and its metrics.[3]
The get_domain_overview MCP tool (src/server/mcp/tools/get-domain-overview.ts) returns organic traffic estimate, organic keyword count, backlinks, and referring domains for a domain, charging approximately 100–300 credits with results cached for 12 hours per domain.[2] The tool's includeSubdomains boolean parameter is deprecated; callers should use the scope parameter ('subdomains' or 'domain') instead.[2] get_domain_overview resolves scope from args.scope first; if absent, it falls back to includeSubdomains (true → "subdomains", false → "domain"), then passes the resolved value to DomainService.getOverview.[2] The tool's text output includes a warning that overview metrics always cover the whole domain including subdomains when scope is not 'subdomains'; callers needing scoped keyword data should use get_ranked_keywords with an explicit scope.[2]
The get_backlinks_profile tool's mode parameter accepts 'one_per_domain' (default, returns each referring domain's strongest link) or 'as_is' (returns individual backlink rows).[4] The tool's pageSize parameter accepts only the values 50, 100, or 200, with the default determined by DEFAULT_BACKLINKS_PAGE_SIZE.[4] The hideSpam parameter defaults to true, filtering out spammy backlinks unless explicitly set to false; the handler passes this as a separate options argument to BacklinksService.profileBacklinksPage alongside the validated request and billing context.[4] The filters parameter in get_backlinks_profile supports filtering by source URL terms (include/exclude), authority/spam score ranges, dofollow/nofollow type, lost/broken visibility, or exact domainFrom.[4]
Sources
Updated
The get_search_console_performance MCP tool queries the connected Search Console property's Search Analytics — clicks, impressions, CTR, and average position — and is read-only, using no DataForSEO credits.[1] In get_search_console_performance, ctr is a 0–1 fraction, position is a 1-based average, dates are in Pacific Time, and the last ~3 days of data may be incomplete due to GSC data lag.[1] Keyword cannibalization occurs when multiple pages on a site compete for the same search query, splitting ranking signals and reducing overall position; consolidating or differentiating overlapping content mitigates it.
The dimensions parameter of get_search_console_performance defaults to ['query']; ['page'] returns top pages, ['query','page'] maps queries to pages for cannibalization detection, and ['date'] produces a time series.[1] The dateRange convenience parameter defaults to last_28_days and is ignored when explicit startDate and endDate are provided; the maximum GSC lookback is 16 months.[1] The rowLimit parameter defaults to 1,000 (the maximum per call); GSC sorts results by clicks descending and cannot filter by position, so striking-distance position filtering must be done client-side, and pagination uses startRow when hasMore is true.[1] The filters parameter accepts up to 5 AND-combined dimension filters; to get queries for one page, pass [{dimension:'page', operator:'equals', expression:'https://example.com/post'}] with dimensions: ['query'].[1]
When searchAppearance is included in dimensions alongside any other dimension, get_search_console_performance returns an invalid_request error because the GSC API rejects that combination.[1] Providing only one of startDate or endDate in get_search_console_performance returns an invalid_request error; both must be supplied together, or neither (use dateRange instead).[1] GSC connection errors in get_search_console_performance are classified into three types — GscNotConnectedError (not connected), GscTokenError (expired or revoked token), and GscApiError (API-level error) — each producing a distinct human-readable message and reason in structured output.[1] When GSC is not connected, get_search_console_performance returns structured content with ok: false, reason: "not_connected" and a connectUrl pointing to the project's Search Performance page rather than the settings page.[1]
The get_search_console_performance handler calls GscService.getPerformance and detects whether the result has more rows by checking if rows.length >= requestedLimit, exposing hasMore and nextStartRow for pagination.[1] The text summary of get_search_console_performance results shows the first 15 rows as a formatted table (constant TEXT_SUMMARY_ROWS = 15), while the structured content carries the full row set.[1]
The inspect_urls MCP tool accepts 1–10 absolute URLs to inspect, each of which must belong to the connected GSC property.[1]
Sources
Updated
OpenSEO's three site-audit MCP tools—run_site_audit, get_audit_status, and get_audit_issues—coordinate around a single audit lifecycle: initiate a crawl with run_site_audit, poll its completion status, then retrieve findings sorted and filtered by severity and type. The tools default to the project's most recent audit and manage scale with configurable page budgets and issue result limits, ensuring agents can query partial results from failed audits and gracefully handle audits predating issue data collection.
The three site-audit MCP tools — run_site_audit, get_audit_status, and get_audit_issues — are implemented in site-audit-tools.ts; the auditId parameter is optional in all three, and when omitted each tool resolves against the project's most recent audit.[1]
The maxPages parameter of run_site_audit accepts integers between 10 and 10,000; the default page budget (as tracked in analytics) is 50.[1] When runLighthouse is false (the default), run_site_audit sets lighthouseStrategy to "none"; when true, it sets it to "auto".[1]
The run_site_audit handler calls AuditService.resolveAuditLimitTier to determine the billing tier, then calls AuditService.startAudit to begin the crawl.[1] On success, run_site_audit emits a "site_audit:start" PostHog event recording project_id, max_pages, run_lighthouse, and source: "mcp".[1] run_site_audit starts a crawl and returns immediately; agents must poll get_audit_status until the audit reaches a terminal state (completed or failed) before treating results as final.
The resolveAudit helper in site-audit-tools.ts fetches a specific audit by ID if auditId is provided, or falls back to AuditRepository.getLatestAuditForProject; it throws AppError("NOT_FOUND") if no audit is found.[1]
A failed audit with pagesCrawled > 0 still holds partial results; get_audit_status instructs agents to call get_audit_issues on a failed audit rather than treating it as having no data.[1]
The limit parameter of get_audit_issues defaults to 200 and is capped at 1,000.[1] The get_audit_issues handler passes severity and issueType filters into AuditRepository.getIssuesForAudit, then sorts and slices the result client-side.[1]
get_audit_issues sorts issues severity-first (using ISSUE_SEVERITY_ORDER) so that when the result is truncated by limit, info-severity rows are dropped first and critical rows are never lost.[1] The get_audit_issues response includes a summary array that groups issues by type with count and severity, sorted severity-first then by descending count.[1] When no issues match and no filters are applied, get_audit_issues hints that audits run before issue checks existed have no issue data and should be re-run with run_site_audit.[1]
Sources
Updated
The OpenSEO Cursor plugin bundles nine agent skills—competitive landscape, competitor analysis, keyword clustering, keyword research, link prospecting, local SEO, SEO audit, SEO coach, and SEO project setup—to automate SEO workflows within Cursor. The plugin is free and open source with optional hosted plans; users authenticate via OAuth on first use.
The OpenSEO Cursor plugin (plugins/openseo/README.md) bundles nine agent skills: competitive landscape, competitor analysis, keyword clustering, keyword research, link prospecting, local SEO, SEO audit, SEO coach, and SEO project setup.[1] The plugin package (plugins/openseo/README.md) is free and open source; hosted plans and usage credits are available at openseo.so/pricing, and the project can also be self-hosted.[1] On first use, Cursor triggers an OAuth prompt through which the user signs in and approves the connection — authentication details are covered in Auth modes and gating.[1]
Sources
Updated
Database schema must run on both SQLite (D1) and Postgres; the dual-backend constraint is enforced at the type level in src/db/schema.ts and verified by schema-parity.test.ts, with getDatabaseProvider() switching backends at runtime. OpenSEO's core tables (projects, rankTrackingConfigs, rankCheckRuns, savedKeywords, and their related lookup tables) use partial unique indexes, enums, and soft-delete timestamps to prevent race conditions, enforce business rules, and preserve audit trails across both database engines.
Database schema changes and queries must remain compatible with both SQLite (D1) and Postgres — the dual-backend constraint is a hard invariant.[1] D1 (SQLite) is the default database backend; Postgres is an opt-in backend for installs that outgrow D1, documented in docs/LOCAL_POSTGRES.md.[2] The D1 binding is named DB, the database is named open-seo, and migrations are stored in the drizzle/ directory, as declared in wrangler.jsonc.[3] Hyperdrive is the ONLY way the app connects to Postgres from a Worker — there is no direct-connection fallback. The Hyperdrive binding is kept commented out in wrangler.jsonc and is used only for local Postgres development, never read by Alchemy or Docker deployments.[3]
src/db/schema.ts is the canonical schema barrel: repositories import their tables from here and receive the SQLite type definitions, while at runtime the values are either the SQLite or Postgres schema depending on the active getDatabaseProvider() result — so each repository is written once for both backends.[4] In src/db/schema.ts the exported type identity is typeof sqliteApp & … (the SQLite definitions), while the runtime values are cast to that type — a cast guarded by schema-parity.test.ts, which asserts that the two dialect schemas are structurally interchangeable (same tables, columns, nullability, PKs, and unique indexes).[4] src/db/pg/schema.ts is a pure re-export barrel for all Postgres dialect schema modules: app, project-context, audit, sam, better-auth, billing, ga4, gsc, and telemetry.[5]
Projects support soft-delete via an archivedAt timestamp: archived projects are hidden everywhere, but their data — keywords, rank tracking, and audits — is preserved.[6] A partial unique index on the projects table prevents more than one un-archived Default project (with null domain) per organization, guarding against a get-or-create race when multiple requests enter a new organization simultaneously.[6] The projects table defaults DataForSEO locationCode to 2840 (United States) and languageCode to 'en'; these are set during onboarding and reused by every project-scoped data call.[6]
The rankTrackingConfigs table enforces separate uniqueness for national configs (no locationName) and local configs (with locationName) via two conditional partial unique indexes, allowing the same domain/location combination to coexist only if one is national and one is local.[6] The rankTrackingConfigs table's scheduleInterval column is an enum of 'daily', 'weekly', 'monthly', 'manual', defaulting to 'weekly'.[6] The rankTrackingConfigs table's devices column is an enum of 'both', 'desktop', 'mobile', defaulting to 'both'.[6] The rank_check_runs table enforces at most one in-flight run per config at the DB level via a partial unique index on config_id WHERE status IN ('pending', 'running') — a second pending run for the same config fails with a unique-constraint violation.[6] The rankCheckRuns table's status column is an enum of 'pending', 'running', 'completed', 'failed', defaulting to 'pending'.[6]
The savedKeywords table enforces uniqueness on (projectId, keyword, locationCode, languageCode), preventing duplicate keyword entries for the same project, location, and language combination.[6] Tag colors in savedKeywordTags are optional: a null color column means the render layer should derive a stable color from the tag ID at render time, as implemented in src/shared/tag-colors.ts.[6] The savedKeywordTagAssignments table intentionally omits a standalone index on savedKeywordId because the unique index on (savedKeywordId, tagId) already serves those lookups as its leftmost column.[6] The keywordMetrics table stores the latest cached DataForSEO metrics per (projectId, keyword, locationCode, languageCode) and is joined onto savedKeywords when rendering the saved keyword list.[6]
The userOnboardingAnswers table has a gscNudgeDismissedAt column that tracks when the user resolves the Search Console ask — either during onboarding or via a one-time re-engagement nudge for legacy users; null means not yet shown or resolved.[6]
Sources
Updated
OpenSEO charges through third-party DataForSEO (self-hosted accounts pay 28% less); hosted mode tracks credit balance across monthly and top-up tiers via prepareRankCheckKeywords and AuditService.ts, gating rank-check and audit features behind paid-plan checks and capacity limits. Cost estimation and validation happen twice—once in prepareRankCheckKeywords using queued or live pricing, and again in triggerCheck—with caller-supplied maxCostCredits caps enforcing user approval before any external API call.
DataForSEO is a pay-as-you-go third-party service; new accounts include $1 of free credit, and the minimum top-up is $50. The DATAFORSEO_API_KEY environment variable holds a Base64-encoded email:password credential pair obtained from the DataForSEO dashboard, not a bare API token. Self-hosters pay DataForSEO directly and pay roughly 28% less than through the hosted service, which marks up every DataForSEO request by that margin.
Credit balance checks in prepareRankCheckKeywords run only in hosted mode (isHostedServerAuthMode()), combining a monthly balance (AUTUMN_SEO_DATA_BALANCE_FEATURE_ID) and a top-up balance (AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID) to determine total availability. In hosted mode, when the combined monthly and top-up credit balance is exhausted, rank-check and audit features are blocked until the balance is replenished. requireRankCheckAccess in RankTrackingService.ts is a no-op in self-hosted mode; on the hosted platform it gates access behind a paid-plan check via customerHasPaidPlan. In hosted mode, resolveAuditLimitTier in AuditService.ts maps subscription state to one of three AuditLimitTier values: 'self_hosted' (not hosted mode), 'paid' (has paid plan), or 'free' (has managed access but no paid plan). Accounts with no managed access at all receive an AppError('PAYMENT_REQUIRED').
Credit cost estimation in prepareRankCheckKeywords uses 'queued' pricing for scheduled triggers and 'live' pricing for manual triggers, because a live-price estimate for scheduled checks would incorrectly skip checks the user can afford. prepareRankCheckKeywords enforces a caller-supplied cost cap: if maxCostCredits is set and the estimated credit cost exceeds it, a VALIDATION_ERROR with a user-readable approval message is thrown before touching any external APIs. triggerCheck in RankTrackingService.ts also validates the estimated credit cost against the caller-supplied maxCostCredits budget before starting the workflow, throwing VALIDATION_ERROR if exceeded.
The includeClickstreamData parameter on the research_keywords tool is opt-in and defaults to false; enabling it doubles the credit cost per seed by using clickstream-refined volumes that disaggregate Google Ads' close-variant groupings, and has no effect for countries served from Google Ads data. When audit capacity is reached, run_site_audit returns a human-readable error directing users to delete old audits rather than throwing; it catches AppError with code "AUDIT_CAPACITY_REACHED" for this case.
Updated
OpenSEO has evolved from PageSpeed Insights integration through DataForSEO, added MCP-based AI agent connectivity with session persistence and extended TTLs, and expanded keyword/rank coverage to 48 countries while fixing response formats and cost economics. Key milestones include multi-project organization support, self-hosted Google Search Console, opt-in features (Lighthouse, clickstream volumes), and tooling integrations (Cursor plugin, Durable Objects-backed audits).
In v0.0.3, the project was renamed from OpenRank back to OpenSEO.[1] Lighthouse audits were moved from PageSpeed Insights to DataForSEO in v0.0.4/v0.0.5, so users no longer need to supply a PSI API key.[2]
MCP support was introduced in v0.0.11, enabling OpenSEO to serve as an SEO data source for Claude Code, Codex, and any MCP-compatible AI agent.[3] MCP access tokens were extended to a 24-hour TTL in v0.0.13 to reduce reconnect interruptions for MCP clients.[4] MCP tool text responses were fixed in v0.0.23 to return full result sets instead of row counts or truncated lists; this applies to research_keywords, get_keyword_metrics, get_ranked_keywords, get_serp_results, search_local_businesses, get_local_serp_results, get_google_business_questions, find_serp_competitors, get_backlinks_profile, get_backlinks_overview, get_domain_keyword_suggestions, get_rank_tracker, and get_search_console_performance.[5] The create_project MCP tool was added in v0.1.2, allowing AI agents to create projects directly.[6] MCP clients were broken after the first day in deployments prior to v0.1.4; v0.1.4 restored persistent MCP client sessions.[7] MCP API key support for hosted mode was added in commit 16eb599.[8]
Clickstream-refined search volumes became opt-in in v0.0.20, cutting the cost of a default keyword research run roughly in half.[9] Keyword research, rank tracking, and SERP analysis for 48 new countries — powered by Google Ads data — were added in v0.0.20; keyword difficulty and search intent are not available for these countries.[9] Research scope can be set to Exact URL, Subfolder, Domain, or Subdomains as of v0.1.5.[10]
Rank tracking costs were reduced approximately 3× in v0.0.21 by routing scheduled checks through DataForSEO's task queue and stopping SERP page crawling once the tracked domain is found.[11] Rank tracking was fixed in v0.1.3 to report rank_group (organic position) instead of rank_absolute (absolute SERP slot that counts features like local pack and AI overviews), so rankings no longer appear worse than they actually are.[12]
The seo-audit agent skill — a one-page, plain-language site report — was added in v0.1.3.[12] Lighthouse is opt-in for agent-run site audits as of v0.1.6.[13] The site audit crawl was refactored to use Durable Objects in commit 1e8a924 to improve reliability and performance.[14] The audit_links table was dropped in commit 8d6e993.[15]
In v0.0.17, an infinite redirect loop between /verify-email and the app triggered by BYPASS_EMAIL_VERIFICATION=true (local dev) was fixed; the bypass is now honored consistently across the auth route guard, onboarding redirect, and verify-email page, with production behavior unchanged.[16] Self-hosted Google Search Console support, requiring your own Google OAuth client, was added in v0.0.18.[17] Multi-project support per organization was introduced in v0.0.19.[18] AI features in Docker self-hosting require the OPENROUTER_API_KEY environment variable to be set; this was enabled in v0.1.3.[12] A Cursor marketplace plugin package was added in commit c469a48.[19]
Sources
github.com/every-app/open-seo/releases/tag/v0.0.3github.com/every-app/open-seo/releases/tag/v0.0.5github.com/every-app/open-seo/releases/tag/v0.0.11github.com/every-app/open-seo/releases/tag/v0.0.13github.com/every-app/open-seo/releases/tag/v0.0.23github.com/every-app/open-seo/releases/tag/v0.1.2github.com/every-app/open-seo/releases/tag/v0.1.4github.com/every-app/open-seo/commit/16eb599github.com/every-app/open-seo/releases/tag/v0.0.20github.com/every-app/open-seo/releases/tag/v0.1.5github.com/every-app/open-seo/releases/tag/v0.0.21github.com/every-app/open-seo/releases/tag/v0.1.3github.com/every-app/open-seo/releases/tag/v0.1.6github.com/every-app/open-seo/commit/1e8a924github.com/every-app/open-seo/commit/8d6e993github.com/every-app/open-seo/releases/tag/v0.0.17github.com/every-app/open-seo/releases/tag/v0.0.18github.com/every-app/open-seo/releases/tag/v0.0.19github.com/every-app/open-seo/commit/c469a48Updated
These snippets automate testing OpenSEO UI interactions—from console capture and element discovery to form filling, tab navigation, filter persistence, and performance measurement—using Playwright's page object and locator API.
Capture browser console logs during Playwright automation by attaching a handler via page.on('console', ...)
console_logs = []
def handle_console_message(msg):
console_logs.append(f"[{msg.type}] {msg.text}")
print(f"Console: [{msg.type}] {msg.text}")
page.on("console", handle_console_message)
page.goto(url)
page.wait_for_load_state('networkidle')
page.click('text=Dashboard')
page.wait_for_timeout(1000)
Discover buttons, links, and input fields on a page using Playwright locators after navigating to a URL
page.goto('http://localhost:5173')
page.wait_for_load_state('networkidle')
buttons = page.locator('button').all()
for i, button in enumerate(buttons):
text = button.inner_text() if button.is_visible() else "[hidden]"
links = page.locator('a[href]').all()
for link in links[:5]:
href = link.get_attribute('href')
inputs = page.locator('input, textarea, select').all()
for input_elem in inputs:
name = input_elem.get_attribute('name') or input_elem.get_attribute('id') or "[unnamed]"
input_type = input_elem.get_attribute('type') or 'text'
Automate interaction with a local static HTML file using a file:// URL in Playwright, including form fill and screenshot
html_file_path = os.path.abspath('path/to/your/file.html')
file_url = f'file://{html_file_path}'
page.goto(file_url)
page.screenshot(path='/mnt/user-data/outputs/static_page.png', full_page=True)
page.click('text=Click Me')
page.fill('#name', 'John Doe')
page.fill('#email', 'john@example.com')
page.click('button[type="submit"]')
page.wait_for_timeout(500)
page.screenshot(path='/mnt/user-data/outputs/after_submit.png', full_page=True)
Closing an inactive search tab leaves the active tab selected and removes the closed tab from the URL
await openDomainOverview(page, "keywords");
const secondUrl = new URL(page.url());
secondUrl.searchParams.set("domain", SECONDARY_TEST_DOMAIN);
await page.goto(secondUrl.toString());
const inactiveCloseButton = page.getByRole("button", {
name: `Close ${PRIMARY_TEST_DOMAIN} tab`,
});
await inactiveCloseButton.click();
await expect.poll(() => new URL(page.url()).searchParams.get("domain")).toBe(SECONDARY_TEST_DOMAIN);
Clearing page filters via 'Clear all' does not remove keyword filters; keyword filter value persists in URL and input
await applyFilters(page, "minTraffic", "10"); // keyword filter
await switchDomainTab(page, "pages");
await applyFilters(page, "pMinTraffic", "20"); // page filter
await ensureFiltersOpen(page, "Include Page Terms");
await page.getByRole("button", { name: "Clear all" }).click();
await expect.poll(() => new URL(page.url()).searchParams.get("pMinTraffic")).toBe(null);
await expect.poll(() => new URL(page.url()).searchParams.get("minTraffic")).toBe("10");
Saved filter defaults apply when navigating without explicit tab filter params, but explicit URL params take precedence
await applyFilters(page, "pMinTraffic", "20");
const urlWithoutPageFilters = new URL(page.url());
urlWithoutPageFilters.searchParams.delete("pMinTraffic");
await page.goto(urlWithoutPageFilters.toString());
await ensureFiltersOpen(page, "Include Page Terms");
await expect(page.getByPlaceholder("Min").nth(0)).toHaveValue("20"); // saved default applied
const urlWithExplicitPageFilters = new URL(page.url());
urlWithExplicitPageFilters.searchParams.set("pMinTraffic", "30");
await page.goto(urlWithExplicitPageFilters.toString());
await ensureFiltersOpen(page, "Include Page Terms");
await expect(page.getByPlaceholder("Min").nth(0)).toHaveValue("30"); // explicit param wins
Main-thread performance is measured after applying and editing filters using CDP CPU throttling and a custom perf probe
await installDomainPerfProbe(page);
const client = await page.context().newCDPSession(page);
await client.send("Emulation.setCPUThrottlingRate", { rate: CPU_THROTTLE_RATE });
await openDomainOverview(page, "pages");
await openFilters(page);
await typeIntoDraftInput(page, page.getByPlaceholder("Min").nth(0), "10", "Pages Traffic min", {
actionTimeoutMs: PERF_BUDGETS.actionMs,
cdpSession: client,
inputLatencyBudgetMs: PERF_BUDGETS.maxInputMs,
recordPerf: true,
});
await applyFilters(page, "pMinTraffic", "10");
const finalMetrics = await getDomainPerfMetrics(page);
Playwright exposes the Chrome DevTools Protocol (CDP) via page.context().newCDPSession(), enabling low-level browser control—such as CPU throttling via Emulation.setCPUThrottlingRate—for capabilities not available through Playwright's standard API.
Clicking 'Back to Recent searches' clears the active keyword tab query param from the URL
await page.goto(`/p/${projectId}/keywords?q=keyword%20research&loc=2840&kLimit=150&mode=auto`);
const recentSearchesButton = page.locator(
'[data-testid="keyword-research-recent-searches"]:visible',
);
await recentSearchesButton.click();
await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe(null);
Closing the active middle keyword tab removes it and selects the next tab, leaving the search tabs tablist with one fewer tab
await page.getByRole("tab", { name: /^backlinks/i }).click();
const closeButton = page.getByRole("button", { name: "Close backlinks tab" });
await closeButton.click();
await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("open seo");
await expect(page.getByRole("tab", { name: /^open seo/i })).toHaveAttribute("aria-selected", "true");
await expect(
page.getByRole("tablist", { name: "Search tabs" }).getByRole("tab"),
).toHaveCount(2);
Sources