Repeat steps in a loop
This guide runs a flow's steps once for each element of a list, several at a time, or again and again until a condition holds.
A loop step runs a small flow of its own, its body, several times:
foreach: once for each element of a list, one or several at a time.while: again and again, until a condition on the last pass holds.
Before you start
Section titled “Before you start”- A pack with the tools from Write a flow, such as
acme.get-order, on a runtime such askindgi dev, with Kindgi 0.1's CLI.
For each element of a list
Section titled “For each element of a list”A foreach loop does the same work for every element. Ask your coding agent:
"I want a flow that checks the stock of every item in an order, two at a
time." Kindgi's skills in your pack tell it how
(How it knows Kindgi). For reference, the
tool and the flow look like this. acme.check-order-stock checks every item
of an order with a new tool, acme.check-stock, two items at a time:
import { defineTool } from '@kindgi/sdk/define';import type { ToolId } from '@kindgi/sdk/types';import { z } from 'zod';
const STOCK: Record<string, number> = { mug: 10, desk: 0, lamp: 5 };
const defined = defineTool({ id: 'acme.check-stock' as ToolId, description: 'Checks whether a quantity of one item is in stock.', version: '0.1.0', input: z.object({ sku: z.string(), quantity: z.number().int().positive() }), output: z.object({ sku: z.string(), inStock: z.boolean() }), effects: [], mutating: false, handler: async ({ sku, quantity }) => ({ sku, inStock: (STOCK[sku] ?? 0) >= quantity }),});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;import { defineFlow } from '@kindgi/sdk/define';
const defined = defineFlow({ id: 'acme.check-order-stock', version: '0.1.0', name: 'Check stock for an order', description: 'Checks every item of an order, two at a time.', nodes: [ { id: 'order', kind: 'tool', ref: 'acme.get-order', inputMapping: { orderId: { path: 'runInput.orderId' } }, }, { id: 'each-item', kind: 'loop', loopKind: 'foreach', iterateOver: { path: 'nodeOutputs.order.items' }, concurrency: 2, maxIterations: 100, collectAllIterations: true, outputSchema: { type: 'object', properties: { sku: { type: 'string' }, inStock: { type: 'boolean' } }, required: ['sku', 'inStock'], }, body: { nodes: [{ id: 'check', kind: 'tool', ref: 'acme.check-stock' }], edges: [ { id: 'b1', from: '$loop-start', to: 'check' }, { id: 'b2', from: 'check', to: '$loop-end' }, ], }, }, ], edges: [ { id: 'e1', from: '$start', to: 'order' }, { id: 'e2', from: 'order', to: 'each-item' }, { id: 'e3', from: 'each-item', to: '$end' }, ], output: { mapping: { items: { path: 'nodeOutputs.each-item.outputs' } }, },});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;from pydantic import BaseModel, Field
from kindgi import tool
STOCK = {"mug": 10, "desk": 0, "lamp": 5}
class StockInput(BaseModel): sku: str quantity: int = Field(gt=0)
class Stock(BaseModel): sku: str in_stock: bool = Field(alias="inStock")
@tool(id="acme.check-stock", mutating=False)def check_stock(input: StockInput) -> Stock: """Checks whether a quantity of one item is in stock.""" return Stock(sku=input.sku, inStock=STOCK.get(input.sku, 0) >= input.quantity)from kindgi import Flow
check_order_stock = Flow( id="acme.check-order-stock", version="0.1.0", name="Check stock for an order", description="Checks every item of an order, two at a time.", nodes=[ { "id": "order", "kind": "tool", "ref": "acme.get-order", "inputMapping": {"orderId": {"path": "runInput.orderId"}}, }, { "id": "each-item", "kind": "loop", "loopKind": "foreach", "iterateOver": {"path": "nodeOutputs.order.items"}, "concurrency": 2, "maxIterations": 100, "collectAllIterations": True, "outputSchema": { "type": "object", "properties": {"sku": {"type": "string"}, "inStock": {"type": "boolean"}}, "required": ["sku", "inStock"], }, "body": { "nodes": [{"id": "check", "kind": "tool", "ref": "acme.check-stock"}], "edges": [ {"id": "b1", "from": "$loop-start", "to": "check"}, {"id": "b2", "from": "check", "to": "$loop-end"}, ], }, }, ], edges=[ {"id": "e1", "from": "$start", "to": "order"}, {"id": "e2", "from": "order", "to": "each-item"}, {"id": "e3", "from": "each-item", "to": "$end"}, ], output={"mapping": {"items": {"path": "nodeOutputs.each-item.outputs"}}},)package acme.tools;
import com.kindgi.pack.Tool;import jakarta.validation.constraints.Positive;import java.util.Map;
public final class CheckStock { public record Input(String sku, @Positive int quantity) {}
public record Output(String sku, boolean inStock) {}
private static final Map<String, Integer> STOCK = Map.of("mug", 10, "desk", 0, "lamp", 5);
public static final Tool<Input, Output> TOOL = Tool.define("acme.check-stock") .description("Checks whether a quantity of one item is in stock.") .version("0.1.0") .input(Input.class) .output(Output.class) .mutating(false) .handler((input, ctx) -> new Output(input.sku(), STOCK.getOrDefault(input.sku(), 0) >= input.quantity()));
private CheckStock() {}}package acme.flows;
import com.kindgi.pack.Flow;import java.util.List;import java.util.Map;
public final class CheckOrderStock { public static final Flow FLOW = Flow.define("acme.check-order-stock") .version("0.1.0") .set("name", "Check stock for an order") .set("description", "Checks every item of an order, two at a time.") .node(Map.of("id", "order", "kind", "tool", "ref", "acme.get-order", "inputMapping", Map.of("orderId", Map.of("path", "runInput.orderId")))) .node(Map.of( "id", "each-item", "kind", "loop", "loopKind", "foreach", "iterateOver", Map.of("path", "nodeOutputs.order.items"), "concurrency", 2, "maxIterations", 100, "collectAllIterations", true, "outputSchema", Map.of( "type", "object", "properties", Map.of("sku", Map.of("type", "string"), "inStock", Map.of("type", "boolean")), "required", List.of("sku", "inStock")), "body", Map.of( "nodes", List.of(Map.of("id", "check", "kind", "tool", "ref", "acme.check-stock")), "edges", List.of( Map.of("id", "b1", "from", "$loop-start", "to", "check"), Map.of("id", "b2", "from", "check", "to", "$loop-end"))))) .edge("e1", "$start", "order") .edge("e2", "order", "each-item") .edge("e3", "each-item", "$end") .set("output", Map.of("mapping", Map.of("items", Map.of("path", "nodeOutputs.each-item.outputs")))) .build();
private CheckOrderStock() {}}package acme.tools
import com.kindgi.pack.scaladsl._import jakarta.validation.constraints.Positive
object CheckStock { final case class Input(sku: String, @Positive quantity: Int) final case class Output(sku: String, inStock: Boolean)
private val stock = Map("mug" -> 10, "desk" -> 0, "lamp" -> 5)
val tool: Tool[Input, Output] = Tool[Input, Output]("acme.check-stock") .description("Checks whether a quantity of one item is in stock.") .version("0.1.0") .readOnly .handler((in, _) => Output(in.sku, stock.getOrElse(in.sku, 0) >= in.quantity))}package acme.flows
import com.kindgi.pack.scaladsl._
object CheckOrderStock { val flow: Flow = Flow("acme.check-order-stock") .version("0.1.0") .set("name", "Check stock for an order") .set("description", "Checks every item of an order, two at a time.") .node(Map("id" -> "order", "kind" -> "tool", "ref" -> "acme.get-order", "inputMapping" -> Map("orderId" -> Map("path" -> "runInput.orderId")))) .node(Map( "id" -> "each-item", "kind" -> "loop", "loopKind" -> "foreach", "iterateOver" -> Map("path" -> "nodeOutputs.order.items"), "concurrency" -> 2, "maxIterations" -> 100, "collectAllIterations" -> true, "outputSchema" -> Map( "type" -> "object", "properties" -> Map("sku" -> Map("type" -> "string"), "inStock" -> Map("type" -> "boolean")), "required" -> List("sku", "inStock")), "body" -> Map( "nodes" -> List(Map("id" -> "check", "kind" -> "tool", "ref" -> "acme.check-stock")), "edges" -> List( Map("id" -> "b1", "from" -> "$loop-start", "to" -> "check"), Map("id" -> "b2", "from" -> "check", "to" -> "$loop-end"))))) .edge("e1", "$start", "order") .edge("e2", "order", "each-item") .edge("e3", "each-item", "$end") .set("output", Map("mapping" -> Map("items" -> Map("path" -> "nodeOutputs.each-item.outputs")))) .build()}iterateOveris the list: a{ path }that must resolve to an array when the loop starts.bodyhas its own steps and edges, from$loop-startto$loop-end. Each pass gets one element as its input: here{ "sku": "desk", "quantity": 1 }, which isacme.check-stock's input as it is.concurrencyruns that many passes at a time, 1 by default and at most 32.maxIterationscaps the passes, andoutputSchemais what each pass must return. Both are required. AnoutputSchemathat doesn't compile is refused when the flow is loaded or deployed, not at each run.
Run it:
pnpm exec kindgi runs start --flow=acme.check-order-stock --input='{"orderId":"A-200"}'uv run kindgi runs start --flow=acme.check-order-stock --input='{"orderId":"A-200"}'./kindgiw runs start --flow=acme.check-order-stock --input='{"orderId":"A-200"}'./kindgiw runs start --flow=acme.check-order-stock --input='{"orderId":"A-200"}'--flow is the flow, and --input its input. It prints the run, with each
item's stock:
{ … "status": "completed", … "output": { "items": [ { "sku": "desk", "inStock": false }, { "sku": "lamp", "inStock": true } ] }, …}The loop step's own output is in the journal, which
kindgi runs journal <run-id> prints:
{"sequence": 18, "kind": "step.completed", "nodeId": "each-item", "payload": {"output": {"outputs": [{"sku": "desk", "inStock": false}, {"sku": "lamp", "inStock": true}], "iterations": 2, "stopReason": "array-exhausted", "finalOutput": {"sku": "lamp", "inStock": true}}}, …}finalOutputis the last pass's output.outputshas every pass's output, in the list's order, even when passes finish out of order. It's there only withcollectAllIterations: true.iterationsis how many passes ran.stopReasonis why the loop stopped:array-exhausted,exit-conditionfor awhileloop, ormax-iterations.
Each pass is in the journal as iteration.started and iteration.completed,
and the body's steps carry the pass they belong to.
Until a condition holds
Section titled “Until a condition holds”A while loop repeats until its condition holds. Ask your coding agent:
"I want a flow that reads every page of my open orders." For reference, the
tool and the flow look like this. acme.list-all-orders reads every page of
a paginated list with a new tool, acme.list-orders:
import { defineTool } from '@kindgi/sdk/define';import type { ToolId } from '@kindgi/sdk/types';import { z } from 'zod';
const PAGES: Record<string, { orders: string[]; nextCursor?: string }> = { first: { orders: ['A-100', 'A-101'], nextCursor: 'p2' }, p2: { orders: ['A-102', 'A-103'], nextCursor: 'p3' }, p3: { orders: ['A-200'] },};
const defined = defineTool({ id: 'acme.list-orders' as ToolId, description: 'Lists open orders, one page at a time.', version: '0.1.0', input: z.object({ cursor: z.string().optional() }), output: z.object({ orders: z.array(z.string()), nextCursor: z.string().optional() }), effects: [], mutating: false, handler: async ({ cursor }) => { const page = PAGES[cursor ?? 'first']; if (page === undefined) throw new Error(`Unknown cursor ${cursor}`); return page; },});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;import { defineFlow } from '@kindgi/sdk/define';
const defined = defineFlow({ id: 'acme.list-all-orders', version: '0.1.0', name: 'List all open orders', description: 'Reads every page of open orders.', nodes: [ { id: 'pages', kind: 'loop', loopKind: 'while', // stop when the last page read has no next cursor exitCondition: { op: 'falsy', value: { path: 'iterationOutput.nextCursor' } }, maxIterations: 20, collectAllIterations: true, outputSchema: { type: 'object', properties: { orders: { type: 'array', items: { type: 'string' } }, nextCursor: { type: ['string', 'null'] }, }, required: ['orders'], }, body: { nodes: [ { id: 'page', kind: 'tool', ref: 'acme.list-orders', // runInput here is the previous page (the run input on the first pass) inputMapping: { cursor: { path: 'runInput.nextCursor' } }, }, ], edges: [ { id: 'b1', from: '$loop-start', to: 'page' }, { id: 'b2', from: 'page', to: '$loop-end' }, ], }, }, ], edges: [ { id: 'e1', from: '$start', to: 'pages' }, { id: 'e2', from: 'pages', to: '$end' }, ], output: { mapping: { pages: { path: 'nodeOutputs.pages.outputs' } }, },});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;from pydantic import BaseModel, Field
from kindgi import tool
PAGES = { "first": {"orders": ["A-100", "A-101"], "nextCursor": "p2"}, "p2": {"orders": ["A-102", "A-103"], "nextCursor": "p3"}, "p3": {"orders": ["A-200"]},}
class ListInput(BaseModel): cursor: str | None = None
class Page(BaseModel): orders: list[str] next_cursor: str | None = Field(None, alias="nextCursor")
@tool(id="acme.list-orders", mutating=False)def list_orders(input: ListInput) -> Page: """Lists open orders, one page at a time.""" page = PAGES.get(input.cursor or "first") if page is None: raise ValueError(f"Unknown cursor {input.cursor}") return Page(**page)from kindgi import Flow
list_all_orders = Flow( id="acme.list-all-orders", version="0.1.0", name="List all open orders", description="Reads every page of open orders.", nodes=[ { "id": "pages", "kind": "loop", "loopKind": "while", # stop when the last page read has no next cursor "exitCondition": {"op": "falsy", "value": {"path": "iterationOutput.nextCursor"}}, "maxIterations": 20, "collectAllIterations": True, "outputSchema": { "type": "object", "properties": { "orders": {"type": "array", "items": {"type": "string"}}, "nextCursor": {"type": ["string", "null"]}, }, "required": ["orders"], }, "body": { "nodes": [ { "id": "page", "kind": "tool", "ref": "acme.list-orders", # runInput here is the previous page (the run input on the first pass) "inputMapping": {"cursor": {"path": "runInput.nextCursor"}}, } ], "edges": [ {"id": "b1", "from": "$loop-start", "to": "page"}, {"id": "b2", "from": "page", "to": "$loop-end"}, ], }, }, ], edges=[ {"id": "e1", "from": "$start", "to": "pages"}, {"id": "e2", "from": "pages", "to": "$end"}, ], output={"mapping": {"pages": {"path": "nodeOutputs.pages.outputs"}}},)package acme.tools;
import com.kindgi.pack.Tool;import java.util.List;import java.util.Map;import org.jspecify.annotations.Nullable;
public final class ListOrders { public record Input(@Nullable String cursor) {}
public record Page(List<String> orders, @Nullable String nextCursor) {}
private static final Map<String, Page> PAGES = Map.of( "first", new Page(List.of("A-100", "A-101"), "p2"), "p2", new Page(List.of("A-102", "A-103"), "p3"), "p3", new Page(List.of("A-200"), null));
public static final Tool<Input, Page> TOOL = Tool.define("acme.list-orders") .description("Lists open orders, one page at a time.") .version("0.1.0") .input(Input.class) .output(Page.class) .mutating(false) .handler((input, ctx) -> { Page page = PAGES.get(input.cursor() == null ? "first" : input.cursor()); if (page == null) throw new IllegalArgumentException("Unknown cursor " + input.cursor()); return page; });
private ListOrders() {}}package acme.flows;
import com.kindgi.pack.Flow;import java.util.List;import java.util.Map;
public final class ListAllOrders { public static final Flow FLOW = Flow.define("acme.list-all-orders") .version("0.1.0") .set("name", "List all open orders") .set("description", "Reads every page of open orders.") .node(Map.of( "id", "pages", "kind", "loop", "loopKind", "while", // stop when the last page read has no next cursor "exitCondition", Map.of("op", "falsy", "value", Map.of("path", "iterationOutput.nextCursor")), "maxIterations", 20, "collectAllIterations", true, "outputSchema", Map.of( "type", "object", "properties", Map.of( "orders", Map.of("type", "array", "items", Map.of("type", "string")), "nextCursor", Map.of("type", List.of("string", "null"))), "required", List.of("orders")), "body", Map.of( "nodes", List.of(Map.of( "id", "page", "kind", "tool", "ref", "acme.list-orders", // runInput here is the previous page (the run input on the first pass) "inputMapping", Map.of("cursor", Map.of("path", "runInput.nextCursor")))), "edges", List.of( Map.of("id", "b1", "from", "$loop-start", "to", "page"), Map.of("id", "b2", "from", "page", "to", "$loop-end"))))) .edge("e1", "$start", "pages") .edge("e2", "pages", "$end") .set("output", Map.of("mapping", Map.of("pages", Map.of("path", "nodeOutputs.pages.outputs")))) .build();
private ListAllOrders() {}}package acme.tools
import com.kindgi.pack.scaladsl._
object ListOrders { final case class Input(cursor: Option[String]) final case class Page(orders: Seq[String], nextCursor: Option[String])
private val pages = Map( "first" -> Page(Seq("A-100", "A-101"), Some("p2")), "p2" -> Page(Seq("A-102", "A-103"), Some("p3")), "p3" -> Page(Seq("A-200"), None))
val tool: Tool[Input, Page] = Tool[Input, Page]("acme.list-orders") .description("Lists open orders, one page at a time.") .version("0.1.0") .readOnly .handler { (in, _) => val cursor = in.cursor.getOrElse("first") pages.getOrElse(cursor, throw new NoSuchElementException(s"Unknown cursor $cursor")) }}package acme.flows
import com.kindgi.pack.scaladsl._
object ListAllOrders { val flow: Flow = Flow("acme.list-all-orders") .version("0.1.0") .set("name", "List all open orders") .set("description", "Reads every page of open orders.") .node(Map( "id" -> "pages", "kind" -> "loop", "loopKind" -> "while", // stop when the last page read has no next cursor "exitCondition" -> Map("op" -> "falsy", "value" -> Map("path" -> "iterationOutput.nextCursor")), "maxIterations" -> 20, "collectAllIterations" -> true, "outputSchema" -> Map( "type" -> "object", "properties" -> Map( "orders" -> Map("type" -> "array", "items" -> Map("type" -> "string")), "nextCursor" -> Map("type" -> List("string", "null"))), "required" -> List("orders")), "body" -> Map( "nodes" -> List(Map( "id" -> "page", "kind" -> "tool", "ref" -> "acme.list-orders", // runInput here is the previous page (the run input on the first pass) "inputMapping" -> Map("cursor" -> Map("path" -> "runInput.nextCursor")))), "edges" -> List( Map("id" -> "b1", "from" -> "$loop-start", "to" -> "page"), Map("id" -> "b2", "from" -> "page", "to" -> "$loop-end"))))) .edge("e1", "$start", "pages") .edge("e2", "pages", "$end") .set("output", Map("mapping" -> Map("pages" -> Map("path" -> "nodeOutputs.pages.outputs")))) .build()}- The first pass gets the loop step's input: the output of the step before
it, here the run's input. Each later pass gets the previous pass's
output, so
runInput.nextCursorin the body is the cursor the last page returned. exitConditionis checked after each pass, againstiterationOutput, that pass's output, anditerationIndex, from 0. The loop stops when it holds, or aftermaxIterationspasses.
Run it, with an empty input:
pnpm exec kindgi runs start --flow=acme.list-all-orders --input='{}'uv run kindgi runs start --flow=acme.list-all-orders --input='{}'./kindgiw runs start --flow=acme.list-all-orders --input='{}'./kindgiw runs start --flow=acme.list-all-orders --input='{}'The run's output holds every page's orders:
{ "pages": [ { "orders": ["A-100", "A-101"], "nextCursor": "p2" }, { "orders": ["A-102", "A-103"], "nextCursor": "p3" }, { "orders": ["A-200"] } ]}{ "pages": [ { "orders": ["A-100", "A-101"], "nextCursor": "p2" }, { "orders": ["A-102", "A-103"], "nextCursor": "p3" }, { "orders": ["A-200"], "nextCursor": null } ]}A pydantic field that is None is null in the output. So the schema allows
null, and the condition is falsy, which is true for a missing value and
for null.
{ "pages": [ { "orders": ["A-100", "A-101"], "nextCursor": "p2" }, { "orders": ["A-102", "A-103"], "nextCursor": "p3" }, { "orders": ["A-200"] } ]}{ "pages": [ { "orders": ["A-100", "A-101"], "nextCursor": "p2" }, { "orders": ["A-102", "A-103"], "nextCursor": "p3" }, { "orders": ["A-200"] } ]}That's the run's output, from nodeOutputs.pages.outputs.
A loop that reaches maxIterations stops with stopReason: "max-iterations"
and completes. Check stopReason if running out of passes is an error for
you.