# Extending app behavior (/docs/build/extending-app-behavior)



Blocks let you add UI. **Actions** let you change what the app *does* without the app owner editing their code: gate an add-to-cart, rewrite a checkout payload, fire your own event when a coupon applies.

This is the seam partner integrations should reach for first. It arrived in [0.27.0](/docs/changelog/0-27-0); on older platform versions you have to modify app code instead.

## The app object [#the-app-object]

`@gauntlet/brewery` exports a single `app` that owns registration:

```ts
import { app } from '@gauntlet/brewery'

app.components.register('block', 'promo-banner', PromoBanner)
app.trackers.add(new AcmeTracker())
app.modules.register('wishlist', createAcmeWishlist)
app.actions.extend('cart:add', { /* … */ })
app.events.on('config:ready', () => { /* … */ })
```

Every `register`/`add`/`extend` call **returns an undo**. Calling it restores exactly what was there before, which is what makes an integration safe to unload and re-register.

## Extending an action [#extending-an-action]

`app.actions.extend(id, lifecycle)` gives you five seams around a built-in action. They run in this order:

<Steps>
  <Step>
    ### transform [#transform]

    Reshape the input before anything else sees it: normalize, enrich, re-route.
  </Step>

  <Step>
    ### validate [#validate]

    Gate it. Any non-ok verdict **short-circuits the action**, and the caller gets your reason back instead of a silent no-op.
  </Step>

  <Step>
    ### override [#override]

    Replace the built-in behavior. The first extension to return `{ value }` wins; return nothing to fall through to the default handler.
  </Step>

  <Step>
    ### the built-in handler [#the-built-in-handler]

    Runs unless an `override` already answered.
  </Step>

  <Step>
    ### onSuccess / onError [#onsuccess--onerror]

    Fire-and-forget observation of the outcome. Both are isolated, so a throw here cannot fail the action or mask the error.
  </Step>
</Steps>

```ts
import { app } from '@gauntlet/brewery'
import { err, ok } from '@gauntlet/types'

app.actions.extend('cart:add', {
  validate: async (payload, ctx) => {
    const allowed = await acme.canPurchase(payload)
    return allowed ? ok() : err('This item is reserved for members.')
  },
  onSuccess: (result, payload) => acme.track('add_to_cart', payload),
})
```

Two things to know:

* **A gated run fires neither `onSuccess` nor `onError`.** Those two report on the *handler's* outcome; if `validate` blocked the run, the handler never happened. If you need to observe blocked runs, put that logic in `validate` itself.
* **A seam that throws is caught** and returned as the error arm, so callers only ever handle a `Result`, never a rejection.

Extensions run in registration order, and several can stack on one action.

## Built-in actions [#built-in-actions]

| Action                          | What it does                  |
| ------------------------------- | ----------------------------- |
| `cart:add`                      | Add line items to the cart    |
| `cart:update-quantity`          | Change a line item's quantity |
| `cart:remove`                   | Remove a line item            |
| `checkout:begin`                | Start checkout                |
| `coupon:apply`                  | Apply a discount code         |
| `gift:apply`                    | Apply a gift                  |
| `route:gotoLink`                | Navigate a link               |
| `config:init`, `config:refresh` | Load or reload app config     |

`app.actions.run(id, payload)` invokes one yourself, and `app.actions.define(id, handler)` adds your own for other code to extend.

## Every run returns a Result [#every-run-returns-a-result]

Every `run` returns a `Result` from `@gauntlet/types`. Inspect it with `isOk` / `isErr`, and turn a failure into user-facing copy with `toDisplayMessage`:

```ts
import { isErr, toDisplayMessage } from '@gauntlet/types'

const result = await app.actions.run('cart:add', payload)
if (isErr(result)) showToast(toDisplayMessage(result))
```

This is why gating matters: a blocked add-to-cart returns a reason you can show, instead of a button that just stops working.

## Events [#events]

`app.events.on(name, handler)` subscribes; `app.events.emit(name, payload)` publishes. The platform emits `config:ready` when app config has landed — the reliable place to run setup that needs config.

## Registering components [#registering-components]

`app.components.register(kind, key, Component)` covers every registry in one call — kinds are `block`, `screen`, `slot`, `icon`, `option`, `product-element`, `product-card`, and `appbar-element`. Known ids autocomplete; any other string is treated as a custom id.

```ts
const undo = app.components.register('block', 'promo-banner', PromoBanner)
```

For blocks this does the same job as `blockRegistry.set(...)` in [Custom blocks](/docs/build/custom-blocks), plus the undo.
