Skip to main content
This cookbook takes plain text whose markup has been stripped - lines hard-wrapped mid-sentence, no heading markers, no list bullets - and reconstructs the structure as Markdown: headings, paragraphs, lists, quotes, code, callouts. The input is a team memo in exactly that state. A text-generation model could rewrite the text into Markdown, but a rewrite can also change the words. Here the model never generates text: it answers narrow questions about the document - does this line pick up mid-sentence? what kind of content is this block? - and code does the rendering, so every character of the output comes from the input, and every judgment carries a probability. The whole pipeline is two API requests per document, run in sequence:
  • Pass 1 - stitch. One Noul (a yes/no question whose answer is the probability that yes is correct) per adjacent pair of lines: did the line break split a sentence across these two lines? All the pairs go in a single request, and lines that continue a split sentence get merged back into blocks.
  • Pass 2 - classify. One Choice (pick one option from a list, with a probability for every option) per merged block: heading, paragraph, list item, quote, code, or callout (a note, tip, or warning set apart from the main text). The blocks only exist once pass 1 has answered, so this is a second request; it also carries companion questions for every block - heading level, step order, callout kind - whose answers are read only when the block’s type makes them relevant.
  • Direct evidence stays in code. Blank lines and explicit markers (- , 1., #) are read in code, never sent to the model to reconsider; this memo kept its blank lines but lost every marker. The model gets only the questions code cannot answer from the text.
All of the behavior is specified in the question criteria - a handful of one-line descriptions in pass 2; the rest of the code is plumbing around them. The cost and latency numbers are in the appendix.

Setup

then set TYPESAFE_API_KEY. Every API call is cached in json_cache.json, which ships with the cookbook, so re-rendering replays the published numbers without calling the API. Delete that file to re-run everything live.

The document: a team memo that lost its formatting

The test document is a memo about a build-system migration, in the state it arrives in a plain-text inbox: paragraphs hard-wrapped mid-sentence, a shell command sitting on a bare line, two lists with no bullets or numbers, a warning with nothing marking it as one. The text is fetched from a pinned gist so the cookbook’s numbers stay reproducible.
Line splitting, blank-line tracking, and id tagging all happen in code - no model involved. Each line gets a short id (L014| ); the ids are ordinary text the model reads as part of the state, and questions and answers refer to lines by these ids (the same scheme as the semantic search cookbook).

Pass 1: stitching split sentences

One Noul per adjacent pair of lines, all in one request; pairs separated by a blank line are skipped. The question is deliberately narrow - “does this line pick up mid-sentence?” - which is close to an objective fact about the text. The appendix covers both the wording choice and how the merge thresholds were derived.
The cutoff for merging depends on how the previous line ends - a stricter bar after sentence-ending punctuation - and the appendix walks through the probabilities behind the two numbers.

Pass 2: classifying blocks

Each stitched block gets a Choice: what kind of content is this? These three dicts, plus the step question’s true/false criteria inside classify_questions below, are the entire specification of the classifier - there is no other logic. To adapt the pipeline to your own documents, edit these descriptions.
Everything below is plumbing: build the questions, send one request, read the answers back. If the type comes back heading, the renderer needs a heading level; if list_item, whether order matters; if callout, which kind. The types are not known yet - waiting for them would mean a third round trip - so the companion questions are asked up front in the same request. Most of these answers are never read - the step probability of a paragraph means nothing and is simply ignored. An extra question adds little - the state is most of the tokens and is sent once either way - while an extra round trip adds a full request of latency.
Every block’s judgment is in that table, and the companion column shows the up-front answers being put to use: the three “Things to do before Monday” lines carry step probabilities near 0.9 (they will render as a numbered list), the three team lines sit near 0.1 (bulleted), and the unmarked warning about the doctor script was classified as a callout of kind warning. The appendix looks at the one block the model was unsure about.

Rendering

Code assembles the page from the judgments. Consecutive list items become one list, numbered when the mean of the items’ step probabilities is at least 0.5 - a group-level decision no single question asked directly.
Every word above is from the input - the pipeline only chose boundaries, types, and markup.

Open it in the playground

This share link holds the stitched blocks and the full pass-2 question set. Open it to re-run the classification live.
Open the stitched memo + questions in the TypeSafe playground →

Appendix

Cost and latency

Two round trips, 10,211 tokens, 0.8s, $0.0015.

Where the join thresholds come from

The per-line join probabilities from pass 1:
The probabilities land in two separate bands: line breaks that split a sentence score 0.39 and up, breaks the author meant score close to zero. But where to put the cutoff between the bands depends on a fact code can read directly - how the previous line ends:
  • After a dangling line (one with no sentence-ending punctuation), anything at 0.2 or above counts as a continuation. True continuations score as low as 0.39 here - L004| make the switch for real. - so a single cautious cutoff at 0.5 would break up healthy paragraphs.
  • After terminal punctuation (a character that ends a sentence or clause: . ! ? : ;), the cutoff rises to 0.5. The memo’s team list shows why: L015| The platform team follows a colon and scores 0.22 - a low but nonzero “this continues the sentence” signal that would clear the 0.2 cutoff and merge the list into the sentence introducing it. No single threshold works for both cases; once code checks the punctuation first, the two bands separate.

Why the question is “mid-sentence” and not “same paragraph”

The first version of this pipeline asked the obvious question - “are these two lines part of the same paragraph?” - and it failed in a specific way. A run of short lines under a heading (a list typed without bullets) is a paragraph in the loose sense: the lines sit together and share a topic. Asked about paragraphs, the model says yes to every pair, and the stitch pass merges the whole list into one long block. Same document, same request shape, only the wording changed:
With the paragraph wording, every unmarked list item scores above 0.75 and both lists collapse - the memo merges into a few run-on blocks. “Same paragraph” asks the model to judge whether the topic carries over, and between list items it genuinely does. “Picks up mid-sentence” asks about the text itself. When a judgment call feeds a threshold, the question should name the narrowest fact that decides it. Here the wording is the difference between 17 blocks and 12.

The lowest-confidence block

The sentence introducing the team list is genuinely ambiguous - it names what follows (heading-like), is a complete sentence (paragraph-like), and sits where a callout would go. The probabilities spread accordingly (paragraph 0.53, list_item 0.24, callout 0.19), and a UI can surface that - for example, underline for review any block whose type confidence (the probability behind the winning choice) is under 0.55.