> ## 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 add and customize tips

> Surface actionable Aspect CLI tips in the terminal, GitHub checks, Buildkite, and PR comments with add_tip and the tip_suggestion hook in config.axl.

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

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

```python title=".aspect/config.axl" theme={null}
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):

```python title=".aspect/config.axl" theme={null}
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

| Argument               | Type          | Notes                                                                                                                                                              |
| ---------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ctx`                  | `TaskContext` | The task the tip is added to.                                                                                                                                      |
| `id`                   | `str`         | **Stable 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. |
| `severity`             | `str`         | `TIP_IMPORTANT` / `TIP_WARNING` / `TIP_SUGGESTION` / `TIP_INFO`. Drives ordering and the emoji/label.                                                              |
| `title`                | `str`         | Short headline, interpreted per `type`.                                                                                                                            |
| `body`                 | `str`         | Markdown body, interpreted per `type`.                                                                                                                             |
| `type`                 | `str`         | Interpolation engine — `TIP_TEMPLATE_RAW` (default), `TIP_TEMPLATE_FORMAT`, or `TIP_TEMPLATE_JINJA2`. See [Template types](#template-types).                       |
| `key`                  | `str`         | Optional discriminator for same-`id` tips about different subjects (e.g. one flaky-test tip per label). Empty → a single tip per `id`.                             |
| `vars`                 | `dict`        | Fixed scalar values for `FORMAT` / `JINJA2` interpolation.                                                                                                         |
| `accumulate`           | `dict`        | List-valued Jinja2 fields that union across re-emits (deduped + sorted). `JINJA2` only.                                                                            |
| `surfaces`             | `list[str]`   | Allowlist of surfaces that may render the tip. Empty (default) shows it everywhere. See [Surfaces](#surfaces).                                                     |
| `combine_across_tasks` | `bool`        | How same-`(id, key)` tips from sibling tasks combine in the PR summary. See [Accumulating tips](#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:

| Severity            | Use for                                      | Silenceable? |
| ------------------- | -------------------------------------------- | ------------ |
| `TIP_IMPORTANT` 🔥  | Something the user really should act on now. | Yes          |
| `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. Severity only
affects ordering and the emoji/label; silencing is by `id` (see
[Silencing tips](#silencing-tips)), independent of severity.

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

| Identifier                      | Surface                           |
| ------------------------------- | --------------------------------- |
| `SURFACE_CLI`                   | The terminal `TIP:` block.        |
| `SURFACE_GITHUB_STATUS_CHECKS`  | GitHub check-run summary body.    |
| `SURFACE_BUILDKITE_ANNOTATIONS` | Buildkite annotations.            |
| `SURFACE_GITHUB_PR_SUMMARY`     | The 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:

```python title=".aspect/config.axl" theme={null}
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](/docs/cli/guides/repro-fix-suggestions).

```python title=".aspect/config.axl" theme={null}
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

| Verdict                                                                   | Effect                                                                                              |
| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `TIP_ACCEPT`                                                              | Keep the tip unchanged. Chain continues to the next hook.                                           |
| `TIP_REJECT`                                                              | Drop 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. |

<Note>
  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.
</Note>

### `TipInfo` fields

| Field                  | Type        | Notes                                                                     |
| ---------------------- | ----------- | ------------------------------------------------------------------------- |
| `task_path`            | `str`       | Joined `<group...> <kind>` CLI path.                                      |
| `task_kind`            | `str`       | The task kind — the command being run.                                    |
| `task_group`           | `list[str]` | The group prefix.                                                         |
| `id`                   | `str`       | The tip's stable id.                                                      |
| `key`                  | `str`       | The tip's discriminator (may be empty).                                   |
| `severity`             | `str`       | A `TIP_*` severity value.                                                 |
| `title`                | `str`       | Pre-rendered title — **empty for JINJA2 tips** (rendered after the hook). |
| `body`                 | `str`       | Pre-rendered body — **empty for JINJA2 tips**.                            |
| `type`                 | `str`       | The template type.                                                        |
| `surfaces`             | `list[str]` | The tip's surface allowlist (empty = all).                                |
| `combine_across_tasks` | `bool`      | The tip's cross-task combine policy.                                      |

### Examples

**Veto a tip by id:**

```python title=".aspect/config.axl" theme={null}
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:**

```python title=".aspect/config.axl" theme={null}
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:

```python title=".aspect/config.axl" theme={null}
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 tip without writing a hook, set the `Tips` feature's `silence` list to the `id`s you want to drop, or pass them on the command line:

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

def config(ctx: ConfigContext):
    ctx.features[Tips].args.silence = ["lint-style-guide"]
```

```bash theme={null}
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.

## See also

* [How to customize repro & fix suggestions](/docs/cli/guides/repro-fix-suggestions) — the sibling accept/reject/replace hook for the `🔁 Reproduce` / `🛠️ Fix` lines.
* [How to run and define tasks](/docs/cli/guides/basic) — task fundamentals and the `task()` definition surface.
* [Aspect Extension Language overview](/docs/cli/overview#aspect-extension-language) — what AXL is and why it's typed Starlark.
