Some tool calls should wait for a yes
Asking a person before a tool runs means your program holds a risky tool call until a human says yes. It matters because a tool that saves, sends or deletes does real damage if the model asks for it by mistake.
A price lookup changes nothing, so it can run at once. For a risky call, your program, never the model, decides, and it asks a person first.
By the end of this lesson
Note
I can make a risky tool wait for a person to approve it.
Key words
| Word | What it means |
|---|---|
| Read tool | a tool that only looks at data |
| Write tool | a tool that changes something |
| Approval | a person's yes before a call runs |
| Default deny | refuse unless there is a clear yes |
| Log | a record of what was asked and what ran |
The cells students fill in are left blank in this preview.
Sort every tool by what it can change
Give each tool a risk. A read tool only looks, so it can run at once. A write tool changes something, so it needs a yes.
Keep the risk in your own table. The model never says how risky a call is, and a model that is wrong cannot talk its way past the table.
Worked example: three tools and their risk
| Tool | What it does | Risk | Runs |
|---|---|---|---|
| get_price | Looks up a price | read | At once |
| add_note | Adds to a list of notes | write | After a yes |
| delete_note | Removes a note | write | After a yes |
| anything else | Not in the table | unknown | Never |
The cells students fill in are left blank in this preview.
Save a helper that calls the model
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)};require('fs').writeFileSync('ai.cjs','exports.ask='+ask+';');console.log('Saved ai.cjs: ask(messages, tools) returns the model message.')"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Ask the person and refuse by default
Print what the tool will do and read one answer. Only y is a yes. Anything else, an empty line or no answer at all, is a no.
This is default deny: a call runs only with a clear yes. In the next Run the answers are fed in, so every student sees the same lines. Typed by hand, it would wait for you.
Gate three calls with fixed answers
printf 'y\nn\n' | docker exec -i -w /home/node/project agent node -e "const rl=require('readline').createInterface({input:process.stdin});const it=rl[Symbol.asyncIterator]();const R={get_price:'read',add_note:'write',delete_note:'write'};const N=['milk'];const F={get_price:a=>40,add_note:a=>N.push(a.text),delete_note:a=>N.splice(a.index,1)};(async()=>{const log=[];for(const c of [{n:'get_price',a:{item:'pear'}},{n:'add_note',a:{text:'eggs'}},{n:'delete_note',a:{index:0}}]){let ok=R[c.n]=='read';if(ok===false){process.stdout.write('Allow '+c.n+' '+JSON.stringify(c.a)+'? (y/n) ');const l=await it.next();ok=l.value?.trim()=='y';console.log(ok?'y':'n')}if(ok)F[c.n](c.a);log.push(c.n+': '+(ok?'ran':'refused'))}console.log(log.join(', '));console.log('Notes: '+N.join(', '));rl.close()})()"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Let a model ask, and refuse it
printf 'n\n' | docker exec -i -w /home/node/project agent node -e "const{ask}=require('./ai.cjs');const rl=require('readline').createInterface({input:process.stdin});const it=rl[Symbol.asyncIterator]();const T=[{type:'function',function:{name:'add_note',description:'Save a short note.',parameters:{type:'object',properties:{text:{type:'string'}}}}}];(async()=>{let c;try{c=(await ask([{role:'user',content:'Save a note: buy milk'}],T,'required')).tool_calls[0].function}catch(e){console.log('No model ('+e.message+'). Recorded sample:');c={name:'add_note',arguments:JSON.stringify({text:'buy milk'})}}process.stdout.write('The model wants '+c.name+' '+c.arguments+'. Allow? (y/n) ');const l=await it.next();const ok=l.value?.trim()=='y';console.log(ok?'y':'n');console.log(ok?'Ran add_note.':'Refused. Nothing ran.');rl.close()})()"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Keep a log of every call
Record each call: its name, whether a person was asked, what they said and whether it ran. A log shows what an assistant did, and what it was stopped from doing.
Log the refusals too. A refused call that nobody can see is a puzzle later.
A common mistake
Warning
Many people think the model decides whether a risky call is safe. It does not, and it can be wrong or be tricked by text it reads. Your program decides, from your own table, and asks a person before any write.
Going further: change the answers
Change the fed-in answers in the Run that gates three calls from y and n to n and n. Run it again and read the notes line.
Then change get_price to write in R. Write down what the program now asks.