Contents

Cookbook: Adding a Provider

A provider is an implementation of a sole-provider seam — a service key that exactly one row may claim, where nothing sits behind the key dispatching between implementations (docs/subagents.md §1). Adding a tool (docs/cookbook/adding-a-tool.md) grows an agent’s roster; adding a provider swaps out a whole capability the harness resolves by key. This page walks it using ctx[:summarizer] as the model, because Terret already ships two implementations of that one seam — so the pattern is on the page, not invented for it.

The two are Terret::RoleSummarizer (the no-signup default, a model call through a configured role) and Terret::Morph::Summarizer (Morph’s extractive Compact API over the wire). Both live in the tree — gems/terret-core/lib/terret/compactor.rb and gems/terret-morph — and both claim service_key :summarizer. Only one is ever mounted at a time, and §3 is why that is a guarantee rather than a hope.

Read docs/hames-primer.md §1 first: a provider is a Hames service, and “sole-provider seam” is just the kernel’s one-key-one-registration rule (there enforced by register_service raising ContractError) put to work.

1. The seam contract

A seam is a method signature and a discipline, agreed between the provider and whatever injects the key. For ctx[:summarizer] the whole contract is one method:

ctx[:summarizer].summarize(history) # => a String, or nil to decline

history is the projected message history; the return is a compacted string, or nil/empty to decline (§4). The consumer is the compactor (gems/terret-core/lib/terret/compactor.rb), which injects the key and treats a decline as “skip this boundary”:

class Compactor < Hames::Service
  service_key :compactor
  inject :sessions, :summarizer
  # ...
  def compact!(session_id)
    # ...
    summary = @ctx[:summarizer].summarize(history)
    unless summary.is_a?(String) && !summary.strip.empty?
      warn "terret: compaction skipped for #{session_id}: summarizer declined"
      return nil
    end
    # ... append the boundary event
  end
end

That inject :summarizer line is the entire coupling. The compactor never names Morph or the role summarizer; it names the key, and boot resolves whichever provider a profile mounted. A new provider is a new class that answers summarize the same way — nothing about the consumer changes.

Before you write one, find the seam’s contract the same way: the injecting service and its call site tell you the method, the argument, and what a decline means. Get the return discipline wrong — raise where the consumer expects nil — and you turn an optimization that degrades into a crash.

2. service_key claims the seam

A provider declares the seam it implements with service_key, and injects whatever it needs to do the job. RoleSummarizer needs the model seam; Morph::Summarizer needs no seam at all, only config and a network call:

# gems/terret-core/lib/terret/compactor.rb
class RoleSummarizer < Hames::Service
  service_key :summarizer
  inject :llm
  config_schema role: { type: [String, Symbol], default: :compactor,
                        doc: "llm role a summary is produced under" }

  def summarize(history)
    request = LLM::Request.new(model: nil, system: PROMPT, messages: history, tools: [])
    @ctx[:llm].stream(@ctx, role: config[:role] || :compactor, request: request) { |_ev| }.text
  end
end
# gems/terret-morph/lib/terret/morph/summarizer.rb
class Summarizer < Hames::Service
  service_key :summarizer
  config_schema compression_ratio: { type: Numeric, default: 0.4,
                                     doc: "target fraction of the original token count" },
                api_key:  { type: String,
                            doc: "Morph key; falls back to ENV MORPH_API_KEY when unset" },
                api_base: { type: String, default: "https://api.morphllm.com/v1",
                            doc: "Morph Compact API base URL" },
                timeout:  { type: Numeric, default: 30.0,
                            doc: "seconds a compaction request may run" }
  # ...
end

Both declare config_schema (docs/hames-primer.md §6) so trt doctor and the config catalog can read them — a provider that reads config without a schema is reported unschema'd and is the exact signal an unaudited plugin gives off (docs/composition.md §9). Note what is not in Morph’s schema: transport: (§5). It is a test seam, not YAML config, so it is read from the config hash but deliberately left out of the schema — an operator never sets it, and a doctor should not invite them to.

3. The sole-provider refusal

Two rows both claiming :summarizer do not “last one wins” — they collide at boot. Context#register_service refuses a second claim on a key that is taken, and Service#apply registers through it, so the second row to mount raises Hames::ContractError naming the key (docs/hames-primer.md §1). That refusal is the whole meaning of “sole-provider”: the answer to “what summarizes in this deployment” is decided once, by which single row mounts, and cannot be quietly doubled.

Which means choosing a provider is choosing which row is enabled — not mounting both and hoping. A profile mounts exactly one summarizer row. Swapping RoleSummarizer for Morph is a plugin: swap on that one row, the same single-row move the sandbox seam uses (docs/composition.md §4):

# in a profile's patch.yml — one row, plugin swapped, config restated whole
rows:
  - id: summarizer
    plugin: Terret::Morph::Summarizer
    config:
      api_key: !env MORPH_API_KEY
      compression_ratio: 0.4

Because a patch replaces config wholesale (docs/composition.md §4), the swap carries the new provider’s config with it; a swap that should inherit nothing from the row it replaces says config: {} out loud. The base bundle ships no summarizer row at all — compaction is opt-in, so a profile that wants it adds both a summarizer provider row and a compactor row (gems/terret/config/bundle.yml has neither). That is the ordinary shape for an optional seam: the consumer and its provider are mounted together by the profile that wants the capability.

A provider gem also does not have to be a bundle. terret-morph ships no terret metadata key in its gemspec — it is a library of one provider class, mounted by a profile that lists terret/morph in its requires: (or a bundle’s) and names Terret::Morph::Summarizer in a row. docs/cookbook/adding-a-bundle.md covers when a gem should ship a bundle of its own versus be mounted by a row someone else writes.

4. Nil-on-failure, where it applies

ctx[:summarizer] is an optimization seam: compaction makes a long history cheaper, and a compaction that cannot happen should leave the session exactly as it was, not end the turn. So both providers decline rather than raise, and every failure mode in Morph funnels through one helper:

def summarize(history)
  key = api_key
  return decline("MORPH_API_KEY not configured") if key.nil? || key.empty?
  # ... POST ...
  return decline("HTTP #{status}") unless (200..299).cover?(status)
  # ... parse ...
  output.empty? ? decline("empty output") : output
rescue JSON::ParserError => e
  decline("invalid JSON: #{e.message}")
rescue StandardError => e
  decline("#{e.class}: #{e.message}")
end

def decline(message)
  warn "terret-morph: compact declined: #{message}"
  nil
end

A missing key, a non-2xx status, unparseable JSON, an unexpected response shape, a non-string output, an empty output, a transport exception — all decline to nil with a warn, and the compactor skips the boundary and retries on the next overweight turn. The warn is not decoration: a seam that silently returns nil is a capability that quietly stopped working, and the log line is how an operator finds out.

This discipline is specific to seams whose absence is survivable. It is not a house style to copy blindly onto every provider. A sole-provider seam the harness cannot proceed without — the session store, say — must raise on failure, because a nil there is data loss dressed as success. The rule is the consumer’s: read what the injecting service does with your return value. The compactor treats nil as “skip”; a store’s caller treats a lost write as a bug. Match the discipline to what a failure actually means, and say which one you chose in a comment, the way Morph’s class doc says “every failure declines to nil with a warn — compaction is an optimization”.

5. An injectable transport, so tests need no network

Morph talks to a real HTTP API, and its unit tests touch no network. The trick is one seam: the network call is read from the config hash, defaulting to the real implementation, so a test injects a callable and asserts against what the provider sent:

def transport
  config[:transport] || method(:http_post)
end

def http_post(url, headers, body)
  uri = URI(url)
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = uri.scheme == "https"
  http.open_timeout = http.read_timeout = http.write_timeout = timeout
  response = http.post(uri.request_uri, body, headers)
  [response.code.to_i, response.body]
end

The transport’s contract is call(url, headers, body) => [status, body] — a plain callable, so method(:http_post) satisfies it and so does a lambda. This is the same injectable-seam move the OpenRouter adapter uses for its HTTP transport (gems/terret-openrouter): keep the one line that touches the network behind a callable the config can replace, and the whole provider becomes testable without mocking a library. Keeping it out of the schema (§2) is deliberate — an injectable test seam is not an operator knob, and a doctor listing it would invite someone to set it in YAML.

6. The test

With the transport injectable, a provider test is a fake transport, a boot, and assertions on both what went over the wire and what came back. The example below is adapted from terret-morph’s own test and abridged for the page — the real file (gems/terret-morph/test/summarizer_test.rb) walks six decline cases and richer wire assertions than the two shown here — but the shape is the template:

# frozen_string_literal: true
require "minitest/autorun"
require_relative "../lib/terret/morph"

class MorphSummarizerTest < Minitest::Test
  HISTORY = [
    Terret::LLM::Message.new(role: :user, parts: [Terret::LLM::Text.new(text: "deploy the thing")]),
    Terret::LLM::Message.new(role: :assistant, parts: [Terret::LLM::Text.new(text: "Deployed.")])
  ].freeze

  def boot(transport:, config: {})
    Hames.reset_events!
    Terret.declare_events!
    loader = Hames::Loader.new
    loader.layer([
      { id: "summarizer", plugin: Terret::Morph::Summarizer,
        config: { api_key: "test-key", transport: transport }.merge(config) }
    ])
    loader.boot!
  end

  def test_happy_path_posts_the_wire_shape_and_returns_output
    seen = nil
    transport = lambda do |url, headers, body|
      seen = [url, headers, JSON.parse(body, symbolize_names: true)]
      [200, JSON.generate({ output: "compressed transcript" })]
    end
    ctx = boot(transport: transport)

    assert_equal "compressed transcript", ctx[:summarizer].summarize(HISTORY)
    url, headers, body = seen
    assert_equal "https://api.morphllm.com/v1/compact", url
    assert_equal "Bearer test-key", headers["Authorization"]
    assert_in_delta 0.4, body[:compression_ratio]
  end

  def test_every_failure_mode_declines_to_nil_with_a_warn
    cases = {
      "HTTP 500"          => ->(*) { [500, "boom"] },
      "invalid JSON"      => ->(*) { [200, "not json"] },
      "non-string output" => ->(*) { [200, JSON.generate({ output: 42 })] },
      "transport error"   => ->(*) { raise IOError, "connection reset" }
    }
    cases.each do |label, transport|
      ctx = boot(transport: transport)
      result = nil
      warned = capture_warn { result = ctx[:summarizer].summarize(HISTORY) }
      assert_nil result, "#{label} must decline to nil"
      assert_match(/terret-morph/, warned, "#{label} must warn")
    end
  end

  private

  def capture_warn
    old = $stderr
    $stderr = StringIO.new
    yield
    $stderr.string
  ensure
    $stderr = old
  end
end

The shape generalizes to any provider: boot layers just the provider row and injects the fake transport; the happy-path test asserts on what the provider sent (the wire shape is a contract too, and this is where you pin it); and a table-driven test walks every decline path, asserting nil and a warn. A MORPH_LIVE=1-gated test in the same file exercises the real API when a key is present and skips otherwise — the way to keep a real integration honest without making the default test run depend on a network or a secret.

7. What you have when this is done

A provider is a class claiming a seam key, injecting what it needs, declaring a schema, and answering the seam’s one method with the seam’s return discipline. Mounting it is choosing which single row claims the key; the kernel’s sole-provider refusal (§3) guarantees nobody else quietly does. Testing it is a fake transport and a boot — no network, no secret, no mocks.

For the other two build-on-Terret recipes: docs/cookbook/adding-a-tool.md grows the roster instead of swapping a seam, and docs/cookbook/adding-a-bundle.md is how any of this reaches a running Terret through a profile.