How to Add Custom Hooks to Modify Session Behavior or Transform Tool Results in the Copilot SDK

The Copilot SDK exposes a typed hook system that lets you intercept every major lifecycle event—from tool execution to session termination—by implementing handler methods and registering them with your session.

The github/copilot-sdk repository provides a multi-language toolkit for building AI-assisted workflows. Understanding how to add custom hooks to modify session behavior or transform tool results in Copilot SDK allows you to enforce security policies, rewrite prompts, and inject contextual metadata at runtime.

Understanding the Hook Architecture

The SDK implements a unified dispatcher pattern centered in rust/src/hooks.rs. When a lifecycle event occurs, the system deserializes the JSON payload into a HookEvent enum, invokes SessionHooks::on_hook, and validates that the returned HookOutput matches the requested hook type. If your handler returns HookOutput::None or a mismatched variant, the SDK treats the hook as unregistered and proceeds with default behavior.

You can intercept nine distinct stages:

  • preToolUse – Intercept before a tool executes to allow/deny or modify arguments
  • preMcpToolCall – Intercept before an MCP tool call is sent to the server
  • postToolUse – Transform successful tool results before they reach the model
  • postToolUseFailure – Add hidden guidance after a tool fails
  • userPromptSubmitted – Rewrite the user’s raw message before processing
  • sessionStart – Inject startup context when a session is created or resumed
  • sessionEnd – Execute cleanup actions when a session terminates
  • errorOccurred – Define retry policies or user notifications for SDK errors
  • agentStop – Block or allow the agent’s natural stop decision

Each hook follows a strict input-output contract. For example, preToolUse accepts a PreToolUseInput struct containing the tool name, arguments, and working directory, and expects a PreToolUseOutput struct containing permission decisions, modified arguments, or suppression flags.

Implementing Session Hooks in Rust

In Rust, you implement the SessionHooks trait from github_copilot_sdk::hooks using async_trait. Define your handlers, then attach them via SessionConfig::with_hooks.

use github_copilot_sdk::hooks::{
    HookOutput, PreToolUseInput, PreToolUseOutput,
    SessionStartInput, SessionStartOutput,
    SessionHooks,
};
use async_trait::async_trait;
use serde_json::json;

#[derive(Default)]
struct MyHooks;

#[async_trait]
impl SessionHooks for MyHooks {
    async fn on_pre_tool_use(
        &self,
        input: PreToolUseInput,
        _ctx: github_copilot_sdk::hooks::HookContext,
    ) -> Option<PreToolUseOutput> {
        if input.tool_name == "dangerous_tool" {
            Some(PreToolUseOutput {
                permission_decision: Some("deny".into()),
                permission_decision_reason: Some("policy block".into()),
                ..Default::default()
            })
        } else {
            None
        }
    }

    async fn on_session_start(
        &self,
        _input: SessionStartInput,
        _ctx: github_copilot_sdk::hooks::HookContext,
    ) -> Option<SessionStartOutput> {
        Some(SessionStartOutput {
            additional_context: Some(
                "You are now in a secure environment. Remember to avoid privileged commands."
                    .into(),
            ),
            ..Default::default()
        })
    }
}

Register the hooks when creating the session:

let config = SessionConfig::default().with_hooks(Arc::new(MyHooks));
let session = client.create_session(config).await?;

The trait definition and dispatcher logic reside in rust/src/hooks.rs, while session creation is handled in rust/src/session.rs.

Registering Hooks in Node.js

The Node.js SDK accepts a plain JavaScript object with camelCase method names. The registerHooks method in nodejs/src/session.ts serializes your handlers to the same JSON contract used by the Rust core.

import { CopilotClient } from "copilot-sdk";

const myHooks = {
  onPreToolUse: async (input, { sessionId }) => {
    if (input.toolName === "secret_reader") {
      return { 
        permissionDecision: "deny", 
        permissionDecisionReason: "no secrets allowed" 
      };
    }
    return undefined;
  },

  onPostToolUse: async (input, { sessionId }) => {
    const note = "**NOTE:** the following information was fetched automatically.";
    return { 
      modifiedResult: { 
        value: `${note}\n${input.toolResult.value}` 
      } 
    };
  },

  onUserPromptSubmitted: async (input) => {
    const prefixed = `[Team‑Assistant] ${input.prompt}`;
    return { modifiedPrompt: prefixed };
  },
};

async function main() {
  const client = new CopilotClient({ token: "…" });
  const session = await client.createSession({ hooks: myHooks });
}

The SDK invokes _handleHooksInvoke internally to route these calls through the CLI.

Adding Hooks in Python

Python implementations use a TypedDict structure defined in python/copilot/session.py. Pass a dictionary with snake_case keys to CopilotClient.create_session.

from copilot import CopilotClient, SessionHooks

def deny_secret_tool(input, ctx):
    if input["toolName"] == "read_secret":
        return {
            "permissionDecision": "deny",
            "permissionDecisionReason": "access to secrets prohibited"
        }
    return None

def add_failure_guidance(input, ctx):
    return {
        "additionalContext": f"The tool `{input['toolName']}` failed because: {input['error']}. "
                            "Consider checking the file path."
    }

my_hooks: SessionHooks = {
    "on_pre_tool_use": deny_secret_tool,
    "on_post_tool_use_failure": add_failure_guidance,
    "on_session_start": lambda i, c: {
        "additionalContext": "Welcome! Your session logs are encrypted."
    },
}

client = CopilotClient(token="…")
session = client.create_session(hooks=my_hooks)

The Python client stores hooks in a thread-safe field and forwards invocations via _handle_hooks_invoke.

Transforming System Messages with Section Callbacks

For advanced use cases requiring mutation of the system message itself, the Node.js SDK supports section transform callbacks. Register these via registerTransformCallbacks to dynamically inject content into specific sections like projectSummary.

session.registerTransformCallbacks(new Map([
  ["projectSummary", async (content) => {
    const dynamic = await computeProjectSummary();
    return `${dynamic}\n\n${content}`;
  }],
]));

This pattern allows you to prepend computed metadata to the model’s context without modifying individual tool results.

Summary

  • Hook types cover the full session lifecycle: tool execution (pre/post), failures, prompt submission, session start/end, errors, and agent stops.
  • Rust requires implementing the SessionHooks trait and attaching it via SessionConfig::with_hooks.
  • Node.js accepts plain objects with camelCase handlers via createSession({ hooks }) or session.registerHooks().
  • Python uses a TypedDict with snake_case keys passed to create_session(hooks=...).
  • Validation occurs in the core dispatcher at rust/src/hooks.rs; returning None or null skips the hook and continues with default behavior.
  • Section transforms provide a separate mechanism for modifying system message content in Node.js applications.

Frequently Asked Questions

Can I block a tool from executing using hooks?

Yes. Implement the preToolUse hook (or on_pre_tool_use in Python) and return a permissionDecision of "deny" with an optional permissionDecisionReason. The SDK will halt execution before the tool runs and return the denial reason to the model.

How do I modify the output of a failed tool call?

Use the postToolUseFailure hook, which receives the error message via PostToolUseFailureInput. Return additionalContext containing guidance or corrected instructions. This context is hidden from the user but visible to the model for recovery planning.

Are hooks supported in all Copilot SDK languages?

Yes. The SDK provides hooks for Rust, Node.js, and Python, though the implementation patterns differ. Rust uses a trait-based approach, Node.js uses plain objects with camelCase methods, and Python uses TypedDict with snake_case keys. All languages ultimately serialize to the same JSON contract handled by the Rust core in rust/src/hooks.rs.

What happens if my hook returns None or null?

Returning None in Rust, undefined or null in Node.js, or None in Python signals that the hook is not handling this specific invocation. The SDK proceeds with default behavior, allowing the session to continue unmodified. This is useful for conditional logic where you only intercept specific tools or error types.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →