Ting Blueprint · 002

Build an SEO Automation System

A practical technical reference for discovering search opportunities, grounding bilingual content in approved evidence, validating every revision, publishing safely and monitoring performance.

Difficulty
Advanced
Estimated build time
5–10 days
Architecture type
Guardrailed workflow orchestration
Updated
July 2026
Language
English
On this page
Reference boundaries

What this system builds

A resumable workflow that evaluates a search opportunity, avoids duplicate intent, gathers attributable evidence, drafts English and Arabic content, applies deterministic and model-assisted gates, requests approval, verifies publication, then monitors the page. It uses fictional reference records and deliberately excludes autonomous publication without approval.

Included

  • Opportunity and existing-content decisions
  • Evidence and claim traceability
  • Bilingual brief and draft contracts
  • Human approval and publication verification

Not included

  • Ting’s internal SEO rules or thresholds
  • Private prompts, customer data or credentials
  • A guarantee of ranking or traffic
  • Unreviewed autonomous publishing
System design

Architecture and state machine

Treat the workflow as a state machine, not one long prompt. Every node reads and writes a typed state, and every gate can pause, retry or send work to review without losing provenance.

Visual workflow from search opportunity discovery through content and evidence checks, bilingual drafts, validation and approval, publishing and monitoring.
A simplified governed SEO workflow; the state diagrams and contracts below define the implementation behavior.
MermaidArchitecture and state machine
01Opportunity
02Existing Content
03Evidence
04Bilingual Draft
05Quality Gates
06Approval
07Publication
08Monitoring
View Mermaid source
flowchart LR
    A["Search Opportunity"] --> B["Eligibility & Existing Content"]
    B --> C["Evidence Research"]
    C --> D["Bilingual Brief & Draft"]
    D --> E["Claim, Quality & SEO Gates"]
    E --> F["Human Approval"]
    F --> G["Publish & Verify"]
    G --> H["Monitor & Lifecycle Action"]
MermaidJob state transitions
01Discovered
02Eligible
03Researched
04Drafted
05Validated
06Approved
07Published
08Monitoring
View Mermaid source
stateDiagram-v2
    [*] --> Discovered
    Discovered --> Eligible
    Eligible --> Researched
    Researched --> Drafted
    Drafted --> Validated
    Validated --> Approved
    Approved --> Published
    Published --> Monitoring
    Validated --> Drafted: gate failed
    Approved --> Drafted: changes requested
    Monitoring --> Researched: refresh
Educational choices

Reference stack

These named tools are one practical educational choice—not a statement about Ting’s production stack. Keep model access and traces server-side, and keep your content repository as the source of truth.

OpenRouter

Server-side reference model gateway for structured research, classification and drafting tasks.

Official documentation

LangChain

Model adapters, tools and schema-validated outputs for individual workflow nodes.

Official documentation

Web Hoster / Cloud Infrastructure

Runs the application server, worker and queue with durable state and injected secrets.

Start locally

Project structure and setup

Separate orchestration, content storage, adapters and evaluation data. This makes the critical gates testable without contacting external systems.

Project treeTEXT
src/
  seo/
    graph.ts
    state.ts
    nodes/
      discover-opportunity.ts
      inspect-existing-content.ts
      research-evidence.ts
      build-brief.ts
      draft-locales.ts
      validate-claims.ts
      validate-seo.ts
      request-approval.ts
      publish.ts
      monitor.ts
  content/
    repository.ts
    schemas.ts
  api/seo-jobs.ts
  evals/seo-cases.json
  tests/seo-workflow.test.ts
Install packagesSHELL
npm install @langchain/core @langchain/langgraph @openrouter/ai-sdk-provider ai langsmith zod

# Add your application server, CMS adapter and test runner.
# Keep every credential on the server.
.env.exampleENV
# .env.example — placeholders only
OPENROUTER_API_KEY=replace_with_server_side_secret
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
REFERENCE_MODEL_ID=choose_for_your_requirements
LANGSMITH_TRACING=true
LANGSMITH_API_KEY=replace_with_server_side_secret
LANGSMITH_PROJECT=seo-automation-reference
CONTENT_REPOSITORY_URL=replace_with_internal_endpoint
CONTENT_REPOSITORY_TOKEN=replace_with_server_side_secret
PUBLIC_SITE_ORIGIN=https://replace-with-your-domain.example
Contracts first

Opportunity, evidence and workflow state

Persist decisions and source IDs rather than relying on a chat transcript. State should be resumable, auditable and safe to correct.

Opportunity schemaTYPESCRIPT
import { z } from "zod";

export const Opportunity = z.object({
  id: z.string(),
  query: z.string().min(2),
  locale: z.enum(["en", "ar"]),
  intent: z.enum(["informational", "commercial", "navigational"]),
  existingUrls: z.array(z.string().url()),
  decision: z.enum(["create", "refresh", "merge", "reject"]),
  evidenceIds: z.array(z.string()),
  status: z.enum(["discovered", "researched", "drafted", "validated", "approved", "published", "monitoring"])
});
Evidence and claim schemasTYPESCRIPT
export const Evidence = z.object({
  id: z.string(),
  url: z.string().url(),
  publisher: z.string(),
  title: z.string(),
  retrievedAt: z.string().datetime(),
  excerpt: z.string(),
  supportsClaims: z.array(z.string()),
  authority: z.enum(["primary", "official", "secondary"]),
  locale: z.enum(["en", "ar"])
});

export const Claim = z.object({
  text: z.string(),
  evidenceIds: z.array(z.string()).min(1),
  status: z.enum(["supported", "needs_review", "rejected"])
});
Workflow stateTYPESCRIPT
type SeoState = {
  jobId: string;
  opportunity: Opportunity;
  evidence: Evidence[];
  brief?: ContentBrief;
  drafts: Partial<Record<"en" | "ar", Draft>>;
  gates: GateResult[];
  approval?: { by: string; at: string };
  publication?: { urls: string[]; verifiedAt: string };
  lifecycle?: "keep" | "refresh" | "merge" | "noindex";
  errors: Array<{ node: string; safeCode: string }>;
};
LangGraph workflowTYPESCRIPT
const workflow = new StateGraph(SeoState)
  .addNode("eligibility", inspectExistingContent)
  .addNode("research", researchEvidence)
  .addNode("brief", buildBrief)
  .addNode("draft", draftLocales)
  .addNode("claims", validateClaims)
  .addNode("quality", validateSeo)
  .addNode("approval", requestHumanApproval)
  .addNode("publish", publishAndVerify)
  .addConditionalEdges("eligibility", routeEligibility)
  .addEdge("research", "brief")
  .addEdge("brief", "draft")
  .addEdge("draft", "claims")
  .addConditionalEdges("claims", routeGateResult)
  .addConditionalEdges("quality", routeGateResult)
  .addConditionalEdges("approval", routeApproval)
  .addEdge("publish", END);

export const seoGraph = workflow.compile({ checkpointer });
Prevent cannibalization

Existing-content and clustering logic

Before drafting, compare locale, search intent, topic cluster and current coverage. The default action can be create, refresh, merge or reject; only create when the opportunity adds genuinely distinct value.

create

No strong page covers the same locale and intent.

refresh

One relevant page exists but coverage is partial or stale.

merge

Several pages compete for the same intent.

reject

Existing content is strong or the opportunity is ineligible.

Deterministic decisionTYPESCRIPT
export function decideAction(input: {
  opportunity: Opportunity;
  candidates: ExistingPage[];
}) {
  const sameIntent = input.candidates.filter((page) =>
    page.locale === input.opportunity.locale &&
    page.intent === input.opportunity.intent
  );

  if (sameIntent.some((page) => page.coverage === "strong")) return "reject";
  if (sameIntent.some((page) => page.coverage === "partial")) return "refresh";
  if (sameIntent.length > 1) return "merge";
  return "create";
}
Evidence before prose

Research and claim grounding

Store sources independently from the draft. Each factual claim carries evidence IDs, retrieval time and support status. Unsupported or stale claims block publication instead of being softened into plausible language.

Claim validationTYPESCRIPT
export function validateClaims(draft: Draft, evidence: Evidence[]) {
  const knownEvidence = new Map(evidence.map((item) => [item.id, item]));
  return draft.claims.map((claim) => {
    const sources = claim.evidenceIds.map((id) => knownEvidence.get(id)).filter(Boolean);
    return {
      ...claim,
      status: sources.length > 0 && sources.every(isFreshAndAllowed)
        ? "supported"
        : "needs_review"
    };
  });
}
  • Allow official and primary sources according to policy.
  • Persist source title, retrieval time and excerpt.
  • Distinguish an unavailable source from an unsupported claim.
  • Never allow the model to invent a URL or evidence ID.
Localize meaning

Bilingual brief and drafting

Create one shared evidence-backed brief, then produce locale-specific drafts. Arabic is a first-class Saudi-market document, not a literal English translation. Both versions must preserve the same supported facts and intent.

01

Shared brief

Intent, audience, evidence, boundaries and required links.

02

Two native drafts

Each locale is written for its readers under the same claim boundaries.

03

Locale pairing

Each version carries correct canonical and hreflang relationships.

04

Drift review

Compare claims, numbers and promises across both locales.

No silent publishing

Quality, SEO and publication gates

Use deterministic checks for metadata, links, schema, locale pairing, canonical URLs and duplicate intent. Use model-assisted checks only where judgment is needed, then require explicit approval before publication.

Gate result contractJSON
{
  "jobId": "seo-ref-0042",
  "status": "awaiting_approval",
  "decision": "refresh",
  "locales": ["en", "ar"],
  "gates": {
    "claims": "pass",
    "duplicateIntent": "pass",
    "internalLinks": "pass",
    "metadata": "pass",
    "localization": "pass"
  },
  "previewUrls": ["/preview/en", "/preview/ar"],
  "publishable": false
}
Every claim is supported
No duplicate intent
Valid title and description
Reachable internal links
Correct canonical and hreflang
Compatible structured data
Recorded human approval
Public URL verified
Content has a lifecycle

Monitoring, refresh, merge and noindex

After publication, verify the public response, canonical, indexability and locale alternates. Monitor meaningful search and engagement signals, then open a refresh, merge or noindex review instead of endlessly creating new pages.

keep

Content remains useful, accurate and distinct.

refresh

Evidence, coverage or performance needs an update.

merge

Multiple pages compete and should be consolidated.

noindex

The page no longer provides independent value.

Transparent failure

Application API and fallback behavior

Return an accepted job ID, persist every transition and use safe error codes. If research, model access or publishing is unavailable, pause the job. Never report a page as published until the public URL passes verification.

POST /api/seo-jobsTYPESCRIPT
export async function POST(request: Request) {
  const input = StartJobInput.parse(await request.json());
  const job = await repository.create(input);

  try {
    await seoGraph.invoke({ jobId: job.id, opportunity: input });
    return Response.json({ jobId: job.id, status: "started" }, { status: 202 });
  } catch (error) {
    await repository.failSafely(job.id, "WORKFLOW_UNAVAILABLE");
    return Response.json(
      { jobId: job.id, status: "paused", retryable: true },
      { status: 503 }
    );
  }
}
Regression before scale

Testing and evaluation matrix

Test node functions, state transitions and complete workflow fixtures. Maintain holdout cases for duplicates, unsupported claims, translation drift, public verification failures and lifecycle decisions.

Critical regression casesTYPESCRIPT
const cases = [
  ["strong existing page", "reject"],
  ["partial same-intent page", "refresh"],
  ["unsupported claim", "block_publish"],
  ["Arabic draft changes the claim", "block_publish"],
  ["CMS accepts but public URL is 404", "fail_verification"],
  ["traffic decays after publication", "refresh_review"]
];

test.each(cases)("%s", async (scenario, expected) => {
  expect(await runFixture(scenario)).toEqual(expected);
});
Decision accuracy

create / refresh / merge / reject matches the expected fixture.

Claim coverage

Share of factual claims linked to valid evidence.

Locale parity

No changed numbers, promises or meaning between versions.

Publication safety

Zero publications without approval and public verification.

Operational ownership

Deployment, observability and rollback

Run the application server on a Web Hoster / Cloud Infrastructure. Use durable state, injected secrets, bounded retries, queue visibility, health checks, trace retention rules and a documented owner for approvals and incidents.

Durable state

Persist jobs, transitions, evidence and approval outside process memory.

Bounded retries

Use idempotency, backoff and a dead-letter queue instead of open retries.

Observability

Track node latency, cost, gate failures and job age.

Rollback

Keep the prior version, document rollback and reverify the public URL.

Ownership

Assign approval, publishing and incident owners before launch.

Production boundary

Security and intentional exclusions

Apply least privilege to publishing credentials, sanitize imported text, restrict URLs, rate-limit job creation and redact logs. This Blueprint intentionally omits private ranking thresholds, proprietary prompts and exploitable production details.

Least privilege

Separate content read access from publishing approval.

Untrusted input

Sanitize HTML and source instructions before model use.

Server secrets

Never expose keys or traces to the browser.

Request boundaries

Limit job rate, size and duration.

Safe logs

Redact secrets, personal data and private content.

Human review

Do not automate away the approval gate.