Toolnexus.Client conversation
Elixir · package toolnexus · SPEC §8 · elixir/lib/toolnexus/client.ex
# low-level, stateless primitive — you carry the transcript@spec run(Toolnexus.Client.t(), String.t(), term(), keyword()) :: Toolnexus.Client.RunResult.t()def run(client, prompt, toolkit, opts \\ []) # opts: history: [map()]
# durable, id-keyed primitive — the client carries the transcript via a ConversationStore@spec ask(Toolnexus.Client.t(), String.t(), term(), keyword() | String.t()) :: Toolnexus.Client.RunResult.t()def ask(client, prompt, toolkit, opts \\ []) # opts: id: String.t(), on_text: (String.t() -> any())There is no Client.conversation/1 object in the Elixir port — the other five ports wrap
multi-turn memory as a stateful Conversation value; here it collapses to two functions you
already have. run/4’s history: option is the stateless primitive (you hold the transcript);
ask/4 is the stateful, id-keyed one built on top of it and a
ConversationStore (you hold only an id).
When to use it
Section titled “When to use it”- One turn, then you’re done with the transcript — plain
run/4, nohistory:needed. - You already have
RunResult.messagesfrom a previous call and want to continue it once —run(client, next_prompt, toolkit, history: prior.messages). No store, no id. - A conversation keyed by something durable — a user id, a chat thread id, an A2A
contextId—ask(client, prompt, toolkit, id: id). The client loads that id’s transcript, runs, and saves the update back, every time. - You want text streamed to a callback but still need the final
RunResult—ask/4’s:on_textoption; the block-style alternative to consumingstream/4’s iterator yourself.
Why this and not the alternative
Section titled “Why this and not the alternative”ask/4 and run/4 behave identically for openai and anthropic style — the store never sees
provider-specific shape, only whatever RunResult.messages already is.
Examples
Section titled “Examples”1. The smallest useful call — ask/4 with no :id is just run/4
Section titled “1. The smallest useful call — ask/4 with no :id is just run/4”Without an id, ask/4 is a stateless one-shot — the store is never touched.
alias Toolnexus.Client
plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) resp = %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "hello"}}], "usage" => %{"prompt_tokens" => 2, "completion_tokens" => 1, "total_tokens" => 3}} conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp))end
client = Client.create(base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug])
via_ask = Client.ask(client, "hi", [])via_run = Client.run(client, "hi", [])
true = via_ask.text == via_run.texttrue = via_ask.text == "hello"
# Nothing was saved — no id was given.store = Client.conversation_store(client)true = Toolnexus.Client.InMemoryConversationStore.get(store, "hi") == nil
IO.puts("ok: ask/4 with no :id == run/4 (#{via_ask.text})")2. The realistic case — ask/4 with :id, the durable path
Section titled “2. The realistic case — ask/4 with :id, the durable path”The second ask/4 for the same id continues the conversation: its request carries turn one’s
messages plus the new prompt, with no history: passed by hand.
alias Toolnexus.Client
{:ok, bodies} = Agent.start_link(fn -> [] end){:ok, calls} = Agent.start_link(fn -> 0 end)
plug = fn conn -> {:ok, raw, conn} = Plug.Conn.read_body(conn) body = Jason.decode!(raw) Agent.update(bodies, fn b -> b ++ [body] end) n = Agent.get_and_update(calls, fn c -> {c + 1, c + 1} end)
text = if n == 1, do: "42, got it", else: "you said 42" resp = %{"choices" => [%{"message" => %{"role" => "assistant", "content" => text}}], "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 2, "total_tokens" => 5}} conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp))end
client = Client.create(base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug])
first = Client.ask(client, "remember 42", [], id: "thread-1")true = first.text == "42, got it"
second = Client.ask(client, "what did I say?", [], "thread-1")true = second.text == "you said 42"
[_first_body, second_body] = Agent.get(bodies, & &1)true = length(second_body["messages"]) == 3true = List.last(second_body["messages"]) == %{"role" => "user", "content" => "what did I say?"}
IO.puts("ok: turn 2 saw #{length(second_body["messages"])} messages from turn 1's transcript")3. The full surface — :on_text streams deltas but still returns a RunResult
Section titled “3. The full surface — :on_text streams deltas but still returns a RunResult”on_text makes ask/4 internally stream, forwarding each text delta to your callback, while the
return value stays the ordinary final RunResult — the return type never changes based on
whether you streamed.
alias Toolnexus.Client
sse = """data: {"choices":[{"delta":{"content":"The "}}]}
data: {"choices":[{"delta":{"content":"answer is 9."}}]}
data: {"choices":[{"delta":{}}],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}}
data: [DONE]
"""
plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) conn |> Plug.Conn.put_resp_content_type("text/event-stream") |> Plug.Conn.send_resp(200, sse)end
client = Client.create(base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug])
{:ok, deltas} = Agent.start_link(fn -> [] end)on_text = fn delta -> Agent.update(deltas, fn d -> d ++ [delta] end) end
result = Client.ask(client, "what is 4+5?", [], on_text: on_text)
true = Agent.get(deltas, & &1) == ["The ", "answer is 9."]true = result.text == "The answer is 9."true = result.status == "done"
IO.puts("ok: streamed #{length(Agent.get(deltas, & &1))} delta(s), returned final text #{inspect(result.text)}")Options
Section titled “Options”| Where | Option | What it does |
|---|---|---|
run/4 |
:history |
A prior RunResult.messages to continue. Stateless — you carry it. |
ask/4 |
:id |
Loads that id’s transcript from the client’s store, runs, saves the update back. Omit ⇒ stateless, identical to run/4. |
ask/4 |
:on_text |
(delta :: String.t() -> any()), invoked per text delta while ask/4 streams internally. Omit ⇒ non-streaming. Return value is still a RunResult. |
See also
Section titled “See also”Toolnexus.Client.run— the stateless loopask/4is built on.Toolnexus.Client.stream— the iteratorask/4’s:on_textconsumes internally.Toolnexus.Client.InMemoryConversationStore— the default store, and how to swap in your own.Toolnexus.Client.create—:storeis set here.