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
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.

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"]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: refreshReference 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.
LangChain
Model adapters, tools and schema-validated outputs for individual workflow nodes.
LangGraph
Resumable state-machine orchestration, conditional gates and bounded retries.
LangSmith
Traces, evaluation datasets and regressions for model-assisted nodes.
Web Hoster / Cloud Infrastructure
Runs the application server, worker and queue with durable state and injected secrets.
Project structure and setup
Separate orchestration, content storage, adapters and evaluation data. This makes the critical gates testable without contacting external systems.
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.tsnpm 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.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.exampleOpportunity, 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.
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"])
});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"])
});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 }>;
};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 });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.
createNo strong page covers the same locale and intent.
refreshOne relevant page exists but coverage is partial or stale.
mergeSeveral pages compete for the same intent.
rejectExisting content is strong or the opportunity is ineligible.
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";
}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.
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.
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.
Shared brief
Intent, audience, evidence, boundaries and required links.
Two native drafts
Each locale is written for its readers under the same claim boundaries.
Locale pairing
Each version carries correct canonical and hreflang relationships.
Drift review
Compare claims, numbers and promises across both locales.
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.
{
"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
}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.
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.
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 }
);
}
}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.
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);
});create / refresh / merge / reject matches the expected fixture.
Share of factual claims linked to valid evidence.
No changed numbers, promises or meaning between versions.
Zero publications without approval and public verification.
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.
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.
