This is a preview of the Storyblok Website with Draft Content

JoyConf 2026 is back. Content Confidence. Human Connection. Save your spot!

Couch, Sofa, or Settee? Letting Jev Sort Out Your Storyblok Taxonomies

Storyblok is the first headless CMS that works for developers & marketers alike.

Does your space have three tags that mean the same thing? Did someone create a new category because the existing one felt too broad? Do half of your entries have no category at all?

Most content classification ends up here after a few years of real editors doing real work. Every tag made sense to the team who created it. But this results in content categorization becoming out of sync.

Storyblok Taxonomies move that list into one place. Terms are defined centrally, and editors pick from them instead of creating their own. The list is still only as clean as the people who manage it, but it stops growing every time someone tags a post. The catch is getting your existing content onto it. Every entry needs someone to decide which term its old categories actually mean, or what it's about if it was never categorized. Across hundreds or thousands of entries, that's daunting enough to hold teams back from migrating to Taxonomies at all.

But that decision doesn't have to be made by hand. The experiment below hands it to Jev, a System One model, on a home goods blog with 2200 posts and years of messy tagging: Furniture next to Kitchen Furniture and Home Furniture, Couch next to Sofa and Settee, TV Console next to TV Stand. Jev decides which taxonomy term each post belongs to, and the script applies it.

What is Jev?

Jev is the flagship model from TypeSafe AI, and the first in a class TypeSafe calls System One models.

Jev reads natural language much as an LLM does, but instead of writing a reply, it returns a structured decision with a probability attached. You send it a state (the content you want judged) and a set of typed questions. It returns typed answers with probabilities. No prose, no parsing.

Three question types, which TypeSafe calls primitives, cover most classification work:

{{ ADD IMAGE HERE }}

Each question runs in parallel and in isolation against the same state, so asking 44 questions in one request costs barely more time than asking one. Jev is also trained for calibrated probabilities: a 0.9 should be right about nine times out of ten across many predictions. That calibration is what lets code decide when to trust an answer and when to send it to a person.

Jev can't fetch your content, call an API, or write tags back to anything. Code owns the workflow, and Jev supplies the judgment in the middle.

Hint:

Read the TypeSafe introduction and the System One overview for the full model.

What are Storyblok Taxonomies?

Taxonomies launched on September 9, 2026. A taxonomy is a tree of terms, each with a name, a technical name, and an optional description. A content type's Taxonomy tab in the Block Library sets which taxonomies editors can assign and how many terms a story can take. Editors assign terms from the Taxonomies panel in the right toolbar, and both the Management API and the Content Delivery API can query stories by term.

Taxonomies are new, but most spaces already have a classification system of their own: tags, datasources, or reference fields that point at category stories. None of it moves into Taxonomies on its own.

Hint:

The Taxonomies manual covers setup, block rules, and assignment in the Visual Editor.

What's the problem?

Adopting Taxonomies means one of two jobs. Either your existing classification has drifted, with Furniture next to Kitchen Furniture and Immersion Blender next to Immersionblender and Hand Blender, and every post's labels need mapping to one clean term. Or your posts were never classified, and someone has to read each one and decide what it's about.

Either way, someone reads every entry. For a few dozen posts that's an afternoon. For a few thousand, it's the reason the migration never happens.

This is where Jev comes in.

Using Jev to drive Taxonomies

The experiment ran against a Storyblok space built to look like a home goods blog that had been tagged by many hands over many years.

The setup

The space uses a page content type with a body field of rich text, and two reference fields, categories and subcategories, that point at category and subcategory stories. Those stories were deliberately messy:

  • 19 category stories, including near-duplicates like Furniture, Kitchen Furniture, and Home Furniture, or Appliances, Appliance, Kitchen Appliances, and Small Appliances.
  • 117 subcategory stories, including Couch, Sofa, Settee, Loveseat, and Sectional, or TV Stand, TV Console, Media Console, and Entertainment Center.
  • Four labels that aren't in the taxonomy at all: Mattress, Electric Kettle, Ceiling Fan, and Throw Pillows. These are traps, they test whether Jev forces a label onto the nearest term.

On top of that sit 2,200 blog posts, each with a richtext body about its topic:

  • 1,342 posts reference subcategories and categories, and 68% of those subcategory references use a spelling that doesn't match a term name.
  • 88 posts reference only the trap labels.
  • 220 posts reference only a category.
  • 550 posts have no references at all, 55 of them about topics outside the taxonomy (houseplants, decluttering, painting an accent wall).

The target is a clean taxonomy with one spelling per idea: six taxonomies (Furniture, Appliances, Cookware, Lighting, Textiles, and Materials) holding 44 terms in total. Each term has a description written to tell Jev what belongs and what doesn't. The Dutch Oven term, for example, says "Not an electric oven," and Bed says "Not mattresses."

Because every post was planned before it was written, each one has a known correct answer. That makes the 2,200 posts a test set as well as content.

How the Jev state was configured

A Node script pulls the taxonomies from Storyblok, descriptions included, and sends each post down one of three paths. The taxonomy never goes into the state. Each term travels in the questions, as a Choice option or a Noul subject with its description attached.

Path

State

Questions

Threshold

Subcategory labels

{ title }

One Choice per label, over the 44 terms plus NONE

A confidence of 0.9 accepts the pick. Below that, a Noul checks it, and 0.3 or higher keeps it.

Category label only

{ title }, then { title, body }

A Choice maps the label to a taxonomy, then one Noul per term inside it

0.7 or higher tags the term

No labels

{ title, body }

One Noul per term, all 44

0.7 or higher tags the term

Every label for a post goes out in one request, with the title as context:

const labelToTerm = (label) =>
  choice(
    {
      question: "`label` is a tag an editor attached to a home goods blog post titled `title`. Which taxonomy term names the same kind of product or material as `label`? Ignore differences in spelling, spacing, plurals, hyphens and synonyms.",
      label,
    },
    termCriteria, // { Sofa: "An upholstered seat for two or more people... (Category: Furniture)", ..., NONE: "..." }
  );

The Noul check exists for the traps. A Choice must pick one of its options, so Mattress lands on Bed, just with low confidence. Across the run, every trap label that reached the check scored between 0.04 and 0.11, while correct matches scored 0.86 or higher.

Posts without labels are judged from the body, one Noul per term:

Would an editor tag this blog post (title and body) with term so readers browsing that tag find it? Tag it only if the post helps someone interested in term; an incidental mention does not count.

Noul fits here because a post can belong to several terms at once (a walnut dining table post is both Table and Wood). When a category label mapped to a taxonomy, only that taxonomy's terms are asked, so the category rule is enforced in code. Score never comes into it: Score answers "how much," and this job only asks "which."

The terms go back to Storyblok through the Management API, one call per story, without touching content or publish state. The model is pinned to jev-1.13.0 so the thresholds stay valid.

Hint:

Run the script without --apply first. The dry run writes a report of every decision, including a review list of posts with no term or a dropped label, and changes nothing in the space.


node --env-file=.env classify.mjs           # dry run
node --env-file=.env classify.mjs --apply   # assign the terms

How the wording was picked

Before touching the space, two rounds of experiments ran against 69 hand-labeled label mappings and 26 post bodies. Asking whether a term was "a main subject" of a post missed secondary topics, at 88.9% recall. Asking whether an editor would tag the post reached 100% recall at 97.3% precision. Descriptions mattered most for labels that shouldn't match: without them, Jev mapped Mattress to Bed at 0.96 confidence.

Jev vs no Jev

The obvious alternative is to hand the whole job to an LLM. The same 2,200 posts went through Claude Sonnet 5.5 and Claude Opus 5.5 on the Claude API, with the same data, the same rules, and structured outputs turned on.

What are Anthropic structured outputs?

Ask an LLM for JSON in the prompt and you'll almost always get it, but your code still has to parse the text and check that every term actually exists. Structured outputs move that check into decoding. You pass a JSON Schema in the request's output_config.format, and Claude can only produce tokens that keep the response valid against it (apart from refusals and cut-off responses). Here, the schema's items are an enum of the 44 term names, so Claude can't return "Couch" when the term is "Sofa":

{
  "type": "object",
  "properties": {
    "terms": { "type": "array", "items": { "type": "string", "enum": ["Chair", "Sofa", "Table", "..."] } }
  },
  "required": ["terms"],
  "additionalProperties": false
}

Structured outputs constrain the shape of the answer, not the judgment inside it. Claude still decides which valid terms a post gets, and it doesn't say how sure it is.

Hint:

Claude Code's --json-schema flag isn't the same feature. It answers through a tool call, which takes an extra turn. These runs call the Messages API directly.

How the Claude request was configured

Claude gets the whole job in one request per post. The system prompt holds the same three rules the script follows, plus the full taxonomy as JSON:

Rules:

  1. If the post has subcategory_labels, map each label to the one term that names the same kind of product or material. Ignore differences in spelling, spacing, plurals, hyphens and synonyms. If a label names something not in the taxonomy, drop it. Do not use the body in this case.
  2. If it has no usable subcategory label but has category_labels, map them to taxonomy categories, then tag the terms within those categories that the body is genuinely about.
  3. If it has no labels, tag every term an editor would use so readers browsing that tag find the post. An incidental mention does not count. Off-topic posts get an empty list.

The user message is the post as JSON: its title, labels, and body. The schema goes in output_config.format :

const response = await fetch("<https://api.anthropic.com/v1/messages>", {
  method: "POST",
  headers: {
    "x-api-key": process.env.ANTHROPIC_API_KEY,
    "anthropic-version": "2023-06-01",
    "content-type": "application/json",
  },
  body: JSON.stringify({
    model: "claude-sonnet-5-5",
    max_tokens: 512,
    system: [{ type: "text", text: SYSTEM, cache_control: { type: "ephemeral" } }],
    messages: [{ role: "user", content: JSON.stringify(post) }],
    output_config: { format: { type: "json_schema", schema: SCHEMA } },
  }),
});

The system prompt is cached after the first few requests. No tools are attached and no thinking parameter is set, though both models still thought on some posts (Sonnet on 51, Opus on 342).

Diving into the result

Jev ran twice: once one request at a time, and once with six concurrent requests to match the Claude runs. Model time adds up every request. Wall-clock time is how long the model calls actually took.


Jev (jev-1.13.0)

Jev, concurrent

Claude Sonnet 5.5

Claude Opus 5.5

Exact match

2,117/2,200

2,118/2,200

2,099/2,200

2,168/2,200

Labeled posts

1,339/1,342

1,340/1,342

1,342/1,342

1,342/1,342

Category-only posts

217/220

217/220

208/220

220/220

Body-only posts

418/495

418/495

406/495

465/495

Precision

97.7%

97.6%

96.1%

99.0%

Recall

99.2%

99.3%

99.9%

99.8%

Concurrent requests

1

6

6

6

Input tokens

8.1 million

8.1 million

7.6 million (6.8 million cached)

7.6 million (6.8 million cached)

Output tokens

Not billed

Not billed

45,765

75,337

Model time

8 minutes

8.5 minutes

50 minutes

68 minutes

Wall-clock time

8 minutes

85 seconds

8 minutes

12 minutes

Cost

$0.34

$0.34

$2.70

$6.08

Both Claude models mapped all 1,342 labeled posts correctly, and every model, Jev included, kept all 88 trap-only posts untagged. Jev's three label misses are the exact-name cases from the results above, which a name check in code fixes before Jev is asked.

Posts without subcategory labels are where the models separated. On body-only posts, Opus got 465 of 495 exactly right, Jev 418, and Sonnet 406. Jev's extra terms were the material policy calls from the results above. Sonnet over-tagged materials even more, adding Wood 29 times. It also missed 12 category-only posts, and on 10 of them it broke the category rule by adding a material term from outside the category the editor picked. Jev can't make that mistake, because the script only asks about terms inside the mapped taxonomy. Opus read those posts more conservatively and didn't miss a single category-only post.

Cost scaled the way you'd expect. Jev classified all 2,200 posts for 34 cents. Sonnet cost $2.70, eight times the cost of Jev, and finished 18 exact matches behind it. Opus cost $6.08, 18 times the cost of Jev, for 51 more exact matches.

When running one request at a time, Jev finished its model calls in about eight minutes. Sonnet needed 50 minutes of model time, but with six requests in flight it also finished in about eight minutes, and Opus finished in about 12. With the same six concurrent requests, Jev finished all 2,200 posts in 85 seconds, about six times faster than Sonnet and eight times faster than Opus, at the same 34 cents. The model time barely moved, because each Jev request still takes about 0.2 seconds. Running six at once only means less waiting.

The two Jev runs differed on nine posts, all close to a threshold. Nine of 2,200 posts landed differently between the two runs. Two of the three label misses from the first run came out right this time, and a different Desk label went to Table instead. The totals moved by a single exact match. The Desk and Immersion Blender labels were a coin flip for Jev, with every pick at about 0.5 confidence, so they flipped between runs.

Jev's answers come with probabilities. The review list in the script exists because a Choice reports its confidence and a Noul reports a probability, so code can decide which posts a person needs to check. Structured outputs guarantee that Claude returns valid term names, not how sure it is about them. To get the same review list, you'd have to add a confidence field to the schema, then trust a number the model wrote about itself.

Conclusion

Taxonomies give a space one central list of terms. Jev gets existing content onto that list without someone reading every post. It classified 2,200 posts in 85 seconds for 34 cents, mapped near-duplicate labels, refused to force out-of-taxonomy labels onto the nearest term, and tagged unlabeled posts from their bodies. Claude Opus 5.5 was more accurate on posts without labels at 18 times the cost, Claude Sonnet 5.5 was less accurate at eight times the cost, and neither returns probabilities to route the uncertain posts to a person.

The pattern carries over to your own space. Write descriptions for your terms that say what belongs and what doesn't. Let Choice map the labels you have, check the low-confidence picks with a Noul, and let Nouls judge the posts that have nothing. Run it dry, read the review list, then apply.

Try out Taxonomies and Jev for youself!