
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.

```ruby
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:

```bash
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:

```ruby
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`):

```ruby
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):

  ```ruby
  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:

```sh
NEXO_LIVE=1 NEXO_MODEL=… ruby -Ilib examples/chat_session.rb
```

> [View `examples/chat_session.rb` on GitHub →](https://github.com/maquina-app/nexo/blob/main/examples/chat_session.rb)


---

## Next steps

<div class="not-prose mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2">
  <a href="../loops/" class="group relative rounded-2xl border border-zinc-200 p-6 hover:border-[#50B1FD]/50 dark:border-zinc-800 dark:hover:border-[#50B1FD]/50 transition">
    <h3 class="font-sora font-semibold text-zinc-900 dark:text-white group-hover:text-[#50B1FD] transition">
      Loops
    </h3>
    <p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
      The engine that drives an agent's tool-calling turns.
    </p>
  </a>

  <a href="../concurrency/" class="group relative rounded-2xl border border-zinc-200 p-6 hover:border-[#50B1FD]/50 dark:border-zinc-800 dark:hover:border-[#50B1FD]/50 transition">
    <h3 class="font-sora font-semibold text-zinc-900 dark:text-white group-hover:text-[#50B1FD] transition">
      Concurrency
    </h3>
    <p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
      Fan out sessions and agents without tripping rate limits.
    </p>
  </a>
</div>
