How to Build a Multi-Step AI Pipeline With Gradio’s gr.Workflow
A hands-on tutorial that hand-authors a real Gradio gr.Workflow graph, wiring local Python functions and an Ollama model into a working REST API, uncovering two undocumented gotchas along the way.
Most real AI features are not one model call. They are a chain of steps: clean the input, run a model, run a second model, combine the results into something a human or another system can use. You can wire that chain together with plain Python function calls, but the moment you want to inspect an intermediate result, expose one step as its own API, or hand the pipeline to someone who is not a programmer, a plain function chain stops being enough.
Table Of Content
- What You Will Learn
- Prerequisites
- Step 1: Install Gradio and Confirm gr.Workflow Exists
- Step 2: Understand the Three Kinds of Nodes in a Workflow Graph
- Step 3: Write the Pipeline’s Python Functions
- Step 4: Write the Workflow Graph by Hand, and Why That Is the Right Call Here
- Step 5: Launch It
- Step 6: Call Your Pipeline as a REST API
- Calling It With Plain curl
- Step 7: Catch and Fix a Real Calibration Bug
- Step 8: A Second Gotcha: required Does Not Mean What You Think
- Common Mistakes and Gotchas, Summarized
- How to Confirm the Whole Thing Works End to End
- Next Steps
Gradio is an open source Python library, maintained by Hugging Face, for turning Python functions into shareable web apps without writing any HTML, CSS, or JavaScript. On August 25, 2026, Hugging Face published “Wire It, Run It, Deploy It: AI Workflows in Gradio”, introducing gr.Workflow, a new way to describe a pipeline as a graph of nodes: you wire your functions and models together, and Gradio serves both a visual, drag-and-drop canvas for the pipeline and a REST API, automatically, from the same definition. That post is mostly a tour of finished demo Spaces; this tutorial builds one from the ground up instead, an original support-ticket triage pipeline, so you can see exactly how the pieces fit together and reproduce every result yourself.
This tutorial builds that pipeline from scratch: a tool that cleans an incoming support ticket, classifies its urgency with a local AI model, pulls out keywords with plain Python, drafts a reply with the same local model, and combines all three into one report. Every command below was run against a real, freshly installed Gradio 6.26.0 on this machine, and along the way it uncovers two undocumented gotchas: one where the feature silently does not do what you would expect, and one where a bug in the pipeline’s own AI step gets caught and fixed with real before-and-after output.
gr.Workflow is brand new. Gradio 6.26.0, the release that ships it, went out on August 24, 2026, one day before this tutorial was written, and Gradio itself prints a warning on every startup calling it “currently in beta” with an API that “may change in future releases.” Treat the exact JSON shape described here as accurate for 6.26.0, and double-check with pip show gradio if you are reading this later and something does not line up.
What You Will Learn
- What a
gr.Workflowgraph is made of: references, operators, and subjects, and what each one actually does at run time - How to hand-write a working pipeline as Python functions plus a JSON graph, without touching the visual canvas
- A real, undocumented gotcha: the obvious way to build a workflow does not give you a REST API at all, and the fix
- How to call your pipeline both from Python and with raw
curl, using Gradio’s two-step queued call convention - A real prompt-calibration bug in the pipeline’s own AI step, caught with a wrong classification, fixed with a rubric, and verified with a second wrong-looking case turned right
- A second real gotcha: a port marked
requiredin the graph does not do the validation you would assume
Prerequisites
- Python 3.10 or newer, with
pipavailable (this tutorial used Python 3.13.14) - Ollama installed and running locally, with a small model pulled. This tutorial uses
qwen2.5:1.5b(ollama pull qwen2.5:1.5b, just under 1 GB), which is fast enough to run two model calls per request without a GPU - Comfortable reading Python and JSON; no prior Gradio experience required
- A terminal and about 15 minutes; the whole install is CPU-only and takes under a minute
Step 1: Install Gradio and Confirm gr.Workflow Exists
Create a fresh project folder and install Gradio:
mkdir gradio_workflow_demo
cd gradio_workflow_demo
pip install gradio
This pulls in gradio_client automatically as a dependency, which you will use later to call your pipeline from Python. Confirm the installed version and that gr.Workflow is actually present, since this tutorial depends on a very recent release:
import gradio as gr
print("Gradio version:", gr.__version__)
print("has Workflow:", hasattr(gr, "Workflow"))
Expected output:
Gradio version: 6.26.0
has Workflow: True
If has Workflow prints False, run pip install --upgrade gradio and check again. gr.Workflow does not exist in older Gradio releases.
Step 2: Understand the Three Kinds of Nodes in a Workflow Graph
Before writing any code, it helps to know what a workflow graph is actually made of, since the whole rest of this tutorial is built on these three roles. Reading the installed gradio/workflow.py and gradio/workflow_api.py source directly (both ship with the package, no internet access needed) makes this precise rather than guessed:
- References are the graph’s inputs. A reference with no incoming edge from another node is a “free input,” meaning whoever calls the workflow (a person on the canvas, or your code through the API) has to supply it. This tutorial has exactly one: the raw ticket text.
- Operators are the steps that do work. Each operator has a
kind:fnfor a Python function you supply yourself,modelfor a model hosted on Hugging Face Inference Providers,spacefor another Gradio Space, ordatasetfor rows from a Hugging Face dataset. This tutorial uses onlyfnoperators, since that is the kind that runs entirely on your own machine with no Hugging Face account or token involved. - Subjects are the graph’s outputs, and this is the detail that trips people up: only subjects, not operators, get exposed as callable API endpoints. An operator can compute a perfectly good result and never be reachable from outside the graph unless a subject is wired to it.
Edges connect one node’s output port to another node’s input port by id, and that is the entire graph: references feed operators, operators feed other operators or subjects, and subjects are what the outside world can call.
Step 3: Write the Pipeline’s Python Functions
Save this as app.py. Start with the imports and a small stopword list for the keyword step:
import re
import textwrap
from collections import Counter
import requests
import gradio as gr
OLLAMA_URL = "http://localhost:11434/api/generate"
MODEL = "qwen2.5:1.5b"
STOPWORDS = {
"the", "a", "an", "is", "are", "was", "were", "to", "of", "in", "on", "for",
"and", "or", "but", "with", "this", "that", "it", "its", "be", "been", "has",
"have", "had", "i", "we", "you", "our", "my", "your", "not", "as", "at", "by",
"from", "if", "so", "up", "will", "can", "cannot", "cant", "dont", "did",
"does", "please", "thanks", "when", "then", "than", "still", "also", "just",
}
The first pipeline step normalizes the raw ticket text. It is a plain Python function, nothing Gradio-specific about it yet:
def clean_ticket(raw_text: str) -> str:
text = raw_text.strip()
text = re.sub(r"\s+", " ", text)
text = re.sub(r"[<>]", "", text)
return text
Next, a step that calls your local Ollama model to classify how urgent the ticket is. This first version is deliberately naive; you will see exactly why that matters in Step 7:
def classify_urgency(ticket_text: str) -> str:
prompt = (
"Classify the urgency of this support ticket as exactly one word: "
"low, medium, or high. Then on a new line write 'Reason:' followed by "
"one short sentence.\n\n"
f"Ticket: {ticket_text}\n\nUrgency:"
)
resp = requests.post(
OLLAMA_URL,
json={
"model": MODEL,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0},
},
timeout=60,
)
resp.raise_for_status()
return resp.json()["response"].strip()
A second step pulls out keywords with plain string processing, no model call at all. This is here on purpose, to show that a single graph can mix real AI steps with ordinary deterministic code:
def extract_keywords(ticket_text: str) -> str:
words = re.findall(r"[a-zA-Z]{4,}", ticket_text.lower())
words = [w for w in words if w not in STOPWORDS]
top = [w for w, _count in Counter(words).most_common(5)]
return ", ".join(top) if top else "(none found)"
A third step, another local model call, drafts a reply:
def draft_reply(ticket_text: str) -> str:
prompt = (
"Write a short, polite, two-sentence reply to this customer support "
"ticket. Do not invent facts you do not know; ask a clarifying "
"question if needed.\n\n"
f"Ticket: {ticket_text}\n\nReply:"
)
resp = requests.post(
OLLAMA_URL,
json={
"model": MODEL,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0.3},
},
timeout=60,
)
resp.raise_for_status()
return resp.json()["response"].strip()
The final step takes all three upstream results and combines them into one formatted report. This is the first function with more than one parameter, which matters in the next step:
def build_report(urgency: str, keywords: str, reply: str) -> str:
return textwrap.dedent(
f"""\
TICKET TRIAGE REPORT
=====================
Urgency: {urgency}
Keywords: {keywords}
Suggested reply: {reply}
"""
)
That is the whole pipeline: one input, three parallel steps working off the cleaned text, one step that gathers them back together. Structurally, this is a fan-out into a fan-in: one input spreads out to multiple parallel steps, and those results converge back into a single output. Hugging Face’s own announcement post uses the fan-out half of this same pattern in its Generative Art Lab and AI Media Studio examples, though neither of those recombines the parallel results back into one final output the way this pipeline’s build_report step does.
Step 4: Write the Workflow Graph by Hand, and Why That Is the Right Call Here
gr.Workflow also accepts a shortcut: pass your functions straight to a bind= argument along with a list of edges=, and Gradio will generate a graph for you and serve it on the visual canvas. It looks like this, and it is worth seeing once specifically because of what it does not do:
wf = gr.Workflow(
bind=[clean_ticket, classify_urgency, extract_keywords, draft_reply, build_report],
edges=[
("clean_ticket", "classify_urgency"),
("clean_ticket", "extract_keywords"),
("clean_ticket", "draft_reply"),
("classify_urgency", "build_report.urgency"),
("extract_keywords", "build_report.keywords"),
("draft_reply", "build_report.reply"),
],
)
This runs, opens fine in a browser, and looks correct. But if you try to call it as an API, you will hit the first real gotcha of this tutorial: it has no working API at all, beyond an internal endpoint the canvas frontend uses to load the graph. The reason is in gradio/workflow_api.py, which ships with the package: the auto-generated graph from bind=/edges= is written in a simpler, older shape with no schema_version field, and the function that parses a graph for server-side execution, WorkflowGraph.from_json, explicitly rejects anything that is not schema_version == "2":
if data.get("schema_version") != "2":
# Only the current schema is executable server-side; the frontend
# migrates v1→v2 on load, so saved files are v2.
return None
In other words: the canvas frontend can read and edit the simple auto-generated graph just fine, and if you open it in a browser and click Save, it gets rewritten into schema v2 and starts working as an API too. But if your goal is a script that produces a working API without a human ever opening the canvas, you need to write that schema-v2 graph yourself. Save this as workflow.json in the same folder as app.py:
{
"schema_version": "2",
"name": "Ticket Triage",
"references": [
{
"id": "ticket_in",
"role": "reference",
"label": "Ticket Text",
"asset_type": "text",
"inputs": [],
"outputs": [{"id": "out", "label": "Text", "type": "text"}],
"data": {}
}
],
"operators": [
{
"id": "clean_op",
"role": "operator",
"kind": "fn",
"fn": "clean_ticket",
"label": "Clean Ticket",
"inputs": [{"id": "in_raw_text", "label": "raw_text", "type": "text", "required": true}],
"outputs": [{"id": "out_0", "label": "output", "type": "text"}],
"data": {}
},
{
"id": "urgency_op",
"role": "operator",
"kind": "fn",
"fn": "classify_urgency",
"label": "Classify Urgency",
"inputs": [{"id": "in_ticket_text", "label": "ticket_text", "type": "text", "required": true}],
"outputs": [{"id": "out_0", "label": "output", "type": "text"}],
"data": {}
},
{
"id": "keywords_op",
"role": "operator",
"kind": "fn",
"fn": "extract_keywords",
"label": "Extract Keywords",
"inputs": [{"id": "in_ticket_text", "label": "ticket_text", "type": "text", "required": true}],
"outputs": [{"id": "out_0", "label": "output", "type": "text"}],
"data": {}
},
{
"id": "reply_op",
"role": "operator",
"kind": "fn",
"fn": "draft_reply",
"label": "Draft Reply",
"inputs": [{"id": "in_ticket_text", "label": "ticket_text", "type": "text", "required": true}],
"outputs": [{"id": "out_0", "label": "output", "type": "text"}],
"data": {}
},
{
"id": "report_op",
"role": "operator",
"kind": "fn",
"fn": "build_report",
"label": "Build Report",
"inputs": [
{"id": "in_urgency", "label": "urgency", "type": "text", "required": true},
{"id": "in_keywords", "label": "keywords", "type": "text", "required": true},
{"id": "in_reply", "label": "reply", "type": "text", "required": true}
],
"outputs": [{"id": "out_0", "label": "output", "type": "text"}],
"data": {}
}
],
"subjects": [
{
"id": "report_subject",
"role": "subject",
"label": "Report",
"asset_type": "text",
"inputs": [{"id": "in", "label": "Report", "type": "text"}],
"outputs": [{"id": "out", "label": "Report", "type": "text"}],
"data": {}
}
],
"edges": [
{"id": "e1", "from_node_id": "ticket_in", "from_port_id": "out", "to_node_id": "clean_op", "to_port_id": "in_raw_text", "type": "text"},
{"id": "e2", "from_node_id": "clean_op", "from_port_id": "out_0", "to_node_id": "urgency_op", "to_port_id": "in_ticket_text", "type": "text"},
{"id": "e3", "from_node_id": "clean_op", "from_port_id": "out_0", "to_node_id": "keywords_op", "to_port_id": "in_ticket_text", "type": "text"},
{"id": "e4", "from_node_id": "clean_op", "from_port_id": "out_0", "to_node_id": "reply_op", "to_port_id": "in_ticket_text", "type": "text"},
{"id": "e5", "from_node_id": "urgency_op", "from_port_id": "out_0", "to_node_id": "report_op", "to_port_id": "in_urgency", "type": "text"},
{"id": "e6", "from_node_id": "keywords_op", "from_port_id": "out_0", "to_node_id": "report_op", "to_port_id": "in_keywords", "type": "text"},
{"id": "e7", "from_node_id": "reply_op", "from_port_id": "out_0", "to_node_id": "report_op", "to_port_id": "in_reply", "type": "text"},
{"id": "e8", "from_node_id": "report_op", "from_port_id": "out_0", "to_node_id": "report_subject", "to_port_id": "in", "type": "text"}
]
}
Two things worth noticing before you move on. First, report_op has three input ports, in_urgency, in_keywords, and in_reply, in the same order as build_report’s three parameters; a kind: fn operator calls your function positionally, so the order of its inputs list has to match your function’s parameter order exactly. Second, report_op (an operator) and report_subject (a subject) are two separate nodes connected by an edge, not the same thing. That separation is what the next step is about.
Now finish app.py by loading this graph and binding the actual functions to it. Leaving graph= unset makes Gradio look for workflow.json next to your script by default, and since that file now exists, it loads it instead of generating one from bind=/edges=:
wf = gr.Workflow(
bind=[clean_ticket, classify_urgency, extract_keywords, draft_reply, build_report],
)
if __name__ == "__main__":
wf.launch(server_name="127.0.0.1", server_port=7860, share=False, inbrowser=False)
bind= is still required here. The JSON graph only describes the shape of the pipeline (which node calls which function, wired to which other nodes); it does not contain any actual code. The bound dictionary is what lets report_op’s "fn": "build_report" field resolve to your real Python function at run time.
Step 5: Launch It
python app.py
Expected output:
C:\...\gradio\workflow.py:1777: UserWarning: gr.Workflow is currently in beta. Its API and UX may change in future releases.
warnings.warn(...)
Workflow: edges= is ignored because 'workflow.json' already exists. Delete the file to regenerate the workflow from bind/edges.
Both lines are expected, not errors. The first is Gradio’s own beta warning, printed on every startup. The second confirms your hand-written workflow.json is what actually got loaded, not an auto-generated one, which is exactly what you want; if you ever want to go back to the auto-generated shortcut from Step 4, delete workflow.json first.
Verify this worked: open http://127.0.0.1:7860 in a browser on the same machine. You will see the interactive canvas: five function nodes and one output node, wired exactly the way workflow.json describes, each one individually runnable and draggable. Editing and saving from this canvas is how Gradio expects most people to build workflows day to day; this tutorial hand-writes the JSON instead specifically so you understand exactly what the canvas is producing under the hood.
Step 6: Call Your Pipeline as a REST API
Leave app.py running and, in a second terminal, use gradio_client (installed automatically with Gradio) to see what API is actually being served:
from gradio_client import Client
client = Client("http://127.0.0.1:7860/")
client.view_api()
Real output from this exact setup:
Client.predict() Usage Info
---------------------------
Named API endpoints: 2
- predict(api_name="/_load_initial") ->
Parameters:
- None
Returns:
- None
- predict(in_0, api_name="/report") -> report
Acts on your behalf: this endpoint takes your Hugging Face token (optional).
Parameters:
- [Textbox] in_0: str (required)
Returns:
- [Textbox] report: str
/_load_initial is internal, used by the canvas frontend to fetch the graph JSON when the page loads; ignore it. The endpoint you actually want is /report, and this is the second thing worth calling out explicitly: the endpoint name comes from the subject’s label field (“Report”, lowercased and slugified), not from the operator’s label (“Build Report”) and not from the Python function’s name (build_report). Calling client.predict(ticket, api_name="/build_report"), the guess most people would reach for first, fails with a plain ValueError: Cannot find a function with api_name: /build_report. If you rename a subject’s label later, its endpoint URL changes with it.
Call the real endpoint with a sample ticket:
ticket = (
"Hi, I have been trying to export my invoices to CSV for the last hour "
"and it keeps failing at 90 percent with a timeout. This is blocking "
"our month-end close and my manager is asking me for status every "
"10 minutes. Please help ASAP."
)
result = client.predict(ticket, api_name="/report")
print(result)
Real output:
TICKET TRIAGE REPORT
=====================
Urgency: High
Reason: The ticket indicates that the customer's work is being blocked by an issue with exporting invoices, which is affecting their ability to complete a time-sensitive task (month-end close) and is causing them significant stress due to frequent updates from their manager.
Keywords: trying, export, invoices, last, hour
Suggested reply: Hi there! Thank you for reaching out. I'm sorry to hear that your export process has been so slow. Could you please provide more details on the specific error message or any additional information about what's happening? This will help us better understand the issue and find a solution quickly.
One call triggered all five functions in the right order: clean_ticket ran first, its output fanned out to classify_urgency, extract_keywords, and draft_reply in parallel, and all three fed into build_report. You never called those intermediate steps directly; asking for the /report subject was enough for the server to work out and run its whole upstream chain.
Calling It With Plain curl
Since the workflow is a normal Gradio app under the hood, it also speaks Gradio’s queued HTTP API directly, no Python client required. This is a two-step call: POST to start the job and get back an event id, then GET that event id to read the result as a server-sent event:
curl -s -X POST http://127.0.0.1:7860/gradio_api/call/report \
-H "Content-Type: application/json" \
-d '{"data": ["Password reset link never arrived, can you resend it?"]}'
Real output (the event id is random and will differ on every call):
{"event_id":"a9c1fb0355ac4827b9f23186872160ae"}
curl -s -N http://127.0.0.1:7860/gradio_api/call/report/a9c1fb0355ac4827b9f23186872160ae
Real output:
event: complete
data: [" TICKET TRIAGE REPORT\n =====================\n Urgency: High\n\nReason: The password reset link has not been received and is urgently needed for user authentication.\n Keywords: password, reset, link, never, arrived\n Suggested reply: I'm sorry for the inconvenience. Could you please provide your email address so I can resend the password reset link?\n"]
Note the endpoint path is the slug again, /gradio_api/call/report, matching what view_api() showed you, not /call/build_report or anything referencing the underlying function.
Step 7: Catch and Fix a Real Calibration Bug
Try one more ticket, this time something that clearly should not be urgent:
print(client.predict(
"Just wondering, is there a dark mode planned for the dashboard at "
"some point? No rush at all, just curious.",
api_name="/report",
))
Real output, unedited:
TICKET TRIAGE REPORT
=====================
Urgency: High
Reason: The user is asking about a feature that has not been officially announced or scheduled. This indicates a need for immediate attention and action to inform the user of any plans regarding this feature.
A ticket that says “no rush at all” in plain English came back High. This is a real bug, and it is the pipeline’s bug, not Gradio’s: classify_urgency’s prompt from Step 3 just asks a small model to pick a word with almost no guidance on what the words mean, and a 1.5-billion-parameter model with a vague prompt tends to treat anything phrased as a problem as urgent. This is exactly why this tutorial did not reach for a bigger model to fix it; the point is to see and fix the actual failure, not hide it behind more compute.
Fix it by replacing the prompt with an explicit rubric and two worked examples. Update classify_urgency in app.py:
def classify_urgency(ticket_text: str) -> str:
prompt = (
"You triage customer support tickets. Use this rubric strictly:\n"
"- high: the customer cannot use the product at all right now, is "
"blocked from finishing time-sensitive work, or mentions money, "
"security, or data loss.\n"
"- medium: something is broken or annoying but the customer has a "
"workaround or it is not blocking their work today.\n"
"- low: a question, a feature request, feedback, or the customer "
"explicitly says there is no rush.\n\n"
"Example: 'No rush, just wondering if dark mode is planned' -> low\n"
"Example: 'Checkout is throwing a 500 error, we are losing sales "
"right now' -> high\n\n"
"Classify the urgency of this ticket as exactly one word: low, "
"medium, or high. Then on a new line write 'Reason:' followed by "
"one short sentence.\n\n"
f"Ticket: {ticket_text}\n\nUrgency:"
)
resp = requests.post(
OLLAMA_URL,
json={
"model": MODEL,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0},
},
timeout=60,
)
resp.raise_for_status()
return resp.json()["response"].strip()
Stop the running server (Ctrl+C) and start it again with python app.py so it picks up the new code, then rerun both the dark mode ticket and a clearly urgent one:
print(client.predict(
"Just wondering, is there a dark mode planned for the dashboard at "
"some point? No rush at all, just curious.",
api_name="/report",
))
print(client.predict(
"Checkout is throwing a 500 error for every customer right now, we "
"are losing sales every minute this stays down.",
api_name="/report",
))
Real output, both after the fix:
TICKET TRIAGE REPORT
=====================
Urgency: low
Reason: The customer is asking about a feature that does not currently affect their ability to use the product or complete time-sensitive work.
TICKET TRIAGE REPORT
=====================
Urgency: High
Reason: The checkout system is experiencing frequent failures, which directly impacts the ability to complete transactions and cause financial losses.
The dark mode question now correctly comes back low, and the genuinely urgent checkout outage still correctly comes back High. The fix was not a bigger model; it was a rubric with two contrasting examples, which is worth remembering the next time a small local model “seems to get everything wrong.”
Step 8: A Second Gotcha: required Does Not Mean What You Think
Every input port in workflow.json from Step 4 has "required": true, which reads like it should reject empty input. Test that assumption directly:
try:
result = client.predict("", api_name="/report")
print("SUCCESS (unexpected):", repr(result))
except Exception as e:
print("ERROR:", type(e).__name__, str(e))
Real output:
SUCCESS (unexpected): " TICKET TRIAGE REPORT\n =====================\n Urgency: High\nReason: The customer mentions money and security issues, indicating they cannot use the product at all right now due to financial concerns related to data loss.\n Keywords: (none found)\n Suggested reply: Thank you for reaching out! Is there anything specific I can assist you with today?\n"
An empty ticket ran all the way through the pipeline and produced a completely fabricated “High, money and security issues” report out of nothing. Reading the executor’s source explains why: the required check only tests whether a resolved value is None. An empty string is falsy but it is not None, so it sails straight past the check. required: true only guarantees a port is wired to something; it says nothing about whether the value that arrives is actually usable, and it never will, since the graph has no idea what “usable” means for your specific function.
That validation is your job, in your own function. Add a guard to clean_ticket, the natural place for it since every other step depends on its output:
def clean_ticket(raw_text: str) -> str:
text = raw_text.strip()
if not text:
raise ValueError("Ticket text cannot be empty.")
text = re.sub(r"\s+", " ", text)
text = re.sub(r"[<>]", "", text)
return text
Restart the server and try the empty ticket again:
try:
client.predict("", api_name="/report")
except Exception as e:
print(type(e).__name__, str(e))
Real output:
AppError The upstream Gradio app has raised an exception but has not enabled verbose error reporting. To enable, set show_error=True in launch().
Gradio correctly turned your ValueError into a client-visible failure instead of running the rest of the pipeline on garbage, but it hides the real message by default. For local development, tell it to show the actual error by adding show_error=True to the launch call from Step 4:
wf.launch(
server_name="127.0.0.1",
server_port=7860,
share=False,
inbrowser=False,
show_error=True,
)
Restart once more and retry:
AppError Ticket text cannot be empty.
Verify this worked: confirm a real ticket still succeeds after the fix, so you know the guard only rejects genuinely empty input and did not break the normal path:
print(client.predict(
"The mobile app crashes every time I try to upload a photo larger than 5MB.",
api_name="/report",
))
This returns a full report exactly like the earlier examples, confirming the fix is targeted correctly.
Common Mistakes and Gotchas, Summarized
- Calling
bind=/edges=a finished pipeline. It builds a real, editable canvas, but not a callable API, until you either save from the canvas once or hand-write aschema_version: "2"graph yourself. - Guessing an endpoint name from a function name. The URL is always a slug of the subject’s
label, one level removed from your Python code. Runview_api()and read the real name rather than guessing. - Assuming a small local model needs a bigger model to fix bad output. The urgency bug in Step 7 was a prompt problem, not a model-size problem, and a rubric with contrasting examples fixed it completely.
- Trusting
required: trueas input validation. It only checks for a missing wire, not a bad value. Validate inside your own functions, and turn onshow_error=Truelocally so a raised exception actually tells you what went wrong.
How to Confirm the Whole Thing Works End to End
- Start the server fresh:
python app.py, and confirm it prints the beta warning with no traceback. - Open
http://127.0.0.1:7860in a browser and confirm the canvas shows five connected nodes. - Run
client.view_api()and confirm/reportis listed as a named endpoint. - Call
client.predict(<a real ticket>, api_name="/report")and confirm you get back a full report with all three fields filled in. - Call it again with an obviously low-urgency ticket and confirm the urgency field says
low, notHigh. - Call it with
""and confirm you get a cleanAppError: Ticket text cannot be empty.instead of a fabricated report.
If all six pass, you have a real, working, locally verified gr.Workflow pipeline, with both of its non-obvious behaviors already found and handled.
Next Steps
- Open the canvas in your browser, drag the nodes into a clearer layout, and save; that rewrites
workflow.jsonfrom the UI, which is a good way to see the exact schema-v2 shape the canvas itself produces for a graph you already understand by hand. - Add a
kind: "model"operator that calls a real Hugging Face-hosted model through Inference Providers, and compare it against the localfnoperators in this tutorial; Hugging Face’s own announcement post is the place to see that pattern in full. - Swap the naive keyword extractor for a real NLP library, or add a fourth parallel branch, and notice you only need to add one operator and a couple of edges since the fan-out/fan-in shape already scales.
- If you want to see a different way to chain multiple local models together, this site’s multi-tier LLM router tutorial wires three Ollama models together with automatic fallback when one is unavailable or too slow.








No Comment! Be the first one.