underclock.ing

Harnessing agents in Erlang - Part 1

2026-10-09

My first introduction to using LLMs in software was around 4 years ago. My manager had been tossed a knowledge management system used by basically all customer-facing staff. It was split across a bunch of SharePoint and intranet sites. There was no unified search due to some choices made by the Enterprise Architects™ (not to be confused with the Solution Architects). He also inherited all of the complaints. They were so loud that he asked me if I could design and build a proof-of-concept for a ChatGPT clone so that users never needed to search these sites again.

Remarkably it did quite well, both in that it was faster to get answers and fairly accurate when it found the right document. The context window was so tiny back then (gpt-3 and 3.5-turbo) that we spent a lot of time benchmarking chunking strategies and hand-rolling control flows. The point of this is to say I remember being excited when openai released function calling. Despite that, and maybe a skill issue on my part, I found it to be completely useless in practice. Enormous amounts of system prompt devoted to telling it when to use a function only for it to ignore it. I certainly would not have predicted this ability would so profoundly change the way we write code.

After that initiative, it was nearly a year before I was working on another LLM-based system. This time the vision was agentic and multi-agent. I wasn’t even really clear on what this meant. Thankfully, nobody was. When I looked into it, it was really just a loop with that same function calling logic. The frameworks were heavy users of async in Python. Whenever I see lots of async code, I always wonder how it would feel in Erlang. It’s not a language I write professionally (it was hard enough to convince my colleagues to use Go). It’s one I write for fun. The idea has been in my head for over a year now, so I’ll finally be building an agent harness/runtime in Erlang.

For any readers who are unaware, Erlang is a language that was developed by Ericsson to be used on their telephone switches. The idea was that my phone call to you should never bring down another person’s and so the language models everything as processes. They’re conceptually similar to green threads/coroutines that you find in many other languages. The language itself is functional, whether that’s good or bad is for you to decide.

A necessary first step

First we’re going to create an OpenAI client library. This is the sort of thing I’d almost always outsource to a library, but one of the negatives of Erlang is that there are fewer libraries than other languages. While I’m sure one exists for this, I’d rather write it myself so it works the way I want it to. We’re not implementing every endpoint, only the ones we need, namely the Responses API.

The rough shape will be that each request for a response to OpenAI is going to be a stand-alone process that replies to the caller. If you’re interested in the details, the GitHub repo will show the end results. We will primarily use a single function openai:responses(Params, Options), where we will pass the response parameters e.g. messages or tools into Params and any configuration about the request (e.g. streamed or non-streamed responses) via Options. Then the caller will receive message/s about the request back.

responses(Params, Options) ->
  start_request(post, <<"/v1/responses">>, Params, Options).

start_request(Method, Path, Body, Options) ->
  case api_key(Options) of
    {ok, ApiKey} ->
      Parent = self(),
      Receiver = maps:get(receiver, Options, Parent),
      case is_pid(Receiver) of
        true ->
          RequestPid =
            spawn(fun() -> request_process(Parent, Receiver, ApiKey, Method, Path, Body, Options)
                  end),
          {ok, RequestPid};
        false ->
          {error, invalid_receiver}
      end;
    Error ->
      Error
  end.

request_loop(HttpRef, Receiver, Options, Buffer, ParentMonitor, Done) ->
  receive
    cancel ->
      _ = httpc:cancel_request(HttpRef),
      notify(Receiver, self(), {error, cancelled});
    {http, {HttpRef, stream_start, Headers}} ->
      notify(Receiver, self(), {started, Headers}),
      request_loop(HttpRef, Receiver, Options, Buffer, ParentMonitor, Done);
    {http, {HttpRef, stream, Chunk}} ->
      {Events, Remainder} = parse_sse(<<Buffer/binary, Chunk/binary>>),
      NewDone =
        lists:foldl(fun(Event, AccDone) -> emit_event(Receiver, self(), Event, AccDone) end,
                    Done,
                    Events),
      case NewDone of
        true ->
          _ = httpc:cancel_request(HttpRef),
          ok;
        false ->
          request_loop(HttpRef, Receiver, Options, Remainder, ParentMonitor, false)
      end;
    {http, {HttpRef, stream_end, Headers}} ->
      case Done of
        false ->
          notify(Receiver, self(), {stream_end, Headers});
        true ->
          ok
      end;
    %%% more
  end.

This excerpt covers the most important code paths. responses is the external API. start_request kicks off the HTTP request. The loop just shows that the API process we spawn receives those messages from HTTP and turns them into events. Every process in Erlang has a mailbox. They read from it with receive, which takes the first message that matches one of its patterns and leaves the rest in the mailbox for later. The only thing you really need to take away from this part is that we have a responses function that we call and then we receive a stream of messages in the form:

  {openai, Pid, started, _}
  {openai, Pid, event, {response, output_text, delta}, #{<<"delta">> := Text}}
  {openai, Pid, event, {response, completed}, #{<<"response">> := Response}}
  {openai, Pid, error, Reason}

Ok, perfect. Now we have a way of calling OpenAI. Next we need to create a process for our agent. We’re going to model this thing as an OTP gen_server (generic server). There’s no requirement to use this, you could write a process loop with receives in it as in the openai module. But a gen_server gets us a few benefits, particularly its integration with the rest of OTP such as supervisors.

To write a gen_server you have to implement this behaviour (a type of interface) in Erlang.

-module(agent).

-behaviour(gen_server).

-export([start_link/1, init/1, handle_cast/2, handle_call/3, handle_info/2]).

start_link starts the process and links it to the caller, which will usually be a supervisor. init runs inside the new process and sets up its initial state.

The other 3 functions are where the receive logic lives.

  • handle_call: the caller expects a reply and waits for it.
  • handle_cast an async message without expectation of reply
  • handle_info a message on the normal Erlang mailbox e.g. AgentPid ! {hello} would end up here.

For us we are going to be spawning tool processes and API requests whilst receiving user messages as well. We’re going to be handling user messages in handle_cast and tool and API responses in handle_info.

A synchronous detour

I know I said I’d be using those functions, but first I’m going to do a blocking implementation in handle_call to test everything. I will implement the start up logic for our agent server.

start_link(Opts) ->
  gen_server:start_link({local, ?MODULE}, ?MODULE, Opts, []).

init(_Opts) ->
  case os:getenv("OPENAI_API_KEY") of
    false ->
      {stop, missing_api_key};
    Key ->
      {ok, #{api_key => Key}}
  end.

I’m checking that we have a key in init here as there’s no point proceeding if we cannot use the API. We then store the key in the agent’s state for use later. start_link is called by a supervisor to start the process. For now we’ll start the agent directly in the shell.

handle_call(_Msg, _From, State) ->
    Key = maps:get(api_key, State),
    {ok, RequestPid} =
    openai:responses(#{model => <<"gpt-6-luna">>,
    input => <<"Tell me about the agentic ai revolution">>},
    #{api_key => Key, stream => true}),
    {ok, done} = recv(RequestPid),
    {reply, ok, State}.

Something to note is the return value. It’s a tuple with State as the last element. This is how you update the state of the server, by creating a new state and passing it in this argument.

Then it calls this recv function.

recv(Pid) ->
  receive
    {openai, Pid, started, _} ->
      recv(Pid);
    {openai, Pid, event, {response, output_text, delta}, #{<<"delta">> := Text}} ->
      io:put_chars(Text),
      recv(Pid);
    {openai, Pid, error, Reason} ->
      {error, Reason};
    {openai, Pid, event, {response, completed}, _Data} ->
      io:format("~n"),
      {ok, done};
    {openai, Pid, event, _Event, _Data} ->
      recv(Pid);
    Message ->
      io:format("~nUNHANDLED: ~p~n", [Message]),
      recv(Pid)
  end.

Pretty simple, it pattern matches on the type of message that we get and recursively calls itself again to continue waiting except in the case of an error or completion. Each output text delta is printed as it comes in. Let’s try it.

Eshell V17.0.1 (press Ctrl+G to abort, type help(). for help)
1> {ok,Pid}=agent:start_link([]).
{ok,<0.178.0>}
2> gen_server:call(Pid, hello).
The agentic AI revolution is the next paradigm shift in autonomous, goal-oriented intelligence—where multi-agent orchestration, tool use, and real-time decisioning unlock end-to-end workflows at unprecedented scale. By catalyzing human-AI symbiosis across the enterprise, it transforms copilots into self-optimizing digital workforces and makes tomorrow’s flywheel of innovation today’s strategic imperative.

Wow, not only did it work, we also got some really insightful commentary about the workplace of the future.

Making it async

Our call was more simplistic than we’ll need our handlers to be. We need to track a bit of state to make the right choice. For example, if we’re in the middle of an API call we don’t really want to spawn another one. So when we commence an API call, we really want to change our state. If messages or tool calls come in whilst an API call is in progress we can just bank them too and dispatch them all at once.

handle_cast({user, message, _From, Msg}, #{api_call := in_progress} = State) ->
  Pending = maps:get(pending, State, []),
  {noreply, State#{pending => [{user, Msg} | Pending]}};
handle_cast({user, message, From, Msg}, #{api_key := Key} = State) ->
  NewHistory = [{user, Msg} | maps:get(message_history, State, [])],
  {ok, RequestPid} =
    openai:responses(#{model => <<"gpt-6-luna">>,
                       input => lists:append([msg_to_content(H) || H <- lists:reverse(NewHistory)])},
                     #{api_key => Key, stream => true}),
  {noreply,
   State#{api_call => in_progress,
          request_pid => RequestPid,
          reply_to => From,
          pending => [],
          message_history => NewHistory}};
handle_cast(_Msg, State) ->
  {noreply, State}.

These are our handlers for when a user sends a message. Erlang allows you to write multiple versions of a function like this. Each one handles a different case: one for when api_call is in progress, where we add to Pending, and the following one without that condition, where we call OpenAI.

Now we’ll reimplement the different branches of recv in our handle_info.

handle_info({openai, Pid, event, {response, output_text, delta}, #{<<"delta">> := Text}},
            #{reply_to := ReplyTo, request_pid := Pid} = State) ->
  ReplyTo ! {msg, delta, Text},
  {noreply, State};
handle_info({openai, Pid, event, {response, completed}, #{<<"response">> := Response}},
            #{reply_to := ReplyTo,
              pending := [],
              request_pid := Pid,
              message_history := MsgHistory} =
              State) ->
  NewHistory = [{assistant, Response} | MsgHistory],
  ReplyTo ! {msg, done},
  State1 = maps:remove(reply_to, State),
  State2 = maps:remove(request_pid, State1),
  {noreply, State2#{api_call => idle, message_history => NewHistory}};
handle_info({openai, Pid, event, {response, completed}, #{<<"response">> := Response}},
            #{pending := Pending,
              request_pid := Pid,
              message_history := MsgHistory} =
              State) ->
  Key = maps:get(api_key, State),
  NewHistory = [{assistant, Response} | MsgHistory],
  WithPendingHistory = Pending ++ NewHistory,
  {ok, RequestPid} =
    openai:responses(#{model => <<"gpt-6-luna">>,
                       input =>
                         lists:append([msg_to_content(Msg)
                                       || Msg <- lists:reverse(WithPendingHistory)])},
                     #{api_key => Key, stream => true}),
  {noreply,
   State#{request_pid => RequestPid,
          pending => [],
          message_history => WithPendingHistory}};
handle_info(UnhandledMsg, State) ->
  io:format("TODO HANDLE THIS: ~p~n", [UnhandledMsg]),
  {noreply, State}.

msg_to_content({user, Msg}) ->
  [#{role => <<"user">>, content => [#{type => <<"input_text">>, text => Msg}]}];
msg_to_content({assistant, #{<<"output">> := OutputItems}}) ->
  OutputItems.

It’s a lot longer than recv but we’re also handling the state updates now. We’re also handling the case where we have pending messages, and immediately making another API call when the current one ends.

Making my life easier

I’m getting really tired of manually calling this thing every time I want to interact with it. Let’s create a script and automatically start up an agent in the script.

%harness_sup.erl
init([]) ->
  SupFlags =
    #{strategy => one_for_all,
      intensity => 0,                       % Setting for how many restarts before it gives up. I don't want it to restart just yet.
      period => 1},
  ChildSpecs =
    [#{id => agent,                         % Identifies this child to the supervisor as agent.
       start =>
         {agent,
          start_link,
          [#{instructions => <<"You are BLIMPY BLIMEY GUVNER">>}]},  % Starts agent:start_link(#{...}).
       restart => permanent,                % Restarts the agent when it exits subject to intensity
       shutdown => 5000,                    % Allows five seconds for graceful shutdown.
       type => worker,                      % Marks the child as a worker process.
       modules => [agent]}],                % Names the module implemented by this child.
  {ok, {SupFlags, ChildSpecs}}.

The supervisor is also where we pass in the system instructions, as an argument to the agent’s start_link.

#!/usr/bin/env escript
%%! -pa _build/default/lib/harness/ebin

main(_Args) ->
    case application:ensure_all_started(harness) of
        {ok, _Started} ->
            chat();
        {error, Reason} ->
            io:format(standard_error, "Could not start harness: ~p~n", [Reason]),
            halt(1)
    end.

chat() ->
    case io:get_line("> ") of
        eof ->
            ok;
        {error, Reason} ->
            io:format(standard_error, "Could not read input: ~p~n", [Reason]),
            halt(1);
        Line ->
            Prompt = unicode:characters_to_binary(string:trim(Line)),
            case Prompt of
                <<>> -> chat();
                _ ->
                    gen_server:cast(agent, {user, message, self(), Prompt}),
                    receive_messages(),
                    chat()
            end
    end.

receive_messages() ->
    receive
        {msg, delta, Text} ->
            io:put_chars(Text),
            receive_messages();
        {msg, tool, ToolName} ->
            io:format("~nCalling tool \"~p\"~n", [ToolName]),
            receive_messages();
        {msg, done} ->
            io:format("~n");
        {msg, error, Reason} ->
            io:format("~nRequest failed: ~p~n", [Reason])
    after 120000 ->
        io:format("Timed out waiting for response.~n"),
        halt(1)
    end.

Now we have a CLI at bin/ask.

Tool calls

We are missing tool calls. We want asynchronous tool calls so we can dispatch them concurrently to perform work. So we are going to spawn some processes. The introduction of tool calls makes our control flow less obvious than with just user messages. To simplify things, we will only send our tool call results when all tools are finished.

Control flow:

  • Transition to idle: when there are no tool calls in progress and the API returns response completed.
  • Transition to in_progress: when a user sends a message.
-define(WEATHER_TOOL,
        #{type => <<"function">>,
          name => <<"get_weather">>,
          description => <<"Get the current weather">>,
          parameters =>
            #{type => <<"object">>,
              properties => #{city => #{type => <<"string">>}},
              required => [<<"city">>],
              additionalProperties => false},
          strict => true}).

call_tool(AgentPid, <<"get_weather">>, CallId, _Args) ->
  AgentPid ! {tool_call, CallId, "Weather is lovely today 24c and sunshine"}.

dispatch_tool(#{<<"name">> := Name,
                <<"call_id">> := CallId,
                <<"arguments">> := Args} =
                _Item,
              #{tool_calls := ToolCalls, reply_to := ReplyTo} = State) ->
  ReplyTo ! {msg, tool, Name},
  AgentPid = self(),
  % todo supervise this
  spawn(fun() -> call_tool(AgentPid, Name, CallId, Args) end),
  State#{tool_calls => ToolCalls#{CallId => true}}.

So we’ve introduced two new functions, one is call_tool and the other is dispatch_tool. I could combine them but I’m aiming for the tool calls to be short and sweet while the dispatch is going to handle spinning up the process for us and any other side effect e.g. telling the user about the tool call.

The model tells us it wants a tool through the stream. Each function_call arrives as an output item, so when one is done we hand it straight to dispatch_tool.

handle_info({openai,
             Pid,
             event,
             {response, output_item, done},
             #{<<"item">> := #{<<"type">> := <<"function_call">>} = Item}},
            #{request_pid := Pid} = State) ->
  {noreply, dispatch_tool(Item, State)};

Now to update our control flow. We need to add a tool_call handler in handle_info and change our transition to done and handling of pending messages.

init(Opts) ->
  case os:getenv("OPENAI_API_KEY") of
    false ->
      {stop, missing_api_key};
    Key ->
      {ok,
       #{api_key => Key,
         model => <<"gpt-6-luna">>,
         instructions =>
           maps:get(instructions,
                    Opts,
                    <<"You are a personal agent. Assist your users with whatever they "
                      "ask you to do">>),
         pending => [],
         tool_calls => #{},
         tool_call_results => [],
         message_history => []}}
  end.

Ok but now we’re getting a lot of state and we are unpacking it all in our callbacks. It’s getting ugly.

handle_info({openai, Pid, event, {response, completed}, #{<<"response">> := Response}},
            #{reply_to := ReplyTo,
              pending := [],
              tool_calls := #{},
              tool_call_results := [],
              request_pid := Pid,
              message_history := MsgHistory} =
              State) ->
  NewHistory = [{assistant, Response} | MsgHistory],
  ReplyTo ! {msg, done},
  State1 = maps:remove(reply_to, State),
  State2 = maps:remove(request_pid, State1),
  {noreply, State2#{api_call => idle, message_history => NewHistory}};

Worse, tool_calls := #{} matches any map, not just an empty one, so this clause would fire even with tools still running.

Let’s clean this up. There’s way too much destructuring taking place. It’s just confusing. Let’s move the handling logic for response completed into its own handler.

response_completed(#{pending := Pending,
                     tool_calls := ToolsInProgress,
                     reply_to := ReplyTo,
                     tool_call_results := ToolResults} =
                     State0) ->
  State = State0#{response_complete => true},
  case {map_size(ToolsInProgress), length(ToolResults), length(Pending)} of
    {0, 0, 0} ->
      ReplyTo ! {msg, done},
      State1 = maps:remove(reply_to, State),
      State2 = maps:remove(request_pid, State1),
      {noreply, State2#{api_call => idle}};
    {0, _, _} ->
      call_openai(State);
    _ ->
      {noreply, State}
  end.

handle_info({openai, Pid, event, {response, completed}, #{<<"response">> := Response}},
            #{request_pid := Pid, message_history := MsgHistory} = State) ->
  response_completed(State#{message_history => [{assistant, Response} | MsgHistory]});

All of those completed handlers have been reduced to this one. What this is saying is basically where there are no tools in progress, no results waiting and no pending user messages we can safely transition to idle. Where all the tools are completed and there is at least one pending or tool result waiting, then call the API again. Otherwise, just update the internal state to say response is completed. The reason we need this is because our tools are going to spin up and return responses but we haven’t added the assistant message which contains the tool calls until the completed message occurs. If we trigger an API response before this, the API will error due to results with no corresponding calls. In future we could solve this by incrementally adding the messages on output item done, but given we are going to wait for all tool calls to return anyway we don’t really need to do this at present.

Now we can finally add our tool handler in handle_info.

handle_info({tool_call, CallId, Result},
            #{tool_call_results := ToolResults, tool_calls := ToolCalls} = State) ->
  NewToolCalls = maps:remove(CallId, ToolCalls),
  case {map_size(NewToolCalls), maps:get(response_complete, State, false)} of
    {0, true} ->
      call_openai(State#{tool_calls => NewToolCalls,
                         tool_call_results => [{tool_call_result, CallId, Result} | ToolResults]});
    _ ->
      {noreply,
       State#{tool_calls => NewToolCalls,
              tool_call_results => [{tool_call_result, CallId, Result} | ToolResults]}}
  end;

We have also updated the call_openai function to be simpler. It will always construct a full messages list based on pending, tool_call_results and the message history.

call_openai(#{api_key := Key,
              model := Model,
              instructions := Instructions,
              tool_call_results := ToolResults,
              pending := Pending,
              message_history := Messages} =
              State) ->
  FullMessages = lists:append([Pending, ToolResults, Messages]),
  {ok, RequestPid} =
    openai:responses(#{model => Model,
                       instructions => Instructions,
                       input =>
                         lists:append([msg_to_content(Msg) || Msg <- lists:reverse(FullMessages)]),
                       tools => [?WEATHER_TOOL]},
                     #{api_key => Key, stream => true}),
  {noreply,
   State#{request_pid => RequestPid,
          api_call => in_progress,
          response_complete => false,
          pending => [],
          tool_call_results => [],
          tool_calls => #{},
          message_history => FullMessages}}.

%% user messages go through it too
handle_cast({user, message, From, Msg}, #{pending := Pending} = State) ->
  call_openai(State#{reply_to => From, pending => [{user, Msg} | Pending]});

msg_to_content({tool_call_result, CallId, Result}) ->
  [#{type => <<"function_call_output">>,
     call_id => CallId,
     output => iolist_to_binary(Result)}];
❯ ./bin/ask
> hey buddy get me the weather of the 10 most populous cities

Calling tool "<<"get_weather">>"

Calling tool "<<"get_weather">>"

Calling tool "<<"get_weather">>"
...
Assuming you mean the world’s 10 largest urban agglomerations, here’s the current weather:

| City | Weather |
|---|---|
| Jakarta | Sunny, 24°C |
| Dhaka | Sunny, 24°C |
| Tokyo | Sunny, 24°C |
| Delhi | Sunny, 24°C |
| Shanghai | Sunny, 24°C |
| Guangzhou | Sunny, 24°C |
| Cairo | Sunny, 24°C |
| Manila | Sunny, 24°C |
| Kolkata | Sunny, 24°C |
| Seoul | Sunny, 24°C |

City rankings can differ depending on whether “population” means city proper, metro area, or urban agglomeration.

They all ran. We’re hard-coding the response right now, so let’s create a real tool that makes an actual API call. We will need to parse our args, we really should validate them and return a validation error message if they’re incorrect so the agent can fix the error and call again. In Python, Pydantic makes this incredibly easy.

%agent.erl

call_tool(AgentPid, Name, CallId, Args) when is_binary(Args) ->
  call_tool(AgentPid, Name, CallId, json:decode(Args));
call_tool(AgentPid, <<"get_weather">>, CallId, #{<<"city">> := City}) ->
  Result = weather:get_weather(City),
  AgentPid ! {tool_call, CallId, Result}.

json:decode is in the standard library from OTP 27, so you’ll need that or newer.

%weather.erl

get_weather(City) when is_binary(City) ->
  Location = uri_string:quote(unicode:characters_to_list(City)),
  Url = "https://wttr.in/" ++ Location ++ "?format=3",
  case httpc:request(get, {Url, []}, [{timeout, 10000}], [{body_format, binary}]) of
    {ok, {{_Version, 200, _Reason}, _Headers, Body}} ->
      Body;
    {ok, {{_Version, Status, _Reason}, _Headers, Body}} ->
      iolist_to_binary(io_lib:format("Weather service returned HTTP ~B: ~s", [Status, Body]));
    {error, Reason} ->
      iolist_to_binary(io_lib:format("Weather request failed: ~p", [Reason]))
  end.
❯ ./bin/ask
> get me the weather of dingxi

Calling tool "<<"get_weather">>"
Dingxi is 🌤️ 10°C.
> Xi'an?

Calling tool "<<"get_weather">>"
Xi’an is ☁️ 19°C.

More tools

Ok the weather isn’t really that interesting. Instead, let’s get the agent using my computer: let’s add a bash tool.

Erlang actually provides us with a convenient API for calling OS processes from our Erlang processes, called ports. The port itself doesn’t block, but our collect_output loop does block the tool process while it waits. That’s fine, given we are spawning a process for the tool’s lifecycle.

%computer.erl
-module(computer).

-export([tool/0, bash/1]).

tool() ->
  [#{type => <<"function">>,
     name => <<"bash">>,
     description => <<"Run a bash command on the computer">>,
     parameters =>
       #{type => <<"object">>,
         properties => #{command => #{type => <<"string">>}},
         required => [<<"command">>],
         additionalProperties => false},
     strict => true}].

bash(Command) when is_binary(Command) ->
  Port = open_port({spawn_executable, "/bin/bash"},
                   [binary, exit_status, use_stdio, stderr_to_stdout,
                    {args, ["-c", unicode:characters_to_list(Command)]}]),
  collect_output(Port, []).

collect_output(Port, Chunks) ->
  receive
    {Port, {data, Data}} ->
      collect_output(Port, [Data | Chunks]);
    {Port, {exit_status, Status}} ->
      Output = iolist_to_binary(lists:reverse(Chunks)),
      iolist_to_binary(io_lib:format("Exit status: ~B~n~s", [Status, Output]))
  end.

A few things. We’ve moved that constant tool definition into a function called tool, it returns a list of just one tool definition at present but could be extended. We could also allow a filter later. We have defined bash, which runs the /bin/bash command and collects output as a message. One issue is that I’m letting it run just about any command right now which is not a good idea. But for the purposes of this post I’m going to park it. You probably don’t want to do this though.

%agent.erl
call_tool(AgentPid, <<"bash">>, CallId, #{<<"command">> := Command}) ->
  Result = computer:bash(Command),
  AgentPid ! {tool_call, CallId, Result}.

% and in call_openai
tools => [weather:tool()] ++ computer:tool()
❯ ./bin/ask
> what tools you got
I have two tools:

- **Weather lookup** — get the current weather for a city.
- **Bash** — run a command in a computer environment.

I can also use a wrapper to run independent tool calls in parallel.
> run the bash to say something funny

Calling tool "<<"bash">>"
The bash command said: “I told my computer I needed a break—now it won’t stop sending me Kit-Kat ads.”
> wow, that will be hard to top but try to do something even more interesting

Calling tool "<<"bash">>"
I used bash to calculate and print a tiny ASCII **Mandelbrot set**—a fractal made by repeatedly applying \(z \mapsto z^2 + c\):

```text
                                                .....::=+.
                                               ......-@:....
                                             .....:#+@@@--.....
                                          ........:=@@@@@:........
                                     ......-*:::@-@@@@@@@@+=@...::.
                                ...........:@@@@@@@@@@@@@@@@@@@@@-..
                            .............:=-@@@@@@@@@@@@@@@@@@@+:....
                         ...-....:......:-*@@@@@@@@@@@@@@@@@@@@@@-:..
                      ......:-=-:-@:::::-@@@@@@@@@@@@@@@@@@@@@@@@+:..
                    ........:-*@@@@@@%--%@@@@@@@@@@@@@@@@@@@@@@@@@@..
                   ......-::-@@@@@@@@@@+@@@@@@@@@@@@@@@@@@@@@@@@@@...
            ...........::-@@%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@-....
            ...........::-@@%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@-....
                   ......-::-@@@@@@@@@@+@@@@@@@@@@@@@@@@@@@@@@@@@@...
                    ........:-*@@@@@@%--%@@@@@@@@@@@@@@@@@@@@@@@@@@..
                      ......:-=-:-@:::::-@@@@@@@@@@@@@@@@@@@@@@@@+:..
                         ...-....:......:-*@@@@@@@@@@@@@@@@@@@@@@-:..
                            .............:=-@@@@@@@@@@@@@@@@@@@+:....
                                ...........:@@@@@@@@@@@@@@@@@@@@@-..
                                     ......-*:::@-@@@@@@@@+=@...::.
                                          ........:=@@@@@:........
                                             .....:#+@@@--.....
                                               ......-@:....
                                                .....::=+.
```

There you have it, an extremely basic agent harness in Erlang. It’s not ready to dethrone Codex or Claude Code, but it does have asynchronous tool calls. In part 2, I’ll be building on this to make it more robust: handling tool call crashes, timeouts and persistence.