Tutorial: your first governed tool
In the next five minutes you will build a Flue tool that sends password-reset links, watch flue-guard refuse it when the caller doesn't own the account, and prove afterwards, cryptographically, exactly what happened.
You need Node.js 22.19 or newer (node --version), which runs TypeScript files directly.
1. Set up a project
mkdir guarded-tool && cd guarded-tool
npm init -y
npm pkg set type=module
npm i flue-guard @flue/runtime valibot2. Write the tool
Create first-tool.ts:
// first-tool.ts
import * as v from "valibot";
import { govern, caller, isGovernanceDenial } from "flue-guard";
import { HashChainAuditLog } from "flue-guard/audit";
// A stand-in account store. This mapping is the server-side truth;
// the model can never argue with it.
const owners = new Map([
["acct-alice", "alice"],
["acct-bob", "bob"],
]);
const audit = new HashChainAuditLog({ path: "audit.jsonl" });
const gov = govern({ audit });
const resetPassword = gov.tool({
name: "reset_password",
description: "Send a password reset link for an account.",
parameters: v.object({ accountId: v.string() }),
sideEffect: true,
// The authorization gate: the caller must own the account they name.
authorize: caller(
(a: { accountId: string }, ctx) => owners.get(a.accountId) === ctx.actor.id,
),
execute: async (a) => {
console.log(` [side effect] reset link sent for ${a.accountId}`);
return `Sent a reset link for ${a.accountId}.`;
},
});
// Bind who is calling. It comes from your auth, never from the model.
await gov.run(
{ actor: { id: "alice", roles: ["account_holder"] }, tenantId: "demo" },
async () => {
// Alice resets her own account: allowed.
console.log((await resetPassword.run({ data: { accountId: "acct-alice" } })).output);
// "Alice" asks for Bob's account: refused before the side effect runs.
try {
await resetPassword.run({ data: { accountId: "acct-bob" } });
} catch (err) {
if (!isGovernanceDenial(err)) throw err;
console.log(`DENIED: ${err.message}`);
}
},
);
console.log("audit chain:", await audit.verify());gov.tool(...) returns a real Flue ToolDefinition, and the script invokes it through its run({ data }) handler. This exercises governance directly; Flue's model loop additionally validates the input schema before calling run and consumes the returned { output } envelope. The model chooses the arguments, which is exactly why the ownership check exists.
3. Run it
node first-tool.tsYou should see:
[side effect] reset link sent for acct-alice
Sent a reset link for acct-alice.
DENIED: "reset_password" was not authorized for this caller/target.
audit chain: { valid: true }The first call executed. The second was refused before execute ran: no reset link for Bob's account was ever sent.
4. Read the receipt
Every call was recorded in audit.jsonl. Create verify-audit.ts:
// verify-audit.ts
import { HashChainAuditLog } from "flue-guard/audit";
const audit = new HashChainAuditLog({ path: "audit.jsonl" });
for (const e of await audit.entries()) {
console.log(`#${e.seq} ${e.tool} ${e.decision}/${e.outcome} actor=${e.actorId}`);
}
console.log(await audit.verify());node verify-audit.ts#0 reset_password allow/executing actor=alice
#1 reset_password allow/success actor=alice
#2 reset_password deny/denied actor=alice
{ valid: true }Three entries: a side-effecting call writes an executing intent before the handler runs and an outcome after (so a side effect can never run unrecorded), and the denial is on the record too.
5. Try to tamper with it
Each entry stores a hash of the previous one, so the log is a chain. Open audit.jsonl in your editor, and on the first line change acct-alice to acct-bob. Save, then verify again:
node verify-audit.ts{ valid: false, brokenAt: 0, reason: 'content hash mismatch at seq 0' }The rewritten history no longer matches its own hash, and verification points at the exact line. That is what "tamper-evident" means here, and you have now demonstrated it yourself.
Where to go next
You have a governed tool: hand it to your agent like any other Flue tool and bind context inside the agent function with gov.withContext(...) using authenticated useDelivery() attributes. An ambient gov.run(...) around dispatch does not reach the detached tool execution.
- Choose authorize vs scope: which gate fits which tool, and how to combine them.
- Make retries safe: idempotency keys, so an agent retry can't send the reset link twice.
- Verify & protect the audit log: HMAC keys, and what the chain does and doesn't prove.
- The trust model: what is guaranteed, and what is deliberately not.