In the previous chapter, Atomic Resolution (The "Game Show Buzzer"), we solved the problem of handling simultaneous responses by creating a strict "buzzer" system. We ensured that only one decisionβwhether from the user, the AI, or a remote bridgeβactually counts.
But once that decision is made, where does it go?
If a user denies a tool, we need to know why. If an AI classifier auto-approves a dangerous command, we need a record of exactly what logic was used.
Welcome to the final piece of the puzzle: Centralized Telemetry.
Imagine a pilot flying a plane.
If the "flight logs" were written on random sticky notes, some in the cockpit, some in the cargo hold, and some shouted into the wind, accident investigation would be impossible.
In code, developers often do this:
// β The "Scattered Diary" approach
if (userSaidYes) {
console.log("User agreed"); // Logging to console
analytics.track("agreement"); // Logging to a service
// ... forgot to log the timing!
}
This leads to inconsistent data. You might record that a tool ran, but forget to record who authorized it (The User? The Config? The AI?).
We solve this by creating a dedicated layerβa "Black Box"βthat handles Fan-Out.
The rest of the application calls one function with the result. That function then splits the signal and sends it everywhere it needs to go:
We force every single permission decision in the entire app to pass through logPermissionDecision. This is our "choke point." By controlling this gateway, we guarantee that no decision ever goes unrecorded.
It is not enough to say "Allowed." We must know the Source.
user: The human clicked "Allow."config: The settings.json file auto-approved it.classifier: An AI model analyzed the risk and approved it.This is the "mailing list" concept. You send one message to the logging function, and it automatically forwards copies to different destinations (Metrics, Logs, Traces).
As a developer using the PermissionContext, you rarely need to call the logging function manually. The context methods we built in Chapter 1 handle it for you.
Example: How the Context uses it internally
// Inside PermissionContext.ts
async function handleUserAllow(input) {
// ... save changes ...
// β
AUTOMATIC LOGGING
// The context knows "who" (User) and "what" (Accept)
this.logDecision({
decision: 'accept',
source: { type: 'user', permanent: false }
});
return this.buildAllow(input);
}
Example: Analyzing the Output When the code above runs, the "Black Box" generates a structured event like this:
{
"event": "tengu_tool_use_granted_in_prompt_temporary",
"metadata": {
"toolName": "WriteFile",
"waiting_for_user_permission_ms": 1500,
"sandboxEnabled": true
}
}
Notice how it automatically calculated how long the user waited (1500ms)? That logic is centralized, so we never forget to calculate it.
Let's look at the flow of data when a decision is made.
This logic lives in permissionLogging.ts. It acts as a switchboard.
This function takes the raw context and the decision arguments. It is the only public door to the logging system.
// permissionLogging.ts
function logPermissionDecision(
ctx: PermissionLogContext, // The "Case File"
args: PermissionDecisionArgs, // The Decision (Accept/Reject)
promptStartTimeMs?: number // Timing data
) {
const { tool, input, messageId } = ctx;
const { decision, source } = args;
// Calculate how long the user stared at the screen
const waitMs = promptStartTimeMs
? Date.now() - promptStartTimeMs
: undefined;
// ... proceed to fan-out ...
}
We use distinct event names so data analysts can easily filter "Funnels" (e.g., how many users reject requests vs. allow them).
// Inside logPermissionDecision...
if (decision === 'accept') {
// Helper function maps the source to a specific event string
logApprovalEvent(tool, messageId, source, waitMs);
} else {
logRejectionEvent(tool, messageId, source, waitMs);
}
For engineering observability, we want standard metrics. We also do something special for Code Editing tools: we try to guess the programming language to track "most edited languages."
// Inside logPermissionDecision...
// 1. Log generic system event
logOTelEvent('tool_decision', { decision, sourceString, toolName });
// 2. Special handling for Code Editors
if (isCodeEditingTool(tool.name)) {
// Extract language (e.g., "typescript") from the input filename
const attributes = await buildCodeEditToolAttributes(tool, input);
// Increment a counter
getCodeEditToolDecisionCounter().add(1, attributes);
}
Finally, we store the decision on the toolUseContext object. This allows other parts of the code to look back and ask, "What happened with that tool use 5 minutes ago?"
// Inside logPermissionDecision...
toolUseContext.toolDecisions.set(toolUseID, {
source: sourceString,
decision,
timestamp: Date.now(),
});
By centralizing telemetry, we gain Observability.
waiting_for_user_permission_ms. If this number is high, we know our UI is confusing or users are hesitant.ctx.handleUserAllow().We have reached the end of the Tool Permission tutorial series.
Let's recap our journey:
You now understand the architecture of a high-performance, safe, and observable permission system. Happy coding!
Generated by Code IQ