Files
redefined-designs/docs/superpowers/plans/2026-08-31-intake-drafting-worker.md
T
bermudalambandClaude Opus 5 054a4b0cfd docs(intake): plan the AI drafting worker (#223)
Eight tasks: the SDK and its optional key, the Zod shape the model must answer in, the prompt, the call, writing a draft back, the worker that drives it, the wiring, and one real photograph to see whether any of it writes something worth reading.

The prompt is the correctness surface and gets its own task with its own tests. On one-of-a-kind stock an invented "1930s hand-thrown stoneware" is not a cosmetic error but a false claim on a storefront, and nothing downstream can tell an invented detail from an observed one — the only place that distinction can be enforced is in the instruction, so the tests assert it is there.

The other governing rule is that a submission is the only irreplaceable thing in the pipeline. The photos are often the only copy of an item no longer in the sender's hands, so a missing key, a failed call, a malformed answer and three exhausted retries all end the same way: the item keeps its photos and waits undrafted. Nothing in the worker deletes anything, and an absent key does not spend an attempt.

Only the suggested price reaches the item, per #220, with price_source recording that a model rather than a person chose it. The name and description stay on the draft row until the review queue in #225 exists.

The monthly spend ceiling is deliberately left out. Task 8 measures what a real call costs first, because a budget set from a guessed number is one nobody trusts.

Every test stubs the Anthropic client. A test that reaches the real API is a defect in the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:32:40 -05:00

46 KiB

Intake Drafting Worker Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A submitted item arrives with a drafted name, description, category, tags and suggested price, written from its photos and the sender's note.

Architecture: A background worker reads item_drafts rows at state='queued', sends the photos and note to Claude with a Zod-constrained output shape, and writes the result back. It is driven both by a call at the end of a successful submission and by a node-cron sweeper that picks up anything stranded. Every failure leaves the submission intact and undrafted.

Tech Stack: TypeScript, @anthropic-ai/sdk, zod, node-cron, Jest.

Spec: docs/superpowers/specs/2026-08-29-intake-pipeline-design.md

Already verified against the tree, so no need to re-check: item_drafts has every column this plan writes (ai_name, ai_description, ai_category_id, ai_tag_names, ai_suggested_price_cents, price_source, ai_error, input_tokens, output_tokens, cost_micros, drafted_at, state, attempts, model). items.price_cents is NOT NULL with the 8000 default from #222's migration, and items.status has no CHECK constraint — so the seed below inserts name and status alone and lands on 8000. item_images is (item_id, image_path, sort_order). typeForExtension in src/uploadTypes.ts lowercases its own input. Integration tests open the database as import { pool } from '../../src/db' plus resetDb/closeDb from ./setup/testDb, and tear down with await pool.end(); await closeDb();. envValidation.test.ts provides MINIMAL, withEnv() and without().

Global Constraints

  • The model is claude-sonnet-5, from INTAKE_MODEL, defaulting in code. Chosen in the design over Opus for cost; the task is description-writing, not reasoning.
  • ANTHROPIC_API_KEY is optional and its absence is a working configuration. Both compose files already carry the line and neither joins ALWAYS_REQUIRED. A missing key is treated exactly like a failed call: the submission keeps its photos and waits undrafted. Losing a consignment to an expired key is worse than an item arriving without a description.
  • No test spends money. The Anthropic client is injected and stubbed everywhere. A test that makes a real call is a defect in the test.
  • response.parsed_output is null when parsing fails — guard it, never assert with !. The SDK reference says so explicitly.
  • The note is untrusted input from an unauthenticated stranger. It is passed as data. Nothing the model returns is executed, interpolated into SQL, or rendered as HTML — the description goes through the same markdown-with-html: false treatment as every other stored body.
  • Every route handler stays wrapped in asyncRoutetests/unit/routesAreWrapped.test.ts enforces it.
  • Playwright and the integration suite need Node 20+; the machine default is 18.16.1. Use scripts/run-tests.ps1, or put a newer Node first on PATH for the command only — never nvm use, which needs elevation and rewrites a machine-global symlink.
  • Commit style: Conventional Commits, subject ending (#223), no hard wrapping in bodies.

The correctness surface

The prompt is not a style choice. On a one-of-a-kind item an invented "1930s hand-thrown stoneware" is a false claim on the storefront, not a cosmetic slip — and the person who would have to answer for it is the shop, not the model.

The note is the only trustworthy source for anything not visible in a photo. So the system prompt must say, and the tests must assert, that the model describes what is visible, uses the note for what is not, and states no material, age, maker or provenance found in neither.

File Structure

Created:

  • backend/src/intake/draftPrompt.ts — builds the system prompt and user content. Pure.
  • backend/src/intake/draftSchema.ts — the Zod shape the model must return. Pure.
  • backend/src/intake/anthropicClient.ts — constructs the SDK client, or null when unconfigured.
  • backend/src/intake/draftListing.ts — one call, one draft. Client injected.
  • backend/src/intake/applyDraft.ts — writes a result to items and item_drafts.
  • backend/src/intake/draftingWorker.ts — claims queued rows, applies results, counts attempts.
  • backend/tests/unit/draftPrompt.test.ts
  • backend/tests/unit/draftSchema.test.ts
  • backend/tests/unit/draftCost.test.ts
  • backend/tests/integration/drafting.integration.test.ts

Modified:

  • backend/package.json@anthropic-ai/sdk, zod
  • backend/src/envValidation.ts — warn when the key is absent, never fail
  • backend/src/routes/intake.ts — kick the worker after a successful submission
  • backend/src/server.ts — the sweeper

A directory rather than six files loose in src/: they are one concern, they change together, and src/ already has twenty-odd modules.


Task 1: Dependencies and configuration

Files:

  • Modify: backend/package.json, backend/src/envValidation.ts
  • Test: backend/tests/unit/envValidation.test.ts (extend)

Interfaces:

  • Produces: @anthropic-ai/sdk and zod available; a boot warning when ANTHROPIC_API_KEY is absent.

  • Step 1: Install

cd backend
npm install @anthropic-ai/sdk zod

Then confirm the two things this plan asserts but could not check before the packages existed:

ls node_modules/@anthropic-ai/sdk/helpers/          # expect a zod entry
grep -rn "zodOutputFormat" node_modules/@anthropic-ai/sdk/helpers/ | head -3

Task 4 imports zodOutputFormat from @anthropic-ai/sdk/helpers/zod. If the installed SDK exports it from somewhere else, that import is the thing to change — not the approach.

Both are production dependencies — the worker runs in the container. Confirm they landed under "dependencies", not "devDependencies": the final Docker stage installs with --omit=dev, so the wrong section produces a container that fails on the first submission and nowhere else. That is exactly how sharp went wrong in #226.

  • Step 2: Write the failing test

Add to backend/tests/unit/envValidation.test.ts:

The file already defines MINIMAL (a valid environment), withEnv(extra) and without(...names). Use those rather than adding a second fixture. MINIMAL does not include ANTHROPIC_API_KEY, so it is already the absent case.

describe('the intake drafting key', () => {
  // Absent is a working configuration, so this must never reach the errors
  // list. A submission that arrives undrafted is a far better outcome than a
  // container that will not boot.
  it('is not required', () => {
    expect(validateEnv(MINIMAL).errors).toEqual([]);
  });

  // But silence would be worse than a warning: an operator who thinks drafting
  // is on and finds every item undrafted has no way to tell why.
  it('warns when it is absent', () => {
    expect(validateEnv(MINIMAL).warnings.join(' ')).toMatch(/ANTHROPIC_API_KEY/);
  });

  it('says nothing when it is set', () => {
    const warnings = validateEnv(withEnv({ ANTHROPIC_API_KEY: 'sk-ant-test' })).warnings;
    expect(warnings.join(' ')).not.toMatch(/ANTHROPIC_API_KEY/);
  });
});
  • Step 3: Run it to verify it fails
cd backend && npx jest -c jest.unit.config.js envValidation

Expected: FAIL on the warning assertions.

  • Step 4: Add the warning

In backend/src/envValidation.ts, alongside the other warning rules:

// Absent is a working configuration: a submission still arrives, keeps its
// photos and waits undrafted. Warned about rather than required because losing
// somebody's consignment to an expired key would be far worse than an item
// arriving without its description written — and the photos are often the only
// copy of something no longer in the sender's hands. USPS is the precedent.
function checkDraftingKey(env: NodeJS.ProcessEnv): string[] {
  if (isPresent(env, 'ANTHROPIC_API_KEY')) return [];
  return [
    'ANTHROPIC_API_KEY is not set — submitted items will arrive undrafted and wait in the review queue.'
  ];
}

Add it to the warning composition the same way the existing rules are composed; do not nest it inside validateEnv, which is what keeps that function's cognitive complexity down.

  • Step 5: Verify
cd backend && npm run test:unit && npm run lint && npm run build

Expected: PASS, lint no new warnings, build clean.

  • Step 6: Commit
git add backend/package.json backend/package-lock.json backend/src/envValidation.ts backend/tests/unit/envValidation.test.ts
git commit -m "build(intake): add the Anthropic SDK and warn when its key is absent (#223)"

Task 2: The output shape

Files:

  • Create: backend/src/intake/draftSchema.ts, backend/tests/unit/draftSchema.test.ts

Interfaces:

  • Produces: DraftSchema (Zod), type DraftResult = z.infer<typeof DraftSchema>

  • Step 1: Write the failing test

Create backend/tests/unit/draftSchema.test.ts:

import { DraftSchema } from '../../src/intake/draftSchema';

const valid = {
  name: 'Blue stoneware vase',
  description: 'A hand-thrown vase with a chipped base.',
  category: 'Ceramics',
  tags: ['stoneware', 'blue'],
  suggestedPriceCents: 4500
};

describe('DraftSchema', () => {
  it('accepts a complete draft', () => {
    expect(DraftSchema.parse(valid)).toEqual(valid);
  });

  // The model is told to say nothing rather than guess, so every field it may
  // decline to answer has to be expressible as absent.
  it('accepts a draft with no category, tags or price', () => {
    const parsed = DraftSchema.parse({
      name: valid.name,
      description: valid.description,
      category: null,
      tags: [],
      suggestedPriceCents: null
    });
    expect(parsed.category).toBeNull();
    expect(parsed.suggestedPriceCents).toBeNull();
  });

  // A name and a description are the whole point. A draft without them is not
  // a partial success worth storing.
  it('refuses a draft with no name', () => {
    expect(() => DraftSchema.parse({ ...valid, name: '' })).toThrow();
  });

  it('refuses a draft with no description', () => {
    expect(() => DraftSchema.parse({ ...valid, description: '' })).toThrow();
  });

  // A negative or absurd price reaching the review queue would be a number
  // somebody has to notice is wrong. Cheaper to refuse it here.
  it('refuses a negative price', () => {
    expect(() => DraftSchema.parse({ ...valid, suggestedPriceCents: -1 })).toThrow();
  });

  it('refuses a price beyond anything this shop sells', () => {
    expect(() => DraftSchema.parse({ ...valid, suggestedPriceCents: 100_000_00 })).toThrow();
  });
});
  • Step 2: Run it to verify it fails
cd backend && npx jest -c jest.unit.config.js draftSchema

Expected: FAIL — module not found.

  • Step 3: Write the schema

Create backend/src/intake/draftSchema.ts:

import { z } from 'zod';

/**
 * What the model must return, enforced rather than hoped for.
 *
 * Constraining the shape is the difference between a bad draft and a crash:
 * the SDK validates the response against this before any of it reaches the
 * database, so a model that answers in prose or invents a field is a caught
 * error rather than a row full of nonsense.
 *
 * Every field the model may decline to answer is nullable, because it is told
 * to say nothing rather than guess. A null category means it did not recognise
 * one, which is a better answer than a wrong one and is what the review queue
 * exists to resolve.
 */

/** Beyond anything this shop sells, so an absurd number is caught here. */
const MAX_SUGGESTED_PRICE_CENTS = 1_000_000;

export const DraftSchema = z.object({
  /** A short title. The item arrives named for its submission timestamp. */
  name: z.string().min(1).max(200),
  /** The storefront body. Markdown, rendered with html: false like every other stored body. */
  description: z.string().min(1).max(4000),
  /**
   * Chosen from the categories it was given, or null. The prompt supplies the
   * closed set; this cannot enforce membership, so applyDraft checks it against
   * the real table before writing anything.
   */
  category: z.string().nullable(),
  /** Also from a supplied set, and also checked on the way in rather than here. */
  tags: z.array(z.string()),
  /**
   * Cents, or null when it will not guess. Bounded at both ends because a
   * negative or absurd figure reaching the review queue is a number somebody
   * has to notice is wrong, and the queue's whole job is being trustworthy.
   */
  suggestedPriceCents: z.number().int().min(0).max(MAX_SUGGESTED_PRICE_CENTS).nullable()
});

export type DraftResult = z.infer<typeof DraftSchema>;
  • Step 4: Run it to verify it passes
cd backend && npx jest -c jest.unit.config.js draftSchema

Expected: PASS, 6 tests.

  • Step 5: Commit
git add backend/src/intake/draftSchema.ts backend/tests/unit/draftSchema.test.ts
git commit -m "feat(intake): constrain what a draft may contain (#223)"

Task 3: The prompt

This is the correctness surface. Read "The correctness surface" above before writing it.

Files:

  • Create: backend/src/intake/draftPrompt.ts, backend/tests/unit/draftPrompt.test.ts

Interfaces:

  • Consumes: nothing

  • Produces:

    • buildSystemPrompt(categories: string[], tags: string[]): string
    • buildUserContent(photos: {mediaType: string; base64: string}[], note: string | null): unknown[]
  • Step 1: Write the failing test

Create backend/tests/unit/draftPrompt.test.ts:

import { buildSystemPrompt, buildUserContent } from '../../src/intake/draftPrompt';

describe('buildSystemPrompt', () => {
  const prompt = buildSystemPrompt(['Ceramics', 'Textiles'], ['vintage', 'blue']);

  // The whole reason this function is tested rather than inlined. On a
  // one-of-a-kind item an invented age or maker is a false claim on the
  // storefront, and nothing downstream can tell an invented detail from an
  // observed one.
  it('forbids inventing what is neither visible nor in the note', () => {
    expect(prompt).toMatch(/do not (state|invent)/i);
    expect(prompt).toMatch(/material|age|maker|provenance/i);
  });

  it('offers the categories it may choose from', () => {
    expect(prompt).toContain('Ceramics');
    expect(prompt).toContain('Textiles');
  });

  it('offers the tags it may choose from', () => {
    expect(prompt).toContain('vintage');
    expect(prompt).toContain('blue');
  });

  // Otherwise a model with no matching option picks the closest wrong one.
  it('permits declining a category', () => {
    expect(prompt).toMatch(/null/i);
  });

  it('survives a shop with no categories or tags yet', () => {
    expect(() => buildSystemPrompt([], [])).not.toThrow();
  });
});

describe('buildUserContent', () => {
  const photo = { mediaType: 'image/jpeg', base64: 'AAAA' };

  it('sends every photo as an image block', () => {
    const content = buildUserContent([photo, photo], 'a note') as {
      type: string;
      source?: { media_type: string; data: string };
    }[];
    const images = content.filter((block) => block.type === 'image');

    expect(images).toHaveLength(2);
    expect(images[0]?.source?.media_type).toBe('image/jpeg');
    expect(images[0]?.source?.data).toBe('AAAA');
  });

  it('includes the note verbatim', () => {
    const text = JSON.stringify(buildUserContent([photo], 'Chipped base, bought 1998'));
    expect(text).toContain('Chipped base, bought 1998');
  });

  // A submission with no note is ordinary — the field is optional — and the
  // model has to be told that rather than left to read an empty string as a
  // fact about the item.
  it('says so when there is no note', () => {
    const text = JSON.stringify(buildUserContent([photo], null));
    expect(text).toMatch(/no (note|description)/i);
  });
});
  • Step 2: Run it to verify it fails
cd backend && npx jest -c jest.unit.config.js draftPrompt

Expected: FAIL — module not found.

  • Step 3: Write the prompt builder

Create backend/src/intake/draftPrompt.ts:

/**
 * What the model is told, and what it is shown.
 *
 * Pure and separately tested because this is where the correctness of every
 * draft is decided. Nothing downstream can distinguish an observed detail from
 * an invented one — the description arrives as prose either way — so the only
 * place that distinction can be enforced is here, in the instruction.
 *
 * On a one-of-a-kind item an invented "1930s hand-thrown stoneware" is a false
 * claim on a storefront, and it is the shop that answers for it rather than the
 * model. The submitter's note is the only trustworthy source for anything a
 * photograph cannot show.
 */

export function buildSystemPrompt(categories: string[], tags: string[]): string {
  // Listed rather than described, so the model chooses from what exists instead
  // of inventing a taxonomy the storefront filters know nothing about.
  const categoryList = categories.length > 0 ? categories.join(', ') : '(none defined yet)';
  const tagList = tags.length > 0 ? tags.join(', ') : '(none defined yet)';

  return [
    'You write short listings for a shop that sells one-of-a-kind second-hand items.',
    '',
    'You are given photographs of a single item, and sometimes a note from the person sending it in.',
    '',
    'Describe only what you can see in the photographs, plus whatever the note tells you.',
    'Do not state a material, age, maker, or provenance that is neither visible nor in the note.',
    'If you do not know something, leave it out rather than guessing — a wrong detail here becomes',
    'a false claim on a public shop, and the shop answers for it rather than you.',
    '',
    `Choose a category from this list, or null if none fits: ${categoryList}`,
    `Choose tags from this list, or an empty list if none fit: ${tagList}`,
    'Do not invent categories or tags that are not listed.',
    '',
    'Suggest a price in cents if the photographs and note give you enough to judge one,',
    'or null if they do not. A person reviews it before anything is listed.'
  ].join('\n');
}

interface Photo {
  mediaType: string;
  base64: string;
}

/**
 * The photos, then the note.
 *
 * Images first because the note refers to them. The note is quoted and labelled
 * as coming from the sender rather than merged into the instruction: it is
 * untrusted text from an unauthenticated stranger, and it should read as
 * evidence to weigh rather than as something the shop is asserting.
 */
export function buildUserContent(photos: Photo[], note: string | null): unknown[] {
  const blocks: unknown[] = photos.map((photo) => ({
    type: 'image',
    source: { type: 'base64', media_type: photo.mediaType, data: photo.base64 }
  }));

  blocks.push({
    type: 'text',
    text:
      note && note.trim() !== ''
        ? `The sender wrote this about the item:\n\n${note}`
        : 'The sender left no note, so the photographs are all you have.'
  });

  return blocks;
}
  • Step 4: Run it to verify it passes
cd backend && npx jest -c jest.unit.config.js draftPrompt

Expected: PASS, 8 tests.

  • Step 5: Commit
git add backend/src/intake/draftPrompt.ts backend/tests/unit/draftPrompt.test.ts
git commit -m "feat(intake): tell the model to describe rather than invent (#223)"

Task 4: One call, one draft

Files:

  • Create: backend/src/intake/anthropicClient.ts, backend/src/intake/draftListing.ts
  • Test: extend backend/tests/unit/draftCost.test.ts

Interfaces:

  • Consumes: DraftSchema, buildSystemPrompt, buildUserContent

  • Produces:

    • getAnthropicClient(): Anthropic | null — null when unconfigured
    • costMicros(model: string, inputTokens: number, outputTokens: number): number
    • draftListing(client, input): Promise<{ draft: DraftResult; usage: {...} }>
  • Step 1: Write the failing cost test

Create backend/tests/unit/draftCost.test.ts:

import { costMicros } from '../../src/intake/draftListing';

// Sonnet 5 is $2 per million input tokens and $10 per million output.
describe('costMicros', () => {
  it('prices a million input tokens at two dollars', () => {
    expect(costMicros('claude-sonnet-5', 1_000_000, 0)).toBe(2_000_000);
  });

  it('prices a million output tokens at ten dollars', () => {
    expect(costMicros('claude-sonnet-5', 0, 1_000_000)).toBe(10_000_000);
  });

  it('adds both halves', () => {
    expect(costMicros('claude-sonnet-5', 1_000_000, 1_000_000)).toBe(12_000_000);
  });

  it('rounds to whole micros rather than carrying a fraction', () => {
    expect(Number.isInteger(costMicros('claude-sonnet-5', 1, 1))).toBe(true);
  });

  // An unknown model must not silently price at zero, which would make a
  // budget ceiling read as unspent however much was actually used.
  it('falls back to a non-zero rate for an unrecognised model', () => {
    expect(costMicros('some-future-model', 1_000_000, 0)).toBeGreaterThan(0);
  });
});
  • Step 2: Run it to verify it fails
cd backend && npx jest -c jest.unit.config.js draftCost

Expected: FAIL — module not found.

  • Step 3: Write the client factory

Create backend/src/intake/anthropicClient.ts:

import Anthropic from '@anthropic-ai/sdk';

/**
 * The client, or null when there is no key.
 *
 * Null rather than a throw, because an unconfigured environment is a working
 * one: submissions still arrive and wait undrafted. The worker treats null
 * exactly as it treats a failed call, which keeps one path rather than two.
 *
 * Constructed once. The SDK holds a connection pool, and building one per
 * submission would be wasteful on a route a stranger can trigger.
 */
let cached: Anthropic | null = null;
let resolved = false;

export function getAnthropicClient(): Anthropic | null {
  if (resolved) return cached;

  const key = process.env.ANTHROPIC_API_KEY;
  cached = key && key.trim() !== '' ? new Anthropic({ apiKey: key }) : null;
  resolved = true;

  return cached;
}

/** Exposed for tests, which need a fresh decision per case. */
export function resetAnthropicClient(): void {
  cached = null;
  resolved = false;
}
  • Step 4: Write the drafting call

Create backend/src/intake/draftListing.ts:

import type Anthropic from '@anthropic-ai/sdk';
import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod';
import { DraftSchema, DraftResult } from './draftSchema';
import { buildSystemPrompt, buildUserContent } from './draftPrompt';

/**
 * The model, from configuration with a default in code.
 *
 * Sonnet rather than Opus: the task is writing a description from a photograph,
 * not reasoning, and this runs once per submission on a public route. The
 * variable exists so the choice can be revisited without a deploy.
 */
const DEFAULT_MODEL = 'claude-sonnet-5';

export function draftingModel(): string {
  return process.env.INTAKE_MODEL || DEFAULT_MODEL;
}

/**
 * Dollars per million tokens, as micros-per-token, so the arithmetic below is
 * integer and a cost never carries a floating-point fraction into the database.
 *
 * The fallback is deliberately not zero. An unrecognised model pricing at
 * nothing would make a budget ceiling read as unspent however much was really
 * spent, which is the one failure a spend guard must not have.
 */
// CONFIRM THESE against the current pricing page before committing. They are
// the one set of numbers in this plan taken from memory rather than read out of
// the repository, and the test above pins them exactly. If a rate differs,
// change the table and the test together — a cost column that is quietly wrong
// by half is worse than no cost column, because #227 sets a budget from it.
const RATES: Record<string, { input: number; output: number }> = {
  'claude-sonnet-5': { input: 2, output: 10 },
  'claude-opus-5': { input: 5, output: 25 },
  'claude-haiku-4-5': { input: 1, output: 5 }
};
const FALLBACK_RATE = { input: 5, output: 25 };

export function costMicros(model: string, inputTokens: number, outputTokens: number): number {
  const rate = RATES[model] ?? FALLBACK_RATE;
  return Math.round(inputTokens * rate.input + outputTokens * rate.output);
}

export interface DraftInput {
  photos: { mediaType: string; base64: string }[];
  note: string | null;
  categories: string[];
  tags: string[];
}

export interface DraftOutcome {
  draft: DraftResult;
  model: string;
  inputTokens: number;
  outputTokens: number;
  costMicros: number;
}

/**
 * One submission, one draft.
 *
 * The client is a parameter rather than a module import so every test can pass
 * a stub. A test that reaches the real API is a defect in the test: this runs
 * on a public route and each call costs money.
 */
export async function draftListing(
  client: Anthropic,
  input: DraftInput
): Promise<DraftOutcome> {
  const model = draftingModel();

  const response = await client.messages.parse({
    model,
    max_tokens: 2000,
    system: buildSystemPrompt(input.categories, input.tags),
    messages: [{ role: 'user', content: buildUserContent(input.photos, input.note) as never }],
    output_config: { format: zodOutputFormat(DraftSchema) }
  });

  // Null when the response did not satisfy the schema. Guarded rather than
  // asserted with `!`: the SDK documents this as a real outcome, and a model
  // answering in prose is exactly the case worth failing cleanly on.
  const draft = response.parsed_output;
  if (!draft) {
    throw new Error('the model did not return a draft matching the expected shape');
  }

  const inputTokens = response.usage?.input_tokens ?? 0;
  const outputTokens = response.usage?.output_tokens ?? 0;

  return {
    draft,
    model,
    inputTokens,
    outputTokens,
    costMicros: costMicros(model, inputTokens, outputTokens)
  };
}
  • Step 5: Run the tests and build
cd backend && npx jest -c jest.unit.config.js draftCost && npm run build && npm run lint

Expected: PASS 5 tests, build clean, no new lint warnings.

  • Step 6: Commit
git add backend/src/intake/anthropicClient.ts backend/src/intake/draftListing.ts backend/tests/unit/draftCost.test.ts
git commit -m "feat(intake): draft a listing from photos and a note (#223)"

Task 5: Writing a draft back

Files:

  • Create: backend/src/intake/applyDraft.ts
  • Test: backend/tests/integration/drafting.integration.test.ts

Interfaces:

  • Consumes: DraftResult

  • Produces: applyDraft(client: PoolClient, itemId: number, outcome: DraftOutcome): Promise<void>

  • Step 1: Write the failing test

Create backend/tests/integration/drafting.integration.test.ts:

import { pool } from '../../src/db';
import { applyDraft } from '../../src/intake/applyDraft';
import { resetDb, closeDb } from './setup/testDb';

beforeEach(async () => {
  await resetDb();
});

afterAll(async () => {
  await pool.end();
  await closeDb();
});

async function seedSubmission(): Promise<number> {
  const { rows } = await pool.query<{ id: number }>(
    `INSERT INTO items (name, status) VALUES ('Submission placeholder', 'pending') RETURNING id`
  );
  const itemId = rows[0]!.id;
  await pool.query(`INSERT INTO item_drafts (item_id, submitter_note) VALUES ($1, 'a note')`, [
    itemId
  ]);
  return itemId;
}

const outcome = {
  draft: {
    name: 'Blue stoneware vase',
    description: 'Hand-thrown, chipped base.',
    category: null,
    tags: [],
    suggestedPriceCents: 4500
  },
  model: 'claude-sonnet-5',
  inputTokens: 1000,
  outputTokens: 200,
  costMicros: 4000
};

describe('applying a draft', () => {
  it('writes the copy onto the draft row, not the item', async () => {
    const itemId = await seedSubmission();
    const client = await pool.connect();
    await applyDraft(client, itemId, outcome);
    client.release();

    const { rows } = await pool.query(
      `SELECT ai_name, ai_description, state, model FROM item_drafts WHERE item_id = $1`,
      [itemId]
    );
    expect(rows[0]?.ai_name).toBe('Blue stoneware vase');
    expect(rows[0]?.state).toBe('ready');
    expect(rows[0]?.model).toBe('claude-sonnet-5');

    // The item keeps its placeholder name until a person approves the draft.
    // Nothing a model wrote should reach the catalogue unreviewed.
    const item = await pool.query(`SELECT name FROM items WHERE id = $1`, [itemId]);
    expect(item.rows[0]?.name).toBe('Submission placeholder');
  });

  // The one thing that does reach the item, because #220 chose pre-pricing over
  // an unpriced row — with price_source recording that nobody chose it.
  it('writes a suggested price onto the item and records where it came from', async () => {
    const itemId = await seedSubmission();
    const client = await pool.connect();
    await applyDraft(client, itemId, outcome);
    client.release();

    const item = await pool.query(`SELECT price_cents FROM items WHERE id = $1`, [itemId]);
    expect(item.rows[0]?.price_cents).toBe(4500);

    const draft = await pool.query(
      `SELECT price_source, ai_suggested_price_cents FROM item_drafts WHERE item_id = $1`,
      [itemId]
    );
    expect(draft.rows[0]?.price_source).toBe('ai');
    expect(draft.rows[0]?.ai_suggested_price_cents).toBe(4500);
  });

  it('leaves the default price alone when the model would not guess', async () => {
    const itemId = await seedSubmission();
    const client = await pool.connect();
    await applyDraft(client, itemId, { ...outcome, draft: { ...outcome.draft, suggestedPriceCents: null } });
    client.release();

    const item = await pool.query(`SELECT price_cents FROM items WHERE id = $1`, [itemId]);
    expect(item.rows[0]?.price_cents).toBe(8000);

    const draft = await pool.query(`SELECT price_source FROM item_drafts WHERE item_id = $1`, [itemId]);
    expect(draft.rows[0]?.price_source).toBe('default');
  });

  it('records what the call cost', async () => {
    const itemId = await seedSubmission();
    const client = await pool.connect();
    await applyDraft(client, itemId, outcome);
    client.release();

    const { rows } = await pool.query(
      `SELECT input_tokens, output_tokens, cost_micros, drafted_at FROM item_drafts WHERE item_id = $1`,
      [itemId]
    );
    expect(rows[0]?.input_tokens).toBe(1000);
    expect(rows[0]?.cost_micros).toBe(4000);
    expect(rows[0]?.drafted_at).not.toBeNull();
  });

  // A category the shop does not have would break the storefront filters, and
  // the schema cannot enforce membership — so it is checked here.
  it('ignores a category that does not exist', async () => {
    const itemId = await seedSubmission();
    const client = await pool.connect();
    await applyDraft(client, itemId, {
      ...outcome,
      draft: { ...outcome.draft, category: 'Invented Category' }
    });
    client.release();

    const { rows } = await pool.query(
      `SELECT ai_category_id FROM item_drafts WHERE item_id = $1`,
      [itemId]
    );
    expect(rows[0]?.ai_category_id).toBeNull();
  });
});
  • Step 2: Run it to verify it fails

Bring up a database first (see Global Constraints), then:

cd backend && npx jest -c jest.integration.config.js --runInBand drafting.integration

Expected: FAIL — module not found.

  • Step 3: Write it

Create backend/src/intake/applyDraft.ts:

import { PoolClient } from 'pg';
import { DraftOutcome } from './draftListing';

/**
 * Writes a finished draft to the database.
 *
 * The copy goes on `item_drafts`, never on the item. The item keeps its
 * placeholder name and description until a person approves them in the review
 * queue (#225) — nothing a model wrote reaches the catalogue unreviewed.
 *
 * The price is the deliberate exception, because #220 chose to price an item on
 * arrival rather than leave it unpriced. `price_source` records that the number
 * came from a model rather than a person, which is what lets the review queue
 * mark it as unconfirmed.
 *
 * A category is checked against the real table before it is stored. The schema
 * constrains the shape of the answer but cannot enforce membership, and a
 * category the shop does not have would be invisible to every storefront
 * filter — a draft nobody could find rather than an obvious error.
 */
export async function applyDraft(
  client: PoolClient,
  itemId: number,
  outcome: DraftOutcome
): Promise<void> {
  const { draft } = outcome;

  const categoryId = draft.category
    ? (
        await client.query<{ id: number }>(`SELECT id FROM categories WHERE name = $1`, [
          draft.category
        ])
      ).rows[0]?.id ?? null
    : null;

  await client.query(
    `UPDATE item_drafts
     SET state = 'ready',
         model = $2,
         ai_name = $3,
         ai_description = $4,
         ai_category_id = $5,
         ai_tag_names = $6,
         ai_suggested_price_cents = $7,
         price_source = $8,
         input_tokens = $9,
         output_tokens = $10,
         cost_micros = $11,
         ai_error = NULL,
         drafted_at = now()
     WHERE item_id = $1`,
    [
      itemId,
      outcome.model,
      draft.name,
      draft.description,
      categoryId,
      draft.tags,
      draft.suggestedPriceCents,
      draft.suggestedPriceCents === null ? 'default' : 'ai',
      outcome.inputTokens,
      outcome.outputTokens,
      outcome.costMicros
    ]
  );

  // Only when there is one. Absent, the item keeps the migration's default and
  // price_source stays 'default' — the review queue shows both the same way,
  // as a number nobody chose.
  if (draft.suggestedPriceCents !== null) {
    await client.query(`UPDATE items SET price_cents = $2 WHERE id = $1`, [
      itemId,
      draft.suggestedPriceCents
    ]);
  }
}
  • Step 4: Run it to verify it passes
cd backend && npx jest -c jest.integration.config.js --runInBand drafting.integration

Expected: PASS, 5 tests.

  • Step 5: Commit
git add backend/src/intake/applyDraft.ts backend/tests/integration/drafting.integration.test.ts
git commit -m "feat(intake): record a draft without publishing it (#223)"

Task 6: The worker

Files:

  • Create: backend/src/intake/draftingWorker.ts
  • Test: extend backend/tests/integration/drafting.integration.test.ts

Interfaces:

  • Consumes: everything above

  • Produces: draftQueued(limit?: number): Promise<{ drafted: number; failed: number; skipped: number }>

  • Step 1: Write the failing tests

Append to backend/tests/integration/drafting.integration.test.ts:

import { draftQueued, MAX_ATTEMPTS } from '../../src/intake/draftingWorker';
import { resetAnthropicClient } from '../../src/intake/anthropicClient';

describe('the drafting worker', () => {
  beforeEach(() => {
    delete process.env.ANTHROPIC_API_KEY;
    resetAnthropicClient();
  });

  // The case that must never lose a submission. An unconfigured environment is
  // a working one: the item keeps its photos and waits.
  it('leaves submissions queued when there is no key', async () => {
    const itemId = await seedSubmission();

    const result = await draftQueued();

    expect(result.skipped).toBe(1);
    const { rows } = await pool.query(`SELECT state, attempts FROM item_drafts WHERE item_id = $1`, [
      itemId
    ]);
    expect(rows[0]?.state).toBe('queued');
    // Skipping is not an attempt. Otherwise a fortnight without a key would
    // exhaust the retries and mark everything failed.
    expect(rows[0]?.attempts).toBe(0);
  });

  it('gives up after MAX_ATTEMPTS and keeps the item', async () => {
    const itemId = await seedSubmission();
    await pool.query(`UPDATE item_drafts SET attempts = $2 WHERE item_id = $1`, [
      itemId,
      MAX_ATTEMPTS
    ]);

    const result = await draftQueued();

    expect(result.skipped).toBe(1);
    const item = await pool.query(`SELECT id, status FROM items WHERE id = $1`, [itemId]);
    expect(item.rows[0]?.status).toBe('pending');
  });
});
  • Step 2: Run it to verify it fails
cd backend && npx jest -c jest.integration.config.js --runInBand drafting.integration

Expected: FAIL — module not found.

  • Step 3: Write the worker

Create backend/src/intake/draftingWorker.ts:

import { promises as fs } from 'fs';
import path from 'path';
import { pool } from '../db';
import { typeForExtension } from '../uploadTypes';
import { getAnthropicClient } from './anthropicClient';
import { draftListing } from './draftListing';
import { applyDraft } from './applyDraft';

/**
 * Turns queued submissions into drafts.
 *
 * Driven from two places: a call at the end of a successful submission, so a
 * draft is usually waiting by the time anybody looks, and a cron sweeper, so a
 * restart mid-draft is recoverable rather than a permanently stalled row.
 *
 * The governing rule is that a submission is the only irreplaceable thing here.
 * The photos may be the only copy of an item no longer in the sender's hands,
 * so every failure below leaves the row and its images intact and merely
 * undrafted. Nothing in this file deletes anything.
 */

/** Three tries, then it waits for a person rather than burning money on a loop. */
export const MAX_ATTEMPTS = 3;

const UPLOADS_DIR = process.env.UPLOADS_DIR || '/app/uploads';

interface QueuedRow {
  item_id: number;
  submitter_note: string | null;
}

async function readPhotos(itemId: number): Promise<{ mediaType: string; base64: string }[]> {
  const { rows } = await pool.query<{ image_path: string }>(
    `SELECT image_path FROM item_images WHERE item_id = $1 ORDER BY sort_order`,
    [itemId]
  );

  const photos = [];
  for (const row of rows) {
    // basename only: image_path is stored as '/uploads/<name>' and the
    // directory it lives in is a server constant. Same rule as handleRow in
    // backfillImageReencode.ts — but using the shared typeForExtension rather
    // than that file's private copy of the map.
    const file = path.join(UPLOADS_DIR, path.basename(row.image_path));
    const mediaType = typeForExtension(path.extname(file));
    // typeForExtension returns null for anything the app would refuse to serve.
    // Sending it to the model would be paying to have it refused.
    if (!mediaType) continue;
    photos.push({ mediaType, base64: (await fs.readFile(file)).toString('base64') });
  }
  return photos;
}

async function namesOf(table: 'categories' | 'tags'): Promise<string[]> {
  const { rows } = await pool.query<{ name: string }>(`SELECT name FROM ${table} ORDER BY name`);
  return rows.map((row) => row.name);
}

export async function draftQueued(
  limit = 5
): Promise<{ drafted: number; failed: number; skipped: number }> {
  const client = getAnthropicClient();

  const { rows } = await pool.query<QueuedRow>(
    `SELECT item_id, submitter_note FROM item_drafts
     WHERE state = 'queued' AND attempts < $2
     ORDER BY created_at
     LIMIT $1`,
    [limit, MAX_ATTEMPTS]
  );

  // Unconfigured is not a failure and must not spend an attempt. A fortnight
  // without a key would otherwise exhaust the retries and mark every waiting
  // submission failed, with nothing wrong with any of them.
  if (!client) {
    return { drafted: 0, failed: 0, skipped: rows.length };
  }

  let drafted = 0;
  let failed = 0;

  for (const row of rows) {
    const photos = await readPhotos(row.item_id);
    if (photos.length === 0) {
      await pool.query(
        `UPDATE item_drafts SET attempts = attempts + 1, state = 'failed',
           ai_error = 'no readable photos' WHERE item_id = $1`,
        [row.item_id]
      );
      failed++;
      continue;
    }

    try {
      const outcome = await draftListing(client, {
        photos,
        note: row.submitter_note,
        categories: await namesOf('categories'),
        tags: await namesOf('tags')
      });

      const db = await pool.connect();
      try {
        await db.query('BEGIN');
        await applyDraft(db, row.item_id, outcome);
        await db.query('COMMIT');
      } catch (err) {
        await db.query('ROLLBACK');
        throw err;
      } finally {
        db.release();
      }
      drafted++;
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      console.error(`[drafting] item ${row.item_id}:`, message);

      // The row stays reachable either way: 'queued' under the cap so the
      // sweeper retries it, 'failed' once the tries are spent so it stops
      // costing money and waits for a person. The item and its photos are
      // untouched in both.
      await pool.query(
        `UPDATE item_drafts
         SET attempts = attempts + 1,
             ai_error = $2,
             state = CASE WHEN attempts + 1 >= $3 THEN 'failed' ELSE 'queued' END
         WHERE item_id = $1`,
        [row.item_id, message.slice(0, 500), MAX_ATTEMPTS]
      );
      failed++;
    }
  }

  return { drafted, failed, skipped: 0 };
}
  • Step 4: Run it to verify it passes
cd backend && npx jest -c jest.integration.config.js --runInBand drafting.integration

Expected: PASS, 7 tests.

  • Step 5: Commit
git add backend/src/intake/draftingWorker.ts backend/tests/integration/drafting.integration.test.ts
git commit -m "feat(intake): draft queued submissions without ever losing one (#223)"

Task 7: Wiring

Files:

  • Modify: backend/src/routes/intake.ts, backend/src/server.ts

  • Step 1: Kick the worker after a successful submission

In backend/src/routes/intake.ts, after await client.query('COMMIT'); and before the response:

      // Deliberately not awaited. A slow or failing model request must not turn
      // into a failed upload for someone who did nothing wrong — the whole
      // reason the AI is not called inline. The sweeper below picks up anything
      // this misses, so the only cost of it failing here is a short delay.
      void draftQueued(1).catch((err) => console.error('[drafting] after submission:', err));

Import it at the top:

import { draftQueued } from '../intake/draftingWorker';
  • Step 2: Add the sweeper

In backend/src/server.ts, beside the existing schedules:

// Every five minutes, in the same shape as the cart sweep above. This is what
// makes a restart mid-draft recoverable rather than a permanently stalled row,
// and what picks up anything the post-submission call missed. void, because an
// unhandled rejection here would take the container down with it.
setInterval(() => void draftQueued(), 5 * 60 * 1000);

Import it beside the other background work:

import { draftQueued } from './intake/draftingWorker';
  • Step 3: Verify nothing regressed
cd backend
npm run lint
npm run build
npm run test:unit
npx jest -c jest.integration.config.js --runInBand

Expected: all pass. The intake suite matters most — submissions must still succeed with no key configured, which is the state the test database runs in.

  • Step 4: Commit
git add backend/src/routes/intake.ts backend/src/server.ts
git commit -m "feat(intake): run the drafting worker after a submission and on a sweep (#223)"

Task 8: One real call

Everything above is stubbed. This is the only step that spends money, and it is the only one that proves the prompt produces something worth reading.

  • Step 1: Configure a key
$env:ANTHROPIC_API_KEY = "sk-ant-..."

Set a spend limit on the key in the Anthropic console first. Nothing in this repository can enforce one.

  • Step 2: Submit a real photograph

Start the stack, create an upload link in the admin, open /submit/<token>, and send a real photograph of a real object with a short note. A synthetic test image proves nothing about whether the writing is any good.

  • Step 3: Read what it wrote
SELECT ai_name, ai_description, ai_suggested_price_cents, price_source,
       input_tokens, output_tokens, cost_micros
FROM item_drafts ORDER BY id DESC LIMIT 1;

Judge three things, in order of importance:

  1. Does it claim anything it could not know? A material, an age, a maker, a provenance that is neither visible in the photograph nor in the note. This is the failure that matters — it is a false claim on a shop — and if it happens the prompt needs work before this ships, not after.
  2. Is the description worth editing rather than rewriting?
  3. Is cost_micros in the range the estimate suggested — a few pence per item?
  • Step 4: Submit one with no note

The note is optional and its absence is ordinary. Confirm the draft degrades to describing what is visible rather than inventing the rest.

  • Step 5: Record what it cost

Put the real figures on #223 as a comment. The design estimated a few pence per item; that estimate has never been checked against a real call, and the monthly budget in #227 should be set from a measured number rather than a guessed one.


Done when

  • A submission is drafted within a few minutes without anybody doing anything.
  • With no key configured, submissions still arrive, keep their photos, and stay queued with attempts at zero.
  • A failing call leaves the item pending with its images, and the draft failed after three tries.
  • The item's name and description are untouched; only a suggested price reaches it, with price_source = 'ai'.
  • A category the shop does not have is ignored rather than stored.
  • npm run lint, npm run build, npm run test:unit and the integration suite all pass.
  • A real photograph produces a draft that claims nothing it could not know.

Not in this plan

The monthly spend ceiling. The design mentions it, but a budget set from a guessed cost is a number nobody trusts — Task 8 measures the real one first, and #227 already exists for the submission ceiling that bounds volume. Filing the spend ceiling separately, informed by that measurement, is better than inventing a threshold here.

The review queue that reads these drafts is #225. Until it exists, drafts are visible only in the database — which is fine: nothing is published either way.