Graph engineering is the design work: breaking a task into nodes, connecting them with edges, and deciding where code, models, or people control the next step. The Agent Development Kit (ADK)‘s Workflow turns that design into an executable process, with functions and agents doing the work. Through a refund example, this post shows how to run steps in parallel, route decisions, pause for human review, and process a list of cases. It also explains when to declare the paths in a static graph and when to let Python schedule further work as results arrive.

TL;DR: Using a refund workflow in ADK, we’ll cover fan-out and fan-in, deterministic and agent routers, human-in-the-loop pauses, parallel workers, and dynamic orchestration— along with when to use a static graph or let Python decide what runs next.

Start with a single agent

We can give one agent the tools and instructions to handle the refund request from start to finish:

code_block
<ListValue: [StructValue([('code', 'refund_agent = Agent(rn name="refund_agent", model=MODEL,rn tools=[fetch_order, fetch_payment, fetch_history],rn instruction="""You handle refund requests.rn 1. Look up the order.rn 2. Check the payment record.rn 3. Check the customer's refund history.rn 4. Deny if there's an open chargeback or it's past 30 days.rn Approve if it's under $50 and they've had fewer than threern refunds this year.rn 5. Write the customer an email explaining the decision.""",rn)'), ('language', 'lang-py'), ('caption', )])]>

This puts the model in charge of choosing the tools, applying the policy, and writing the reply. But the prompt already describes distinct pieces of work: three lookups, a decision, and a response. Making those pieces separate nodes lets us decide how each should run.

graph-workflows-in-adk-everything-you-need-to-know-01-five-nodes

Assume the customer has selected an order and clicked “Request refund.” The app knows the order ID, so the workflow can begin with the lookups.

Let independent steps run together

The order, payment, and refund-history lookups all need the order ID, but none needs another lookup’s result. Although the prompt lists them one after another, there is no reason for them to wait for each other. We can run all three in parallel.

The policy decision is different: it needs all three records. So the workflow splits into three paths, then brings their results together before continuing. These two moves are called fan-out and fan-in.

In ADK, the lookup functions can become nodes directly. A nested tuple starts them together, and a JoinNode waits for their results. First, the imports and a lookup signature:

code_block
dict:rn …’), (‘language’, ‘lang-py’), (‘caption’, )])]>

Each lookup receives the order ID through node_input and returns a dictionary. We can connect them like this:

code_block
<ListValue: [StructValue([('code', 'join_case = JoinNode(name="join_case")rnrnedges=[rn (START, (fetch_order, fetch_payment, fetch_history), join_case),rn]'), ('language', 'lang-py'), ('caption', )])]>

Read this from left to right: start all three lookups, then continue through join_case once they finish.

graph-workflows-in-adk-everything-you-need-to-know-02-fanout-join

The join returns a dictionary keyed by node name. The next node can read node_input["fetch_order"], node_input["fetch_payment"], and node_input["fetch_history"] without a model call to collect the results. The JoinNode guide explains the details.

If the payment lookup needed a transaction ID from the order lookup, those two would run in sequence. Dependencies determine the edges, even when the prompt lists every step in order.

You can learn more about fan out and fan in in this video:

Video about how to build a production-ready, multi-agent AI system from scratch using Graph Engineering and Google's Agent Development Kit (ADK).

Graph Engineering with ADK

One detail matters when turning tools into nodes: the parameter named node_input receives the previous node’s output. Other names bind to ctx.state by default, so order_id would look for ctx.state["order_id"] and raise a ValueError if it is missing. Here, the str annotation also converts START‘s types.Content input to a string.

Route each request to the right workflow

So far, the customer has explicitly requested a refund. In a broader support conversation, we first need to identify what they want and send the request to the right process. “The shoes are the wrong size. Could you send me a different pair?” should go to an exchange workflow, while a request for money back should enter our refund workflow.

That introduces a router, a node that chooses which branch runs next.

  • A deterministic router follows explicit rules, the same inputs produce the same route.
  • A nondeterministic router can choose different branches for the same input.
  • An agent router uses a model to interpret the request, so its choice can vary.

Routers choose among the paths defined by the workflow. In our support example, we can use an agent to identify the customer’s intent, then fixed rules to apply the refund policy.

Identify the intent with an agent router

An agent can interpret the customer’s message and classify the request. In one ADK pattern, it returns a structured category, then a small function emits the corresponding Event(route=...) to send the request to the chosen workflow:

code_block
<ListValue: [StructValue([('code', 'Customer message → classification agent → route functionrn ├─ REFUND → refund workflowrn ├─ EXCHANGE → exchange workflowrn └─ CLARIFY → ask a follow-up question'), ('language', ''), ('caption', )])]>

The model identifies the intent; the graph defines the available destinations. If the request is unclear, the workflow can ask a follow-up question. ADK’s routing sample shows this pattern.

This intent router would sit before our refund workflow. For the selected-order example, the “Request refund” button has already established the intent, so we can enter that workflow directly.

Apply the refund policy with a deterministic router

Inside the refund workflow, the three lookups give us the facts for another decision: approve, deny, or ask a person to review. This time, the policy gives us explicit thresholds, so a function can choose the path:

code_block
30:rn return “DENY”rn if amount < 50 and priors < 3:rn return "AUTO_APPROVE"rn return "MANUAL_REVIEW"rnrndef route_refund(node_input):rn case = {**node_input["fetch_order"], **node_input["fetch_payment"],rn **node_input["fetch_history"]}rn route = refund_policy(case["amount_usd"], case["placed_days_ago"],rn case["chargeback_open"], case["prior_refunds_12mo"])rn return Event(output=case, route=route) # the function names the path'), ('language', 'lang-py'), ('caption', )])]>

route_refund combines the records and returns the case with one of three route names: AUTO_APPROVE, DENY, or MANUAL_REVIEW. Manual review handles cases that meet neither automatic rule.

This is a deterministic router: the same case data produces the same decision, and we can test the policy without calling a model.

 

  Deterministic router Agent router
Decisions come best from Rules in code A model interpreting the input
Best fit Known facts and explicit policy Meaning that is hard to capture in rules
Example Deny an order older than 30 days Recognize an exchange request
Model call for routing None Required
 

Both routers choose among defined paths. An agent router can sit inside a static graph: the model’s choice varies, while the possible connections stay the same.

For an automatic approval or denial, the next step is to explain the decision to the customer. We give each route a notice agent that writes the reply from the case data. ADK passes the dictionary in Event(output=case, ...) to that agent as a JSON user message:

code_block
<ListValue: [StructValue([('code', 'approve_notice = Agent(rn name="approve_notice", model=MODEL,rn instruction="Tell the customer their refund is approved and when to expect the "rn "money, using the case JSON you receive. Short email, warm, no fluff.",rn)rndenial_notice = Agent(rn name="denial_notice", model=MODEL,rn instruction="Tell the customer their refund was declined and exactly why, based "rn "on the case JSON you receive. If it carries a reviewer_note, that is "rn "the reason. Short email, direct and kind. Do not invent policy.",rn)'), ('language', 'lang-py'), ('caption', )])]>

The refund workflow now has a path from the initial lookups to a decision and a reply, with a third branch for cases that need a person:

code_block
<ListValue: [StructValue([('code', 'workflow = Workflow(rn name="refund_decision",rn edges=[rn (START, (fetch_order, fetch_payment, fetch_history),rn join_case, route_refund),rn (route_refund, {"AUTO_APPROVE": approve_notice,rn "MANUAL_REVIEW": escalate_to_human,rn "DENY": denial_notice}),rn ],rn)'), ('language', 'lang-py'), ('caption', )])]>

The first chain fetches the records, joins them, and applies the policy. The second maps the router’s decision to a destination. Automatic decisions go straight to a notice agent; MANUAL_REVIEW goes to the human-review node we’ll define next.

graph-workflows-in-adk-everything-you-need-to-know-03-refund-graph

That third branch needs more than another function call. A reviewer may take minutes or days to answer, so the workflow must pause with the case pending and continue once the person decides.

The review node yields RequestInput, which records the pending request and pauses the run. On resume, rerun_on_resume=True runs the node again, with the answer available in ctx.resume_inputs:

code_block
<ListValue: [StructValue([('code', 'REVIEW = "refund:review"rnrnclass ReviewDecision(BaseModel):rn approve: bool = Field(description="True to refund, False to decline.")rn note: str = Field("", description="Why, in the reviewer's words.")rnrn@node(rerun_on_resume=True)rnasync def escalate_to_human(ctx: Context, node_input: dict):rn answer = ctx.resume_inputs.get(REVIEW)rn if answer is None: # first pass: ask, then stoprn yield RequestInput(rn interrupt_id=REVIEW,rn message=f"Refund ${node_input['amount_usd']} on order "rn f"{node_input['order_id']}?",rn payload=node_input, # what the reviewer is shownrn response_schema=ReviewDecision,rn )rn returnrn # second pass: the answer is here, so route on itrn yield Event(output={**node_input, "reviewer_note": answer.get("note", "")},rn route="AUTO_APPROVE" if answer["approve"] else "DENY")'), ('language', 'lang-py'), ('caption', )])]>

The response schema gives the node an approve value to route on and a note to carry forward. If the reviewer declines, the notice agent receives their reason with the case. One more edge connects the review decision to the reply:

code_block
<ListValue: [StructValue([('code', 'edges=[rn …,rn (escalate_to_human, {"AUTO_APPROVE": approve_notice,rn "DENY": denial_notice}),rn]'), ('language', 'lang-py'), ('caption', )])]>

ADK ships two variants of this. The one above is the single-node pattern: the node reruns and reads ctx.resume_inputs, as in the request_input_rerun sample. The request_input sample shows the two-node variant, where one node yields RequestInput and the reviewer’s answer arrives as the next node’s node_input — so there is no ctx.resume_inputs to find in that file. The companion scripts linked below include the code that sends the reviewer’s answer back to the run.

A Workflow is also a node. This whole refund process can become one step in a larger customer-service workflow.

Apply the same step to a batch of cases

We now have a process for one refund. Suppose a batch of cases arrives with the records already collected. Each needs the same policy check, and the number of cases changes from batch to batch. We can apply one node to every item using a parallel worker.

In ADK, parallel_worker=True runs a node once per item in an input list and collects the results in the original order. We can reuse our policy function:

code_block
<ListValue: [StructValue([('code', '@node(parallel_worker=True)rndef review_case(node_input):rn # Each worker receives one case from the input list.rn case = node_inputrn decision = refund_policy(rn case["amount_usd"], case["placed_days_ago"],rn case["chargeback_open"], case["prior_refunds_12mo"],rn )rn return {"order_id": case["order_id"], "decision": decision}rnrndef collect_decisions(node_input):rn # This node receives the list of worker results.rn return {"decisions": node_input}rnrnbatch_review = Workflow(rn name="batch_review",rn edges=[(START, review_case, collect_decisions)],rn)'), ('language', 'lang-py'), ('caption', )])]>

Each worker receives one case, and collect_decisions receives the results as a list. No separate JoinNode is needed. The flag also works on agents—for example, to write an explanation for each case. The parallel_worker sample shows both forms.

The policy calculation here is small. Concurrency is more useful when each item waits on an API or model call, but the way inputs and results move stays the same.

 

Pattern Work distributed Collected output
Fan-out with JoinNode Different notes doing independent jobs Dictionary keyed by node name
Parallel worker The same node for every item List in input order
 

The batch size can change without changing the graph. A variable amount of work still fits inside a fixed process. See the parallel worker guide for execution details.

Let results shape the next step

Our refund process has known paths, even when a batch contains more cases or a reviewer takes longer to answer. But some work only becomes clear as we investigate.

Suppose a disputed refund reveals a second transaction. Checking it raises a delivery question that needs further investigation. Now each result can create follow-up work. A dynamic node can examine those results and schedule the next checks in Python.

ADK provides ctx.run_node to run another node and await its result. To see how that changes orchestration, let’s first express our existing refund flow this way:

code_block
<ListValue: [StructValue([('code', 'HANDLERS = {rn "AUTO_APPROVE": approve_notice,rn "MANUAL_REVIEW": escalate_to_human,rn "DENY": denial_notice,rn}rnrn@node(rerun_on_resume=True)rnasync def refund_flow(ctx, node_input):rn # Step 1: fetch all three records at once.rn order, payment, history = await asyncio.gather(rn ctx.run_node(fetch_order, node_input, use_sub_branch=True),rn ctx.run_node(fetch_payment, node_input, use_sub_branch=True),rn ctx.run_node(fetch_history, node_input, use_sub_branch=True),rn )rn case = order | payment | historyrnrn # Step 2: apply the refund policy — the same pure function, 0 LLM calls.rn decision = refund_policy(rn amount=case["amount_usd"],rn days_ago=case["placed_days_ago"],rn chargeback_open=case["chargeback_open"],rn priors=case["prior_refunds_12mo"],rn )rnrn # Step 3: run the chosen handler, and use its output as this node's output.rn await ctx.run_node(HANDLERS[decision], case, use_as_output=True)'), ('language', 'lang-py'), ('caption', )])]>

asyncio.gather runs the lookups together. Python combines their results, applies the policy, and runs the selected handler. use_as_output=True makes the handler’s result the parent node’s output without emitting it twice.

The dynamic version also adjusts the human-review node: after the answer arrives, it calls the chosen notice agent through ctx.run_node. The complete dynamic script (refund_dynamic.py) includes that variation.

graph-workflows-in-adk-everything-you-need-to-know-04-dynamic

The outer graph only needs an entry point:

code_block
<ListValue: [StructValue([('code', 'workflow = Workflow(rn name="refund_dynamic",rn edges=[(START, refund_flow)],rn)'), ('language', 'lang-py'), ('caption', )])]>

In the static version, the edge list shows the branches and destinations. In this version, we read refund_flow to see them. The dynamic node guide covers this approach.

The human-review pause still works here. When the answer arrives, refund_flow runs again from the top, but completed ctx.run_node calls return their recorded outputs from session history. The lookups do not repeat, as direct function calls would. Keeping side effects inside child nodes lets completed calls replay their results when the parent resumes. Each child also gets its own trace span, and use_sub_branch=True keeps concurrent children’s events on separate branches.

For this fixed refund process, the edge list remains easy to inspect. The Python version gives us a place to add the investigation logic described above: inspect a result, choose a follow-up node, and run independent checks together. A model might suggest what to investigate, while code limits the work—for example, three follow-ups per finding and two levels of investigation before human review.

Loops can still fit in a static graph. A fixed draft → check → revise process can use a conditional back-edge and a router that limits revisions. “Static” describes the possible connections; the actual path and iteration count can vary. The graph guide covers conditional cycles.

You can also place a dynamic node inside a static workflow, using Python for a stage that needs it while keeping the surrounding process visible.

Choose who decides what runs next

Start with a question: can you draw the possible workflow before the input arrives? Include branches, loops, and repeated stages. You do not need to predict the path each request will take.

 

What you need

Pattern
Independent jobs, then all their results Fan-out and fan-in
A branch chosen by fixed rules Deterministic router
A branch chosen by interpreting meaning Agent router
A person’s decision before continuing RequestInput
The same step across a list Parallel worker
Code that schedules work as results arrive Dynamic node
 

Use an edge list when it makes those connections clear. Use dynamic orchestration when results create further work or Python expresses the control more naturally. A small, open-ended task may need only one agent and its tools.

In our refund workflow, the graph coordinates the lookups, code applies the policy, a person handles exceptions, and a model writes the reply. Graph engineering gives each a clear responsibility—and makes it easier to see how the process works.

Get started

Author: wp_admin - This post was originally published on this site
Share this post

Subscribe to our newsletter

Keep up with the latest blog posts by staying updated. No spamming: we promise.
By clicking Sign Up you’re confirming that you agree with our Terms and Conditions.

Related posts

☎
New Educronix Product

Educronix Softphone

Free WebRTC desktop softphone for Windows and macOS. Connects directly to your PBX — voice and video calls, Call Waiting, DND, live call quality and more. Choose your edition and platform:

100% WebRTC — built on the JsSIP library.

Standard Edition
Call Center Edition
🎙 AI Assistant(voice)