Deploying Quill: Gradio UI, the Hub, and the CLI (smolagents, Module 13)
This is Module 13 of smolagents Mastery, a free 15-module course that takes you from your first code agent to a production-grade one. Start at Module 1 or browse the full syllabus.
Twelve modules in, the Quill you built in Module 12 is genuinely good. It plans, writes pandas, saves charts, delegates web research to a web_researcher sub-agent, looks up ambiguous columns in a knowledge base, and returns a cited QuillReport. And it runs exactly one way:
uv run python -m quill "Which category grew fastest, and is that consistent with the public trend?" --data data/sales.csv
That command is perfect for you. It is useless for the colleague on the sales team — the one who actually has the dataset and the question. She is not going to clone a repo, write a .env, install uv, and type a Python invocation. The best agent in the world helps nobody if nobody can run it. And an agent that lives on your laptop does not help your team.
This module fixes that. You wrap Quill in a web UI where anyone drops a CSV and chats with it, push the whole agent to the Hub in one call (it becomes a Space that runs itself), and launch a code agent from the command line. Quill stops being a script and becomes a product.
In this module
You’ll learn
- Wrap any
MultiStepAgentin aGradioUI— with CSV uploads, multi-turn memory, and streaming — in three lines, and understand each constructor knob (file_upload_folder,reset_agent_memory).- Explain why the UI runs the agent with
reset=Falseunder the hood — and how that ties the chat experience to the memory model from Module 6.- Serialize and share an agent: know exactly what
save()/push_to_hubwrite (agent.json,prompts.yaml,tools/,managed_agents/,app.py,requirements.txt) so Quill runs as a Hugging Face Space out of the box.- Load an agent back with
from_hub/from_folder/from_dict— and explain whytrust_remote_code=Trueis mandatory and dangerous (you execute remote tool code).- Run a code agent from the
smolagentCLI, pass the right flags, and tellsmolagent(generalistCodeAgent) apart fromwebagent(vision browser).You’ll build Quill becomes a web app: a
GradioUIwith CSV upload and streaming, pushed to the Hub as a one-click Space, and launchable from thesmolagentCLI.Theory areas covered
T12 — Production (UI/deploy/Hub/CLI) — 11%(this module owns the deployment slice; telemetry and eval are Module 14, hardening is Module 15). (No “exam domain” — this course has no certification.)Prerequisites Module 6 (
reset=False, streaming,interrupt(), memory), Module 9 (push_to_hub/save/load_tool, the “pushable” rules,from_hub), Module 10 (managed_agents,web_researcher), Module 12 (RetrieverTool, cited reports). AnHF_TOKENwith WRITE scope in.env— required for the Hub push.
Where you are in the course
M1 ✅ M2 ✅ M3 ✅ M4 ✅ M5 ✅ M6 ✅ M7 ✅ M8 ✅ M9 ✅ M10 ✅ M11 ✅ M12 ✅
👉 M13 ⬜ M14 ⬜ M15
Quill so far: a complete agent that runs only as a Python script. After this module: the same agent, now reachable as a Gradio web app, a one-click Hub Space, and a smolagent CLI command. The chain is linear, and this module is almost entirely reuse. The UI runs Quill with the reset=False continuation you learned in Module 6. The Hub push is the push_to_hub / save machinery from Module 9 — except now applied to the whole agent, not a single tool. And every layer wraps the one frozen constructor, build_quill() (manager + web_researcher + RetrieverTool), without rebuilding it.
That last point is a hard rule, not a style preference: ui.py, app.py, and publish.py all import and call build_quill(). None of them reconstructs the agent. If they did, the web Quill would drift from the Quill the tests exercise — and you would be shipping an untested agent to your team.
Three lines to a web app: GradioUI
The entry point of the module is one class. GradioUI is smolagents’ built-in Gradio interface for a MultiStepAgent — “Gradio interface for interacting with a MultiStepAgent,” in the docstring’s words. It is built on top of gradio.ChatInterface, so you get a chat window, a message box, and a file uploader without writing a line of front-end code. It needs the [gradio] extra (gradio>=5.14.0, as of smolagents 1.26.0):
uv pip install 'smolagents[gradio]'
Its constructor, verified against smolagents 1.26.0, is:
GradioUI(agent, file_upload_folder=None, reset_agent_memory=False)
Three arguments, each load-bearing:
agent— theMultiStepAgentto wrap. For Quill this is always the output ofbuild_quill().file_upload_folder(str, optional) — the directory uploaded files land in. If it isNone(the default), upload is disabled entirely. A data analyst’s UI with no upload is useless, so Quill passes"uploads". That single non-Nonevalue is what unlocks the CSV uploader.reset_agent_memory(bool, defaultFalse) — whenTrue, the agent’s memory is wiped at the start of every interaction. We keep the defaultFalse, so the chat remembers across turns (more on that in the next section).
The smallest useful wrapper is genuinely three lines of body:
from smolagents import GradioUI
from quill.agent import build_quill # build_quill lives in quill/agent.py (frozen, 06 §2)
agent = build_quill() # manager + web_researcher + RetrieverTool, fully wired
GradioUI(
agent,
file_upload_folder="uploads",
reset_agent_memory=False, # keep the conversation going across turns
).launch()
.launch(share=True, **kwargs) starts the server. One detail to teach plainly: share defaults to True — that opens a temporary public *.gradio.live tunnel, which is handy for a quick demo but is almost never what you want for real. Locally you usually pass share=False (no public tunnel), which is the default Quill’s launcher uses. Any extra kwargs are forwarded straight to Gradio’s own launch.
The CSV upload trap
Here is the concrete, differentiating point of this section — the kind of thing the tutorials skip. GradioUI.upload_file validates an upload’s extension against an allow-list, and that allow-list defaults to:
allowed_file_types=[".pdf", ".docx", ".txt"] # the smolagents 1.26.0 default
There is no .csv in that list. A vanilla GradioUI refuses a CSV upload. For an agent whose entire job is analyzing tabular data, that default is backwards. So Quill subclasses GradioUI and overrides exactly one method — upload_file — to widen the allow-list to the tabular formats it understands:
from smolagents import GradioUI
QUILL_ALLOWED_FILE_TYPES = [".csv", ".parquet", ".xlsx"]
class QuillGradioUI(GradioUI):
"""A GradioUI that accepts CSV/Parquet/XLSX uploads (T12.1).
The ONLY behavioural change from the stock GradioUI is the upload allow-list.
Everything else — the ChatInterface, the reset=False continuation, the
streaming — is the parent's. We wrap the agent; we do not reimplement the UI.
"""
def upload_file(self, file, file_uploads_log, allowed_file_types=None):
if allowed_file_types is None:
allowed_file_types = QUILL_ALLOWED_FILE_TYPES
return super().upload_file(file, file_uploads_log, allowed_file_types=allowed_file_types)
That is the whole override. We change the default and delegate to the parent for the actual validate-and-store logic.
Now the second half of the mechanic, which is easy to misunderstand: an upload does not inject a DataFrame. When a user drops sales.csv, Gradio validates the extension, writes the file into file_upload_folder (here uploads/), and appends the file’s path to the conversation. The agent then sees a message that mentions a file at a known path, and Quill’s frozen load_dataset(path) tool (from Module 3) reads it. The upload deposits a file; the path is the bridge to the toolbox. The DataFrame only exists once Quill chooses to load it.
The full request flow, browser to report and back, looks like this:
flowchart LR
U[User browser] --> G["GradioUI (ChatInterface)<br/>upload CSV → uploads/"]
G -->|"agent.run(message, reset=False)<br/>(under the hood — Module 6)"| Q["build_quill()<br/>manager CodeAgent"]
Q --> WR[web_researcher]
Q --> RT[RetrieverTool]
Q --> DT[data tools]
Q --> R["cited QuillReport"]
R -->|stream steps| G --> U
The factory Quill actually uses builds (but does not launch) the UI, so the wiring is testable without ever opening a port. make_ui(None) builds the real Quill via build_quill(); tests pass their own fake-model agent so no LLM is touched:
def make_ui(agent=None, *, file_upload_folder="uploads", reset_agent_memory=False):
if agent is None:
agent = build_quill() # the ONLY place Quill is constructed (06 §2)
return QuillGradioUI(
agent,
file_upload_folder=file_upload_folder,
reset_agent_memory=reset_agent_memory,
)
launch_ui(share=False) is then a one-liner — make_ui().launch(share=share) — and it is the only place .launch() is ever called. Importing quill.ui never opens a port.
Why the UI keeps memory: reset=False, streaming, and the stop button
The previous section set reset_agent_memory=False and waved at “the chat remembers.” Now let’s open the hood, because this is the single most-misunderstood knob in deploying an agent.
The reset=False continuation
Under the hood, when reset_agent_memory=False, the GradioUI runs each new message as:
agent.run(user_request, reset=False)
That is the opposite of agent.run’s own default. agent.run(task) defaults to reset=True, which wipes the agent’s memory before the run — the right behavior for a one-shot script that analyzes one CSV and exits. The UI flips it to reset=False, which keeps the memory: the loaded DataFrame, the code Quill already wrote, and the previous question all stay in context. That is what makes a chat a chat. You drop sales.csv, ask “chart monthly revenue,” and then ask “now break that down by region” — and the second message works without re-uploading, because Quill still has the DataFrame in memory.
You already met this exact flag in Module 6, where run_multi_turn ran turn two with reset=False. The UI does the same thing automatically for every turn after the first. The only new fact here is that the UI inverts the default for you — which is precisely why people get surprised that the chat “remembers.”
⚠️ Common misconception: “
reset_agent_memory=Falseandreset=Falsemean the agent re-thinks from scratch each turn / wipes the context.” It is the inverse.reset=Falsekeeps the context — the memory accumulates. The word “reset” is doing the opposite of what the intuition suggests:reset=Trueis the wipe;reset=Falseis the continue. The production consequence is real: memory (and token cost) grow on every turn. That is exactly why you prune stale observations withstep_callbacks(the context-engineering work from Module 6).
Here is the distinction in one table:
reset=True (script one-shot) | reset=False (UI / chat) | |
|---|---|---|
| Memory at run start | wiped | kept |
| Use case | one-shot analysis | multi-turn chat |
| Who sets it | you, in a script | GradioUI, automatically |
| Cost over turns | bounded | grows each turn → prune (M6) |
Streaming with stream_to_gradio
A multi-step agent can take a while — plan, profile, write pandas, maybe delegate to web_researcher, draw a chart. You do not want the UI to sit blank until the final answer lands. GradioUI streams each step as it completes, and the public helper that does it is stream_to_gradio — “Runs an agent with the given task and streams the messages from the agent as gradio ChatMessages,” from src/smolagents/gradio_ui.py. Import it directly when you build a custom app:
from smolagents import stream_to_gradio
Internally, stream_to_gradio runs agent.run(..., stream=True) (the same stream=True you saw in Module 6, now exposed on the UI side), accumulates ChatMessageStreamDelta chunks, and gives the FinalAnswerStep special handling so the last message renders cleanly. The stock GradioUI uses this for you; you reach for it directly only when the clé-en-main UI is not enough.
The stop button: agent.interrupt()
A public agent that goes off the rails should be stoppable. The hook for that is agent.interrupt() — it “will stop the agent at the end of its current step, then raise an error.” Read that carefully: it does not abort mid-LLM-call or mid-code-execution. It stops at the end of the current step, then raises. That is consistent with the “1 step = 1 LLM call → 1 action → 1 execution” model from Module 2: the smallest unit smolagents can cleanly interrupt at is a step boundary. A stop button is a minimal guard for an agent exposed to the public (a security concern we revisit in the production note).
Quill ships an optional custom app — build_custom_app(agent) — that demonstrates the lower-level path: drive the agent with stream_to_gradio, wire a Stop button to agent.interrupt(). It builds a gradio.Blocks without launching it, so it stays importable and testable offline:
def build_custom_app(agent, *, max_turns_label="Quill"):
import gradio as gr
from smolagents import stream_to_gradio
def _respond(message, history):
messages = []
for msg in stream_to_gradio(agent, task=message, reset_agent_memory=False):
messages.append(msg) # accumulate steps as they land
yield messages
with gr.Blocks(title=max_turns_label) as app:
chatbot = gr.Chatbot(label=max_turns_label, type="messages")
msg = gr.Textbox(label="Ask Quill")
with gr.Row():
send = gr.Button("Send", variant="primary")
stop = gr.Button("Stop", variant="stop")
send.click(_respond, inputs=[msg, chatbot], outputs=chatbot)
msg.submit(_respond, inputs=[msg, chatbot], outputs=chatbot)
stop.click(lambda: agent.interrupt()) # stops at the END of the current step, then raises
return app
This is the “Try it yourself” path. For most deployments the stock QuillGradioUI is all you need — the custom app exists to show you what it is made of.
Shipping the agent: save(), push_to_hub, and the app.py Space
A web app on your laptop still lives on your laptop. To hand Quill to your team, you push the whole agent to the Hub. In Module 9 you used tool.push_to_hub to share a single tool. This is the agent-level sibling — push_to_hub on the agent — and its signature, verified against smolagents 1.26.0, is:
MultiStepAgent.push_to_hub(
repo_id,
commit_message="Upload agent",
private=None,
token=None,
create_pr=False,
)
You call it as agent.push_to_hub("user/quill"). The token comes from the environment (HF_TOKEN) — you never hard-code it, and you never pass a secret here.
What save() serializes
push_to_hub is save() plus an upload. MultiStepAgent.save(output_dir) writes — and push_to_hub(repo_id) uploads — exactly these artefacts:
| File | What it is |
|---|---|
agent.json | the agent’s dict representation (from to_dict()) |
prompts.yaml | the agent’s prompt templates |
tools/<name>.py | one file per tool — load_dataset.py, profile_dataframe.py, save_chart.py, retriever.py, final_answer.py |
managed_agents/ | each managed sub-agent’s logic — here web_researcher/ |
app.py | a ready-to-run GradioUI — this is what makes the repo run as a Space |
requirements.txt | the detected dependencies |
A note on the filename: the brief calls it prompt.yaml, but smolagents 1.26.0 writes prompts.yaml — we assert the real filename in the tests. Two artefacts deserve emphasis. First, managed_agents/ exists only because Quill is multi-agent — it serializes the web_researcher sub-agent’s own logic. A solo agent would have no such directory; this folder is a direct consequence of the team you built in Module 10. Second, app.py is the load-bearing one: it is a GradioUI over the reloaded agent, and it is the Space entry point. Push the repo, and Hugging Face runs app.py — your agent is a hosted chat with nothing else added.
flowchart LR
A["build_quill()"] --> S["save() / push_to_hub"]
S --> J[agent.json]
S --> P[prompts.yaml]
S --> T["tools/*.py<br/>(load_dataset, profile_dataframe,<br/>save_chart, retriever, final_answer)"]
S --> M["managed_agents/web_researcher/*"]
S --> AP["app.py (GradioUI)"]
S --> RQ[requirements.txt]
AP --> SP[Hugging Face Space runs app.py]
The “pushable” friction
Here is where pushing a rich agent gets real. save() and push_to_hub only succeed if every tool is “pushable.” This is the same T3.15 contract you learned at the tool level in Module 9, now binding on the whole agent: an agent is only pushable if all of its tools are.
smolagents serializes a Tool subclass by reading only its own class source with a static AST validator (validate_tool_attributes). That static reading imposes three rules, every one of them load-bearing:
- All imports live inside the tool’s functions, never at module level. The saved tool carries only its own method bodies; a top-level import would not travel with it.
__init__takes no argument beyondself. Constructor arguments are “not traceable” and fail validation — so configuration lives in class attributes, not constructor args.- Each tool body references no module-level helper. A call to an outside function like
_read_tableorload_corpusfails the static check withName '<helper>' is undefined.
Quill’s tools were written to these rules from the start, but this module hardened them for the agent-level push. The frozen data tools (load_dataset, profile_dataframe) inline their read-table body instead of calling a shared _read_table. And the RetrieverTool is the candidate the brief warned about — it builds a BM25 index in setup(), so it is exactly the kind of tool that breaks the push. It stays pushable by construction:
class RetrieverTool(Tool):
name = "retriever"
description = "Looks up the project's data dictionary and domain docs ..."
inputs = {"query": {"type": "string", "description": "What to look up ..."}}
output_type = "string"
# CLASS attributes — NOT __init__ args, and written as LITERALS (not
# references to module constants), or Tool.to_dict's validator rejects them.
corpus_dir = "data/corpus"
k = 5
def setup(self) -> None:
import pathlib
from rank_bm25 import BM25Okapi # import INSIDE the method
# the corpus walk is INLINED here — it does NOT call the module-level
# load_corpus, so the saved tool has no undefined outside reference.
...
Note three deliberate choices: no __init__ arguments (the corpus comes from the class attribute corpus_dir), the class attributes are written as literal strings and ints (a non-literal class attribute trips the validator’s “Complex attributes should be defined in __init__” rule), and the from rank_bm25 import BM25Okapi lives inside setup(). The official smolagents RAG example passes docs to __init__ and uses langchain_community.BM25Retriever; Quill deviates on both, precisely so the whole agent can be pushed whole. The friction is real: a single outside reference makes agent.save raise. The payoff is that one push_to_hub call ships everything.
Push security
Pushing an agent publishes its tool code to the Hub. Two consequences. Never embed a secret in a tool — the token comes from .env (HF_TOKEN), never hard-coded, and push_to_hub(..., private=True) gives you a private repo if the tools touch anything sensitive. (And once the agent is public, you will want traces to see what people are doing with it — that is Module 14.)
Loading an agent back: from_hub, from_folder, trust_remote_code
If you can push it, you can load it. from_hub reloads a pushed agent — “Loads an agent defined on the Hub”:
from smolagents import CodeAgent
agent = CodeAgent.from_hub("user/quill", trust_remote_code=True)
A style note worth internalizing: from_hub is a classmethod inherited from MultiStepAgent. Prefer the class form CodeAgent.from_hub(...) over the instance form agent.from_hub(...). The instance form works, but it is misleading — you are not loading into that instance, you are constructing a new one. The local equivalents are from_folder(folder) (load from a directory save() wrote) and from_dict(agent_dict) (load from the to_dict() representation).
trust_remote_code=True — mandatory and dangerous
This is the flag of the module, so do not paste it on autopilot. Loading an agent from the Hub downloads and executes remote tool code. from_hub defaults trust_remote_code=False, and with the default it refuses to run the loaded tools. You must pass trust_remote_code=True to actually use the agent.
Why this is not a formality: you are running Python that someone else wrote. The tools/*.py files come down from the Hub and execute on your machine. Inspect the repo before you trust it. This is the same warning as the MCP trust_remote_code gate from Module 9, and it is worth stating plainly because plenty of tutorials paste trust_remote_code=True with no explanation at all. For your own pushed Quill it is fine — you wrote the tools. For a random agent off the Hub, read the code first.
Under the hood, deserialization is gated by an AGENT_REGISTRY that maps the strings "CodeAgent" and "ToolCallingAgent" to their classes. The agent’s type is recorded in agent.json, and the registry is what reconstructs the correct class from that string. You will not call it directly, but it is why from_dict knows whether to build a CodeAgent or a ToolCallingAgent.
Running as a Space or an API
Because push_to_hub shipped an app.py (a GradioUI), the pushed repo runs as a Space — a hosted chat — with no extra configuration. That is the “one click for a non-developer” promise: your colleague opens a URL and starts dropping CSVs. If you need a programmatic API instead of a chat window, you have two paths: host the Gradio app (Gradio exposes a REST/queue API over any app) or wrap agent.run(...) in your own server. Either way, app.py is the seam.
The CLI: smolagent vs webagent
smolagents ships two command-line entry points, declared in [project.scripts] of its pyproject.toml. The one that runs a code agent is smolagent, whose entry point is smolagents.cli:main. Launch it with a prompt, or with no prompt to drop into interactive mode:
smolagent "Which sales category grew fastest last quarter?" \
--model-type "InferenceClientModel" --model-id "Qwen/Qwen2.5-Coder-32B-Instruct" \
--imports "pandas numpy" --tools "web_search"
The flags and their exact defaults, as of smolagents 1.26.0:
| Flag | Default | Notes |
|---|---|---|
prompt (positional) | None | omit it → interactive mode |
--model-type | "InferenceClientModel" | one of InferenceClientModel, OpenAIModel, LiteLLMModel, TransformersModel |
--model-id | "Qwen/Qwen3-Next-80B-A3B-Thinking" | ⚠️ volatile, “subject to change” — pass an explicit coder model |
--action-type | "code" | alt: "tool_calling" |
--imports | [] (space-separated) | the CodeAgent’s authorized imports |
--tools | ["web_search"] (space-separated) | the default toolbox |
--verbosity-level | 1 | range 0–2 |
--provider / --api-base / --api-key | — | provider routing and credentials |
Two freshness notes baked into that table. The default --model-type is InferenceClientModel, never the banned HfApiModel. And the default --tools is ["web_search"], never "search" (the old name) — the renamed tool is web_search.
Now the distinction to hammer, because the names invite confusion. smolagent and webagent are two different entry points:
| Name | Entry point | Agent type | Default model | Use |
|---|---|---|---|---|
smolagent | smolagents.cli:main | generalist CodeAgent | InferenceClientModel + Qwen/Qwen3-Next-* | run a code agent from flags |
webagent | smolagents.vision_web_browser:main | vision browser (helium/Chrome) | gpt-4o via LiteLLMModel | drive a real browser |
webagent is the vision browser you met in Module 11 — it drives a real Chrome through helium and reads screenshots. It is not for Quill. Quill is a generalist code agent, so it launches with smolagent.
One honesty caveat: smolagent builds a generic agent from the flags. It does not resurrect the rich build_quill — no manager, no web_researcher, no RetrieverTool. To launch the real Quill from the terminal, keep using your own entry point: python -m quill (one-shot report) or python -m quill --ui (web app). The smolagent CLI is the standard smolagents path for a simple code agent, and that is how this module presents it — not as a way to revive the full Quill in one command.
And a cost note, because this is a free course and the free tier is small. The default --model-id (and the default for a bare InferenceClientModel) burns the HF Inference Providers free tier — about $0.10/month as of smolagents 1.26.0, and subject to change. A multi-step CodeAgent spends tokens fast. Pass an explicit model_id and a free provider (Groq, Cerebras) or a local model when you can. Quill does exactly this through make_model, pinning Qwen/Qwen2.5-Coder-32B-Instruct rather than relying on the volatile default.
Build it
Your goal: wrap the full Quill (manager + web_researcher + RetrieverTool) in a GradioUI with CSV upload and streaming, push the whole agent to the Hub so it runs as a Space, and launch a code agent from the smolagent CLI. The complete, tested code lives in smolagents-course-labs/module-13; the offline tests pass with no network and no token.
You start from the cumulative Module 12 Quill. Add the UI extra and set your token:
uv sync
uv pip install 'smolagents[gradio]' # gradio>=5.14.0, as of smolagents 1.26.0
# HF_TOKEN (WRITE scope) in .env — required for the Hub push and from_hub
Launch the web app. quill/ui.py builds the real Quill via build_quill(), wraps it in QuillGradioUI with CSV upload, and serves it. No public tunnel by default:
uv run python -m quill.ui # share=False (local only)
uv run python -m quill.ui --share # also open a temporary *.gradio.live tunnel
Drop data/sales.csv via the uploader, type “Chart monthly revenue and tell me which category grew fastest,” and watch Quill stream its steps and return a cited QuillReport with the chart. Then send a follow-up — “now break that down by region” — and notice it does not re-upload or re-profile. The memory persisted, because the UI ran reset=False.
Ship it to the Hub. quill/publish.py wraps the agent-level push_to_hub and prints the artefacts it wrote. The offline path (--save-dir) is what the smoke test exercises:
uv run python -m quill.publish --repo "<your-user>/quill" # push (needs HF_TOKEN + network)
uv run python -m quill.publish --repo "<your-user>/quill" --private # push privately
uv run python -m quill.publish --save-dir build/quill # save locally (FULLY offline)
The offline save proves Quill is pushable without touching the network:
[Quill] Saved the agent to 'build/quill'. Top-level artefacts:
- agent.json
- app.py
- managed_agents
- prompts.yaml
- requirements.txt
- tools
managed_agents/ is present because Quill has the web_researcher sub-agent; app.py is the Space entry point. The publish_quill function itself is small — it builds the real Quill (never rebuilds it) and calls the agent-level API:
def publish_quill(repo_id, *, private=False, commit_message="Upload Quill", agent=None):
if agent is None:
agent = build_quill() # 06 §2 — never reconstructed here
return agent.push_to_hub(repo_id, commit_message=commit_message, private=private)
Load it back. reload_from_hub shows the trust_remote_code gate and the classmethod form:
def reload_from_hub(repo_id, *, trust_remote_code=True):
from smolagents import CodeAgent
return CodeAgent.from_hub(repo_id, trust_remote_code=trust_remote_code)
Launch a code agent from the CLI:
smolagent "Which sales category grew fastest last quarter?" \
--model-type "InferenceClientModel" --model-id "Qwen/Qwen2.5-Coder-32B-Instruct" \
--imports "pandas numpy"
Run the tests. The deploy core is fully offline — the UI is constructed but never launched (no server, no port, no network), and agent.save(tmp_path) writes the six artefacts to disk with no Hub upload:
uv run pytest module-13/tests -q
# real push_to_hub/from_hub round-trip (needs HF_TOKEN, opt-in):
QUILL_LIVE_TESTS=1 uv run pytest module-13/tests -q -m live
The offline serialization test is the heart of it: it saves the whole multi-agent Quill, asserts the six artefacts, then CodeAgent.from_folder round-trips it back — same tools, same sub-agent — using an InferenceClientModel that is constructed but never called (construction makes no API request). Only the real Hub round-trip is live, and it skips cleanly without a token.
Try it yourself
- Build a custom Gradio app with
build_custom_app— a Stop button wired toagent.interrupt()viastream_to_gradio— and watch it stop at the end of the current step rather than mid-call. - Reload your pushed Quill with
CodeAgent.from_hub("<your-user>/quill", trust_remote_code=True), ask it the same question you asked the local one, and confirm it answers the same way. Then open theapp.pythe Hub received and read exactly what serves it.
In production
The moment Quill is public, the threat model changes: a public agent is a threat vector. This is the agent-exposure case of the security work from Module 5. A stranger can upload a booby-trapped file or inject a prompt into their question, and the code Quill generates in response runs somewhere. In production Quill must run in a remote sandbox (Approach 2, the capstone in Module 15), never in local — the lab keeps local only because we are developing the UI on a laptop.
Cost and abuse. A public Space lets anyone spend your quota. Bound it: Gradio auth, rate limits, a hard max_steps. And remember the reset=False memory grows on every turn, so the Module 6 pruning callbacks are not optional decoration — they are how you keep a long chat affordable.
share and tunnels. launch(share=True) opens a temporary public tunnel — fine for a five-minute demo, wrong for anything real. Prefer a hosted Space or a controlled deployment.
trust_remote_code. Loading an agent from the Hub executes remote code. Import only what you have read.
Secrets and versioning. A pushed agent publishes its tool code, so embed no secret — use .env, Space secrets, a private repo if needed. Tag each release with push_to_hub(commit_message=...) and pin your dependencies in requirements.txt.
And once it is live, “it looks like it works” is not proof. You will want traces and an eval harness to measure whether Quill is any good — that is the next module.
Concept check
Why this matters. This module owns the deployment slice of T12: turning a working agent into something other people can actually run. The five scenarios below test the knobs that decide whether a deployment succeeds — the upload allow-list, the reset=False continuation, what serialization writes, the trust_remote_code gate, and which CLI does what.
1. You wrap Quill with GradioUI(agent, file_upload_folder="uploads"), but when a user drops sales.csv the uploader rejects it. What is wrong, and what is the fix?
- A.
file_upload_foldermust beNoneto enable uploads. - B. The default
allowed_file_typesis[".pdf", ".docx", ".txt"], which excludes.csv; widen the allow-list. - C.
GradioUIcannot accept file uploads at all. - D. You must set
reset_agent_memory=Truefor uploads to work.
2. A user asks Quill to chart revenue, then asks “now break that down by region” — and it works without re-uploading. Why?
- A. Gradio caches the DataFrame in the browser.
- B.
GradioUIrunsagent.run(..., reset=False)under the hood, so memory persists across turns. - C.
reset_agent_memory=Truekeeps the dataset loaded. - D. The agent reloads the file silently on every message.
3. You push Quill and want to know which artefact makes the repo run as a Space without extra config. Which one — and why is managed_agents/ in the export?
- A.
requirements.txt;managed_agents/is always written. - B.
agent.json;managed_agents/holds the prompt templates. - C.
app.py(aGradioUI);managed_agents/is written because Quill has aweb_researchersub-agent. - D.
prompts.yaml;managed_agents/lists the authorized imports.
4. CodeAgent.from_hub("user/quill") loads but refuses to run the tools. What is missing, and why is it not just a formality?
- A. Nothing;
from_hubalways needs a secondload()call. - B.
trust_remote_code=True—from_hubdownloads and executes remote tool code, so inspect it first. - C.
private=True; private repos cannot be loaded. - D.
reset=False; the agent needs prior memory to run.
5. A reader runs smolagent "..." expecting a browser-driving vision agent and is confused when it just writes code. What did they mix up?
- A. Nothing;
smolagentalways drives a browser. - B.
smolagent(smolagents.cli:main) is a generalistCodeAgent; the vision browser iswebagent(smolagents.vision_web_browser:main). - C. They needed
--action-type "tool_calling"to enable the browser. - D.
smolagentandwebagentare the same entry point.
Answers.
1 — B. The smolagents 1.26.0 default allowed_file_types is [".pdf", ".docx", ".txt"] — no .csv. Quill subclasses GradioUI and overrides upload_file to use [".csv", ".parquet", ".xlsx"]. (A is backwards: a non-None file_upload_folder enables upload; None disables it. C and D are false.)
2 — B. The UI runs each message as agent.run(message, reset=False), the opposite of agent.run’s own default (reset=True, which wipes memory). The kept memory holds the loaded DataFrame and prior findings. (A and D are not how it works; C inverts the knob — reset_agent_memory=True would wipe memory each turn.)
3 — C. app.py is a ready-to-run GradioUI over the reloaded agent — the Space entry point. managed_agents/ appears only because Quill is multi-agent; it serializes the web_researcher sub-agent’s logic. A solo agent would have no such directory. (The other options misassign the artefacts.)
4 — B. from_hub defaults trust_remote_code=False and refuses to run the tools until you pass True. It is not a formality because loading executes Python someone else wrote — the same warning as the MCP trust_remote_code gate from Module 9. Inspect the repo first. (A, C, D are false.)
5 — B. Two distinct entry points: smolagent → smolagents.cli:main, a generalist code agent; webagent → smolagents.vision_web_browser:main, a helium/Chrome vision browser defaulting to gpt-4o. Quill launches with smolagent. (C and D are false; A confuses the two.)
Common pitfalls.
- Reading
reset=Falseas “forget the context.” It is the inverse —reset=Falsekeeps memory, so it grows (and costs more) each turn. Prune withstep_callbacks(Module 6). This is the most common deployment surprise. - Pasting
trust_remote_code=Truewithout reading the code.from_hubexecutes remote tool code on your machine. Fine for your own agent; risky for a stranger’s. Inspect before you trust — the same gate as MCP in Module 9. - Rebuilding the agent in
ui.py/app.py, or embedding a secret in a tool. Reconstructing the agent outsidebuild_quill()guarantees drift from the tested Quill. And a pushed agent publishes its tool code — so a hard-coded key is now public. Always callbuild_quill(); keep secrets in.env.
Key takeaways
GradioUI(agent, file_upload_folder="uploads", reset_agent_memory=False).launch()is a web app in three lines — butfile_upload_folder=Nonedisables upload, so pass a real folder.- The UI runs
reset=Falseunder the hood, so the chat keeps memory across turns (the Module 6 continuation).reset=Falsekeeps context;reset=Truewipes it. - The default
allowed_file_typesis[".pdf", ".docx", ".txt"]— no.csv— so a data-analyst UI must widen the allow-list. save()/push_to_hubserializeagent.json+prompts.yaml+tools/+managed_agents/+app.py+requirements.txt;app.pyis what makes the repo run as a Space, andmanaged_agents/appears only because Quill has a sub-agent.- An agent is pushable only if every tool is: imports inside functions, no
__init__args, literal class attributes, no module-level helper calls. from_hubis a classmethod and requirestrust_remote_code=True— which downloads and executes remote code. Inspect before you trust.smolagent(generalistCodeAgent,smolagents.cli:main) is notwebagent(vision browser,smolagents.vision_web_browser:main); and the CLI builds a generic agent, not the fullbuild_quill.- A public agent is a threat vector: run it in a remote sandbox, bound its steps and rate, and never embed a secret.
Keep going
Want the full smolagents concept-check bank and the next module in your inbox? Subscribe here — it’s free, like everything in this series.
Quill is now a one-click web app — but “it looks like it works” isn’t proof. Module 14 wires up OpenTelemetry traces and an eval harness so you can measure whether Quill is any good — task success, citations, cost per run.
Links: the Module 13 lab · Module 12: Agentic RAG · Module 14: Observability and Evaluation · course index
References
- smolagents — Agents reference (
GradioUI,stream_to_gradio,save,push_to_hub,from_hub,interrupt): https://huggingface.co/docs/smolagents/reference/agents - smolagents — Guided tour (
GradioUI,reset=False, sharing agents as Spaces): https://huggingface.co/docs/smolagents/guided_tour - smolagents — index (“share and load agents and tools to/from the Hub as Gradio Spaces”): https://huggingface.co/docs/smolagents/index
- smolagents — Tools reference / tutorial (the “pushable” rules,
Tool.save/push_to_hub): https://huggingface.co/docs/smolagents/tutorials/tools - smolagents — CLI source (
smolagentflags and defaults): https://github.com/huggingface/smolagents/blob/main/src/smolagents/cli.py - Gradio —
ChatInterface(the base ofGradioUI): https://www.gradio.app/docs/gradio/chatinterface