Slice 2 of #220. Depends on the slice-1 issue being done — it adds behaviour to the item_drafts rows that slice already creates, and needs no schema of its own.
The first LLM integration in this codebase. A backend/src/intakeDrafting.ts worker, driven by a call at the end of a successful submission and by a node-cron sweeper that picks up anything left queued or stuck in drafting — the sweeper is what makes a restart mid-draft recoverable rather than a permanently stalled row.
The request goes through @anthropic-ai/sdk with client.messages.parse() and zodOutputFormat, so the shape is validated rather than parsed out of prose. It is given every photo as an image block, the submitter's note, and the existing categories and tags as the closed set to choose from. It returns a name, a marketing description, a category, tags, and a suggested price.
The prompt is a correctness surface, not a style choice
The note is the only trustworthy source of anything not visible in a photo. A model cannot see that a vase is hand-thrown stoneware or 1930s, and on a one-of-a-kind item an invented "1930s hand-thrown stoneware" is not a cosmetic error — it is a false claim on a storefront. The system prompt has to say plainly: describe what is visible, use the note for what is not, and state neither a material nor an age nor a maker nor a provenance that is in neither.
The note is also 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.
Pricing
Where the model returns a price it is written onto the item with price_source='ai'; where it does not, the item keeps the 8000 default and price_source stays 'default'. The suggestion is recorded on the draft either way, so what the model proposed stays readable after the admin has edited the price — otherwise there is no way to ask later whether its numbers were any good.
Config
ANTHROPIC_API_KEY, INTAKE_MODEL (defaults to claude-sonnet-5), INTAKE_MONTHLY_BUDGET_USD (defaults to 20). Required only when intake is enabled, so an environment that does not run it is unaffected. Anything added to the always-required list must also reach both compose files — composeEnvironment.test.ts enforces that, which is what #107 exists to prevent recurring.
Failure is the main design constraint
A submission is the only irreplaceable thing here: the photos may be the only copy and the item may no longer be in the sender's hands. Three attempts, then state='failed' — leaving a perfectly good pending item with photos, just no drafted copy. Past the monthly budget the API call is skipped and the submission still saves and still notifies. The ceiling protects the bill; it must never be the thing that loses inventory.
Tests stub the Anthropic client. No test spends money.
Slice 2 of #220. Depends on the slice-1 issue being done — it adds behaviour to the `item_drafts` rows that slice already creates, and needs no schema of its own.
**Design:** `docs/superpowers/specs/2026-08-29-intake-pipeline-design.md`
## Scope
The first LLM integration in this codebase. A `backend/src/intakeDrafting.ts` worker, driven by a call at the end of a successful submission and by a `node-cron` sweeper that picks up anything left `queued` or stuck in `drafting` — the sweeper is what makes a restart mid-draft recoverable rather than a permanently stalled row.
The request goes through `@anthropic-ai/sdk` with `client.messages.parse()` and `zodOutputFormat`, so the shape is validated rather than parsed out of prose. It is given every photo as an image block, the submitter's note, and the existing categories and tags as the closed set to choose from. It returns a name, a marketing description, a category, tags, and a suggested price.
## The prompt is a correctness surface, not a style choice
The note is the only trustworthy source of anything not visible in a photo. A model cannot see that a vase is hand-thrown stoneware or 1930s, and on a one-of-a-kind item an invented "1930s hand-thrown stoneware" is not a cosmetic error — it is a false claim on a storefront. The system prompt has to say plainly: describe what is visible, use the note for what is not, and state neither a material nor an age nor a maker nor a provenance that is in neither.
The note is also 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.
## Pricing
Where the model returns a price it is written onto the item with `price_source='ai'`; where it does not, the item keeps the 8000 default and `price_source` stays `'default'`. The suggestion is recorded on the draft either way, so what the model proposed stays readable after the admin has edited the price — otherwise there is no way to ask later whether its numbers were any good.
## Config
`ANTHROPIC_API_KEY`, `INTAKE_MODEL` (defaults to `claude-sonnet-5`), `INTAKE_MONTHLY_BUDGET_USD` (defaults to 20). Required only when intake is enabled, so an environment that does not run it is unaffected. Anything added to the always-required list must also reach both compose files — `composeEnvironment.test.ts` enforces that, which is what #107 exists to prevent recurring.
## Failure is the main design constraint
A submission is the only irreplaceable thing here: the photos may be the only copy and the item may no longer be in the sender's hands. Three attempts, then `state='failed'` — leaving a perfectly good pending item with photos, just no drafted copy. Past the monthly budget the API call is skipped and the submission still saves and still notifies. The ceiling protects the bill; it must never be the thing that loses inventory.
Tests stub the Anthropic client. No test spends money.
Part of #220
Plan written: docs/superpowers/plans/2026-08-31-intake-drafting-worker.md, on feature/223-drafting-worker.
Eight tasks — the SDK and its key, the Zod shape the model must answer in, the prompt, the call, writing a draft back, the worker, the wiring, and one real photograph at the end to see whether any of it produces something worth reading.
One decision I made rather than asked about, recorded here so it can be reversed cheaply.ANTHROPIC_API_KEY is optional: absent, the container still boots, submissions still arrive, keep their photos, and wait in the queue undrafted, with a warning at startup saying so. The alternative is refusing to boot without it. I went with optional because the photos are often the only copy of an item that is no longer in the sender's hands, so losing a consignment to an expired key is a worse outcome than an item arriving without its description written. It also matches how USPS and PayPal are already treated. Say the word and it becomes required — it is one line in envValidation.ts and one test.
Two things the plan deliberately does not do:
The monthly spend ceiling is left to its own issue. The design mentions it, but nothing here has ever made a real call, so any threshold now would be invented. Task 8 measures what one submission actually costs and that number sets the budget. #227 already bounds volume in the meantime.
Nothing the model writes reaches the catalogue. The name and description stay on the draft row until the review queue in #225 exists. The single exception is the suggested price, which goes onto the item per the decision in #220, with price_source = 'ai' recording that a model rather than a person chose it.
The prompt gets its own task with its own tests, because it is where the correctness of every draft is decided. On one-of-a-kind stock an invented "1930s hand-thrown stoneware" is not a cosmetic error — it is a false claim on a storefront, and the shop answers for it rather than the model. Nothing downstream can distinguish an invented detail from an observed one, so the tests assert the instruction forbidding it is actually present.
Every test stubs the client. Nothing in the automated suites spends money; Task 8 is the only step that does, and it is manual and deliberate.
Plan written: `docs/superpowers/plans/2026-08-31-intake-drafting-worker.md`, on `feature/223-drafting-worker`.
Eight tasks — the SDK and its key, the Zod shape the model must answer in, the prompt, the call, writing a draft back, the worker, the wiring, and one real photograph at the end to see whether any of it produces something worth reading.
**One decision I made rather than asked about, recorded here so it can be reversed cheaply.** `ANTHROPIC_API_KEY` is optional: absent, the container still boots, submissions still arrive, keep their photos, and wait in the queue undrafted, with a warning at startup saying so. The alternative is refusing to boot without it. I went with optional because the photos are often the only copy of an item that is no longer in the sender's hands, so losing a consignment to an expired key is a worse outcome than an item arriving without its description written. It also matches how USPS and PayPal are already treated. Say the word and it becomes required — it is one line in `envValidation.ts` and one test.
Two things the plan deliberately does not do:
The **monthly spend ceiling** is left to its own issue. The design mentions it, but nothing here has ever made a real call, so any threshold now would be invented. Task 8 measures what one submission actually costs and that number sets the budget. #227 already bounds volume in the meantime.
**Nothing the model writes reaches the catalogue.** The name and description stay on the draft row until the review queue in #225 exists. The single exception is the suggested price, which goes onto the item per the decision in #220, with `price_source = 'ai'` recording that a model rather than a person chose it.
The prompt gets its own task with its own tests, because it is where the correctness of every draft is decided. On one-of-a-kind stock an invented "1930s hand-thrown stoneware" is not a cosmetic error — it is a false claim on a storefront, and the shop answers for it rather than the model. Nothing downstream can distinguish an invented detail from an observed one, so the tests assert the instruction forbidding it is actually present.
Every test stubs the client. Nothing in the automated suites spends money; Task 8 is the only step that does, and it is manual and deliberate.
Implemented, tasks 1–7, on feature/223-drafting-worker. 341 unit tests and 297 integration tests pass; lint and both builds are clean, with no new warnings.
A submitted item is now drafted within a few minutes of arriving — name, description, category, tags and a suggested price — driven by a call at the end of a successful submission and a five-minute sweeper that picks up anything stranded by a restart.
The drafting model is an Admin setting, per the request mid-implementation. It sits on the Settings page as a dropdown showing each model's price per million tokens, so switching Sonnet to Opus is visibly a 2.5x decision rather than a silent one. It is a dropdown validated on the server rather than a text box: the API only rejects an unknown model at the point of use, so a typo would be stored happily and then fail on every submission, surfacing as drafts quietly not appearing rather than as anything anybody could act on. A model retired after being chosen falls back to the default instead of being handed on.
The model list and the price table are one catalogue, in src/intake/models.ts. The dropdown needs the models and costMicros needs their rates, and the price shown beside a model in Admin has to be the price it is billed at — which it cannot be if the two are kept separately. Rates were confirmed against the pricing page rather than recalled, which was worth doing: an increase to $3/$15 had been scheduled for tomorrow, 1 September, and was cancelled with Sonnet's $2/$10 made permanent.
Everything above is verified against a stub. No automated test spends money, and Task 8 — one real photograph, to judge whether the prompt produces something worth reading — is the only step that will. It is blocked on the key existing.
Two behaviours worth stating plainly, both tested:
Nothing the model writes reaches the catalogue. The name and description stay on the draft row until the review queue in #225. Only the suggested price reaches the item, per #220, with price_source = 'ai'.
No failure loses a submission. A missing key, an unreadable file, a failed call and three exhausted retries all end the same way — the item keeps its photos, stays pending, and waits. An absent key returns early without spending an attempt, so a fortnight without one does not quietly exhaust the retries on every waiting item and mark them all failed.
Still open: the monthly spend ceiling, deliberately deferred until Task 8 measures what a call actually costs, since a budget set from a guess is one nobody trusts.
Implemented, tasks 1–7, on `feature/223-drafting-worker`. 341 unit tests and 297 integration tests pass; lint and both builds are clean, with no new warnings.
A submitted item is now drafted within a few minutes of arriving — name, description, category, tags and a suggested price — driven by a call at the end of a successful submission and a five-minute sweeper that picks up anything stranded by a restart.
**The drafting model is an Admin setting**, per the request mid-implementation. It sits on the Settings page as a dropdown showing each model's price per million tokens, so switching Sonnet to Opus is visibly a 2.5x decision rather than a silent one. It is a dropdown validated on the server rather than a text box: the API only rejects an unknown model at the point of use, so a typo would be stored happily and then fail on every submission, surfacing as drafts quietly not appearing rather than as anything anybody could act on. A model retired after being chosen falls back to the default instead of being handed on.
The model list and the price table are one catalogue, in `src/intake/models.ts`. The dropdown needs the models and `costMicros` needs their rates, and the price shown beside a model in Admin has to be the price it is billed at — which it cannot be if the two are kept separately. Rates were confirmed against the pricing page rather than recalled, which was worth doing: an increase to $3/$15 had been scheduled for tomorrow, 1 September, and was cancelled with Sonnet's $2/$10 made permanent.
Everything above is verified against a stub. **No automated test spends money**, and Task 8 — one real photograph, to judge whether the prompt produces something worth reading — is the only step that will. It is blocked on the key existing.
Two behaviours worth stating plainly, both tested:
Nothing the model writes reaches the catalogue. The name and description stay on the draft row until the review queue in #225. Only the suggested price reaches the item, per #220, with `price_source = 'ai'`.
No failure loses a submission. A missing key, an unreadable file, a failed call and three exhausted retries all end the same way — the item keeps its photos, stays `pending`, and waits. An absent key returns early without spending an attempt, so a fortnight without one does not quietly exhaust the retries on every waiting item and mark them all failed.
Still open: the monthly spend ceiling, deliberately deferred until Task 8 measures what a call actually costs, since a budget set from a guess is one nobody trusts.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Slice 2 of #220. Depends on the slice-1 issue being done — it adds behaviour to the
item_draftsrows that slice already creates, and needs no schema of its own.Design:
docs/superpowers/specs/2026-08-29-intake-pipeline-design.mdScope
The first LLM integration in this codebase. A
backend/src/intakeDrafting.tsworker, driven by a call at the end of a successful submission and by anode-cronsweeper that picks up anything leftqueuedor stuck indrafting— the sweeper is what makes a restart mid-draft recoverable rather than a permanently stalled row.The request goes through
@anthropic-ai/sdkwithclient.messages.parse()andzodOutputFormat, so the shape is validated rather than parsed out of prose. It is given every photo as an image block, the submitter's note, and the existing categories and tags as the closed set to choose from. It returns a name, a marketing description, a category, tags, and a suggested price.The prompt is a correctness surface, not a style choice
The note is the only trustworthy source of anything not visible in a photo. A model cannot see that a vase is hand-thrown stoneware or 1930s, and on a one-of-a-kind item an invented "1930s hand-thrown stoneware" is not a cosmetic error — it is a false claim on a storefront. The system prompt has to say plainly: describe what is visible, use the note for what is not, and state neither a material nor an age nor a maker nor a provenance that is in neither.
The note is also 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.Pricing
Where the model returns a price it is written onto the item with
price_source='ai'; where it does not, the item keeps the 8000 default andprice_sourcestays'default'. The suggestion is recorded on the draft either way, so what the model proposed stays readable after the admin has edited the price — otherwise there is no way to ask later whether its numbers were any good.Config
ANTHROPIC_API_KEY,INTAKE_MODEL(defaults toclaude-sonnet-5),INTAKE_MONTHLY_BUDGET_USD(defaults to 20). Required only when intake is enabled, so an environment that does not run it is unaffected. Anything added to the always-required list must also reach both compose files —composeEnvironment.test.tsenforces that, which is what #107 exists to prevent recurring.Failure is the main design constraint
A submission is the only irreplaceable thing here: the photos may be the only copy and the item may no longer be in the sender's hands. Three attempts, then
state='failed'— leaving a perfectly good pending item with photos, just no drafted copy. Past the monthly budget the API call is skipped and the submission still saves and still notifies. The ceiling protects the bill; it must never be the thing that loses inventory.Tests stub the Anthropic client. No test spends money.
Part of #220
Plan written:
docs/superpowers/plans/2026-08-31-intake-drafting-worker.md, onfeature/223-drafting-worker.Eight tasks — the SDK and its key, the Zod shape the model must answer in, the prompt, the call, writing a draft back, the worker, the wiring, and one real photograph at the end to see whether any of it produces something worth reading.
One decision I made rather than asked about, recorded here so it can be reversed cheaply.
ANTHROPIC_API_KEYis optional: absent, the container still boots, submissions still arrive, keep their photos, and wait in the queue undrafted, with a warning at startup saying so. The alternative is refusing to boot without it. I went with optional because the photos are often the only copy of an item that is no longer in the sender's hands, so losing a consignment to an expired key is a worse outcome than an item arriving without its description written. It also matches how USPS and PayPal are already treated. Say the word and it becomes required — it is one line inenvValidation.tsand one test.Two things the plan deliberately does not do:
The monthly spend ceiling is left to its own issue. The design mentions it, but nothing here has ever made a real call, so any threshold now would be invented. Task 8 measures what one submission actually costs and that number sets the budget. #227 already bounds volume in the meantime.
Nothing the model writes reaches the catalogue. The name and description stay on the draft row until the review queue in #225 exists. The single exception is the suggested price, which goes onto the item per the decision in #220, with
price_source = 'ai'recording that a model rather than a person chose it.The prompt gets its own task with its own tests, because it is where the correctness of every draft is decided. On one-of-a-kind stock an invented "1930s hand-thrown stoneware" is not a cosmetic error — it is a false claim on a storefront, and the shop answers for it rather than the model. Nothing downstream can distinguish an invented detail from an observed one, so the tests assert the instruction forbidding it is actually present.
Every test stubs the client. Nothing in the automated suites spends money; Task 8 is the only step that does, and it is manual and deliberate.
Implemented, tasks 1–7, on
feature/223-drafting-worker. 341 unit tests and 297 integration tests pass; lint and both builds are clean, with no new warnings.A submitted item is now drafted within a few minutes of arriving — name, description, category, tags and a suggested price — driven by a call at the end of a successful submission and a five-minute sweeper that picks up anything stranded by a restart.
The drafting model is an Admin setting, per the request mid-implementation. It sits on the Settings page as a dropdown showing each model's price per million tokens, so switching Sonnet to Opus is visibly a 2.5x decision rather than a silent one. It is a dropdown validated on the server rather than a text box: the API only rejects an unknown model at the point of use, so a typo would be stored happily and then fail on every submission, surfacing as drafts quietly not appearing rather than as anything anybody could act on. A model retired after being chosen falls back to the default instead of being handed on.
The model list and the price table are one catalogue, in
src/intake/models.ts. The dropdown needs the models andcostMicrosneeds their rates, and the price shown beside a model in Admin has to be the price it is billed at — which it cannot be if the two are kept separately. Rates were confirmed against the pricing page rather than recalled, which was worth doing: an increase to $3/$15 had been scheduled for tomorrow, 1 September, and was cancelled with Sonnet's $2/$10 made permanent.Everything above is verified against a stub. No automated test spends money, and Task 8 — one real photograph, to judge whether the prompt produces something worth reading — is the only step that will. It is blocked on the key existing.
Two behaviours worth stating plainly, both tested:
Nothing the model writes reaches the catalogue. The name and description stay on the draft row until the review queue in #225. Only the suggested price reaches the item, per #220, with
price_source = 'ai'.No failure loses a submission. A missing key, an unreadable file, a failed call and three exhausted retries all end the same way — the item keeps its photos, stays
pending, and waits. An absent key returns early without spending an attempt, so a fortnight without one does not quietly exhaust the retries on every waiting item and mark them all failed.Still open: the monthly spend ceiling, deliberately deferred until Task 8 measures what a call actually costs, since a budget set from a guess is one nobody trusts.