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. I want to make Ruby the best language to work with LLMs. Part of that is a great JSON Schema DSL. Schematist https://github.com/crmne/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 = { "$schema" = "https://json-schema.org/draft/2020-12/schema", "title" = "Invoice", "description" = "A billing document", "type" = "object", "properties" = { "id" = { "type" = "string", "pattern" = "^inv ", "title" = "Invoice ID" }, "total" = { "type" = "number", "description" = "Amount due", "exclusiveMinimum" = 0 }, ... }, "required" = "id", "total", "currency", "status" , "additionalProperties" = false } 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 https://github.com/crmne/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: php 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: js 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 https://github.com/crmne/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 https://github.com/crmne/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 https://rubyllm.com 2.0 will depend on Schematist, so structured output will get a lot more powerful. Use It bundle add schematist Schematist https://github.com/crmne/schematist was always a JSON Schema DSL. Now it has the name to match.