Skip to content

Repeat steps in a loop

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.

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, at most 32).
  • maxIterations caps the passes, and outputSchema is what each pass must return. Both are required.
Terminal window
kindgi runs start --flow=acme.check-order-stock --input='{"orderId":"A-200"}'
{
…
"status": "completed",
…
"output": {
"items": [
{
"sku": "desk",
"inStock": false
},
{
"sku": "lamp",
"inStock": true
}
]
},
…
}

The loop step's own output, in the journal:

{"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}}}, …}
  • 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 (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.

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.
Terminal window
kindgi runs start --flow=acme.list-all-orders --input='{}'
{
"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.