May 9, 20264 min read/2026/05/09/getting-started-github-copilot-sdk-part-4-tool-use-hooks/

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 callOnPreToolUse before, OnPostToolUse after — via SessionHooks on the SessionConfig.
  • Pre sees input.ToolName; Post sees input.ToolResult. Return null from 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.HooksDemo from the
course repo.