Sessions
A remembering instance of an agent, addressed by (agent_name, instance_id), that accumulates context across invocations. A session adds only memory and addressability — never authority.
A Workflow is fire-and-finish. A Nexo::Session is the other half: a remembering instance of an agent, addressed by (agent_name, instance_id), that accumulates context across separate invocations.
Nexo::Session.resume(Assistant, "user-42").prompt("My name is Mac.")
# ... a later request, job, or process ...
Nexo::Session.resume(Assistant, "user-42").prompt("What is my name?")
# => "...Mac..." — the persisted thread carried the earlier turn
resume finds-or-creates the one thread for that pair (the pair is unique — one thread per pair) and returns a session whose #prompt appends to it. #prompt takes the same max_turns: and &on_event block as Agent#prompt, yielding the same (:tool_call | :tool_result | :done, payload) events. Extra keywords are forwarded to the agent constructor (e.g. Nexo::Session.resume(Assistant, "u1", cwd: repo)).
A session adds only memory + addressability — never authority. Its sandbox, permissions (default
:read_only), skills, MCP servers, andfetch_alloware exactly the agent’s; opening or resuming a session never widens what the agent can do. The persisted record supplies the thread; the agent supplies the tools/skills/instructions onto it.
Composition — acts_as_chat, owned by the host
Message persistence is RubyLLM’s acts_as_chat — Nexo defines no message table and serializes nothing. The host Rails app owns all four persistence models (Chat, Message, ToolCall, Model), generated by ruby_llm’s own installer:
rails g ruby_llm:install # generates the Chat/Message/ToolCall/Model models + migrations
One setup step beyond the installer: the session chat model must be addressable, so add two columns and a unique composite index to the generated chats table:
class AddNexoAddressingToChats < ActiveRecord::Migration[8.0]
def change
add_column :chats, :agent_name, :string
add_column :chats, :instance_id, :string
add_index :chats, [:agent_name, :instance_id], unique: true
end
end
Tell Nexo which model hosts sessions (only if it isn’t ruby_llm’s default Chat):
Nexo.configure { |c| c.session_chat_model = "Chat" } # default; a String class name,
# constantized lazily at resume time
Rails-only durability — plain Ruby is in-memory
Durable sessions require ActiveRecord. Backend selection guards on defined?(::ActiveRecord::Base) and the host chat model being defined (mirroring how RunStore only uses the AR store when Nexo::WorkflowRun is present):
- Rails (durable): the thread is a
chatsrow;acts_as_chat’s callbacks persist every message. It survives across requests, jobs, and process restarts. - Plain Ruby (in-memory): a process-wide store holds a live
RubyLLM::Chatper pair. The thread lives only for the process — a fresh process starts empty. This is documented, non-durable behavior, not a bug.
Re-applying the agent’s instructions on every resume is idempotent: acts_as_chat stores instructions as role: :system messages, and Nexo re-applies them with with_instructions (replace semantics) so the stored thread keeps exactly one copy across resumes rather than accumulating duplicate system messages. The runtime tools (the four sandbox tools + MCP + fetch) are re-attached each resume — they are not persisted, and that is correct.
Retention, PII, and #close — the honest trade-off
A continuing session is a persistence surface, and that has real costs:
- Stored messages persist until you delete them, and may contain sensitive data. A long-lived thread accumulates whatever the user and tools put into it. Nexo does not redact, expire, or GC anything — retention is your responsibility. Treat the
chats/messagestables as PII stores and apply your own retention policy. -
Close sessions that hold resources. If the agent declares MCP servers (stdio/SSE) or fetch, a session holds live subprocesses/sockets. Call
#closewhen done — it delegates toAgent#close, tearing those down (idempotent, safe with nothing held):session = Nexo::Session.resume(InboxAssistant, "user-42") begin session.prompt("Summarize my unread threads.") ensure session.close # releases the agent's MCP/stdio/SSE connections end
Live example
A runnable, env-gated two-prompt resume is in the repo:
NEXO_LIVE=1 NEXO_MODEL=… ruby -Ilib examples/chat_session.rb