Getting Started with the GitHub Copilot SDK — Part 4: Pre/Post Tool-Use Hooks

This is Part 4 of my hands-on series on the GitHub Copilot SDK for .NET. The companion
code lives at egarim/GettingStartedWithGithubCopilotSDK
— each numbered folder runs on its own.
In Part 3 we gave the model tools. Now we put a checkpoint in front of them.
Hooks let you intercept every tool call — before and after it runs.
That's the whole idea. Auditing, access control, debugging — all of it lives here.
The two hooks
There are two points in a tool call's life you can hook into:
OnPreToolUse— fires before the tool executes. You see the name and the arguments, and you decide: allow or deny.OnPostToolUse— fires after the tool returns. You see the result the tool actually produced.
You wire them up through SessionHooks on the SessionConfig:
var session = await client.CreateSessionAsync(new SessionConfig
{
Tools = [lookupTool],
Hooks = new SessionHooks
{
OnPreToolUse = (input, invocation) => { /* ... */ },
OnPostToolUse = (input, invocation) => { /* ... */ }
}
});
For the whole demo I use one little tool — lookup_price, a fake product catalog:
[Description("Looks up the price of a product by name")]
static string LookupPrice([Description("Product name")] string productName)
{
var catalog = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
{
["Widget Pro"] = 29.99m, ["Gadget X"] = 49.95m,
["Super Deluxe Widget"] = 199.00m, ["Basic Widget"] = 9.99m,
};
return catalog.TryGetValue(productName, out var price)
? quot;Product: {productName}, Price: ${price}"
: quot;Product '{productName}' not found.";
}
PreToolUse — watch it, then wave it through
The simplest hook: log the tool name and allow the call.
OnPreToolUse = (input, invocation) =>
{
Console.WriteLine(quot; [PreToolUse] Tool: {input.ToolName} -> ALLOW");
return Task.FromResult<PreToolUseHookOutput?>(
new PreToolUseHookOutput { PermissionDecision = "allow" });
}
Ask "What is the price of 'Widget Pro'?" and the console shows the hook firing, then the
right answer — $29.99. The hook saw the call and let it pass.
input.ToolName is what's being called. invocation.SessionId tells you which session it
belongs to — handy when you're logging across many.
PostToolUse — inspect what came back
The Post hook runs after the tool returns, and hands you the raw result:
OnPostToolUse = (input, invocation) =>
{
Console.WriteLine(quot; [PostToolUse] Tool: {input.ToolName}");
Console.WriteLine(quot; [PostToolUse] Result: {input.ToolResult}");
return Task.FromResult<PostToolUseHookOutput?>(null);
}
That input.ToolResult is the exact string the tool produced — before the model gets to
spin it into prose. Perfect for an audit log of what your tools really return.
Note the null return. You're just observing here, not changing anything.
Both together — the full lifecycle
Put both hooks in one session and you can watch the whole arc: Pre → tool runs → Post.
OnPreToolUse = (input, invocation) =>
{
Console.WriteLine(quot; [PRE] -> {input.ToolName}");
return Task.FromResult<PreToolUseHookOutput?>(
new PreToolUseHookOutput { PermissionDecision = "allow" });
},
OnPostToolUse = (input, invocation) =>
{
Console.WriteLine(quot; [POST] <- {input.ToolName}: {input.ToolResult}");
return Task.FromResult<PostToolUseHookOutput?>(null);
}
Ask for "Super Deluxe Widget" and the console reads:
[PRE] -> lookup_price
[POST] <- lookup_price: Product: Super Deluxe Widget, Price: $199.00
That's complete observability for one tool call, in two lines.
Deny — block the call entirely
Here's where Pre earns its keep. Return "deny" and the tool never runs.
OnPreToolUse = (input, invocation) =>
{
Console.WriteLine(quot; [PreToolUse] DENYING: {input.ToolName}");
return Task.FromResult<PreToolUseHookOutput?>(
new PreToolUseHookOutput { PermissionDecision = "deny" });
}
The model doesn't crash. It gets told the call was denied and has to answer without it —
so it explains it couldn't reach the tool.
This is your hook for per-tool, per-user, per-context security policy. The model asks; your code decides.
Human-in-the-loop
Because the hook is just C#, you can ask a human inside it. Wire Console.ReadLine() into a
streaming chat and every tool call waits for a real s/n:
OnPreToolUse = (input, invocation) =>
{
Console.Write(quot;\n [Hook] '{input.ToolName}' wants to run. Allow? (s/n): ");
var resp = Console.ReadLine()?.Trim().ToLowerInvariant();
var decision = resp == "n" ? "deny" : "allow";
return Task.FromResult<PreToolUseHookOutput?>(
new PreToolUseHookOutput { PermissionDecision = decision });
}
Now you've got an approval gate. The model proposes; the human confirms — live.
Hooks vs. permissions
One thing worth keeping straight: hooks are not the SDK's permission system.
Hooks (SessionHooks) |
Permissions | |
|---|---|---|
| Fires on | every tool call | only destructive ops (write, run) |
| For | auditing, access control, debugging | user consent for risky actions |
| Granularity | per tool call | per permission kind |
Hooks fire on everything and are your tool. Permissions are about consent for the SDK's own
risky operations — and that's Part 5.
Takeaways
- Hooks intercept every tool call —
OnPreToolUsebefore,OnPostToolUseafter — viaSessionHookson theSessionConfig. - Pre sees
input.ToolName; Post seesinput.ToolResult. Returnnullfrom Post when you're only observing. - Return
PermissionDecision = "deny"from Pre to block a call — the model copes and answers without it. - Because a hook is plain C#, you can drop a human approval prompt right inside it.
Next up, Part 5 — Permission Request Handling: the SDK's built-in consent flow for its own
destructive tools like editing files and running commands.
Read Part 5 →
Want to follow along? Grab the .NET 10 SDK and GitHub Copilot access, then
dotnet run --project 04.HooksDemofrom the
course repo.