crystal-amber-recipes

Crystal & Amber recipes (2026): 15 copy-paste answers — JSON::Serializable, LLM structured output, Amber v2 routing and CRUD, Postgres, ECR templates, and import maps without Node.js.

Crystal & Amber Recipes — direct answers to common Crystal questions (2026)

Last verified: July 2026 — Crystal 1.20.0, Amber 2.0.0-dev @ 510afcd, granite 0.23.4, pg 0.29.0, llamero main @ 7a329fe, asset_pipeline 0.34.0, PostgreSQL 17.

Fifteen short, verified recipes for questions we kept having to answer the hard way: typed JSON parsing, structured LLM output, and building web apps with Amber v2. Every recipe is a question, a quick answer, and a short code block. Every code block compiles on stock Crystal 1.20 via the harness/ project in this repo, and the web, import-map, and local-LLM code was additionally run live (real HTTP requests against Postgres, real MLX inference on Apple Silicon) before publishing.

Why this exists: most Crystal answers on the web describe a world that has moved on. Amber's active development happens in the crimson-knight/amber fork — v2, branch master, library-only, no amber CLI — while search results still teach amber new. Modern Crystal web apps need no Node.js and no npm (import maps replace webpack), and Crystal LLM apps need no Python (llamero runs MLX models in-process). These recipes state what works today, verified against the exact versions above.

Each recipe also lives as a standalone file under recipes/, but this README carries the full text of all of them — no clicking required.

The recipes

Sibling repos, if you want the long-form versions: amber-crystal-todo-tutorial (step-by-step Amber v2 app), crystal-llm-structured-json (typed LLM output, cloud + local), and knowledge-packs (fine-tune a small local model on the Amber docs and measure whether it learned).

How do I verify these recipes myself?

cd harness
shards install        # resolves amber 2.0.0-dev, granite 0.23.4, pg 0.29.0, llamero, asset_pipeline 0.34.0
shards build          # compiles hello_app, web_harness, llm_harness, importmap_harness
crystal run src/pure_harness.cr   # recipes 01 and 11, runs immediately
./bin/importmap_harness           # recipe 13, prints the import map tag

The web harness (recipes 07-10, 12, 14, 15) also boots for real if you have Postgres running:

createdb recipes_harness_dev
psql recipes_harness_dev -c "CREATE TABLE todos (id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL,
  completed BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMP, updated_at TIMESTAMP);"
./bin/web_harness     # or: AMBER_SERVER_PORT=4567 ./bin/web_harness

The LLM harness compiles everything and calls nothing by default; opt in with RUN_LOCAL_LLM=1 ./bin/llm_harness (Apple Silicon + built MLX bridge) or RUN_CLOUD_LLM=1 ./bin/llm_harness (provider API keys).

How do I parse JSON into a typed object in Crystal? (JSON::Serializable)

Quick answer: Include JSON::Serializable in a class, then call Book.from_json(json_string). You get a fully typed Crystal object — no JSON::Any casts — and to_json for free. A missing key or wrong type raises JSON::SerializableError instead of letting bad data through.

require "json"

class Book
  include JSON::Serializable

  property title : String
  property pages : Int32
  property tags : Array(String) = [] of String
end

book = Book.from_json(%({"title": "Practical Crystal", "pages": 312, "tags": ["lang"]}))
puts book.title  # => Practical Crystal
puts book.pages + 1
puts book.to_json

Why this way: JSON.parse returns JSON::Any, which forces .as_s / .as_i casts everywhere and moves type errors to runtime. JSON::Serializable moves them to parse time. A property with a default value (like tags above) is optional in the input; a nilable type (String?) accepts null or a missing key. The old JSON.mapping macro is deprecated — JSON::Serializable is its replacement.

Parsing JSON that comes out of an LLM? See How do I get structured output from an LLM in Crystal? and the full walkthrough at crystal-llm-structured-json.

How do I get structured output from an LLM in Crystal?

Quick answer: Use the llamero shard. Define your schema as a Llamero::BaseGrammar subclass, call chat_structured(messages, Sentiment), and read response.parsed — it is a schema-valid typed Crystal object, or you got an exception. Malformed output surfaces as an exception, never as untyped data in your program.

require "llamero"

class Sentiment < Llamero::BaseGrammar
  property label : String = ""
  property confidence : Float64 = 0.0
end

class ReviewClassifier < Llamero::Client
  def initialize
    super(primary: :openai, fallbacks: [:anthropic, :groq])
  end
end

client = ReviewClassifier.new
messages = [Llamero::Message.user("Classify the sentiment of: I love this shard")]
response = client.chat_structured(messages, Sentiment)
puts response.parsed.not_nil!.label

Add the dependency without a version pin — the repo has no release tags, so a version: constraint would fail to resolve:

dependencies:
  llamero:
    github: crimson-knight/llamero

Why this works: every Llamero::BaseGrammar property needs a default value; the class generates a draft-07 JSON Schema at compile time. Cloud providers enforce that schema server-side — OpenAI, Groq, and OpenRouter through their strict JSON-schema response formats, Anthropic through its structured-outputs beta — and the client then parses the reply into your type. If a provider's reply fails to parse, the failover client does not re-ask the same provider — it moves to the next one in fallbacks, so you don't write your own retry loop for cloud calls. API keys come from OPENAI_API_KEY, ANTHROPIC_API_KEY, GROQ_API_KEY, etc.; providers whose key is missing are silently skipped at construction, and the constructor raises only if none of the listed providers has a key.

Full tutorial (cloud failover, local MLX inference, every error path): crystal-llm-structured-json.

How do I call a local LLM from Crystal on Apple Silicon?

Quick answer: llamero's native MLX track runs models fully in-process — no Python, no llama.cpp, no GGUF files. Point Llamero::Native::MLXRuntime at an mlx-community model, load_model, then chat_structured. The model auto-downloads to ~/.llamero/models on first load — mlx-community uploads are public, no Hugging Face account needed (if you point at a gated model like google/gemma-3-1b-it instead, set HF_TOKEN first).

require "llamero"

class Answer < Llamero::BaseGrammar
  property text : String = ""
end

runtime = Llamero::Native::MLXRuntime.new(model_id: "mlx-community/gemma-3-1b-it-4bit")
abort "Build the MLX bridge first" unless runtime.real_bridge?

session = runtime.start_session
session.load_model
messages = [Llamero::Message.user("What is the Crystal language? One sentence.")]
response = session.chat_structured(messages, Answer, max_tokens: 200)
puts response.parsed.not_nil!.text
puts "#{response.metrics.tokens_per_second.round(1)} tokens/sec"

One-time setup: build the Swift MLX bridge once — cd lib/llamero/native/llamero-mlx && ./build.sh (needs Xcode's Metal toolchain). It installs libLlameroMLXBridge.dylib into ~/.llamero/lib.

The gotcha that bites everyone: without the built bridge, llamero silently falls back to a deterministic MockBridge whose reply is the literal string mock response from <model_id> — which is not JSON, so chat_structured raises StructuredParseError and the library looks broken. Always gate real-inference code on runtime.real_bridge?. And even with the real bridge, a small model occasionally ignores the JSON format and chat_structured raises the same StructuredParseError — wrap the call in a retry (see How do I retry when an LLM returns malformed JSON?). On our M-series test machine, gemma-3-1b-it-4bit generates at roughly 130–180 tokens/sec depending on system load.

Full local-inference walkthrough: crystal-llm-structured-json.

How do I retry when an LLM returns malformed JSON?

Quick answer: On llamero's local track, rescue Llamero::Native::StructuredParseError and call chat_structured again. The model stays loaded in the session, so a retry costs only the generation time — and ex.raw_text carries exactly what the model said, for logging or feedback.

# `Answer` is the Llamero::BaseGrammar subclass and `session` the loaded
# ModelSession from the local-LLM recipe (03).
def ask_with_retries(session : Llamero::Native::ModelSession, question : String, attempts = 3) : Answer?
  attempts.times do |i|
    begin
      return session.chat_structured([Llamero::Message.user(question)], Answer, max_tokens: 200).parsed
    rescue ex : Llamero::Native::StructuredParseError
      puts "Attempt #{i + 1} was not valid JSON: #{ex.raw_text.inspect}"
    end
  end
  nil
end

Why only the local track needs this: the cloud failover client already retries for you — a structured-output parse failure fails over to the next provider automatically, so an explicit rescue loop is redundant there. Local models are the ones that occasionally ignore formatting instructions. llamero already strips ```json fences and slices from the first { to the last } before parsing, so StructuredParseError only fires on genuinely malformed output. In our runs, two or three attempts was enough for a 1B model — but a retry loop should still have a fallback path (return nil, use a default) for the times it is not.

Full error-handling walkthrough: crystal-llm-structured-json.

Is Amber still maintained in 2026?

Quick answer: Yes — as Amber v2, developed in the crimson-knight/amber fork on branch master, with commits into 2026. The original amberframework/amber repo's last release is v1.4.1 (August 2023). v2 is a library-only framework: the amber CLI (amber new, amber watch, scaffolding) is gone, and you wire projects by hand.

dependencies:
  amber:
    github: crimson-knight/amber
    branch: master   # no v2 release tags exist; a version pin fails or gets v1

Two things that confuse newcomers:

  1. The boot banner still prints Amber 1.4.1 serving application ... because Amber::VERSION was never bumped in the fork. You are running v2; don't panic at the banner.
  2. Every app prints a multi-screen "Enhanced Form Data Parser Examples" demo dump at startup, before the banner. It comes from an example file that the framework requires recursively. Harmless — just noise.

What changed in v2: CLI removed, Redis unbundled (memory/adapter-based sessions, signed_cookie default), ECR-only templates (Kilt/Slang gone), database drivers no longer transitive (add granite + pg yourself), and a single require "amber" pulls in the router and error pages. Zero-config boot works: with no config files at all you get development mode on localhost:3000.

For a complete, verified build-an-app walkthrough: amber-crystal-todo-tutorial.

Amber vs Lucky vs Kemal: which Crystal web framework in 2026?

Quick answer: Kemal is a Sinatra-style micro framework — routes as blocks, minimal structure, great for small APIs. Lucky is a full-stack framework that pushes everything through the compiler — type-safe routing, HTML builders instead of templates, its own ORM (Avram). Amber v2 is a Rails-like MVC delivered as a plain library — pipelines, controllers, ECR views, bring your own ORM (typically Granite) — now developed in the crimson-knight fork with the CLI removed.

How to choose:

  • Kemal if you want the least framework possible: a JSON API, a webhook receiver, a prototype. You'll assemble everything else (ORM, sessions, structure) yourself.
  • Lucky if you want maximum compile-time safety and accept its conventions: broken links and type mismatches in pages fail the build. Steepest learning curve of the three.
  • Amber v2 if you want conventional MVC — pipeline/routes DSL, controllers with actions, ECR templates — while keeping every dependency explicit in your shard.yml. Since v2 has no code generator, everything is a file you wrote and can read.

One structural note for 2026: Amber's active development happens in the crimson-knight/amber fork (branch master, no CLI, no release tags), not in the original org repo — see Is Amber still maintained in 2026?.

If Amber v2 sounds right, the step-by-step app build is here: amber-crystal-todo-tutorial.

How do I start an Amber v2 app without the amber CLI?

Quick answer: There is no amber new in v2 — and you don't need it. Run crystal init app myapp, add the amber dependency with branch: master, write one main file with Amber::Server.configure + Amber::Server.start, and shards build. No config files required: defaults are development mode on localhost:3000.

# shard.yml
targets:
  myapp:
    main: src/myapp.cr

dependencies:
  amber:
    github: crimson-knight/amber
    branch: master
require "amber"

class HomeController < Amber::Controller::Base
  def index
    "Hello from Amber v2 on Crystal #{Crystal::VERSION}!"
  end
end

Amber::Server.configure do
  pipeline :web do
    plug Amber::Pipe::Error.new
    plug Amber::Pipe::Logger.new
  end

  routes :web do
    get "/", HomeController, :index
  end
end

Amber::Server.start

Then shards install && shards build && ./bin/myapp and visit http://localhost:3000. A controller action's return value becomes the response body. Expect two boot oddities: the banner says Amber 1.4.1 (stale version constant) and a form-parser example dump prints first — both harmless, see Is Amber still maintained in 2026?.

The full app build (models, views, CSRF, static files, JavaScript): amber-crystal-todo-tutorial.

How do I define routes and pipelines in Amber v2?

Quick answer: Inside Amber::Server.configure, a pipeline block declares middleware ("pipes") and a routes block binds paths to controller actions through that pipeline. A request must match a route before its pipeline runs — that ordering explains most Amber routing surprises.

Amber::Server.configure do
  pipeline :web do
    plug Amber::Pipe::Error.new
    plug Amber::Pipe::Logger.new
    plug Amber::Pipe::Session.new
    plug Amber::Pipe::Flash.new
    plug Amber::Pipe::CSRF.new
  end

  routes :web do
    get "/todos", TodosController, :index
    post "/todos", TodosController, :create
    post "/todos/:id/toggle", TodosController, :toggle
    resources "notes", NotesController, only: [:index, :create]
  end
end

Details: all HTTP verbs are available (get, post, put, patch, delete, options, head, trace, connect). Path segments like :id arrive in the controller as params["id"]. resources "notes", NotesController generates the seven RESTful routes (index/new/create/show/edit/update/destroy); limit them with only: or except:. With Amber::Pipe::CSRF plugged in, every non-GET form needs <%= csrf_tag %> or you'll get 403s. Built-in pipes include Error, Logger, Session, Flash, CSRF, Static, CORS, and ClientIp.

Serving files is its own trap — see How do I serve static files in Amber v2?. Full routing in a real app: amber-crystal-todo-tutorial.

How do I connect Amber to Postgres with Granite?

Quick answer: Add granite (> 0.23.0) and pg (> 0.29.0) to shard.yml — Amber v2 no longer bundles database drivers. Register the connection with Granite::Connections << before requiring any model files, then declare models as Granite::Base subclasses.

# src/myapp.cr — registration must come BEFORE requiring models
require "granite"
require "granite/adapter/pg"

Granite::Connections << Granite::Adapter::Pg.new(
  name: "pg",
  url: ENV["DATABASE_URL"]? || "postgres://localhost/myapp_dev"
)

require "amber"
require "./models/todo"
# src/models/todo.cr
class Todo < Granite::Base
  connection pg
  table todos

  column id : Int64, primary: true
  column title : String
  column completed : Bool = false

  timestamps
end

The parts people miss: there is no bundled migration tool — create the table yourself (or use a shard like micrate):

CREATE TABLE todos (id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL,
  completed BOOLEAN NOT NULL DEFAULT FALSE,
  created_at TIMESTAMP, updated_at TIMESTAMP);

timestamps requires the created_at/updated_at columns to actually exist. The connection pg line refers to the name: you registered — if the registration runs after the model file loads, queries fail at runtime.

Query and controller usage: How do I do CRUD in an Amber controller?. Full app: amber-crystal-todo-tutorial.

How do I do CRUD in an Amber controller?

Quick answer: Subclass Amber::Controller::Base; read form and path parameters through params; use Granite's select/find/save/destroy; finish writes with redirect_to. Declare any instance variable your view uses (@todos = [] of Todo) or the compile fails.

class TodosController < Amber::Controller::Base
  @todos = [] of Todo

  def index
    @todos = Todo.order(id: :asc).select
    render("index.ecr")
  end

  def create
    title = params["title"].to_s.strip
    Todo.new(title: title).save unless title.empty?
    redirect_to "/todos"
  end

  def delete
    Todo.find(params["id"].to_s.to_i64).try(&.destroy)
    redirect_to "/todos"
  end
end

The error everyone hits: skip the @todos = [] of Todo declaration and the compiler stops with Error: can't infer the type of instance variable '@todos' of TodosController — Crystal can't infer an instance variable's type from a method-call result, so give it an explicit initial value at class level. render("index.ecr") resolves the template from the controller's filename: src/controllers/todos_controller.cr renders src/views/todos/index.ecr, wrapped in src/views/layouts/application.ecr. With CSRF plugged in, the create/delete forms each need <%= csrf_tag %>. redirect_to "/todos" issues a 302.

This controller, live-tested end to end (create 302 + row in Postgres, delete removes it): amber-crystal-todo-tutorial.

How do I render an ECR template?

Quick answer: ECR (Embedded Crystal) compiles templates into your binary at build time — there is no runtime template loading. Standalone, use ECR.def_to_s "greeting.ecr" inside a class; the template sees the class's instance variables. <%= %> embeds a value, <% %> runs control flow.

require "ecr"

class Greeting
  def initialize(@name : String)
  end

  ECR.def_to_s "greeting.ecr"
end

puts Greeting.new("Crystal").to_s  # => <h1>Hello, Crystal!</h1>
<!-- greeting.ecr (path is relative to where you run crystal) -->
<h1>Hello, <%= @name %>!</h1>

In Amber: controllers wrap the same mechanism — render("index.ecr") compiles src/views/<controller-name>/index.ecr into the action and injects it into src/views/layouts/application.ecr where the layout says <%= content %>. Because templates are compiled, a typo in a template is a build error, not a 500 — and Amber v2 dropped Kilt/Slang, so ECR is the only built-in template language.

Views in a working app (loops, forms, csrf_tag): amber-crystal-todo-tutorial.

How do I serve static files in Amber v2? (:static pipeline)

Quick answer: Give static files their own pipeline plus a catch-all route. Plugging Amber::Pipe::Static into your :web pipeline does nothing — a request must match a route before any pipeline runs, so every asset URL 404s with Amber::Exceptions::RouteNotFound.

Amber::Server.configure do
  pipeline :static do
    plug Amber::Pipe::Error.new
    plug Amber::Pipe::Static.new("./public")
  end

  routes :static do
    get "/*", Amber::Controller::Static, :index
  end
end

Why: Amber's router dispatches first and pipelines run second, so Amber::Pipe::Static in :web is dead code for unrouted paths. The get "/*" catch-all through a dedicated :static pipeline is the supported pattern — your explicit :web routes still win over the catch-all, so GET / keeps hitting your controller while GET /app.css falls through to the ./public directory.

This pairs with fingerprinted JavaScript from import maps — see How do I add JavaScript without npm or webpack?. Working app with both wired up: amber-crystal-todo-tutorial.

How do I add JavaScript without npm or webpack? (import maps)

Quick answer: Use the asset_pipeline shard's FrontLoader to emit a <script type="importmap"> tag. CDN packages are pinned by URL, local files are SHA-256-fingerprinted and copied into your static root — ES modules with zero build step, no Node.js, no npm, no webpack.

require "asset_pipeline"

front_loader = AssetPipeline::FrontLoader.new(
  js_source_path: Path.new("src/app/javascript"),
  js_output_path: Path.new("public")
)

import_map = front_loader.get_import_map
import_map.add_import("@hotwired/stimulus",
  "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/+esm", preload: true)
import_map.add_import("HelloController", "hello_controller.js")

puts front_loader.render_import_map_tag
dependencies:
  asset_pipeline:
    github: amberframework/asset_pipeline
    version: ~> 0.34.0

Put the tag's output in your layout <head>, then boot with a plain module script: <script type="module">import "HelloController";</script> — the browser resolves names through the import map.

The path gotcha: render_import_map_tag builds each local file's URL by replacing js_output_path with /, so any subdirectory disappears from the URL. With js_output_path: Path.new("public/assets") the map says /hello_controller-HASH.js while the file sits at /assets/hello_controller-HASH.js — a guaranteed 404. Set js_output_path to your static root (public) so URLs and file locations agree. Rendering the tag is also what performs the fingerprint-and-copy, so call it from your layout (Amber serves public/ via the :static pipeline).

Wired into a full Amber app with Stimulus: amber-crystal-todo-tutorial.

How do I read env-based config in Amber v2? (AMBER_SERVER_PORT)

Quick answer: AMBER_ENV selects which config/environments/<env>.yml file loads (default development), and AMBER_{SECTION}_{KEY} variables — AMBER_SERVER_PORT, AMBER_SERVER_HOST, AMBER_DATABASE_URL, AMBER_NAME — override values from the file. In code, read everything through Amber.settings.

# config/environments/development.yml — v2 format (server: is a mapping)
name: myapp

server:
  host: localhost
  port: 3000
puts "#{Amber.settings.name} (#{Amber.env}) on port #{Amber.settings.port}"
$ AMBER_SERVER_PORT=4567 ./bin/myapp
Booting myapp (development) on port 4567

The catch: the AMBER_* overrides only apply when a settings file in the v2 format exists — the loader detects v2 by server: being a nested mapping. With no config file at all, Amber boots fine on compiled-in defaults (development, localhost:3000), but then AMBER_SERVER_PORT is ignored; and a v1-style flat file (port: 3000 at the root) also skips the override pass. So: ship the small v2 YAML above, then configure everything per-environment with plain environment variables. For your own settings, ordinary ENV["MY_KEY"]? reads work as in any Crystal program.

More environment plumbing in a real app: amber-crystal-todo-tutorial.

How do I validate form params in Amber? (params vs Schema API)

Quick answer: Call params.validation with required/optional rules and capture its return value — that validator object carries valid? and errors. Blocks add custom predicates. (Amber v2 also ships an opt-in Schema API for declarative validation; the params validator remains the backward-compatible default.)

class SignupsController < Amber::Controller::Base
  def create
    validator = params.validation do
      required(:email) { |email| email.to_s.includes?('@') }
      required(:name)
      optional(:company)
    end

    unless validator.valid?
      response.status_code = 422
      return "Invalid signup: #{validator.errors.map(&.message).join(", ")}"
    end

    "Welcome, #{params["name"]}!"
  end
end

Why capture the return value: in v2, params itself is a compatibility wrapper (it can also carry validated Schema-API data), so calling params.valid? directly does not compile — the validator returned by params.validation is the object with valid?, errors, and the filtered params hash. A failed required rule (missing key, or a block returning false) adds an error whose .message reads like Field email is required. For plain reads without validation, params["title"].to_s works everywhere, including path segments like :id.

Validation in a full CRUD flow: amber-crystal-todo-tutorial.


Maintained by the team at AgentC Consulting - https://agentc.consulting?ref=crystal-recipes

Repository

crystal-amber-recipes

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 0
  • about 2 hours ago
  • July 7, 2026
License

MIT License

Links
Synced at

Tue, 07 Jul 2026 17:06:39 GMT

Languages