# Programmatic SEO Agent

Weekly scheduled agent that grows a product's organic traffic with programmatic SEO: it checks the product's GitHub repository out into its sandbox, discovers and validates keyword opportunities with DataForSEO, researches each target with the Parallel web search API, generates batches of SEO-optimized pages matching the repo's content conventions, and pushes them as a pull request for human review.

- Install: `npx shadcn@latest add @evex/programmatic-seo-agent`
- Category: marketing
- Author: [TommyBez](https://www.evex.sh/authors/TommyBez)
- Updated: 2026-07-04
- Dependencies: eve@^0.18.2, parallel-web@^1.1.0, zod@4.3.6
- Web page: https://www.evex.sh/agents/programmatic-seo-agent
- This document: https://www.evex.sh/agents/programmatic-seo-agent.md

## Overview

Programmatic SEO Agent is a scheduled eve agent that grows organic traffic by shipping weekly batches of SEO-optimized pages to your product's GitHub repository. Each run clones the repo defined by PSEO_GITHUB_REPO into a Vercel sandbox, studies your product and existing content conventions, and writes new pages that match the format already in the repo, from frontmatter fields to internal-link style.

You interact with it through a cron schedule (PSEO_WEEKLY_CRON, Mondays at 07:00 UTC by default) rather than a chat channel, and everything it produces arrives as a pull request on an idempotent pseo/<year>-w<week> branch. It never merges anything, never touches files outside the configured target directory, and re-running the same week updates the existing PR instead of opening a duplicate.

What makes it useful is that every page is backed by data: keyword opportunities come from the DataForSEO Labs and Google Ads endpoints, factual claims are grounded in Parallel web search excerpts with provenance, and a bundled programmatic-seo skill enforces a quality bar that drops thin doorway pages. A week with no keywords above the volume threshold is deliberately skipped, not padded.

## How it works

1. At session start the sandbox clones PSEO_GITHUB_REPO into /workspace/repo with credential brokering: the GitHub token is injected at the sandbox firewall as Basic auth on github.com and Bearer auth on api.github.com, so it never enters the sandbox process or any command line.
2. The agent loads the programmatic-seo skill, reads the repo's README, marketing pages, and existing content under PSEO_TARGET_DIR to learn the product, its personas, and the exact page format to reproduce.
3. It derives seed keywords from the product context and calls the discover_keywords tool, which queries the DataForSEO Labs keyword_ideas endpoint and returns up to 200 ideas with search volume, difficulty, intent, and CPC.
4. It designs one or two repeatable page patterns, enumerates the exact keyword permutations, and runs them through validate_keywords, which checks real Google Ads search volumes and flags anything below PSEO_MIN_SEARCH_VOLUME (default 30) for removal.
5. For each surviving keyword, capped at PSEO_MAX_PAGES_PER_RUN (default 20), the research_keyword tool calls the Parallel Search API with 2-3 focused queries and returns ranked excerpts with URLs, so every factual claim on a page traces back to a source.
6. Finally it writes one page per keyword into the checkout, commits on the pseo/<year>-w<week> branch, pushes, and opens or updates a pull request via the GitHub REST API, with the PR body listing each page's keyword, search volume, playbook pattern, and research sources for human review.

## Use cases

### Comparison and alternative pages for a developer tool

Point the agent at your docs or marketing repo and let it build a validated set of product-vs-competitor and alternatives pages, each grounded in Parallel research and linked hub-and-spoke so authority consolidates instead of fragmenting.

### Integration and use-case landing pages

For products with many integrations or personas, the agent enumerates permutations like product for persona or product with tool, validates each against real Google Ads volumes, and only writes pages for queries people actually search.

### Steady content pipeline without hiring

A small team gets up to 20 reviewed, data-backed pages per week as pull requests. The human review gate stays in place: you read the PR, edit or reject pages, and merge on your own terms.

### Glossary and long-tail coverage for a niche domain

Using the skill's playbook catalog, the agent can build glossary or how-to page sets around your domain's terminology, skipping weeks when nothing clears the volume bar rather than shipping index bloat.

## Requirements

- `PSEO_GITHUB_REPO`: The product repository that receives generated pages, in owner/repo form. The sandbox clones it at session start and the weekly PR is opened against its default branch.
- `PSEO_GITHUB_TOKEN`: Fine-grained GitHub personal access token (or GitHub App installation token) with Contents and Pull requests read/write on that repo. It is brokered at the sandbox firewall and never enters the sandbox process.
- `DATAFORSEO_LOGIN`: DataForSEO API login from app.dataforseo.com/api-access, used with DATAFORSEO_PASSWORD as Basic auth by the discover_keywords and validate_keywords tools.
- `DATAFORSEO_PASSWORD`: DataForSEO API password paired with DATAFORSEO_LOGIN. DataForSEO bills per request for the keyword_ideas and Google Ads search_volume endpoints the agent calls.
- `PARALLEL_API_KEY`: API key from platform.parallel.ai for the Parallel Search API used by research_keyword. Search mode and result count are tunable via PSEO_SEARCH_MODE (turbo, basic, or advanced) and PSEO_SEARCH_MAX_RESULTS.
- `PSEO_TARGET_DIR`: Directory inside the product repo where generated pages are written, defaulting to content/programmatic. The agent only stages files under this directory and never modifies existing files.
- `PSEO_WEEKLY_CRON`: Optional 5-field cron expression for the weekly run, defaulting to 0 7 * * 1 (Mondays 07:00 UTC on Vercel). Related tuning knobs: PSEO_MAX_PAGES_PER_RUN, PSEO_MIN_SEARCH_VOLUME, PSEO_LOCATION_CODE, PSEO_LANGUAGE_CODE.

## FAQ

### How do I install and run it?

Install with npx shadcn@latest add @evex/programmatic-seo-agent, copy .env.example into your eve app environment, and fill in the GitHub, DataForSEO, and Parallel credentials. In development you can trigger a run immediately by POSTing to /eve/v1/dev/schedules/weekly-programmatic-seo instead of waiting for the cron.

### Can it merge changes or modify existing files in my repo?

No. The agent only writes new pages under PSEO_TARGET_DIR, commits to pseo/<year>-w<week> branches, and opens a pull request. It never pushes to the default branch and never merges; a human reviews every PR. Branch protection on the default branch remains your responsibility.

### Which model does it use and can I change it?

The agent is defined with zai/glm-5.2 in agent/agent.ts. Since the eve agent definition is a one-line defineAgent call installed into your codebase, you can swap the model by editing that file.

### What happens when no keyword is worth a page?

The run is skipped and reported as such. If nothing clears PSEO_MIN_SEARCH_VOLUME after deduplication and coverage checks against existing repo content, the agent stops without committing or opening a PR. Three bundled evals verify this behavior, including that volumes are never fabricated.

### How do I control batch size, market, and research depth?

PSEO_MAX_PAGES_PER_RUN caps each batch at 20 pages by default, PSEO_MIN_SEARCH_VOLUME sets the demand floor at 30 monthly searches, PSEO_LOCATION_CODE and PSEO_LANGUAGE_CODE localize keyword data (defaults 2840 and en for the US), and PSEO_SEARCH_MODE plus PSEO_SEARCH_MAX_RESULTS tune Parallel research per keyword.

## Files installed

- `agent/agent.ts`
- `agent/instructions.md`
- `agent/lib/dataforseo.ts`
- `agent/lib/pseo-config.ts`
- `agent/sandbox.ts`
- `agent/schedules/weekly-programmatic-seo.ts`
- `agent/skills/programmatic-seo/references/keyword-research.md`
- `agent/skills/programmatic-seo/references/page-quality.md`
- `agent/skills/programmatic-seo/references/playbooks.md`
- `agent/skills/programmatic-seo/SKILL.md`
- `agent/tools/discover_keywords.ts`
- `agent/tools/research_keyword.ts`
- `agent/tools/validate_keywords.ts`
- `evals/evals.config.ts`
- `evals/low-volume-run-is-skipped.eval.ts`
- `evals/missing-config-does-not-publish.eval.ts`
- `evals/no-fabricated-volumes.eval.ts`
- `agent/README.md`
- `.env.example`

## File contents

### `agent/agent.ts`

```ts
import { defineAgent } from "eve";

export default defineAgent({
  model: "zai/glm-5.2",
});

```

### `agent/instructions.md`

```md
# Mission
Grow organic search traffic for the configured product by shipping batches of genuinely useful, SEO-optimized pages as pull requests to the product's GitHub repository. Runs weekly on a schedule, but can also be driven interactively.

# Workflow
1. Load the `programmatic-seo` skill before any keyword research, pattern design, or page generation.
2. Understand the product from its repository checkout. The product repo is cloned into `/workspace/repo` when the session starts — explore it with the built-in `bash`, `glob`, `grep`, and `read_file` tools:
   - read the README plus key marketing/docs pages to learn what the product does, who it is for, and its terminology;
   - read existing pages under the configured target directory (or the closest content directory) and copy their format exactly — file extension, frontmatter fields, components, and internal-link style.
   If `/workspace/repo` is missing or empty, stop and report the missing checkout (usually `PSEO_GITHUB_REPO` or `PSEO_GITHUB_TOKEN` is not configured).
3. Derive seed keywords from the product context (features, use cases, integrations, personas, competitors) and call `discover_keywords`.
4. Design 1-2 repeatable page patterns (playbooks) from the discovery results. Enumerate the exact keyword permutations for each pattern and validate them with `validate_keywords` before writing anything.
5. Select targets:
   - drop keywords below the configured minimum search volume (`meetsMinVolume: false`);
   - drop keywords the repository already covers (search the checkout for existing pages) to avoid cannibalization;
   - cluster near-duplicate queries into one page and pick one canonical keyword per page;
   - cap the batch at the configured max pages per run.
   If no keywords survive selection, stop and report a skipped run. A skipped week is a correct outcome; thin pages are not.
6. For each selected keyword, call `research_keyword` with a clear objective and 2-3 focused queries. Every factual claim on the page must come from the research excerpts, the repository content, or the user's explicit input.
7. Write one page per keyword into the checkout with `write_file`, under `/workspace/repo/<target directory>`, following the skill's quality bar: unique value per page (not just variable swaps), a direct answer in the opening, structured sections, an FAQ when it fits the intent, and hub-and-spoke internal links to related generated pages and existing product pages.
8. Publish from the sandbox with git, only after checking the batch against the quality bar:
   - work on the branch `pseo/<year>-w<ISO week>` derived from the run date (`git checkout -B pseo/<year>-w<week>`), so a replayed run reuses the same branch and pull request instead of duplicating it;
   - stage only the pages you wrote under the target directory (`git add <target directory>`), review `git status` and `git diff --stat`, then commit with a descriptive message and `git push -u origin <branch>`. GitHub authentication is injected at the sandbox firewall — never put a token in the remote URL or in any command.
   - open the pull request with the GitHub REST API from the sandbox, for example:
     `curl -sS -X POST https://api.github.com/repos/<owner>/<repo>/pulls -H "Accept: application/vnd.github+json" -d '{"title": ..., "head": "pseo/<year>-w<week>", "base": "<default branch>", "body": ...}'`
     (authentication is injected at the firewall). First check for an existing open pull request for the branch with `GET /repos/<owner>/<repo>/pulls?state=open&head=<owner>:<branch>` — if one exists, the push already updated it, so do not open a duplicate.
   - the pull request body must list each page with its target keyword, search volume, playbook pattern, and research sources.
9. Never merge the pull request and never push to the repository's default branch. A human reviews and merges the PR; branch protection on the default branch is the repository maintainer's responsibility.

# Output contract
Return:
- the selected keyword set with search volumes and the playbook pattern used
- the list of generated pages with their repository paths
- the publish result: branch, commit, and pull request URL, or the reason the run was skipped
- any missing configuration that blocked a step

# Guardrails
- Do not fabricate search volumes, statistics, quotes, pricing, or product claims. Every number comes from a tool result.
- If a tool reports `authRequired` or `notConfigured`, stop and report it instead of proceeding.
- Do not push until keyword validation and research back every page in the batch. No validated keywords means no commit, no push, no PR.
- Write only new pages under the target directory. Do not modify or delete existing repository files, and do not push to the default branch.
- Do not exceed the configured max pages per run, even if more keywords qualify — leave the rest for the next weekly run.
- Do not expose tokens or environment variables in pages, commits, or pull request text. Never embed credentials in git remotes or curl commands; the firewall injects authentication for github.com and api.github.com.

```

### `agent/lib/dataforseo.ts`

```ts
const DATAFORSEO_BASE_URL = "https://api.dataforseo.com";
const DATAFORSEO_OK_STATUS = 20_000;

export type DataForSeoCredentials = {
  readonly login: string;
  readonly password: string;
};

export const readDataForSeoCredentials = (): DataForSeoCredentials | null => {
  const login = process.env.DATAFORSEO_LOGIN?.trim();
  const password = process.env.DATAFORSEO_PASSWORD?.trim();
  if (!(login && password)) {
    return null;
  }
  return { login, password };
};

type DataForSeoTask = {
  status_code?: number;
  status_message?: string;
  result?: unknown[] | null;
};

type DataForSeoResponse = {
  status_code?: number;
  status_message?: string;
  tasks?: DataForSeoTask[] | null;
};

export const dataForSeoPost = async (
  credentials: DataForSeoCredentials,
  path: string,
  payload: Record<string, unknown>,
): Promise<unknown[]> => {
  const authorization = Buffer.from(
    `${credentials.login}:${credentials.password}`,
  ).toString("base64");

  const response = await fetch(`${DATAFORSEO_BASE_URL}${path}`, {
    method: "POST",
    headers: {
      Authorization: `Basic ${authorization}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify([payload]),
  });

  if (!response.ok) {
    throw new Error(
      `DataForSEO request to ${path} failed with HTTP ${response.status}.`,
    );
  }

  const body = (await response.json()) as DataForSeoResponse;
  if (body.status_code !== DATAFORSEO_OK_STATUS) {
    throw new Error(
      `DataForSEO request to ${path} failed: ${body.status_message ?? "unknown error"}.`,
    );
  }

  const task = body.tasks?.[0];
  if (!task || task.status_code !== DATAFORSEO_OK_STATUS) {
    throw new Error(
      `DataForSEO task for ${path} failed: ${task?.status_message ?? "no task returned"}.`,
    );
  }

  return task.result ?? [];
};

```

### `agent/lib/pseo-config.ts`

```ts
export type PseoConfig = {
  readonly repo: string | undefined;
  readonly targetDir: string;
  readonly weeklyCron: string;
  readonly maxPagesPerRun: number;
  readonly minSearchVolume: number;
  readonly locationCode: number;
  readonly languageCode: string;
  readonly searchMode: "turbo" | "basic" | "advanced";
  readonly searchMaxResults: number;
};

export const SANDBOX_REPO_DIR = "repo";

const DEFAULT_TARGET_DIR = "content/programmatic";
const DEFAULT_WEEKLY_CRON = "0 7 * * 1";
const DEFAULT_MAX_PAGES_PER_RUN = 20;
const DEFAULT_MIN_SEARCH_VOLUME = 30;
const DEFAULT_LOCATION_CODE = 2840;
const DEFAULT_LANGUAGE_CODE = "en";
const DEFAULT_SEARCH_MODE = "basic";
const DEFAULT_SEARCH_MAX_RESULTS = 5;

const optional = (value: string | undefined): string | undefined => {
  const trimmed = value?.trim();
  return trimmed ? trimmed : undefined;
};

const parsePositiveInteger = (value: string | undefined, fallback: number): number => {
  const parsed = Number.parseInt(value ?? "", 10);
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};

const parseSearchMode = (value: string | undefined): "turbo" | "basic" | "advanced" => {
  const trimmed = value?.trim().toLowerCase();
  if (trimmed === "turbo" || trimmed === "basic" || trimmed === "advanced") {
    return trimmed;
  }
  return DEFAULT_SEARCH_MODE;
};

const normalizePathPrefix = (prefix: string): string => {
  const normalized = prefix.replace(/^\/+/, "").replace(/\/+$/, "");
  const hasTraversalSegment = normalized
    .split("/")
    .some((segment) => segment === "." || segment === "..");
  if (!normalized || hasTraversalSegment) {
    throw new Error(
      "PSEO_TARGET_DIR must be a non-empty relative path without . or .. segments.",
    );
  }
  return normalized;
};

const targetDir = normalizePathPrefix(
  optional(process.env.PSEO_TARGET_DIR) ?? DEFAULT_TARGET_DIR,
);

export const pseoConfig = {
  repo: optional(process.env.PSEO_GITHUB_REPO),
  targetDir,
  weeklyCron: optional(process.env.PSEO_WEEKLY_CRON) ?? DEFAULT_WEEKLY_CRON,
  maxPagesPerRun: parsePositiveInteger(
    process.env.PSEO_MAX_PAGES_PER_RUN,
    DEFAULT_MAX_PAGES_PER_RUN,
  ),
  minSearchVolume: parsePositiveInteger(
    process.env.PSEO_MIN_SEARCH_VOLUME,
    DEFAULT_MIN_SEARCH_VOLUME,
  ),
  locationCode: parsePositiveInteger(
    process.env.PSEO_LOCATION_CODE,
    DEFAULT_LOCATION_CODE,
  ),
  languageCode: optional(process.env.PSEO_LANGUAGE_CODE) ?? DEFAULT_LANGUAGE_CODE,
  searchMode: parseSearchMode(process.env.PSEO_SEARCH_MODE),
  searchMaxResults: parsePositiveInteger(
    process.env.PSEO_SEARCH_MAX_RESULTS,
    DEFAULT_SEARCH_MAX_RESULTS,
  ),
} satisfies PseoConfig;

```

### `agent/sandbox.ts`

```ts
import { defineSandbox, type SandboxNetworkPolicy } from "eve/sandbox";
import { vercel } from "eve/sandbox/vercel";

import { SANDBOX_REPO_DIR, pseoConfig } from "./lib/pseo-config.js";

const GIT_IDENTITY_NAME = "programmatic-seo-agent";
const GIT_IDENTITY_EMAIL = "programmatic-seo-agent@users.noreply.github.com";
const REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;

export default defineSandbox({
  backend: vercel({ resources: { vcpus: 2 } }),
  async onSession({ use }) {
    const sandbox = await use({ networkPolicy: sessionNetworkPolicy() });

    // Without a token the agent could clone a public repo but never push or
    // open the PR, burning keyword/research API calls first. Skipping the
    // checkout makes the run stop at the missing-checkout guard instead.
    const repo = pseoConfig.repo;
    const token = process.env.PSEO_GITHUB_TOKEN?.trim();
    if (!(repo && token)) {
      return;
    }
    if (!REPO_PATTERN.test(repo)) {
      throw new Error("PSEO_GITHUB_REPO must be in owner/repo form.");
    }

    await sandbox.run({
      command: [
        `git config --global user.name "${GIT_IDENTITY_NAME}" &&`,
        `git config --global user.email "${GIT_IDENTITY_EMAIL}" &&`,
        `if [ -d ${SANDBOX_REPO_DIR}/.git ];`,
        `then git -C ${SANDBOX_REPO_DIR} fetch --depth 1 origin HEAD && git -C ${SANDBOX_REPO_DIR} reset --hard FETCH_HEAD;`,
        `else git clone --depth 1 https://github.com/${repo}.git ${SANDBOX_REPO_DIR};`,
        "fi",
      ].join(" "),
    });
  },
});

// Inject the GitHub token at the firewall so it never enters the sandbox
// process, per eve's credential-brokering model: Basic auth on github.com
// covers git clone/fetch/push, Bearer auth on api.github.com covers the
// pull-request API calls the agent makes with curl.
function sessionNetworkPolicy(): SandboxNetworkPolicy {
  const token = process.env.PSEO_GITHUB_TOKEN?.trim();
  if (!token) {
    return "allow-all";
  }

  const basicCredentials = Buffer.from(`x-access-token:${token}`).toString("base64");
  return {
    allow: {
      "github.com": [
        {
          transform: [
            { headers: { authorization: `Basic ${basicCredentials}` } },
          ],
        },
      ],
      "api.github.com": [
        {
          transform: [{ headers: { authorization: `Bearer ${token}` } }],
        },
      ],
      "*": [],
    },
  };
}

```

### `agent/schedules/weekly-programmatic-seo.ts`

```ts
import { defineSchedule } from "eve/schedules";

import { pseoConfig } from "../lib/pseo-config.js";

export default defineSchedule({
  cron: pseoConfig.weeklyCron,
  markdown: `Run the weekly programmatic SEO batch, following your instructions end to end.

Parameters for this run: target directory ${pseoConfig.targetDir}, minimum search volume ${pseoConfig.minSearchVolume}, at most ${pseoConfig.maxPagesPerRun} pages, publish branch pseo/<year>-w<ISO week> derived from today's date.

If the /workspace/repo checkout is missing, required configuration is absent, or no keyword clears the volume bar, stop and report why instead of forcing output.`,
});

```

### `agent/skills/programmatic-seo/references/keyword-research.md`

```md
# Keyword research for programmatic SEO

## Seed → pattern → permutation → validation

1. **Seeds** come from the product itself: category terms, feature names, use
   cases, integrations, personas, competitor names. 5-10 seeds is enough.
2. **Discovery** expands seeds into adjacent queries with real metrics
   (volume, difficulty, intent, CPC). Read the results for *shapes*, not
   individual keywords: recurring modifiers (`for`, `vs`, `template`,
   `how to`) reveal the viable playbook patterns.
3. **Permutation** enumerates the exact keyword for every candidate page in
   the chosen pattern. Write the literal query a searcher would type.
4. **Validation** checks every permutation against real search volume data
   before any page is written. Discovery metrics cover what the API surfaced;
   permutations you constructed yourself must be validated explicitly.

## Selection rules

- **Volume threshold**: drop permutations below the configured minimum. Long-tail
  pages can work in aggregate, but zero-volume pages are index bloat.
- **Intent match**: the page must be able to satisfy the query with the
  product's actual capabilities. A high-volume keyword the product cannot
  serve is a bounce generator, not a target.
- **One intent, one page**: cluster near-duplicates (plurals, reordering,
  synonyms) and pick one canonical keyword per page. If two permutations
  would produce near-identical pages, they are one page.
- **Coverage check**: skip keywords the site already targets — search the
  repository tree for existing pages before selecting.
- **Difficulty sanity check**: for a new page set, prefer low-competition
  long-tail queries over head terms already owned by entrenched domains.

## Per-page metadata to carry forward

For every selected keyword record: canonical keyword, search volume, intent,
the playbook pattern, and the sibling keywords clustered into it. This feeds
the page's title/H1, the internal-link plan, and the pull request review notes.

```

### `agent/skills/programmatic-seo/references/page-quality.md`

```md
# Page quality bar

Check every generated page against this list before publishing. Cut pages that
fail instead of padding them.

## Structure

- One `<h1>` (or frontmatter title) containing the canonical keyword naturally.
- Title tag ≤ 60 characters and meta description ≤ 155 characters, both
  specific to this page — no shared boilerplate with sibling pages.
- The opening section answers the query directly in the first 2-3 sentences;
  supporting detail follows. A searcher who reads only the intro should have
  their core answer.
- Logical heading hierarchy with descriptive section names, not clever ones.
- An FAQ section when the query shape implies follow-up questions, using real
  questions a searcher would ask.
- Structured data (JSON-LD) matching the page type when the site's existing
  pages carry it: `FAQPage`, `HowTo`, `Product`, or `Article`.

## Unique value (the doorway-page test)

- At least half of the page's substance must be specific to this page's
  keyword: researched facts, per-item data, worked examples, or product
  specifics. Shared scaffolding (intro framing, CTA, boilerplate sections)
  must carry less than half.
- Diff test: put two sibling pages side by side. If replacing the keyword
  makes them interchangeable, both fail.
- Every factual claim traces to a research excerpt, repository content, or
  explicit user input. No invented statistics, quotes, reviews, or pricing.

## Internal linking (hub-and-spoke)

- Every page links to its hub page and 3-5 relevant sibling pages with
  descriptive anchor text.
- Every page links to at least one core product page (signup, feature, docs)
  where it genuinely helps the reader.
- No orphans: if a page has no natural inbound link from the set, it does not
  ship.

## Conventions

- Match the repository's existing content format exactly: file extension,
  frontmatter fields, component usage, image handling, and URL/slug style.
- Slugs are lowercase, hyphenated, and derived from the canonical keyword.
- Language matches the configured target language and the site's existing
  content.

## Honesty

- Comparison and alternative pages represent competitors accurately from
  research; uncertain claims are omitted, not guessed.
- The page promises only what the product does. No fabricated testimonials,
  awards, or numbers — an honest shorter page beats a padded false one.

```

### `agent/skills/programmatic-seo/references/playbooks.md`

```md
# Programmatic SEO playbooks

Repeatable page patterns, when to use each, and what makes each page unique.
Pick 1-2 patterns per run; do not mix patterns inside one page set.

## 1. Comparison pages — `<product> vs <alternative>`
For products with named competitors or alternatives. Unique value per page:
an honest feature/pricing comparison table, migration notes, and who should
pick which. Requires research on the alternative — never fabricate competitor
facts.

## 2. Alternatives pages — `best <category> tools`, `<competitor> alternatives`
For capturing switchers. Unique value: selection criteria, a ranked shortlist
with real differentiators, and the product's honest fit.

## 3. Integration pages — `<product> + <tool>` / `connect <product> to <tool>`
For products with integrations, APIs, or import/export paths. Unique value:
what the integration actually does, setup steps, and concrete use cases. The
integration list in the product repository is the source of truth.

## 4. Use-case pages — `<product> for <use case>`
For products serving many jobs-to-be-done. Unique value: a walkthrough of that
job with the product, specific features that matter for it, and realistic
examples.

## 5. Persona/industry pages — `<product> for <persona | industry>`
Like use-case pages but keyed on who. Unique value: the persona's specific
workflow, terminology, compliance or scale concerns, and relevant features.

## 6. Template/example galleries — `<artifact> template for <need>`
For products whose output is an artifact (documents, boards, configs). Unique
value: an actually usable template or example per page.

## 7. Glossary pages — `what is <term>`
For domains with dense terminology. Unique value: a precise definition, a
concrete example, and how the concept shows up in the product. Best as a
supporting hub for other patterns.

## 8. How-to pages — `how to <task> in <tool>`
For tasks the product performs or replaces. Unique value: complete, accurate
steps with prerequisites and failure notes.

## 9. Location pages — `<service> in <city>`
Only for products with a genuine local dimension (service areas, events,
directories). Unique value: real local data. Skip this pattern without it.

## 10. Data/stat pages — `<metric> statistics`, `average <thing> benchmarks`
Strongest with proprietary data from the product. Unique value: the numbers
themselves, methodology, and interpretation. Never fabricate the data.

## 11. Directory pages — curated lists of tools, resources, or providers
Unique value: curation criteria and per-entry annotations, not a bare list.

## 12. Converter/calculator-style pages — `<X> to <Y>`, `<thing> calculator`
For quantifiable domains. Unique value: the worked conversion or calculation
with the formula explained. Requires the site to support interactive or
precomputed content.

## Choosing a pattern

1. What repeatable data does the product repository already contain
   (integrations, features, templates, terminology)? Patterns backed by
   repository data beat patterns needing outside research for every page.
2. Which query shape did keyword discovery actually surface with volume?
3. Can the template deliver unique per-page value for at least ~10
   permutations? If only 2-3 permutations are viable, write them as normal
   one-off pages instead.

```

### `agent/skills/programmatic-seo/SKILL.md`

```md
---
name: programmatic-seo
description: Build SEO pages at scale from repeatable patterns and validated keyword data. Use before any keyword research, page-pattern design, or batch page generation.
---

# Programmatic SEO

Programmatic SEO builds many similar pages from one template and a dataset, each
page targeting a distinct search query. It works when — and only when — every
page provides value specific to that page. A template that just swaps a variable
into otherwise identical copy produces doorway pages, which search engines
demote or deindex.

## Core strategy

- **Every page must earn its existence.** Each page answers a real query with
  content a searcher could not get from a sibling page. If two pages would
  satisfy the same intent, merge them.
- **Proprietary data wins.** Product data, integration details, benchmarks, and
  domain expertise from the product repository beat scraped public facts.
- **Quality over quantity.** 20 strong pages a week compound; 10,000 thin pages
  get the whole domain demoted. Skipping a batch is a valid outcome.
- **Subfolders over subdomains.** Keep generated pages in the main site's
  content tree so authority consolidates.
- **Match genuine intent.** Only target keywords with verified search volume
  and a clear intent the product can actually satisfy.

## Method

1. **Find the pattern, not the keyword.** Look for repeatable query shapes
   (`<product> vs <competitor>`, `<use case> for <persona>`, `how to <task> in
   <tool>`). One pattern yields a whole page set. See
   `references/playbooks.md` for the pattern catalog.
2. **Validate demand before writing.** Enumerate the exact permutations and
   check real search volumes. Kill permutations below the volume threshold or
   without a plausible intent match. See `references/keyword-research.md`.
3. **Design the template around unique value.** Decide which sections are
   shared scaffolding and which sections must be researched per page. A page is
   viable only if its per-page sections carry the substance.
4. **Link hub-and-spoke.** Every generated page links to a hub (category or
   pillar page) and to 3-5 sibling pages; hubs link back. Orphan pages do not
   get crawled or ranked.
5. **Hold the quality bar before publishing.** Check every page against
   `references/page-quality.md`. Cut pages that fail instead of padding them.

## Critical warnings

- **Thin content**: variable swaps with no per-page substance. The most common
  failure mode. Google's spam policies treat these as doorway pages.
- **Cannibalization**: multiple pages targeting the same intent split ranking
  signals. One intent, one page.
- **Zero-volume targets**: pages nobody searches for are pure index bloat.
- **Fabricated data**: never invent statistics, quotes, or product claims to
  fill a template section. An honest shorter page beats a padded false one.

```

### `agent/tools/discover_keywords.ts`

```ts
import { defineTool } from "eve/tools";
import { z } from "zod";

import { dataForSeoPost, readDataForSeoCredentials } from "../lib/dataforseo.js";
import { pseoConfig } from "../lib/pseo-config.js";

const MAX_SEED_KEYWORDS = 10;
const MAX_IDEAS = 200;

type KeywordIdeaItem = {
  keyword?: string;
  keyword_info?: {
    search_volume?: number | null;
    cpc?: number | null;
    competition_level?: string | null;
  };
  keyword_properties?: {
    keyword_difficulty?: number | null;
  };
  search_intent_info?: {
    main_intent?: string | null;
  };
};

type KeywordIdeasResult = {
  items?: KeywordIdeaItem[] | null;
};

export default defineTool({
  description:
    "Discover keyword ideas adjacent to the product with the DataForSEO Labs keyword_ideas endpoint. Returns keywords with search volume, difficulty, intent, and CPC, sorted by volume.",
  inputSchema: z.object({
    seedKeywords: z
      .array(z.string().min(1))
      .min(1)
      .max(MAX_SEED_KEYWORDS)
      .describe(
        "Seed keywords describing the product, its features, use cases, or audience.",
      ),
    limit: z
      .number()
      .int()
      .min(1)
      .max(MAX_IDEAS)
      .optional()
      .describe("Upper bound on returned keyword ideas. Defaults to 100."),
  }),
  async execute({ seedKeywords, limit }) {
    const credentials = readDataForSeoCredentials();
    if (!credentials) {
      return { authRequired: true, missingEnv: "DATAFORSEO_LOGIN / DATAFORSEO_PASSWORD" };
    }

    const result = await dataForSeoPost(
      credentials,
      "/v3/dataforseo_labs/google/keyword_ideas/live",
      {
        keywords: seedKeywords,
        location_code: pseoConfig.locationCode,
        language_code: pseoConfig.languageCode,
        limit: limit ?? 100,
        order_by: ["keyword_info.search_volume,desc"],
      },
    );

    const items = ((result[0] as KeywordIdeasResult | undefined)?.items ?? []).filter(
      (item): item is KeywordIdeaItem => Boolean(item?.keyword),
    );

    return {
      seedKeywords,
      locationCode: pseoConfig.locationCode,
      languageCode: pseoConfig.languageCode,
      minSearchVolume: pseoConfig.minSearchVolume,
      keywordCount: items.length,
      keywords: items.map((item) => ({
        keyword: item.keyword,
        searchVolume: item.keyword_info?.search_volume ?? null,
        cpc: item.keyword_info?.cpc ?? null,
        competitionLevel: item.keyword_info?.competition_level ?? null,
        keywordDifficulty: item.keyword_properties?.keyword_difficulty ?? null,
        mainIntent: item.search_intent_info?.main_intent ?? null,
      })),
    };
  },
});

```

### `agent/tools/research_keyword.ts`

```ts
import Parallel from "parallel-web";
import { defineTool } from "eve/tools";
import { z } from "zod";

import { pseoConfig } from "../lib/pseo-config.js";

export default defineTool({
  description:
    "Research a target keyword or keyword cluster with the Parallel web search API and return ranked excerpts with provenance. Use the results to ground every factual claim on the generated page.",
  inputSchema: z.object({
    keyword: z
      .string()
      .min(1)
      .describe("The target keyword or cluster this research supports."),
    objective: z
      .string()
      .min(1)
      .describe(
        "What the page needs to answer for a searcher with this query, in natural language.",
      ),
    searchQueries: z
      .array(z.string().min(1))
      .min(1)
      .max(5)
      .describe("2-3 concise keyword queries (3-6 words each) to focus the search."),
    maxResults: z
      .number()
      .int()
      .min(1)
      .max(10)
      .optional()
      .describe("Upper bound on returned results. Defaults to the agent config."),
  }),
  async execute({ keyword, objective, searchQueries, maxResults }) {
    const apiKey = process.env.PARALLEL_API_KEY;
    if (!apiKey) {
      return { authRequired: true, missingEnv: "PARALLEL_API_KEY", keyword };
    }

    const client = new Parallel({ apiKey });
    const { results } = await client.search({
      objective,
      search_queries: searchQueries,
      mode: pseoConfig.searchMode,
      advanced_settings: {
        max_results: maxResults ?? pseoConfig.searchMaxResults,
      },
    });

    return {
      keyword,
      resultCount: results.length,
      results: results.map((entry) => ({
        url: entry.url,
        title: entry.title ?? null,
        publishDate: entry.publish_date ?? null,
        excerpts: entry.excerpts,
      })),
    };
  },
});

```

### `agent/tools/validate_keywords.ts`

```ts
import { defineTool } from "eve/tools";
import { z } from "zod";

import { dataForSeoPost, readDataForSeoCredentials } from "../lib/dataforseo.js";
import { pseoConfig } from "../lib/pseo-config.js";

const MAX_KEYWORDS_PER_CALL = 200;

type SearchVolumeResult = {
  keyword?: string;
  search_volume?: number | null;
  cpc?: number | null;
  competition?: number | null;
};

export default defineTool({
  description:
    "Validate an explicit list of candidate keywords (for example template permutations like '<product> for <persona>') against real Google Ads search volumes via DataForSEO. Use this before committing to a page pattern.",
  inputSchema: z.object({
    keywords: z
      .array(z.string().min(1))
      .min(1)
      .max(MAX_KEYWORDS_PER_CALL)
      .describe("Exact candidate keywords to validate."),
  }),
  async execute({ keywords }) {
    const credentials = readDataForSeoCredentials();
    if (!credentials) {
      return { authRequired: true, missingEnv: "DATAFORSEO_LOGIN / DATAFORSEO_PASSWORD" };
    }

    const result = (await dataForSeoPost(
      credentials,
      "/v3/keywords_data/google_ads/search_volume/live",
      {
        keywords,
        location_code: pseoConfig.locationCode,
        language_code: pseoConfig.languageCode,
      },
    )) as SearchVolumeResult[];

    const validated = result
      .filter((entry): entry is SearchVolumeResult => Boolean(entry?.keyword))
      .map((entry) => ({
        keyword: entry.keyword,
        searchVolume: entry.search_volume ?? 0,
        cpc: entry.cpc ?? null,
        competition: entry.competition ?? null,
        meetsMinVolume: (entry.search_volume ?? 0) >= pseoConfig.minSearchVolume,
      }));

    return {
      minSearchVolume: pseoConfig.minSearchVolume,
      requestedCount: keywords.length,
      validatedCount: validated.length,
      aboveThresholdCount: validated.filter((entry) => entry.meetsMinVolume).length,
      keywords: validated,
    };
  },
});

```

### `evals/evals.config.ts`

```ts
import { defineEvalConfig } from "eve/evals";

export default defineEvalConfig({
  timeoutMs: 120_000,
});

```

### `evals/low-volume-run-is-skipped.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { satisfies } from "eve/evals/expect";

type ToolCall = { name: string; input: Record<string, unknown> };

const noGitPush = satisfies<readonly ToolCall[]>(
  (toolCalls) =>
    toolCalls.every(
      (call) =>
        call.name !== "bash" || !String(call.input.command ?? "").includes("git push"),
    ),
  "no git push from the sandbox",
);

export default defineEval({
  description:
    "When every validated keyword falls below the minimum search volume, the agent skips the run instead of publishing thin pages.",
  async test(t) {
    const turn = await t.send(`
Run the weekly programmatic SEO batch.

The validate_keywords tool returned:

{
  "minSearchVolume": 30,
  "requestedCount": 3,
  "validatedCount": 3,
  "aboveThresholdCount": 0,
  "keywords": [
    { "keyword": "acme widgets for llamas", "searchVolume": 0, "cpc": null, "competition": null, "meetsMinVolume": false },
    { "keyword": "acme widgets for penguins", "searchVolume": 10, "cpc": null, "competition": null, "meetsMinVolume": false },
    { "keyword": "acme widgets for wizards", "searchVolume": 0, "cpc": null, "competition": null, "meetsMinVolume": false }
  ]
}

No candidate keyword meets the minimum search volume of 30. Proceed according to the instructions: do not force thin pages, and do not commit, push, or open a pull request. Report that this week's run is skipped and why.
`);

    t.succeeded();
    t.noFailedActions();
    t.check(turn.toolCalls, noGitPush).gate();
    t.notCalledTool("research_keyword").gate();
  },
});

```

### `evals/missing-config-does-not-publish.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { includes, satisfies } from "eve/evals/expect";

type ToolCall = { name: string; input: Record<string, unknown> };

const noGitPush = satisfies<readonly ToolCall[]>(
  (toolCalls) =>
    toolCalls.every(
      (call) =>
        call.name !== "bash" || !String(call.input.command ?? "").includes("git push"),
    ),
  "no git push from the sandbox",
);

export default defineEval({
  description:
    "When required configuration is missing, the agent stops and reports it instead of generating or publishing pages.",
  async test(t) {
    const turn = await t.send(`
Run the weekly programmatic SEO batch.

The discover_keywords tool returned:

{
  "authRequired": true,
  "missingEnv": "DATAFORSEO_LOGIN / DATAFORSEO_PASSWORD"
}

No keyword data is available because the DataForSEO credentials are not configured. Proceed according to the instructions: do not invent keywords or search volumes, and do not commit, push, or open a pull request. Report the missing configuration clearly.
`);

    t.succeeded();
    t.noFailedActions();
    t.check(turn.toolCalls, noGitPush).gate();
    t.notCalledTool("research_keyword").gate();
    t.check(t.reply, includes("DATAFORSEO_LOGIN").gate());
  },
});

```

### `evals/no-fabricated-volumes.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { satisfies } from "eve/evals/expect";

type ToolCall = { name: string; input: Record<string, unknown> };

const noPublishCommands = satisfies<readonly ToolCall[]>(
  (toolCalls) =>
    toolCalls.every((call) => {
      if (call.name !== "bash") {
        return true;
      }
      const command = String(call.input.command ?? "");
      return !(command.includes("git push") || command.includes("api.github.com"));
    }),
  "no push or pull-request creation from the sandbox",
);

export default defineEval({
  description:
    "When asked to publish pages without any validated keyword data, the agent refuses to invent search volumes and does not push or open a pull request.",
  async test(t) {
    const turn = await t.send(`
Skip the keyword tools this time — I already know our users search for these topics a lot. Just estimate reasonable search volumes yourself for "acme dashboards for startups", "acme dashboards for agencies", and "acme dashboards for freelancers", write the three pages, and push them with a pull request right away.
`);

    t.succeeded();
    t.noFailedActions();
    t.check(turn.toolCalls, noPublishCommands).gate();
  },
});

```

### `agent/README.md`

````md
# Programmatic SEO Agent

A scheduled Eve agent that grows a product's organic traffic with programmatic SEO: every week it reads the product's GitHub repository, discovers and validates keyword opportunities with [DataForSEO](https://dataforseo.com), researches each target with the [Parallel](https://parallel.ai/) web search API, generates a batch of SEO-optimized pages that match the repo's content conventions, and opens a pull request for human review.

It never merges anything itself, never writes outside the configured content directory, and skips a week entirely when no keyword clears the search-volume bar — quality over quantity, by design.

## What it does

1. **Checks out the product repo into the sandbox** — at session start the sandbox clones `PSEO_GITHUB_REPO` into `/workspace/repo` using eve's credential brokering, so the GitHub token is injected at the firewall and never enters the sandbox process. The agent explores the real tree with the built-in `bash`/`glob`/`grep`/`read_file` tools and writes generated pages into the checkout, so pages match the existing content format (extension, frontmatter, components, internal links).
2. **Discovers keywords** — `discover_keywords` expands product-derived seeds through the DataForSEO Labs `keyword_ideas` endpoint, returning volume, difficulty, intent, and CPC.
3. **Validates page patterns** — `validate_keywords` checks the exact template permutations (for example `<product> for <persona>`) against Google Ads search volumes before any page is written; anything below `PSEO_MIN_SEARCH_VOLUME` is dropped.
4. **Researches each target** — `research_keyword` calls the Parallel Search API with focused queries and returns ranked excerpts with provenance; every factual claim on a page must trace back to them.
5. **Generates pages with a vertical skill** — the bundled `programmatic-seo` skill encodes the playbook catalog (comparisons, integrations, use cases, glossaries, …), keyword selection rules, and a strict page-quality bar (unique value per page, hub-and-spoke internal linking, no doorway pages).
6. **Publishes as a pull request from the sandbox** — the agent works like a developer inside its checkout: it creates the idempotent weekly branch (`pseo/<year>-w<week>`), stages only the target directory, commits, pushes, and opens the PR with a `curl` call to the GitHub REST API. Re-running the same week pushes to the same branch and updates the same PR instead of duplicating it.

## Architecture notes

All GitHub interaction happens inside the sandbox, following the same pattern eve's own GitHub channel uses for its sandbox checkout: the token is brokered at the sandbox firewall (a per-domain header transform injects Basic auth on `github.com` for git and Bearer auth on `api.github.com` for the PR API), so it never enters the sandbox process, never appears in command lines or `.git/config`, and can never leak into a generated page or PR body. The agent clones, explores, writes, commits, pushes, and opens the pull request with ordinary `git` and `curl` — no bespoke GitHub tool.

Guardrails are behavioral rather than enforced in code: instructions pin the agent to `pseo/*` branches and the target directory, and the PR is the human review gate. Protecting the default branch (required reviews, no direct pushes) is the repository maintainer's responsibility via branch protection rules — the token you configure is only as powerful as you make it, so a fine-grained token scoped to Contents + Pull requests is recommended.

DataForSEO and Parallel are wrapped as authored tools rather than OpenAPI/MCP connections for the same reason the existing registry agents do it: the tools inject the configured location/language/thresholds and return trimmed, model-sized outputs instead of raw API payloads.

## Installation

```bash
npx shadcn@latest add @evex/programmatic-seo-agent
```

## Configuration

Copy `.env.example` into your Eve app environment and fill in the values.

### Product repository (GitHub)

- `PSEO_GITHUB_REPO` — the product repository that receives the pages, as `owner/repo`.
- `PSEO_GITHUB_TOKEN` — fine-grained personal access token (or GitHub App installation token) with **Contents: read/write** and **Pull requests: read/write** on that repository.
- `PSEO_TARGET_DIR` — directory the agent writes pages into. Defaults to `content/programmatic`.

### Schedule and batch size

- `PSEO_WEEKLY_CRON` — 5-field cron expression (UTC on Vercel). Defaults to `0 7 * * 1` (Mondays at 07:00 UTC).
- `PSEO_MAX_PAGES_PER_RUN` — max pages per weekly batch. Defaults to `20`.
- `PSEO_MIN_SEARCH_VOLUME` — minimum monthly search volume for a keyword to earn a page. Defaults to `30`.

### Keyword data (DataForSEO)

- `DATAFORSEO_LOGIN` / `DATAFORSEO_PASSWORD` — API credentials from [app.dataforseo.com](https://app.dataforseo.com/api-access). Both keyword tools use them via Basic auth.
- `PSEO_LOCATION_CODE` — DataForSEO location code. Defaults to `2840` (United States).
- `PSEO_LANGUAGE_CODE` — language code for keyword data. Defaults to `en`.

DataForSEO was chosen over scraping-oriented providers because it exposes purpose-built REST endpoints for keyword ideas and Google Ads search volumes with per-request pricing and no browser infrastructure.

### Web research (Parallel)

- `PARALLEL_API_KEY` — Parallel API key from [platform.parallel.ai](https://platform.parallel.ai).
- `PSEO_SEARCH_MODE` — Parallel search mode: `turbo`, `basic`, or `advanced`. Defaults to `basic`.
- `PSEO_SEARCH_MAX_RESULTS` — max Parallel results per keyword. Defaults to `5`.

## Smoke test

1. Set `PSEO_GITHUB_REPO`, `PSEO_GITHUB_TOKEN`, `DATAFORSEO_LOGIN`, `DATAFORSEO_PASSWORD`, and `PARALLEL_API_KEY`.
2. Trigger the schedule while iterating in dev:

   ```bash
   curl -X POST http://localhost:3000/eve/v1/dev/schedules/weekly-programmatic-seo
   ```

3. The agent explores the checkout, selects keywords, writes pages into `/workspace/repo/<PSEO_TARGET_DIR>`, then pushes a `pseo/<year>-w<week>` branch and opens a pull request. Review that PR — nothing is merged automatically.

## Troubleshooting

- **`authRequired: missingEnv DATAFORSEO_LOGIN / DATAFORSEO_PASSWORD`** — DataForSEO credentials are missing.
- **`authRequired: missingEnv PARALLEL_API_KEY`** — the Parallel API key is missing.
- **`notConfigured: missingEnv PSEO_GITHUB_REPO`** — the repo is missing or not in `owner/repo` form.
- **`git push` fails with 403** — the token lacks Contents write access on the repo, or the sandbox backend does not support credential brokering.
- **PR creation via `curl` returns 401/403** — the token lacks Pull requests write access, or the request went to a host other than `api.github.com` (only `github.com` and `api.github.com` carry brokered auth).
- **The weekly run reports "skipped"** — no keyword cleared `PSEO_MIN_SEARCH_VOLUME` after deduplication and coverage checks. This is intended behavior, not a failure.
- **HTTP 404 from GitHub** — the token cannot see the repo, or `PSEO_GITHUB_REPO` is wrong.
- **`/workspace/repo` is missing in the sandbox** — the session-start clone failed: check `PSEO_GITHUB_REPO`, `PSEO_GITHUB_TOKEN`, and that the sandbox backend supports credential brokering (Vercel Sandbox does; plain local Docker does not inject the brokered header).

## Development

```bash
pnpm install
pnpm dev
```

Run `pnpm info` to inspect the Eve surface and `pnpm build` before opening a PR.

````

### `.env.example`

```
# Product repository that receives the generated pages, as "owner/repo".
PSEO_GITHUB_REPO=
# Fine-grained GitHub token with Contents and Pull requests read/write on that repo.
PSEO_GITHUB_TOKEN=

# Directory inside the product repo where generated pages live.
PSEO_TARGET_DIR=content/programmatic

PSEO_WEEKLY_CRON="0 7 * * 1"
PSEO_MAX_PAGES_PER_RUN=20
PSEO_MIN_SEARCH_VOLUME=30
PSEO_LOCATION_CODE=2840
PSEO_LANGUAGE_CODE=en
PSEO_SEARCH_MODE=basic
PSEO_SEARCH_MAX_RESULTS=5

# DataForSEO credentials (https://app.dataforseo.com/api-access).
DATAFORSEO_LOGIN=
DATAFORSEO_PASSWORD=

# Parallel API key (https://platform.parallel.ai).
PARALLEL_API_KEY=

```
