Skip to content

Advanced Script Configuration

State Modifiers

By using state modifiers, you can determine the state of a configuration item while the user configures your script. Currently there are 2 states that can be modified. One to decide if a configuration item should be visible and the other to validate the item. To add a state modifier to your configuration method, configure the stateModifier property in the @ScriptConfigurationItem annotation. This accepts an object class of type StateModifier. For example:

interface MonitorUptimeConfiguration : ScriptConfiguration {

    @ScriptConfigurationItem(
        keyName = "website",
        name = "Website",
        description = "The website to monitor.",
        stateModifier = WebsiteStateModifier::class,
    )
    fun website(): String?

}

object WebsiteStateModifier : StateModifier {
    override fun getVisibility(scriptConfig: ScriptConfig): Visibility {
        return Visibility.VisibleRequired
    }

    override fun getError(scriptConfig: ScriptConfig): String? {
        val websiteValue = (scriptConfig.valueForKey("website") as? ScriptConfigValue.StringScriptConfigValue)?.value

        return if (websiteValue?.startsWith("http") != true) {
            "Website URL must start with http"
        } else {
            null
        }
    }
}

Configuration value types

ScriptConfig#valueForKey returns a ScriptConfigValue whose concrete type follows the return type of the configuration method it belongs to:

Configuration return type ScriptConfigValue subtype
String StringScriptConfigValue
Int IntScriptConfigValue
Float FloatScriptConfigValue
Double DoubleScriptConfigValue
Boolean BooleanScriptConfigValue
Enum EnumScriptConfigValue
List<T : ScriptConfigurationListItem> ListScriptConfigValue (one nested ScriptConfig per row, in items)
Proxy / RandomProxy ProxyGroupScriptConfigValue
Secret SecretScriptConfigValue (call value.reveal() for the plaintext)
any type with valuePerTask = true SequenceScriptConfigValue

Any of them can instead be NullScriptConfigValue when the user has not supplied a value yet.

Things to keep in mind when using state modifiers:

  • The state modifier implementations must be an object, not a class. If your script is obfuscated, your ProGuard configuration must also keep those objects — the Script Template does this for you, but a hand-rolled configuration needs a rule such as -keep class * implements com.cereal.sdk.statemodifier.StateModifier { *; }. A missing rule surfaces at runtime as the same "stateModifier which isn't an object" error as declaring it a class.
  • The configuration value retrieved using the ScriptConfig#valueForKey method can return a ScriptConfigValue#NullScriptConfigValue, even if the return type of the corresponding method in your configuration isn't nullable. This occurs because the user may not have provided a value for the requested item.
  • Configuration items with a stateModifier that can return Visibility.Hidden in the getVisibility method MUST have a nullable return type, otherwise, users will encounter validation errors and won't be able to start your script.
  • The return type for ScriptConfig#valueForKey in a configuration item where valuePerTask is set to true is either SequenceScriptConfigValue or NullScriptConfigValue.
  • A state modifier reading a Secret item receives the entered credential, so you can validate its format on the configuration screen rather than failing on authentication mid-run:
object ApiKeyStateModifier : StateModifier {
    override fun getVisibility(scriptConfig: ScriptConfig): Visibility = Visibility.VisibleRequired

    override fun getError(scriptConfig: ScriptConfig): String? {
        val apiKey = (scriptConfig.valueForKey("apiKey") as? ScriptConfigValue.SecretScriptConfigValue)?.value

        return if (apiKey != null && !apiKey.reveal().startsWith("sk-")) {
            "API keys start with sk-"
        } else {
            null
        }
    }
}

Complex lists

Available since SDK 1.11.0

Complex lists require cereal-sdk 1.11.0 or later. Set your manifest's sdk_version to 1.11.0 (or higher) to use them. On an older client, a script declaring 1.11.0 is refused at load time with a prompt for the user to update, rather than failing with an unsupported-type error.

A list configuration item is always a list of records — a list of purchase targets, each with a SKU, a quantity and a size. Declare the element type as an interface extending ScriptConfigurationListItem and annotate each field with @ScriptConfigurationItem. A list of single values is a record with one field:

interface Target : ScriptConfigurationListItem {
    @ScriptConfigurationItem(keyName = "sku", name = "SKU", description = "Product identifier", position = 0)
    fun sku(): String

    @ScriptConfigurationItem(keyName = "qty", name = "Quantity", description = "How many to buy", position = 1)
    fun qty(): Int

    @ScriptConfigurationItem(keyName = "size", name = "Size", description = "Which size to pick", position = 2)
    fun size(): Size

    @ScriptConfigurationItem(keyName = "notify", name = "Notify", description = "Notify on success", position = 3)
    fun notify(): Boolean?
}

interface PurchaseConfiguration : ScriptConfiguration {
    @ScriptConfigurationItem(keyName = "targets", name = "Targets", description = "Products to purchase", position = 0)
    fun targets(): List<Target>
}

Cereal renders one card per row, with the widget matching each field's type, and validates each field against its own type. Your script receives typed objects — no delimiters and no hand-parsing:

for (target in configuration.targets()) {
    provider.logger().info("Buying ${target.qty()} x ${target.sku()} (${target.size()})")
}

Complex lists vs per-task values

A complex list and valuePerTask = true both let the user enter many records, and both can be bulk-loaded from a CSV, so the two configuration surfaces look alike. They are not alternatives to each other, and picking the wrong one is not a matter of taste — the difference is who consumes the rows: Cereal or your script.

valuePerTask = true List<T : ScriptConfigurationListItem>
What one row becomes One task — a separate parallel run of your script One element of a collection handed to a single run
Task fan-out Yes; the user chooses how many tasks run concurrently None; every task receives the whole list
What your script reads This task's own single value The full list, which your script iterates itself
Permitted types Singular scalars only: String, Int, Float, Double, Secret A record whose fields may be String, Int, Float, Double, Boolean, an enum, or their nullable forms
Value in a state modifier SequenceScriptConfigValue ListScriptConfigValue, with one nested ScriptConfig per row
Who drives the iteration Cereal — one execute loop and one ExecutionResult per row Your script — ordering, retries and partial failures are yours to handle

The reason they look interchangeable is that several per-task items together also form a record-shaped table: one column per item, one row per task. But those columns are separate top-level configuration items that happen to line up by row, whereas a complex list is a single configuration value with genuine nested structure — its own row-level stateModifier, its own CSV import, and validation per field per row.

Choosing between them comes down to one question: should these records run concurrently and independently?

  • Yes — use valuePerTask = true. One URL per task, one account credential per task. The user-controlled concurrency comes for free.
  • No — use a complex list. A purchase basket, a rule table, a lookup mapping: data the run has to weigh as a whole, or data with a Boolean or enum field, which a per-task item cannot express at all.

Two asymmetries are worth knowing before you commit to a complex list: it is guaranteed to hold at least one row when non-nullable, and it supports no default values and no pre-seeded rows.

Neither can identify a script instance. isScriptIdentifier is ignored on a per-task item and rejected outright on a complex list.

Permitted field types

A record field must be one of String, Int, Float, Double, Boolean, an enum, or the nullable form of any of those. Proxy, RandomProxy, nested lists and nested record types are rejected when Cereal loads your script.

Cardinality and nullability

  • A non-nullable List<T> is guaranteed to hold at least one row when your script runs — the user cannot start the script with an empty list, so you do not have to defend against an empty collection.
  • A nullable List<T>? arrives as null when the user supplied no rows, so "not configured" is distinguishable from "configured as empty".
  • A nullable field within a row arrives as null when the user left it blank.
  • A minimum or maximum row count is expressed through the list item's own stateModifier, which can count the rows. There are no annotation parameters for it.

Validation

A row missing a value for a non-nullable field is a validation error on that field, and the configuration stays invalid until the user fills it in or removes the row. Rows are never silently dropped — that would lose data the user typed. A row the user never touched at all is not a row; it is simply ignored.

Importing rows from a CSV

Every complex list offers an Import from CSV action next to Add. You get it for free — there is nothing to declare in your script. Users download a template, fill it in with their spreadsheet and upload it; the rows appear as ordinary editable rows they can still correct, add to or remove before starting the script.

The contract, so you can tell your users how to build their files:

  • Column names are your keyNames. The downloaded template writes one header per record field, ordered by position. Matching is trimmed and case-insensitive, so SKU, sku and Sku all mean the same column.
  • Column order does not matter, and columns your record does not declare are ignored — a wider export from another system can be fed in as-is.
  • A column for a non-nullable field must be present. A column for a nullable field may be left out entirely, in which case that field is null on every row. A blank cell means "no value"; blank in a non-nullable field is an error naming the row and column.
  • Numbers must parse as their declared type. 12x in an Int column is reported with its row, column and text.
  • Booleans accept true/false, yes/no and 1/0, in any capitalisation. Anything else is rejected rather than read as false.
  • Enums must match one of your constants exactly. A value that matches none is rejected, and the message lists the constants that would have worked.
  • The separator is detected from the header row — comma, semicolon or tab — so an export from a European spreadsheet or a copy-paste out of one both work. The file is read as UTF-8; a byte-order mark is stripped and blank lines are skipped.
  • Duplicate rows are kept as given; nothing is deduplicated.
  • At most 5000 rows per file. A larger file is rejected with a message naming both its row count and the limit.
  • Import is all-or-nothing. A file with any problem imports nothing, and up to ten problems are reported together so the file can be fixed in one pass. A file with only a header row is reported as containing no rows.
  • Import replaces the list rather than appending to it, so re-importing a corrected file does not duplicate every row. The user is warned first if the list already holds rows they entered.

The imported rows are ordinary configuration values: they validate exactly like typed rows, they persist with the script configuration, and the file is not remembered — moving or deleting it afterwards changes nothing.

Reading the rows from a state modifier

ScriptConfig#valueForKey returns a ListScriptConfigValue whose items are themselves ScriptConfig views — one per row. A row's field is read with the same valueForKey call you already use for top-level items:

object TargetsStateModifier : StateModifier {
    override fun getVisibility(scriptConfig: ScriptConfig): Visibility = Visibility.VisibleRequired

    override fun getError(scriptConfig: ScriptConfig): String? {
        val rows = (scriptConfig.valueForKey("targets") as? ScriptConfigValue.ListScriptConfigValue)
            ?.items
            .orEmpty()

        if (rows.size > 10) return "At most 10 targets are supported."

        val firstSku = (rows.firstOrNull()?.valueForKey("sku") as? ScriptConfigValue.StringScriptConfigValue)?.value
        return if (firstSku != null && !firstSku.startsWith("SKU-")) "SKUs start with SKU-" else null
    }
}

A field the user left blank is absent from its row, so it reads back as NullScriptConfigValue.

Unsupported combinations

Cereal refuses to load a script whose configuration does any of the following, and the message names the fix:

Declaration Why it is rejected
valuePerTask = true on a complex list A complex list never fans out into tasks. Use Task data (valuePerTask on singular items) to start one task per row.
isScriptIdentifier = true on a complex list A list of records cannot identify a script instance. Mark a singular item instead.
A stateModifier on a field inside a record Visibility and validation belong to the list item itself, which already has a stateModifier.
A default implementation on a complex list, or on a field inside a record Complex lists have no default values and no pre-seeded rows.
An unsupported field type inside a record See permitted field types.
Two fields in one record sharing a keyName A copy-paste mistake would otherwise silently drop a column.

Field keys are namespaced under the list item's own key, so a record field may reuse the key name of a top-level configuration item.

Changing a record between script versions

Stored rows survive a script update. A stored key your record no longer declares is dropped, and a newly declared key is treated as unset — ordinary validation then asks the user to fill in any new non-nullable field before starting. Adding or removing a field does not destroy the values the user already entered.

Script identifier

When users create multiple instances of your script, Cereal displays them in a list, using the script name as an identifier. Consequently, users cannot easily determine at a glance how each script has been configured. To address this, you can set the isScriptIdentifier property of a single ScriptConfigurationItem to true. This allows Cereal to display the value of that configuration item next to the script name, helping the user identify it.

Some limitations apply when using this parameter: * Only one ScriptConfigurationItem in the entire script configuration can have isScriptIdentifier set to true. * If valuePerTask is set to true, or if the property is used in a child script configuration, it will be ignored. * An item returning Secret cannot be the script identifier. Cereal refuses to load a configuration that declares one. A masked identifier would make every instance of your script display identically and leave the user unable to tell them apart, and an unmasked one would defeat the whole point of the type. Pick a non-sensitive item — the account name or target URL the credential belongs to — as the identifier instead.