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:
{: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
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.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.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:
@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.
{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:
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
endThen use it:
{:ok, agent} = SkillKit.start_agent(definition,
conversation_store: {MyApp.ConversationStore.Postgres, []}
)Testing with stores
Use a temporary directory with SkillKit.Conversation.Store.Filesystem:
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