DEV Community

Cover image for What I let Claude write, and what I made n8n compute
Cécile Maindron
Cécile Maindron

Posted on

What I let Claude write, and what I made n8n compute

I'm a B2B growth marketer, not an engineer. Over the past month, I built an n8n workflow that takes a monthly list of SEO keywords, asks the Claude API whether each one deserves a page, writes it if it does, and sends it through two human reviews before it goes live on GitHub and Cloudflare Pages.

I wrote about the editorial side on Medium. This post is the plumbing: the output contract, the checks, the error path, and the one design change that halved the cost of a page. The site is Funnelsight, a demo SaaS I created for an AI certification. The workflow is real and runs every month.

The flow, in one paragraph

A schedule trigger reads the new rows from a Google Sheet. A Loop Over Items node processes keywords one at a time. For each one, Claude returns a decision: create, skip or duplicate. A created page goes to the SEO specialist in Slack (send-and-wait form: approve or request changes). If approved, n8n opens a GitHub branch, commits the page and the support files, and opens a pull request. The web developer merges it, or sends it back. Every step writes a status back to the sheet.

n8n workflow canvas for the Funnelsight SEO pipeline, grouped into six colored zones: keyword intake, Claude decision and generation, output checks, Slack review by the SEO specialist, GitHub pull request and publishing, and error handling.

Two things I learned early about n8n here:

  • Slack send-and-wait with several items sends one form for all of them. The loop isn't optional: it's what gives each keyword its own review.
  • A Limit node drops items silently. I'd added one so the context files were fetched once, not once per keyword. It also let only the first keyword through, with no error. Inside the loop, the files are now re-read at every iteration, which turned out to be a feature: each keyword is checked against the pages published earlier in the same batch.

The output contract

The prompt ends with a strict output format. For skip or duplicate, Claude returns only two sections. For create, six:

## DECISION
create | skip | duplicate

## REASON
One sentence.

## FILENAME
[folder]/[slug].html

## PAGE
(the full HTML, from a fixed page shell)

## MEMORY_ENTRY
(one line describing the page, 9 fields, internal_linking = AUTO)

## RESOURCE_CARD
(one HTML snippet for the resources page)
Enter fullscreen mode Exit fullscreen mode

A refusal costs between 1 and 7 cents. A full page costs around 11. Since I expect about half of each monthly list not to become pages, making "no" cheap mattered.

The Parse node splits the response on the headers. My first regex used $ with the multiline flag to mark the end of the last section, which in JavaScript matches the end of every line, not the end of the string. Sections came back truncated. The fix:

const re = new RegExp(
  `^## ${label}[ \\t]*\\n([\\s\\S]*?)(?=\\n## [A-Z_]+[ \\t]*\\n|(?![\\s\\S]))`,
  'm'
);
Enter fullscreen mode Exit fullscreen mode

(?![\s\S]) means "nothing left at all", whatever the flags say.

Checks before anything touches GitHub

The prompt asks Claude to self-check. The workflow doesn't trust it. Before a page reaches the reviewer, the Parse node rejects it if:

  • the filename doesn't match folder/slug.html with an allowed folder;
  • the slug already exists;
  • a <!-- FILL: --> placeholder is left in the HTML;
  • the memory entry doesn't have exactly 9 fields;
  • the resource card is missing;
  • there isn't exactly one read-next block, with the exact markup;
  • an internal link points to a page that doesn't exist.

These caught a page with the wrong read-next markup before anyone had to read it. Checking it against the HTML is cheaper than a human noticing it later.

The change that halved the cost

On the first real scheduled run, the response hit the token limit and the workflow stopped. Looking at why, about two thirds of the output was Claude rewriting content-memory.md, the file that lists every published page and its links, just to add one line.

Worse, that file had been listing links that didn't exist in the HTML for a week. The model was describing the site from memory, and nobody had checked it against the pages.

So I split the work by a simple rule: anything that can be computed from a real source is computed by code. Claude writes the page and one line describing it, with internal_linking: AUTO. Then n8n:

  • extracts the links actually present in the page's HTML and builds internal_linking from them;
  • flags a missing return link on each page the new one links to;
  • updates the page counters;
  • adds the sitemap <url> entry with today's date;
  • puts the resource card in the right section, based on the page's folder. GitHub diff on content-memory.md: one new line for the page

Results on the same keyword: output dropped by about two thirds, input context by about 30% (29,142 to 20,660 tokens), and cost per page went from $0.23 to $0.11. I also raised max_tokens from 16,000 to 32,000 as a safety margin, and the workflow checks stop_reason === "max_tokens" to block anything truncated.

The static block (instructions, template, page shell, product facts, memory) is sent with cache_control: ephemeral, on the default 5-minute window. After a page is created, the cache rarely helps: the next call comes after a human review, and the memory file has changed anyway. Where it pays off is a run of skip or duplicate decisions back to back. A second consecutive duplicate read the whole block from cache and cost $0.01 instead of $0.06.

Committing to GitHub without conflicts

A page touches five files: the page itself, the memory file, the sitemap, the resources page, and a token log that records what each call cost. The GitHub contents API needs each file's current SHA on the branch. Parallel PUTs on the same branch conflict, so the commits run one after another, each fetching the SHA from the branch first.

The error path

Every node that can fail has its error output (continueErrorOutput) wired to one Build Error Message node. The tricky part: error items lose their pairing, so the node can't just read the keyword from $json. It tries the nodes that carry the ID, in order:

function tryItem(node) {
  try { return $(node).item.json; } catch (e) { return null; }
}
const src = [
  'Parse Claude Response', 
  'Build API Body', 
  'Combine Context With Keywords'
]
  .map(tryItem)
  .find(j => j && j.ID !== undefined && j.ID !== '') || {};
Enter fullscreen mode Exit fullscreen mode

Then the workflow deletes the branch if one was created (DELETE /git/refs/heads/{branch}), logs the cost of the failed call, marks the row Failed, and sends a Slack alert to whoever maintains the workflow. The loop moves on to the next keyword: one failure doesn't stop the month.

Slack alert sent to the workflow maintainer: the keyword in row 34 failed, nothing was published, the keyword is marked Failed in the sheet, and the rest of the batch continues.

To test it, I dropped max_tokens to 2,000 on purpose. The truncation check fired, nothing was published, the row went to Failed, and the next keyword ran normally.

Two reviews, two people

Approving a page and publishing it are separate decisions. The SEO specialist reviews the content in Slack. The web developer reviews the pull request and either merges it or answers "Cannot publish as is" with a reason. Both send the keyword back to the same status, Needs Rework, but with a prefix (SEO review: or Web review:) so the next generation knows whose feedback it's addressing. The prompt treats them differently: editorial notes from one, technical notes from the other.

What I'd tell someone starting this

  • Write the output contract first and parse it strictly. Everything downstream depends on it.
  • Don't let the model maintain a file the code could rebuild. It will drift, and it costs tokens.
  • Design the failure before the success path is finished. Mine was built after the first real run broke, which is how I'd do it again, just sooner.
  • Keep the human decisions human, and give each one its own step.

Next on my list: a cap on rejections, so a keyword that's been sent back two or three times goes to a person instead of using more calls.

The repo, with the architecture decisions behind each choice: github.com/CecileMaindron/funnelsight-site

Cover photo by Konstantinos Feggoulis on Unsplash.

Top comments (0)