crystal-llm-marten
crystal_llm_marten
Marten persistence layer for CrystalLLM — a faithful Crystal port of ruby_llm's ActiveRecord integration.
This shard is the Crystal/Marten analog of ruby_llm's acts_as_chat / acts_as_message / acts_as_tool_call / acts_as_model / acts_as_agent extension for Rails/ActiveRecord. It wires CrystalLLM's in-memory objects to persisted Marten::Model rows so that chats, messages, tool calls, and agents survive across requests.
Attribution
crystal_llm_marten is a derivative work of the ActiveRecord extension in ruby_llm (MIT, © 2025 Carmine Paolino). All credit for the integration design, macro names, and persistence patterns belongs to Carmine Paolino and the ruby_llm contributors.
- Original project: https://github.com/crmne/ruby_llm
- Original documentation: https://rubyllm.com
- Ported from: ruby_llm 1.16.0 (
lib/ruby_llm/active_record/) - Core shard:
crystal_llm
Because the macro names and behaviour mirror the Rails originals, ruby_llm's persistence docs are your primary reference — just swap acts_as_chat etc. into a Marten::Model context and see the differences section below.
Installation
Add both shards to shard.yml:
dependencies:
crystal_llm_marten:
github: stevegeek/crystal-llm-marten
crystal_llm:
github: stevegeek/crystal-llm
Then shards install (or script/shards install in the workspace, which sets CRYSTAL_LIBRARY_PATH for the asdf-0.18 workaround).
Usage
1. Define your models
Load order matters: Chat (no FKs) → Message (FK to Chat) → ToolCall (FK to Message).
# models/chat.cr
class Chat < Marten::Model
include CrystalLLMMarten::ActsAsChat
field :id, :big_int, primary_key: true, auto: true
with_timestamp_fields
acts_as_chat message_class: Message, tool_call_class: ToolCall
end
# models/message.cr
class Message < Marten::Model
include CrystalLLMMarten::ActsAsMessage
field :id, :big_int, primary_key: true, auto: true
field :role, :string, max_size: 32
field :content, :text, null: true, blank: true
field :input_tokens, :int, null: true, blank: true
field :output_tokens, :int, null: true, blank: true
field :cached_tokens, :int, null: true, blank: true
field :cache_creation_tokens, :int, null: true, blank: true
field :parent_tool_call_id, :big_int, null: true, blank: true
with_timestamp_fields
acts_as_message chat_class: Chat, tool_call_class: ToolCall
end
# models/tool_call.cr
class ToolCall < Marten::Model
include CrystalLLMMarten::ActsAsToolCall
field :id, :big_int, primary_key: true, auto: true
field :tool_call_id, :string, max_size: 255
field :name, :string, max_size: 255
field :arguments, :json, null: true, blank: true
with_timestamp_fields
acts_as_tool_call message_class: Message
end
2. Create migrations
Run crystal run manage.cr -- genmigrations or write them by hand. Minimum schema:
create_table :chats do
column :id, :big_int, primary_key: true, auto: true
column :created_at, :date_time
column :updated_at, :date_time
end
create_table :messages do
column :id, :big_int, primary_key: true, auto: true
column :chat_id, :reference, to_table: :chats, to_column: :id, null: false
column :parent_tool_call_id, :big_int, null: true # plain bigint, no FK constraint
column :role, :string, max_size: 32
column :content, :text, null: true
column :input_tokens, :int, null: true
column :output_tokens, :int, null: true
column :created_at, :date_time
column :updated_at, :date_time
end
create_table :tool_calls do
column :id, :big_int, primary_key: true, auto: true
column :message_id, :reference, to_table: :messages, to_column: :id, null: false
column :tool_call_id, :string, max_size: 255
column :name, :string, max_size: 255
column :arguments, :json, null: true
column :created_at, :date_time
column :updated_at, :date_time
end
3. Optional columns
Add any of these fields plus matching migration columns to Message:
| Column | Type | Purpose |
|---|---|---|
cached_tokens |
:int, null: true |
Cache read tokens |
cache_creation_tokens |
:int, null: true |
Cache write tokens |
thinking_tokens |
:int, null: true |
Extended thinking tokens |
thinking_text |
:text, null: true |
Thinking block text |
thinking_signature |
:text, null: true |
Thinking signature (for Anthropic round-trips) |
content_raw |
:json, null: true |
Raw provider payload |
4. Build a persisted chat
chat = Chat.create!(model: "gpt-4o")
# Add messages directly
chat.add_message("system", "You are a helpful assistant.")
chat.add_message("user", "Hello!")
# Query persisted messages
chat.messages.count # => 2
chat.messages.filter(role: "user").first.content # => "Hello!"
# Bridge to an in-memory CrystalLLM::Chat with all messages loaded
llm_chat = chat.to_llm
response = llm_chat.ask("What can you help me with?")
5. Macros at a glance
| Macro | Marten model | ruby_llm analog |
|---|---|---|
acts_as_chat |
Chat model (owns messages) | acts_as_chat |
acts_as_message |
Message model (belongs to chat) | acts_as_message |
acts_as_tool_call |
ToolCall model (belongs to message) | acts_as_tool_call |
acts_as_model |
Model registry persistence (refresh! / save_to_database) |
acts_as_model |
acts_as_agent |
Agent ↔ Chat persistence (chat_model / create / find / sync_instructions!) |
acts_as_agent |
Differences from ruby_llm
Because Crystal has no runtime metaprogramming and Marten differs from ActiveRecord, a handful of patterns diverge. See PORTING.md and the core shard's PORTING.md for the full list.
| ruby_llm (Rails) | crystal_llm_marten (Marten) |
|---|---|
| Class names inferred from strings at runtime | message_class: and tool_call_class: are required macro arguments |
parent_tool_call as an ActiveRecord belongs_to |
Stored as a plain :big_int column (no FK constraint) to break the compile-time Message ↔ ToolCall circular dependency |
acts_as_agent resolves chat_model "ClassName" via runtime const_get |
acts_as_agent chat_model: Chat binds the Marten model class at macro-expansion time (Crystal has no runtime constant lookup) |
| File attachment integration via Active Storage | Via the marten_storages shard (the Marten ActiveStorage analog) — opt in with attachment_class: |
Rails generators (rails generate ruby_llm:install) |
Marten management commands (gen_llm_install / gen_llm_chat_ui / gen_llm_agent / gen_llm_tool / gen_llm_schema) |
| Agent runtime-instruction blocks evaluated per call | Static agent config (instructions/tools/…) is applied; runtime-input blocks are an in-memory concern |
Known limitations
- TODO: attachment persistence has a brief "message visible before its attachments are" window.
persist_message_completionandpersist_user_message(llm_content)(src/crystal_llm_marten/acts_as_chat.cr) callrecord.save!/create!for the Message row, then callpersist_message_attachments(which loopsMartenStorages::Service.attachper attachment) afterwards, as two separate, non-transactional steps. Between those two steps the message row is committed and readable (e.g. by a concurrent request rendering the same chat, or a background job) but its attachments haven't landed yet, somessage.to_llmwould return the message with no attachments during that window. This is a narrower variant of the problem ruby_llm 1.16.0'sbf46044b("Handle pending ActiveStorage uploads in chat history") addresses for its own (different) mechanism — seePORTING.md§"ruby_llm 1.16.0 ActiveRecord-layer sync" for the full comparison. Not fixed here (no proposed fix has been vetted); candidates worth evaluating: wraprecord.save!+persist_message_attachmentsin a singleself.class.transaction, or attach files to storage first and only create/save the Message row once every attachment row exists.
Version / parity
This shard follows the same versioning policy as the core crystal_llm shard: pre-parity releases are 0.x; at parity with ruby_llm X.Y.Z, both shards adopt matching version numbers (X.Y.Z tracks ruby_llm X.Y.Z). The public surface now matches ruby_llm 1.16.0, so both shards are versioned 1.16.0.
Development
./script/spec
Requires Crystal 1.20+ and a configured asdf setup (or adapt CRYSTAL_LIBRARY_PATH in script/spec).
License
MIT. See LICENSE.
This project is a Crystal port of the ActiveRecord integration in ruby_llm (MIT, © 2025 Carmine Paolino). All original work is © Carmine Paolino and the ruby_llm contributors. The Crystal/Marten port is © 2026 the CrystalLLM authors.
crystal-llm-marten
- 0
- 0
- 0
- 0
- 5
- about 6 hours ago
- July 17, 2026
Other
Fri, 17 Jul 2026 15:25:43 GMT