docs/guides/workflow/graph/index.md
A workflow in ADK 2.0 is a directed graph. The nodes are the work, the edges say what follows what, and execution moves along those edges until there is nothing left to run. The chain tuple shown on the Workflow page is one of two ways to write that graph down, and whichever you use, the finished graph is checked against nine rules before it runs.
The graph is the execution plan for a multi-step agent interaction. It specifies three things:
None of it is checked lazily. The graph is compiled and validated the moment you instantiate the Workflow class, so a graph that could never have worked is rejected before a single node runs.
The graph below is complete: START fans out to two nodes that run in parallel,
a JoinNode waits for both, and a final node reads the aggregated result. The
examples further down are fragments of a workflow shaped like this one rather
than complete programs.
from typing import Any
from google.adk import Workflow
from google.adk.workflow import JoinNode, START
def word_count(node_input: str) -> int:
return len(node_input.split())
def shout(node_input: str) -> str:
return node_input.upper()
join = JoinNode(name="join")
def report(node_input: dict[str, Any]) -> str:
return f"{node_input['shout']} ({node_input['word_count']} words)"
workflow = Workflow(
name="fan_out_workflow",
edges=[
(START, (word_count, shout), join, report),
],
)
The single chain tuple expands into five edges: START to each of the two
workers, each worker to join, and join to report. Both workers receive the
same input, the user's message, because they both follow START.
Both halves of a graph have a type you can name in your own code. NodeLike
covers everything that can serve as a node, and Edge is a single transition
written out in full.
NodeLike)A node is one unit of execution in the workflow. Several different kinds of object can serve as one, and the type that covers all of them is called NodeLike:
FunctionNode for you, so a bare function is already a node. The @node decorator is there to configure that wrapper, for when you want a retry_config, a timeout or rerun_on_resume on it, and you never need it to make a function usable in the first place. The exception is an explicit Edge, which takes a BaseNode rather than a function, so a function used there has to be wrapped even when there is nothing to configure. See Explicit edge objects.LlmAgent instances, usually in single_turn mode. Pass one straight into a chain tuple, the way you would a function.BaseTool instances, which go into a chain tuple in the same way.Workflow is itself a BaseNode, so one workflow can be nested as a child node inside another.START. The sentinel that marks the entry point. It has to be present in the graph and nothing may point into it, since it never executes and so an edge arriving at it could never fire, but any number of edges may leave it, and the fan-out/fan-in sample opens three parallel branches straight from START. Import it from google.adk.workflow and not from google.adk, because the top-level package does not export it and the wrong import fails with ImportError before any node runs. Workflow shows which names the top-level package does export. The string "START" is an accepted alias for the START object and the shipped samples use it, but the examples here stay with the object, since that is the form Edge(from_node=START, ...) requires.Edge)An edge is a transition from a source node (from_node) to a destination node (to_node).
Edges are unconditional unless you say otherwise, which means that as soon as the source node completes, execution moves on to the destination.
An edge can also carry one or more routes, each of which is a string, an integer or a boolean. An edge with routes is followed only when the source node emits one that matches.
To emit a route, the source node yields an Event(route="my_route"), or returns or yields an object that maps to that route.
For the cases you have not enumerated, you can add a fallback edge using DEFAULT_ROUTE, which you get with from google.adk.workflow import DEFAULT_ROUTE or write as the literal "__DEFAULT__". That edge is taken when the source node emits a route and no specific conditional edge matches it.
Without a fallback, a route that matches no edge ends that branch where it stands. Nothing downstream of the source node runs, and the workflow carries on with whatever other branches it has.
However you build it, the graph reaches the framework the same way: as a list of edges passed to the Workflow constructor. There are two ways to write that list, and you can mix them.
A chain tuple describes a run of nodes in the order they should execute, and nesting inside it is what gives you parallel and conditional transitions. The chain tuple is the shorter of the two syntaxes, and most graphs are written with it.
Sequential Chain:
edges=[
(START, step_a, step_b, step_c),
]
Each node runs once the one before it has finished, which gives you
START -> step_a -> step_b -> step_c.
Parallel Fan-Out: Use a tuple of nodes to split execution into parallel branches.
edges=[
(START, step_a, (step_b, step_c)),
]
step_a runs after START, and once it finishes both step_b and
step_c start at the same time.
Watch the end of that graph, though. As written it finishes with two
terminal nodes, and if both of them produce an output the run fails with a
ValueError saying multiple terminal nodes produced output. Send the
branches into a JoinNode first, the way the fan-out example above does.
Workflow Output explains why the
rule exists.
Conditional Routing: Use a dictionary, called a routing map, to define conditional branches.
from google.adk.workflow import DEFAULT_ROUTE
edges=[
(START, step_a, {
"success": step_b,
"failure": step_c,
DEFAULT_ROUTE: fallback_step,
}),
]
If step_a yields Event(route="success") execution goes to step_b, and
if it yields "failure" it goes to step_c instead. Any other route falls
through to fallback_step.
A routing map value may itself be a tuple, in which case matching that route fans out to every node in it:
edges=[
(START, step_a, {"success": (step_b, step_c), "failure": fallback_step}),
]
That is a fan-out like the one above, so the same terminal-output rule
applies to step_b and step_c.
The second syntax names every edge individually with an Edge object. It is more to type, and worth it once a graph is large enough that a nested tuple stops being readable, or whenever you would rather see each transition spelled out.
from google.adk.workflow import Edge, START, node
@node()
def step_a(node_input: str):
...
@node()
def step_b(node_input: str):
...
@node()
def step_c(node_input: str):
...
edges=[
Edge(from_node=START, to_node=step_a),
Edge(from_node=step_a, to_node=step_b, route="success"),
Edge(from_node=step_a, to_node=step_c, route="failure"),
]
Edge is stricter than a chain tuple about what counts as a node. Both
from_node and to_node are annotated BaseNode, and Pydantic enforces that,
so a bare function is rejected with "Input should be a valid dictionary or
instance of BaseNode". Wrap the function first, with @node() as above or with
FunctionNode(func=...). For the same reason the string "START" works in a
chain tuple but not here; pass the START object.
Initializing a Workflow builds the graph and checks it, so that a structural mistake surfaces while you are still writing the graph rather than in the middle of a run. There are nine rules, and all of them are enforced.
Every distinct node object in the graph needs its own name, so two different function nodes both called process_data fail validation. The name is how the graph, the event stream and a JoinNode's aggregated output all refer to a node, and none of those could tell the two apart. Rename one of them. If what you actually wanted was for the graph to come back to the same step twice, reuse the exact same object instance instead of creating a second one, and the rule stops applying.
The graph has to contain the START node, and no edge may name START as its destination. Any number of edges may leave it. A graph with no START at all, or one with an edge pointing back into it, is rejected. Execution begins at START and START itself never runs, so an edge arriving there could never fire and a graph without one has nowhere to begin.
An edge out of START must be unconditional, because nothing has run yet that could have emitted a route. Writing (START, {"a": node_a}) fails with "Edges from START must not have routes". When you want to branch immediately, put the router in a node of its own and branch out of that.
Every node in the graph must be reachable from START. A node nothing points at never becomes ready, so it would sit in the graph without ever running, and validation fails rather than quietly leaving it out. Defining a node and then forgetting to connect it is the usual way to trip this rule.
The same two nodes may be joined only once. Listing Edge(from_node=A, to_node=B) twice in the same edge list fails, which is what catches the copy-and-paste slip. Repeating an edge is not how you make B run more than once. A second run comes from giving B another predecessor, or from a conditional loop back to it.
A node may have at most one outgoing DEFAULT_ROUTE edge, since two fallbacks would leave the choice undefined. DEFAULT_ROUTE also cannot share a list with other routes, so route=["success", DEFAULT_ROUTE] is invalid.
A cycle made entirely of unconditional edges, such as A -> B -> A with no routes anywhere in it, is rejected, because nothing in the graph would ever break out of it. Conditional loops are allowed and are how you write iteration: the same A -> B -> A is fine as long as the edge from B back to A depends on a route.
If a node has an output_schema and its successor has an input_schema, the two must be equal, and the graph is rejected when they are not. The rule catches people out because both schemas are often inferred rather than written: a function node takes its output_schema from its return type hint and its input_schema from its node_input hint, so two functions annotated with different Pydantic models fail the check without either schema having been set by hand. See BaseNode.
An LlmAgent configured with mode='chat' may only follow the START node. A chat-mode agent manages its own conversational history and has no way to consume an input handed to it by a preceding node, so it cannot sit in the middle of a chain. Use mode='single_turn' for a step that takes input from the node before it.
ValidationError. Validation runs while the Workflow is being constructed, so the message above is nested in a Pydantic report rather than being the exception's first line. ValidationError subclasses ValueError, so an except ValueError still catches it.Each of these is a working graph you can read for the shape rather than the syntax:
START, rejoined by a JoinNode.