Easier to read with cream paper, a tint, more contrast or larger text? Choose your colours and text, once, for every page.

Free lesson

Asking a person before a tool runs

After this lesson: I can make a risky tool wait for a person to approve it.

60 min lesson3 commandsAI coding agent lab: use opencode to explain and fix code, and check its work

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

WordWhat it means
Read toola tool that only looks at data
Write toola tool that changes something
Approvala person's yes before a call runs
Default denyrefuse unless there is a clear yes
Loga 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

ToolWhat it doesRiskRuns
get_priceLooks up a pricereadAt once
add_noteAdds to a list of noteswriteAfter a yes
delete_noteRemoves a notewriteAfter a yes
anything elseNot in the tableunknownNever

The cells students fill in are left blank in this preview.

Save a helper that calls the model

Command
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

Command
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

Command
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.

About this lab and its source: AI coding agent lab: use opencode to explain and fix code, and check its work

Teaching a class? Set this module for your students with a teacher guide, marked assignments and a lab for every student.