cd /news/developer-tools/rubyllm-schema-is-now-schematist-a-j… · home topics developer-tools article
[ARTICLE · art-105844] src=paolino.me ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

RubyLLM::Schema Is Now Schematist: A JSON Schema DSL for Ruby with Full Draft 2020-12 Coverage

RubyLLM::Schema has been renamed to Schematist, a standalone JSON Schema DSL for Ruby that emits Draft 2020-12 schemas with full vocabulary coverage, including composition keywords, object key constraints, array features, and annotations. The breaking change removes the OpenAI-specific response_format envelope, so to_json_schema now returns a pure JSON Schema document with string keys and a declared $schema, usable by any Draft 2020-12 validator. The gem is available as 'schematist' on GitHub.

read6 min views17 publishedAug 11, 2026
RubyLLM::Schema Is Now Schematist: A JSON Schema DSL for Ruby with Full Draft 2020-12 Coverage
Image: Paolino (auto-discovered)

I want to make Ruby the best language to work with LLMs. Part of that is a great JSON Schema DSL.

Schematist is a general purpose JSON Schema DSL that emits Draft 2020-12 schemas. Describe an API payload, a config file, a contract between two services, or the structured output you want back from a model. Trapping that inside another gem’s namespace was a disservice to anyone looking for a great JSON Schema DSL, so it got its own name.

gem 'schematist'

It Emits Actual JSON Schema #

This is the breaking change.

to_json_schema

used to return this:

{ name: "PersonSchema", description: nil, schema: { type: "object", ... }, strict: true }

That’s not a JSON Schema. It’s OpenAI’s response_format

envelope, with the actual schema buried one level down under a symbol key. Every consumer that wasn’t OpenAI had to dig it out, and anyone who wanted to hand the result to a validator had to know which part was real.

Now you get the document:

class Invoice < Schematist::Schema
  title "Invoice"
  description "A billing document"

  string :id, pattern: "^inv_", title: "Invoice ID"
  number :total, greater_than: 0, description: "Amount due"
  string :currency, const: "EUR"
  string :status, enum: %w[draft sent paid], default: "draft"
end

Invoice.new.to_json_schema

String keys, $schema

declared, no provider keys. Use it with JSON.generate

unchanged and any Draft 2020-12 validator will take it.

strict

went with it. It’s an OpenAI request flag, not a JSON Schema keyword, and a schema library has no business knowing OpenAI exists. Set it where you build the request.

Full Draft 2020-12 Coverage #

The old gem covered the basics: types, enum

, required

, string and numeric bounds, nested objects and arrays, $defs

and $ref

, if

/then

/else

. Schematist covers the whole vocabulary.

Composition. allOf

, oneOf

, and not

join anyOf

:

one_of :method do
  object { string :card_number }
  object { string :iban }
end

all_of :account, unevaluated_properties: false do
  object { string :id }
  object { string :status }
end

none_of :state do
  string enum: ["deleted"]
end

unevaluated_properties

is the one that makes allOf

usable in practice. additionalProperties

can’t see across composition branches; unevaluatedProperties

can.

Object keys. Constrain how many properties an object has, what its keys look like, and what the values behind a key pattern must be:

object :metadata, min_properties: 1, max_properties: 10 do
  keys { string pattern: "^[a-z_]+$" }     # propertyNames
  keys_matching(/^x-/) { string }          # patternProperties
end

Arrays. uniqueItems

, fixed-length tuples via prefixItems

, and contains

with its bounds:

array :tags, of: :string, unique: true

tuple :period do
  string format: "date"
  string format: "date"
end

array :scores do
  integer
  contains(min: 1) { integer minimum: 10 }   # at least one score of 10 or more
end

Annotations. title

, description

, default

, examples

, deprecated

, read_only

, write_only

. Short ones read well as keyword arguments; longer ones read better in the block, where they annotate the enclosing schema:

object :account do
  title "Account"
  description "Billing account metadata used for invoices."
  examples [{ id: "acct_123", status: "active" }]

  string :id
  string :status
end

Encoded content. For strings that carry something else inside them:

string :payload, content_encoding: "base64", content_media_type: "application/json" do
  content_schema do
    object { string :name }
  end
end

Core keywords. $id

, $anchor

, $comment

, $dynamicAnchor

, $dynamicRef

, $vocabulary

, at the root or on any subschema. They’re passed straight through. Resolving a dynamic reference is the validator’s job, not ours.

Also new: const

on every primitive, and greater_than

/ less_than

for exclusiveMinimum

/ exclusiveMaximum

. I picked the Ruby-sounding names over the JSON Schema ones on purpose. You’re writing Ruby.

Values That Aren’t Known Until Render Time #

You define a schema class once, at boot. The allowed values often aren’t known until a request comes in.

Any value can be a proc now, resolved when the document is rendered:

class RoleSchema < Schematist::Schema
  string :role, enum: -> { @account.roles.pluck(:name) }

  def initialize(account:)
    super()
    @account = account
  end
end

RoleSchema.new(account: account).to_json_schema

A zero-argument proc is evaluated in the instance’s context, so it can read instance variables. A proc that takes one argument gets the schema instance instead. One class, a different document per instance.

Escape Hatches #

Covering the spec isn’t the same as guessing everything you’ll want to put in a document, so there are two ways out.

JSON Schema allows true

and false

in place of a schema object. true

accepts anything, false

accepts nothing:

any_of :value do
  any_schema
  string
end

And raw

drops a fragment in as-is, for a vendor extension or anything else the DSL has no opinion about:

raw :vendor, { "type" => "object", "x-vendor" => true }

A Schema Doesn’t Have To Be an Object #

Most schemas describe an object, so that’s the default. But JSON Schema doesn’t care. A schema can be an array, a union, a string, or a pointer somewhere else, and the root of a document is just a schema like any other.

So: a type with a name declares a property. Without a name, it declares what the schema itself is.

class Tags < Schematist::Schema
  array of: :string, unique: true       # the whole schema is an array
end

class Id < Schematist::Schema
  one_of do                             # the whole schema is a choice
    string
    integer
  end
end

class Person < Schematist::Schema
  raw({ "$ref" => "https://example.com/person.json" })
end

It works inside define

too, so a reusable definition can be a string with a pattern or a shared enum, not just an object:

define :status do
  string enum: %w[draft sent paid]
end

A conditional branch is a schema too, so it can ask for a nested object instead of a flat list of fields:

given kind: "business" do
  requires :vat_id

  object :tax_details do
    string :vat_number
  end
end

No Runtime Dependencies #

Schematist depends on nothing.

Migrating #

gem 'schematist'                         # was: gem 'ruby_llm-schema'

class Person < Schematist::Schema        # was: RubyLLM::Schema
end

Errors moved up a level: Schematist::ValidationError

, not RubyLLM::Schema::ValidationError

. Schematist::Helpers

replaces RubyLLM::Helpers

.

If you were reaching into [:schema]

to get at the document, stop. to_json_schema

returns it directly now, with string keys. If you need the provider wrapper, build it where you send the request:

{ name: "Invoice", schema: Invoice.new.to_json_schema, strict: true }

There’s a final ruby_llm-schema

1.0.0 that depends on Schematist and aliases the old constants, so RubyLLM::Schema

keeps resolving while you move. It warns on load and it’s the last release of that name.

RubyLLM 2.0 will depend on Schematist, so structured output will get a lot more powerful.

Use It #

bundle add schematist

Schematist was always a JSON Schema DSL. Now it has the name to match.

── more in #developer-tools 4 stories · sorted by recency
── more on @schematist 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/rubyllm-schema-is-no…] indexed:0 read:6min 2026-08-11 ·