Skip to content

Repeat steps in a loop

View as Markdown

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.
  • A pack with the tools from Write a flow, such as acme.get-order, on a runtime such as kindgi dev, with Kindgi 0.1's CLI.

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:

tools/check-stock/index.ts
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;
flows/check-order-stock/index.ts
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;
  • iterateOver is the list: a { path } that must resolve to an array when the loop starts.
  • body has its own steps and edges, from $loop-start to $loop-end. Each pass gets one element as its input: here { "sku": "desk", "quantity": 1 }, which is acme.check-stock's input as it is.
  • concurrency runs that many passes at a time, 1 by default and at most 32.
  • maxIterations caps the passes, and outputSchema is what each pass must return. Both are required.

Run it:

Terminal window
pnpm exec kindgi 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
}
]
},
…
}
Excerpt

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}}}, …}
Excerpt
  • finalOutput is the last pass's output.
  • outputs has every pass's output, in the list's order, even when passes finish out of order. It's there only with collectAllIterations: true.
  • iterations is how many passes ran.
  • stopReason is why the loop stopped: array-exhausted, exit-condition for a while loop, or max-iterations.

Each pass is in the journal as iteration.started and iteration.completed, and the body's steps carry the pass they belong to.

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:

tools/list-orders/index.ts
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;
flows/list-all-orders/index.ts
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;
  • 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.nextCursor in the body is the cursor the last page returned.
  • exitCondition is checked after each pass, against iterationOutput, that pass's output, and iterationIndex, from 0. The loop stops when it holds, or after maxIterations passes.

Run it, with an empty input:

Terminal window
pnpm exec kindgi 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"] }
]
}

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.