Only the manual verification against a real photograph is left, and it needs an API key that does not exist yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
48 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 (
- [x]) 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, fromINTAKE_MODEL, defaulting in code. Chosen in the design over Opus for cost; the task is description-writing, not reasoning. ANTHROPIC_API_KEYis optional and its absence is a working configuration. Both compose files already carry the line and neither joinsALWAYS_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_outputisnullwhen 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: falsetreatment as every other stored body. - Every route handler stays wrapped in
asyncRoute—tests/unit/routesAreWrapped.test.tsenforces 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 onPATHfor the command only — nevernvm 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 toitemsanditem_drafts.backend/src/intake/draftingWorker.ts— claims queued rows, applies results, counts attempts.backend/tests/unit/draftPrompt.test.tsbackend/tests/unit/draftSchema.test.tsbackend/tests/unit/draftCost.test.tsbackend/tests/integration/drafting.integration.test.ts
Modified:
backend/package.json—@anthropic-ai/sdk,zodbackend/src/envValidation.ts— warn when the key is absent, never failbackend/src/routes/intake.ts— kick the worker after a successful submissionbackend/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/sdkandzodavailable; a boot warning whenANTHROPIC_API_KEYis 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[]): stringbuildUserContent(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 3b: The model catalogue, and choosing it from Admin settings
Added mid-execution, at Thom's request: the model must be settable from the Admin settings page rather than only by an environment variable and a redeploy.
Two things fall out of that. A dropdown, not a free-text box — a mistyped model name fails on every submission and surfaces only as drafts quietly not appearing, so the valid set is enforced on the server rather than merely offered by the UI. And one catalogue, not two — the settings dropdown needs the model list, costMicros needs each model's rates, and those must not be two lists that drift. So the catalogue is a module both import.
Rates confirmed against the pricing page on 2026-08-31, not recalled: Sonnet 5 $2/$10, Opus 5 $5/$25, Haiku 4.5 $1/$5 per million input/output tokens. Worth having checked — an increase to $3/$15 had been scheduled for 2026-09-01 and was cancelled, with $2/$10 made permanent.
Files:
- Create:
backend/src/intake/models.ts,backend/tests/unit/draftCost.test.ts - Modify:
backend/src/adminSettings.ts(achoicetype),backend/src/routes/adminSettings.ts,frontend/src/admin/Settings.tsx
Interfaces:
-
Produces:
DRAFTING_MODELS,DEFAULT_DRAFTING_MODEL,isDraftingModel(),costMicros(), and adraftingModeladmin setting. -
Step 1: The catalogue, test first.
costMicros(model, input, output)in whole micros. An unrecognised model must price above zero — a budget that reads as unspent however much was spent is the one failure a spend guard cannot have. -
Step 2: Add a
choicetype toadminSettings.ts. The module's own docstring says adding a setting means adding a row and nothing else; that holds forhoursandtext, and a third type is what makes it hold for a constrained one. Row:{ key: 'drafting_model', name: 'draftingModel', type: 'choice', fallback: DEFAULT_DRAFTING_MODEL, options: [...] }. -
Step 3: Validate membership in the PUT route, as a third loop beside the hours and text loops. A value outside the set is a 400, not a stored string that breaks drafting later.
-
Step 4: The dropdown in
Settings.tsx, showing each model's price so the person switching can see that Opus costs 2.5x Sonnet before they pick it. -
Step 5:
npm run lint && npm run build && npm run test:unit, and the frontend's checks. -
Step 6: Commit.
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 unconfiguredcostMicros(model: string, inputTokens: number, outputTokens: number): numberdraftListing(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';
// Read from Admin settings (Task 3b) rather than the environment, so the choice
// can be changed without a redeploy. getSettings() supplies the fallback, so
// there is no default written twice here to disagree with the one there.
async function draftingModel(): Promise<string> {
return (await getSettings()).draftingModel;
}
/**
* 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.
*/
// Rates and costMicros live in ./models, shared with the Admin settings
// dropdown so the list of models and their prices cannot drift apart.
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)"
Tasks 1-7 complete as of 2026-08-31, on
feature/223-drafting-worker. Task 8 is the only manual step and is blocked on an API key existing.
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:
- 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.
- Is the description worth editing rather than rewriting?
- Is
cost_microsin 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
queuedwithattemptsat zero. - A failing call leaves the item
pendingwith its images, and the draftfailedafter 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:unitand 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.