> ## Documentation Index
> Fetch the complete documentation index at: https://site.aspect.build/llms.txt
> Use this file to discover all available pages before exploring further.

# How to run code before and after a task

> Run AXL before and after any Aspect CLI task with ctx.hooks.pre_task and ctx.hooks.post_task; post-task hooks receive the TaskConclusion, including on early exit and on error.

Every task the Aspect CLI runs goes through the same lifecycle: features initialize, the task body runs, and the runtime prints a closing bookend with the verdict. `ctx.hooks` lets your `.aspect/config.axl` or a feature run AXL at the two seams of that lifecycle:

* `ctx.hooks.pre_task(fn)` runs `fn(ctx)` right before the task body.
* `ctx.hooks.post_task(fn)` runs `fn(ctx, conclusion)` right after it, **however the body ended**: a normal return, an early `ctx.std.process.exit`, or an error.

The `ctx` a hook receives is the running task's `TaskContext`, the same one the body gets, so a hook can read `ctx.task`, `ctx.args`, and `ctx.traits`, call `ctx.std`, or make HTTP requests. `conclusion` is the `TaskConclusion` the runtime resolved for the task.

<Note>
  Task hooks were added in Aspect CLI <a href="https://github.com/aspect-build/aspect-cli/releases/tag/v2026.38.16">v2026.38.16</a>. Pin that version or newer in <code>.aspect/version.axl</code>; see <a href="/docs/cli/version-pinning">version pinning</a>.
</Note>

## Register hooks from `config.axl`

Hooks registered in `config.axl` apply to every task in the repository. This is the place for repository-wide policy: a precondition every task must meet, or a check on how every task ran. The example below refuses to run on a dirty working tree, and warns when a task that passed took longer than its budget, a signal that the remote cache or the runner size needs a look.

```python title=".aspect/config.axl" theme={null}
load("@std//time.axl", "time")

# Wall-clock budgets per task, in minutes. Overrunning one does not fail the
# task; the warning is a prompt to look at cache hit rates or runner size.
_BUDGET_MINUTES = {"build": 15, "test": 30, "lint": 10}

def _require_clean_tree(ctx: TaskContext) -> None:
    git = ctx.std.process.command("git").args(["status", "--porcelain"]).stdout("piped")
    if git.spawn().wait_with_output().stdout.strip():
        ctx.std.process.exit(1, "Commit or stash your changes before running CI tasks.")

def config(ctx: ConfigContext):
    started = {}

    def _stamp_start(tctx: TaskContext) -> None:
        started[tctx.task.id] = time.now_ms()

    def _check_budget(tctx: TaskContext, outcome: TaskConclusion) -> None:
        budget = _BUDGET_MINUTES.get(tctx.task.kind)
        if budget == None or outcome.exit_code != 0:
            return
        minutes = (time.now_ms() - started[tctx.task.id]) // 60000
        if minutes > budget:
            print("WARNING: {} passed but took {} min, over its {} min budget".format(tctx.task.kind, minutes, budget))

    ctx.hooks.pre_task(_require_clean_tree)
    ctx.hooks.pre_task(_stamp_start)
    ctx.hooks.post_task(_check_budget)
```

The two hooks share state through a dict that `config` closes over: `_stamp_start` records when each task began, and `_check_budget` reads it back once the task has ended. Only a passing task is measured, since a failure has a more important story to tell.

An `exit` inside a pre-task hook stands in for the body. If `_require_clean_tree` refuses, the task never runs, the message prints as an `ERROR:` line with no traceback, and the post-task hooks still run with that conclusion.

## Register hooks from a feature

A feature can register the same hooks from its implementation. Package the behavior as a feature when it should be reusable across repositories, or when it needs the feature's own `args`.

```python title=".aspect/verdicts.axl" theme={null}
def _notify(ctx: TaskContext, outcome: TaskConclusion) -> None:
    line = ctx.task.friendly_name + (" passed" if outcome.exit_code == 0 else " failed")
    if outcome.message:
        line += ": " + outcome.message.split("\n")[0]
    ctx.http.post(
        url = ctx.args.webhook,
        headers = {"Content-Type": "application/json"},
        data = json.encode({"text": line}),
    ).block()

def _impl(ctx: FeatureContext) -> None:
    ctx.hooks.post_task(_notify)

Verdicts = feature(
    implementation = _impl,
    args = {"webhook": args.string()},
)
```

A failure in the body reaches `_notify` with `outcome.exit_code == 1` and the failure text as `outcome.message`; the traceback still prints afterwards, as it would without the hook.

## Register a post-task hook from the task body

A task body can register post-task hooks too. Use one instead of `ctx.defer` when the cleanup depends on how the task ended, such as keeping a scratch directory for inspection only when the task failed.

```python title=".aspect/lint.axl" theme={null}
def _impl(ctx: TaskContext) -> int:
    work = ctx.std.fs.mkdtemp(prefix = "aspect-lint-")

    def _cleanup(c: TaskContext, outcome: TaskConclusion) -> None:
        if outcome.exit_code == 0:
            c.std.fs.remove_dir_all(work)
        else:
            print("kept " + work + " for inspection")

    ctx.hooks.post_task(_cleanup)
    ...
```

Pre-task hooks cannot be registered from the body: by then the body has started, so the call is an error. Register them from `config.axl` or a feature.

## What a post-task hook receives

| Field       | Type            | Meaning                                                                                                                                              |
| ----------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `exit_code` | `int`           | The task's final exit code, after the runtime's own checks. `0` is a pass.                                                                           |
| `text`      | `str`           | The bookend suffix a task returned via `TaskConclusion(text = ...)`, or `""`.                                                                        |
| `flagged`   | `bool`          | `True` when the task passed with a warning and the bookend reads "Flagged".                                                                          |
| `message`   | `str` or `None` | Why the task ended: the `message` of a returned `TaskConclusion`, the message passed to `ctx.std.process.exit`, or the one-line summary of an error. |

## Order of execution

For a task with hooks from every source, the runtime runs:

1. Pre-task hooks, in registration order. Hooks from `config.axl` come before hooks from features, since configs evaluate first.
2. The task body.
3. Post-task hooks, in registration order.
4. `ctx.defer` callbacks, in reverse registration order.
5. The closing bookend.

A post-task hook that raises an error is reported as a `WARNING:` line and does not change the task's exit code; the remaining hooks still run. The same is true of a failing `ctx.defer` callback.

## Hooks or defer?

| Use                       | When                                                                                                                            |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `ctx.defer(fn, *args)`    | Cleanup that needs no knowledge of how the task ended: close a file, kill a subprocess, remove a temp file.                     |
| `ctx.hooks.post_task(fn)` | Anything that needs the outcome: reporting, notifications, closing a status surface with the real verdict, conditional cleanup. |
| `ctx.hooks.pre_task(fn)`  | A precondition or setup step that must run before any task body, registered from `config.axl` or a feature.                     |

The built-in `build` and `test` tasks use a post-task hook themselves: when a task ends before its normal conclusion, the hook closes the GitHub check run, Buildkite annotation, or GitLab commit status with the real verdict and the message the task ended on.

## Related

* [Cleanup with defer](/docs/cli/guides/basic#cleanup-with-defer) in the basic guide.
* The `TaskContext`, `FeatureContext`, and `ConfigContext` references list every attribute available to a hook: [TaskContext](/docs/axl/types/task_context), [FeatureContext](/docs/axl/types/feature_context), [ConfigContext](/docs/axl/types/config_context).
