Work sample · Claude API integration
Claude API → JSON → Shopify blog article, in one cron route
Sample code written to show how we would build this; not a client project and not deployed against a real store. The tests below run with a fake Claude client and a fake Shopify API, so no network calls or keys were involved.
A single Next.js App Router route in strict TypeScript. Vercel Cron calls it once a day; it asks Claude for one blog post as JSON (title, body_html, tags, summary), checks the reply, and creates the article in a Shopify blog through the Admin GraphQL API.
What happens on each run
- Auth. 401 unless the request carries
Authorization: Bearer <CRON_SECRET>, the header Vercel adds to cron calls. The compare is constant-time. - Config. Every environment variable is checked first. A missing or malformed one returns 500 with a list of what is wrong, before any API call.
- Duplicate guard. Vercel’s docs say a scheduled run can occasionally arrive twice. Each article carries a tag for its UTC date, and the route stops if today’s tag already exists, without calling Claude.
- Generate. One Messages API call with the official
@anthropic-ai/sdk. The JSON schema goes inoutput_config.format(structured outputs). The model defaults toclaude-sonnet-5-5and is set byCLAUDE_MODEL. - Validate. Exact fields, lengths, 1–10 tags without commas, plain-text title and summary, and an allow-list for
body_html: no scripts, no event handlers, no images, https links only. - Retry once. Malformed JSON, a failed check or a reply cut off at
max_tokensgets one more request that lists the problems. A second failure returns 500 and nothing is created. A refusal is not retried. - Create.
articleCreatewithblogId,title,body,summary,tags,authorandisPublished. ShopifyuserErrorsbecome a 500 with Shopify’s messages. The default is a hidden draft, so a person reads each post before it goes live.
Files
| File | Purpose |
|---|---|
app/api/cron/generate-blog/route.ts | The route. Connects the real Claude client, fetch and clock. |
lib/handler.ts | Auth and the run order, with every dependency injected. |
lib/generate.ts | Claude request, validation, one retry, refusal handling. |
lib/blog-post.ts | The JSON schema sent to Claude and the reply validator. |
lib/shopify.ts | ArticleCreateInput builder, GraphQL client, access-token options, duplicate search. |
lib/config.ts | Environment checks and defaults. |
test/*.test.ts | 35 tests on Node’s built-in test runner. |
vercel.json, .env.example, README.md | Cron schedule (daily, 08:00 UTC), every variable with notes, setup and limits. |
Key code
The route
/**
* GET /api/cron/generate-blog
* Vercel Cron calls this route on the schedule in vercel.json. The logic lives in
* lib/handler.ts; this file only connects the real Claude client, fetch and clock.
*/
import Anthropic from "@anthropic-ai/sdk";
import { handleCronRequest } from "../../../../lib/handler.ts";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Worst case: 2 Claude requests x (1 try + 1 SDK retry) x 65 s = 260 s, plus two Shopify calls.
export const maxDuration = 300;
export async function GET(request: Request): Promise<Response> {
return handleCronRequest(request, {
env: process.env,
createClaude: (config) =>
new Anthropic({ apiKey: config.anthropicApiKey, maxRetries: 1, timeout: 65_000 }),
fetch: (input, init) => fetch(input, init),
now: () => new Date(),
log: (line) => console.log(`[generate-blog] ${line}`),
});
}
Generate, validate, retry once
export async function generateBlogPost(client: MessagesClient, options: GenerateOptions): Promise<GenerateResult> {
const log = options.log ?? (() => {});
let problems: string[] = [];
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const message = await client.messages.create(buildRequest(options, problems));
if (message.stop_reason === "refusal") {
const category = message.stop_details?.category ?? "unspecified";
throw new RefusalError(`Claude declined the request (category: ${category}). Nothing was published.`);
}
const text = message.content
.filter((block): block is Anthropic.TextBlock => block.type === "text")
.map((block) => block.text)
.join("");
if (message.stop_reason === "max_tokens") {
problems = ["The reply stopped at max_tokens before the JSON was complete. Write a shorter article."];
} else {
const result = parseBlogPost(text);
if (result.ok) return { post: result.post, attempts: attempt };
problems = result.errors;
}
log(`attempt ${attempt} of ${MAX_ATTEMPTS} rejected: ${problems.join(" | ")}`);
}
throw new GenerationError(
`Claude did not return a valid blog post in ${MAX_ATTEMPTS} attempts: ${problems.join(" ")}`,
problems,
);
}
The Shopify payload
Field names follow the articleCreate mutation and ArticleCreateInput on shopify.dev (API version 2026-07, checked 29 September 2026).
export const ARTICLE_CREATE_MUTATION = `mutation CreateArticle($article: ArticleCreateInput!) {
articleCreate(article: $article) {
article { id handle title isPublished }
userErrors { code field message }
}
}`;
export function buildArticleCreateInput(post: BlogPost, options: ArticleOptions): ArticleCreateInput {
const input: ArticleCreateInput = {
blogId: toBlogGid(options.blogId),
title: post.title,
body: post.body_html,
summary: `<p>${escapeHtml(post.summary)}</p>`,
tags: [...post.tags.filter((tag) => tag !== options.runTag), options.runTag],
author: { name: options.authorName },
isPublished: options.publish,
};
if (options.publish) input.publishDate = options.now.toISOString();
return input;
}
Test output
Unit tests for the JSON validator and the Shopify payload builder, and handler tests with a mocked fetch and a mocked Claude client. Run with node --test on Node 24.21, which runs TypeScript directly by stripping types.
$ node --test "test/*.test.ts" # the npm test script
ℹ tests 35
ℹ suites 0
ℹ pass 35
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
All 35 test names
✔ accepts a valid post and normalises its tags
✔ rejects text that is not JSON
✔ rejects a JSON array or null
✔ reports missing fields and unexpected fields
✔ enforces length limits
✔ rejects script tags, event handlers and javascript: links in body_html
✔ accepts plain allowed markup, including a self-closing br
✔ rejects tags with commas and too many tags
✔ rejects HTML in title and summary
✔ returns the post from the first valid reply
✔ sends the model, the JSON schema and the topic
✔ retries once on malformed JSON and passes the errors back
✔ retries once when the reply stops at max_tokens
✔ fails loudly after two invalid replies
✔ does not retry a refusal
✔ isAuthorized accepts only the exact bearer header
✔ 401 without the cron secret, and nothing is called
✔ 401 for everyone when CRON_SECRET is not set
✔ 500 with a clear message when configuration is incomplete
✔ happy path: checks for today's article, generates, creates a draft
✔ CLAUDE_MODEL overrides the default model
✔ skips without calling Claude when today's article already exists
✔ 500 and no article when Claude fails twice
✔ 500 when Shopify rejects the article
✔ builds an ArticleCreateInput for a draft
✔ sets isPublished and publishDate when publishing
✔ normalises blog IDs and rejects anything else
✔ run tag uses the UTC date
✔ GraphQL client posts to the versioned endpoint with the access token
✔ GraphQL client fails loudly on HTTP errors and GraphQL errors
✔ createArticle sends the mutation and returns the article
✔ createArticle turns userErrors into an exception
✔ findArticleByTag filters by tag and numeric blog ID
✔ client credentials: fetches a token once and reuses it until it nears expiry
✔ static token mode never calls the network
Setup in short
- Shopify access. The app needs the
write_contentscope. Use the Admin API token of an existing admin-created custom app, or the client ID and secret of a Dev Dashboard app installed on a store in the same Shopify organization (the route then uses the client credentials grant). Shopify no longer allows new admin-created custom apps. - Vercel. Add the variables from
.env.example, including a randomCRON_SECRETof 16 or more characters, and deploy. On the Hobby plan a cron job runs at most once a day and may fire anywhere within the scheduled hour. - Model. The default is Claude Sonnet 5.5 (
claude-sonnet-5-5), the current Sonnet model on Anthropic’s models overview (checked 30 September 2026). SettingCLAUDE_MODELis the only step to use another model.
Limits
- Not run against the live Claude API or a real Shopify store.
npm install,tsc --noEmitandnext buildwere not run in our environment; the README lists them as the first steps before a deploy. - The HTML check is an allow-list gate for model output, not a general sanitiser.
- The duplicate guard relies on Shopify’s search. Two calls seconds apart could both miss each other if the search index is behind; a lock store closes that gap.
- No image on the article and no handling of Shopify rate limits beyond failing loudly. At one run a day, the second is unlikely to matter.
- A model can still get a fact wrong. The prompt forbids prices, stock, delivery times and facts not in the store context, and drafts are the default.
Sources (opened 29 September 2026)
- Shopify
articleCreate: https://shopify.dev/docs/api/admin-graphql/latest/mutations/articleCreate - Shopify
ArticleCreateInput: https://shopify.dev/docs/api/admin-graphql/latest/input-objects/ArticleCreateInput - Shopify client credentials grant: https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/client-credentials-grant
- Claude structured outputs: https://platform.claude.com/docs/en/build-with-claude/structured-outputs
- Claude models overview: https://platform.claude.com/docs/en/about-claude/models/overview
- Vercel cron jobs: https://vercel.com/docs/cron-jobs/manage-cron-jobs and https://vercel.com/docs/cron-jobs/usage-and-pricing
- Node.js type stripping: https://nodejs.org/api/typescript.html