Skip to main content

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, and fetch_allow are 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 chats row; 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::Chat per 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/messages tables 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 #close when done — it delegates to Agent#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

View examples/chat_session.rb on GitHub →


Next steps