- 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_updatehook 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.
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
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
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 inargs.templates overrides it, slot by slot:
.aspect/config.axl
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
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
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
Setargs.templates to a dict with any of title, summary and details. Keys you omit keep their built-in template:
.aspect/config.axl
BuildkiteAnnotations and GitlabCommitStatuses — a template you write once can be reused across all of them:
.aspect/config.axl
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 thefull layout, so select full alongside the patch — otherwise that one slot renders full while the rest of the body renders default:
.aspect/config.axl
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
Flattitle / 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
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
Thetitle 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
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:
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
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.
Changing the row badges
If all you want is different status emoji, usestatus_badges instead of a template. It merges over the defaults, so unlisted keys keep their built-ins:
.aspect/config.axl
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 — uploadsaspect-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
["*"] 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
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
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
.aspect/config.axl
update.body
(re-truncated to 255 characters after a rewrite):
.aspect/config.axl
.aspect/config.axl
Chaining and refusals
Handlers run in registration order, and edits thread through the chain: each handler sees its predecessor’sstyle 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_REMOVEon 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
warningmatches no GitHub conclusion, and would otherwise concludefailure, turning a green check red. - A
Nonereturn is treated as publish-unchanged.
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 customstatus_badges.
