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#valueForKeymethod can return aScriptConfigValue#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
stateModifierthat can returnVisibility.Hiddenin thegetVisibilitymethod 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#valueForKeyin a configuration item wherevaluePerTaskis set to true is eitherSequenceScriptConfigValueorNullScriptConfigValue. - A state modifier reading a
Secretitem 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
Booleanor 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 asnullwhen the user supplied no rows, so "not configured" is distinguishable from "configured as empty". - A nullable field within a row arrives as
nullwhen 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 byposition. Matching is trimmed and case-insensitive, soSKU,skuandSkuall 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
nullon 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.
12xin anIntcolumn is reported with its row, column and text. - Booleans accept
true/false,yes/noand1/0, in any capitalisation. Anything else is rejected rather than read asfalse. - 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.