Skip to content
The mobile SDKs are available on request. Email support@grona.ai to get access.

Handle actions

This page implements GronaHost, the protocol Grona uses to act inside your app. When a shopper taps a Grona call to action, or your code calls an action on a decision, the SDK checks the request and then calls your host. Your cart, your catalogue and your router do the work, so prices, stock rules and navigation stay yours.

GronaHost is a main-actor protocol, and the SDK calls its methods on the main actor only. It calls add, replace and navigate only from a shopper’s tap or from your own call on a decision, never on a timer or on its own. It calls product(id:) whenever it resolves content for a slot or a decision. That can follow a basket change, a grant, a refresh or a timer, so keep product(id:) a fast in-memory lookup with no side effects.

Method Called when Return
product(id:) The SDK resolves content for a slot or a decision, or checks an action, and needs a product’s name, price, image or stock state A GronaProduct, or nil when the product is unknown
add(_:source:) The shopper tapped an add on a Grona banner, or your code called add or addAll on a decision A GronaAddResult
replace(productID:with:source:) Your code called replace(with:) on a substitute decision A GronaAddResult
navigate(to:source:) The shopper tapped a Grona call to action that opens a screen true when a screen opened, false otherwise
AcmeGronaHost.swift
import Grona
@MainActor
final class AcmeGronaHost: GronaHost {
private let services: AcmeServices
init(services: AcmeServices) {
self.services = services
}
private var catalog: AcmeCatalog { services.catalog }
private var cart: AcmeCart { services.cart }
private var router: AcmeRouter { services.router }
func product(id: String) -> GronaProduct? {
guard let item = catalog.item(id: id) else { return nil } // in memory, no I/O
return GronaProduct(
id: item.id,
name: item.name,
priceMinor: item.priceMinor,
wasPriceMinor: item.wasPriceMinor,
imageURL: item.imageURL,
inStock: item.inStock
)
}
func add(_ lines: [GronaLine], source: GronaSource) -> GronaAddResult {
let added = lines.filter { cart.add(productID: $0.productID, quantity: $0.quantity) }
if added.isEmpty { return .rejected }
return added.count < lines.count ? .partial : .accepted
}
func replace(productID: String, with line: GronaLine, source: GronaSource) -> GronaAddResult {
guard cart.contains(productID: productID) else { return .rejected }
let replaced = cart.replace(productID: productID, with: line.productID, quantity: line.quantity)
return replaced ? .accepted : .rejected
}
func navigate(to route: GronaRoute, source: GronaSource) -> Bool {
switch route.type {
case .home:
return router.openHome()
case .deals:
return router.openDeals()
case .cart:
return router.openCart()
case .checkout:
return router.openCheckout()
case .product:
guard let id = route.id else { return false }
return router.openProduct(id)
case .category:
guard let id = route.id else { return false }
return router.openCategory(id)
default:
return false // a route type from a newer configuration
}
}
}

Here AcmeServices, AcmeCatalog, AcmeCart and AcmeRouter stand for your app’s own types. Pass AcmeGronaHost(services: .shared) to Grona.start, as Initialise the SDK shows. Each router call returns true when it opened a screen. The cart changes go through your normal cart code, so your cart’s change notification pushes the new basket to Grona as it does for any other change.

product(id:) must be a fast in-memory lookup with no side effects. The SDK calls it on the main actor whenever it resolves content or checks an action, including after a basket change, a grant, a refresh or a timer, so do not read the disk or the network inside it. Return nil for products you do not know.

GronaProduct parameter Default Meaning
id Required The product id, such as sku_10432.
name Required The display name.
priceMinor Required The price in minor units, so 349 is 3.49.
wasPriceMinor nil A previous price, for strike-through copy.
imageURL nil The product image.
inStock true Whether the product can be added now.

Prices, totals and product names in Grona copy always come from this data, never from the configuration. A product your host does not know, or reports out of stock, is dropped from decisions and from add actions.

GronaAddResult Return it when
.accepted Every line was added, or the replacement was made.
.partial Some lines were added and some were not.
.rejected Nothing changed.

The SDK logs the result with the action, so return what actually happened in your cart.

Two options bound what one action may add. Both default to 5 and accept 1 to 20.

Option Limit
maxAddQuantity The highest quantity of one product in one action.
maxAddLines The most lines in one action.

Before calling add, the SDK keeps only lines with a quantity from 1 to maxAddQuantity, a product your host knows and reports in stock, and no repeated product. It keeps at most maxAddLines of them. If no line is left, the action returns .rejected and your host is not called. A replacement uses the cart line’s quantity, capped at maxAddQuantity.

Set the caps in GronaOptions if your cart has its own limits:

let options = try GronaOptions(
workspaceKey: "acme-market",
configKeys: ["acme-market-2026-10": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE..."],
maxAddQuantity: 3,
maxAddLines: 4
)

GronaRoute is a typed destination. Grona never passes raw links, so your own router decides what each route opens. route.type is a GronaRouteType, a struct rather than an enum, so a switch over it needs a default case for types from newer configurations.

route.type Raw value route.id
.home home nil
.product product A product id, such as sku_10432
.category category A category id, such as dairy
.cart cart nil
.checkout checkout nil
.deals deals nil

Return false for a route type you do not recognise, an id you cannot find, or a screen you cannot open right now, such as checkout with an empty cart. Return true only when a screen actually opened.

Every call carries a GronaSource that says which campaign the action came from.

Property Meaning
placement The placement, such as home_top
campaignID The campaign
variantID The variant within the campaign

You do not need it to complete the action. Use it if your own analytics should record where an add came from.