Guides
Building a workflow
A workflow is a graph: its steps are stated in edges rather than chosen by a model, so the path is reviewable, testable and the same every time. What a graph is and when to prefer one over an agent is in agents and workflows; this page is how to write one.
elan new workflow guest-check-in
That writes workflows/guest_check_in/workflow.py and declares the slug in elan.yaml. Slugs share one namespace with agents and follow the same rules — see the two halves.
The shape
from agentkit_core.manifest import Billing, Limits, WorkflowInput, WorkflowManifest
from google.adk.events.event import Event
from google.adk.workflow import DEFAULT_ROUTE, START, FunctionNode, Workflow
from google.genai import types
MANIFEST = WorkflowManifest(
slug="guest-check-in",
version="0.1.0",
display_name="Guest Check-in",
description="Confirms a booking and assigns a room.",
limits=Limits(max_llm_calls=6, timeout_s=120),
billing=Billing(sku="workflow.planning.standard"),
max_concurrency=1,
inputs=[
WorkflowInput(name="guest_name", label="Guest name",
help="As it appears on the reservation."),
WorkflowInput(name="nights", label="Nights", type="integer", default=1),
],
)
def confirm_booking(node_input, guest_name: str = "", nights: int = 1):
"""An authority decision, so code and not a model."""
if guest_name.lower() != "ada lovelace":
return Event(route=["NOT_FOUND"], output={"guest_name": guest_name})
return Event(route=["CONFIRMED"],
output={"guest_name": guest_name, "nights": nights})
def assign_room(node_input: dict) -> dict:
return {**node_input, "room": "712"}
def report(node_input: dict):
# The LAST node: a plain return would reach a caller as nothing.
return Event(content=types.Content(role="model", parts=[types.Part(
text=f"Checked in: room {node_input['room']}.")]))
def decline(node_input):
return Event(content=types.Content(role="model", parts=[types.Part(
text="No reservation matches that name.")]))
def build(ctx) -> Workflow:
confirm = FunctionNode(name="confirm_booking", func=confirm_booking)
assign = FunctionNode(name="assign_room", func=assign_room)
reported = FunctionNode(name="report", func=report)
declined = FunctionNode(name="decline", func=decline)
return Workflow(
name="guest_check_in",
state_schema=ctx.state_schema(),
edges=[
# A dict is a routing map; DEFAULT_ROUTE catches anything unnamed.
(START, confirm, {"CONFIRMED": assign, DEFAULT_ROUTE: declined}),
(assign, reported),
],
)The class is google.adk.Workflow, also importable from google.adk.workflow with the node types. ADK's public documentation describes a GraphAgent API that does not ship; when the two disagree, believe the installed package.
MANIFEST
Everything in an agent's manifest applies except model, tools and compaction, which a WorkflowManifest does not have. Models belong to the LlmAgent nodes inside the graph, each built with ctx.model. A workflow adds:
| Field | Meaning |
|---|---|
| inputs | What the graph is launched with: the console's launch form, the parameters a caller may send, and — through ctx.state_schema() — the graph's state schema. |
| max_concurrency | How many nodes may run at once. Unset is unbounded; 1 runs one node at a time. |
| resumable | False by default. Required for a graph that pauses and carries on later. |
| nodes | Per-node overrides, keyed by node name. A name that is not in the graph fails the pod at start. |
| execution | Leave it at push. job, for compute that runs long, is not built yet — a graph that WAITS for a person or a system does not need it. |
nodes takes a NodeOverrides per node — the operational knobs live in the manifest, where they are versioned, while the topology stays in code:
| NodeOverrides | Meaning |
|---|---|
| timeout | Seconds this node may run. |
| max_attempts | How many times it is tried; at least 1. |
| tier | Whether this node's pause may be answered without a person. Only at t2, and only for a node that declares it here; a node that says nothing is always asked. |
Declared inputs and the state schema
inputs is three things written once: the console's launch form, the values a caller may send, and the graph's own schema. Pass it to the graph as Workflow(state_schema=ctx.state_schema()), and ADK refuses to construct a graph whose function node asks for a parameter the manifest does not declare — a build-time error rather than a failure halfway through a run.
It is not optional once inputs is non-empty. A graph built without it fails the pod at start, saying so. Attaching a schema after construction would do nothing — ADK validates against the schema the graph was built with — so the check is that it was there from the start.
inputs=[
WorkflowInput(name="brief", label="What is the event?", type="text"),
WorkflowInput(name="headcount", label="Expected guests",
type="integer", default=100),
WorkflowInput(name="event_date", label="Target date", type="date",
required=False, default=""),
]Each input takes name, label, type (default string), required (default True), default, help and, for an enum, options as InputOption(value=..., label=...).
| Rule | Why |
|---|---|
An optional input must declare a default. | “Optional” is a property of the form, never of the binding: ADK raises for a parameter missing from state rather than passing None. Refused when the manifest loads. |
ctx, node_input and self are reserved. | ADK's parameter binding skips exactly these, so an input declared under one would never reach a node. |
Names match ^[a-z][a-z0-9_]{0,39}$. | A name is both a state key and a Python parameter. : is refused so an input cannot collide with ADK's app:, user: and temp: prefixes, which skip schema validation. |
An enum must declare options, and nothing else may. | Option values must be unique, and a default must be one of them. |
A default must match the type. | True is not an integer here; a number accepts a whole-number default. |
A type is a widget hint as much as a type, and three of them are strings by the time a node sees them:
| type | Reaches the node as | Note |
|---|---|---|
| string | str | A one-line field. |
| text | str | A multi-line field. |
| integer | int | |
| number | float | |
| boolean | bool | |
| enum | str | Must be one of options. |
| date | str | ISO 8601, not a date object — session state is JSON, so it arrives as a string either way. |
Nodes and edges
A FunctionNode runs a Python function; an LlmAgent placed in the edges runs a model and takes its node name from the agent. A JoinNode waits for every branch that feeds it.
What a function node receives
node_inputis the previous node's output. For the first node it is the message the run was started with.ctx, if the function asks for it, is ADK's node context —ctx.stateamong it.- Every other parameter is looked up in session state by name, which is where declared inputs land. A parameter missing from state takes its Python default; with no default, ADK raises
Missing value for parameter. Give state-bound parameters defaults and a run launched without a form — or called as a tool — still works.
Edges
An edge is a tuple read left to right, and two kinds of element inside one mean something special. A tuple is a parallel fan-out; a dict is a routing map.
edges=[
# Three model nodes in parallel; the JoinNode waits for all three.
(START, intake, (venue_scout, vendor_shortlist, budget_model), gather),
(gather, compose_brief, approval_check),
# Only an explicit AUTO_APPROVE skips the person.
(approval_check, {"AUTO_APPROVE": finalize, DEFAULT_ROUTE: approval_gate}),
(approval_gate, finalize),
]A node chooses a route by returning Event(route=["NAME"], output=...). Name the routes you handle and let DEFAULT_ROUTE catch the rest, so an unrecognized route declines or escalates rather than doing the risky thing by accident. The route lands on event.actions.route; reading event.route raises rather than returning None.
Put models at the end
Each node receives the previous node's output, so a model between two function nodes hands the second one a sentence where it expected a dict. A function node that must follow a model has to accept text — which is why the example's routing decision reads a declared input rather than the model's prose.
A function node at the end returns nothing
A function node's return value becomes the event's output, with no content — and what a caller receives is the last content the graph produced. So a graph whose final node is a function node answers with nothing unless that node returns an Event carrying explicit content, as report and decline do above.
Pausing and resumability
A node pauses for a person by yielding a RequestInput:
from google.adk.events.request_input import RequestInput
def approval_gate(node_input):
estimate = node_input.get("estimate") if isinstance(node_input, dict) else None
yield RequestInput(message=f"This plan is estimated at {estimate}. Approve to continue.",
payload={"estimate": estimate})Declare resumable=True on any graph that waits — for a person, or for a long-running tool. It turns on ADK's resumability, which records each node's status as the run goes so a paused run can carry on from where it stopped. The run stops at the pause and nothing is held while it waits; the answer arrives later as its own delivery.
- A resume replays the prefix without re-running it — no extra model calls. ADK documents resumption as at-least-once, though, so a node with side effects should be idempotent on something stable: the invocation id does not change across a resume.
- Without
resumable, ADK writes no per-node status checkpoint, so the console's run view infers each step's status from its events instead.
Invoking a workflow
The same route, the same ordering and the same events as an agent. A workflow is a target like any other, named in target_slug; there is no separate endpoint. The route's full reference is on invocations.
curl -X POST "https://api.openelan.ai/v1/invocations" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"target_slug": "event-planner",
"project": "agents",
"session_id": "web-9a3f1b2c4d5e",
"text": "offsite for the platform team",
"inputs": {"brief": "two-day offsite, London", "headcount": 24}
}'text is still required and non-empty, even when everything you care about is in inputs. It is the message the first node receives as node_input, and an empty one is a 422, not a launch.
inputs, and the trap in it
inputs carries the declared launch inputs, not arbitrary data — and a partly filled inputs is stricter than none at all, so adding a key can break a call that worked without it.
| What you send | What happens |
|---|---|
inputs omitted, or {} | A free-text launch. Defaults are seeded; a required input with no default is left out of state, and the node's own Python default covers it. |
inputs with any key | A form. Every required input without a default must now be present and non-empty, or the run fails. |
So {} launches event-planner happily and {"headcount": 24} fails it, because brief is required, has no default, and you have declared yourself to be filling in the form.
A bad inputs is an error event, not a 400. The API has no copy of your manifest — it lives in your image — so it passes inputs through and answers 202; the pod validates, and the refusal arrives on the stream naming the field:
{"kind": "error", "payload": {"error": "event-planner: no declared input named 'guests'", "type": "ValueError"}}Three ways to earn one: a key the manifest does not declare, a required input left empty once you are filling the form, and a value that will not convert. Values are converted, so strings from a form work: {"headcount": "24"} is fine for an integer, and "two dozen" is the third error.
inputs is ignored entirely for an agent, and for a workflow that declares none — silently, not refused. If values are not reaching the graph, check that the manifest declares them before looking anywhere else.
Reading a workflow's events
The envelope is an agent's, with three differences that matter:
target_kindisworkflow. Keep it apart fromagentin anything you measure: a workflow's duration can include waiting for a person, which makes a shared latency percentile meaningless.- Node identity is
payload.node_info.path, notpayload.author. OnlyLlmAgentnodes set an author, so grouping by it folds every function node into one unnamed bucket. A path readsguest_check_in@1/confirm_booking@1. - A function node contributes no text. Its event has an
outputand nocontent, so text extraction correctly finds nothing — a silent workflow is usually its last node returning a plain value.
Resuming one
A paused run emits two paused events and no finished at all — the pause is where that delivery ends. The second is the one carrying interrupt_id and the node's prompt, and other events can arrive between the two. A paused run waits indefinitely, so do not hold a socket open for it: keep the invocation id, and GET /v1/runs?status=paused finds what is waiting on you. Answer a node's input with:
POST /v1/invocations/{invocation_id}/resume
{"approved": true, "note": "", "payload": {"cost_centre": "CC-3317"}}- The body becomes the paused node's output and flows to the next node as its
node_input: yourpayloadkeys plusapproved,noteanddecided_by, which the platform sets and a payload cannot override. So a person can hand the graph a corrected address or a chosen option, not merely consent. - A rejection does not stop the graph.
approved: falsearrives in the next node's input like any other value, and that node decides what no means. - Only the person who started the run, or an admin, may answer it — anybody else is a
403. A run that is not paused is a409. Unknown fields in the body are refused. - A resumed turn reserves its own deposit, so a balance that ran out in between refuses it even though the first half succeeded.
A confirmation a tool raised is answered through the actions API instead; see approvals.
The replay looks like re-execution
The resumed delivery re-emits the nodes that already finished, as new events with an output and no content, authored by the workflow. Each keeps its node_info.path but gets a fresh event id, so deduplicate on the path — keying on the id renders the whole pre-pause run twice. segment tells the halves apart: empty for the first, the interrupt id for each resume, which is also why a resume's seq restarting at 1 does not collide.
Calling a workflow from an agent
def build(ctx):
return LlmAgent(
name="hotel_concierge",
model=ctx.model,
instruction=INSTRUCTION,
tools=[ctx.workflow_tool("guest-check-in")],
)ctx.workflow_tool(slug) builds the named workflow from its own manifest and offers it as a tool whose parameters are the workflow's declared inputs and whose description is its description. The workflow must be in the same project — one image holds them all — and must declare inputs, or the tool has no parameters to offer and building it fails. ADK's own AgentTool cannot wrap a workflow: it probes sub_agents, which a graph does not have, and raises — once when the tool is declared and again when the model calls it.
- The arguments arrive as one JSON document in the first node's
node_input, not as session state. A graph that serves both a launch form and a tool call should read its declared inputs from state and fall back to parsingnode_input. - No plugin callback fires inside the nested run — not the platform's, not yours. A tier governs the workflow tool at its boundary, not each tool inside it, and the graph's tokens never reach the ledger; price the workflow tool in the caller's tool_costs to charge for it.
- The nested run is invisible on the caller's stream. The caller sees one tool call and its result — the text of the last content the graph produced.
- It runs in a throwaway in-memory session, discarded when the call returns, so a graph that pauses for a person is not one to offer as a tool.