An action is a single-key JSON object where the key names the action kind and the value is the action payload. Actions are the sole mechanism for side effects in StemJSON.
Applies a partial update to the current module’s state, with name-matched upward propagation to ancestor modules that have declared a key of the same name. See §11.3.
repo
Executes a CRUD operation on a named repository dependency.
ai
Calls an AI provider through a remote repository and binds the (optionally unwrapped) result. Provider-agnostic.
template
Invokes a reusable action registered as a dependency.
listen
Subscribes to realtime repository updates (e.g. Firebase). Fires the notification action on each update.
service
Invokes a device or platform service (push notifications, audio playback, etc.).
navigate
Programmatically pushes, pops, or resets a navigation stack.
condition
Evaluates a predicate expression and branches to one of two action arrays.
interval
Fires output actions repeatedly at a fixed delay. Cancellable by ID.
A state action applies a partial-merge write and does exactly one of two things, depending on whether output.success is present:
No output.success chain → the write propagates. The runtime first applies a local merge gated by the dispatching module’s declarations (matched keys updated locally; unmatched keys silently dropped from the local write). It then propagates upward by name match: every key in input is offered through the component hierarchy, and any ancestor that has declared a key of the same name receives a partial-merge update with that value (see §11.3). Ancestors without a matching declaration are unaffected. The dispatching module is not required to declare the key for it to reach an ancestor that does — propagation gating is independent of local-declaration gating.
output.success chain present → the write stays local. The local merge runs as above, but no upward propagation occurs. The chain then runs in the dispatching module’s context, preserving the ${...} / @{item} scope it was authored against. This is what makes chained repo.persist, service calls, and local UI-feedback writes resolve correctly.
Rationale for the chain/propagation exclusivity. A chain expresses sequenced async work scoped to one context — the dispatching module’s. If the same write also propagated, ancestors that received the propagation would each be a candidate to re-fire the chain in their own (different) scope: ${...} paths would resolve against the ancestor’s state, @{item} might be absent entirely, and the chain’s behavior would depend on hierarchy depth. To keep chain semantics deterministic and local, the runtime trades propagation for chain-context fidelity. Authors who need both effects split into two separate writes (see pattern below).
// Pattern: need to propagate entries upward AND sequence a persist call.
// Two separate state actions dispatched in parallel from the same event array.
// 1. Chain-less write — propagates entries upward to the root tracker.
Update each listed state key with the corresponding value (partial merge of declared keys).
Reference / expression → dictionary
”input”: ”@{some_dict_key}“
A reference or {{ … }} expression that RESOLVES to a dictionary; merged exactly as the inline dictionary form.
input must be — or resolve to — a dictionary. A value that is not a dictionary (a bare string or number, or a reference/expression that resolves to a scalar) writes nothing: no state key changes, and there is no form that assigns a scalar to a state key inferred from the action’s id.
⚠ Important: State update is a partial merge of known keys only — it never adds new keys. Keys declared in the module’s state block are the only keys that can ever be updated.
Chain rules. A state action accepts output.success to sequence subsequent async work (typically repo, service, interval, listen, condition, template, navigate). It does NOT accept output.failure — state writes never fail. And the actions inside output.success MUST be non-state: a state action chained directly inside another state action’s output.success is redundant (state-to-state sequencing has no async wait or branch outcome), so combine the two writes into one multi-key input dictionary instead.
The remote repository wraps HTTP. All four operations share one input shape — only the HTTP method differs.
Operation
HTTP method
read
GET
create
POST
update
PUT / PATCH
delete
DELETE
Every repo action carries repositoryId, operation, and params. For the remote kind, params recognises exactly two keys (any others are silently ignored):
Key
Type
Meaning
subUrl
string
Path appended to the repo’s configured endPoint. Optional; defaults to "".
body
object
The request payload. On GET the body is flattened into query parameters (?k=v&…). On POST / PUT / DELETE it is encoded as a JSON request body.
Default headers: accept: application/json, content-type: application/json; charset=utf-8, cache-control: no-cache. Auth and additional headers come from the repo’s config.interceptor.
// GET https://geocoding-api.open-meteo.com/v1/search?name=Kyiv&count=1
{ collection } (whole collection) or { collection, documentId }
create
{ collection, body }. Optional: documentId (string) and rewrite: true.
update
{ collection, documentId, body }
delete
{ collection, documentId }
Document IDs.documentId is optional on create. When omitted, the runtime assigns UUID().uuidString. When provided, the runtime uses the value as-is; if a row with that id already exists, the create succeeds only when rewrite: true and otherwise fails. Every read row exposes its id under the field documentId, so subsequent delete / update calls reference it as @{item.documentId}. Identity must be unique within the collection — if you supply a documentId, you own its uniqueness.
Backed by the iOS keychain. The shape of params depends on the operation:
Operation
params shape
Meaning
read / delete
plain string
The keychain key name (e.g. "params": "openai_token").
create / update
{ "key": …, "value": … }
key (string) — keychain key name. value (string) — secret value to store.
read returns the stored string under the action’s id (@{<actionId>}). The most common consumer is a remote repo’s config.interceptor.adapter.setBearerToken, which reads a secret keyed by repositoryId + key and injects it as an Authorization header — keeping secrets out of JSON-level state.
Subscribes to realtime updates from a firebase repository. On each incoming update, the runtime fires the notification action defined in params.notification.
Field
Type
Required
Description
id
string
REQUIRED
Unique action id.
input.dependencyId
string
REQUIRED
The id of the firebase repository dependency to subscribe to.
input.state
string
REQUIRED
State key name that receives each incoming update value.
input.params.collection
string
REQUIRED
Firestore collection path to listen on.
input.params.notification
action
Optional
A single action object fired on each update. Receives the new value in context.
ℹ output.success fires once when the first data event arrives — use it to confirm the subscription is active (e.g., set a “connected” flag). output.failure fires if the stream throws an error. input.params.notification fires on every update.
{ "listen": {
"id": "listen_messages",
"input": {
"dependencyId": "repo_firebase_id_1",
"state": "messages",
"params": {
"collection": "messages",
"notification": {
"service": { "id": "notify_action",
"input": { "serviceId": "push_service_id",
"params": { "title": "New message", "body": "${message}",
Programmatically drives navigation within the nearest enclosing navigation component.
Field
Type
Required
Description
id
string
REQUIRED
Unique action id.
input.operation
string enum
REQUIRED
One of push, pop, or popToRoot.
input.source
Source string
Conditional
push only. Source string resolving to the component to push. Accepts dp:// (registered dependency) or file:// (bundled JSON file).
input.moduleId
string
Optional
pop only. ID of the screen to pop back to (inclusive). When omitted, pops only the top screen.
output.success
array
Optional
push only. Actions executed after the component is loaded and the screen is pushed onto the stack.
debounce
number
Optional
Debounce delay in seconds.
Constraints
input.source is required when operation is push. It is ignored for pop and popToRoot.
ℹ Note: input.source is a source string — it is NOT an inline component object. The component to push must be pre-registered as a dependency (dp://) or packaged as a JSON file (file://). All other source schemes are silently ignored and the action produces no navigation.
⚠ file:// requires an actual file bundle.file:// resolves to a JSON file inside the StemJSON zip package (see §14 Package format). For single-file inline modules — the default and the typical AI-authoring case — there IS no file bundle, so file://anything.json resolves to nothing and the push silently fails (no transition, no error logged). In single-file modules, always declare the destination as a component dependency and push via dp://<id>. Use file:// only when shipping a .stemzip package and you genuinely have that file inside it.
Wrong (in a single-file module — silently does nothing):
Evaluates a boolean expression at runtime and branches via the standard output mechanism. input is the expression (or state/context reference) that resolves to a boolean. Neither branch is pre-evaluated.
Field
Type
Required
Description
id
string
REQUIRED
Unique action id.
input
bool / expr
REQUIRED
Expression or reference that resolves to a boolean. Truthy routes to output.success; falsy routes to output.failure.
Fires output.success actions repeatedly at a fixed delay. Use input.delay for the interval in seconds. Cancel a running interval by sending another interval action with the same id and input.cancel: true.
Calls an AI provider and binds the result into the action chain — the foundation for AI-as-a-dynamic-backend (e.g. a game opponent, summarisation, on-the-fly data shaping). Two provider paths exist, selected by the kind of the repository that input.provider names: a remote repository (module-authored endpoint + auth interceptor; body is provider-native) or an ai repository (host-injected provider; body uses the host convention below). On either path, API credentials never appear in the action, body, or module state.
Field
Type
Required
Description
id
string
REQUIRED
Unique action id.
input.provider
string
REQUIRED
Id of the repository dependency that fronts the AI provider — a remote repository (module-authored endpoint + auth interceptor) or an ai repository (host-injected provider, §5.3).
input.body
object
REQUIRED
The literal provider request payload, authored in the provider’s own format (model, messages/prompt, response schema, …). POSTed as the body.
input.subUrl
string
Optional
Path appended to the provider repo’s endPoint. Defaults to "".
input.responsePath
string
Optional
Structural path into the provider’s JSON response (e.g. output[0].content[0].text) selecting the field that holds the answer. When omitted, the raw response binds unchanged.
input.parseJson
bool
Optional
When a responsePath is set, parse the extracted string as JSON (default true). Set false to bind the extracted value verbatim. Ignored when responsePath is absent.
debounce
number
Optional
Debounce delay in seconds applied to this action.
output.success
array
Optional
Actions to execute on success. The unwrapped result is available as @{actionId}.
output.failure
array
Optional
Actions to execute on failure. Access the error via @{actionId.code} / @{actionId.message}.
Bring-your-own provider (remote repository). The action POSTs body to the provider repo, then — when responsePath is given — extracts that field and (unless parseJson is false) JSON-parses it, so @{actionId} is the clean answer ready to bind into state. Because the model, prompt, and response schema all live in body, switching providers (e.g. OpenAI ⇄ Anthropic) is purely a matter of pointing provider at a different remote repository and authoring the matching body + responsePath — the runtime is unchanged.
Host-managed provider (ai repository kind, §5.3). When provider names an ai-kind repository, the host supplies the endpoint, credentials, and model selection; body uses the portable convention
where tier picks the host’s model class, schema is a JSON-Schema-shaped description of the exact reply object expected (keep it small and flat), and data is optional structured context serialized into the request. The reply binds already parsed: read fields via @{actionId.field}. subUrl, responsePath, and parseJson are ignored on this path. As with every async action, pair any UI-gating flag with an output.failure chain that resets it.
// Host-managed move from a game-opponent model (ai repository kind).
{ "ai": { "id": "ai_move",
"input": {
"provider": "my_ai",
"body": { "tier": "fast",
"system": "You are the black player. Reply with one legal move.",
The provider’s API key is supplied to the remote repo’s interceptor (e.g. setBearerToken for an OpenAI Authorization: Bearer, or setHeaderx-api-key for Anthropic) from a secured repository — it never appears in body or in module state.
Every action that produces a result places it in the action’s context slot under its id. The shape differs by action kind:
repo — success envelope
The result is the raw response body decoded as a JSON value. Its structure is determined by the repository and operation:
Operation
@{actionId} resolves to
read
The response value (object, array, string, or number).
create
The created document or an acknowledgement object.
update
The updated document or an acknowledgement object.
delete
An acknowledgement object or null.
Path access into the result: @{actionId.someField}, @{actionId.nested.field}.
repo / service — failure envelope
Available in output.failure chains under the same @{actionId} slot:
Field
Type
Description
@{actionId.code}
Int
Structured error code. See §16.1 for the full table.
@{actionId.message}
String
Human-readable error description.
service — success envelope
The result is a service-defined value placed at @{actionId}. Services that produce no output (e.g. audio playback) resolve to null on success.
state — no result
State actions produce no result value (@{actionId} is not populated). output.success is permitted for sequencing non-state actions (see §10.2 chain rules); output.failure is not — state writes never fail.
condition — no result
Condition actions produce no result value. They branch into output.success (input is truthy) or output.failure (input is falsy). @{actionId} is not populated.
navigate — no result
Navigate actions produce no result value. For push, output.success fires after the component is loaded and the screen is pushed; output.failure is never triggered. pop and popToRoot do not fire output.
Actions declared together in a single event array (co-declared actions) are fired in parallel. Their async work runs concurrently and completion order is not guaranteed.
Chained actions declared in output.success or output.failure are executed sequentially, in order. Each chained action starts only after the previous one completes.
Always use output.success / output.failure chaining when action B depends on the result of action A.
Failure of one action in a co-declared (parallel) set does not stop the others. Each action handles its own success and failure independently.
A non-network exception that is not a recognised StemActionError is logged internally and does not trigger the output.failure chain.
Chained actions (output.success) receive a state snapshot taken after the triggering action completes. For repo and service actions the result is available in context under the action’s id key.