# Recipe: categorise product listings

> Pick a category from twelve, check the item against your prohibited list and pick its condition, then publish the listing with its fields or hold it for a person.

A seller lists a used city bike. One request picks its category from twelve, checks it against your prohibited items and reads its condition. The listing goes live with the right fields, or waits for a person when the item might be prohibited.

## The request

- **State.** The listing as JSON. The questions point at the title and the description; the model still reads the price and the seller type next to them.
- **`category` is a `pick` of 12 options**, with `other` as the way out. The descriptions separate close neighbours: `bikes` holds complete bicycles, `bike_parts` holds parts and accessories, and `sports` holds everything but bicycles. Every description adds question tokens: this request has 216 question tokens against 73 for the state.
- **`prohibited` is a `check`** whose `criteria` list the prohibited kinds: weapons, drugs, counterfeit goods, stolen goods, live animals and recalled products. Keep that list in one place in your code and send it with every request.
- **`condition` is a `pick`** of four conditions a buyer understands.
- **`min_confidence`** on every question: 0.6 on the prohibited check, where a wrong answer costs most.

```json
{
  "state": {
    "listing": {
      "title": "Gazelle city bike, 57 cm frame",
      "description": "Dutch city bike with 3 gears and a front basket. Rides well, but the rear light is broken and the chain has some rust. Collect in Utrecht.",
      "price_eur": 140,
      "seller_type": "private"
    }
  },
  "questions": {
    "category": {
      "type": "pick",
      "instructions": "Which category fits {{listing.title}} with {{listing.description}} best?",
      "options": {
        "bikes": "Complete bicycles and e-bikes",
        "bike_parts": "Bicycle parts and accessories",
        "cars": "Cars, motorbikes and scooters",
        "electronics": "Computers, audio, cameras and consoles",
        "phones": "Phones, tablets and smartwatches",
        "home_garden": "Furniture, household items and garden",
        "fashion": "Clothes, shoes and bags",
        "sports": "Sports and outdoor gear other than bicycles",
        "toys": "Toys and baby items",
        "books_media": "Books, music, films and games",
        "tickets": "Tickets and vouchers",
        "other": null
      },
      "min_confidence": 0.5
    },
    "prohibited": {
      "type": "check",
      "instructions": "Is the item in {{listing.description}} prohibited on a general marketplace?",
      "criteria": "Prohibited: weapons, drugs, counterfeit goods, stolen goods, live animals and recalled products.",
      "min_confidence": 0.6
    },
    "condition": {
      "type": "pick",
      "instructions": "What condition is the item in {{listing.description}} in?",
      "options": {
        "new": "Unused, in its original packaging",
        "as_new": "Used briefly, no visible wear",
        "used": "Normal signs of use, works",
        "for_parts": "Broken or only good for parts"
      },
      "min_confidence": 0.5
    }
  }
}
```

## Run it

Put a test key in `DEX_API_KEY` (see the [Quickstart](/docs/quickstart/#get-a-key-in-60-seconds)). Save the Python code as `categorisation.py` and run `python categorisation.py`. Save the TypeScript code as `categorisation.mts` and run `npx tsx categorisation.mts`: the code uses `await` at the top level, and the `.mts` ending makes the file an ES module. Install the SDKs from [Downloads](/docs/reference/sdks/#downloads).

```bash tab="curl"
curl https://api.thinqit.ai/v1/decide \
  -H "authorization: Bearer $DEX_API_KEY" \
  -H "content-type: application/json" \
  --data-binary @- <<'DEX_REQUEST'
{
  "state": {
    "listing": {
      "title": "Gazelle city bike, 57 cm frame",
      "description": "Dutch city bike with 3 gears and a front basket. Rides well, but the rear light is broken and the chain has some rust. Collect in Utrecht.",
      "price_eur": 140,
      "seller_type": "private"
    }
  },
  "questions": {
    "category": {
      "type": "pick",
      "instructions": "Which category fits {{listing.title}} with {{listing.description}} best?",
      "options": {
        "bikes": "Complete bicycles and e-bikes",
        "bike_parts": "Bicycle parts and accessories",
        "cars": "Cars, motorbikes and scooters",
        "electronics": "Computers, audio, cameras and consoles",
        "phones": "Phones, tablets and smartwatches",
        "home_garden": "Furniture, household items and garden",
        "fashion": "Clothes, shoes and bags",
        "sports": "Sports and outdoor gear other than bicycles",
        "toys": "Toys and baby items",
        "books_media": "Books, music, films and games",
        "tickets": "Tickets and vouchers",
        "other": null
      },
      "min_confidence": 0.5
    },
    "prohibited": {
      "type": "check",
      "instructions": "Is the item in {{listing.description}} prohibited on a general marketplace?",
      "criteria": "Prohibited: weapons, drugs, counterfeit goods, stolen goods, live animals and recalled products.",
      "min_confidence": 0.6
    },
    "condition": {
      "type": "pick",
      "instructions": "What condition is the item in {{listing.description}} in?",
      "options": {
        "new": "Unused, in its original packaging",
        "as_new": "Used briefly, no visible wear",
        "used": "Normal signs of use, works",
        "for_parts": "Broken or only good for parts"
      },
      "min_confidence": 0.5
    }
  }
}
DEX_REQUEST
```

```python tab="Python"
# Save as dex_request.py, then run: python dex_request.py
import json

from thinqit_dex import Client

client = Client()  # reads DEX_API_KEY from the environment

request = json.loads(r'''
{
  "state": {
    "listing": {
      "title": "Gazelle city bike, 57 cm frame",
      "description": "Dutch city bike with 3 gears and a front basket. Rides well, but the rear light is broken and the chain has some rust. Collect in Utrecht.",
      "price_eur": 140,
      "seller_type": "private"
    }
  },
  "questions": {
    "category": {
      "type": "pick",
      "instructions": "Which category fits {{listing.title}} with {{listing.description}} best?",
      "options": {
        "bikes": "Complete bicycles and e-bikes",
        "bike_parts": "Bicycle parts and accessories",
        "cars": "Cars, motorbikes and scooters",
        "electronics": "Computers, audio, cameras and consoles",
        "phones": "Phones, tablets and smartwatches",
        "home_garden": "Furniture, household items and garden",
        "fashion": "Clothes, shoes and bags",
        "sports": "Sports and outdoor gear other than bicycles",
        "toys": "Toys and baby items",
        "books_media": "Books, music, films and games",
        "tickets": "Tickets and vouchers",
        "other": null
      },
      "min_confidence": 0.5
    },
    "prohibited": {
      "type": "check",
      "instructions": "Is the item in {{listing.description}} prohibited on a general marketplace?",
      "criteria": "Prohibited: weapons, drugs, counterfeit goods, stolen goods, live animals and recalled products.",
      "min_confidence": 0.6
    },
    "condition": {
      "type": "pick",
      "instructions": "What condition is the item in {{listing.description}} in?",
      "options": {
        "new": "Unused, in its original packaging",
        "as_new": "Used briefly, no visible wear",
        "used": "Normal signs of use, works",
        "for_parts": "Broken or only good for parts"
      },
      "min_confidence": 0.5
    }
  }
}
''')

decision = client.decide(
    request["state"],
    request["questions"],
)
for question_id, answer in decision.answers.items():
    print(question_id, answer)
```

```ts tab="TypeScript"
// Save as dex-request.mts, then run: npx tsx dex-request.mts (Node.js 18 or newer)
import { Client, parseRequest } from "@thinqit/dex";

const client = new Client(); // reads DEX_API_KEY from the environment

// parseRequest keeps the key order of the text (JSON.parse would move labels such as "1" to the front).
const request = parseRequest(`{
  "state": {
    "listing": {
      "title": "Gazelle city bike, 57 cm frame",
      "description": "Dutch city bike with 3 gears and a front basket. Rides well, but the rear light is broken and the chain has some rust. Collect in Utrecht.",
      "price_eur": 140,
      "seller_type": "private"
    }
  },
  "questions": {
    "category": {
      "type": "pick",
      "instructions": "Which category fits {{listing.title}} with {{listing.description}} best?",
      "options": {
        "bikes": "Complete bicycles and e-bikes",
        "bike_parts": "Bicycle parts and accessories",
        "cars": "Cars, motorbikes and scooters",
        "electronics": "Computers, audio, cameras and consoles",
        "phones": "Phones, tablets and smartwatches",
        "home_garden": "Furniture, household items and garden",
        "fashion": "Clothes, shoes and bags",
        "sports": "Sports and outdoor gear other than bicycles",
        "toys": "Toys and baby items",
        "books_media": "Books, music, films and games",
        "tickets": "Tickets and vouchers",
        "other": null
      },
      "min_confidence": 0.5
    },
    "prohibited": {
      "type": "check",
      "instructions": "Is the item in {{listing.description}} prohibited on a general marketplace?",
      "criteria": "Prohibited: weapons, drugs, counterfeit goods, stolen goods, live animals and recalled products.",
      "min_confidence": 0.6
    },
    "condition": {
      "type": "pick",
      "instructions": "What condition is the item in {{listing.description}} in?",
      "options": {
        "new": "Unused, in its original packaging",
        "as_new": "Used briefly, no visible wear",
        "used": "Normal signs of use, works",
        "for_parts": "Broken or only good for parts"
      },
      "min_confidence": 0.5
    }
  }
}`);

const decision = await client.decide(request);
console.log(decision.answers);
```

## Expected output

```json
{
  "id": "req_01M3JE1A7KRPTYJVKBC3J9VHXQ",
  "object": "decision",
  "created": 1790546323,
  "model": "dex-1.0.1",
  "served_by": "gpu",
  "calibration": "cal-20260926-1",
  "answers": {
    "category": {
      "type": "pick",
      "choice": "bikes",
      "probabilities": {
        "bikes": 0.8658,
        "bike_parts": 0.0268,
        "cars": 0.0177,
        "electronics": 0.0129,
        "phones": 0.0115,
        "home_garden": 0.0123,
        "fashion": 0.0098,
        "sports": 0.0101,
        "toys": 0.0076,
        "books_media": 0.0071,
        "tickets": 0.0084,
        "other": 0.01
      },
      "confidence": 0.839,
      "abstained": false
    },
    "prohibited": {
      "type": "check",
      "probability": 0.0802,
      "confidence": 0.8397,
      "abstained": false
    },
    "condition": {
      "type": "pick",
      "choice": "used",
      "probabilities": {
        "new": 0.0167,
        "as_new": 0.0261,
        "used": 0.9262,
        "for_parts": 0.031
      },
      "confidence": 0.8952,
      "abstained": false
    }
  },
  "usage": {
    "input_tokens": 289,
    "state_tokens": 73,
    "question_tokens": 216,
    "allowance_tokens": 0,
    "paid_tokens": 0,
    "charge_micro_cents": 0,
    "unit_price_micro_cents": 0,
    "tier": "test"
  }
}
```

Captured from the live API on 2026-09-27 with a test key: model `dex-1.0.1`, calibration `cal-20260926-1`, `served_by: gpu`, 289 input tokens (73 for the state, 216 for the questions). A test key is charged nothing, so `tier` is `test` and the charge is 0. On this exact version the same request always returns these answers.

| Question | Type | Answer | Confidence | `min_confidence` | Abstained |
| --- | --- | --- | --- | --- | --- |
| `category` | pick | `bikes` (0.8658) | 0.839 | 0.5 | no |
| `prohibited` | check | yes with probability 0.0802 | 0.8397 | 0.6 | no |
| `condition` | pick | `used` (0.9262) | 0.8952 | 0.5 | no |

The listing is a complete bike (`bikes`, 0.8658), not parts (0.0268), and it is not prohibited (0.0802). Its condition is `used` (0.9262); `for_parts` gets 0.031, although the rear light is broken. No answer abstained.

## Act on it

`listing_id` and the functions `hold_for_review`, `publish_listing`, `ask_seller_for_category`, `set_category` and `set_condition` stand for your own code. Here the listing is published in `bikes`, in `used` condition.

```python tab="Python"
prohibited = decision.check("prohibited")
if prohibited.abstained or prohibited.probability >= 0.5:
    hold_for_review(listing_id)  # a person checks it before it goes live
else:
    publish_listing(listing_id)

category = decision.pick("category")
if category.abstained or category.choice == "other":
    ask_seller_for_category(listing_id)
else:
    set_category(listing_id, category.choice)

condition = decision.pick("condition")
if not condition.abstained:
    set_condition(listing_id, condition.choice)
```

```ts tab="TypeScript"
const { category, prohibited, condition } = decision.answers;

if (prohibited?.type === "check" && (prohibited.abstained || prohibited.probability >= 0.5)) {
  holdForReview(listingId); // a person checks it before it goes live
} else {
  publishListing(listingId);
}
if (category?.type === "pick") {
  if (category.abstained || category.choice === "other") askSellerForCategory(listingId);
  else setCategory(listingId, category.choice);
}
if (condition?.type === "pick" && !condition.abstained) setCondition(listingId, condition.choice);
```

An abstained prohibited check holds the listing, the safe route for a question where a miss is expensive. As in the [support recipe](/docs/cookbook/support-routing/#act-on-it), the TypeScript answers have the general `Answer` type, so the code narrows each one on `type`.

## Adapt it

- **Deep category trees in two stages.** Pick a branch first (bikes, cars, electronics), then send a second request that picks a leaf within that branch (city bike, road bike, e-bike). Each pick stays short and clear, and no pick needs more than the 255 options allowed. See [Questions](/docs/concepts/questions/#pick).
- **Your prohibited list.** Write each kind in `criteria` in English, and add the edge cases your marketplace sees, such as replica watches counting as counterfeit.
- **Short descriptions for long lists.** Give a description only where a label could be read two ways, and use `null` elsewhere: every description adds question tokens.
- **A whole catalogue.** Run it with bounded concurrency and a results file: see [Score a batch](/docs/cookbook/batch-scoring/). On the same version a listing always gets the same category.
