Deploying Quill: Gradio UI, the Hub, and the CLI (smolagents, Module 13)

Module 13 of 15 13 min read Lab code ↗

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

  1. Wrap any MultiStepAgent in a GradioUI — with CSV uploads, multi-turn memory, and streaming — in three lines, and understand each constructor knob (file_upload_folder, reset_agent_memory).
  2. Explain why the UI runs the agent with reset=False under the hood — and how that ties the chat experience to the memory model from Module 6.
  3. Serialize and share an agent: know exactly what save() / push_to_hub write (agent.json, prompts.yaml, tools/, managed_agents/, app.py, requirements.txt) so Quill runs as a Hugging Face Space out of the box.
  4. Load an agent back with from_hub / from_folder / from_dict — and explain why trust_remote_code=True is mandatory and dangerous (you execute remote tool code).
  5. Run a code agent from the smolagent CLI, pass the right flags, and tell smolagent (generalist CodeAgent) apart from webagent (vision browser).

You’ll build Quill becomes a web app: a GradioUI with CSV upload and streaming, pushed to the Hub as a one-click Space, and launchable from the smolagent CLI.

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). An HF_TOKEN with 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 — the MultiStepAgent to wrap. For Quill this is always the output of build_quill().
  • file_upload_folder (str, optional) — the directory uploaded files land in. If it is None (the default), upload is disabled entirely. A data analyst’s UI with no upload is useless, so Quill passes "uploads". That single non-None value is what unlocks the CSV uploader.
  • reset_agent_memory (bool, default False) — when True, the agent’s memory is wiped at the start of every interaction. We keep the default False, 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=False and reset=False mean the agent re-thinks from scratch each turn / wipes the context.” It is the inverse. reset=False keeps the context — the memory accumulates. The word “reset” is doing the opposite of what the intuition suggests: reset=True is the wipe; reset=False is the continue. The production consequence is real: memory (and token cost) grow on every turn. That is exactly why you prune stale observations with step_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 startwipedkept
Use caseone-shot analysismulti-turn chat
Who sets ityou, in a scriptGradioUI, automatically
Cost over turnsboundedgrows 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:

FileWhat it is
agent.jsonthe agent’s dict representation (from to_dict())
prompts.yamlthe agent’s prompt templates
tools/<name>.pyone file per toolload_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.pya ready-to-run GradioUIthis is what makes the repo run as a Space
requirements.txtthe 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:

  1. 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.
  2. __init__ takes no argument beyond self. Constructor arguments are “not traceable” and fail validation — so configuration lives in class attributes, not constructor args.
  3. Each tool body references no module-level helper. A call to an outside function like _read_table or load_corpus fails the static check with Name '<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:

FlagDefaultNotes
prompt (positional)Noneomit 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-level1range 0–2
--provider / --api-base / --api-keyprovider 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:

NameEntry pointAgent typeDefault modelUse
smolagentsmolagents.cli:maingeneralist CodeAgentInferenceClientModel + Qwen/Qwen3-Next-*run a code agent from flags
webagentsmolagents.vision_web_browser:mainvision browser (helium/Chrome)gpt-4o via LiteLLMModeldrive 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

  1. Build a custom Gradio app with build_custom_app — a Stop button wired to agent.interrupt() via stream_to_gradio — and watch it stop at the end of the current step rather than mid-call.
  2. 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 the app.py the 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_folder must be None to enable uploads.
  • B. The default allowed_file_types is [".pdf", ".docx", ".txt"], which excludes .csv; widen the allow-list.
  • C. GradioUI cannot accept file uploads at all.
  • D. You must set reset_agent_memory=True for 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. GradioUI runs agent.run(..., reset=False) under the hood, so memory persists across turns.
  • C. reset_agent_memory=True keeps 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 (a GradioUI); managed_agents/ is written because Quill has a web_researcher sub-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_hub always needs a second load() call.
  • B. trust_remote_code=Truefrom_hub downloads 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; smolagent always drives a browser.
  • B. smolagent (smolagents.cli:main) is a generalist CodeAgent; the vision browser is webagent (smolagents.vision_web_browser:main).
  • C. They needed --action-type "tool_calling" to enable the browser.
  • D. smolagent and webagent are 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: smolagentsmolagents.cli:main, a generalist code agent; webagentsmolagents.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=False as “forget the context.” It is the inverse — reset=False keeps memory, so it grows (and costs more) each turn. Prune with step_callbacks (Module 6). This is the most common deployment surprise.
  • Pasting trust_remote_code=True without reading the code. from_hub executes 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 outside build_quill() guarantees drift from the tested Quill. And a pushed agent publishes its tool code — so a hard-coded key is now public. Always call build_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 — but file_upload_folder=None disables upload, so pass a real folder.
  • The UI runs reset=False under the hood, so the chat keeps memory across turns (the Module 6 continuation). reset=False keeps context; reset=True wipes it.
  • The default allowed_file_types is [".pdf", ".docx", ".txt"]no .csv — so a data-analyst UI must widen the allow-list.
  • save() / push_to_hub serialize agent.json + prompts.yaml + tools/ + managed_agents/ + app.py + requirements.txt; app.py is what makes the repo run as a Space, and managed_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_hub is a classmethod and requires trust_remote_code=True — which downloads and executes remote code. Inspect before you trust.
  • smolagent (generalist CodeAgent, smolagents.cli:main) is not webagent (vision browser, smolagents.vision_web_browser:main); and the CLI builds a generic agent, not the full build_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