Terret is a Ruby framework for running AI agents, tied to no particular model provider. An agent here is a language model in a loop: the model reads a conversation, asks for tools, the harness runs them and writes the results back, and the loop goes around again until the turn finishes. Terret is everything around that loop.
Every piece of the machinery is a plugin. The session log, the tools pipeline, the loop itself, the model adapter, the network interfaces, and the sandbox are all rows in a YAML config, so swapping an implementation is super easy. Hames, the kernel that mounts those plugins knows nothing about language models, and in theory could be used as the foundation for many other kinds of software projects. Overall, this project is inspired by the architecture of DeepSeek Harness, and prioritized based on the requirements of serious real-world usage at ZAR.
Note on naming: A terret is the ring on a horse harness that the driving reins pass through. It is the
small piece that lets one driver guide any horse. Hames, the kernel underneath, takes its name from the load-bearing parts of the same harness.
gem install terret
trt doctor --profile headless # validate a profile without booting it
trt acp --profile headless # serve the Agent Client Protocol on stdio for an editorThe twelve gems
All released together at 0.1.0:
| Gem | What it is |
|---|---|
hames |
The kernel. Services live in a context, events are typed and declared up front, every install is reversible, and boot order comes from declared dependencies. Standard library only, and nothing in it mentions language models. |
terret-core |
The agent harness built on that kernel: the session log, the tools pipeline, the agent loop, and the seam a model adapter plugs into. Also durable approvals, compaction, session titling, live-updatable permissions, subagents, and credential handling. Standard library only. |
terret-openrouter |
The model adapter. Talks to OpenRouter's OpenAI-compatible API, streams responses, handles tool calling, and counts token usage. |
terret-store-sqlite |
Durable session storage: one event per row in a SQLite database. |
terret-ws |
The WebSocket interface, one connection per agent. A client that reconnects gets the session's events replayed exactly, then the live stream. |
terret-acp |
The editor interface: an Agent Client Protocol server speaking JSON-RPC over stdio, so an editor can drive an agent. |
terret-mcp |
The MCP client. Mounts tools from MCP servers, local or over HTTP, with per-server approval and per-call timeouts. |
terret-morph |
A compaction provider that calls Morph's Compact API to shrink a long session's history. |
terret-exec |
The execution world: files, subprocesses, shells, and terminals. Every path stays inside a granted workspace and every command goes through the sandbox. |
terret-tools-std |
The standard tools, under Claude Code's names verbatim: Read, Write, Edit, Glob, Grep, Bash, WebFetch, Task, TodoWrite, terminals, background jobs. |
terret-sandbox-docker |
The container sandbox. One config row moves everything a tool executes into a long-lived container, with the network off by default. |
terret |
The meta-gem, the one you install. It pulls in the base roster and ships the bundles, the profiles, and the trt command (boot / dump-config / doctor / acp). |
Design notes
Everything is a plugin. Services, listeners, and tools install through effects that return disposers, so unloading a plugin takes its authority with it. Disposing an agent does the same. YAML decides which plugins run and how each is configured: a bundle ships ordered config rows, profiles stack bundles, and a patch swaps or inserts one row by id. A third-party gem joins the composition by shipping normally with one line of gemspec metadata.
Shared runtime. When a plugin boots, it installs things into the shared runtime. A service lands in the context under a key, so ctx[:shell] resolves to it. Listeners attach to the event bus. Tools go into the roster the model can call. In most systems those are one-way writes into global tables, and the only way to truly remove a plugin is to restart the process, because nothing remembers what the plugin put where.
Safe runtime configuration changes. Hames routes every one of those installs through a single method, ctx.effect. The method performs the install and hands back a small undo handle, a lambda that knows how to reverse exactly that one install. That handle is the disposer. The kernel records each disposer against whichever plugin was mounting at the time, so it always holds a complete ledger: for this plugin, here is everything it ever installed and here is how to take each piece back out. Loader#unload! walks that ledger in reverse. The plugin's service leaves the context, its listeners come off the bus, its tools leave the roster. Once the plugin is unloaded, nothing it installed can still act. Subagents ride the same mechanism. Each subagent runs in a forked scope of the context, everything registered through that fork gets its disposer recorded on the fork, and dispose_agent reaps the ledger.
Model-visible means logged. Everything the LLM sees is in the session log, and everything in the session log is exactly what the LLM saw. The harness enforces this on every request, and the rest of the design leans on this one rule.
The session log. Every session is an append-only list of events: a user message, an assistant reply, a tool call, a tool result. Events are written once and never edited. When a turn needs the conversation history, the harness rebuilds it by reading the log from the top. There is no second copy of the conversation anywhere.
Enforcement. Before a request leaves for a model adapter, the harness fingerprints the messages it is about to send and compares that against the history rebuilt from the log. A 3rd-party middleware plugin that slips extra content into your request without appending it to the log first breaks the comparison, and will raise an exception.
Resiliency. A turn that gets stuck mid-tool-call survives kill -9. A fresh process reads the log, finds the open turn, and finishes what the crashed one owed. When a client drops and reconnects, the harness replays the same events from the log and then follows with the live tail, so the client sees no gap. Compaction obeys the rule too: the model sees the summary, therefore the summary is appended to the log as a durable event like everything else.
A stdlib kernel and core. hames and terret-core run on the Ruby standard library alone. Installing them adds no dependencies to your application, so there is nothing to conflict with whatever your app already loads. Gems that touch the network carry those dependencies themselves; terret-openrouter brings async-http, and you pay that cost only if you mount it.
The execution world. The agent gets real hands: it can read and write files, run commands, keep a persistent shell, hold terminals open, and fetch web pages. Every one of those abilities operates inside limits the harness enforces, whatever the model asks for.
Workspaces. You grant the agent specific directories. Every path a file tool touches must resolve, through realpath, to a location inside one of them. Escape tricks fail with an error before anything is read or written: ../ traversal, a symlink pointing outside the workspace, even a symlink whose target does not exist yet.
The sandbox. Every command line passes through the sandbox seam before it spawns. The default provider runs commands on the host unchanged. Add the docker provider and they run inside a long-lived container, each workspace mounted at the same absolute path, network off unless you turn it on. That switch is one config row, and no tool code changes.
Network access. WebFetch reaches only domains you have listed. The check runs again on every redirect hop, and any address that resolves to the machine itself or to the local network segment is refused after DNS resolution, whatever the domain list says.
Permissions. The list of tools an agent may call starts empty, and you name what is allowed. The check sits inside the tool registry where no plugin can mount ahead of it. Per-agent policy applied mid-session can only narrow that list.
Secrets. Every credential the harness resolves is registered with a scrubber. Tool results pass through it before they land in the log, and the log-append boundary runs it again on everything else, so an API key never reaches disk in the clear. Redaction cooperates with the logging rule: the stored log and the history rebuilt from it contain the same redacted bytes. Resume refuses to re-execute a command whose logged text was redacted, because it cannot know what it would actually be running.
Subagents. The Task tool lets an agent delegate a piece of work to a child agent. The child gets a durable session of its own with an empty transcript, inherits the caller's tools, runs one full turn, and returns its final text. The harness always cleans it up afterward, success or failure.
No escalation. The child runs unattended, so any tool call that would normally pause and wait for a human is denied. Its permissions are the ones granted at boot, because permission changes live in the session log as events and a brand-new session contains none. The guarantee comes from the data model itself; there is no authorization check somewhere that a future refactor could forget.
Background work and parallel calls. A job started with job_start keeps running after its turn ends, and a later turn collects the output. When one assistant message carries several tool calls, the calls marked safe for parallel execution run concurrently on the event loop the whole harness shares, while a serial tool waits for everything in flight and then runs alone. Whatever order they finish in, results are appended to the log in the order the model asked for them, so a replayed or resumed session rebuilds the same history byte for byte.
Provenance
The code in this release grew milestone by milestone against a written plan, test-first: each feature's tests were written before the code that passes them.
Every milestone closed with two reviews. A senior reviewer who had no part in writing the code read the whole milestone's diff cold. A model from a different vendor then challenged the same diff, because different models miss different things. The release itself closed with a consolidated security audit on top of both. Every finding from all of it, fixed or deferred, is recorded in the plan's §14 ledger in the repo.
Known limitations
WebFetchhas no overall time limit yet, so a slow server can hold a fetch open for a long while. It refuses addresses on the machine itself and on the local link, but other private network ranges stay reachable unless your domain list excludes them. Ablock_private_rangesswitch is queued for 0.2.- Permissions removed from a parent agent mid-session do not carry over to a
Taskchild. The child starts from the permissions granted at boot. - If the process dies after a tool ran but before its result was logged, resume runs the tool again. A tool with side effects needs to tolerate that in v1.
- Cost accounting adds up what the log recorded. Model calls made for compaction and titling never produce a usage event, and a step that died before its usage was written drops out of the total. Treat the number as a floor.
- Idle sessions stay in the in-process cache until the process restarts. The cap on concurrent agents keeps that bounded.
- Sandbox providers for Linux Landlock and macOS Seatbelt, file watching, and docker resource limits are recorded as future work with no milestone attached.
OpenRouter is the only real model adapter so far. The seam makes another one cheap to write when it is wanted. Tests and demos run on LLM::FakeAdapter, which replays a scripted conversation with no network at all. The OpenRouter path is proven two ways: unit tests that replay captured wire traffic, and a smoke lane that hits the live API when a key is present.
Documentation
docs/terret-implementation-plan.md: the roadmap and the reasoning behind the design. Milestones are in §12, the deferral ledger in §14.docs/hames-primer.md: the kernel explained on its own terms, with no agent vocabulary.docs/cookbook/: worked recipes. Add a tool, add a model provider, add a bundle.docs/protocol.md: the WebSocket wire format.docs/acp.md: how the Agent Client Protocol maps onto the harness.docs/mcp.md: how MCP servers become tool sources.docs/exec.md: the execution world in detail. Workspace scoping, the sandbox, redaction.docs/security.md: the threat model and where the boundaries stop.
Ruby 4.0.6. MIT license.