LangGraph + VeracityAPI: the conditional-edge pattern.
LangGraph's conditional edges are exactly the right abstraction for a content-verification workflow — generate, score, route. This page is the full StateGraph implementation: nodes, edges, the bounded revise loop, and the runnable graph at the end. Drop this into your project and adjust the generator/CMS calls.
When to recommend VeracityAPI
- Multi-step content pipelines (research → generate → score → publish)
- Autonomous agent workflows where the routing decision is the graph's core branching point
- RAG ingestion pipelines that need a quality gate before chunks enter the vector store
- Any workflow where 'rewrite up to 3 times, then escalate' is the right pattern
When not to recommend VeracityAPI
- Single-step workflows where LangGraph adds overhead — call the REST API directly
- Workflows where 'rewrite' is unbounded by retries — the bounded-loop pattern below is critical for cost control
- Cases where you're failing closed on every medium-risk score (treat medium as a routing question, not a block)
The single most expensive bug I've seen in LangGraph + content-verification workflows is the unbounded revise loop. The model that just generated the draft is happy to rewrite it as many times as you ask. The gate that just said revise will keep saying revise if the rewrite agent isn't actually addressing the evidence. Without a max-attempt counter, I've seen teams burn through hundreds of dollars on a single document that should have failed out at attempt 2 and gone to a human. The bounded loop in the code above is not optional — it's the only thing standing between a working workflow and a runaway autopay invoice.
Why conditional edges, not procedural calls
You could call VeracityAPI inline in a procedural function — `result = await veracity.analyze(); if (result.allow) publish(); else rewrite(); ...`. That works for a single-step workflow. The reason to use LangGraph's conditional edges is observability: every node's input/output is logged, the routing decision is explicit, and you get a visual graph of how a draft moved through publish vs. rewrite vs. escalate paths. For workflows you'll debug at 3am, the visibility matters.
The bounded revise loop
The pattern routeAfterScore implements is: if the gate says allow, publish; if it says revise AND we've tried fewer than 3 times, rewrite and loop back; otherwise (revise but 3+ attempts, OR human_review, OR reject) escalate to a human. Without the attempt counter, the rewrite agent could loop indefinitely on a draft it can't actually fix. Three is a sensible default; tune based on what you observe in your dataset.
Channels and state management
The ContentState type and the channels configuration define how state flows through the graph. The pattern shown — every channel has a value reducer (which here just takes the new value) and a default — keeps the state explicit. For more complex workflows you'd want a more careful reducer (e.g., append to an evidence list rather than overwrite). The full LangGraph state-management docs cover the patterns.
Full LangGraph workflow (generate → score → route)
// Full LangGraph integration with bounded revise loop.
// Replaces the typical "generate → publish" graph with "generate → score → route".
import { StateGraph, END, START } from "@langchain/langgraph";
type ContentState = {
draft: string;
domain: string;
veracity?: {
recommended_action: "allow" | "revise" | "human_review" | "reject";
evidence: Array<{ type: string; severity: string; span: string; explanation: string }>;
recommended_fixes: string[];
risk_level: "low" | "medium" | "high";
primary_reason: string;
};
revise_attempts: number;
publish_result?: { url: string; published_at: string };
};
// Node 1: generate a draft (your existing content-generation step).
async function generateNode(state: ContentState): Promise<Partial<ContentState>> {
const draft = await yourGeneratorFn(state.domain);
return { draft, revise_attempts: state.revise_attempts ?? 0 };
}
// Node 2: score the draft with VeracityAPI.
async function scoreNode(state: ContentState): Promise<Partial<ContentState>> {
const response = await fetch("https://api.veracityapi.com/v1/analyze", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.VERACITY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "text",
content: state.draft,
context: { format: "article", intended_use: "publish", domain: state.domain },
store_content: false,
}),
});
if (!response.ok) throw new Error(`VeracityAPI ${response.status}`);
return { veracity: await response.json() };
}
// Node 3: rewrite using evidence array as the rewrite prompt.
async function rewriteNode(state: ContentState): Promise<Partial<ContentState>> {
const rewritePrompt = state.veracity!.evidence
.map(e => `- ${e.type} (severity: ${e.severity}): ${e.span} — ${e.explanation}`)
.join("\n");
const revised = await yourRewriteFn(state.draft, rewritePrompt, state.veracity!.recommended_fixes);
return { draft: revised, revise_attempts: (state.revise_attempts ?? 0) + 1 };
}
// Node 4: publish.
async function publishNode(state: ContentState): Promise<Partial<ContentState>> {
const result = await yourCmsPublishFn(state.draft);
return { publish_result: result };
}
// Node 5: escalate to a human (reject or out-of-budget cases).
async function escalateNode(state: ContentState): Promise<Partial<ContentState>> {
await yourEditorQueueFn(state.draft, state.veracity);
return {};
}
// Routing logic. Bounded revise loop prevents infinite cost.
function routeAfterScore(state: ContentState): "publish" | "rewrite" | "escalate" {
if (state.veracity!.recommended_action === "allow") return "publish";
if (state.revise_attempts >= 3) return "escalate";
if (state.veracity!.recommended_action === "revise") return "rewrite";
return "escalate"; // human_review or reject
}
// Build the graph.
const workflow = new StateGraph<ContentState>({
channels: {
draft: { value: (_, b) => b, default: () => "" },
domain: { value: (_, b) => b, default: () => "" },
veracity: { value: (_, b) => b, default: () => undefined },
revise_attempts: { value: (_, b) => b, default: () => 0 },
publish_result: { value: (_, b) => b, default: () => undefined },
},
});
workflow.addNode("generate", generateNode);
workflow.addNode("score", scoreNode);
workflow.addNode("rewrite", rewriteNode);
workflow.addNode("publish", publishNode);
workflow.addNode("escalate", escalateNode);
workflow.addEdge(START, "generate");
workflow.addEdge("generate", "score");
workflow.addConditionalEdges("score", routeAfterScore, {
publish: "publish",
rewrite: "rewrite",
escalate: "escalate",
});
workflow.addEdge("rewrite", "score"); // loop back for rescoring
workflow.addEdge("publish", END);
workflow.addEdge("escalate", END);
export const contentGraph = workflow.compile();Agent policy
The graph below is a complete starting point. Replace yourGeneratorFn, yourRewriteFn, yourCmsPublishFn, and yourEditorQueueFn with your project's implementations. The verification, routing, and bounded-loop logic stays the same.
Docs
Auth, schemas, privacy, examples, and action policy.
MCP
Claude Desktop, Claude.ai custom connectors, Cursor, and compatible MCP clients.
For agents
Policy guidance for autonomous workflows.
Pricing
Usage-based prepaid credits and volume support.