Skip to main content

How to Send Compact Data to an LLM with TOON and Parse the Reply

In this guide, we will send a list of Scala books to a large language model (LLM) as compact TOON, ask it to recommend one, and decode its reply into a case class. By the end, you will have a prompt builder that encodes records in TOON's tabular layout and a decoder that turns the model's TOON answer into a typed Recommendation, or into a clear error when the answer is malformed.

We take an incremental approach:

  • Encode a catalog of books as tabular TOON and compare its size with JSON
  • See why ArrayFormat.Tabular must be set explicitly
  • Pick a delimiter that keeps values unquoted
  • Build the prompt string
  • Decode the model's reply, handle malformed replies and strip the prose around them

No LLM is called anywhere in this guide: the model's reply is a fixed string, so every output you see is deterministic.

The Problem​

LLM APIs bill and limit by tokens, and a prompt that carries data spends most of its tokens on that data. JSON repeats every field name in every record and wraps every string in quotes, so a list of 50 records pays for its field names 50 times. TOON (Token-Oriented Object Notation) sends a uniform list of records as a table instead: the field names appear once in a header, and each record becomes one row of values.

The reply is the other half. Asking the model to answer in a structured format only helps if the answer is checked: a model can rename a field, leave one out, or wrap its answer in friendly prose. Without a decoder, that check is a hand-written parser.

For example, without a codec library, preparing the prompt and reading the reply looks like this:

case class Book(title: String, firstAuthor: String, year: Int, tags: String)
case class Recommendation(title: String, reason: String)

// Building the table by hand: header, quoting and escaping are all on us
def toTable(books: List[Book]): String =
books
.map(b => s"${b.title},${b.firstAuthor},${b.year},${b.tags}") // breaks when a value contains a comma
.mkString("books{title,firstAuthor,year,tags}:\n ", "\n ", "")

// Parsing the reply by hand: every field, every failure case
def parseReply(reply: String): Either[String, Recommendation] = {
val fields = reply.linesIterator.flatMap { line =>
line.split(":", 2) match {
case Array(key, value) => Some(key.trim -> value.trim)
case _ => None
}
}.toMap
for {
title <- fields.get("title").toRight("missing title")
reason <- fields.get("reason").toRight("missing reason")
} yield Recommendation(title, reason)
}

This approach doesn't scale: the table writer silently corrupts any value that contains a comma, the parser silently drops lines it doesn't understand, and both must be rewritten whenever a field is added.

In this guide, we derive one TOON codec per type from its Schema instead. The codec writes the table, quotes values only when needed, and reports every malformed reply as a SchemaError.

Prerequisites​

Add the TOON module to your build.sbt. It depends on the schema module, so you don't need to add that separately:

// Scala 2.13 and Scala 3.3 to 3.8
libraryDependencies += "dev.zio" %% "zio-blocks-schema-toon" % "0.0.56"

// Scala 3.9 and newer (also needs scalacOptions += "-experimental")
libraryDependencies += "dev.zio" %% "zio-blocks-next-schema-toon" % "0.0.56"

All code in this guide uses these two imports:

import zio.blocks.schema._
import zio.blocks.schema.toon._

This guide assumes you can derive a Schema for a case class and read a SchemaError. If not, start with the tutorial Your First Schema.

The Book Catalog​

Our catalog holds three books. Each Book has only primitive fields, which is what lets TOON lay the list out as a table. The tags field is a single comma-separated string rather than a List[String] for that reason. A Catalog wraps the list in a named field, so the TOON header reads books[3]{...}:, a label the model can refer to. To define the catalog:

case class Book(title: String, firstAuthor: String, year: Int, tags: String)

object Book {
implicit val schema: Schema[Book] = Schema.derived
}

case class Catalog(books: List[Book])

object Catalog {
implicit val schema: Schema[Catalog] = Schema.derived
}

val catalog = Catalog(
List(
Book("Zionomicon", "John A. De Goes", 2024, "zio, effects, concurrency"),
Book("Programming in Scala", "Martin Odersky", 2008, "scala, language, fundamentals"),
Book("Functional Programming in Scala", "Paul Chiusano", 2014, "fp, scala, exercises")
)
)
warning

The tabular layout needs every field of the record to be a primitive value: a string, a number, a boolean and so on. A field that holds a list or a nested record breaks the table. Flatten such fields before encoding, as tags does here.

Encoding the Records for a Prompt​

ToonCodecDeriver is the deriver behind ToonFormat, and its ToonCodecDeriver#with* methods return a configured copy. We call ToonCodecDeriver#withArrayFormat(ArrayFormat.Tabular) to ask for the tabular layout, derive a codec for Catalog with it, and encode the catalog with ToonCodec#encodeToString:

val tabular = ToonCodecDeriver.withArrayFormat(ArrayFormat.Tabular)

val catalogCodec: ToonCodec[Catalog] = Catalog.schema.derive(tabular)

val catalogToon = catalogCodec.encodeToString(catalog)

The result is the text we put in the prompt:

println(catalogToon)
// books[3]{title,firstAuthor,year,tags}:
// Zionomicon,John A. De Goes,2024,"zio, effects, concurrency"
// Programming in Scala,Martin Odersky,2008,"scala, language, fundamentals"
// Functional Programming in Scala,Paul Chiusano,2014,"fp, scala, exercises"

The header books[3]{title,firstAuthor,year,tags}: names the field, the number of rows and the column names, once. Each line below it is one book. The tags values contain commas, so the encoder quotes them to keep them apart from the commas that separate columns.

For comparison, here is the same catalog as JSON, from the toJsonString extension method that the schema provides:

val catalogJson = catalog.toJsonString

To print the JSON text and compare its length with the TOON text:

println(catalogJson)
// {"books":[{"title":"Zionomicon","firstAuthor":"John A. De Goes","year":2024,"tags":"zio, effects, concurrency"},{"title":"Programming in Scala","firstAuthor":"Martin Odersky","year":2008,"tags":"scala, language, fundamentals"},{"title":"Functional Programming in Scala","firstAuthor":"Paul Chiusano","year":2014,"tags":"fp, scala, exercises"}]}
println(s"JSON: ${catalogJson.length} characters, TOON: ${catalogToon.length} characters")
// JSON: 344 characters, TOON: 251 characters

The TOON text is shorter because it states each field name once instead of once per book, and drops most quotes and braces. Characters are only a rough proxy for cost: the exact token count depends on the model's tokenizer, so measure with your provider's token counter before relying on a number.

The savings grow with the number of rows and shrink with the shape of the data. TOON pays off for long lists of uniform, flat records. For deeply nested or irregular data, where few records share the same fields, it saves little over compact JSON.

Why ArrayFormat.Tabular Must Be Set​

ArrayFormat has four cases: Auto, Tabular, Inline and List. The default deriver, the one derive(ToonFormat) uses, is set to Auto. In practice, Auto writes a list of records in list form, one - item per record with every field name repeated, even when the records are flat and uniform. Only ArrayFormat.Tabular produces the table.

To compare the two layouts, encode the same catalog with both settings:

val autoToon = Catalog.schema.derive(ToonFormat).encodeToString(catalog)

To print both encodings and their lengths side by side:

println("ArrayFormat.Auto:")
// ArrayFormat.Auto:
println(autoToon)
// books[3]:
// - title: Zionomicon
// firstAuthor: John A. De Goes
// year: 2024
// tags: zio, effects, concurrency
// - title: Programming in Scala
// firstAuthor: Martin Odersky
// year: 2008
// tags: scala, language, fundamentals
// - title: Functional Programming in Scala
// firstAuthor: Paul Chiusano
// year: 2014
// tags: fp, scala, exercises
println()
//
println("ArrayFormat.Tabular:")
// ArrayFormat.Tabular:
println(catalogToon)
// books[3]{title,firstAuthor,year,tags}:
// Zionomicon,John A. De Goes,2024,"zio, effects, concurrency"
// Programming in Scala,Martin Odersky,2008,"scala, language, fundamentals"
// Functional Programming in Scala,Paul Chiusano,2014,"fp, scala, exercises"
println()
//
println(s"Auto: ${autoToon.length} characters, Tabular: ${catalogToon.length} characters")
// Auto: 354 characters, Tabular: 251 characters

The list form is valid TOON and decodes back to the same Catalog, but it repeats title:, firstAuthor:, year: and tags: for every book. That repetition is exactly what we wanted to avoid, so always set ArrayFormat.Tabular when encoding records for a prompt.

Choosing a Delimiter​

With the default comma delimiter, any value that contains a comma must be quoted, as the tags column showed. ToonCodecDeriver#withDelimiter picks a different column separator: Delimiter.Comma (the default), Delimiter.Tab or Delimiter.Pipe. A fourth case, Delimiter.None, exists for the reader's internal use and isn't meant for encoding.

To see how each delimiter changes the encoded table:

for (delimiter <- List(Delimiter.Comma, Delimiter.Pipe, Delimiter.Tab)) {
val codec = Catalog.schema.derive(tabular.withDelimiter(delimiter))
println(s"$delimiter:")
println(codec.encodeToString(catalog))
println()
}
// Comma:
// books[3]{title,firstAuthor,year,tags}:
// Zionomicon,John A. De Goes,2024,"zio, effects, concurrency"
// Programming in Scala,Martin Odersky,2008,"scala, language, fundamentals"
// Functional Programming in Scala,Paul Chiusano,2014,"fp, scala, exercises"
//
// Pipe:
// books[3|]{title|firstAuthor|year|tags}:
// Zionomicon|John A. De Goes|2024|zio, effects, concurrency
// Programming in Scala|Martin Odersky|2008|scala, language, fundamentals
// Functional Programming in Scala|Paul Chiusano|2014|fp, scala, exercises
//
// Tab:
// books[3 ]{title firstAuthor year tags}:
// Zionomicon John A. De Goes 2024 zio, effects, concurrency
// Programming in Scala Martin Odersky 2008 scala, language, fundamentals
// Functional Programming in Scala Paul Chiusano 2014 fp, scala, exercises
//

The pipe and tab versions need no quotes, because no value contains a pipe or a tab. The header records the delimiter, as in books[3|] or books[3\t] (with a real tab character), so a decoder knows how to split the rows. Pick the delimiter that is least likely to appear in your values:

  • Delimiter.Comma suits numbers, IDs and short words.
  • Delimiter.Pipe suits free text with commas, such as titles, tags and descriptions, and stays readable.
  • Delimiter.Tab also avoids quoting free text, but tabs are invisible in logs and easy to lose when text is copied.

The codec reads its own output back without any extra setting:

val pipeCodec = Catalog.schema.derive(tabular.withDelimiter(Delimiter.Pipe))

To check that the codec reads its own output back:

println(pipeCodec.decode(pipeCodec.encodeToString(catalog)) == Right(catalog))
// true

Building the Prompt​

A prompt is a plain string, so we build it with ordinary Scala. It holds three things: a short instruction, the TOON block, and a description of the reply we want. Naming the reply's fields and showing their shape makes it far more likely that the model answers in a form our decoder accepts. To assemble the prompt:

val catalogPipeToon = pipeCodec.encodeToString(catalog)

def buildPrompt(catalogToon: String, request: String): String =
List(
"You recommend books from the catalog below. The catalog is in TOON format.",
"",
catalogToon,
"",
s"Request: $request",
"",
"Reply in TOON with exactly two fields and no other text:",
"title: <a title from the catalog>",
"reason: <one sentence>"
).mkString("\n")

val prompt = buildPrompt(catalogPipeToon, "I know Scala and want to learn effect systems.")

This is the text we would send to the model:

println(prompt)
// You recommend books from the catalog below. The catalog is in TOON format.
//
// books[3|]{title|firstAuthor|year|tags}:
// Zionomicon|John A. De Goes|2024|zio, effects, concurrency
// Programming in Scala|Martin Odersky|2008|scala, language, fundamentals
// Functional Programming in Scala|Paul Chiusano|2014|fp, scala, exercises
//
// Request: I know Scala and want to learn effect systems.
//
// Reply in TOON with exactly two fields and no other text:
// title: <a title from the catalog>
// reason: <one sentence>

Decoding the Model's Reply​

The model's answer is described by a case class like any other type. We derive its codec with ToonCodecDeriver#withRejectExtraFields(true), so any key the case class doesn't declare is an error instead of being ignored. The reason for that choice shows up below, when the model adds prose. To define the reply type and its strict codec:

case class Recommendation(title: String, reason: String)

object Recommendation {
implicit val schema: Schema[Recommendation] = Schema.derived
}

val replyCodec: ToonCodec[Recommendation] =
Recommendation.schema.derive(ToonCodecDeriver.withRejectExtraFields(true))

Suppose the model answered with this text. ToonCodec#decode returns an Either[SchemaError, Recommendation]:

val reply =
"""title: Zionomicon
|reason: It teaches ZIO, an effect system for Scala, from first steps to concurrency.""".stripMargin

To decode the reply:

println(replyCodec.decode(reply))
// Right(Recommendation(Zionomicon,It teaches ZIO, an effect system for Scala, from first steps to concurrency.))

A single record's value runs to the end of its line, so the commas in reason need no quotes.

Reading a Malformed Reply​

Models don't always follow instructions. ToonCodec#decode turns each kind of mistake into a Left, and the error's message names the field at fault and its path (. is the top-level record):

val malformedReplies = List(
"missing field" -> "title: Zionomicon",
"renamed field" -> "book: Zionomicon\nreason: It teaches ZIO.",
"extra field" -> "title: Zionomicon\nreason: It teaches ZIO.\nconfidence: 0.9",
"prose before TOON" -> "Sure! Here is my pick:\ntitle: Zionomicon\nreason: It teaches ZIO."
)

To decode each malformed reply and print its error:

for ((label, text) <- malformedReplies)
replyCodec.decode(text) match {
case Left(error) => println(s"$label: ${error.message}")
case Right(value) => println(s"$label: decoded $value")
}
// missing field: Missing required field: reason at: .
// renamed field: Unexpected field: book at: .
// extra field: Unexpected field: confidence at: .
// prose before TOON: Unexpected field: Sure! Here is my pick at: .

The last case is the reason for ToonCodecDeriver#withRejectExtraFields(true). The line Sure! Here is my pick: ends with a colon, so TOON reads it as a key named Sure! Here is my pick. With the default deriver, unknown keys are skipped, and that prose would be accepted without a word:

val lenientCodec = Recommendation.schema.derive(ToonFormat)

To decode the prose-prefixed reply with the lenient codec:

println(lenientCodec.decode("Sure! Here is my pick:\ntitle: Zionomicon\nreason: It teaches ZIO."))
// Right(Recommendation(Zionomicon,It teaches ZIO.))

Rejecting extra fields turns that silent pass into an error you can log, retry or show.

Stripping Prose and Code Fences​

A common reply shape wraps the TOON in a Markdown code fence, with prose before and after it. That text isn't TOON, so ToonCodec#decode rejects it:

val chattyReply =
"""Happy to help! Here is my recommendation:
|
|```toon
|title: Zionomicon
|reason: It teaches ZIO, an effect system for Scala, from first steps to concurrency.
|```
|
|Enjoy the book!""".stripMargin

To decode the chatty reply with the strict codec:

println(replyCodec.decode(chattyReply).left.map(_.message))
// Left(Unexpected field: Happy to help! Here is my recommendation at: .)

Plain string handling is enough to cut out the fenced block before decoding. When there is no fence, the helper returns the trimmed reply unchanged:

def extractToon(reply: String): String = {
val fence = "```"
val open = reply.indexOf(fence)
if (open < 0) reply.trim
else {
val bodyStart = reply.indexOf('\n', open) + 1
val close = reply.indexOf(fence, bodyStart)
reply.substring(bodyStart, if (close < 0) reply.length else close).trim
}
}

Now the chatty reply decodes:

println(replyCodec.decode(extractToon(chattyReply)))
// Right(Recommendation(Zionomicon,It teaches ZIO, an effect system for Scala, from first steps to concurrency.))

Prose without a fence is left in place on purpose. Guessing where TOON starts in free text is fragile, and the strict codec already reports it as an error.

Putting It Together​

This example combines every step: it encodes the catalog as pipe-delimited tabular TOON, builds the prompt, and decodes a fixed model reply, stripping its code fence and rejecting extra fields:

schema-examples/src/main/scala/llmdatawithtoon/CompleteExample.scala
package llmdatawithtoon

import zio.blocks.schema._
import zio.blocks.schema.toon._

/**
* How to Send Compact Data to an LLM with TOON and Parse the Reply — Complete
* Example
*
* Encodes the catalog as pipe-delimited tabular TOON, builds the prompt, and
* decodes a fixed model reply, stripping its code fence and rejecting extra
* fields.
*
* Run with: cd schema-examples && sbt "runMain llmdatawithtoon.CompleteExample"
*/
object CompleteExample extends App {

case class Book(title: String, firstAuthor: String, year: Int, tags: String)

object Book {
implicit val schema: Schema[Book] = Schema.derived
}

case class Catalog(books: List[Book])

object Catalog {
implicit val schema: Schema[Catalog] = Schema.derived
}

val catalog = Catalog(
List(
Book("Zionomicon", "John A. De Goes", 2024, "zio, effects, concurrency"),
Book("Programming in Scala", "Martin Odersky", 2008, "scala, language, fundamentals"),
Book("Functional Programming in Scala", "Paul Chiusano", 2014, "fp, scala, exercises")
)
)

val tabular = ToonCodecDeriver.withArrayFormat(ArrayFormat.Tabular)

val pipeCodec = Catalog.schema.derive(tabular.withDelimiter(Delimiter.Pipe))

val catalogPipeToon = pipeCodec.encodeToString(catalog)

def buildPrompt(catalogToon: String, request: String): String =
List(
"You recommend books from the catalog below. The catalog is in TOON format.",
"",
catalogToon,
"",
s"Request: $request",
"",
"Reply in TOON with exactly two fields and no other text:",
"title: <a title from the catalog>",
"reason: <one sentence>"
).mkString("\n")

val prompt = buildPrompt(catalogPipeToon, "I know Scala and want to learn effect systems.")

case class Recommendation(title: String, reason: String)

object Recommendation {
implicit val schema: Schema[Recommendation] = Schema.derived
}

val replyCodec: ToonCodec[Recommendation] =
Recommendation.schema.derive(ToonCodecDeriver.withRejectExtraFields(true))

def extractToon(reply: String): String = {
val fence = "```"
val open = reply.indexOf(fence)
if (open < 0) reply.trim
else {
val bodyStart = reply.indexOf('\n', open) + 1
val close = reply.indexOf(fence, bodyStart)
reply.substring(bodyStart, if (close < 0) reply.length else close).trim
}
}

val chattyReply =
"""Happy to help! Here is my recommendation:
|
|```toon
|title: Zionomicon
|reason: It teaches ZIO, an effect system for Scala, from first steps to concurrency.
|```
|
|Enjoy the book!""".stripMargin

println(prompt)

replyCodec.decode(extractToon(chattyReply)) match {
case Left(error) => println(error.message)
case Right(recommendation) => println(recommendation)
}
}

Running the Examples​

All examples in this guide have corresponding runnable Scala files in the schema-examples project. To get them, clone the repository and change into the examples project:

git clone https://github.com/zio/zio-blocks.git
cd zio-blocks/schema-examples

Encoding the Records for a Prompt​

This example encodes the catalog as tabular TOON and compares its length with the JSON encoding.

schema-examples/src/main/scala/llmdatawithtoon/Step1EncodeForPromptExample.scala
schema-examples/src/main/scala/llmdatawithtoon/Step1EncodeForPromptExample.scala
package llmdatawithtoon

import zio.blocks.schema._
import zio.blocks.schema.toon._

/**
* How to Send Compact Data to an LLM with TOON and Parse the Reply — Encoding
* the Records for a Prompt
*
* Encodes the catalog as tabular TOON and compares its length with the JSON
* encoding.
*
* Run with: cd schema-examples && sbt "runMain
* llmdatawithtoon.Step1EncodeForPromptExample"
*/
object Step1EncodeForPromptExample extends App {

case class Book(title: String, firstAuthor: String, year: Int, tags: String)

object Book {
implicit val schema: Schema[Book] = Schema.derived
}

case class Catalog(books: List[Book])

object Catalog {
implicit val schema: Schema[Catalog] = Schema.derived
}

val catalog = Catalog(
List(
Book("Zionomicon", "John A. De Goes", 2024, "zio, effects, concurrency"),
Book("Programming in Scala", "Martin Odersky", 2008, "scala, language, fundamentals"),
Book("Functional Programming in Scala", "Paul Chiusano", 2014, "fp, scala, exercises")
)
)

val tabular = ToonCodecDeriver.withArrayFormat(ArrayFormat.Tabular)

val catalogCodec: ToonCodec[Catalog] = Catalog.schema.derive(tabular)

val catalogToon = catalogCodec.encodeToString(catalog)

println(catalogToon)

val catalogJson = catalog.toJsonString

println(catalogJson)
println(s"JSON: ${catalogJson.length} characters, TOON: ${catalogToon.length} characters")
}

Run it from the schema-examples directory:

sbt "runMain llmdatawithtoon.Step1EncodeForPromptExample"

Why ArrayFormat.Tabular Must Be Set​

This example prints the catalog with ArrayFormat.Auto and with ArrayFormat.Tabular, side by side.

schema-examples/src/main/scala/llmdatawithtoon/Step2AutoVsTabularExample.scala
schema-examples/src/main/scala/llmdatawithtoon/Step2AutoVsTabularExample.scala
package llmdatawithtoon

import zio.blocks.schema._
import zio.blocks.schema.toon._

/**
* How to Send Compact Data to an LLM with TOON and Parse the Reply — Why
* ArrayFormat.Tabular Must Be Set
*
* Prints the catalog with ArrayFormat.Auto and with ArrayFormat.Tabular, side
* by side.
*
* Run with: cd schema-examples && sbt "runMain
* llmdatawithtoon.Step2AutoVsTabularExample"
*/
object Step2AutoVsTabularExample extends App {

case class Book(title: String, firstAuthor: String, year: Int, tags: String)

object Book {
implicit val schema: Schema[Book] = Schema.derived
}

case class Catalog(books: List[Book])

object Catalog {
implicit val schema: Schema[Catalog] = Schema.derived
}

val catalog = Catalog(
List(
Book("Zionomicon", "John A. De Goes", 2024, "zio, effects, concurrency"),
Book("Programming in Scala", "Martin Odersky", 2008, "scala, language, fundamentals"),
Book("Functional Programming in Scala", "Paul Chiusano", 2014, "fp, scala, exercises")
)
)

val tabular = ToonCodecDeriver.withArrayFormat(ArrayFormat.Tabular)

val catalogCodec: ToonCodec[Catalog] = Catalog.schema.derive(tabular)

val catalogToon = catalogCodec.encodeToString(catalog)

val autoToon = Catalog.schema.derive(ToonFormat).encodeToString(catalog)

println("ArrayFormat.Auto:")
println(autoToon)
println()
println("ArrayFormat.Tabular:")
println(catalogToon)
println()
println(s"Auto: ${autoToon.length} characters, Tabular: ${catalogToon.length} characters")
}

Run it from the schema-examples directory:

sbt "runMain llmdatawithtoon.Step2AutoVsTabularExample"

Choosing a Delimiter​

This example prints the catalog with the comma, pipe and tab delimiters and decodes the pipe version back.

schema-examples/src/main/scala/llmdatawithtoon/Step3DelimitersExample.scala
schema-examples/src/main/scala/llmdatawithtoon/Step3DelimitersExample.scala
package llmdatawithtoon

import zio.blocks.schema._
import zio.blocks.schema.toon._

/**
* How to Send Compact Data to an LLM with TOON and Parse the Reply — Choosing a
* Delimiter
*
* Prints the catalog with the comma, pipe and tab delimiters and decodes the
* pipe version back.
*
* Run with: cd schema-examples && sbt "runMain
* llmdatawithtoon.Step3DelimitersExample"
*/
object Step3DelimitersExample extends App {

case class Book(title: String, firstAuthor: String, year: Int, tags: String)

object Book {
implicit val schema: Schema[Book] = Schema.derived
}

case class Catalog(books: List[Book])

object Catalog {
implicit val schema: Schema[Catalog] = Schema.derived
}

val catalog = Catalog(
List(
Book("Zionomicon", "John A. De Goes", 2024, "zio, effects, concurrency"),
Book("Programming in Scala", "Martin Odersky", 2008, "scala, language, fundamentals"),
Book("Functional Programming in Scala", "Paul Chiusano", 2014, "fp, scala, exercises")
)
)

val tabular = ToonCodecDeriver.withArrayFormat(ArrayFormat.Tabular)

for (delimiter <- List(Delimiter.Comma, Delimiter.Pipe, Delimiter.Tab)) {
val codec = Catalog.schema.derive(tabular.withDelimiter(delimiter))
println(s"$delimiter:")
println(codec.encodeToString(catalog))
println()
}

val pipeCodec = Catalog.schema.derive(tabular.withDelimiter(Delimiter.Pipe))

println(pipeCodec.decode(pipeCodec.encodeToString(catalog)) == Right(catalog))
}

Run it from the schema-examples directory:

sbt "runMain llmdatawithtoon.Step3DelimitersExample"

Building the Prompt​

This example builds the prompt string around the pipe-delimited catalog and prints it.

schema-examples/src/main/scala/llmdatawithtoon/Step4BuildPromptExample.scala
schema-examples/src/main/scala/llmdatawithtoon/Step4BuildPromptExample.scala
package llmdatawithtoon

import zio.blocks.schema._
import zio.blocks.schema.toon._

/**
* How to Send Compact Data to an LLM with TOON and Parse the Reply — Building
* the Prompt
*
* Builds the prompt string around the pipe-delimited catalog and prints it.
*
* Run with: cd schema-examples && sbt "runMain
* llmdatawithtoon.Step4BuildPromptExample"
*/
object Step4BuildPromptExample extends App {

case class Book(title: String, firstAuthor: String, year: Int, tags: String)

object Book {
implicit val schema: Schema[Book] = Schema.derived
}

case class Catalog(books: List[Book])

object Catalog {
implicit val schema: Schema[Catalog] = Schema.derived
}

val catalog = Catalog(
List(
Book("Zionomicon", "John A. De Goes", 2024, "zio, effects, concurrency"),
Book("Programming in Scala", "Martin Odersky", 2008, "scala, language, fundamentals"),
Book("Functional Programming in Scala", "Paul Chiusano", 2014, "fp, scala, exercises")
)
)

val tabular = ToonCodecDeriver.withArrayFormat(ArrayFormat.Tabular)

val pipeCodec = Catalog.schema.derive(tabular.withDelimiter(Delimiter.Pipe))

val catalogPipeToon = pipeCodec.encodeToString(catalog)

def buildPrompt(catalogToon: String, request: String): String =
List(
"You recommend books from the catalog below. The catalog is in TOON format.",
"",
catalogToon,
"",
s"Request: $request",
"",
"Reply in TOON with exactly two fields and no other text:",
"title: <a title from the catalog>",
"reason: <one sentence>"
).mkString("\n")

val prompt = buildPrompt(catalogPipeToon, "I know Scala and want to learn effect systems.")

println(prompt)
}

Run it from the schema-examples directory:

sbt "runMain llmdatawithtoon.Step4BuildPromptExample"

Decoding the Model's Reply​

This example decodes a well-formed reply, prints the error for each malformed reply, and strips a code fence before decoding.

schema-examples/src/main/scala/llmdatawithtoon/Step5DecodeReplyExample.scala
schema-examples/src/main/scala/llmdatawithtoon/Step5DecodeReplyExample.scala
package llmdatawithtoon

import zio.blocks.schema._
import zio.blocks.schema.toon._

/**
* How to Send Compact Data to an LLM with TOON and Parse the Reply — Decoding
* the Model's Reply
*
* Decodes a well-formed reply, prints the error for each malformed reply, and
* strips a code fence before decoding.
*
* Run with: cd schema-examples && sbt "runMain
* llmdatawithtoon.Step5DecodeReplyExample"
*/
object Step5DecodeReplyExample extends App {

case class Recommendation(title: String, reason: String)

object Recommendation {
implicit val schema: Schema[Recommendation] = Schema.derived
}

val replyCodec: ToonCodec[Recommendation] =
Recommendation.schema.derive(ToonCodecDeriver.withRejectExtraFields(true))

val reply =
"""title: Zionomicon
|reason: It teaches ZIO, an effect system for Scala, from first steps to concurrency.""".stripMargin

println(replyCodec.decode(reply))

val malformedReplies = List(
"missing field" -> "title: Zionomicon",
"renamed field" -> "book: Zionomicon\nreason: It teaches ZIO.",
"extra field" -> "title: Zionomicon\nreason: It teaches ZIO.\nconfidence: 0.9",
"prose before TOON" -> "Sure! Here is my pick:\ntitle: Zionomicon\nreason: It teaches ZIO."
)

for ((label, text) <- malformedReplies)
replyCodec.decode(text) match {
case Left(error) => println(s"$label: ${error.message}")
case Right(value) => println(s"$label: decoded $value")
}

val lenientCodec = Recommendation.schema.derive(ToonFormat)

println(lenientCodec.decode("Sure! Here is my pick:\ntitle: Zionomicon\nreason: It teaches ZIO."))

def extractToon(reply: String): String = {
val fence = "```"
val open = reply.indexOf(fence)
if (open < 0) reply.trim
else {
val bodyStart = reply.indexOf('\n', open) + 1
val close = reply.indexOf(fence, bodyStart)
reply.substring(bodyStart, if (close < 0) reply.length else close).trim
}
}

val chattyReply =
"""Happy to help! Here is my recommendation:
|
|```toon
|title: Zionomicon
|reason: It teaches ZIO, an effect system for Scala, from first steps to concurrency.
|```
|
|Enjoy the book!""".stripMargin

println(replyCodec.decode(chattyReply).left.map(_.message))

println(replyCodec.decode(extractToon(chattyReply)))
}

Run it from the schema-examples directory:

sbt "runMain llmdatawithtoon.Step5DecodeReplyExample"

Complete Example​

Run the complete example from the schema-examples directory:

sbt "runMain llmdatawithtoon.CompleteExample"

To compile every example at once without running them:

sbt compile

Going Further​

  • The TOON Codec Module reference documents every ToonCodecDeriver option, including ReaderConfig and WriterConfig, key folding and discriminators.
  • The Built-in Formats and Codecs overview shows the other formats you can derive from the same Schema.
  • The upcoming tutorial "One Type, Many Formats" encodes one type to TOON alongside JSON, YAML, CSV and other formats.
  • To ask the model for several picks, decode a record with a list field, such as case class Picks(picks: List[Recommendation]). The model can answer with a tabular block like picks[2]{title,reason}:.