liaison.cr
liaison.cr
A zero-dependency Crystal shard for talking to LLMs, whose defining feature is a portable session history: start a conversation with one provider, resume it with another.
WARNING: This shard is a work in progress and in development until this warning is removed.
See DISCLOSURE for information how AI is used by this project.
The point
Every vendor stores a conversation in its own shape. Anthropic keeps signed thinking blocks, OpenAI's Responses API keeps encrypted reasoning items, Gemini pairs tool calls by position rather than by id. Move a conversation between them and something is always lost — usually quietly.
liaison keeps the conversation in its own canonical form (MPSH) and translates at the edges. Translation is the interesting part, because it is not always lossless: the shard's job is to be honest about what it costs, not to pretend a handoff is free.
---
config:
layout: elk
---
graph LR
A["Chat Completions<br/>turns 1-3"] -->|export| S["MPSH::Session<br/><i>canonical, portable</i>"]
S -->|resume| B["Anthropic<br/>turns 4-6"]
B -->|export| S2["MPSH::Session"]
S2 -->|resume| C["Gemini<br/>turns 7-9"]
S -.->|"Capability::Report<br/>says what each<br/>handoff cost"| R["Exact · Restructured<br/>Compensated · Degraded · Refused"]
Quick start
require "liaison"
alias M = Liaison::MPSH
ollama = Liaison::Server.new("ollama", "http://localhost:11434")
provider = Liaison::Provider.for(ollama, Liaison::ProtocolKind::ChatCompletions)
session = M::Session.new
session << M::Message.user("Name three things Vienna is known for.")
reply, report = Liaison::Client.new(provider).send(session, "llama3.2")
session << reply
puts reply.text
send returns the reply and a report. Both matter: a caller that ignores the report is a caller that will not notice a silent degradation, which is the failure this whole design is arranged against.
The handoff
A session is just data, so persisting it is a String in and a String out.
File.write("vienna.json", M::Archive.write(session))
Later, somewhere else, against a different vendor and a different protocol:
session = M::Archive.read(File.read("vienna.json"))
session << M::Message.user("Now recommend a coffee house.")
anthropic = Liaison::Server.new("anthropic", "https://api.anthropic.com",
ENV["ANTHROPIC_API_KEY"])
provider = Liaison::Provider.for(anthropic, Liaison::ProtocolKind::Anthropic)
reply, report = Liaison::Client.new(provider).send(session, "claude-haiku-4-5")
report.annotations.each { |note| puts note } # what the handoff cost, if anything
Nothing in vienna.json names a vendor as its owner. That is the whole idea.
Watching the reply arrive
send takes a block and streams, on all four protocols. Events are presentation: the reply and the report are still what they were, and a caller that accumulates events into an answer has reimplemented the exporter, worse.
reply, report = Liaison::Client.new(provider).send(session, "llama3.2") do |event, turn|
case event
when Liaison::Streaming::TextDelta then print event.text
end
turn.stop if enough?
end
A protocol with no streaming seam falls back to a single request. report.streamed? says which happened.
When a turn does not finish
A stream can drop, a model can hit an output cap, and either leaves a reply holding a tool call nobody finished planning — the one shape a provider will reject outright.
session << MPSH::Repair.repaired(reply) if reply.ending.cut?
Message#ending is complete, truncated, stopped or interrupted, and it is archived, so a session reloaded next week still knows its last turn was a fragment rather than a short answer. Repair drops the unfinished calls and keeps the text.
Giving the model tools it can actually run
Tool declares; Function declares and runs. A Toolbox holds a collection of them and is used at both ends of a turn.
toolbox = Liaison::Toolbox.new([Weather.new, Clock.new] of Liaison::Function)
loop do
reply, _ = client.send(session, model, options: Liaison::Options.new(tools: toolbox.tools))
session << reply
results = toolbox.dispatch(reply)
break unless results
session << results
end
#dispatch returns nil when there is nothing to run, which is the loop's exit condition. Every call gets a result — including one naming a tool the box does not hold, and one whose tool raised — because a session holding a call without its result is the shape a provider rejects. A tool returns Array(MPSH::Block), so a tool that answers with an image or a file needs no special handling.
Choosing how much loss you will accept
Translation loss is graded, and the policy decides what to do about it:
| Policy | Worst outcome it will accept |
|---|---|
Strict |
Restructured — same information, different shape |
Compensating |
Compensated — meaning preserved by synthesizing messages; the default |
Lenient |
Degraded — information lost, substitute used, each occurrence recorded |
Refused is never accepted by any policy: it means the mapping cannot be done at all, so nothing is sent.
Liaison::Client.new(provider, Liaison::Capability::Policy::Strict)
Supported protocols
| Protocol | Notes |
|---|---|
| Anthropic | Signed thinking blocks replayed intact |
| Chat Completions | Also covers Ollama's compatible endpoint |
| Responses | OpenAI's newer API, encrypted reasoning items |
| Gemini | Positional tool pairing, thoughtSignature on calls |
Azure OpenAI is supported as a deployment of the Chat Completions and Responses protocols, not as a protocol of its own.
The liaison command
The shard ships a CLI, which is also the most direct demonstration of the handoff — start a session on one deployment, continue it on another.
liaison start <deployment> <prompt...> [--id <session-id>] [--stream|--no-stream]
[--show-reasoning|--hide-reasoning]
liaison continue <session-id> <prompt...> [--on <deployment>] [--stream|--no-stream]
[--show-reasoning|--hide-reasoning]
liaison list
liaison show <session-id> [--snapshots] [--json]
liaison prune <session-id> --keep <n>
liaison delete <session-id>
$ liaison start ollama "Name three things Vienna is known for."
Session: brisk-comet
Vienna is known for its coffee houses, its classical music, and the Ringstrasse.
$ liaison continue brisk-comet "Recommend one coffee house." --on anthropic
Café Sperl, for the billiard tables and the lack of hurry.
Streaming and reasoning display default off and can be set for good under defaults: in the config. Deployments are named in liaison.yaml; see docs/CLI_DESIGN.md for the format and for why it is shaped the way it is.
Installation
dependencies:
liaison:
github: ModelArmy/liaison.cr
No runtime dependencies, and none planned. The only development dependencies are ameba and wiretap, the latter recording live HTTP once so the suite can replay it offline forever.
Documentation
| Document | Holds |
|---|---|
| docs/MPSH_SPECIFICATION.md | Why the canonical session is shaped this way |
| DEVELOPMENT.md | Layering, conventions, how to add a protocol |
| docs/protocols/ | One file per protocol: gotchas and compensations |
| docs/servers/ | One file per server, and what a green run there does not prove |
| docs/STREAMING_DESIGN.md | The streamed turn, and the two places its design was wrong |
| docs/TOOL_EXECUTION.md | Caller-supplied tools, and what was decided about them |
| docs/CLI_DESIGN.md | The liaison executable |
| SCOPE.md | What is still outstanding |
Contributions, by invitation!
With apologies, at this time contributions to this project are by invitation only and limited to people I know and see often.
- These are early days for the project and I am busy with family and work.
- At this time I want to work on this at a manageable pace.
License
MPL-2.0. See LICENSE.
liaison.cr
- 0
- 0
- 0
- 0
- 2
- about 1 hour ago
- August 17, 2026
Mozilla Public License 2.0
Sun, 13 Sep 2026 02:54:08 GMT