Lyman
A composable agentic harness — and a framework for building harnesses — in Ruby, built on shifty.
Lyman is for people building agentic workflows on models they run themselves: open-weight models on Ollama, LM Studio, vLLM, llama.cpp — or models they've trained. If that's you, you start with less data, less tooling maturity, and less margin for error than the frontier labs. Your one real edge is iteration speed. Lyman exists to maximize it.
Lyman, as in the Lyman series — a nod to the physicist, and to redshift, which is in turn a nod to Ruby and the shifty gem. A series is also, fittingly, a sequence of discrete lines — like the stages of a pipeline.
The idea
An agent harness is a pipeline. Not as a metaphor — literally:
pipeline =
source_worker { rounds.shift } |
Lyman::Workers.chat_completion(base_url:, model:, tools:) |
relay_worker { |c| c.pending_tool_calls.empty? ? c.finish : c } |
Lyman::Workers.tool_execution(handlers) |
side_worker { |c| rounds << c unless c.finished? } |
filter_worker { |c| c.finished? }That's not pseudocode — it's the working heart of harness/repl.rb,
a tool-using chat agent for local models. Every stage is a plain
shifty worker. Want logging? Splice
in a side_worker — anywhere. Want a guardrail? A filter_worker. Want to
swap Ollama for vLLM, or native tool-calling for prompt-and-parse emulation on
a model that lacks it? Replace one worker; nothing else changes. There are
no hooks, because there's nothing to hook into — the whole loop is
already open.
Two principles drive every design decision:
- Legibility. One paradigm — a pipeline of workers — applied everywhere, at every level.
- Guts on the outside. Nothing important happens anywhere you can't see, name, or splice into. This is deliberately the opposite of the frontier-SDK ethos, where the agent loop is a black box you configure from the edges.
And a razor for when they're in tension with convenience: favor whatever lets a user see and change behavior faster. For a data-poor tinkerer, transparency isn't an aesthetic — it's what makes fast iteration possible.
Quick start
You'll need Ruby ≥ 3.2 (CI runs the suite on 3.2, 3.3, 3.4, and 4.0; this
repo pins its own Ruby in mise.toml, your project doesn't have
to — see docs/design/ruby-version-policy.md)
and a local model server
speaking the OpenAI-compatible API — Ollama and
LM Studio both work. Use a model with native tool
support (e.g. qwen3.5, gemma4, mistral).
bundle install
bundle exec ruby harness/repl.rblyman ⇢ gemma4:latest @ http://localhost:11434/v1
you> what time is it?
gemma4:latest> ✻ The user wants the current time. I have a current_time tool…
⚙ current_time {}
✓ current_time → 2026-07-08 18:13:29 EDT
gemma4:latest> It's 6:13 PM on Wednesday, July 8th.
The ✻ aside is a streamed preview of the model's thinking; the ⚙ and ✓
lines are side workers showing a tool call and its result — and the reply
after them means two model round-trips completed inside a single pull on the
pipeline. Point it elsewhere with LYMAN_BASE_URL and LYMAN_MODEL.
How it works, briefly
-
The item is the conversation. What flows through the pipeline is a
Conversation— an append-only series of elements (system, user, reasoning, assistant, tool_call, tool_result) plus the control data workers consult (finished?,pending_tool_calls). A turn carries its whole conversation with it; that's what makes it a conversation. - State lives in the shell. The enclosing scope — the shell — is just state plus a process: the conversation, a queue, and a deliberately boring loop. A human REPL is one shell; an autonomous email-triager is another. Lyman is turn-shaped, not chat-shaped.
- Three archetype harnesses prove it. The same circuit ships inside three shells: a repl (a human drives the loop), a daemon (loop forever on an inbound event stream), and a script (take one work item at launch, process it, halt). Only the shell differs — see docs/design/harness-archetypes.md.
-
The agentic loop is a circuit, not a special construct. Shifty pipelines
are strictly linear, so the model⇄tool cycle is built from stock parts: a
source reading from a queue, a side worker re-enqueueing unfinished rounds,
and a filter at the tail that only lets finished turns escape. Demand does
the rest — N model⇄tool rounds happen inside one
shift, with no loop written anywhere. Full reasoning in docs/design/circuit-pattern.md. - Dependencies stay inside the workers that need them. The HTTP client lives in exactly one file. Use your favorite gems — in such a way that only the worker requiring one knows it exists.
-
File primitives ship planted, not hand-rolled.
Lyman::Tools.search_files(root:)(literal, case-insensitive content search, or a bare file-name/glob lookup when no pattern is given) andLyman::Tools.read_file(root:)(line-numbered, optionally by range) are stdlib-only and planted bylyman newalongsidecurrent_time. Both are confined toroot— every path is resolved withFile.realpath, so..traversal and symlinks pointing outside it are refused — and every failure comes back as a message the model can read rather than an exception. -
Patching is a planted tool with a check seam.
lib/lyman/tools/patch.rbholds two factories, one per patch format:Lyman::Tools.search_replace(exact search-and-replace blocks, the default; robust for models that can't reproduce line numbers) andLyman::Tools.apply_diff(unified diff, hunks placed by context). Wiring one is choosing the format. Each applies one patch to one root-confined file, then runscheck:— a command like"bundle exec standardrb"(the touched path appended, run as an argv, never through a shell), a callable, ornil— and reports one word on a pass, the findings on a failure. It isn't handed to the main model; the file editor agent (below) builds it internally. -
An agent-as-tool is a script shell inside a handler, not a second
framework. A tool handler that builds a fresh conversation, runs a
small circuit with its own tools and its own
max_roundsrunaway guard, and returns the final answer is the script archetype, invoked in-process — the same pattern asharness/compactor.rb, one level down.harness/agents/file_reader.rb(planted bylyman new) is the first: given a path/glob and an optional query, it delegates to a small model only when there's something to extract, and returns just the excerpts, outline, or answer asked for — so a large file's text is read once, by a disposable sub-conversation, and never carried in the main context. It's runnable standalone (ruby harness/agents/file_reader.rb PATH [QUERY]) for tuning its prompt directly.harness/agents/file_editor.rb(also planted bynew) is its write-side twin: given a prompt describing a change, its sub-agent finds the code, patches it withsearch_replace, and may fix its own check failures — but the tests run in a plain stage after its circuit finishes, so the agent structurally cannot see or "fix" them. The caller gets a short report: what changed, any check failures left, and test output only if the tests failed. Standalone:ruby harness/agents/file_editor.rb "REQUEST"(edits files for real). -
Persistence is an opt-in splice, not a built-in.
Lyman::Store(SQLite, confined to its own file) gives a conversation a durable, addressable, full-text-searchable home;Lyman::Workers.store_appendis the oneside_workerthat wires it into a circuit. Neither is planted bylyman new— add them withlyman add storewhen a harness needs to survive a restart or be queried. -
Context management is a wiring choice, not a mode.
Lyman::Abridgementships deterministic, model-free policies —SuppressPriorReasoning,StubToolResults— that shape only the wire viewchat_completionsends; the element series is untouched, so nothing abridged away is gone. A policy is a plain object (call(conversation) -> conversation, lambdas welcome) picked when wiring the chat completion worker, andAbridgement.over_budgetcan gate one on the transport's ownusagereport, stamped onto the conversation after each reply. -
Compaction is a handoff, not a stall. A compaction sidecar
(
harness/compactor.rb,lyman add compactor) is a daemon-archetype shell running in its own thread beside your harness: one side worker,Lyman::Workers.compaction_feed, hands it each new element, and a small fast model keeps a ledger — facts, decisions, open items, each citing the addresses of the elements it summarizes. When the shell decides context is too big,Lyman::Compaction.requesthands back a new conversation (ledger plus the last turn verbatim,parent_idpointing at the original) almost instantly, because the ledger was already current. -
Over-compaction is recoverable, not fatal.
Lyman::Tools.recall(store:)lets the model re-expand what a stub or a ledger entry points at — by address (conv:abc#17-23) or by plain-word search — bounded so a wide recall can't blow the very context it's meant to relieve. Opt-in withlyman add recall_toolonce a store is planted.
The longer story — mission, principles, architecture decisions, open questions — is in docs/vision.md.
Documentation
The wiki is the guided tour:
- Getting Started — scaffold a project and talk to a local model in five minutes
- Core Concepts — workers, the item, the shell, the circuit, and the sharp edges
- Harness Archetypes — choosing between the REPL, Daemon, and Script shapes
-
The Generator CLI —
new,add,update,eject,diff,doctor, and the manifest
The wiki's source of truth is the wiki/ directory in this repo —
documentation changes ride through pull requests, then publish with
wiki/publish.sh. For design intent, the
docs/ design notes remain authoritative.
Is lyman a good fit for you?
Probably yes, if:
- You're building a narrow, purpose-built agent — an email triager, a log summarizer, a domain-specific assistant — tailored to specific problems and specific models, and you want to iterate on it fast.
- You run models locally or self-hosted and want to build a harness that treats that as the primary case, not an afterthought.
- You want to understand and modify your whole agent loop, not configure the edges of someone else's.
- You like Ruby, or at least like the idea of an agent harness you can read top to bottom in a single coding session.
Probably not, if:
- You want a no-code / low-code agent builder. Lyman is a developer's tool.
- You want feature parity with frontier-model harnesses and popular coding agents. Lyman isn't chasing checklists — it's chasing the shortest path from "what if the workflow did this?" to finding out.
- You want a batteries-included platform with every integration bundled. Lyman's bet is that shifty makes those things easy enough to build yourself that shipping them all would be beside the point.
The generator
Lyman is delivered as a pure generator, in the spirit of shadcn/ui: the gem's whole job is to plant legible, manifest-tracked modules into your project, individually upgradeable and yours to extend — or eject and adopt outright. No runtime framework to call into; your harness's only runtime dependency is shifty. "Guts on the outside" extends to whose tree the code lives in. The reasoning is written down in docs/design/deployment.md.
lyman new my-agent # scaffold a project: harness, workers, manifest
lyman list # what lyman installs, and this project's status
lyman update # refresh pristine modules; halt (never clobber) on modified ones
lyman diff conversation # your changes, and upstream's since you planted
lyman eject conversation# take ownership; lyman stops managing it
lyman doctor # smoke-test the pipeline against a stub transport.lyman/manifest.yml records what was planted, at which version, with which
content hash — so update knows pristine from modified, ejected modules get
upstream-change advisories instead of merges, and your harness script is never
touched at all: it's yours from day one.
Status
Early and moving. What exists today: the vision, circuit-pattern,
harness-archetypes, and deployment design docs, the core Conversation item
(an append-only element series), chat-completion and tool-execution workers,
an optional SQLite Store with lineage and full-text search, the
search_files/read_file file primitives, the search_replace/apply_diff
patch tool with its post-edit check, the agent-as-tool pattern with
its first two agents (harness/agents/file_reader.rb and
harness/agents/file_editor.rb), deterministic
wire-time Abridgement policies plus transport usage stamping, the three
archetype harnesses (repl, daemon, script) working against live local
models, and the generator CLI (new / add / update / eject / diff /
doctor / list) with a Minitest suite behind it. Published to
rubygems.org — install it with:
gem install lyman
lyman new my-agentOn deck: tool-call fan-out.
