AppbrewDevelopers
Build

Extending app behavior

Hook into built-in actions to gate, reshape, replace, or observe them.

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; on older platform versions you have to modify app code instead.

The app object

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

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

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

transform

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

validate

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

override

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

the built-in handler

Runs unless an override already answered.

onSuccess / onError

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

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

ActionWhat it does
cart:addAdd line items to the cart
cart:update-quantityChange a line item's quantity
cart:removeRemove a line item
checkout:beginStart checkout
coupon:applyApply a discount code
gift:applyApply a gift
route:gotoLinkNavigate a link
config:init, config:refreshLoad 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 from @gauntlet/types. Inspect it with isOk / isErr, and turn a failure into user-facing copy with toDisplayMessage:

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

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

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.

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

For blocks this does the same job as blockRegistry.set(...) in Custom blocks, plus the undo.

On this page