Skip to main content
Tips are non-fatal, actionable recommendations the Aspect CLI surfaces where the user is already looking — the terminal, GitHub check-run summaries, Buildkite annotations, and the aggregate PR summary comment — so the product is self-documenting. Users shouldn’t have to grep logs for WARN lines or read docs to discover an improvement. A tip has a severity (🔥 important / ⚠️ warning / 💡 suggestion / ℹ️ info), a title and body, and an identity that controls de-duplication and silencing. You add tips with add_tip from any code that has a TaskContext — most commonly a task_update hook in your .aspect/config.axl.
Requires Aspect CLI v2026.23.40 or newer.

Adding a tip

A task_update handler is the usual place to add a tip. The handler fires repeatedly over a task’s lifetime, but that’s fine: re-emitting the same (id, key) just replaces the stored tip (and the terminal only re-prints when the content actually changed), so a static tip can emit on every update without piling up or spamming:
.aspect/config.axl
load("@aspect//tips.axl", "add_tip", "TIP_SUGGESTION")
load("@aspect//traits.axl", "TaskLifecycleTrait", "TaskUpdate")

def _docs_tip(ctx: TaskContext, update: TaskUpdate) -> None:
    add_tip(
        ctx,
        id = "lint-style-guide",
        severity = TIP_SUGGESTION,
        title = "New to our lint rules?",
        body = "See the team style guide at https://wiki/lint-rules",
    )

def config(ctx: ConfigContext):
    ctx.traits[TaskLifecycleTrait].task_update.append(_docs_tip)
add_tip is also a no-op when the Tips feature is disabled (--tips:enabled=false), so emit sites never need to probe for it. If building the tip’s content is expensive, gate the work behind a once-flag. Keep the flag local to config and close over it — use a one-element list so the closure can mutate it (AXL, like Starlark, makes a plain assignment inside a nested function a new local, so a bare bool wouldn’t stick):
.aspect/config.axl
def config(ctx: ConfigContext):
    expensive_tip_added = [False]

    def _expensive_tip(ctx: TaskContext, update: TaskUpdate) -> None:
        if expensive_tip_added[0]:
            return
        expensive_tip_added[0] = True
        add_tip(ctx, id = "...", severity = TIP_SUGGESTION, title = "...", body = _build_body())

    ctx.traits[TaskLifecycleTrait].task_update.append(_expensive_tip)

add_tip arguments

ArgumentTypeNotes
ctxTaskContextThe task the tip is added to.
idstrStable identifier — the kind of tip and its silence key. Re-emitting the same (id, key) replaces the stored tip; id is what --tips:silence=<id> matches.
severitystrTIP_IMPORTANT / TIP_WARNING / TIP_SUGGESTION / TIP_INFO. Drives ordering, the emoji/label, and (for important) immunity from silencing.
titlestrShort headline, interpreted per type.
bodystrMarkdown body, interpreted per type.
typestrInterpolation engine — TIP_TEMPLATE_RAW (default), TIP_TEMPLATE_FORMAT, or TIP_TEMPLATE_JINJA2. See Template types.
keystrOptional discriminator for same-id tips about different subjects (e.g. one flaky-test tip per label). Empty → a single tip per id.
varsdictFixed scalar values for FORMAT / JINJA2 interpolation.
accumulatedictList-valued Jinja2 fields that union across re-emits (deduped + sorted). JINJA2 only.
surfaceslist[str]Allowlist of surfaces that may render the tip. Empty (default) shows it everywhere. See Surfaces.
combine_across_tasksboolHow same-(id, key) tips from sibling tasks combine in the PR summary. See Accumulating tips.

Tip identity

A tip is identified by the pair (id, key):
  • id is the kind of tip and the silence key. Two emits with the same (id, key) are the same tip — the later one replaces the stored content (so a task can revise a tip it made earlier). Silencing is always by id alone, across every key.
  • key is an optional discriminator for “same kind of tip, different subject.” For example, a flaky-test tip might use id = "flaky-test" with key = "//pkg:my_test" so each flaky target gets its own tip, while --tips:silence=flaky-test silences them all.

Severity ordering

Tips render most-urgent-first, stable within a bucket by insertion order. Severity is just a priority/visual signal — a tip can be about anything, not necessarily a failure or a feature:
SeverityUse forSilenceable?
TIP_IMPORTANT 🔥Something the user really should act on now.No — the silence list is ignored.
TIP_WARNING ⚠️Something worth attention but not blocking.Yes
TIP_SUGGESTION 💡An improvement or nicety to consider.Yes
TIP_INFO ℹ️Informational; FYI.Yes
Pick the level by how much you want it to stand out. The only behavioral difference is that TIP_IMPORTANT ignores the silence list (see Silencing tips).

Template types

The type argument selects how title / body are interpreted:
  • TIP_TEMPLATE_RAW (default) — no interpolation. Most tip bodies are literal markdown with {} that must survive verbatim, so this is the safe default.
  • TIP_TEMPLATE_FORMAT — Starlark str.format(**vars); {name} placeholders are replaced from vars.
  • TIP_TEMPLATE_JINJA2 — Jinja2 source rendered against vars (fixed scalars) plus accumulate (each name a deduped + sorted list). The source is retained so the tip re-renders whenever its accumulate state grows.

Surfaces

By default a tip shows on every surface. Pass surfaces to restrict it to an allowlist:
IdentifierSurface
SURFACE_CLIThe terminal TIP: block.
SURFACE_GITHUB_STATUS_CHECKSGitHub check-run summary body.
SURFACE_BUILDKITE_ANNOTATIONSBuildkite annotations.
SURFACE_GITHUB_PR_SUMMARYThe aggregate PR summary comment.
Three shorthands cover the common groupings:
  • TASK_SCREENS — every per-task surface (CLI + check run + Buildkite), but not the cross-task PR summary. Use this for per-invocation tips like deep links that don’t belong in a repo-wide rollup.
  • AGGREGATE_SCREENS — just SURFACE_GITHUB_PR_SUMMARY.
  • SURFACE_ALL — everything (equivalent to leaving surfaces empty).
The terminal print is gated on SURFACE_CLI; omit it to keep a tip off the terminal while still rendering it on status surfaces.

Accumulating tips

For a tip that grows as a condition recurs — e.g. “these N scopes were missing across the run” — use TIP_TEMPLATE_JINJA2 with accumulate. Each emit contributes one value per field; repeated emits union them (deduped, insertion-ordered), and the Jinja2 body re-renders against the merged set:
.aspect/config.axl
load("@aspect//tips.axl", "add_tip", "TIP_WARNING", "TIP_TEMPLATE_JINJA2")

def _emit_missing_scope(ctx, scope):
    add_tip(
        ctx,
        id = "missing-scopes",
        severity = TIP_WARNING,
        type = TIP_TEMPLATE_JINJA2,
        title = "Grant {{ scopes|length }} missing scope{{ 's' if scopes|length != 1 else '' }}",
        body = "{% for s in scopes %}- `{{ s }}`\n{% endfor %}",
        accumulate = {"scopes": scope},
        combine_across_tasks = True,
    )
combine_across_tasks = True unions the accumulated set across every sibling task into a single PR-summary row, instead of the default last-task-wins.

Customizing tips: the tip_suggestion hook

TipsTrait.tip_suggestion lets your config accept, reject, or rewrite any tip before any surface renders it. It’s the same accept/reject/replace shape as the repro_fix_suggestion hook.
.aspect/config.axl
load(
    "@aspect//tips.axl",
    "TIP_ACCEPT",
    "TIP_REJECT",
    "TipInfo",
    "TipSuggestion",
    "TipsTrait",
    "tip_replace",
)

def _hook(ctx: TaskContext, info: TipInfo) -> TipSuggestion:
    # ...inspect info, return a verdict...
    return TIP_ACCEPT

def config(ctx: ConfigContext):
    ctx.traits[TipsTrait].tip_suggestion.append(_hook)
Hooks run in registration order on every add_tip, before the tip reaches storage.

Verdicts

VerdictEffect
TIP_ACCEPTKeep the tip unchanged. Chain continues to the next hook.
TIP_REJECTDrop the tip entirely. Short-circuits the chain.
tip_replace(severity=, title=, body=, surfaces=, combine_across_tasks=)Rewrite the given fields; omit any to keep its prior value. Chain continues with the rewritten tip.
A tip_replace of title / body has no effect on a TIP_TEMPLATE_JINJA2 tip: its templates aren’t rendered until after the hook runs, so scope JINJA2 tips by id / key / type and rewrite policy fields (severity / surfaces / combine_across_tasks) instead.

TipInfo fields

FieldTypeNotes
task_pathstrJoined <group...> <name> CLI path.
task_namestrJust the task name.
task_grouplist[str]The group prefix.
idstrThe tip’s stable id.
keystrThe tip’s discriminator (may be empty).
severitystrA TIP_* severity value.
titlestrPre-rendered title — empty for JINJA2 tips (rendered after the hook).
bodystrPre-rendered body — empty for JINJA2 tips.
typestrThe template type.
surfaceslist[str]The tip’s surface allowlist (empty = all).
combine_across_tasksboolThe tip’s cross-task combine policy.

Examples

Veto a tip by id:
.aspect/config.axl
def _veto_style_guide(ctx: TaskContext, info: TipInfo) -> TipSuggestion:
    return TIP_REJECT if info.id == "lint-style-guide" else TIP_ACCEPT
Downgrade a warning to a suggestion:
.aspect/config.axl
def _soften(ctx: TaskContext, info: TipInfo) -> TipSuggestion:
    if info.id == "missing-scopes":
        return tip_replace(severity = TIP_SUGGESTION)
    return TIP_ACCEPT
Keep a tip off the PR summary by narrowing its surfaces:
.aspect/config.axl
load("@aspect//tips.axl", "TASK_SCREENS", "tip_replace")

def _no_rollup(ctx: TaskContext, info: TipInfo) -> TipSuggestion:
    if info.id == "lint-style-guide":
        return tip_replace(surfaces = TASK_SCREENS)
    return TIP_ACCEPT

Silencing tips

To suppress a non-important tip without writing a hook, append its id to the Tips feature’s silence list, or pass it on the command line:
.aspect/config.axl
def config(ctx: ConfigContext):
    ctx.features["tips"].args.silence.append("lint-style-guide")
aspect build //... --tips:silence=lint-style-guide   # repeat the flag for multiple ids
Each rendered tip’s footer names its own id and points at this knob. Important tips (TIP_IMPORTANT) ignore the silence list — they’re meant to always reach the user.

See also