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
onSuccessnoronError. Those two report on the handler's outcome; ifvalidateblocked the run, the handler never happened. If you need to observe blocked runs, put that logic invalidateitself. - 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
| 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 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.

