Tutorial - Human interaction and approval
This complete, offline recipe distinguishes display, input, required approval, and source-level review. It also checks a real checkpoint resume. It needs an installed Touba CLI and Node.js 24, but no SDK, secret or model provider.
1. Create the two programs
Commands below use a macOS/Linux terminal. Create the files with the displayed names inside this new directory:
mkdir -p human-validation-demo
cd human-validation-demo
module examples.human_validation
type ChangeRequest
summary is Text
strategy RunHumanValidation
takes request as ChangeRequest
gives Text
do
show
with message as "Review required"
ask
with prompt as "Ticket?"
into ticket
human.approve
with context as request
into approved
give ticket
module examples.human_review
type Analysis
confidence is Float
summary is Text
skill ReviewWhenNeeded
takes analysis as Analysis
gives Analysis
do
if analysis.confidence < 0.8
human.review
with context as analysis
into decision
give analysis
else
give analysis
human.approve and human.review both block until approved. Rejection stops
both paths. The distinction is intent and trace mode, not enforcement strength:
required_approval versus agent_review. The latter name also identifies a
source-level review; it does not mean a model initiated it.
2. Derive references from the current execution
The following script runs each stage with the public CLI, verifies its exit code and writes the actual response files. It does not invent interaction or approval references. Its approved/rejected answers are explicit test decisions, not decisions to reuse for a real business approval.
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
function run(args, expectedExit) {
const child = spawnSync("touba", ["run", ...args, "--output", "json"], { encoding: "utf8" });
assert.equal(child.status, expectedExit, child.stderr);
return JSON.parse(child.stdout);
}
function save(path, value) {
writeFileSync(path, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
}
const base = ["human-validation.air.json", "--entry", "strategy:examples.human_validation/RunHumanValidation",
"--inputs-json", JSON.stringify({ request: { summary: "Publish the reviewed report" } })];
const waiting = run(base, 3);
assert.equal(waiting.reason, "user_input_pending");
assert.equal(waiting.messages[0].text, "Review required");
const interactionRef = waiting.pending_interaction.interaction_ref;
save("user-inputs.json", { [interactionRef]: "TICKET-42" });
save("ask.trace.json", waiting.trace);
const paused = run([...base, "--user-inputs", "user-inputs.json",
"--human-checkpoint-out", "human.checkpoint.json"], 3);
assert.equal(paused.reason, "human_approval_pending");
const checkpoint = JSON.parse(readFileSync("human.checkpoint.json", "utf8"));
const approvalRef = checkpoint.approval_ref;
save("human-decisions.json", { [approvalRef]: "approved" });
save("approval.trace.json", paused.trace);
const resumed = run(["human-validation.air.json", "--entry", "strategy:examples.human_validation/RunHumanValidation",
"--human-resume-checkpoint", "human.checkpoint.json", "--human-decisions", "human-decisions.json"], 0);
assert.equal(resumed.reason, "completed");
assert.equal(resumed.result, "TICKET-42");
save("resumed.trace.json", resumed.trace);
save("rejected-decisions.json", { [approvalRef]: "rejected" });
const refused = run([...base, "--user-inputs", "user-inputs.json", "--human-decisions", "rejected-decisions.json"], 4);
assert.equal(refused.reason, "human_approval_rejected");
assert.equal(refused.result, null);
save("rejected.trace.json", refused.trace);
assert.ok(!JSON.stringify(resumed.trace).includes("TICKET-42"));
const reviewArgs = ["review.air.json", "--entry", "skill:examples.human_review/ReviewWhenNeeded",
"--inputs-json", JSON.stringify({ analysis: { confidence: 0.5, summary: "Check the source" } })];
const reviewPending = run(reviewArgs, 3);
const reviewRef = reviewPending.trace.events.find(event => event.kind === "human_approval_requested").payload.approval_ref;
const reviewed = run([...reviewArgs, "--human-decisions-json", JSON.stringify({ [reviewRef]: "approved" })], 0);
assert.equal(reviewed.result.summary, "Check the source");
assert.equal(run([...reviewArgs, "--human-decisions-json", JSON.stringify({ [reviewRef]: "rejected" })], 4).reason, "human_approval_rejected");
console.log(JSON.stringify({ interactionRef, approvalRef, reviewRef, result: resumed.result }));
console.log("ASK_APPROVAL_RESUME_REJECTION_AND_REVIEW_OK");
touba compile human-validation.tb --out human-validation.air.json
touba compile review.tb --out review.air.json
node exercise.mjs
Expected: ASK_APPROVAL_RESUME_REJECTION_AND_REVIEW_OK and result
TICKET-42. Inspect the generated JSON files to see the exact references.
They depend on the current AIR; adding a preceding instruction may change them.
Never reuse decisions or a checkpoint after modifying the program. The runtime
checks the full AIR identity before resume.
3. Understand the public results
| Outcome | CLI exit | reason |
|---|---|---|
| waiting for an answer | 3 | user_input_pending |
| waiting for approval | 3 | human_approval_pending |
| approved and completed | 0 | completed |
| human rejection | 4 | human_approval_rejected |
--output json separates result, messages, pending_interaction and
trace. Messages and answers are business outputs; the trace records their
events and references, not the raw text. A pause is not a successful run.
The human checkpoint contains business inputs and intermediate values in plain
JSON. It is locally protected with restrictive permissions, not encrypted.
Store it securely; do not place it in Git or public logs. Resolved secrets must
not enter it. A checkpoint resumes the saved continuation rather than replaying
earlier effects. Merely restarting touba run starts a new execution.
Next steps
For a real side effect after approval, see
approval before external effects.
A provider can separately request request_human_review; this dynamic choice
does not replace a source-level approval barrier. See
governed agent decisions.