Join branches
This guide brings a flow's branches back together, in a step that runs once after whichever of them ran.
A step with several incoming edges waits until each of them is decided, then runs once if at least one fired.
Before you start
Section titled “Before you start”- A pack with the tools and the branch from
Branch a flow:
acme.get-orderandacme.hold-order. - A runtime such as
kindgi dev, with Kindgi 0.1's CLI.
Join two branches
Section titled “Join two branches”Ask your coding agent: "I want a flow that holds a large order, then emails
the customer either way." Kindgi's skills in your pack tell it how
(How it knows Kindgi). For reference, the
tool and the flow look like this. This guide calls the flow
acme.handle-order, and it adds one tool, acme.email-customer:
import { defineTool } from '@kindgi/sdk/define';import type { ToolId } from '@kindgi/sdk/types';import { z } from 'zod';
const defined = defineTool({ id: 'acme.email-customer' as ToolId, description: 'Emails the customer about their order.', version: '0.1.0', input: z.object({ customer: z.string(), status: z.string().optional(), // absent when the order wasn't held }), output: z.object({ to: z.string(), message: z.string() }), effects: [], handler: async ({ customer, status }) => ({ to: customer, message: status === 'held' ? 'Your order is being reviewed.' : 'Your order is on its way.', }),});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;import { defineFlow } from '@kindgi/sdk/define';
const isLarge = { op: 'gt', left: { path: 'nodeOutputs.order.total' }, right: { literal: 1000 },} as const;
const defined = defineFlow({ id: 'acme.handle-order', version: '0.1.0', name: 'Handle an order', description: 'Holds a large order, then tells the customer either way.', nodes: [ { id: 'order', kind: 'tool', ref: 'acme.get-order', inputMapping: { orderId: { path: 'runInput.orderId' } }, }, { id: 'hold', kind: 'tool', ref: 'acme.hold-order', inputMapping: { orderId: { path: 'runInput.orderId' }, reason: { literal: 'Over 1000 USD' }, }, }, { id: 'email', kind: 'tool', ref: 'acme.email-customer', inputMapping: { customer: { path: 'nodeOutputs.order.customer' }, status: { path: 'nodeOutputs.hold.status' }, // absent when hold didn't run }, }, ], edges: [ { id: 'e1', from: '$start', to: 'order' }, { id: 'e2', from: 'order', to: 'hold', when: isLarge }, { id: 'e3', from: 'order', to: 'email', when: { op: 'not', child: isLarge } }, { id: 'e4', from: 'hold', to: 'email' }, { id: 'e5', from: 'email', to: '$end' }, ],});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;from pydantic import BaseModel
from kindgi import tool
class EmailInput(BaseModel): customer: str status: str | None = None # absent when the order wasn't held
class Emailed(BaseModel): to: str message: str
@tool(id="acme.email-customer")def email_customer(input: EmailInput) -> Emailed: """Emails the customer about their order.""" message = ( "Your order is being reviewed." if input.status == "held" else "Your order is on its way." ) return Emailed(to=input.customer, message=message)from kindgi import Flow
IS_LARGE = { "op": "gt", "left": {"path": "nodeOutputs.order.total"}, "right": {"literal": 1000},}
handle_order = Flow( id="acme.handle-order", version="0.1.0", name="Handle an order", description="Holds a large order, then tells the customer either way.", nodes=[ { "id": "order", "kind": "tool", "ref": "acme.get-order", "inputMapping": {"orderId": {"path": "runInput.orderId"}}, }, { "id": "hold", "kind": "tool", "ref": "acme.hold-order", "inputMapping": { "orderId": {"path": "runInput.orderId"}, "reason": {"literal": "Over 1000 USD"}, }, }, { "id": "email", "kind": "tool", "ref": "acme.email-customer", "inputMapping": { "customer": {"path": "nodeOutputs.order.customer"}, "status": {"path": "nodeOutputs.hold.status"}, # absent when hold didn't run }, }, ], edges=[ {"id": "e1", "from": "$start", "to": "order"}, {"id": "e2", "from": "order", "to": "hold", "when": IS_LARGE}, {"id": "e3", "from": "order", "to": "email", "when": {"op": "not", "child": IS_LARGE}}, {"id": "e4", "from": "hold", "to": "email"}, {"id": "e5", "from": "email", "to": "$end"}, ],)package acme.tools;
import com.kindgi.pack.Tool;import org.jspecify.annotations.Nullable;
public final class EmailCustomer { public record Input(String customer, @Nullable String status) {} // status: absent when the order wasn't held
public record Output(String to, String message) {}
public static final Tool<Input, Output> TOOL = Tool.define("acme.email-customer") .description("Emails the customer about their order.") .version("0.1.0") .input(Input.class) .output(Output.class) .handler((input, ctx) -> new Output(input.customer(), "held".equals(input.status()) ? "Your order is being reviewed." : "Your order is on its way."));
private EmailCustomer() {}}package acme.flows;
import com.kindgi.pack.Flow;import java.util.Map;
public final class HandleOrder { private static final Map<String, Object> IS_LARGE = Map.of( "op", "gt", "left", Map.of("path", "nodeOutputs.order.total"), "right", Map.of("literal", 1000));
public static final Flow FLOW = Flow.define("acme.handle-order") .version("0.1.0") .set("name", "Handle an order") .set("description", "Holds a large order, then tells the customer either way.") .node(Map.of("id", "order", "kind", "tool", "ref", "acme.get-order", "inputMapping", Map.of("orderId", Map.of("path", "runInput.orderId")))) .node(Map.of("id", "hold", "kind", "tool", "ref", "acme.hold-order", "inputMapping", Map.of( "orderId", Map.of("path", "runInput.orderId"), "reason", Map.of("literal", "Over 1000 USD")))) .node(Map.of("id", "email", "kind", "tool", "ref", "acme.email-customer", "inputMapping", Map.of( "customer", Map.of("path", "nodeOutputs.order.customer"), "status", Map.of("path", "nodeOutputs.hold.status")))) // absent when hold didn't run .edge("e1", "$start", "order") .edge(Map.of("id", "e2", "from", "order", "to", "hold", "when", IS_LARGE)) .edge(Map.of("id", "e3", "from", "order", "to", "email", "when", Map.of("op", "not", "child", IS_LARGE))) .edge("e4", "hold", "email") .edge("e5", "email", "$end") .build();
private HandleOrder() {}}package acme.tools
import com.kindgi.pack.scaladsl._
object EmailCustomer { final case class Input(customer: String, status: Option[String]) // status: absent when the order wasn't held final case class Output(to: String, message: String)
val tool: Tool[Input, Output] = Tool[Input, Output]("acme.email-customer") .description("Emails the customer about their order.") .version("0.1.0") .handler { (in, _) => val message = if (in.status.contains("held")) "Your order is being reviewed." else "Your order is on its way." Output(in.customer, message) }}package acme.flows
import com.kindgi.pack.scaladsl._
object HandleOrder { private val isLarge = Map( "op" -> "gt", "left" -> Map("path" -> "nodeOutputs.order.total"), "right" -> Map("literal" -> 1000))
val flow: Flow = Flow("acme.handle-order") .version("0.1.0") .set("name", "Handle an order") .set("description", "Holds a large order, then tells the customer either way.") .node(Map("id" -> "order", "kind" -> "tool", "ref" -> "acme.get-order", "inputMapping" -> Map("orderId" -> Map("path" -> "runInput.orderId")))) .node(Map("id" -> "hold", "kind" -> "tool", "ref" -> "acme.hold-order", "inputMapping" -> Map( "orderId" -> Map("path" -> "runInput.orderId"), "reason" -> Map("literal" -> "Over 1000 USD")))) .node(Map("id" -> "email", "kind" -> "tool", "ref" -> "acme.email-customer", "inputMapping" -> Map( "customer" -> Map("path" -> "nodeOutputs.order.customer"), "status" -> Map("path" -> "nodeOutputs.hold.status")))) // absent when hold didn't run .edge("e1", "$start", "order") .edge(Map("id" -> "e2", "from" -> "order", "to" -> "hold", "when" -> isLarge)) .edge(Map("id" -> "e3", "from" -> "order", "to" -> "email", "when" -> Map("op" -> "not", "child" -> isLarge))) .edge("e4", "hold", "email") .edge("e5", "email", "$end") .build()}email has two incoming edges: e4 from hold, and e3 straight from
order when the order isn't large. Whichever path the run takes, email
runs once, at the end of it.
Run it for a small order and a large one:
pnpm exec kindgi runs start --flow=acme.handle-order --input='{"orderId":"A-100"}'pnpm exec kindgi runs start --flow=acme.handle-order --input='{"orderId":"A-200"}'uv run kindgi runs start --flow=acme.handle-order --input='{"orderId":"A-100"}'uv run kindgi runs start --flow=acme.handle-order --input='{"orderId":"A-200"}'./kindgiw runs start --flow=acme.handle-order --input='{"orderId":"A-100"}'./kindgiw runs start --flow=acme.handle-order --input='{"orderId":"A-200"}'./kindgiw runs start --flow=acme.handle-order --input='{"orderId":"A-100"}'./kindgiw runs start --flow=acme.handle-order --input='{"orderId":"A-200"}'--flow is the flow, and --input its input. The two runs return:
{ "to": "ada@example.com", "message": "Your order is on its way." }{ "to": "grace@example.com", "message": "Your order is being reviewed." }The input of a joining step
Section titled “The input of a joining step”email maps status from nodeOutputs.hold.status. For A-100, hold
didn't run, so the path doesn't resolve and the key is left out. Its journal,
which kindgi runs journal <run-id> prints, shows it:
{"sequence": 4, "kind": "edge.evaluated", "payload": {"edgeId": "e2", "decision": false}, …}{"sequence": 5, "kind": "edge.evaluated", "payload": {"edgeId": "e3", "decision": true}, …}{"sequence": 6, "kind": "step.started", "nodeId": "email", "payload": {"input": {"customer": "ada@example.com"}}, …}That's why status is optional in acme.email-customer's input. A required
key fed by a branch that may not run fails the step whenever that branch
doesn't run.
Give a joining step an inputMapping. Without one, a step gets the output of
the step before it, and a joining step has several: its input is empty.
Branches that both run
Section titled “Branches that both run”Edges without conditions fire together, so two steps after the same step run at the same time. A step that joins them runs once, after both. To run several tools on the same input and collect their outputs as one, a fanout step is shorter.