Extending and embedding · 15

Extensions

An extension is a named block of Ruby that registers things — tools, commands, prompt sections, hooks — with each session. The same extension works in the terminal, headless, over ACP and inside your app.

A complete example

Put this in .riffer/rig.rb in a project, or ~/.riffer/rig.rb for every project:

require 'open3'

class GitLog < Riffer::Tool
  description 'Show the most recent commits in the working directory, newest first.'

  params do
    optional :count, Integer, default: 10, description: 'How many commits to show'
  end

  def call(context:, count: 10)
    output, status = Open3.capture2e('git', 'log', '--oneline', "-n#{count}", chdir: context&.[](:cwd) || Dir.pwd)
    status.success? ? text(output) : error(output, type: :execution_error)
  end
end

Riffer::Rig.extension('git') do |rig|
  rig.tool GitLog
  rig.command('log', description: 'Recent commits') { |ctx| ctx.say `git log --oneline -n 5` }
  rig.prompt(:branch) { "Current branch: #{`git branch --show-current`}" }
end

Start riffer and the extension is live:

  • the model has a git_log tool, described by the description and params above, which it can call whenever it judges a request needs one;
  • /log prints the last five commits, without involving the model;
  • every turn’s system message ends with the current branch.

Edit the file and the next turn picks up the change (Reloading). A tool is an ordinary riffer tool: riffer’s tools guide covers parameters, nested params and responses.

The rig.rb files

Two files are loaded, in this order:

  1. ~/.riffer/rig.rb — for every project; always trusted.
  2. <cwd>/.riffer/rig.rb — for this project; asked about the first time it is seen.

They are plain Ruby: require gems, require_relative your own files, or call Riffer::Rig.extension inline. What they load is what runs; there is no manifest or list of extensions in settings.

The project file can run anything, so riffer-rig asks before loading it, and remembers the answer by path in ~/.riffer/trust.json:

Answer Result
yes stored; the file loads now and in every later session
no stored; the file is skipped until you remove its entry from trust.json
no answer (Ctrl-C or Ctrl-D) skipped this time; asked again next time
nobody to ask (headless, ACP while starting, an app with the null host) skipped; nothing stored, so the terminal can ask later

A rig.rb that raises is reported, and none of its extensions load, while the other file’s still do. A bad require stops startup.

Gem extensions

To share an extension as a gem, name it riffer-rig-<name>, put its entry point at lib/riffer/rig/<name>.rb, and have that file call Riffer::Rig.extension('<name>'). Users load it with require 'riffer/rig/<name>' in a rig.rb.

A gem can also ship lib/riffer/rig/extension.rb, which loads automatically when a user sets "extensions": {"autoload": true} (Configuration).

Gem extensions don’t reload: develop as a local file required from rig.rb, and package it once it settles.

The seams

The rig object passed to the block has one method per kind of thing an extension can register:

Seam Registers
rig.tool a tool the model can call
rig.prompt a section of the system message
rig.command a slash command
rig.on a hook on an event of the session
rig.skills a source of Agent Skills
rig.mcp an MCP server
rig.setting a setting, with its default
rig.provider a model provider

Riffer::Rig.extension(name, requires: nil) { |rig| … } records the block by name, so loading a file again replaces it rather than adding a duplicate. The block runs once for each Runtime, against that Runtime alone: keep per-session state in the block’s local variables.

The rig.tool seam

rig.tool GitLog

Adds a Riffer::Tool class. Its identifier is its name everywhere, including --tools. A later tool with the same identifier replaces it.

The rig.prompt seam

rig.prompt(:git_status) { |ctx| "Branch: #{`git -C #{ctx.cwd} branch --show-current`}" }

Adds a named section to the system message, after the base prompt and before the environment (Instructions). The block runs at the start of every turn and returns the section’s text as-is, so add your own heading if the model needs one. nil or an empty string leaves the section out of that turn. ctx is the Runtime: ctx.cwd, ctx.settings, ctx.host.

The rig.command seam

rig.command('log', description: 'Recent commits') { |ctx| ctx.say `git log -n #{ctx.args} --oneline` }
rig.command('review', description: 'Review the diff') { |ctx| ctx.prompt "Review this diff:\n#{`git diff`}" }

Adds a slash command, named without the slash. Commands work the same in every host. The block receives a ctx:

Member Does
args the text after the command name ("" when there is none)
say(text) shows text as the command’s output
prompt(text) sends text to the model as your message, starting a turn
ask(question, options: nil, secret: false) asks the user; nil when the host can’t ask
confirm(question) asks yes or no; false when unanswered or the host can’t ask
settings this extension’s settings, with defaults filled in
emit(event) sends a custom event to whoever ran the command
runtime, host the Runtime, and its host

A command that raises is reported, and the session carries on. Use ctx.prompt to start a turn: calling ctx.runtime.prompt from a command raises, because one is already running.

The rig.on seam

rig.on(:before_tool_call) { |e| next :block, 'no force pushes' if e.tool == 'bash' && e.args[:command] =~ /push --force/ }
rig.on(:before_prompt) { |e| "#{e.text}\n\nAnswer in one paragraph." }
rig.on(:turn_end) { |e| warn "turn cost $#{e.cost}" if e.cost }

Adds a hook for an event. Hooks for the same event run in load order. An unknown event name fails the extension.

Event Kind Carries Fires
session_start lifecycle the rig event as the first turn starts, and after a reload swaps this extension in
session_end lifecycle the rig event before a reload swaps this extension out, and when the session closes
before_prompt can change text as each prompt starts
before_request can change messages, the whole request before every model call
before_tool_call can change the rig event before each tool runs
after_tool_call observe the rig event after each tool call
after_response observe message, the assistant’s reply after each model response
turn_end observe the rig event as each turn ends
stream observe the riffer stream event for every riffer stream event

Use lifecycle hooks to acquire and release anything process-wide. Observe hooks’ return values are ignored. A hook that can change the event may return a replacement, which the next hook receives, or block:

Event Return a replacement… Return :block or [:block, reason]…
before_prompt a String: the text the model gets the prompt never reaches the model; the user sees the reason; the turn ends :guardrail_blocked
before_tool_call a Hash: the arguments the tool runs with the tool doesn’t run; the model gets a blocked tool error with the reason, and the turn carries on
before_request an Array of messages: the request, saved back to the session at the start of a turn it ends :guardrail_blocked; after tool results the turn is interrupted

Any other return value, including nil, changes nothing. A hook that raises is reported and counts as no change. Events covers what after_tool_call reports for calls that were blocked or never ran.

The rig.skills seam

rig.skills { |ctx| Riffer::Skills::FilesystemBackend.new(File.join(ctx.cwd, 'docs/skills')) }

Adds a source of Agent Skills. The block returns a Riffer::Skills::Backend and runs when the session starts and on every reload. When two sources have a skill of the same name, the earlier source wins.

The rig.mcp seam

rig.mcp 'tracker', url: 'https://tracker.example.com/mcp', headers: { 'Authorization' => "Bearer #{ENV.fetch('TRACKER_TOKEN')}" }

Declares an HTTPS MCP server: rig.mcp(name, url:, headers: {}, auth: {}). auth: and ${field} headers work as in settings. A later declaration of the same name replaces the earlier one.

The rig.setting seam

Riffer::Rig.extension('git') do |rig|
  rig.setting :depth, default: 3
  rig.command('log', description: 'Recent commits') { |ctx| ctx.say `git log -n #{ctx.settings[:depth]} --oneline` }
end

Declares a key under the extension’s namespace in settings — here "git": { "depth": 10 } — with a default for when it is unset. Commands read it as ctx.settings[:depth] and prompt sections as ctx.settings[:git][:depth]; inside the block, rig.settings holds the values so far, so an extension can register according to them. An extension can’t be named after a core settings key.

The rig.provider seam

Registers a model provider under a new prefix. Custom providers covers it.

Bundled extensions and replacement

The built-in tools, AGENTS.md, skills and the MCP client are extensions too — read, write, edit, bash, agents_md, skills and mcp — built on the same seams. You can disable any of them.

To replace one piece instead, register something with the same name from your own extension: a tool with the same identifier, a command, prompt section or MCP server with the same name. The later registration wins, takes the earlier one’s place, and the session shows a notice such as Extension sandbox replaces tool bash from bash.

Error isolation

An extension whose block raises, whose requires: doesn’t match the installed riffer-rig, or that is named after a core settings key is skipped and reported. The other extensions still load. Errors are shown to the user, never to the model.

API versioning

Riffer::Rig.extension and the rig.* seams are riffer-rig’s public API. While riffer-rig is 0.x, a breaking change ships in a minor release, with a deprecation release first where possible and a BREAKING CHANGES note in the changelog. Pin the versions an extension supports with requires::

Riffer::Rig.extension('git', requires: '~> 0.15') do |rig|
  # ...
end

On a riffer-rig outside the requirement, the extension is reported as failing to load rather than run.