The ALLM.Session API wraps the chat loop with persistent state. A
%Session{} carries the engine, the thread, the status (idle, halted
for tools, halted for user, terminated), pending tool calls, and any
caller metadata you want to ride along. Sessions round-trip safely
through :erlang.term_to_binary/1 and ALLM.Serializer.to_json!/1, so
you can persist them to a database column, an ETS table, or a queue
between turns.
This guide covers when to reach for sessions, the status union, the streaming reducer pattern, and the canonical persistence shapes.
When to use Session vs chat
Use chat/3 when the conversation lives in one process for one request
— a CLI tool, a one-off script, a test. The thread is yours to manage.
Use Session when the conversation needs to outlive a request. Web app
where each user message is a new HTTP request? Background worker
resuming after a crash? Job queue with durable state between turns?
Reach for Session.
Building a session
Session.start/3 runs the first turn:
iex> engine = ALLM.Engine.new(
...> adapter: ALLM.Providers.Fake,
...> adapter_opts: [script: [{:text, "Hello!"}, {:finish, :stop}]]
...> )
iex> {:ok, session} = ALLM.Session.start(engine, [ALLM.user("Hi.")])
iex> session.status
:idleA :idle session has completed its turn and is ready for the next one.
The session.thread field carries the full conversation; serialize the
session and stash it.
Replying
Session.reply/4 appends a user message and runs the next turn:
iex> engine = ALLM.Engine.new(
...> adapter: ALLM.Providers.Fake,
...> adapter_opts: [scripts: [
...> [{:text, "Hello!"}, {:finish, :stop}],
...> [{:text, "Goodbye!"}, {:finish, :stop}]
...> ]]
...> )
iex> {:ok, session} = ALLM.Session.start(engine, [ALLM.user("Hi.")])
iex> {:ok, session} = ALLM.Session.reply(session, engine, "Bye.")
iex> session.status
:idleNote Session.reply/4 takes the engine again — engines are not
persisted on the session (they hold non-serializable bits like Finch
names and key resolvers). The session and engine pair to make a turn.
The status union
| Status | Meaning | Caller action |
|---|---|---|
:idle | Last turn completed; ready for next reply | Call reply/4 or continue/3 |
:halted_for_tools | Loop halted on manual tool calls | Run the manual tools, call submit_tool_result/3, then continue/3 |
:halted_for_user | Loop halted on {:ask_user, _, _} | Append user reply via reply/4 |
:terminated | Session ended (e.g., max iterations, fatal error) | New session if you want to continue |
Pattern-match on session.status to drive your application's UI.
Manual tool flow
When session.status == :halted_for_tools, the pending calls live on
session.pending_tool_calls. After you run them externally, submit
each result and continue:
{:ok, session} = ALLM.Session.submit_tool_result(session, "call_1", %{ok: true})
{:ok, session} = ALLM.Session.continue(session, engine)submit_tool_result/3 mutates the session in-memory; continue/3
re-enters the chat loop.
Persistence patterns
Serialize to ETF (BEAM-to-BEAM)
Best for a process-restart-safe queue or an ETS table:
binary = :erlang.term_to_binary(session)
# ... store, fetch, restart ...
session = :erlang.binary_to_term(binary)Serialize to JSON (cross-language, DB column)
ALLM.Serializer.to_json!/1 and from_json/1 round-trip without loss:
json = ALLM.Serializer.to_json!(session)
{:ok, ^session} = ALLM.Serializer.from_json(json)Useful for storing the session in a text or jsonb column in
Postgres alongside the user/conversation row.
Database column shape
defmodule MyApp.Conversation do
use Ecto.Schema
schema "conversations" do
field :session_json, :string
timestamps()
end
end
# Persist after each turn:
Ecto.Changeset.change(conv, session_json: ALLM.Serializer.to_json!(session))Restoring before the next turn:
{:ok, session} = ALLM.Serializer.from_json(conv.session_json)
{:ok, session} = ALLM.Session.reply(session, engine, user_input)The streaming reducer
Session.stream_start/3 and Session.stream_reply/4 return a stream of
events plus a final updated session. The ALLM.Session.StreamReducer
helper folds the event stream into both — useful when you want to push
events to a Phoenix LiveView or websocket while still ending up with a
persisted session.
{:ok, stream} = ALLM.Session.stream_reply(session, engine, "Hello?")
{:ok, session, events} =
ALLM.Session.StreamReducer.run(stream, fn event ->
Phoenix.PubSub.broadcast(MyApp.PubSub, "chat:#{session.id}", event)
end)The reducer hands each event to your callback (for side effects), returns the final updated session, AND collects every event into a list for replay or testing.
Round-trip safety
A session is round-trip safe iff it never carries a non-serializable value. ALLM enforces this on construction — engines (which DO carry non-serializable bits) are passed at call time, not stored on the session. Verify in your tests:
iex> engine = ALLM.Engine.new(
...> adapter: ALLM.Providers.Fake,
...> adapter_opts: [script: [{:text, "ok"}, {:finish, :stop}]]
...> )
iex> {:ok, session} = ALLM.Session.start(engine, [ALLM.user("hi")])
iex> binary = :erlang.term_to_binary(session)
iex> ^session = :erlang.binary_to_term(binary)
iex> session.status
:idleWhere to next
tools.md— for the manual tool flow that drives:halted_for_tools.streaming.md— for the event union the stream reducer folds.examples/08_session_round_trip.exs— runnable round-trip smoke test.examples/09_ask_user.exs— runnable ask-user halt and resume.examples/15_per_tool_manual_session.exs— runnable per-tool manual flow overSession.*.