04 · Shaping the Lifecycle with Hooks
Module 4 — Shaping Copilot CLI’s lifecycle with hooks
Section titled “Module 4 — Shaping Copilot CLI’s lifecycle with hooks”| ← Previous: Enhancing the test suite with remote and delegation | Next: Adding a new feature → |
|---|
The AI layer of the agent loop is probabilistic. It may or may not remember to run tests, lint or build after a change. Hooks are the deterministic layer underneath it, hosting shell commands the harness runs at defined lifecycle points regardless of what the model decides, feeding structured output back into the conversation so the next turn starts from reality rather than assumption.
What you will learn
Section titled “What you will learn”- How hooks fit into the agent execution loop and why they complement the already existing AI infrastructure from Module 2.
- The hook event model: which events fire when, what payloads they receive and what output they expect.
- Hook types:
command,httpandprompt- and when to reach for each. - Scoping hooks to specific locations and tool calls.
- How to wire up a
postToolUsehook that runs the right tests after each file edit and injects the results back into the agent’s context.
Scenario
Section titled “Scenario”At the end of Module 3 you delegated the test backfill to the Copilot cloud agent. Those tests now exist, but they run only when you explicitly ask, or when CI picks them up on a push. There is nothing that automatically runs the right checks right after the agent edits a file, while it still has context on what it just changed. Hooks close that gap.
Lifecycle hooks in Copilot CLI
Section titled “Lifecycle hooks in Copilot CLI”What a hook is
Section titled “What a hook is”A hook is a JSON-declared shell command, HTTP call or prompt that the Copilot CLI harness invokes synchronously at a named point in the agent loop. Hooks run outside the AI model - they are not prompts and the model does not decide when or whether they fire. The harness fires them unconditionally when a trigger event occurs, collects their stdout and injects it back into the agent’s context before the next turn.
Your instructions tell the model how to behave, while your hooks enforce behavior regardless of what the model does. This distinction matters. A postToolUse hook that runs pytest after every Python file edit is not a suggestion - it runs every single time.
Consider hooks for checks that are:
- Fast: hooks run synchronously, so a slow hook stalls the agent. Rule of thumb - Anything over ~30 seconds belongs in CI or a delegated job, not a hook.
- Deterministic: the output should be stable for the same input. A hook that flakes trains the agent (and you) to discount its output.
- Objective: pass/fail, lint errors, type errors. Anything requiring judgment (code review, architecture decisions) should stay in the conversation, not in a hook.
Types of hooks
Section titled “Types of hooks”Each hook entry declares a type field which can either be:
command- runs a shell command locally. Providebashand optionallypowershellfor cross-platform support. This is the most common type.http- POSTs the event payload as JSON to a URL. Useful for audit trails, webhook-triggered CI or external governance systems without a local script. HTTPS is required by default, but you can allow HTTP for localhost by settingCOPILOT_HOOK_ALLOW_LOCALHOST=1.prompt- auto-submits a text string or slash command into the session onsessionStart. Only fires on new interactive sessions (not on resume or in non-interactive-pmode).
How hooks receive events and return output
Section titled “How hooks receive events and return output”The harness emits named events across the session lifecycle and your hook configuration maps event names to arrays of hook entries.
| Event | When it fires | What your hook can do |
|---|---|---|
sessionStart | New or resumed session begins | Inject context (additionalContext) e.g., current branch, open issues |
sessionEnd | Session terminates | Logging, cleanup, notifications |
userPromptSubmitted | User submits a prompt | Intercept and short-circuit with a direct response, bypassing the model |
preToolUse | Before a tool call executes | Allow, deny or modify the tool’s arguments |
postToolUse | After a tool call completes successfully | Modify the tool result seen by the LLM or append additionalContext |
postToolUseFailure | After a tool call fails | Provide recovery guidance via additionalContext |
agentStop | The agent finishes a turn | Block the turn from closing, e.g., force another turn with a reason |
subagentStart | A subagent is spawned | Inject context into the subagent’s prompt |
subagentStop | A subagent completes | Block and force another subagent turn |
permissionRequest | CLI prompts the user for tool approval | Programmatically allow or deny |
For the test-and-lint feedback loop in this module, the events that matter most are:
postToolUse- to run checks after each file edit and feed results back to the agent loopagentStop- to optionally block the agent from finishing a turn if checks are red).
Reading hook input and writing hook output
Section titled “Reading hook input and writing hook output”Every hook entry of type command receives the full event payload as JSON on stdin. Your script reads it with INPUT=$(cat) and extracts fields with jq. The exact schema is event-specific, but all payloads share the common fields sessionId, timestamp and cwd.
For postToolUse - the event you’ll use most for validation in this module - the payload is:
{ sessionId: string; timestamp: number; cwd: string; toolName: string; toolArgs: unknown; // the exact args the tool was called with (e.g., { path: "...", new_str: "..." } for edit) toolResult: { resultType: "success"; textResultForLlm: string; };}Your script communicates back to the harness by writing a single JSON object to stdout. For postToolUse, the schema is:
{ modifiedResult?: { resultType: "success"; textResultForLlm: string; // replaces the tool result the LLM sees }; additionalContext?: string; // appended to what the LLM sees}For agentStop, the output schema is different:
{ decision: "allow" | "block"; reason?: string; // required when decision is "block" — becomes the prompt for the forced next turn}A "block" decision tells the harness to open another agent turn automatically, with reason as the injected prompt. This is how you will implement “if tests are red, the agent must address them before it can stop.”
Exit codes and fail-open vs. fail-closed
Section titled “Exit codes and fail-open vs. fail-closed”Exit codes are how a command hook communicates its own health back to the harness, separate from the JSON it writes to stdout. Every command hook produces an exit code, and the harness uses it to decide whether to treat the hook as successful, warn or fail, before it ever looks at the hook’s output.
| Exit code | Meaning |
|---|---|
0 | Success - stdout parsed as hook output if present |
2 | Warning - stderr surfaced, run continues |
| Other non-zero | Hook failure logged, run continues (fail-open) |
Most hook failures are fail-open - a crash, timeout or non-zero exit is logged and the agent continues. preToolUse is the deliberate exception. Because it sits in front of every tool call as a security gate, a command hook that crashes, times out or exits non-zero (other than exit 2) denies the tool call. Here, the harness refuses to let a broken guard silently become no guard at all.
Scoping hooks with matcher
Section titled “Scoping hooks with matcher”Every hook entry can include a matcher field - a regular expression matched against the tool name. Without a matcher, the hook fires for every tool call in that event, but with one, it fires only when the tool name matches.
For a postToolUse hook that should only fire when the agent edits or creates a file:
{ "type": "command", "matcher": "edit|create", "bash": ".github/hooks/scripts/test-router.sh", "timeoutSec": 60}Hook configuration files
Section titled “Hook configuration files”Hooks are declared in JSON files and require no explicit registration step. All you need to do is drop a correctly structured file in the right directory and restart Copilot CLI - it loads on startup.
The harness loads hooks from four sources, in order. Hooks from all sources for the same event are merged (not overwritten):
- Policy hooks: Machine-wide hooks installed by enterprise IT administrators. They load before everything else, cannot be disabled by
disableAllHooksand end users cannot modify or override them. - User hooks:
~/.copilot/hooks/*.json(or%USERPROFILE%\.copilot\hooks\on Windows). Personal hooks stored on your local machine, not version-controlled and not shared with teammates. This is ideal for desktop notifications, personal audit logging or any preference you don’t want to impose on the team. - Repository hooks:
.github/hooks/*.json. Checked into source control so every team member gets them automatically on clone. This is also the only source the cloud agent loads since user files and plugins do not exist in the cloud sandbox. - Plugin hooks: Declared inside each installed Copilot CLI plugin’s own directory and loaded automatically alongside other sources when a plugin is installed.
Every hook file must declare "version": 1 at the top level:
{ "version": 1, "hooks": { "postToolUse": [], "agentStop": [] }}Exercise 1: Wire up after-edit hooks for AssetTrack
Section titled “Exercise 1: Wire up after-edit hooks for AssetTrack”AssetTrack’s checks span four different stacks - .NET, Java, Python, TypeScript, so “run the tests” means a different command depending on which file the agent just touched and that’s an easy step to forget mid-session. That’s exactly the kind of deterministic, no-judgment-required work you read about above, so instead of relying on the model to remember, you’ll build:
-
a
postToolUsehook that:- inspects which file was just edited,
- routes to the right test runner for that stack,
- feeds the output back as
additionalContextso the next agent turn begins with the actual test result.
-
A second hook -
agentStop, that blocks the agent from finishing a turn if any stack’s checks are red.
-
Return to your codespace and open a terminal.
-
Create the directory for the hook scripts:
Terminal window mkdir -p .github/hooks/scripts -
Download
.github/hooks/scripts/test-router.sh, which reads the edited file path from thepostToolUsepayload, runs the right test runner for that stack and emits the result asadditionalContext. The same script also handlesagentStopby looking at changed files and blocking only when the relevant stack’s tests fail:Terminal window curl -o .github/hooks/scripts/test-router.sh \https://raw.githubusercontent.com/github-samples/advanced-copilot-cli/main/assets/04/.github/hooks/scripts/test-router.sh -
Make the script executable:
Terminal window chmod +x .github/hooks/scripts/test-router.sh -
Create the hook configuration file
.github/hooks/hooks.jsonand paste in the following content. This declares thepostToolUseandagentStophooks, both of which call the same script you just downloaded:{"version": 1,"hooks": {"postToolUse": [{"type": "command","matcher": "edit|create","bash": ".github/hooks/scripts/test-router.sh","timeoutSec": 60}],"agentStop": [{"type": "command","bash": ".github/hooks/scripts/test-router.sh","timeoutSec": 90}]}}
Exercise 2: Verify the hooks load
Section titled “Exercise 2: Verify the hooks load”-
Restart Copilot CLI to pick up the new configuration, then load the environment customizations with:
/envYou should see your
postToolUseandagentStopentries listed under Hooks. -
Test the hook script in isolation before relying on it in a session. From the repository root, pipe a synthetic payload and confirm the output is valid JSON:
Terminal window echo "{\"toolName\":\"edit\",\"toolArgs\":{\"path\":\"$PWD/services/reporting-svc/app/main.py\",\"old_str\":\"\",\"new_str\":\"\"},\"toolResult\":{\"resultType\":\"success\",\"textResultForLlm\":\"edited\"}}" \| .github/hooks/scripts/test-router.sh | jq .pytest runs and the script emits one JSON object:
{"additionalContext": "Hook check for services/reporting-svc/app/main.py passed.\nStack: Python\nCommand: cd services/reporting-svc && pytest\nExit code: 0\n..."}The
additionalContextvalue is what matters: it contains the command exit code, and last 40 lines of test output. That output is exactly what the harness appends to the tool result before the model reads it on its next turn. -
Test the
agentStoppath before making any stack changes. The hook files themselves do not map to a test stack, so it should allow the turn to finish:Terminal window echo '{}' | .github/hooks/scripts/test-router.sh | jq .Expected output:
{"decision": "allow"} -
The hook infrastructure is ready. Commit the hook config and script on this branch.
Create a new branch called add-lifecycle-hooks, commit the
.github/hooks/directory with a message explaining what the hooks do and why, then push and open a PR.
Exercise 3: Close the feedback loop on a real change
Section titled “Exercise 3: Close the feedback loop on a real change”With hooks wired up, prove the loop end-to-end: a change goes in, the postToolUse hook fires and reports results, and if the agentStop gate catches a failure, the agent addresses it before finishing the turn.
-
Start a new Copilot CLI session (or run
/newto reset context). Confirm the hooks are active with/env. -
Ask Copilot to add a small testable method. Pick the stack that matches your background.
For Java:
In AssignmentService, add a private helper method isOverdue(LocalDate dueDate) that returns true if dueDate is before today. Add a unit test for it in AssignmentServiceTest — no public API changes.For C# (.NET):
In the assets service, add a private helper method IsWarrantyExpiring(DateTime expiryDate) that returns true if expiryDate is within 30 days from today. Add a unit test for it — no public API changes.Or for Python:
In the reporting service, add a helper function days_until_expiry(expiry_date: date) -> int that returns the number of days until a warranty expires. Add a pytest test for it.Watch the agent’s tool calls. After the
editorcreatecall that writes the test file, the agent’s next turn should include the hook output in its visible context. -
Confirm the test output surfaced - ask the agent directly:
What did the test run report?The agent should be able to quote or summarize the test output from the
additionalContextthe hook injected. -
Now deliberately break the assertion to trigger the
agentStopgate. Ask Copilot to introduce a broken test, (purely for demonstration purposes):Change the assertion in the test you just added so it asserts the wrong expected value — make it definitely fail. -
Watch what happens when the agent tries to finish its turn. The
agentStoppath intest-router.shruns, detects the test failure and returns"decision": "block"with the failing command output in the reason.The harness opens a new agent turn automatically with that reason as the prompt. The agent should propose a fix without you typing anything. If the agent reports green tests but the gate still blocks, ask it to quote the block reason - the command, exit code and output in that reason can be treated as the source of truth.
-
Confirm the loop closes - the agent fixes the assertion, the tests pass on the next
postToolUsehook fire and theagentStoppath returns"decision": "allow". -
Revert the example changes so the branch stays clean for the PR, then confirm tests are green:
Revert the method and test you added, then confirm all tests pass.
Summary
Section titled “Summary”You’ve now:
- Explored hooks as the deterministic enforcement layer underneath the probabilistic model layer - they run unconditionally, feed structured output back via stdin/stdout JSON contracts and can block or modify agent actions.
- Authored a
postToolUsehook scoped to file edits that dispatches to the right test runner per stack and injects results asadditionalContext. - Wired an
agentStopgate that blocks the agent from finishing a turn while tests are red and forces a self-correction loop.
Next, you’ll put all of the infrastructure to work by adding a real new feature (barcode / QR support) from /plan through to merged PR in Module 5.
Resources
Section titled “Resources”| ← Previous: Enhancing the test suite with remote and delegation | Next: Adding a new feature → |
|---|