Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ called out explicitly under **Changed** or **Removed**.

## [Unreleased]

### Fixed

- **CLI writes no longer fail on automations with loops, and `show` renders
loops.** `activate`, `deactivate`, `move`, `steps add|edit|remove` and
`roundtrip --execute` failed with `HTTP 400: … step_loop must be provided
for loop` on any automation with a loop, after a clean `--dry-run`. They
now write it back unchanged. `--dry-run` catches a broken loop and names
the step. `show` labels each loop with its array and item variables and
puts its body under it. `steps add --parent <loop>` puts the new step at
the head of the body, `steps add` builds an `exit_loop`, and
`steps remove` refuses to leave a loop unclosed.

## [0.3.0] — 2026-10-21

### Changed
Expand Down
14 changes: 9 additions & 5 deletions scripts/cli-tree-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1069,16 +1069,18 @@
│ edit Patch one step: top-level keys in the JSON replace the step's. │
│ add Insert one step. The spec is a single AutomationDef step (step_type + │
│ config block; field_refs welcome); placement comes from the flags, and │
│ by default the parent's existing children move under the new step. │
│ by default the parent's existing children move under the new step, except │
│ a next_or_finish or merge_branches, which stays on its loop or group. │
│ remove Splice one step out of the graph (children adopt its parent, go_to │
│ references retarget). A condition/goal with children needs --cascade. │
│ references retarget). A condition, goal or loop with children needs │
│ --cascade, and a next_or_finish goes only with its loop. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯

Usage: automations steps add [OPTIONS] {api_name}

Insert one step. The spec is a single AutomationDef step (step_type + config block; field_refs
welcome); placement comes from the flags, and by default the parent's existing children move under
the new step.
the new step, except a next_or_finish or merge_branches, which stays on its loop or group.

╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮
│ * api_name <str> Automation api_name. [required] │
Expand Down Expand Up @@ -1139,7 +1141,8 @@
Usage: automations steps remove [OPTIONS] {api_name} {key}

Splice one step out of the graph (children adopt its parent, go_to references retarget). A
condition/goal with children needs --cascade.
condition, goal or loop with children needs --cascade, and a next_or_finish goes only with its
loop.

╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮
│ * api_name <str> Automation api_name. [required] │
Expand Down Expand Up @@ -4346,7 +4349,8 @@

`--check` is the session-start form: it reports whether a newer version
exists, caches the answer for a day, and always exits 0 — offline, behind a
proxy, or with no remote configured, it just says so quietly.
proxy, or with no remote configured, it just says so quietly. Under
app-builder it says the installation is managed there, and still exits 0.

╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮
│ --check Only report whether a newer version exists; change nothing. │
Expand Down
21 changes: 20 additions & 1 deletion src/kizen_builder/cli/automations.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,12 @@ def _step_label(step: dict[str, Any]) -> str:
if len(desc) > 72:
desc = desc[:69] + "…"
label = f"[bold]{step['key']}[/bold]"
loop = step.get("step_loop")
if step.get("type") == "loop" and loop:
label += (
f" [cyan]\\[for each {escape(str(loop.get('array_variable')))} as "
f"{escape(str(loop.get('item_variable')))}][/cyan]"
)
goto = step.get("action_go_to_automation_step")
if goto:
target = goto.get("step_key") or goto.get("trigger_key") or "?"
Expand Down Expand Up @@ -409,14 +415,27 @@ def add_children(node: Any, parent_key: str | None) -> None:
child_node = node.add(_step_label(child))
descend(child_node, child)

def add_loop_children(node: Any, loop_key: str) -> None:
kids = step_tools.children_of(payload, loop_key, branch="")
body = [k for k in kids if k["type"] != "next_or_finish"]
if body:
body_node = node.add("[dim]body[/dim]")
for child in body:
descend(body_node.add(_step_label(child)), child)
for closer in (k for k in kids if k["type"] == "next_or_finish"):
descend(node.add(_step_label(closer)), closer)

def descend(node: Any, step: dict[str, Any]) -> None:
for branch, style in (("yes", "green"), ("no", "red")):
kids = step_tools.children_of(payload, step["key"], branch=branch)
if kids:
branch_node = node.add(f"[{style}]{branch}[/{style}]")
for child in kids:
descend(branch_node.add(_step_label(child)), child)
add_children(node, step["key"])
if step["type"] == "loop":
add_loop_children(node, step["key"])
else:
add_children(node, step["key"])

# The leading initialize_variable chain is always linear (the server
# requires init-vars at the front), so render it flat instead of as a
Expand Down
6 changes: 4 additions & 2 deletions src/kizen_builder/cli/steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,8 @@ def steps_add(
) -> None:
"""Insert one step. The spec is a single AutomationDef step (step_type +
config block; field_refs welcome); placement comes from the flags, and
by default the parent's existing children move under the new step."""
by default the parent's existing children move under the new step, except
a next_or_finish or merge_branches, which stays on its loop or group."""
if bool(parent) == root:
err_console.print("[red]error:[/red] pass exactly one of --parent or --root")
raise typer.Exit(code=2)
Expand Down Expand Up @@ -274,7 +275,8 @@ def steps_remove(
json_out: bool = typer.Option(False, "--json", help="Emit JSON."),
) -> None:
"""Splice one step out of the graph (children adopt its parent, go_to
references retarget). A condition/goal with children needs --cascade."""
references retarget). A condition, goal or loop with children needs
--cascade, and a next_or_finish goes only with its loop."""

def mutate(payload: dict[str, Any], raw: dict[str, Any]) -> Any:
return step_tools.remove_step(payload, key, cascade=cascade)
Expand Down
13 changes: 11 additions & 2 deletions src/kizen_builder/docs/specs/automation-step.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,20 @@ Consequences worth knowing:
complete config block, not a partial one.
- **`add` inserts into the chain by default** — the parent's children move under
the new step. `--leaf` appends without adopting them, `--branch yes|no` targets
a condition/goal branch, `--root` puts the new step first.
a condition/goal branch, `--root` puts the new step first. A
`next_or_finish` or `merge_branches` child is never adopted, so `--parent
<loop>` puts the new step at the head of the loop body, and `--parent
<next_or_finish>` puts it right after the loop. An `exit_loop` adds like any
step. A `loop` or `next_or_finish` can't be added on its own: add the loop,
its closer and its item variable with `kizen automations update
--spec-file`.
- **`remove` splices:** children adopt the removed step's parent and inherit its
branch position, and `go_to` references retarget to that parent. Removing a
condition or goal that has children requires `--cascade`, which deletes the
whole subtree.
whole subtree. So does a loop with steps under it, where the subtree is the
body and everything after the loop. A `next_or_finish` can't be removed, even
with `--cascade`: remove its loop instead. Removing the last body step leaves
an empty body, which is valid.
- **The post-apply diff is positional**, so a mid-graph change shows up as
every later step shifting. Verify structure with `show` and treat the diff as
the audit trail, not as a minimal changeset.
Expand Down
33 changes: 31 additions & 2 deletions src/kizen_builder/docs/specs/automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,15 @@ plan preview warns about it. Kizen's 2026-09-28 migration inserted a `branch`
step ahead of every existing fan-out, and an `update` from a spec without one
removes it again. Confirmed live 2026-09-28.

**Loops** are initiators too. A `loop` step's body hangs off the loop, its one
`next_or_finish` child closes the body, and the steps after the loop hang off
the `next_or_finish`. `exit_loop` leaves the loop from inside the body.
`next_or_finish` and `exit_loop` take no config block. `step_loop` names an
array variable of records, strings or UUIDs and a separate item variable, each
declared by an `initialize_variable` step. The item variable is a single
variable of the array's type. `--dry-run` checks loops against
the server's rules too. Confirmed live 2026-10-27.

**Skipping a condition or goal** (`should_skip_execution: true`) needs
`continue_with_branch: "yes"` or `"no"`, the branch the run takes past it. The
spec is rejected without it. Confirmed live 2026-09-28.
Expand Down Expand Up @@ -163,7 +172,7 @@ A `goal` step's `step_goal.triggers` are wait-until triggers with the same
to wait for a reply. Its `wait_type` is `never`, `delay` or
`specific_datetime`; `wait_forever` is a 400. Confirmed live 2026-10-14.

## Wired step types (27)
## Wired step types (30)

| step_type | config block | purpose |
|-----------|--------------|---------|
Expand Down Expand Up @@ -193,6 +202,9 @@ to wait for a reply. Its `wait_type` is `never`, `delay` or
| `search_records` | `action_search_records` | search an object, write results into an array variable |
| `branch` | none | run its children in parallel — see Branch groups |
| `merge_branches` | none | rejoin a branch group — see Branch groups |
| `loop` | `step_loop` | run its body once per item of an array variable — see Branch groups |
| `next_or_finish` | none | close a loop body — see Branch groups |
| `exit_loop` | none | leave the loop from inside its body |

`search_records` (the automation-builder equivalent of `records list
--filter`) needs its own `custom_object` — independent of the automation's
Expand Down Expand Up @@ -854,7 +866,9 @@ Why a translator is mandatory rather than nice-to-have:
Derived read-only keys are expected and never sent: `yes_steps`/`no_steps`
on a condition, and the message fields (`subject`, `content`, `name`, …) a
`notify_member_via_email`/`_text` read flattens into the block (confirmed
live 2026-10-16).
live 2026-10-16). A whole non-empty `step_*`/`action_*` block the CLI
doesn't read for that step's type stops the same verbs, forwarding types
included.

Also: **automation updates need PUT, not PATCH.** PATCH refuses step/trigger
changes, and PUT requires the current `revision` as `last_revision`.
Expand Down Expand Up @@ -904,6 +918,8 @@ Each of these was discovered via a live 400, 500, or silent data loss:
| `continue_with_branch`, present only when set | echoed when non-null. A condition or goal with `should_skip_execution: true` 400s without it. Confirmed live 2026-09-28 |
| `error_notification_severity_level` on every step | echoed as read. Omitted, the step is silently reset to `inherit`. Confirmed live 2026-09-28 |
| `branch` and `merge_branches` steps, read with no config block | no `action_*` block written. Confirmed live 2026-09-28 |
| `step_loop` with `array_variable`/`item_variable` as variable definitions | bare variable **names**; their ids are a 400 ("The loop must iterate an array variable…"). `next_or_finish` and `exit_loop` are read with no config block, and none is written. Confirmed live 2026-10-27 |
| A loop's item variable read with `is_array_item_variable: true` and `source_array_variable: <array>` | echoed as read. The server sets both itself from the loop, so sending them is harmless and omitting them changes nothing. Confirmed live 2026-10-27 |
| Extraction/transcription `input_field` plus its mirrored `context_entity_field` `input_value_source` | `input_field` only — see [Extraction input](#extraction-input) |
| `modify_related_entities.automation_variables` (expanded variable definitions) and the block's own `id` | not sent; `record_source_variables` (bare names) is the write key |

Expand Down Expand Up @@ -936,6 +952,19 @@ inconsistent:
one `merge_branches` child, and every `merge_branches` step's parent is an
initiator** (400). A skipped condition or goal needs `continue_with_branch`
`yes` or `no` (400). Confirmed live 2026-09-28.
- **Loops** (each a 400, confirmed live 2026-10-27):
- a `loop` has exactly one `next_or_finish` child;
- a `loop` has no `merge_branches` child;
- a `next_or_finish` hangs directly off a `loop`;
- an `exit_loop` is inside a loop body: walking up its parents, it reaches a
loop through a body step, not through that loop's `next_or_finish`;
- a loop iterates an array variable of records, strings or UUIDs, declared
by an `initialize_variable` step;
- its item variable is declared by an `initialize_variable` step as a single
variable of the array's type (and, for records, the same object), and no
two loops share one.

There is no nesting limit (6 deep saves) and no limit on body lanes.

### Unlisted step types

Expand Down
2 changes: 2 additions & 0 deletions src/kizen_builder/models/spec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
StepConditionConfig,
StepDelayConfig,
StepGoalConfig,
StepLoopConfig,
ActionGoToAutomationStepConfig,
ActionStopExecutionConfig,
ActionArchiveRecordConfig,
Expand Down Expand Up @@ -268,6 +269,7 @@ def build_payload_meta(model: BaseModel) -> dict[str, Any]:
"StepConditionConfig",
"StepDelayConfig",
"StepGoalConfig",
"StepLoopConfig",
"ActionChangeFieldValueConfig",
"ActionSendEmailConfig",
"ActionSendRelatedContactEmailConfig",
Expand Down
8 changes: 7 additions & 1 deletion src/kizen_builder/models/spec/automations.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
StepConditionConfig,
StepDelayConfig,
StepGoalConfig,
StepLoopConfig,
)
from kizen_builder.models.spec.automations_actions_data import (
ActionChangeFieldValueConfig,
Expand Down Expand Up @@ -65,6 +66,7 @@
"delete_scheduled_activity": "action_delete_scheduled_activity",
"file_content_extraction": "action_file_content_extraction",
"goal": "step_goal",
"loop": "step_loop",
"go_to_automation_step": "action_go_to_automation_step",
"http_request": "action_http_request",
"initialize_variable": "action_initialize_variable",
Expand All @@ -88,6 +90,8 @@
"search_records": "action_search_records",
"branch": None,
"merge_branches": None,
"next_or_finish": None,
"exit_loop": None,
}


Expand All @@ -101,6 +105,7 @@
"go_to_automation_step",
"http_request",
"initialize_variable",
"loop",
"modify_automation",
"modify_related_entities_automation",
"notify_member_via_email",
Expand Down Expand Up @@ -195,6 +200,7 @@ class AutomationStepDef(BaseModel):
step_condition: StepConditionConfig | None = None
step_delay: StepDelayConfig | None = None
step_goal: StepGoalConfig | None = None
step_loop: StepLoopConfig | None = None
action_assign_team_member: ActionAssignTeamMemberConfig | None = None
action_audio_transcription: ActionAudioTranscriptionConfig | None = None
action_call_llm: ActionLlmCallConfig | None = None
Expand Down Expand Up @@ -246,7 +252,7 @@ def _validate_step_config(self) -> Self:
raise ValueError(
f"step_type '{self.step_type}' requires an '{config_field}' block"
)
if self.step_type in ("branch", "merge_branches"):
if config_field is None:
given = sorted(
f
for f in set(_STEP_TYPE_TO_CONFIG_FIELD.values())
Expand Down
14 changes: 14 additions & 0 deletions src/kizen_builder/models/spec/automations_actions_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ class StepDelayConfig(BaseModel):
variable: str | None = None


class StepLoopConfig(BaseModel):
"""Config for `step_type: loop`. Both variables are named, and each must be
declared by an `initialize_variable` step: the array, and a single variable
of the array's type that holds the current item."""

model_config = ConfigDict(extra="allow")

loop_type: Literal["for_each_item"] = "for_each_item"
array_variable: str = Field(description="Name of the array variable to iterate.")
item_variable: str = Field(
description="Name of the variable that holds the current item."
)


class StepGoalConfig(BaseModel):
"""Config for `step_type: goal`."""

Expand Down
10 changes: 10 additions & 0 deletions src/kizen_builder/tools/automations.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,8 +431,18 @@ def build_wire_step(
LiveContext,
_build_step_payload,
)
from kizen_builder.tools.plans import PlanError
from kizen_builder.tools.steps import synthesize_key

counterpart = {"loop": "next_or_finish", "next_or_finish": "loop"}.get(
spec.get("step_type", "")
)
if counterpart:
raise PlanError(
f"a {spec['step_type']} is added together with its {counterpart} and "
"the loop's item variable, which one 'steps add' can't do. Add it "
"with 'kizen automations update --spec-file'."
)
spec = dict(spec)
for linkage in ("parent_key", "parent_branch", "id"):
spec.pop(linkage, None)
Expand Down
Loading
Loading