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

# How to customize CI status surfaces

> Customize what Aspect CLI reports on GitHub checks, PR and MR summary comments, GitLab commit statuses, and Buildkite annotations — with presets, templates, or by intercepting each write with a hook.

Aspect CLI reports each task's live state onto whichever CI surfaces you enable — GitHub check runs, a PR summary comment, GitLab commit statuses and MR notes, and Buildkite annotations.

There are three ways to change what those surfaces say, and they compose:

* **[Presets](#presets-picking-a-starting-layout)** pick a whole layout by name. This is the coarsest knob and the place to start.
* **[Templates](#templates-check-runs-commit-statuses-and-annotations)** decide what a surface renders, from structured task data. This is the right tool for authoring content, and overrides whatever the preset supplies.
* **[The `status_surface_update` hook](#the-status_surface_update-hook)** runs immediately before each write and decides what happens to it: publish it, restyle it, rewrite the rendered content, or (Buildkite only) remove it.

<Note>
  The template sections work on any recent release, except the `title` key, which needs [v2026.33.2](https://github.com/aspect-build/aspect-cli/releases/tag/v2026.33.2), and the `status_surface_update` hook, which needs [v2026.33.3](https://github.com/aspect-build/aspect-cli/releases/tag/v2026.33.3).

  `template_preset`, and the one-line-per-task comment layout described here, need [2026.36](https://github.com/aspect-build/aspect-cli/releases/tag/2026.36) or later.

  The `default` layout, scoping by renderer kind, and the `aspect-report.json` artifact ship in the first release after [2026.41](https://github.com/aspect-build/aspect-cli/releases/tag/2026.41).
</Note>

There are two surface families, and they template differently.

| Surface | Feature | Template keys |
| - | - | - |
| GitHub check runs | `GithubStatusChecks` | `title`, `summary`, `details` |
| Buildkite annotations | `BuildkiteAnnotations` | `title`, `summary`, `details` |
| GitLab commit statuses | `GitlabCommitStatuses` | `title`, `summary`, `details` |
| GitHub PR summary comment | `GithubStatusComments` | `body` |
| GitLab MR summary note | `GitlabStatusComments` | `body` |

The first three share one table of **per-task-kind renderers**, so a template written for one works on the others. The two comment surfaces render a single aggregate document covering every task, so they take one `body` key instead.

## Presets: picking a starting layout

A **preset** is a named bundle of templates — one coherent layout for a surface, chosen with a single arg instead of assembled slot by slot:

```python title=".aspect/config.axl" theme={null}
load("@aspect//feature/github_status_checks.axl", "GithubStatusChecks")

def config(ctx: ConfigContext):
    ctx.features[GithubStatusChecks].args.template_preset = "full"
```

Every status-surface feature takes it, and it is also a flag:

```bash theme={null}
aspect build //... --github-status-checks:template-preset=full
```

Two presets ship:

| Preset | Layout |
| - | - |
| `default` | The verdict with complete counts, the results links, the task's own findings with their repro commands, and the downloads. This is the default. |
| `full` | The complete report: every target bucket, the Bazel details umbrella, workspace status, build metadata and resolved options. |

`default` leaves out the runner metadata and the Bazel invocation internals — command line, invocation details, timing breakdown, build-graph and cache tables, workspace status, resolved options. None of it is discarded: every task that posts a status check uploads [`aspect-report.json`](#the-report-artifact) with the full metadata.

Findings that have no equivalent in the Build Results UI stay in the body, because the check is the only place a reviewer sees them — lint diagnostics with their auto-fix list and per-linter breakdown, delivery's per-target results and dedup keys, and the format and gazelle file lists with their patch download. So does anything you configured by hand: `ASPECT_WORKFLOWS_LINKS`, the keys you named in [`metadata_keys`](#surfacing-extra-bes-metadata), and [tips](/docs/cli/guides/tips) at every severity.

To stay on the previous layout, name it:

```python title=".aspect/config.axl" theme={null}
def config(ctx: ConfigContext):
    ctx.features[GithubStatusChecks].args.template_preset = "full"
    ctx.features[GitlabCommitStatuses].args.template_preset = "full"
    ctx.features[BuildkiteAnnotations].args.template_preset = "full"
```

The PR and MR summary comments are unaffected either way: `default` defines no `body`, so those two surfaces render the same under both presets.

<Note>
  If you patch the lint `details` template, note that `default` renders the per-linter breakdown from `linter_summaries` and `clean_linters`, which replace the `linters_chart` string the `full` layout uses. `linters_count` is unchanged.
</Note>

### Templates always win

A preset supplies the *starting* templates. Anything you set in `args.templates` overrides it, slot by slot:

```python title=".aspect/config.axl" theme={null}
def config(ctx: ConfigContext):
    ctx.features[GithubStatusChecks].args.template_preset = "full"
    # Everything else stays as `full` renders it.
    ctx.features[GithubStatusChecks].args.templates = {
        "lint": {"details": _LINT_DETAILS_TEMPLATE},
    }
```

Both layers understand [scoping](#scoping-an-override-to-one-renderer-or-task-kind), and they resolve in this order, lowest precedence first:

| Precedence | Layer |
| - | - |
| 1 (lowest) | The preset's flat `title` / `summary` / `details` |
| 2 | The preset's per-renderer blocks |
| 3 | The preset's per-task-kind blocks |
| 4 | Your flat keys in `args.templates` |
| 5 | Your per-renderer blocks in `args.templates` |
| 6 (highest) | Your per-task-kind blocks in `args.templates` |

The practical guarantee: a flat key you set by hand beats a per-kind block from the preset. Your config is never outranked by the layout it selected, so a future change to the shipped default cannot quietly take over a slot you already customized.

### Presets are chosen per surface

`template_preset` is a feature arg, so each surface picks its own:

```python title=".aspect/config.axl" theme={null}
ctx.features[GithubStatusChecks].args.template_preset = "full"
ctx.features[BuildkiteAnnotations].args.template_preset = "full"
ctx.features[GithubStatusComments].args.template_preset = "full"
```

A preset only has to cover the surfaces it is about. Selecting one on a surface it says nothing about leaves that surface on its built-in template:

| Preset defines | Check runs / annotations | PR and MR comments |
| - | - | - |
| `title` / `summary` / `details` | the preset's layout | built-in |
| `body` | built-in | the preset's layout |
| both | the preset's layout | the preset's layout |

<Note>
  The lint review-comment features (`GithubLintComments`, `GitlabLintComments`) take no preset — they have no template surface at all.
</Note>

### Presets and imported built-ins

One sharp edge worth knowing before you combine a preset with [patching a built-in](#patching-a-built-in-instead-of-replacing-it).

`SUMMARY_TEMPLATE` and friends are module constants, read when your config loads — before any arg is known. They are always the `full` layout, whatever `template_preset` is set to. Since `default` is the default, patching a constant now opts that slot back into `full` without saying so:

```python title=".aspect/config.axl" theme={null}
# template_preset is "default", but this slot renders `full`'s summary, patched.
ctx.features[GithubStatusChecks].args.templates = {
    "summary": SUMMARY_TEMPLATE.replace(_ANCHOR, _MINE),
}
```

That is the safe direction to fail — your explicit customization wins, per the table above — but it is rarely what you meant. Either select `full` deliberately alongside the patch, or write the slot from scratch against the [variables](#summary-template-variables) rather than patching a constant.

## Templates: check runs, commit statuses and annotations

Set `args.templates` to a dict with any of `title`, `summary` and `details`. Keys you omit keep their built-in template:

```python title=".aspect/config.axl" theme={null}
load("@aspect//feature/github_status_checks.axl", "GithubStatusChecks")

def config(ctx: ConfigContext):
    ctx.features[GithubStatusChecks].args.templates = {
        "summary": ":rocket: **Status:** `{{ last_update }}`",
    }
```

Because these three features share the renderer table, the same dict works on `BuildkiteAnnotations` and `GitlabCommitStatuses` — a template you write once can be reused across all of them:

```python title=".aspect/config.axl" theme={null}
load("@aspect//feature/buildkite_annotations.axl", "BuildkiteAnnotations")
load("@aspect//feature/github_status_checks.axl", "GithubStatusChecks")
load("@aspect//feature/gitlab_commit_statuses.axl", "GitlabCommitStatuses")

_SHARED = {"summary": ":rocket: **Status:** `{{ last_update }}`"}

def config(ctx: ConfigContext):
    ctx.features[GithubStatusChecks].args.templates = _SHARED
    ctx.features[BuildkiteAnnotations].args.templates = _SHARED
    ctx.features[GitlabCommitStatuses].args.templates = _SHARED
```

Only the features you've enabled will use it; the rest are inert.

<Note>
  Buildkite concatenates the rendered `title`, `summary` and `details` into a **single** Markdown annotation separated by a horizontal rule, where GitHub check runs keep them as distinct API fields. Buildkite hard-caps an annotation at 1 MiB; Aspect truncates from the tail if the body exceeds it. On GitLab commit statuses, only the rendered `title` lands on the status itself (in `description`, truncated to 255 characters) — the full body goes to the `GitlabStatusComments` MR note.
</Note>

### Patching a built-in instead of replacing it

Replacing a template wholesale means giving up everything the layout already renders. Usually you want to change *one line* and inherit the rest.

Import the built-in template and patch it. The exported constants are the [`full`](#presets-picking-a-starting-layout) layout, so select `full` alongside the patch — otherwise that one slot renders `full` while the rest of the body renders `default`:

```python title=".aspect/config.axl" theme={null}
load("@aspect//feature/buildkite_annotations.axl", "BuildkiteAnnotations")
load("@aspect//private/lib/bazel_results.axl", "SUMMARY_TEMPLATE")

_DEFAULT_LAST_UPDATE_LINE = """:speech_balloon: **Last update:** `{{ last_update }}`{% endif %}"""

_CUSTOM_LAST_UPDATE_LINE = """:buildkite: **Status:** `{{ last_update }}`{% if branch %} · :git: `{{ branch }}`{% endif %}{% endif %}"""

if _DEFAULT_LAST_UPDATE_LINE not in SUMMARY_TEMPLATE:
    fail("Aspect SUMMARY_TEMPLATE drifted; update _DEFAULT_LAST_UPDATE_LINE.")

_CUSTOM_SUMMARY_TEMPLATE = SUMMARY_TEMPLATE.replace(
    _DEFAULT_LAST_UPDATE_LINE,
    _CUSTOM_LAST_UPDATE_LINE,
)

def config(ctx: ConfigContext):
    ctx.features[BuildkiteAnnotations].args.template_preset = "full"
    ctx.features[BuildkiteAnnotations].args.templates = {
        "summary": _CUSTOM_SUMMARY_TEMPLATE,
    }
```

<Warning>
  **Always guard the patch with `fail()`.** `.replace()` on a string that doesn't contain the anchor is a silent no-op — your customization would just disappear the next time an Aspect CLI upgrade edits the built-in template, and the surface would quietly revert with nothing in the logs. The `fail()` turns that into a loud config-load error naming the constant to update.
</Warning>

These templates live under `private/lib/`, which means they are **not** a stability-guaranteed API — they can change between releases. That's exactly the risk the drift guard exists to catch, and it's the reason to anchor on the smallest snippet that uniquely identifies the line you're replacing.

Every per-kind renderer module exports both `SUMMARY_TEMPLATE` and `DETAILS_TEMPLATE`:

| Task kind | Module |
| - | - |
| build / test | `@aspect//private/lib/bazel_results.axl` |
| lint | `@aspect//private/lib/lint_results.axl` |
| format | `@aspect//private/lib/format_results.axl` |
| gazelle | `@aspect//private/lib/gazelle_results.axl` |
| delivery | `@aspect//private/lib/delivery_results.axl` |
| warming | `@aspect//private/lib/warming_results.axl` |

`SUMMARY_TEMPLATE` is identical across all six modules, so a patched summary applies cleanly everywhere — import it from whichever module is convenient. `DETAILS_TEMPLATE` is kind-specific.

### Scoping an override to one renderer or task kind

Flat `title` / `summary` / `details` keys apply to **every** task the feature renders. To narrow an override, nest it under a renderer kind or a task kind:

```python title=".aspect/config.axl" theme={null}
ctx.features[GithubStatusChecks].args.templates = {
    # Every task.
    "summary": _CUSTOM_SUMMARY_TEMPLATE,
    # Every task the format renderer draws — `format`, and aliases like `buildifier`.
    "format_results": {"details": _FILES_DETAILS_TEMPLATE},
    # Just the buildifier alias, overlaying the block above.
    "buildifier": {"details": _BUILDIFIER_DETAILS_TEMPLATE},
}
```

A nested block overlays the flat keys rather than replacing them, so you can set a shared default and still special-case one task. Both kinds of key are valid:

| Scope key | Matches |
| - | - |
| `bazel_results` | `build` and `test` |
| `lint_results` | `lint` |
| `format_results` | `format`, and every `format.alias` such as `buildifier` |
| `gazelle_results` | `gazelle` |
| `delivery_results` | `delivery` |
| `warming_results` | cache warming |
| `build`, `test`, `lint`, `format`, `gazelle`, `delivery`, `warming` | that task kind only |
| any task name | that task only — use this for an alias or a custom task |

Which one you want depends on what you are overriding. A `details` template is written against one **renderer's** data, so the renderer key is usually right: it covers every task that renders the same way, including aliases and custom tasks you add later. Reach for a task kind when you need to distinguish tasks that share a renderer — `build` from `test`, or one alias from the rest of `format_results`. A task-kind block outranks a renderer block, so you can set the renderer key and still special-case one task.

<Note>
  Scoping is optional and backward compatible: a `templates` dict with only flat keys applies to every task, and a dict keyed only by task kinds behaves exactly as it did before renderer keys existed.
</Note>

### Summary template variables

| Variable | Type | Notes |
| - | - | - |
| `last_update` | `str` | The task's current progress line — e.g. `build task complete`. |
| `detail_rows` | `list[list]` | Rows of `{icon, value}` chips — status, timing, user, commit, target and action counts, links. |
| `config_items` | `list` | `{key, value}` pairs for task config (lint Strategy, delivery Mode); empty for build/test. |
| `target_pattern` | `str` | The task's subject, e.g. `//...`. Empty for query-driven tasks. |
| `branch` | `str` | Branch name from BES metadata. |
| `tag` | `str` | Tag from BES metadata. |
| `short_sha` | `str` | First 7 characters of the commit SHA. |
| `commit_msg` | `str` | Commit message, truncated to 72 characters. |
| `tips` | `list` | Decorated tip rows — see [Tips](/docs/cli/guides/tips). |

`detail_rows` is the whole chip grid, and its rows drop out when empty, so indexes into it are not stable. These name the individual facts instead:

| Variable | Type | Notes |
| - | - | - |
| `status_icon` | `str` | Status emoji on its own. |
| `status_label` | `str` | `Succeeded`, `Failed`, `Running`… |
| `verdict_counts` | `str` | Every populated bucket — `1 target failed to build, 2 tests failed, 1 timed out`. Empty while running or aborted. |
| `state` | `str` | The single-bucket verdict clause, as the title uses. |
| `elapsed_text` | `str` | Task elapsed time, already formatted. |
| `aspect_url` | `str` | The Build Results UI link for this invocation; `""` when unavailable. |
| `bes_url` | `str` | A BES viewer that is **not** the Build Results UI; `""` when it would duplicate `aspect_url`. |
| `cached_tests_sentence` | `str` | e.g. `All 24 test targets were cached, so none were re-run.` Empty when nothing was cached. |
| `is_terminal` | `str` | Truthy once the task has finished, so a template can show progress only while running. |
| `operator_links` | `list` | `{text, url}` from `ASPECT_WORKFLOWS_LINKS`. |
| `pinned_metadata` | `list` | `{key, value}` for the keys named in [`metadata_keys`](#surfacing-extra-bes-metadata). |

String values interpolated from BES metadata are HTML-escaped before they reach the template.

### Title template variables

The `title` key renders the single line reviewers scan — the check-run title, or the heading of a Buildkite annotation.

| Variable | Type | Notes |
| - | - | - |
| `icon` | `str` | Status emoji — ✅ / ⚠️ / ❌ / 🔄 / 🔥. |
| `task_kind` | `str` | The task kind, e.g. `lint`. |
| `subject` | `str` | The task's target pattern, pre-truncated to fit the title cap. |
| `state` | `str` | The verdict clause the renderer computed — `Passed`, `3 errors, 8 warnings`, `Running...`, `12 delivered`. |
| `state_detail` | `str` | The count-complete clause, where `state` names one bucket out of several — `1 target failed to build, 2 tests failed, 1 timed out`. Set for build and test; empty elsewhere, because every other kind already counts everything it found. |
| `status` | `str` | Raw lifecycle status: `running`, `failing`, `passed`, `failed`, `warning`, `aborted`. |

`state` is the useful one: it carries the verdict Aspect already computed, so you can reorder or reword the title without reimplementing per-kind status logic. Write `{{ state_detail or state }}` to prefer the complete counts wherever they exist — that is what the `default` layout does, and it is what makes a GitLab commit status readable, since there the title is the entire payload.

```python title=".aspect/config.axl" theme={null}
ctx.features[GithubStatusChecks].args.templates = {
    "title": "{{ icon }} {{ task_kind }}{% if subject %} on {{ subject }}{% endif %}{% if state %} — {{ state }}{% endif %}",
}
```

Titles are truncated to GitHub's 160-character cap after rendering, so an override can't produce a title the API would reject.

Unlike the summary and details bodies, a `title` override never yields an error marker or a blank — it falls back to the **built-in layout** instead, since GitHub's check-run API requires `output.title` and an error string in place of the title would be worse than the default wording. That fallback covers both a template that fails to render *and* one that renders successfully but empty, which a syntactically valid template can do when every variable it references happens to be empty:

```python theme={null}
# Renders blank on a task with no target pattern, so the built-in layout is
# used for those runs instead.
"title": "{{ subject }}"
```

Either case is logged as a warning naming the template, so check the task log if a title isn't what you expected.

## Templates: PR and MR summary comments

`GithubStatusComments` aggregates every sibling CI job's task status into one PR comment; `GitlabStatusComments` renders the same body into a GitLab MR note. Both take a single `body` key, and `GitlabStatusComments` reuses GitHub's exported default template — so one patched body serves both hosts:

```python title=".aspect/config.axl" theme={null}
load("@aspect//feature/github_status_comments.axl", "DEFAULT_BODY_TEMPLATE", "GithubStatusComments")
load("@aspect//feature/gitlab_status_comments.axl", "GitlabStatusComments")

_DEFAULT_HEADING = """## Aspect Workflows Tasks"""
_CUSTOM_HEADING = """## Acme CI"""

if _DEFAULT_HEADING not in DEFAULT_BODY_TEMPLATE:
    fail("Aspect DEFAULT_BODY_TEMPLATE drifted; update _DEFAULT_HEADING.")

_CUSTOM_BODY = DEFAULT_BODY_TEMPLATE.replace(_DEFAULT_HEADING, _CUSTOM_HEADING)

def config(ctx: ConfigContext):
    ctx.features[GithubStatusComments].args.templates = {"body": _CUSTOM_BODY}
    ctx.features[GitlabStatusComments].args.templates = {"body": _CUSTOM_BODY}
```

Unlike the check-run templates, `DEFAULT_BODY_TEMPLATE` is a **public export** — patching it doesn't reach into `private/lib/`. Keep the drift guard anyway: the default body does change between releases as sections are added.

### Body template variables

| Variable | Notes |
| - | - |
| `marker` | HTML comment identifying the comment across updates. Keep it — Aspect finds the comment to update by this marker. |
| `started_at` | Earliest sibling-task start time, UTC. |
| `status_badges` | `{badge_key -> label}` map, see below. |
| `entries` | Per-task entries, alphabetical by `(job, kind, name)`, each decorated with `_severity`, `_badge_key`, `_links_text`, `_elapsed_text`, `_result_text`. |
| `sections` | `entries` bucketed into `running` / `failed` / `failing` / `flagged` / `successful`. Still passed for custom templates; the built-in body lists tasks in run order and no longer uses it. |
| `build_snippets` / `test_snippets` | Selected inline failure snippets. |
| `repro_rows` / `fix_rows` | Deduped repro and fix commands across all tasks. |
| `tip_rows` | Decorated tips — see [Tips](/docs/cli/guides/tips). |
| `*_overflow_note` | "N more not shown" line for each capped section. |
| `cli_version` | The running Aspect CLI version, for an attribution footer. |
| `last_updated_at` / `api_usage` | Footer timestamp and the GitHub API quota line. |

Every cell is precomputed in AXL, so the template is a pure renderer — what a cell *says* changes in AXL, not in Jinja2.

<Warning>
  Keep `{{ marker }}` in any custom body. Aspect locates the comment to update by that marker; without it every publish cycle would post a new comment instead of editing the existing one.
</Warning>

### Changing the row badges

If all you want is different status emoji, use `status_badges` instead of a template. It merges over the defaults, so unlisted keys keep their built-ins:

```python title=".aspect/config.axl" theme={null}
ctx.features[GithubStatusComments].args.status_badges = {
    "passed": "✅ ok",
    "failed": "❌ broke",
    "warning": "⚠️ check",
}
```

Keys are `badge_key` values — `passed`, `failed`, `warning`, `running`, `aborted` — which Aspect precomputes from severity and status, not raw lifecycle status.

## The report artifact

Every built-in task that posts a status check — build, test, lint, format, gazelle, delivery and cache warming — uploads `aspect-report.json` as a CI artifact and links it from the body's **Download** row.

It carries what the `default` layout leaves out of the body: per-mnemonic and per-runner action counts, local action-cache statistics, workspace status, build metadata, per-target results, timing, and the failure lists without the caps the rendered body applies.

It is shaped to be read by a tool rather than a person — raw integers and milliseconds instead of formatted durations, `snake_case` keys, no emoji, and local paths to test logs and XML beside their download URLs, so an agent running on the same machine can open them without a round trip. A `truncation` block records what a cap removed, so a consumer can tell a complete picture from a partial one, and the two cache counts carry the expression they were derived from rather than a bare percentage.

The resolved Bazel command line is deliberately absent: flag values carry credentials, and the redaction that makes them safe to render lives in the renderer. Use the repro commands in `commands.repro` instead — each carries the `target` it reproduces.

```bash theme={null}
# On a GitHub Actions runner, after the task has finished.
gh run download --name "aspect-report.json" --dir .
jq '.failures.failed_tests[] | {label, status}' aspect-report.json
```

<Note>
  The upload happens just before the task's terminal status update, so the link is present in the final body. While a task is still running the artifact does not exist yet, which is why the layout never defers a repro command to it.
</Note>

## Surfacing extra BES metadata

On the check-run surfaces, `metadata_keys` adds build metadata to the summary beyond the standard keys already rendered in the invocation rows (`BRANCH_NAME`, `COMMIT_SHA`, `TAG`, `COMMIT_MESSAGE`, `COMMIT_AUTHOR` and friends). Inject the values on the Bazel invocation with `--build_metadata=KEY=value` (or `--bes_metadata=`):

```python title=".aspect/config.axl" theme={null}
ctx.features[GithubStatusChecks].args.metadata_keys = ["DEPLOY_ENV"]
```

Pass `["*"]` to surface every key in the BES stream. Also available as a flag, e.g. `--github-status-checks:metadata-key=DEPLOY_ENV`.

## Buildkite-specific options

`expand_phases` names the phases whose Buildkite log sections open by default, so a failing annotation lands next to the log output that explains it:

```python title=".aspect/config.axl" theme={null}
ctx.features[BuildkiteAnnotations].args.expand_phases = ["build", "test"]
```

Buildkite auto-expands the last collapsed section when none is explicitly expanded, so expanding an earlier phase suppresses that auto-expansion of the closing timing-summary section.

Buildkite also renders only a [restricted subset of HTML and CSS](https://buildkite.com/docs/pipelines/annotations#supported-css-classes) in annotations. Inline `style` attributes are stripped, so use the Basscss utility classes Buildkite allows (`bg-blue-light`, `rounded`, `px1`, `border-top`) rather than custom CSS. Buildkite emoji shortcodes such as `:buildkite:` and `:git:` render natively.

## The `status_surface_update` hook

Templates decide what a surface *renders*. The `status_surface_update` hook, on
`TaskLifecycleTrait`, runs immediately before each surface *writes* and decides what
happens to that write. Register it from `.aspect/config.axl`:

```python title=".aspect/config.axl" theme={null}
load("@aspect//traits.axl", "SURFACE_PUBLISH", "SURFACE_REMOVE", "StatusSurfaceUpdate", "TaskLifecycleTrait")

def _drop_passing_annotations(ctx: TaskContext, update: StatusSurfaceUpdate):
    if update.surface == "buildkite_annotation" and update.final and update.status == "passed":
        return SURFACE_REMOVE
    return SURFACE_PUBLISH

def config(ctx: ConfigContext):
    ctx.traits[TaskLifecycleTrait].status_surface_update.append(_drop_passing_annotations)
```

That example is the common one: drop a task's Buildkite annotation when it passes, so the
Annotations tab only lists what needs attention.

<Warning>
  Despite the name parallel, this is **not** shaped like `task_update`. A `task_update`
  handler observes and returns nothing; a `status_surface_update` handler **must return a
  verdict**. A handler that falls off the end returns `None`, which Aspect reports and
  treats as publish-unchanged — return `SURFACE_PUBLISH` explicitly to leave a write alone.
</Warning>

### Verdicts

| Verdict | Effect |
| - | - |
| `SURFACE_PUBLISH` | Write as rendered. |
| `surface_restyle(style)` | Publish, overriding the styling. |
| `surface_replace(body, style=None)` | Publish an edited or wholly new body, optionally restyling too. |
| `SURFACE_REMOVE` | Suppress the write and retract anything already published. Buildkite annotations only. |

`SURFACE_REMOVE` **replaces** the write rather than following it, so the artifact never
flickers into view and the run spends one agent call instead of two. It's also **terminal**:
once retracted, that surface stops rendering and stops consulting hooks for the rest of the
task. A hook that wants the artifact back later should restyle rather than remove.

### What the hook receives

| Field | Notes |
| - | - |
| `surface` | `buildkite_annotation`, `github_check`, or `gitlab_commit_status`. A plain string, so compare with a literal. |
| `status` | Lifecycle status: `running`, `failing`, `warning`, `passed`, `failed`, `aborted`. |
| `final` | `True` on the task's terminal write. Hooks fire on live updates too — check this to act only on the outcome. |
| `style` | The styling this write would carry, in the surface's own vocabulary (see below). |
| `body` | The rendered content this write would publish, in the surface's own unit. |
| `task_kind` | The task kind — `build`, `lint`, … |
| `task_name` | The per-invocation task name, e.g. `lint-gha`. |

### Per-surface vocabularies

`style` and `body` mean different things per surface, and a restyle **must answer in that
surface's own vocabulary**:

| Surface | `style` | `body` | `SURFACE_REMOVE` |
| - | - | - | - |
| `buildkite_annotation` | `success`, `info`, `warning`, `error` | the whole annotation body | ✅ |
| `github_check` | `success`, `neutral`, `failure` — or `""` when this write lands no conclusion | the check run's details text | ❌ |
| `gitlab_commit_status` | `pending`, `running`, `success`, `failed`, `canceled`, `skipped` | the 255-char description (the only text a commit status carries) | ❌ |

<Note>
  The PR and MR **summary comments** deliberately don't consult this hook. A comment write
  merges every sibling task's status, with a server electing one poster per cycle, so the
  per-task `status` and `final` fields can't describe it — and one task retracting it would
  erase every other task's status. Customize those through their `body` template instead.
</Note>

### Examples per surface

**Buildkite — append a runbook link to failures, and drop passing annotations:**

```python title=".aspect/config.axl" theme={null}
load("@aspect//traits.axl", "SURFACE_PUBLISH", "SURFACE_REMOVE", "StatusSurfaceUpdate", "TaskLifecycleTrait", "surface_replace")

def _buildkite_only(ctx: TaskContext, update: StatusSurfaceUpdate):
    if update.surface != "buildkite_annotation":
        return SURFACE_PUBLISH
    if update.final and update.status == "passed":
        return SURFACE_REMOVE
    if update.status in ("failed", "failing"):
        return surface_replace(update.body + "\n\nRunbook: https://wiki/ci-failures")
    return SURFACE_PUBLISH
```

**GitHub check runs — report lint findings without blocking the PR.** A restyle here
overrides the check's *conclusion*, which is what decides whether it gates a merge:

```python title=".aspect/config.axl" theme={null}
load("@aspect//traits.axl", "SURFACE_PUBLISH", "StatusSurfaceUpdate", "TaskLifecycleTrait", "surface_restyle")

def _lint_never_blocks(ctx: TaskContext, update: StatusSurfaceUpdate):
    if update.surface == "github_check" and update.task_kind == "lint" and update.style == "failure":
        return surface_restyle("neutral")
    return SURFACE_PUBLISH
```

**GitLab commit statuses — prefix the description so a shared pipeline is attributable.**
A commit status carries no body, only the one-line description, so that is `update.body`
(re-truncated to 255 characters after a rewrite):

```python title=".aspect/config.axl" theme={null}
load("@aspect//traits.axl", "SURFACE_PUBLISH", "StatusSurfaceUpdate", "TaskLifecycleTrait", "surface_replace")

def _prefix_team(ctx: TaskContext, update: StatusSurfaceUpdate):
    if update.surface == "gitlab_commit_status":
        return surface_replace("[platform] " + update.body)
    return SURFACE_PUBLISH
```

**All surfaces at once — redact a secret pattern from whatever got rendered.** This is the
case templates can't reach, because the edit depends on the finished output:

```python title=".aspect/config.axl" theme={null}
load("@aspect//traits.axl", "SURFACE_PUBLISH", "StatusSurfaceUpdate", "TaskLifecycleTrait", "surface_replace")

def _redact_tokens(ctx: TaskContext, update: StatusSurfaceUpdate):
    if "ghp_" in update.body:
        return surface_replace(update.body.replace("ghp_", "ghp_[redacted]"))
    return SURFACE_PUBLISH
```

### Chaining and refusals

Handlers run in registration order, and edits thread through the chain: each handler sees
its predecessor's `style` and `body`, and a later plain `SURFACE_PUBLISH` keeps earlier
edits. On a publish, Aspect resolves the verdict before writing, so the surface always
publishes exactly what the chain settled on.

A verdict a surface can't honor is **refused rather than forwarded**, so one unscoped hook
can't corrupt the surfaces it wasn't written for:

* **A `SURFACE_REMOVE`** on a surface that can't retract becomes a publish. Check runs have
  no delete (they're update-only once created) and commit statuses are post-only.
* **A restyle outside the surface's vocabulary** leaves the incoming style standing. The
  vocabularies don't overlap, and forwarding blindly is destructive — Buildkite's `warning`
  matches no GitHub conclusion, and would otherwise conclude `failure`, turning a green
  check red.
* **A `None` return** is treated as publish-unchanged.

Each refusal is skipped rather than ending the chain, so a later handler still gets its say,
and each is logged — at warning level on the task's terminal write, so an unscoped hook is
noticed once per task rather than once per update. Scope with `update.surface` to avoid them.

A `SURFACE_REMOVE` the surface *can* honor is the one case that ends the chain: once the
artifact is being retracted there's nothing left for a later handler to edit.

## Testing your customizations

Nothing here can fail a task. Both mechanisms degrade instead:

| Problem | What happens |
| - | - |
| A `summary` or `details` template raises | That section falls back to a one-line `⚠️ … unavailable` body; a `WARNING` is logged. |
| A `title` template raises, or renders blank | Falls back to the built-in title layout; a `WARNING` is logged. |
| A hook returns `None`, a foreign style, or an unsupported `SURFACE_REMOVE` | The verdict is refused and the write publishes unchanged; logged at warning on the terminal write. |

That's good for reliability — a reporting bug never breaks a build — but it means a broken
customization is easy to miss. **Check the task log for warnings after changing one.**

A `fail()` drift guard is the exception, and deliberately so: any command that loads
`.aspect/config.axl` (even `aspect format --help`) exits non-zero with the message. That
makes it a cheap local check that your template anchors still match after a CLI upgrade.

Each feature is gated on its host — `BuildkiteAnnotations` on the `BUILDKITE` environment
variable, the GitHub features on GitHub Actions plus credentials, `GitlabCommitStatuses` on
a GitLab MR pipeline — and skips silently elsewhere. So a config change validates locally,
but the surface itself only appears on the CI host that owns it. A hook scoped to one
surface is simply never consulted on the others.

## A complete example

[bazel-examples](https://github.com/aspect-build/bazel-examples/blob/main/.aspect/config.axl)
customizes these in one config: a patched summary shared by the Buildkite annotation and the
GitHub check run, custom titles with a lint-scoped variant, a lint-scoped details callout, a
patched PR comment body reused for GitLab, and custom `status_badges`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.