A small assistant that uses your functions
An assistant that uses tools is a program that lets a model call your own functions to do a job. It matters because an assistant is only safe when all the parts you have built so far work together.
It needs tools the model can name and a loop that runs them. It also needs a step limit, errors passed back as results and a person to approve risky calls. This lesson puts those parts together in one small program.
By the end of this lesson
Note
I can build a small assistant that uses my own functions.
Key words
| Word | What it means |
|---|---|
| Assistant | a program that uses a model and tools to do a job |
| Loop | steps repeated until an answer or a limit |
| Router | code that sends each request to its function |
| Approval | a person's yes before a write tool runs |
| Guard | a check that stops a call from running |
The cells students fill in are left blank in this preview.
An assistant is five parts working together
Each part is one you have already built. The tools are your functions and their descriptions. The loop asks, runs and asks again. The router sends each request to its function.
The guards are the step limit, the error results and the approval for writes. Take any one away and a small failure can become real damage.
Worked example: what each part protects against
| Part | What it does | What goes wrong without it |
|---|---|---|
| Step limit | Stops after three steps | A loop that never ends |
| Router table | Refuses an unknown tool | A request runs the wrong thing |
| Error results | Passes a failure back | A crash instead of a recovery |
| Approval | Asks before a write | A mistake changes your data |
| Log | Records every call | Nobody knows what happened |
The cells students fill in are left blank in this preview.
Save the model and approval helpers
docker exec -w /home/node/project agent node -e "const ask=async(m,t,c)=>{const r=await fetch((process.env.AI_URL||'http://ai:4000')+'/v1/chat/completions',{method:'POST',headers:{'content-type':'application/json',authorization:'Bearer lab'},body:JSON.stringify({model:'lab',messages:m,tools:t,tool_choice:c,max_tokens:120})});const j=await r.json();if(r.ok)return j.choices[0].message;throw Error(r.status+' '+j.error.message)};const tool=(n,d,k)=>({type:'function',function:{name:n,description:d,parameters:{type:'object',properties:{[k]:{type:'string'}}}}});const mk=()=>{const rl=require('readline').createInterface({input:process.stdin});const it=rl[Symbol.asyncIterator]();return{yes:async q=>{process.stdout.write(q+' (y/n) ');const l=await it.next();const ok=l.value?.trim()=='y';console.log(ok?'y':'n');return ok},close:()=>rl.close()}};require('fs').writeFileSync('agent.cjs','exports.ask='+ask+';exports.tool='+tool+';exports.mk='+mk+';');console.log('Saved agent.cjs')"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Save the shop tools
docker exec -w /home/node/project agent node -e "const make=()=>{const{tool}=require('./agent.cjs');const N=[];const k=(i,n,o)=>({role:'assistant',tool_calls:[{id:i,function:{name:n,arguments:JSON.stringify(o)}}]});return{N,T:[tool('get_price','Get a shop item price.','item'),tool('add_note','Save a short note.','text')],F:{get_price:a=>({apple:40,pear:45})[a.item]||'Error: not found',add_note:a=>N.push(a.text)},FK:[k('c1','get_price',{item:'pear'}),k('c2','add_note',{text:'pear 45p'}),{role:'assistant',content:'Saved.'}]}};require('fs').writeFileSync('tools.cjs','exports.make='+make+';');console.log('Saved tools.cjs')"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Check a call in a fixed order
For each request, ask the same questions in the same order. Is the name in the table? Does it need approval? Did the person say y? Run it, and turn any error into a result.
A call that fails a question never reaches the next one. Every outcome, run or not, becomes a tool message and a log entry.
Run the whole assistant
printf 'y\n' | docker exec -i -w /home/node/project agent node -e "const{ask,mk}=require('./agent.cjs');const{T,F,N,FK}=require('./tools.cjs').make();const{yes,close}=mk();let rec=0;(async()=>{const m=[{role:'user',content:'Find the price of a pear, then save it as a note.'}];for(let s=1;s<4;s++){let a;try{if(rec)throw 0;a=await ask(m,T)}catch(e){rec||console.log('No model ('+e.message+'). Recorded sample:');rec=1;a=FK[s-1]}m.push(a);if(a.tool_calls==null){console.log('Answer: '+a.content);break}for(const c of a.tool_calls){const n=c.function.name;let r='Error: unknown tool';if(Object.hasOwn(F,n))r=n=='add_note'&&(await yes('Allow '+n+' '+c.function.arguments+'?'))===false?'Refused by the person':String(F[n](JSON.parse(c.function.arguments)));console.log('Step '+s+': '+n+' -> '+r);m.push({role:'tool',tool_call_id:c.id,content:r})}}console.log('Notes: '+N.join(', '));close()})()"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
A common mistake
Warning
Many people think an assistant is safe once the model is good. A good model still gets things wrong. The safety is in your code: the table of tools, the step limit, the approval and the log. Build them first, and trust the model later.
Going further: change the job
Change the question in the assistant Run to Save a note: buy bread. Run it twice, once with y and once with n fed in.
Compare the two notes lines. Write down which part of the program made the difference.