An agent with tools, no framework
An agent is a twenty-line loop: the model asks for a tool, you run it, hand back the result and repeat until it answers. Write it and try it on your own Ollama.
TOOLS are four tools of a workshop (stock, precio, calcular, hoy); each takes an object and returns text. SCRIPT is what a local model replied, step by step, to the question TASK; scriptedModel(SCRIPT) plays it back as if it were the model. The protocol: the model replies with a JSON {"tool": name, "args": {...}} or, once it knows the answer, {"final": "text"}. Write three functions:
parseToolCall(text): pull the first JSON object out of the text, even when prose surrounds it or it sits in a `json block. Return { tool, args } when tool is a string (with args {} if missing), { final } when final is a string, and null otherwise.
runTool(call): run TOOLS[call.tool](call.args) and return its text. When the tool does not exist, return text starting with error: (the model should find out; your program should not crash).
agentLoop(model, task, maxSteps): start with [{ role: "user", content: task }] and, at most maxSteps times: ask the model for a reply, parse it and, if it is final, return { answer, steps, stopped: "final" }; if it is a tool, run it, store { tool, args, result } in steps and append the model's reply (assistant) and the result (tool) to the messages. If it is not JSON, store { error: "no-json" } and ask the model to reply with JSON only. When the steps run out, { answer: null, steps, stopped: "max" }.
The last check uses LIVE: the first real reply of your own model to TASK when you switch "My Ollama" on, or a sample otherwise. The preview shows the transcript of the loop.
Challenges 0/6
- Parses clean JSON, JSON with prose and a code block, and rejects the rest
- Runs a tool and turns an unknown one into a text error
- The full loop on the script: three tools and the final answer
- The model gets every tool result back in the conversation
- Stops at the step cap and survives a reply without JSON
- Understands the first real reply of your model (My Ollama) or the sample
function parseToolCall(text) {
// 1. JSON.parse of the trimmed text; if that fails, the slice from the first { to the last }
// 2. { tool, args } when tool is a string (args {} if missing); { final } when final is a string; otherwise null
return null;
}
function runTool(call) {
// TOOLS[call.tool](call.args) as text; an unknown tool returns 'error: …' instead of throwing
return '';
}
function agentLoop(model, task, maxSteps) {
// messages = [{ role: "user", content: task }]; steps = []
// at most maxSteps times: text = model(messages); call = parseToolCall(text)
// no JSON: steps gets { error: "no-json" }, messages get the reply (assistant) and "error: responde solo con JSON" (user)
// final: return { answer: call.final, steps, stopped: "final" }
// tool: result = runTool(call); steps gets { tool, args, result }; messages get the reply (assistant) and the result (tool)
// out of steps: { answer: null, steps, stopped: "max" }
return { answer: null, steps: [], stopped: 'max' };
}
console.log(agentLoop(scriptedModel(SCRIPT), TASK, 8));The idea
Strip the framework away and an agent is this: a loop that sends the conversation to the model, reads whether it asks for a tool, runs it, appends the result to the conversation and starts again. It ends when the model replies with final or when the step budget runs out. The three decisions that matter are yours: which tools exist, how many steps you allow, and what happens when the model ignores the format.
The robustness lives in parseToolCall. A small model sometimes wraps the JSON in prose, puts it in a code block or invents a tool. None of that should crash your program: parse what you can, send back what you cannot as an error, and keep looping. The step limit is what stops a confused agent from burning an hour of GPU.
What we measured: with this very question and this very protocol, llama3.1:8b and qwen3.6:35b (Ollama, JSON output mode) replied with a valid call to stock in 10 of 10 attempts each, at 0.5–0.9 s per reply, on the DGX Spark on 2026-10-18. In JSON mode there was no prose around it; without that mode there is, which is why the parser tolerates it.
When someone sells you an "autonomous agent", ask about the loop: which tools, what step cap, what it does with an error. If nobody can answer, there is no agent, there is a demo.
Run it on your machine
The same loop in Python against your Ollama (ollama pull llama3.1:8b if you do not have it), with the tools message "My Ollama" uses. The tools are the exercise's; llama3.1:8b reaches the answer in three calls:
python3 -c "
import json, re, urllib.request
def calcular(a): # models name the argument as they please: take the first value, allow arithmetic only
e = str(list(a.values())[0] if a else '').replace(',', '.')
return str(round(eval(e, {}), 2)) if re.fullmatch(r'[\d\s+*/().-]+', e) else 'error: solo aritmética con números, sin llamar a otras herramientas'
TOOLS = {'stock': lambda a: '34', 'precio': lambda a: '9.75', 'calcular': calcular, 'hoy': lambda a: '2026-10-18'}
SYSTEM = '''Eres el asistente de un taller. Herramientas, una por turno:
- stock(producto): unidades en almacén, p. ej. {\"tool\":\"stock\",\"args\":{\"producto\":\"filtro de aceite\"}}
- precio(producto): precio unitario en EUR sin IVA, p. ej. {\"tool\":\"precio\",\"args\":{\"producto\":\"filtro de aceite\"}}
- calcular(expresion): solo aritmética con números, p. ej. {\"tool\":\"calcular\",\"args\":{\"expresion\":\"12 * 9.75 * 1.21\"}}
- hoy(): la fecha, p. ej. {\"tool\":\"hoy\",\"args\":{}}
Responde SOLO con un objeto JSON: una llamada {\"tool\": nombre, \"args\": objeto} o, cuando sepas la respuesta, {\"final\": \"texto para el cliente\"}.'''
msgs = [{'role': 'system', 'content': SYSTEM}, {'role': 'user', 'content': '¿Tenéis 12 filtros de aceite y cuánto costarían con IVA?'}]
for step in range(8):
req = urllib.request.Request('http://localhost:11434/api/chat', data=json.dumps({'model': 'llama3.1:8b', 'messages': msgs, 'stream': False, 'format': 'json', 'options': {'temperature': 0.2}}).encode(), headers={'content-type': 'application/json'})
text = json.load(urllib.request.urlopen(req))['message']['content']
call = json.loads(text)
if 'final' in call: print('final:', call['final']); break
result = TOOLS.get(call.get('tool'), lambda a: 'error: herramienta desconocida')(call.get('args') or {})
print(step, call, '->', result)
msgs += [{'role': 'assistant', 'content': text}, {'role': 'tool', 'content': result}]
else: print('sin respuesta final en 8 pasos: el tope hizo su trabajo')
" Go deeper: the Ollama reference →
This in production, with your data? Let's talk for 15 minutes →