Extending and embedding · 16
Embedding riffer-rig
Riffer::Rig::Runtime runs the agent inside any Ruby process. It never prints, never reads the filesystem and assumes no terminal: everything the shipped hosts can do goes through the same API described here.
There are two ways to get one:
Loader.runtimebuilds it from riffer-rig’s files on disk — settings, credentials,rig.rb, skills, saved sessions — exactly as the terminal does.Runtime.newtakes everything as arguments, for an app that keeps its configuration elsewhere.
Building a Runtime with the Loader
runtime = Riffer::Rig::Loader.runtime(cwd: Dir.pwd, host: MyHost.new)
The Loader reads the settings, picks the model, ensures its SDK gem, resolves its credentials, loads the bundled extensions and the rig.rb files, and builds the Runtime. It also adds the /reload command, /trace when the session is saved, and automatic reloading.
| Keyword | Meaning | Default |
|---|---|---|
cwd: |
the working directory; its .riffer/settings.json is the project scope |
required |
host: |
the host: it receives notices, and questions such as a missing credential | required |
options: |
a Loader::Options: the choices command-line flags make |
Loader::Options.new |
store: |
the session store that saves the session; nil saves nothing |
Stores::JSONL.new(home: home) |
snapshot: |
a snapshot to resume from | nil |
reload: |
:manual turns off automatic reloading for this build |
the reload setting |
extra_extensions: |
extensions to load after the bundle and the rig.rb files, kept across reloads |
[] |
env: |
the environment to read, as Riffer::Rig::Env.load returns it (below) |
Riffer::Rig::Env.load |
home: |
the directory whose .riffer/ holds the home files |
Dir.home |
riffer_config: |
the Riffer::Config that receives credentials and catalog files |
Riffer.config |
Riffer::Rig::Loader.new(cwd:, host:, env:, home:, riffer_config:, store:) keeps the Loader around: build with runtime(options, reload:, extra_extensions:, snapshot:), reload with reload(runtime, force: false), and find saved sessions with the store methods.
The host is asked only when its capabilities include :ask. Anything the Loader can’t settle raises Riffer::Rig::Loader::ConfigurationError, whose message is written for the user: no model and no way to ask, an unknown provider, an invalid RIFFER_MODEL or reasoning level, a missing credential or SDK gem, or a settings or auth.json file that doesn’t parse.
begin
runtime = Riffer::Rig::Loader.runtime(cwd: Dir.pwd, host: Riffer::Rig::Hosts::Null.new)
rescue Riffer::Rig::Loader::ConfigurationError => e
warn e.message
exit 2
end
Loader options
options = Riffer::Rig::Loader::Options.new(model: 'anthropic/claude-sonnet-4-6', skills: false)
runtime = Riffer::Rig::Loader.runtime(cwd: Dir.pwd, host: MyHost.new, options: options)
Every keyword is optional, and each matches a command-line flag:
| Keyword | Meaning | Default |
|---|---|---|
model: |
"provider/name", over every other source |
nil |
reasoning: |
the reasoning level, over every other source | nil |
extensions: false |
skips the rig.rb files and gem autoload; bundled extensions still load |
true |
skills: false |
leaves out skills | true |
agents_md: false |
leaves out AGENTS.md | true |
tools: |
the tool allowlist | nil |
max_steps: |
the step limit | nil |
Constructing a Runtime
runtime = Riffer::Rig::Runtime.new(
'anthropic/claude-sonnet-4-6',
extensions: [*Riffer::Rig.bundled, Riffer::Rig.extension('my_ext') { |rig| rig.tool MyTool }],
host: MyHost.new,
)
The model is positional; everything else is an optional keyword. Provider credentials reach riffer through Riffer.config, one set per process: resolve them with Credentials.install, or set them yourself with Riffer.configure.
| Keyword | Meaning | Default |
|---|---|---|
extensions: |
the extensions to run, in order; Riffer::Rig.bundled returns the bundled ones, and Riffer::Rig.bundled(:read) one of them |
[] |
tools: |
the tool allowlist; nil allows every tool |
nil |
settings: |
the merged settings, with symbol keys: { model: '…', git: { depth: 10 } } |
{} |
host: |
the host | Riffer::Rig::Hosts::Null.new |
cwd: |
the working directory tools and the environment block use | Dir.pwd |
name: |
the agent’s name in the base prompt | "riffer" |
instructions: |
replaces the base prompt | nil |
max_steps: |
the step limit | nil |
reasoning: |
the reasoning level (Configuration) | nil |
model_options: |
riffer model_options sent with every model call; for an anthropic model the Loader passes { cache_control: { type: :ephemeral } } (prompt caching) |
{} |
native_tools: |
the provider-native tool switches: { web_search: true } |
{} |
credentials: |
each provider’s resolved values, { anthropic: { api_key: '…' } }, used by /model and when restoring |
{} |
env:, auth_path:, settings_path: |
where /auth, /model and MCP auth fields read and store credentials, and where --save writes |
Env.new, ~/.riffer/auth.json, ~/.riffer/settings.json |
riffer_config: |
the Riffer::Config the agent reads its catalog and reasoning from |
Riffer.config |
mcp_registry: |
where MCP servers are registered | Riffer::Mcp |
clock: |
a callable returning seconds, which stamps tool call events and times background jobs | the monotonic clock |
snapshot: |
a snapshot to restore | nil |
runtime.settings is the settings: hash with each extension’s declared defaults filled in, and runtime.declared_settings lists what each extension declared, for a host to display. runtime.errors lists the extensions that failed to load.
Two Runtimes in one process share only the extension registry, riffer’s providers and its config — so they share one set of provider credentials.
Prompting
prompt(text) runs one turn on the calling thread. With a block it yields each event as it happens; without one it returns an Enumerator:
runtime.prompt('Explain this repository') do |event|
case event
when Riffer::StreamEvents::TextDelta then print event.content
when Riffer::StreamEvents::ToolCallDone then puts "\n[tool: #{event.name}]"
end
end
ask(text) runs a turn to completion and returns riffer’s Riffer::Agent::Response:
response = runtime.ask('Fix the failing test')
puts response.content
puts response.outcome.reason # :completed, :max_steps, :length, :interrupted, …
puts response.token_usage&.cost # this turn, in USD, when priced
One turn runs at a time per Runtime: a second prompt, ask, command or rebuild while one runs raises Riffer::Rig::Runtime::BusyError, and busy? tells another thread whether one is running. For parallel work, use more Runtimes in more threads.
Token tally and cost
riffer prices each model call from the catalog. tally is the Runtime’s running total, a riffer TokenUsage, or nil before the first turn:
Riffer.config.catalog_files = ['catalog.json']
runtime.ask('Fix the failing test').token_usage.cost # => this turn, in USD
runtime.tally.cost # => every turn so far
Cache reads and writes count inside input_tokens and are priced at their own rates. A model with no catalog entry has a cost of nil, never zero. The Loader sets catalog_files from the settings; without the Loader, set it before the first request.
The cost command shows the tally, then each turn:
session ↑4100 · ↓132 · cache_write:900 · cache_read:900 · ~$0.0109
turn 1 ↑2600 · ↓120 · cache_write:900 · cache_read:900 · ~$0.0063
turn 2 ↑1500 · ↓12 · ~$0.0046
Cancelling a turn
cancel stops the running turn. Call it from another thread — a UI thread, a request handler — while prompt or ask blocks; it returns at once, and the turn ends on its own thread.
turn = Thread.new { runtime.prompt('refactor everything') { |event| render(event) } }
runtime.cancel
turn.join
When cancel is called… |
What happens |
|---|---|
| while a reply streams | it stops at once and keeps the text it had reached |
| while a tool call runs | the call finishes first, except the bundled bash, which kills its command; your own tool can check context[:cancel_flag].set? to do the same |
| with no turn running | nothing |
The turn’s turn_end reports it cancelled — from ask, outcome.reason is :interrupted with detail: "cancelled" — and the next prompt carries on from there. Background jobs keep running.
Commands
Slash commands belong to the Runtime, so they work the same in every host. commands lists them — the Loader’s reload and trace, the core auth, model, reasoning and cost, one skill:<name> per skill, and extensions’ commands — each with a name (no slash) and a description.
run_command(name, args) runs one on the calling thread and yields its events: command_output for each ctx.say, a whole turn’s events if the command starts one, and notify for anything reported. An unknown name, or a command that raises, is reported through the host and never raises from run_command.
runtime.run_command('log', '5') do |event|
case event
when Riffer::Rig::Events::CommandOutput then puts event.text
when Riffer::StreamEvents::TextDelta then print event.content
when Riffer::Rig::Events::Notify then warn event.message
end
end
Hosts covers the commands a host implements itself.
Switching the model and reasoning level
runtime.model = 'openai/gpt-5'
runtime.reasoning = 'low'
Both apply from the next model call and keep the conversation, tally and tools. An invalid value raises Riffer::ArgumentError and changes nothing. The model command does more than the setter: it ensures the provider’s SDK gem and credentials first, refusing the switch if either is missing (Configuration).
Rebuilding after a code reload
rebuild(extensions:, settings:) replaces everything the extensions registered with what a new extension list registers. The Loader’s /reload uses it; an app with its own code reloading calls it directly:
Rails.application.reloader.to_prepare do
runtime.rebuild(extensions: [*Riffer::Rig.bundled, MyApp.rig_extension], settings: MyApp.rig_settings)
end
The new extensions are loaded aside and swapped in only when all of them succeed. If any fails, rebuild raises its error and nothing changes — stricter than construction, which skips a failing extension. The conversation, tally, id, host, any model or reasoning switch and every other constructor keyword are kept. Extensions’ lifecycle hooks run around the swap. rebuild runs between turns, or from a command or hook within one.
Capping the loop
runtime = Riffer::Rig::Runtime.new('anthropic/claude-sonnet-4-6', max_steps: 8)
runtime.ask('do the thing').outcome.reason # => :max_steps if the cap stopped it
max_steps: caps the model calls in one run. The model’s partial output stays on the response. A turn continued for a background job starts a new run with its own cap, but the turn isn’t continued once its runs have used the cap between them.
Snapshots
A Runtime saves to and restores from a plain hash. Storing it is up to you:
File.write('session.json', JSON.generate(runtime.to_h))
snapshot = JSON.parse(File.read('session.json'), symbolize_names: true)
runtime = Riffer::Rig::Runtime.new('anthropic/claude-sonnet-4-6', extensions: Riffer::Rig.bundled, credentials: credentials, snapshot: snapshot)
| Key | Holds |
|---|---|
:id |
the session id, kept by the restored Runtime |
:messages |
the conversation as riffer message hashes, system message first |
:model |
a model switched to with model= or /model, or nil |
:reasoning |
the reasoning level in effect, or nil |
:skills |
the skills the model activated |
A snapshot holds history, not configuration: tools, commands, hooks and settings come from the extensions: and settings: given when restoring. Nothing in a snapshot blocks a restore:
- a call to a tool that no longer exists is just history;
- tool calls left without a result by a crash get an “interrupted” error and are never run;
- a saved model is used only if
credentials:has its provider, otherwise the given model is used and the host is told; - skills that no longer exist are dropped;
- the tally is recomputed from the messages.
Observing messages
runtime.on_message { |message| log.puts(JSON.generate(message.to_h)) }
on_message calls the block with each message the conversation gains — your prompt, each assistant reply, each tool result, each background job notice — as riffer Riffer::Messages objects, on the turn’s thread. This is how a store saves a session as it happens. The system message is not delivered, since it is rebuilt every turn.
Background jobs
Tools describes jobs from the model’s side. A host has two ways to follow them:
runtime.on_job_exit { |completion| exits << completion } # as each job exits
runtime.running_job_count # => 2
on_job_exit runs on a background thread, once per job, the moment it exits. Push the completion onto a queue rather than rendering or prompting from the block. The completion is a frozen Riffer::Rig::Runtime::JobCompletion with job_id, command, exit_status (nil if a signal ended it), signal (such as "TERM"), duration, output_path and tail (the last 2,000 bytes).
The stream’s job_exit event marks the later moment the model is told, on the turn’s thread.
Waiting for jobs
A host whose model stops with jobs still running can wait and give the model a turn for each exit, as headless does:
runtime.prompt('run the three test suites in the background') { |event| render(event) }
while runtime.pending_jobs?
break unless runtime.await_job_exit(timeout: 600)
runtime.follow_up { |event| render(event) }
end
runtime.close
| Method | Does |
|---|---|
pending_jobs? |
true while a job runs or has exited without the model being told |
pending_job_exit_count |
the exits the model hasn’t been told of yet |
await_job_exit(timeout:) |
blocks until an exit is waiting (true) or the timeout passes (false); close ends it |
follow_up |
runs a turn with no user text that tells the model of every waiting exit, streaming like prompt |
close |
kills running jobs, deletes their output files, and refuses further prompts |
Starting jobs from your own tool
A tool finds the Runtime’s job registry at context[:jobs]. A job registered there is one like any bash job: the model is told of its exit, bash_output and bash_kill act on it, and close stops it.
def call(context:, command:)
reader, writer = IO.pipe
pid = Process.spawn(command, pgroup: true, in: File::NULL, out: writer, err: %i[child out])
writer.close
job = context[:jobs].register(pid, command, reader)
text("Job #{job.id} started; output in #{job.output_path}")
end
register(pid, command, output) takes the pid of a process started in its own process group (pgroup: true), the command to show in its notice, and an IO it reads to the end into the job’s output file. It returns the job, whose id and output_path the model needs. Don’t wait on or detach the process yourself: the registry reaps it.
Job notices in the conversation
A store sees every notice the model read, through on_message. A notice delivered mid-turn is part of a tool result. One delivered as its own message is a Riffer::Rig::Runtime::JobNotification, a user-role message whose to_h adds job_notification: true, so anything reading saved messages can tell it from a prompt someone typed; one delivered by follow_up also carries follow_up_exits, the number of exits it reports. A snapshot restores these as JobNotifications.
Credentials
Riffer::Rig::Credentials resolves provider credentials the way the Loader does, for an app that builds its Runtime directly:
resolution = Riffer::Rig::Credentials.install(:azure_openai, host: host)
resolution.values # => { endpoint: "https://…", api_key: "…" }
resolution.missing # => []
| Method | Does |
|---|---|
install(identifier, host:) |
resolves every field in order, asking the host if it can, and applies them once none is missing |
apply(identifier, values) |
assigns values from elsewhere to Riffer.config |
store(identifier, values) |
saves secrets to auth.json and plain values to settings |
remove(identifier) |
deletes what store saved |
status(identifier) |
:env, :stored, :chain or :missing |
A file that doesn’t parse comes back as a String message in place of the result. install runs !command values every time, so call it once and keep the result.
riffer-rig reads the process environment only through a Riffer::Rig::Env: Env.new snapshots it, and Env.new('ANTHROPIC_API_KEY' => '…') builds one from a hash for tests. A provider SDK’s own credential chain, such as Anthropic’s, reads the process environment itself.