Memory & conversations
A single run() is stateless — each call starts fresh. Real assistants need to remember the
thread. ask(prompt, id) loads that conversation’s transcript from a ConversationStore, runs
the loop with it as history, and saves the updated transcript back. The next ask with the same
id continues where it left off.
Remember a thread with an id
Section titled “Remember a thread with an id”const agent = createClient({ baseUrl, style: "openai", model }) // in-memory store by default
await agent.ask("Book me a flight to Berlin.", { toolkit: tk, id: "user-42" })await agent.ask("Actually, make it Munich.", { toolkit: tk, id: "user-42" }) // same thread — rememberedawait agent.ask("What is 21 + 21?", { toolkit: tk }) // no id → one-shotagent = create_client(base_url=base, style="openai", model=model) # in-memory store by default
await agent.ask("Book me a flight to Berlin.", tk, id="user-42")await agent.ask("Actually, make it Munich.", tk, id="user-42") # same thread — rememberedawait agent.ask("What is 21 + 21?", tk) # no id → one-shotagent := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: baseURL, Style: toolnexus.StyleOpenAI, Model: model,})
agent.Ask(ctx, "Book me a flight to Berlin.", tk, "user-42")agent.Ask(ctx, "Actually, make it Munich.", tk, "user-42") // same thread — rememberedagent.Ask(ctx, "What is 21 + 21?", tk, "") // empty id → one-shotLlmClient agent = LlmClient.create(new LlmClient.Options() .baseUrl(base) .style("openai") .model(model));
agent.ask("Book me a flight to Berlin.", tk, "user-42");agent.ask("Actually, make it Munich.", tk, "user-42"); // same thread — rememberedagent.ask("What is 21 + 21?", tk, null); // null id → one-shotvar agent = LlmClient.Create(new LlmClient.Options { BaseUrl = baseUrl, Style = "openai", Model = model,});
await agent.AskAsync("Book me a flight to Berlin.", tk, "user-42");await agent.AskAsync("Actually, make it Munich.", tk, "user-42"); // same thread — rememberedawait agent.AskAsync("What is 21 + 21?", tk); // no id → one-shotagent = Toolnexus.Client.create(base_url: base, style: "openai", model: model) # in-memory store by default
Toolnexus.Client.ask(agent, "Book me a flight to Berlin.", tk, "user-42")Toolnexus.Client.ask(agent, "Actually, make it Munich.", tk, "user-42") # same thread — rememberedToolnexus.Client.run(agent, "What is 21 + 21?", tk) # run/3 → one-shot(require '[toolnexus.client :as client])
;; the client carries a per-client store (in-memory by default); the thread id;; is an option on run rather than a separate entry point(def agent (client/create-client {:base-url base-url :style "openai" :model model}))
(client/run agent "Book me a flight to Berlin." {:toolkit tk :conversation-id "user-42"})(client/run agent "Actually, make it Munich." {:toolkit tk :conversation-id "user-42"}) ; remembered(client/run agent "What is 21 + 21?" {:toolkit tk}) ; no :conversation-id → one-shotNo id ⇒ a stateless one-shot, identical to run. That’s the whole memory model: an id is a thread.
Pluggable store — persist across processes
Section titled “Pluggable store — persist across processes”The default store keeps transcripts in memory for the client’s lifetime. To survive restarts or
share a thread across processes, implement two methods — get(id) → messages and
save(id, messages) — over a file, database, or Redis, and pass it to the client.
import { createClient, type ConversationStore } from "toolnexus"
const redisStore: ConversationStore = { async get(id) { const raw = await redis.get(`conv:${id}`) return raw ? JSON.parse(raw) : undefined }, async save(id, messages) { await redis.set(`conv:${id}`, JSON.stringify(messages)) },}
const agent = createClient({ baseUrl, style: "openai", model, store: redisStore })from toolnexus import create_client, ConversationStore
class RedisStore(ConversationStore): async def get(self, id): raw = await redis.get(f"conv:{id}") return json.loads(raw) if raw else None async def save(self, id, messages): await redis.set(f"conv:{id}", json.dumps(messages))
agent = create_client(base_url=base, style="openai", model=model, store=RedisStore())type redisStore struct{ /* client */ }func (s redisStore) Get(id string) ([]any, error) { /* load + json.Unmarshal */ }func (s redisStore) Save(id string, msgs []any) error { /* json.Marshal + store */ }
agent := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: baseURL, Style: toolnexus.StyleOpenAI, Model: model, Store: redisStore{},})ConversationStore redisStore = new ConversationStore() { public List<Object> get(String id) { /* load + parse */ } public void save(String id, List<Object> messages) { /* serialize + store */ }};
LlmClient agent = LlmClient.create(new LlmClient.Options() .baseUrl(base).style("openai").model(model).store(redisStore));public sealed class RedisStore : IConversationStore{ public Task<List<object?>?> GetAsync(string id) { /* load + deserialize */ } public Task SaveAsync(string id, List<object?> messages) { /* serialize + store */ }}
var agent = LlmClient.Create(new LlmClient.Options{ BaseUrl = baseUrl, Style = "openai", Model = model, Store = new RedisStore(),});defmodule RedisStore do @behaviour Toolnexus.Client.ConversationStore defstruct []
@impl true def get(%__MODULE__{}, id) do case Redis.get("conv:#{id}") do nil -> nil raw -> Jason.decode!(raw) end end
@impl true def save(%__MODULE__{}, id, messages), do: Redis.set("conv:#{id}", Jason.encode!(messages))end
agent = Toolnexus.Client.create(base_url: base, style: "openai", model: model, store: %RedisStore{})(require '[koine.json :as json])
;; A store is exactly two functions — any map with these keys is a store.(defn redis-store [] {:get (fn [id] (some-> (redis-get (str "conv:" id)) json/read-str)) :save (fn [id messages] (redis-set! (str "conv:" id) (json/write-str messages)) nil)})
(def agent (client/create-client {:base-url base-url :style "openai" :model model :store (redis-store)}))Streaming remembers too
Section titled “Streaming remembers too”The same id works on the streaming paths. Pass on_text to ask to stream assistant deltas while
ask still returns the final result, or use stream(prompt, { toolkit, id }) to iterate events
(text / tool_call / tool_result / usage / done). With an id, the thread is loaded before
streaming and saved on the terminal done event. (The Clojure port has no streaming loop — see
Streaming; memory there is the :conversation-id option on run.)
When you’d rather own the transcript
Section titled “When you’d rather own the transcript”The lower-level run(prompt, { toolkit, history }) takes an explicit history array, and a stateful
client.conversation({ toolkit }) wrapper retains history across send calls — both there when you
want to manage the transcript yourself instead of by id.