LLM Agent
LLM Agent
Section titled “LLM Agent”Build a terminal chat agent step by step, progressing from a simple LLM call to a streaming agent with tools.
What We’re Building
Section titled “What We’re Building”A terminal chat agent that:
- Generates text with an LLM
- Maintains multi-turn conversations
- Streams responses in real-time
- Uses tools to access external capabilities
Project Structure
Section titled “Project Structure”llm-agent/├── .wippy.yaml├── wippy.lock└── src/ ├── _index.yaml ├── ask.lua ├── chat.lua └── tools/ ├── _index.yaml ├── current_time.lua └── calculate.luaPhase 1: Simple Generation
Section titled “Phase 1: Simple Generation”Start with a basic function that calls llm.generate() with a string prompt.
Create the Project
Section titled “Create the Project”mkdir llm-agent && cd llm-agentmkdir -p srcEntry Definitions
Section titled “Entry Definitions”Create src/_index.yaml:
version: "1.0"namespace: app
entries: - name: os_env kind: env.storage.os
- name: processes kind: process.host lifecycle: auto_start: true
- name: dep.llm kind: ns.dependency component: wippy/llm version: "*" parameters: - name: env_storage value: app:os_env - name: process_host value: app:processes
- name: ask kind: function.lua source: file://ask.lua method: handler imports: llm: wippy.llm:llmThe LLM module needs two infrastructure entries:
env.storage.osprovides API keys from environment variablesprocess.hostprovides the process runtime the LLM module uses internally
Generation Code
Section titled “Generation Code”Create src/ask.lua:
local llm = require("llm")
local function handler(input) local response, err = llm.generate(input, { model = "gpt-4.1-nano", temperature = 0.7, max_tokens = 512, })
if err then return nil, err end
return response.resultend
return { handler = handler }Model Definition
Section titled “Model Definition”The LLM module resolves models from the registry. Add a model entry to _index.yaml:
- name: gpt-4.1-nano kind: registry.entry meta: name: gpt-4.1-nano type: llm.model title: GPT-4.1 Nano comment: Fast, affordable model capabilities: - generate - tool_use - structured_output class: - fast priority: 100 max_tokens: 1047576 output_tokens: 32768 pricing: input: 0.1 output: 0.4 providers: - id: wippy.llm.openai:provider provider_model: gpt-4.1-nanoInitialize and Test
Section titled “Initialize and Test”wippy initwippy run -x app:ask "What is the capital of France?"This calls the function directly and prints the result. The model definition tells the LLM module which provider to use and what model name to send to the API.
Phase 2: Conversations
Section titled “Phase 2: Conversations”Upgrade from a single call to a multi-turn conversation using the prompt builder. Change the entry from a function to a process with terminal I/O.
Update Entry Definitions
Section titled “Update Entry Definitions”Replace the ask entry with a chat process and add the terminal dependency:
- name: dep.terminal kind: ns.dependency component: wippy/terminal version: "*"
- name: chat kind: process.lua meta: command: name: chat short: Start a terminal chat source: file://chat.lua method: main modules: - io - process imports: llm: wippy.llm:llm prompt: wippy.llm:promptChat Process
Section titled “Chat Process”Create src/chat.lua:
local io = require("io")local llm = require("llm")local prompt = require("prompt")
local function main() io.print("Chat (type 'quit' to exit)") io.print("")
local conversation = prompt.new() conversation:add_system("You are a helpful assistant. Be concise and direct.")
while true do io.write("> ") io.flush() local input = io.readline() if not input or input == "quit" or input == "exit" then break end if input == "" then goto continue end
conversation:add_user(input)
local response, err = llm.generate(conversation, { model = "gpt-4.1-nano", temperature = 0.7, max_tokens = 1024, })
if err then io.print("Error: " .. tostring(err)) goto continue end
io.print(response.result) io.print("") conversation:add_assistant(response.result)
::continue:: end
io.print("Bye!")end
return { main = main }Run It
Section titled “Run It”wippy updatewippy run chatThe prompt builder maintains the full conversation history. Each turn appends the user message and assistant response, giving the model context of prior exchanges.
Phase 3: Agent Framework
Section titled “Phase 3: Agent Framework”The agent module provides a higher-level abstraction over raw LLM calls. Agents are defined declaratively with a prompt, model, and tools, then loaded and executed through a context/runner pattern.
Add Agent Dependency
Section titled “Add Agent Dependency”Add to _index.yaml:
- name: dep.agent kind: ns.dependency component: wippy/agent version: "*" parameters: - name: process_host value: app:processesDefine an Agent
Section titled “Define an Agent”Add an agent entry:
- name: assistant kind: registry.entry meta: type: agent.gen1 name: assistant title: Assistant comment: Terminal chat agent prompt: | You are a helpful terminal assistant. Be concise and direct. Answer questions clearly. If you don't know something, say so. Do not use emoji in responses. model: gpt-4.1-nano max_tokens: 1024 temperature: 0.7Update the Chat Process
Section titled “Update the Chat Process”Switch to the agent framework. Update the entry imports:
- name: chat kind: process.lua meta: command: name: chat short: Start a terminal chat source: file://chat.lua method: main modules: - io - process imports: prompt: wippy.llm:prompt agent_context: wippy.agent:contextUpdate src/chat.lua:
local io = require("io")local prompt = require("prompt")local agent_context = require("agent_context")
local function main() io.print("Chat (type 'quit' to exit)") io.print("")
local ctx = agent_context.new() local runner, err = ctx:load_agent("app:assistant") if err then io.print("Failed to load agent: " .. tostring(err)) return end
local conversation = prompt.new()
while true do io.write("> ") io.flush() local input = io.readline() if not input or input == "quit" or input == "exit" then break end if input == "" then goto continue end
conversation:add_user(input)
local response, gen_err = runner:step(conversation) if gen_err then io.print("Error: " .. tostring(gen_err)) goto continue end
io.print(response.result) io.print("") conversation:add_assistant(response.result)
::continue:: end
io.print("Bye!")end
return { main = main }The agent framework separates the agent definition (prompt, model, parameters) from the execution logic. The same agent can be loaded with different contexts, tools, and models at runtime.
Phase 4: Streaming
Section titled “Phase 4: Streaming”Stream responses token-by-token instead of waiting for the full response.
Streaming Implementation
Section titled “Streaming Implementation”Update src/chat.lua:
local io = require("io")local prompt = require("prompt")local agent_context = require("agent_context")
local STREAM_TOPIC = "stream"
local function stream_response(runner, conversation, stream_ch) local done_ch = channel.new(1)
coroutine.spawn(function() local response, err = runner:step(conversation, { stream_target = { reply_to = process.pid(), topic = STREAM_TOPIC, }, }) done_ch:send({ response = response, err = err }) end)
local full_text = ""
while true do local result = channel.select({ stream_ch:case_receive(), done_ch:case_receive(), }) if not result.ok then break end
if result.channel == done_ch then local r = result.value return full_text, r.response, r.err end
local chunk = result.value if chunk.type == "chunk" then io.write(chunk.content or "") full_text = full_text .. (chunk.content or "") elseif chunk.type == "done" then local r, ok = done_ch:receive() if ok and r then return full_text, r.response, r.err end return full_text, nil, nil elseif chunk.type == "error" then return nil, nil, chunk.error and chunk.error.message or "stream error" end end
return full_text, nil, nilend
local function main() io.print("Chat (type 'quit' to exit)") io.print("")
local ctx = agent_context.new() local runner, err = ctx:load_agent("app:assistant") if err then io.print("Failed to load agent: " .. tostring(err)) return end
local conversation = prompt.new() local stream_ch = process.listen(STREAM_TOPIC)
while true do io.write("> ") io.flush() local input = io.readline() if not input or input == "quit" or input == "exit" then break end if input == "" then goto continue end
conversation:add_user(input)
local text, _, gen_err = stream_response(runner, conversation, stream_ch) if gen_err then io.print("Error: " .. tostring(gen_err)) goto continue end
io.print("") if text and text ~= "" then conversation:add_assistant(text) end
::continue:: end
process.unlisten(stream_ch) io.print("Bye!")end
return { main = main }Key patterns:
coroutine.spawnrunsrunner:step()in a separate coroutine so the main coroutine can process stream chunkschannel.selectmultiplexes the stream channel and done channel- A single
process.listen()is created once and reused across turns - Text is accumulated for adding to the conversation history
Phase 5: Tools
Section titled “Phase 5: Tools”Give the agent tools it can call to access external capabilities.
Define Tools
Section titled “Define Tools”Create src/tools/_index.yaml:
version: "1.0"namespace: app.tools
entries: - name: current_time kind: function.lua meta: type: tool title: Current Time input_schema: | { "type": "object", "properties": {}, "additionalProperties": false } llm_alias: get_current_time llm_description: Get the current date and time in UTC. source: file://current_time.lua modules: [time] method: handler
- name: calculate kind: function.lua meta: type: tool title: Calculate input_schema: | { "type": "object", "properties": { "expression": { "type": "string", "description": "Math expression to evaluate" } }, "required": ["expression"], "additionalProperties": false } llm_alias: calculate llm_description: Evaluate a mathematical expression and return the result. source: file://calculate.lua modules: [expr] method: handlerTool metadata tells the LLM what the tool does:
input_schemais a JSON Schema defining the argumentsllm_aliasis the function name the LLM seesllm_descriptionexplains when to use the tool
Implement Tools
Section titled “Implement Tools”Create src/tools/current_time.lua:
local time = require("time")
local function handler() local now = time.now() return { utc = now:format("2006-01-02T15:04:05Z"), unix = now:unix(), }end
return { handler = handler }Create src/tools/calculate.lua:
local expr = require("expr")
local function handler(args) local result, err = expr.eval(args.expression) if err then return { error = tostring(err) } end return { result = result }end
return { handler = handler }Register Tools with the Agent
Section titled “Register Tools with the Agent”Update the agent entry in src/_index.yaml to reference the tools:
- name: assistant kind: registry.entry meta: type: agent.gen1 name: assistant title: Assistant comment: Terminal chat agent prompt: | You are a helpful terminal assistant. Be concise and direct. Answer questions clearly. If you don't know something, say so. Use tools when they help answer the question. Do not use emoji in responses. model: gpt-4.1-nano max_tokens: 1024 temperature: 0.7 tools: - app.tools:current_time - app.tools:calculateAdd Tool Execution
Section titled “Add Tool Execution”Update the chat process modules to include json and funcs:
modules: - io - json - process - funcsUpdate src/chat.lua with tool execution:
local io = require("io")local json = require("json")local funcs = require("funcs")local prompt = require("prompt")local agent_context = require("agent_context")
local STREAM_TOPIC = "stream"
local function stream_response(runner, conversation, stream_ch) local done_ch = channel.new(1)
coroutine.spawn(function() local response, err = runner:step(conversation, { stream_target = { reply_to = process.pid(), topic = STREAM_TOPIC, }, }) done_ch:send({ response = response, err = err }) end)
local full_text = ""
while true do local result = channel.select({ stream_ch:case_receive(), done_ch:case_receive(), }) if not result.ok then break end
if result.channel == done_ch then local r = result.value return full_text, r.response, r.err end
local chunk = result.value if chunk.type == "chunk" then io.write(chunk.content or "") full_text = full_text .. (chunk.content or "") elseif chunk.type == "done" then local r, ok = done_ch:receive() if ok and r then return full_text, r.response, r.err end return full_text, nil, nil elseif chunk.type == "error" then return nil, nil, chunk.error and chunk.error.message or "stream error" end end
return full_text, nil, nilend
local function execute_tools(tool_calls) local results = {} for _, tc in ipairs(tool_calls) do local args = tc.arguments if type(args) == "string" then args = json.decode(args) or {} end
io.write("[" .. tc.name .. "] ") io.flush()
local result, err = funcs.call(tc.registry_id, args) if err then results[tc.id] = { error = tostring(err) } io.print("error") else results[tc.id] = result io.print("done") end end return resultsend
local function run_turn(runner, conversation, stream_ch) while true do local text, response, err = stream_response(runner, conversation, stream_ch) if err then io.print("") return nil, err end
if text and text ~= "" then io.print("") end
local tool_calls = response and response.tool_calls if not tool_calls or #tool_calls == 0 then return text, nil end
if text and text ~= "" then conversation:add_assistant(text) end
local results = execute_tools(tool_calls)
for _, tc in ipairs(tool_calls) do local result = results[tc.id] local result_str = json.encode(result) or "{}" conversation:add_function_call(tc.name, tc.arguments, tc.id) conversation:add_function_result(tc.name, result_str, tc.id) end endend
local function main() io.print("Terminal Agent (type 'quit' to exit)") io.print("")
local ctx = agent_context.new() local runner, err = ctx:load_agent("app:assistant") if err then io.print("Failed to load agent: " .. tostring(err)) return end
local conversation = prompt.new() local stream_ch = process.listen(STREAM_TOPIC)
while true do io.write("> ") io.flush() local input = io.readline() if not input or input == "quit" or input == "exit" then break end if input == "" then goto continue end
conversation:add_user(input)
local text, gen_err = run_turn(runner, conversation, stream_ch) if gen_err then io.print("Error: " .. tostring(gen_err)) goto continue end if text and text ~= "" then conversation:add_assistant(text) end
::continue:: end
process.unlisten(stream_ch) io.print("Bye!")end
return { main = main }The tool execution loop:
- Call
runner:step()with streaming - If the response contains
tool_calls, execute each tool viafuncs.call() - Add the tool calls and results to the conversation
- Loop back to step 1 for the agent to incorporate the results
- When no more tool calls, return the final text
Run the Agent
Section titled “Run the Agent”wippy updatewippy run chatTerminal Agent (type 'quit' to exit)
> what time is it?[get_current_time] doneThe current time is 17:20 UTC on February 12, 2026.
> what is 125 * 16?[calculate] done125 * 16 = 2000.
> quitBye!Next Steps
Section titled “Next Steps”- LLM Module - Complete LLM API reference
- Agent Module - Agent framework reference
- CLI Applications - Terminal I/O patterns
- Processes - Process model and communication