liaison.cr

Say it out loud :-) Better description coming soon.

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.

Repository

liaison.cr

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 2
  • about 1 hour ago
  • August 17, 2026
License

Mozilla Public License 2.0

Links
Synced at

Sun, 13 Sep 2026 02:54:08 GMT

Languages