Skip to content

Actions

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.

{ "state": { "id": "...", "input": { ... } } }
{ "repo": { "id": "...", "input": { ... }, "output": { ... } } }
{ "template": { "id": "...", "input": "dependency_action_id" } }
{ "listen": { "id": "...", "input": { ... } } }
{ "service": { "id": "...", "input": { ... } } }
{ "navigate": { "id": "...", "input": { ... } } }
{ "condition": { "id": "...", "input": { ... } } }
{ "interval": { "id": "...", "input": { "delay": 1.0 }, "output": { "success": [...] } } }
KindPurpose
stateApplies 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.
repoExecutes a CRUD operation on a named repository dependency.
aiCalls an AI provider through a remote repository and binds the (optionally unwrapped) result. Provider-agnostic.
templateInvokes a reusable action registered as a dependency.
listenSubscribes to realtime repository updates (e.g. Firebase). Fires the notification action on each update.
serviceInvokes a device or platform service (push notifications, audio playback, etc.).
navigateProgrammatically pushes, pops, or resets a navigation stack.
conditionEvaluates a predicate expression and branches to one of two action arrays.
intervalFires 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.
{ "state": { "id": "push_entry", "input": { "entries": "${draftEntry}" } } }
// 2. Chained write — local-only "Saving…" feedback that sequences the persist call.
{ "state": { "id": "show_saving",
"input": { "statusMessage": "Saving…" },
"output": { "success": [
{ "repo": { "id": "persist", "input": { /*…*/ } } }
] } } }
Input formExampleMeaning
Dictionary”input”: { “count”: 5, “error”: "" }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.

PatternAllowed?
state with no chain
state.output.success containing repo, service, interval, listen, condition, template, navigate
state.output.success containing another state action❌ — use one multi-key input dict instead
state.output.failure❌ — state has no failure path
// Atomic multi-key write — replaces any chain you'd otherwise build state→state.
{ "state": { "id": "toggle_secure",
"input": {
"isSecured": "{{ !${isSecured} }}",
"secureIcon": "{{ ${isSecured} ? 'sf://eye' : 'sf://eye.slash' }}"
} } }
// Sequencing UI feedback around a blocking async op (the canonical use of state.output).
{ "state": { "id": "show_saving",
"input": { "statusMessage": "Saving…", "showStatus": true },
"output": { "success": [
{ "repo": { "id": "persist", "input": { ...repo input... },
"output": { "success": [
{ "state": { "id": "show_saved",
"input": { "statusMessage": "Saved!" } } }
] }
} }
] } } }
FieldTypeRequiredDescription
idstringREQUIREDUnique action id.
input.repositoryIdstringREQUIREDThe id of the target repository dependency.
input.operationstringREQUIREDOne of: read
input.paramsobjectOptionalOperation-specific parameters. Content varies by repository kind (see §10.4).
debouncenumberOptionalDebounce delay in seconds applied to this action.
output.successarrayOptionalActions to execute on success. Access results with @{actionId}.
output.failurearrayOptionalActions to execute on failure. Access error with @{actionId.message}.

The remote repository wraps HTTP. All four operations share one input shape — only the HTTP method differs.

OperationHTTP method
readGET
createPOST
updatePUT / PATCH
deleteDELETE

Every repo action carries repositoryId, operation, and params. For the remote kind, params recognises exactly two keys (any others are silently ignored):

KeyTypeMeaning
subUrlstringPath appended to the repo’s configured endPoint. Optional; defaults to "".
bodyobjectThe 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
{ "repo": { "id": "geo_call", "input": {
"repositoryId": "geo_api",
"operation": "read",
"params": { "body": { "name": "${city}", "count": 1, "format": "json" } }
} } }
OperationTypical params
read{ 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:

Operationparams shapeMeaning
read / deleteplain stringThe 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.

FieldTypeRequiredDescription
idstringREQUIREDUnique action id.
input.dependencyIdstringREQUIREDThe id of the firebase repository dependency to subscribe to.
input.statestringREQUIREDState key name that receives each incoming update value.
input.params.collectionstringREQUIREDFirestore collection path to listen on.
input.params.notificationactionOptionalA 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}",
"when": { "now": true } } } }
}
}
}
} }
FieldTypeRequiredDescription
idstringREQUIREDUnique action id.
input.serviceIdstringREQUIREDThe id of the target service dependency.
input.paramsobjectREQUIREDService-specific parameters. Shape depends on the service kind (see §5.5).
output.successarrayOptionalActions to execute after the service call completes successfully.
output.failurearrayOptionalActions to execute if the service call fails.

audio service params

FieldTypeRequiredEnum values
soundstring enumOptionalnewMail | mailSent | smsReceived | sentMessage | tweetSent | phoneBusy | busyTone | keyPress | lock | unlock | shutter | vibrate | none
hapticstring enumOptionalnone | light | medium | success | warning | error

push service params — see §5.5 for the full push params schema.

{ "service": { "id": "audio_action",
"input": { "serviceId": "audio_service_id",
"params": { "sound": "sentMessage", "haptic": "light" } } } }
{ "service": { "id": "push_action",
"input": { "serviceId": "push_service_id",
"params": { "title": "New message", "body": "${message}",
"when": { "now": true } } } } }

Invokes a reusable action registered as an action dependency. The referenced action runs with the current context and state of the calling component.

FieldTypeRequiredDescription
idstringREQUIREDUnique action id for this invocation.
inputstringREQUIREDThe dependency id of the action to invoke (registered in the module’s dependencies).
{ "template": { "id": "invoke_refresh", "input": "refresh_action_id" } }
// Executes the action dependency registered under id "refresh_action_id"

Programmatically drives navigation within the nearest enclosing navigation component.

FieldTypeRequiredDescription
idstringREQUIREDUnique action id.
input.operationstring enumREQUIREDOne of push, pop, or popToRoot.
input.sourceSource stringConditionalpush only. Source string resolving to the component to push. Accepts dp:// (registered dependency) or file:// (bundled JSON file).
input.moduleIdstringOptionalpop only. ID of the screen to pop back to (inclusive). When omitted, pops only the top screen.
output.successarrayOptionalpush only. Actions executed after the component is loaded and the screen is pushed onto the stack.
debouncenumberOptionalDebounce delay in seconds.
Constraintsinput.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):

{ "navigate": { "id": "go", "input": { "operation": "push", "source": "file://detail.json" } } }

Right:

"dependencies": [
{ "component": { "id": "detail", "type": "module", "children": [ ] } }
],
"events": { "onTap": [
{ "navigate": { "id": "go", "input": { "operation": "push", "source": "dp://detail" } } }
] }
{ "navigate": {
"id": "go_to_detail",
"input": { "operation": "push", "source": "dp://detail_module" }
} }
{ "navigate": {
"id": "go_back",
"input": { "operation": "pop" }
} }
{ "navigate": {
"id": "pop_to_screen",
"input": { "operation": "pop", "moduleId": "profile_module_id" }
} }
{ "navigate": { "id": "go_home", "input": { "operation": "popToRoot" } } }

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.

FieldTypeRequiredDescription
idstringREQUIREDUnique action id.
inputbool / exprREQUIREDExpression or reference that resolves to a boolean. Truthy routes to output.success; falsy routes to output.failure.
output.successarrayOptionalActions executed when input is truthy.
output.failurearrayOptionalActions executed when input is falsy.
debouncenumberOptionalDebounce delay in seconds.
{ "condition": {
"id": "check_auth",
"input": "{{ notEmpty(${token}) }}",
"output": {
"success": [
{ "repo": { "id": "load_profile", "input": { "repositoryId": "repo_api",
"operation": "read", "params": {} } } }
],
"failure": [
{ "state": { "id": "set_error", "input": { "error": "Not authenticated" } } }
]
}
} }

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.

{ "interval": {
"id": "countdown",
"input": { "delay": 1.0 },
"output": {
"success": [
{ "state": { "id": "tick", "input": { "seconds": "{{ ${seconds} - 1 }}" } } }
]
}
}
}
Input keyTypeRequiredDescription
delaynumberConditional (REQUIRED unless cancel is true)Interval in seconds between firings.
cancelboolOptionalWhen true, cancels the running interval with this id.

Cancel example:

{ "interval": { "id": "countdown", "input": { "cancel": true } } }

The output actions are not pre-resolved — they resolve against current state each tick, so {{ ${seconds} - 1 }} always reads the latest value.

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.

FieldTypeRequiredDescription
idstringREQUIREDUnique action id.
input.providerstringREQUIREDId 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.bodyobjectREQUIREDThe literal provider request payload, authored in the provider’s own format (model, messages/prompt, response schema, …). POSTed as the body.
input.subUrlstringOptionalPath appended to the provider repo’s endPoint. Defaults to "".
input.responsePathstringOptionalStructural 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.parseJsonboolOptionalWhen 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.
debouncenumberOptionalDebounce delay in seconds applied to this action.
output.successarrayOptionalActions to execute on success. The unwrapped result is available as @{actionId}.
output.failurearrayOptionalActions 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

{ "tier": "strong" | "fast", "system": "", "prompt": "", "schema": { }, "data": { } }

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.",
"prompt": "{{ 'Board: ' + cast(${board}, 'str') }}",
"schema": { "type": "object",
"properties": { "from": { "type": "integer" }, "to": { "type": "integer" } },
"required": ["from", "to"] } } },
"output": {
"success": [ { "state": { "id": "apply_ai", "input": { "aiFrom": "@{ai_move.from}", "aiTo": "@{ai_move.to}", "aiThinking": false } } } ],
"failure": [ { "state": { "id": "ai_err", "input": { "aiThinking": false, "message": "Opponent unavailable — try again." } } } ] } } }
// One move from a game-opponent model, unwrapped and bound into state.
{ "ai": { "id": "opponent",
"input": {
"provider": "openai_repo",
"body": {
"model": "gpt-5.4",
"input": [
{ "role": "system", "content": "You are the black player. Reply with one legal move." },
{ "role": "user", "content": "{{ 'Board: ' + cast(${board}, 'str') }}" }
],
"text": { "format": { "type": "json_schema", "name": "move",
"schema": { "type": "object",
"properties": { "from": { "type": "integer" }, "to": { "type": "integer" } },
"required": ["from", "to"], "additionalProperties": false } } }
},
"responsePath": "output[0].content[0].text"
},
"output": { "success": [
{ "state": { "id": "apply_move", "input": { "lastMove": "@{opponent}" } } }
] } } }

The provider’s API key is supplied to the remote repo’s interceptor (e.g. setBearerToken for an OpenAI Authorization: Bearer, or setHeader x-api-key for Anthropic) from a secured repository — it never appears in body or in module state.

Output refResolves to
@{actionId}The full result value of the action (e.g. entire response body for a repo read).
@{actionId.message}Error message string (available in failure chains).
@{actionId.code}Error code integer (available in failure chains, see §16.1).
@{actionId.field}Any named field in the action result object.

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
readThe response value (object, array, string, or number).
createThe created document or an acknowledgement object.
updateThe updated document or an acknowledgement object.
deleteAn 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:

FieldTypeDescription
@{actionId.code}IntStructured error code. See §16.1 for the full table.
@{actionId.message}StringHuman-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.

Result envelope summary

Action kind@{id} on success@{id} on failureoutput.failure reachable?
repoResponse body (any JSON value)— (see failure envelope)✅ Yes
servicenull (audio/push produce no value)— (see failure envelope)✅ Yes
state— (not populated)n/a❌ Never (state has no failure)
condition— (not populated)✅ Yes (when input falsy)
navigate— (not populated)❌ Never
listen— (uses notification)❌ Never
templateDepends on invoked action❌ Not directly
  • 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.
  • Action ids MUST be unique across the entire unit.