All posts
EngineeringJune 3, 202615 min read

Building an AI-Native Instagram Publishing Pipeline

A production-grade system that turns uploaded photos into a curated, auto-scheduled, auto-published Instagram feed - three Claude agents for the judgment calls, deterministic code for everything that must be exact.

I gave three AI agents a job and kept the receipts.

This is a build log and architecture write-up for a small but production-grade system that turns a stream of uploaded photos into a curated, auto-scheduled, auto-published Instagram feed - with a human in the loop only where it matters. Three different Claude agents handle the parts that are genuinely judgment calls (is this safe? what is this? which photo should go next?), while deterministic code handles everything that must be exact (timestamps, idempotency, token rotation, double-post prevention).

TL;DR

Stack: Next.js 16 (App Router, React 19) · Supabase (Postgres + Auth + Storage + Row-Level Security + DB webhooks) · Anthropic Claude (vision + structured output + prompt caching) · Meta Graph API · Vercel (hosting + cron) · shadcn/ui + Tailwind v4 · TypeScript + Zod end-to-end.

The shape of the system:

Upload ──▶ Moderate ──▶ Classify ──▶ Schedule ──▶ Publish
(browser)   (Claude)     (Claude)    (Claude*)    (Meta Graph API)
   │           │            │            │              │
   └───────────┴── Supabase Postgres (single source of truth) ──┴────────┘
                   RLS · DB webhooks · service-role crons

Claude is used as a tie-breaker in scheduling; deterministic strategies are also available.

What it does

A team uploads photos through a simple drag-and-drop intake page. From there the pipeline runs itself:

  1. Moderation - every upload is screened by a Claude vision agent against a frozen safety + quality rubric. Clear passes are approved automatically; borderline cases are escalated to a human review queue; clear failures are rejected.
  2. Classification - approved photos get analysed for topic, mood, tone, a ready-to-post caption, alt text, hashtags, a colour palette, and a time-of-day hint. The caption and hashtags obey a configurable brand voice.
  3. Scheduling - classified photos are placed into concrete posting slots on a calendar, respecting per-day posting hours, topic/mood cooldowns, and organic-looking timing jitter.
  4. Publishing - a cron worker publishes due slots to an Instagram Business/Creator account via the Meta Graph API, with careful guards against double-posting and Meta's well-known timing quirks.

The only required human touch points are uploading, and approving/rejecting the items moderation wasn't confident about. Everything else is automatic, with manual override buttons (Fill Schedule, Publish Now, Refresh Token) for when an operator wants to force a step.

How it was built, stage by stage

The git history reads as a clean progression - each capability landed as its own feature branch, then the back half of the project is almost entirely hardening: closing race conditions, fixing timezone and timestamp bugs, and preventing duplicate work. That ratio - build fast, then harden relentlessly - is itself one of the more honest things about the project.

Stage 1 - Intake and storage

The foundation is a single submissions table and a browser-to-storage upload path. The first design decision that paid off repeatedly: the Next.js server is never in the upload path. Bytes go straight from the browser to Supabase Storage, so there's no re-encoding, no compression, and no serverless body-size limit to fight.

// src/components/upload/UploadArea.tsx
// ── LOSSLESS UPLOAD ────────────────────────────────────────────────
// The file bytes go directly from the browser to Supabase Storage.
// Next.js server is NOT in this path. No re-encoding, no compression.
// Supabase uses TUS resumable uploads for files > 6MB automatically.
const { error: uploadError } = await supabase.storage
  .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET ?? 'intake-raw')
  .upload(storagePath, item.file, { cacheControl: 'no-cache', upsert: false })

Image dimensions and validation are computed client-side before upload, so a bad file never costs bandwidth:

// src/lib/image-utils.ts
export function getImageDimensions(file: File): Promise<{ width: number; height: number }> {
  return new Promise((resolve) => {
    const img = new Image()
    const url = URL.createObjectURL(file)
    img.onload = () => { resolve({ width: img.naturalWidth, height: img.naturalHeight }); URL.revokeObjectURL(url) }
    img.onerror = () => { resolve({ width: 0, height: 0 }); URL.revokeObjectURL(url) }
    img.src = url
  })
}

Row-Level Security was switched on from migration 0001: a user can only insert and read their own submissions. Everything privileged (the agents, the crons, the dashboard) goes through a separate service-role client that bypasses RLS intentionally and explicitly.

Stage 2 - The moderation agent (event-driven AI)

This is where the architecture's spine appears. Instead of polling, I let Postgres drive the pipeline: a Supabase database webhook fires on insert into submissions and calls /api/moderate. The endpoint authenticates the webhook with a shared secret, then runs a Claude vision call against a versioned rubric.

// src/app/api/moderate/route.ts
const providedSecret = request.headers.get('x-webhook-secret')
if (providedSecret !== expectedSecret) {
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

The agent mints a short-lived signed URL for the image, sends it to Claude with the frozen rubric as a cached system prompt, and gets back a strongly-typed verdict:

// src/lib/moderation/agent.ts
const response = await anthropic.messages.parse({
  model: MODEL,                       // pinned model
  max_tokens: 4096,
  thinking: { type: 'adaptive' },     // Claude decides when to reason harder
  output_config: {
    effort: 'medium',
    format: zodOutputFormat(ModerationVerdictSchema),  // guaranteed shape
  },
  system: [{
    type: 'text',
    text: MODERATION_SYSTEM_PROMPT,
    cache_control: { type: 'ephemeral', ttl: '1h' },   // cache the rubric
  }],
  messages: [{ role: 'user', content: userContent }],  // image + caption
})

The verdict is dual-axis - a safety assessment (10 violation categories, severity none→high) and a quality score (0 - 100, with specific issue codes) - and crucially it includes a self-reported confidence:

// src/lib/moderation/schema.ts
export const ModerationVerdictSchema = z.object({
  verdict: z.enum(['approved', 'rejected', 'needs_review']),
  confidence: z.number(),
  safety_severity: z.enum(['none', 'low', 'medium', 'high']),
  safety_issues: z.array(z.enum(SAFETY_ISSUE_CODES)),
  quality_score: z.number().int(),
  quality_issues: z.array(z.enum(QUALITY_ISSUE_CODES)),
  reasoning: z.string(),
})

The rubric bakes in a deterministic decision table so the model isn't inventing policy on the fly - any auto-reject trigger or high severity → rejected; medium severity, mid quality, or confidence < 0.7needs_review; otherwise → approved. The bias is deliberate: prefer a false "needs review" over a false "approved." needs_review items keep status = 'pending' and surface in the dashboard queue.

Every run is stored immutably in moderation_results - verdict, model name, rubric version, token counts (including cache reads), and the raw response - so decisions are auditable and reproducible if the rubric later changes.

Stage 3 - Classification with a tunable brand voice

When a submission flips to approved, a second DB webhook triggers /api/classify. The classification agent produces the metadata the rest of the pipeline runs on, using the same patterns (vision, adaptive thinking, cached rubric, Zod-validated output). Its schema is all enums and arrays so the model can't drift outside the controlled vocabulary:

// src/lib/classification/schema.ts
export const ClassificationResultSchema = z.object({
  topic: z.enum(TOPIC_CODES),
  subtopics: z.array(z.string()),
  mood: z.enum(MOOD_CODES),
  tone: z.enum(TONE_CODES),
  suggested_caption: z.string(),
  alt_text: z.string(),
  hashtags: z.array(z.string()),
  color_palette: z.array(z.string()),
  time_of_day_hint: z.enum(TIME_OF_DAY_CODES),
  notes: z.string(),
})

The most interesting decision here is where brand voice lives. The rubric (the 200-line system prompt) is frozen and cached. The brand voice - tone, formality/warmth/energy sliders, emoji policy, hashtag strategy, preferred and banned phrases - is operator-editable from a settings form and stored in the brand_voice table. It's injected into the user message, not the system prompt, so editing it never invalidates the prompt cache:

// src/lib/classification/brand-voice.ts
lines.push(`Register: ${voice.register} (formality ${voice.formality}/5, ` +
  `warmth ${voice.warmth}/5, energy ${voice.energy}/5)`)
// ...
lines.push('Precedence: safety rules > brand voice > rubric defaults.')

That precedence line matters: brand voice can shape the caption, but it can never override the safety rules. The result is a "tuning panel" for AI output - operators change the feel of every future caption with no redeploy and no model retraining.

Stage 4 - Scheduling: deterministic where it counts, AI where it helps

The scheduler fills a calendar of posting slots from the pool of approved-and-classified submissions. This stage went through the most iteration, and the lessons are all about identity and idempotency.

A slot is defined by a canonical hour boundary, slot_start, which is UNIQUE. The actual post time, scheduled_for, is slot_start plus a deterministic jitter. Separating these two was the fix for a whole family of duplicate-slot bugs:

// src/lib/scheduling/config.ts
export interface SlotTimestamp {
  /** Hour-boundary identity. Stored in posting_slots.slot_start. UNIQUE. */
  slotStart: Date
  /** Effective post time (slot_start + jitter). Stored in scheduled_for. */
  scheduledFor: Date
}

The jitter is intentionally deterministic - a hash of (date, hour, jitterMinutes) - so re-running the scheduler regenerates the exact same times instead of creating near-duplicate rows a few seconds apart:

// src/lib/scheduling/config.ts — FNV-1a hash → stable per-slot offset
const key = `${date}:${hour}:${jitterMinutes}`
let h = 2166136261
for (let i = 0; i < key.length; i++) {
  h ^= key.charCodeAt(i)
  h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0
}
const range = jitterMinutes * 60 * 2 + 1
return (h % range) - jitterMinutes * 60   // organic-looking, but reproducible

There are three selection strategies, configurable live:

  • fifo - earliest approved always wins. Predictable upload-order. No AI.
  • random - shuffle from a cooldown-filtered pool. Varied feed. No AI.
  • rhythm - Claude picks. After cooldown filtering, if more than one candidate remains, the model weighs time-of-day match, recency, and feed variety to choose the next post - and falls back to earliest-approved if it can't decide.
// src/lib/scheduling/selector.ts
if (pool.length === 1) {
  return { submissionId: pool[0].submission_id,
           reasoning: 'Only candidate matching topic + mood rotation rules.',
           usedLlm: false }
}
// Multiple candidates — ask Claude to pick.
const response = await anthropic.messages.parse({
  model: MODEL, max_tokens: 512, thinking: { type: 'adaptive' },
  output_config: { effort: 'low', format: zodOutputFormat(PickSchema) },
  system: [{ type: 'text', text: SELECTOR_SYSTEM_PROMPT,
             cache_control: { type: 'ephemeral', ttl: '1h' } }],
  // ...
})

This is the heart of the "AI where it helps" philosophy: the placement of slots is exact arithmetic; only the genuinely subjective "which photo feels right next" is delegated to a model - and even then it's behind a cheap effort: 'low' call that only runs when there's an actual tie to break.

A separate catchup cron (every 15 minutes) is the safety net: it finds submissions that are approved and classified but somehow never got a slot - e.g. if the fire-and-forget scheduling hook after classification timed out - and re-runs the filler. Idempotency makes that safe to run constantly.

Stage 5 - The operator dashboard

A single dashboard, role-gated, gives operators the whole picture: a review queue of needs_review items with the moderation reasoning attached, an upcoming posting calendar rendered in the configured timezone, an activity log, and manual-override buttons. Server Components fetch through the service-role client; mutations are Server Actions that re-check authorization on every call:

// src/app/dashboard/actions.ts
export async function approveSubmission(submissionId: string) {
  const auth = await authorizeAction('super_admin')   // server-side gate
  if (!auth.ok) return { ok: false as const, error: auth.error }
  // ... flip status → 'approved', which the DB webhook turns into classification
}

The comment on that action states the security model bluntly: the server-side gate is the security boundary - client-side button hiding is UX only.

Stage 6 - Publishing to Instagram

Publishing uses Meta's asynchronous three-step container flow, and most of the code here exists because the real world is messier than the docs:

1. POST /{ig-user-id}/media          → creation_id   (upload the image URL + caption)
2. GET  /{creation_id}?fields=...    → poll until FINISHED
3. POST /{ig-user-id}/media_publish  → permalink + id

Two empirically-discovered quirks are handled explicitly. First, a ~2 second stabilization delay after the container reports FINISHED, because the publish endpoint sometimes isn't ready yet. Second, a retry loop specifically for Meta error 9007 ("Media ID is not available"):

// src/lib/instagram/client.ts
const maxAttempts = 4
const delaysMs = [1500, 3000, 5000]   // backoff between publish attempts
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
  try {
    // POST media_publish ...
    return await finalizePublish(config, publishBody.id)
  } catch (err) {
    if (!(err instanceof MetaGraphError) || err.code !== 9007) throw err
    if (attempt < maxAttempts) await new Promise((r) => setTimeout(r, delaysMs[attempt - 1]))
  }
}

Double-posting to a live Instagram account is the one truly unrecoverable bug, so the publisher is defended in depth (this is migration 0016's entire reason for existing):

  1. An intermediate publishing status lets a worker atomically claim a slot via compare-and-swap (UPDATE ... WHERE status = 'scheduled'). If that affects zero rows, another worker already has it.
  2. Slots with a non-null external_post_id are filtered out entirely - even if the DB write that flips a slot to posted failed, the presence of a media ID prevents a re-post.
  3. Stale publishing rows (a worker that died mid-flight) are reset after 10 minutes.
  4. After Instagram accepts the post, the "mark as posted" DB write retries up to 5 times - at that point the post is live and the DB must catch up.

Finally, Instagram long-lived tokens expire every ~60 days. Rather than a calendar reminder and a redeploy, a daily cron refreshes the token and stores it in an instagram_credentials table. Config loading prefers the DB row and falls back to env vars, so the very first refresh bootstraps from environment variables and the database becomes the source of truth thereafter - token rotation with zero downtime and no redeploy.

The long tail: hardening

Roughly the back half of the commits are fixes, and they're worth listing because they're the real story of taking something from "works on my machine" to "survives production":

  • Unique constraint + dedup migration after concurrent fills created duplicate slots (0008).
  • Sub-second timestamps leaking Date.now() milliseconds into slot times, defeating the unique index - pinned the column to timestamptz(0) (0013).
  • Canonical slot_start identity separated from jittered time (0014), then a follow-up because the first backfill bucketed jitter into the wrong hour (0015).
  • Pruning orphaned slots when an operator removes an hour from the posting schedule (0017).
  • Magic-link-only auth plus an email→role allow-list (super_admin / editor) replacing a looser login.

None of these are glamorous. All of them are the difference between a demo and a tool a team actually trusts with their live account.

Tech-stack choices - benefits and trade-offs

Next.js 16 (App Router, React 19, Server Components + Server Actions)

Why: one framework for the simple upload UI, the data-heavy dashboard, the webhook endpoints, and the cron handlers. Server Components let the dashboard query Postgres directly with no client-side data-fetching layer; Server Actions give mutations without hand-writing API routes.

Benefit: very little glue code. The dashboard is a Server Component that awaits its data; the action that approves a submission is a function with 'use server' at the top.

Trade-off: App Router + RSC + Server Actions is still a sharp tool - the mental model of what runs where (server vs client, build vs request) is non-obvious, and the project even keeps middleware in a proxy.ts for this version's conventions.

Supabase (Postgres + Auth + Storage + RLS + DB webhooks)

Why: it collapses four services into one and - the key insight - lets the database itself drive the pipeline. Webhooks on row changes mean I never wrote a queue or a poller.

Benefits:

  • RLS gives a real per-user security boundary at the data layer, not just in app code.
  • DB webhooks turn status = 'approved' into "run the classifier" with no infrastructure.
  • Storage signed URLs let me feed images to Claude and to Instagram without making the bucket public.

Trade-offs:

  • The service-role key is a loaded gun - it bypasses RLS, so every cron and agent that uses it is trusted code by definition. The split between the anon-key client (browser, RLS-enforced) and the service-role client (server-only) has to be disciplined and explicit.
  • Webhook-driven flows are eventually consistent and best-effort. That single fact is why the catchup cron, the idempotency, and half the hardening exist. A webhook that times out can't be retried into correctness; you need a reconciler.

Anthropic Claude - three agents, one set of patterns

Why: the three hard problems (moderate, classify, curate) are all vision-plus-judgment, exactly what a strong multimodal model is good at, and all three want structured output rather than prose.

Benefits / patterns reused across all three agents:

  • Structured output via Zod (zodOutputFormat + messages.parse) means the model's response is validated to an exact TypeScript type or it fails loudly - no fragile JSON parsing.
  • Prompt caching the frozen rubric with a 1-hour ephemeral TTL: the big system prompt is sent once and re-used, cutting cost and latency on every subsequent call. The corollary design rule - keep request-specific data (brand voice, the candidate list) out of the cached prefix - shaped the whole brand-voice design.
  • Adaptive thinking + effort levels let me dial cost against difficulty: effort: 'medium' for the consequential moderation/classification calls, effort: 'low' for the cheap scheduling tie-break.
  • Versioned, frozen rubrics stored alongside results make every AI decision auditable and reproducible.

Trade-offs:

  • Cost and latency are real and scale with volume - caching and effort tuning mitigate but don't eliminate them. Moderation runs on every single upload.
  • Non-determinism is intrinsic. The whole architecture leans on this: anything that must be exact (timestamps, dedup, money-equivalent actions like publishing) is deterministic code, and the model is fenced into the genuinely subjective decisions - and even then with deterministic fallbacks.
  • The model is pinned and the rubric is versioned, so a model or prompt change is a deliberate, traceable event rather than silent drift.

Meta Graph API for publishing

Why: it's the only sanctioned path to auto-publish to an Instagram Business/Creator account.

Trade-offs - almost entirely operational: the async container flow, the 9007 timing race, the stabilization delay, and the 60-day token expiry are all things the docs under-sell and production over-delivers. The DB-backed token with a daily refresh cron, and the defense-in-depth against double-posting, are the direct cost of integrating with it responsibly.

Vercel (hosting + cron)

Why: zero-config deploys for Next.js, and cron is config, not infrastructure - four scheduled jobs declared in vercel.json (token refresh, fill, catchup, publish).

Trade-off: serverless functions are short-lived and can be killed mid-flight. That constraint is precisely why the pipeline is built around idempotent, reconcilable steps with a catchup safety net rather than long-running processes.

TypeScript + Zod, end to end

Why: Zod schemas are simultaneously the runtime validator for AI output, the source of the TypeScript types, and the controlled vocabulary the prompts reference. One definition, three jobs.

Benefit: the boundary between "untrusted model output" and "typed application data" is a single, enforced line.

Trade-off: keeping the Zod enums, the SQL check constraints, and the prompt language all in agreement is manual discipline - they're three representations of the same vocabulary and nothing automatically keeps them in sync.

Cross-cutting principles

  1. Let the database drive. Status columns plus webhooks replaced a job queue. The flow is pending → approved → scheduled → posted, and each transition is an event something listens for.
  2. AI for judgment, code for arithmetic. Every irreversible or exactness-critical operation is deterministic. The model is fenced into subjective calls and always has a deterministic fallback.
  3. Idempotency over correctness-on-the-first-try. In a serverless, webhook-driven world, anything can run twice or die halfway. Unique constraints, compare-and-swap claims, and reconciler crons make "run it again" always safe - and usually the fix.
  4. Defense in depth where it's unrecoverable. Double-posting to a live account gets four independent guards, because one is never enough when the failure is public and permanent.
  5. Configuration as data, not code. Brand voice, scheduling strategy, posting hours, and Instagram credentials all live in the database with env-var fallbacks - operators tune the system live; the system bootstraps and degrades gracefully.
  6. Make AI decisions auditable. Every verdict stores its model, prompt/rubric version, token usage, and raw response. If policy changes, history is still explainable.
  7. Build fast, then harden relentlessly. The first six features shipped quickly; the back half of the work was closing races and fixing edge cases. That's not a detour - that's the job.

Ask about Julian Walder

Grounded in his real work

Hi! I'm Julian Walder's assistant. Ask me anything about his work, projects, or background in AI.