Serialization
Riffer::Agent::Serializer turns a resolved agent into a self-contained, provider-neutral data hash (to_h) and reconstructs a runnable agent from that hash (from_h). Use it to persist agent definitions outside of code, or to transfer them across a process/service boundary.
You normally reach it through the delegators on Riffer::Agent:
data = agent.to_h # snapshot rebuilt = Riffer::Agent.from_h(data) # reconstruct
The hash is plain data — symbol-keyed, JSON-safe. For the wire, use the JSON helpers, which handle generating and parsing for you:
json = agent.to_json # or Riffer::Agent::Serializer.to_json(agent:) rebuilt = Riffer::Agent.from_json(json)
The hash forms (to_h / from_h) are public too, if you want to embed the hash in a larger payload. from_h expects symbol keys, so parse with JSON.parse(str, symbolize_names: true) — or just use from_json, which does that for you.
Runtime context
from_h / from_json accept an optional context: — the rebuilt agent’s runtime context, exactly the value you’d pass to Agent.new(context:). It is not used to re-resolve the serialized definition (that’s already resolved); it’s threaded into tool dispatch and read by tools/runtimes at call time. Pass it when a tool or a remote runtime needs per-call data — e.g. context: { tenant: "acme" } for multi-tenant dispatch, or Maestro passing context: { agent: self } so its runtime can call back. Omit it (defaults to empty) when nothing downstream reads context.
Seeding conversation history
The hash carries the agent definition, not its conversation history (see What does not transfer). To resume a persisted conversation, pass a session: — exactly the value you’d pass to Agent.new(session:):
rebuilt = Riffer::Agent.from_h(data, session: persisted_session) rebuilt.generate # continues from the seeded history
The session is used as-is: the rebuilt agent does not prepend anything to it, so the caller owns its full contents — including the system instruction message. Omit session: and the rebuilt agent builds a fresh session, seeded with the hash’s instructions and an empty history. Because a supplied session is authoritative, the hash’s instructions are not re-injected into it — make sure your persisted session already contains the system message.
What the hash carries
{
schema_version: 1, # wire format version
riffer_version: "0.29.1", # diagnostic only
identifier: "support_agent",
model: "openai/gpt-4o", # resolved "provider/model" string
instructions: "You are…", # resolved system prompt
model_options: { temperature: 0.2 },
provider_options: { … }, # see the secrets warning below
max_steps: 8, # integer; -1 = unlimited (see below)
structured_output: { type: "object", … }, # JSON Schema, or null
tools: [ { name:, description:, parameters_schema:, timeout: }, … ]
}
Resolved snapshot
to_h reads the agent’s resolved state, not its raw configuration. By the time you call it, Agent.new has already evaluated any Proc-based model, instructions, or uses_tools against the agent’s own context — so the hash carries plain strings and data, never Procs. The receiver’s context: drives runtime behavior (tool dispatch); it does not re-evaluate baked-in fields.
Structured output
Structured output crosses as provider-neutral JSON Schema (Riffer::Params#to_json_schema), never a provider-rendered schema. from_h rebuilds a validating Riffer::Params via Riffer::Params.from_json_schema, so the rebuilt agent both constrains the model and can parse_and_validate responses — rendering provider-correct bytes at call time, the same way an in-code agent does.
Reconstructing tools
Tools cross as {name, description, parameters_schema, timeout} descriptors — never code. How a descriptor becomes a runnable tool is controlled by the tool_resolver: you pass to from_h. The two common shapes:
In-process (registry lookup)
When the rebuilt agent runs in the same codebase that defined the tools (e.g. persisting an agent definition and rehydrating it later), resolve each descriptor back to its real class:
rebuilt = Riffer::Agent.from_h(data, tool_resolver: ->(descriptor) { MyToolRegistry.fetch(descriptor[:name]) })
The real classes carry their call bodies, so the agent runs on the default Inline runtime — no further wiring.
Distributed (body-less shells)
When the receiver holds only the Riffer gem, the default tool_resolver synthesizes body-less tool shells. A shell advertises the tool’s schema to the LLM but has no call — invoking it in-process raises. Pair the default resolver with a remote Riffer::Tools::Runtime that forwards each call back to the origin:
rebuilt = Riffer::Agent.from_h(data, tool_runtime: MyRemoteToolRuntime.new(client: rpc_client))
Implement the remote runtime by subclassing Riffer::Tools::Runtime and overriding dispatch_tool_call to forward the call over your transport, mapping any failure to Tools::Response.error. See Advanced Tools for the runtime API.
You own what a resolved tool does: a resolver may return real in-process classes, shells, or classes that themselves make network calls. Riffer does not require a runtime — it only ships shells by default.
max_steps
Unlimited steps are nil at the agent level — set it with max_steps nil. On the wire, the serializer encodes that as -1 (and decodes -1 back to nil), so the hash stays portable across transports where JSON null is awkward — proto3, for one, can’t distinguish null from an absent field. The -1 is purely a wire detail: the DSL and your code only ever see nil, and the encode/decode handles the translation at the boundary.
-
DSL — integer = bounded,
nil= unlimited, omitted =Config‘s default (16). -
Wire — integer = bounded,
-1= unlimited, omitted = default (16).
A finite integer round-trips as-is; a hash missing the key falls back to the default rather than running unbounded.
Versioning
schema_version is an integer (Riffer::Agent::Serializer::SCHEMA_VERSION). from_h refuses any version it doesn’t recognize with Riffer::Agent::Serializer::VersionError (a Riffer::ArgumentError). A future incompatible change bumps the integer and adds a backwards-compatible decoder, giving distributed consumers a window to upgrade before the old format is dropped.
Secrets
provider_options and model_options ride on the wire as plain data — they are part of the hash and will transfer. Prefer configuring API keys via environment/global provider configuration rather than provider_options. Never serialize an agent whose options carry sensitive values — and if a serialized definition ever does, handle it as a secret (encrypt it, keep it out of logs).
What does not transfer
-
Guardrails and skills are not supported yet — neither is serialized, so a rebuilt agent enforces no guardrails and has no skills catalog. (As a stopgap, a skills-enabled agent’s
skill_activatetool still crosses as an ordinary tool descriptor.) Both are expected to be revisited.
Next Steps
-
Tools - Creating tools
-
Advanced Tools - Tool runtime and dispatch
-
Configuration - Global configuration