# Conversations
Agents are stateless between restarts by default. Each `SkillKit.start_agent/2`
call starts with an empty message history. If the agent is stopped and
restarted, previous messages are lost.
A **conversation store** solves this by persisting messages to durable storage.
When the agent starts, it loads prior messages. After each turn completes, it
saves the updated history. This enables multi-session conversations, crash
recovery, and conversation audit trails.
## When to use a store
- **Multi-session agents** — a user returns hours later and the agent
remembers the conversation
- **Crash recovery** — if the agent process crashes, the supervisor restarts
it and the store restores state
- **Audit and debugging** — inspect what the agent said and which tools it
called
If your agent is ephemeral (single request, no persistence needed), skip the
store entirely. The default is `nil` — no persistence.
## Configuring a store
Pass `conversation_store: {module, config}` to `SkillKit.start_agent/2`:
```elixir
{:ok, agent} = SkillKit.start_agent(definition,
caller: self(),
conversation_store: {SkillKit.Conversation.Store.Filesystem, path: "priv/conversations"}
)
```
The store is a `{module, config}` tuple. The module implements
`SkillKit.Conversation.Store`. The config is a keyword list passed to every
callback.
## How it works
1. **On init** — the Server calls `store.load(agent_name, config)`. If
messages exist, they become the starting conversation history. If the
store returns `{:error, _}` or the file doesn't exist, the agent starts
with an empty history.
2. **After each turn** — the Server calls `store.save(agent_name, messages, config)`
with the full message list. This happens after the agent loop completes,
including any tool call rounds.
3. **On delete** — `store.delete(agent_name, config)` removes the stored
conversation. This is not called automatically — use it when you want to
clear history explicitly.
The conversation ID is the agent's name (a string).
## The Store behaviour
`SkillKit.Conversation.Store` defines three callbacks:
```elixir
@callback save(conversation_id, [message], keyword()) :: :ok | {:error, term()}
@callback load(conversation_id, keyword()) :: {:ok, [message]} | {:error, term()}
@callback delete(conversation_id, keyword()) :: :ok | {:error, term()}
```
Messages are `SkillKit.Types` structs: `%UserMessage{}`, `%AssistantMessage{}`,
`%SystemMessage{}`, `%ToolResult{}`.
## Built-in: Filesystem store
`SkillKit.Conversation.Store.Filesystem` serializes messages as Erlang binary
terms on disk.
```elixir
{SkillKit.Conversation.Store.Filesystem, path: "priv/conversations"}
```
Files are stored at `{path}/{agent_name}.bin`. The agent name is sanitized
(non-word characters replaced with `_`). Uses `:erlang.term_to_binary/1` for
serialization — fast and lossless for Elixir structs.
This store is suitable for development and single-node deployments. For
distributed systems, implement a database-backed store.
## Writing a custom store
Implement `SkillKit.Conversation.Store` for your storage backend:
```elixir
defmodule MyApp.ConversationStore.Postgres do
@behaviour SkillKit.Conversation.Store
@impl true
def save(conversation_id, messages, _config) do
encoded = :erlang.term_to_binary(messages)
MyApp.Repo.insert_or_update!(
%MyApp.Conversation{id: conversation_id, messages: encoded}
)
:ok
end
@impl true
def load(conversation_id, _config) do
case MyApp.Repo.get(MyApp.Conversation, conversation_id) do
nil -> {:ok, []}
record -> {:ok, :erlang.binary_to_term(record.messages)}
end
end
@impl true
def delete(conversation_id, _config) do
MyApp.Repo.delete_all(
from(c in MyApp.Conversation, where: c.id == ^conversation_id)
)
:ok
end
end
```
Then use it:
```elixir
{:ok, agent} = SkillKit.start_agent(definition,
conversation_store: {MyApp.ConversationStore.Postgres, []}
)
```
## Testing with stores
Use a temporary directory with `SkillKit.Conversation.Store.Filesystem`:
```elixir
setup do
path = Path.join(System.tmp_dir!(), "test_store_#{:erlang.unique_integer([:positive])}")
File.mkdir_p!(path)
on_exit(fn -> File.rm_rf!(path) end)
%{store: {SkillKit.Conversation.Store.Filesystem, path: path}}
end
test "persists messages across agent restarts", %{store: store} do
definition = %SkillKit.Agent{...}
# First session
SkillKit.Test.expect_response(%SkillKit.Response.Text{content: "Hi!"})
{:ok, agent} = SkillKit.start_agent(definition, conversation_store: store, caller: self())
{:ok, _msg} = SkillKit.send_message_sync(agent, "Hello")
Process.sleep(100) # wait for async save
SkillKit.stop_agent(agent)
# Second session — agent should remember
SkillKit.Test.assert_response(%SkillKit.Response.Text{content: "I remember!"}, fn messages, _opts ->
assert length(messages) > 1 # prior messages loaded
end)
{:ok, agent2} = SkillKit.start_agent(definition, conversation_store: store, caller: self())
{:ok, _msg} = SkillKit.send_message_sync(agent2, "Do you remember?")
end
```