LLM Agent
Build a terminal chat agent in five phases, from a single LLM call to streaming responses and tool execution.
Classification: runnable tutorial with an external provider. Each phase is a
cumulative edit to the same project and is runnable before you continue. The Wippy
contracts and local control flow are testable without credentials; generation requires
network access and a valid OPENAI_API_KEY.
What We're Building
A terminal chat agent that:
- Generates text with an LLM.
- Maintains multi-turn conversations.
- Streams responses incrementally.
- Calls registered tools.
Project Structure
llm-agent/
├── wippy.lock
└── src/
├── _index.yaml
├── ask.lua
├── chat.lua
└── tools/
├── _index.yaml
├── current_time.lua
└── calculate.lua
Phase 1: Simple Generation
Start with a basic function that calls llm.generate() with a string prompt.
Start in a Wippy project whose source directory is ./src. Set OPENAI_API_KEY
in the environment that starts Wippy. This tutorial declares its model explicitly;
do not also copy a second entry with the same model name from another application.
Entry Definitions
Create src/_index.yaml:
version: "1.0"
namespace: app
entries:
- name: policy
kind: security.policy
policy:
actions: "*"
resources: "*"
effect: allow
- 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: dep.terminal
kind: ns.dependency
component: wippy/terminal
version: "*"
- name: ask
kind: process.lua
meta:
command:
name: ask
short: Ask a single question
security:
actor:
id: app:ask
policies:
- app:policy
source: file://ask.lua
method: main
modules:
- io
imports:
llm: wippy.llm:llm
The LLM module needs two infrastructure entries:
env.storage.osprovides API keys from environment variables.process.hostprovides the process runtime used internally by the LLM module.
The wippy/terminal dependency provides the terminal.host that commands execute on and where io.print writes.
meta.command gives the process a name so wippy run ask launches it with the remaining arguments as string payloads. Its security block installs the actor and policy scope for that launch: the LLM module resolves models from the registry, and a command launched without a scope reads nothing from it.
Generation Code
Create src/ask.lua:
local io = require("io")
local llm = require("llm")
local function main(input)
local response, err = llm.generate(input, {
model = "gpt-4.1-nano",
temperature = 0.7,
max_tokens = 512,
})
if err then
io.print("Error: " .. tostring(err))
return 1
end
io.print(response.result)
return 0
end
return { main = main }
Model Definition
The LLM module resolves models from the registry. Add a model entry to _index.yaml:
- name: gpt-4o-mini
kind: registry.entry
meta:
name: gpt-4o-mini
type: llm.model
title: GPT-4o mini
comment: Fast, affordable model
capabilities:
- generate
- tool_use
- structured_output
class:
- fast
priority: 100
max_tokens: 128000
output_tokens: 16384
pricing:
input: 0.15
output: 0.6
providers:
- id: wippy.llm.openai:provider
provider_model: gpt-4o-mini
Initialize and Test
wippy init
wippy run ask "What is the capital of France?"
This runs the ask process on the terminal host with the question as its argument 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
Upgrade from a single call to a multi-turn conversation using the prompt builder. Register the process as a named command.
Update Entry Definitions
Replace the ask entry with a chat process:
- name: chat
kind: process.lua
meta:
command:
name: chat
short: Start a terminal chat
security:
actor:
id: app:chat
policies:
- app:policy
source: file://chat.lua
method: main
modules:
- io
imports:
llm: wippy.llm:llm
prompt: wippy.llm:prompt
Executable Lua entries receive process as an ambient runtime module, so it is used
directly in the code below and does not belong in the entry's modules list.
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 not input:find("%S") then goto continue end
conversation:add_user(input)
local response, err = llm.generate(conversation, {
model = "gpt-4o-mini",
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, response.metadata)
::continue::
end
io.print("Bye!")
end
return { main = main }
Run It
wippy update
wippy install
wippy run chat
The 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
The agent module defines prompts, models, and tools declaratively, then loads and executes the resulting agent through a context and runner.
Add Agent Dependency
Add to _index.yaml:
- name: dep.agent
kind: ns.dependency
component: wippy/agent
version: "*"
parameters:
- name: process_host
value: app:processes
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-4o-mini
max_tokens: 1024
temperature: 0.7
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
security:
actor:
id: app:chat
policies:
- app:policy
source: file://chat.lua
method: main
modules:
- io
imports:
prompt: wippy.llm:prompt
agent_context: wippy.agent:context
Update 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 not input:find("%S") then goto continue end
conversation:add_user(input)
local response, gen_err = runner:step(conversation, { drop_stale_thinking = true })
if gen_err then
io.print("Error: " .. tostring(gen_err))
goto continue
end
io.print(response.result)
io.print("")
for _, note in ipairs(response.context_notes or {}) do
conversation:add_message(note.role, note.content, note.name, note.metadata)
end
local metadata = response.metadata or {}
metadata.prefix_fingerprint = response.prefix_fingerprint
conversation:add_assistant(response.result or "", metadata)
::continue::
end
io.print("Bye!")
end
return { main = main }
The agent definition contains the prompt, model, and parameters, while the process controls execution. A context can add tools or override the model at runtime.
Resolve the newly added agent dependency, then run this phase:
wippy update
wippy install
wippy run chat
Phase 4: Streaming
Process response chunks as they arrive instead of waiting for the full response.
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 stream_sequence = 0
local function stream_response(runner, conversation, route_pin)
stream_sequence = stream_sequence + 1
local topic = STREAM_TOPIC .. ":" .. tostring(stream_sequence)
local stream_ch = process.listen(topic)
local done_ch = channel.new(1)
coroutine.spawn(function()
local response, err = runner:step(conversation, {
route = route_pin,
drop_stale_thinking = true,
stream_target = {
reply_to = process.pid(),
topic = topic,
},
})
done_ch:send({ response = response, err = err })
end)
local full_text = ""
local response_result = nil
local stream_done = false
local function finish(text, response, err)
process.unlisten(stream_ch)
return text, response, err
end
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
response_result = result.value
else
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
stream_done = true
elseif chunk.type == "error" then
return finish(nil, nil, chunk.error and chunk.error.message or "stream error")
end
end
if response_result and response_result.err then
return finish(full_text, response_result.response, response_result.err)
end
if response_result and stream_done then
return finish(full_text, response_result.response, response_result.err)
end
end
return finish(full_text, nil, "Stream closed before completion")
end
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 not input:find("%S") then goto continue end
conversation:add_user(input)
local text, response, gen_err = stream_response(runner, conversation)
if gen_err then
io.print("Error: " .. tostring(gen_err))
goto continue
end
io.print("")
for _, note in ipairs(response.context_notes or {}) do
conversation:add_message(note.role, note.content, note.name, note.metadata)
end
local metadata = response.metadata or {}
metadata.prefix_fingerprint = response.prefix_fingerprint
conversation:add_assistant(response.result or text or "", metadata)
::continue::
end
io.print("Bye!")
end
return { main = main }
Key patterns:
coroutine.spawnrunsrunner:step()separately so the main coroutine can process stream chunks.channel.selectwaits on both the stream channel and completion channel.- Each turn uses a unique topic and removes its listener after both the runner and that turn's stream report completion.
- The process displays streamed text, then stores the response and its metadata.
It keeps
context_notesbefore that response so later requests replay the same prefix.
Run the streaming phase with the same command:
wippy run chat
Phase 5: Tools
Give the agent tools it can call to access external capabilities.
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: handler
Tool metadata describes the callable interface to the LLM:
input_schemadefines the arguments with JSON Schema.llm_aliasis the function name presented to the LLM.llm_descriptionexplains when to use the tool.
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
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-4o-mini
max_tokens: 1024
temperature: 0.7
tools:
- app.tools:current_time
- app.tools:calculate
Add Tool Execution
Update the chat process modules to include json and funcs:
modules:
- io
- json
- funcs
Update 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 stream_sequence = 0
local function stream_response(runner, conversation, route_pin)
stream_sequence = stream_sequence + 1
local topic = STREAM_TOPIC .. ":" .. tostring(stream_sequence)
local stream_ch = process.listen(topic)
local done_ch = channel.new(1)
coroutine.spawn(function()
local response, err = runner:step(conversation, {
route = route_pin,
drop_stale_thinking = true,
stream_target = {
reply_to = process.pid(),
topic = topic,
},
})
done_ch:send({ response = response, err = err })
end)
local full_text = ""
local response_result = nil
local stream_done = false
local function finish(text, response, err)
process.unlisten(stream_ch)
return text, response, err
end
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
response_result = result.value
else
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
stream_done = true
elseif chunk.type == "error" then
return finish(nil, nil, chunk.error and chunk.error.message or "stream error")
end
end
if response_result and response_result.err then
return finish(full_text, response_result.response, response_result.err)
end
if response_result and stream_done then
return finish(full_text, response_result.response, response_result.err)
end
end
return finish(full_text, nil, "Stream closed before completion")
end
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 results
end
local function run_turn(runner, conversation)
local route_pin = nil
while true do
local text, response, err = stream_response(runner, conversation, route_pin)
if err then
io.print("")
return nil, err
end
if text and text ~= "" then
io.print("")
end
if response.truncation_reason then
return nil, "Agent output was truncated"
end
route_pin = response.route_pin
for _, note in ipairs(response.context_notes or {}) do
conversation:add_message(note.role, note.content, note.name, note.metadata)
end
local metadata = response.metadata or {}
metadata.prefix_fingerprint = response.prefix_fingerprint
conversation:add_assistant(response.result or text or "", metadata)
local tool_calls = response.tool_calls
if not tool_calls or #tool_calls == 0 then
return text, nil
end
for _, tc in ipairs(tool_calls) do
conversation:add_function_call(tc.name, json.encode(tc.arguments), tc.id, {
provider_metadata = tc.provider_metadata,
})
end
local results = execute_tools(tool_calls)
for _, tc in ipairs(tool_calls) do
local result_str = json.encode(results[tc.id]) or "{}"
conversation:add_function_result(tc.name, result_str, tc.id)
end
end
end
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()
while true do
io.write("> ")
io.flush()
local input = io.readline()
if not input or input == "quit" or input == "exit" then break end
if not input:find("%S") then goto continue end
conversation:add_user(input)
local text, gen_err = run_turn(runner, conversation)
if gen_err then
io.print("Error: " .. tostring(gen_err))
goto continue
end
-- run_turn has already appended the response and its context notes.
::continue::
end
io.print("Bye!")
end
return { main = main }
The tool-execution loop:
- Call
runner:step()with streaming. - Store
context_notes, then the assistant response with thinking metadata andprefix_fingerprint. - Append all tool calls together, execute them, then append their results with the original IDs.
- Pass
route_pininto the next step so a fallback route receives its own tool results. - Return the final text when the response contains no more tool calls.
This tutorial executes its two simple tools sequentially. For tool wrappers, parallel execution and per-result persistence, use the tool caller. Time-aware traits also require the host to render and retain one turn context note; the runner does not inject it per step.
Run the Agent
wippy update
wippy install
wippy run chat
Terminal Agent (type 'quit' to exit)
> what time is it?
[get_current_time] done
The current time is 17:20 UTC on February 12, 2026.
> what is 125 * 16?
[calculate] done
125 * 16 = 2000.
> quit
Bye!
Completeness and Limits
- The page contains every authored Lua file and registry entry needed by the five
phases.
wippy.lockand installed modules are generated by the commands above. - Model output, token usage, tool-choice order, and wording are provider-dependent; the displayed interaction is illustrative rather than an assertion of exact text.
- The calculator is intentionally a small arithmetic parser, not a general expression evaluator. Treat every real tool as an authority boundary and attach narrow security policies before exposing side effects.
Next Steps
- LLM Module — LLM API reference
- Agent Module — Agent framework reference
- CLI Applications — Terminal I/O patterns
- Processes — Process model and communication