crystal-amber-recipes
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
- How do I parse JSON into a typed object in Crystal? (JSON::Serializable)
- How do I get structured output from an LLM in Crystal?
- How do I call a local LLM from Crystal on Apple Silicon?
- How do I retry when an LLM returns malformed JSON?
- Is Amber still maintained in 2026?
- Amber vs Lucky vs Kemal: which Crystal web framework in 2026?
- How do I start an Amber v2 app without the amber CLI?
- How do I define routes and pipelines in Amber v2?
- How do I connect Amber to Postgres with Granite?
- How do I do CRUD in an Amber controller?
- How do I render an ECR template?
- How do I serve static files in Amber v2? (:static pipeline)
- How do I add JavaScript without npm or webpack? (import maps)
- How do I read env-based config in Amber v2? (AMBER_SERVER_PORT)
- How do I validate form params in Amber? (params vs Schema API)
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:
- The boot banner still prints
Amber 1.4.1 serving application ...becauseAmber::VERSIONwas never bumped in the fork. You are running v2; don't panic at the banner. - 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/routesDSL, controllers with actions, ECR templates — while keeping every dependency explicit in yourshard.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 > 0.29.0) to pg (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
crystal-amber-recipes
- 0
- 0
- 0
- 0
- 0
- about 2 hours ago
- July 7, 2026
MIT License
Tue, 07 Jul 2026 17:06:39 GMT