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

  1. Auth. 401 unless the request carries Authorization: Bearer <CRON_SECRET>, the header Vercel adds to cron calls. The compare is constant-time.
  2. 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.
  3. 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.
  4. Generate. One Messages API call with the official @anthropic-ai/sdk. The JSON schema goes in output_config.format (structured outputs). The model defaults to claude-sonnet-5-5 and is set by CLAUDE_MODEL.
  5. 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.
  6. Retry once. Malformed JSON, a failed check or a reply cut off at max_tokens gets one more request that lists the problems. A second failure returns 500 and nothing is created. A refusal is not retried.
  7. Create. articleCreate with blogId, title, body, summary, tags, author and isPublished. Shopify userErrors become a 500 with Shopify’s messages. The default is a hidden draft, so a person reads each post before it goes live.

Files

FilePurpose
app/api/cron/generate-blog/route.tsThe route. Connects the real Claude client, fetch and clock.
lib/handler.tsAuth and the run order, with every dependency injected.
lib/generate.tsClaude request, validation, one retry, refusal handling.
lib/blog-post.tsThe JSON schema sent to Claude and the reply validator.
lib/shopify.tsArticleCreateInput builder, GraphQL client, access-token options, duplicate search.
lib/config.tsEnvironment checks and defaults.
test/*.test.ts35 tests on Node’s built-in test runner.
vercel.json, .env.example, README.mdCron 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

Limits

Sources (opened 29 September 2026)

All work samples