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

  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 deletestore.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
end

Then 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