Writing meta descriptions for a few hundred pages is a classic task to hand to a language model. It is also easy to get wrong: descriptions that are too long, generic ("Discover our amazing services"), duplicated across pages, or that promise things the page does not say. The trick is not the prompt. It is the checks around it and a human approval step. Here is the loop we use.
The pipeline
- Input: a crawl export with URL, title, H1 and the first 300 words of body text.
- Draft: the model writes one description per page, using only that page's text.
- Automatic checks: length, duplicates, banned phrases, and "is every claim in the source text?".
- Human review: a sheet with draft, flags and an approve/edit column.
- Publish: only approved rows go to the CMS.
Drafting, with the source text as the only context
import csv, json
PROMPT = """Write a meta description for this web page.
Rules: 140-155 characters. Plain, specific language. Mention the main service and one concrete detail
from the page. No claims that are not in the page text. No exclamation marks. Return JSON: {{"description": "..."}}
Title: {title}
H1: {h1}
Page text: {text}"""
def draft(row, call_model):
out = call_model(PROMPT.format(title=row["title"], h1=row["h1"], text=row["text"][:1500]))
return json.loads(out)["description"].strip()
call_model is whatever client you use. Keep temperature low; you want consistency, not creativity.
The checks that do most of the work
BANNED = ["discover", "amazing", "best in class", "world-class", "unlock", "elevate", "one-stop"]
def flags(desc, row, seen):
f = []
if not 120 <= len(desc) <= 160:
f.append(f"length {len(desc)}")
if any(b in desc.lower() for b in BANNED):
f.append("banned phrase")
if desc.lower() in seen:
f.append("duplicate")
# crude grounding check: numbers and capitalised terms must appear in the source
source = (row["title"] + " " + row["h1"] + " " + row["text"]).lower()
for token in desc.replace(",", " ").split():
t = token.strip(".:;()").lower()
if (t[:1].isdigit() or token[:1].isupper()) and len(t) > 2 and t not in source:
f.append(f"not in page: {token}")
return f
def run(in_csv, out_csv, call_model):
seen, out = set(), []
for row in csv.DictReader(open(in_csv, encoding="utf-8")):
d = draft(row, call_model)
out.append({"url": row["url"], "current": row.get("meta", ""), "draft": d,
"flags": "; ".join(flags(d, row, seen)), "approved": ""})
seen.add(d.lower())
with open(out_csv, "w", newline="", encoding="utf-8") as fh:
w = csv.DictWriter(fh, fieldnames=list(out[0]))
w.writeheader(); w.writerows(out)
The grounding check is deliberately crude: it flags any number or capitalised word in the draft that does not appear in the page. That catches most invented prices, years, locations and brand names, which are the errors that matter.
The human step is not optional
The output CSV goes to a reviewer who sorts by flags, fixes or rejects the flagged rows, spot-checks the clean ones, and fills approved. How many drafts need an edit depends on how clean the source pages are; measure it on the first batch before you scale up. Reviewing is still much faster than writing every description from scratch, and the approval column gives you an audit trail.
Publishing
Only rows with approved = yes are pushed to the CMS, through its normal API or bulk import, never by editing templates. Keep the previous description in the current column so any change can be rolled back.
Measuring it
Meta descriptions do not change rankings directly; they influence click-through. Compare click-through rate in Search Console for the updated pages over the four weeks before and after, against a set of similar pages you did not change.
We use loops like this one in our AI automation work, always with a person signing off before anything is published.
Written with AI assistance for the Gorilla Vibe marketing team.
Top comments (0)