Skip to main content
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 pick a whole layout by name. This is the coarsest knob and the place to start.
  • Templates 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 runs immediately before each write and decides what happens to it: publish it, restyle it, rewrite the rendered content, or (Buildkite only) remove it.
The template sections work on any recent release, except the title key, which needs v2026.33.2, and the status_surface_update hook, which needs v2026.33.3.template_preset, and the one-line-per-task comment layout described here, need 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.
There are two surface families, and they template differently. 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:
.aspect/config.axl
Every status-surface feature takes it, and it is also a flag:
Two presets ship: 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 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, and tips at every severity. To stay on the previous layout, name it:
.aspect/config.axl
The PR and MR summary comments are unaffected either way: default defines no body, so those two surfaces render the same under both presets.
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.

Templates always win

A preset supplies the starting templates. Anything you set in args.templates overrides it, slot by slot:
.aspect/config.axl
Both layers understand scoping, and they resolve in this order, lowest precedence first: 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:
.aspect/config.axl
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:
The lint review-comment features (GithubLintComments, GitlabLintComments) take no preset — they have no template surface at all.

Presets and imported built-ins

One sharp edge worth knowing before you combine a preset with patching a built-in. 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:
.aspect/config.axl
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 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:
.aspect/config.axl
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:
.aspect/config.axl
Only the features you’ve enabled will use it; the rest are inert.
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.

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 layout, so select full alongside the patch — otherwise that one slot renders full while the rest of the body renders default:
.aspect/config.axl
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.
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: 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:
.aspect/config.axl
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: 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.
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.

Summary template variables

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: 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. 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.
.aspect/config.axl
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:
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:
.aspect/config.axl
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

Every cell is precomputed in AXL, so the template is a pure renderer — what a cell says changes in AXL, not in Jinja2.
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.

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:
.aspect/config.axl
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.
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.

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=):
.aspect/config.axl
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:
.aspect/config.axl
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 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:
.aspect/config.axl
That example is the common one: drop a task’s Buildkite annotation when it passes, so the Annotations tab only lists what needs attention.
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.

Verdicts

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

Per-surface vocabularies

style and body mean different things per surface, and a restyle must answer in that surface’s own vocabulary:
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.

Examples per surface

Buildkite — append a runbook link to failures, and drop passing annotations:
.aspect/config.axl
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:
.aspect/config.axl
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):
.aspect/config.axl
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:
.aspect/config.axl

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: 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 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.