Writing AI agents in YAML instead of Python Docker released docker-agent, an open-source tool that lets developers define AI agents entirely in a single YAML file rather than Python code, and it ships with the `docker agent` command in Docker Desktop 4.63 and later or via `brew install docker-agent`. The tool provides 30 built-in toolsets, including `rag`, `shell`, `filesystem`, `fetch`, `mcp` and `memory`, and its `rag` toolset handles indexing and vector storage automatically, storing embeddings in a local SQLite file and re-indexing changed files. In the walkthrough, an agent configured with `openai/gpt-5-mini` and `openai/text-embedding-3-small` at 1536 vector dimensions searches 72 Polars user-guide markdown files and is run with `docker agent run --exec embeddings.yaml`. Table of Contents Introduction There are plenty of frameworks for building AI agents, such as LangChain https://codecut.ai/langchain-1-0-middleware-production-agents/ or LlamaIndex. But as you add models, tools, and features, the agent quickly grows into a lot of code to maintain. More code means more places for mistakes when you need to make a change. docker-agent keeps the entire workflow in one YAML file. With this approach, you can mix and match models, tools, and features, and see how the whole agent is set up at a glance. In this article, you will learn nice features of docker-agent by building an agent that answers questions about Polars. πŸ’» Get the Code : The agent configs and eval files are on GitHub https://github.com/khuyentran1401/codecut-blog/tree/main/docker-agent-docs-qa . What Is docker-agent? docker-agent https://github.com/docker/docker-agent is Docker’s open-source tool for building and running AI agents. docker-agent does for AI agents what Docker did for apps. Instead of setting up an environment and writing code, you describe the agent in one YAML file and run it with one command. Stay Current with CodeCut Easy-to-digest articles on Python, AI, and open-source tools. Delivered twice a week. Setup Install docker-agent with Homebrew, or use the docker agent command that ships with Docker Desktop 4.63 and later: brew install docker-agent The agent will search the Polars user guide https://github.com/pola-rs/polars/tree/main/docs/source/user-guide and answer questions about it. The project folder looks like this: docker-agent-docs-qa/ └── docs/polars/ 72 markdown files the agent will search Create the Agent To create the agent, define its model, instructions, and tools in a YAML file called embeddings.yaml . Start with the agents section. agents: root: the agent that receives your message model: openai/gpt-5-mini description: Answers questions about Polars from the user guide instruction: | You answer questions about the Polars DataFrame library. Always search the knowledge base before answering. Answer only from what the search returns. End with a "Sources:" list of the file paths you used, relative to docs/polars. To let the agent search the docs, give it a rag tool and tell the tool which folder to search. agents: root: ... toolsets: - type: rag ref: polars docs use the source defined below rag: polars docs: the name the ref points to tool: description: The Polars user guide docs: ./docs/polars what to search strategies: how to search - type: chunked-embeddings split each file into chunks and embed them embedding model: openai/text-embedding-3-small vector dimensions: 1536 number of values per vector RAG setups usually need a vector database and a separate indexing step. docker-agent handles both for you. It stores the vectors in a local SQLite file, indexes the folder the first time the agent starts, and re-indexes files when they change. rag is one of 30 built-in toolsets. Here are a few others that are useful: - shell : run shell commands, such as a Python script or duckdb - filesystem : read, write, and search files in a folder - fetch : read web pages and online docs - mcp : call external services like GitHub or a database through MCP https://codecut.ai/fastmcp-sql-bridge-database-access/ - memory : remember facts across sessions After the YAML file is created, ask the agent a question. The --exec flag runs the agent without opening the interactive terminal UI. docker agent run --exec embeddings.yaml "How do I read a Parquet file without loading it all into memory?" The output shows the search the agent made, followed by its answer. The search results and the model’s reasoning summary are trimmed here: Calling polars docs query: "read Parquet without loading all into memory scan parquet scan parquet streaming lazy read parquet memory efficient 'scan parquet' 'read parquet' 'scan' 'Parquet'" Use a lazy scan and streaming collect so Polars parses and processes the file in batches instead of materializing everything in RAM. - Use pl.scan parquet ... returns a LazyFrame instead of pl.read parquet ... . Scanning delays parsing and enables column/row pruning via the optimizer projection & predicate pushdown . - Apply selects/filters on the LazyFrame so the reader can skip unnecessary columns/rows. - When collecting, use streaming execution to process in batches: .collect engine="streaming" . Sources: - io/parquet.md - lazy/sources sinks.md - lazy/execution.md The answer is correct, and every claim traces back to a file you can open. To ask another question, run the same command with a new question without changing the YAML file. docker agent run --exec embeddings.yaml "When should I use join asof?" Swap the Model To try a cheaper model without committing to it, you can simply pass --model for a single run. embeddings.yaml --model what runs β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ model: gpt-5-mini β”‚ ── gpt-5-nano ── β”‚ model: gpt-5-nano β”‚ β”‚ instruction: ... β”‚ β”‚ instruction: ... β”‚ β”‚ toolsets: rag β”‚ β”‚ toolsets: rag β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ unchanged this run only For example, this runs the same question on gpt-5-nano . docker agent run --exec embeddings.yaml --model openai/gpt-5-nano \ "How do I read a Parquet file without loading it all into memory?" - Use scan parquet to read lazily. This returns a LazyFrame and does not parse the data into memory until you collect. ... Sources: io/parquet.md This is very nice for several reasons: - You can quickly try different models and compare their answers - Because the YAML file does not change during the experiment, your teammates are not affected - Once you like a model, you can edit the YAML file to use it permanently Reuse Instructions Across Questions You might want to vary the instructions based on the context, such as the audience, the length, or the tone. With docker-agent, you can write different instructions in separate files, then pick one with the --prompt-file flag. For example, you can create a pandas-users.md file with the instructions for pandas users and a sql-users.md file with the instructions for SQL users. "How do I read a Parquet file without loading it all into memory?" β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β–Ό β–Ό --prompt-file pandas-users.md --prompt-file sql-users.md β”‚ β”‚ β–Ό β–Ό Pandas: read parquet loads SQL: SELECT FROM the file into memory read parquet 'data.parquet' Let’s try the pandas-users.md file first. Start by creating the file. The reader knows pandas. Show the pandas equivalent next to each Polars answer. Keep each answer under 8 lines. Then start an interactive chat with the file passed to --prompt-file . docker agent run embeddings.yaml --prompt-file pandas-users.md Now every answer follows the instructions in pandas-users.md , even though the questions never mention pandas. How do I read a Parquet file without loading it all into memory? Polars: use scan parquet "file.parquet" to get a LazyFrame parsing is delayed . Collect with streaming to process in batches: lf.collect engine="streaming" . ... Pandas: read parquet "file.parquet" loads the file into memory no built-in lazy/streaming scan . If your audience knows SQL instead, create a prompt file called sql-users.md . The reader knows SQL. Show the Polars SQL equivalent next to each Polars answer. Keep each answer under 8 lines. Start a chat with that file instead. docker agent run embeddings.yaml --prompt-file sql-users.md The same question now gets a SQL answer instead of a pandas one. How do I read a Parquet file without loading it all into memory? - Polars: Use lazy scanning so the file isn't parsed upfront: lf = pl.scan parquet "data.parquet" , which returns a LazyFrame. SQL: SELECT FROM read parquet 'data.parquet' ; - Polars: Push down projections/filters so only needed columns/rows are read. SQL: SELECT col1, col2 FROM read parquet 'data.parquet' WHERE ...; ... Add Variants with Flavors Sometimes you want more than one version of an agent, such as one with a different search method or a different model. It is redundant to write another YAML file for each version with most of the settings staying the same. With docker-agent, you can add a flavor to the same file. A flavor lists only the settings that change, and everything else stays the same. --flavor hybrid off on β”‚ β”‚ β–Ό β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ embedding search β”‚ β”‚ embedding search β”‚ β”‚ β”‚ β”‚ + keyword search β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ base config base + hybrid In this example, we add a flavor called hybrid that adds keyword search to the existing embedding search strategy. embeddings.yaml agents: root: ... rag: polars docs: ... strategies: - type: chunked-embeddings embedding search flavors: new section at the bottom of the file hybrid: rag: polars docs: strategies+: keep chunked-embeddings and add bm25 - type: bm25 keyword search results: fusion: strategy: rrf merge the keyword and embedding results k: 60 deduplicate: true limit: 5 To use a flavor, pass its name with --flavor : embedding search only docker agent run embeddings.yaml embedding search + keyword search docker agent run embeddings.yaml --flavor hybrid Here is what the config looks like after docker-agent applies the hybrid flavor. rag: polars docs: ... strategies: - type: chunked-embeddings from the base file - type: bm25 from hybrid results: fusion: strategy: rrf from hybrid ... To see how this is useful, ask about SQLContext , a class name that appears in the docs. Without the flavor, the agent uses the embedding search strategy and finds nothing. docker agent run --exec embeddings.yaml "What is SQLContext?" Calling polars docs query: "SQLContext" Calling polars docs query: "SQL" I searched the Polars documentation for "SQLContext" and for "SQL" but found no matching documents, so I cannot provide a definition from the docs. With the hybrid flavor, every search runs both strategies. Keyword search finds the exact name, so the right page comes back. docker agent run --exec embeddings.yaml --flavor hybrid "What is SQLContext?" Calling polars docs query: "SQLContext" SQLContext is the Polars object that manages SQL queries... Sources: - sql/intro.md Run Different Agents from One File Sometimes you might want to have multiple agents for different purposes. For example, you might want to have a small model for answering questions and a stronger model for reviewing code. docker-agent lets you add more agents to the same file. They can share tools already defined there, so you don’t need to repeat the configuration. root reviewer β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ gpt-5-mini β”‚ β”‚ gpt-5 β”‚ β”‚ answers questions β”‚ β”‚ reviews code β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ └──────────────┐ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β–Ό β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ polars docs β”‚ β”‚ defined once β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ In the code below, we add a second agent called reviewer that uses a stronger model for reviewing code but uses the same polars docs tool as the root agent. agents: root: model: openai/gpt-5-mini small model for answering questions instruction: | You answer questions about the Polars DataFrame library. ... toolsets: - type: rag ref: polars docs shared search reviewer: model: openai/gpt-5 stronger model for the harder task instruction: | You review Polars code. Search the docs for each function the code uses. Point out slower or outdated patterns and suggest the fix the docs recommend. ... toolsets: - type: rag ref: polars docs same shared search By default, docker-agent runs the first agent in the file. To run the reviewer instead, pass --agent . docker agent run --exec embeddings.yaml --agent reviewer \ 'Review this code: df = pl.read parquet "big.parquet" .filter pl.col "amount" 0 .select "id", "amount" ' Findings: - Using read parquet eagerly loads the entire file before your filter/select are applied. This prevents predicate and projection pushdown, which is slower and uses more memory on large Parquet files. Recommended fix: - Switch to scan parquet and collect at the end: df = pl.scan parquet "big.parquet" .filter pl.col "amount" 0 .select "id", "amount" .collect ... The reviewer spots the eager read parquet call and suggests switching to scan parquet . Test the Agent As you experiment with different models and settings, you might want to make sure the agent keeps working as expected. It can be easy to miss mistakes by just observing the output. docker-agents provides an easy way to define the test cases and run them against the agent. evals/streaming-parquet.json β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ question: How do I read a Parquet file without β”‚ β”‚ loading it all into memory? β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ agent β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β–Ό answer β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β–Ό β–Ό β–Ό βœ“ uses βœ“ mentions βœ“ searched scan parquet streaming the docs To define a test case, create a JSON file in the evals/ folder, such as evals/streaming-parquet.json . { "title": "Large Parquet file question", "messages": {"message": {"message": {"role": "user", "content": "How do I read a Parquet file without loading it all into memory?"}}} , "evals": { "assertions": { "name": "uses scan parquet", "type": "contains", "value": "scan parquet" }, { "name": "mentions streaming", "type": "contains", "value": "streaming" }, { "name": "searched the docs", "type": "tool called", "value": "polars docs" } } } For this question to pass, the answer must mention scan parquet and streaming , and the agent must have searched the docs. Now run the suite with docker agent eval : docker agent eval embeddings.yaml ./evals βœ“ Asof join question $0.001918 βœ“ assertions 3/3 βœ“ Large Parquet file question $0.002629 βœ“ assertions 3/3 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 100% 2/2 βœ“2 βœ—0 βœ… Assertions: 6/6 passed 100.0% Total Cost: $0.004546 Total Time: 26s Each run reports which tests passed, what each answer cost, and how long it took. Together, they tell you whether a change broke the agent, made it pricier, or slowed it down. Since each eval runs in its own container, one test can’t leave files behind that change another’s result. Compare variants before you switch Because eval takes --flavor too, you can test a variant before adopting it. Let’s say you want to know whether the cheaper gpt-5-nano is good enough for your use case. Add it as a second flavor called cheap . flavors: hybrid: ... cheap: agents: root: model: openai/gpt-5-nano smallest, lowest-cost GPT-5 model Then compare the default model against the cheap flavor. Since answers vary between runs https://codecut.ai/prompt-tweak-score-gain/ , --repeat 3 runs each test three times. docker agent eval embeddings.yaml ./evals --repeat 3 docker agent eval embeddings.yaml ./evals --flavor cheap --repeat 3 gpt-5-mini gpt-5-nano cheap assertions passed 18/18 13/18 <- less reliable Parquet eval 3/3 runs 0/3 runs asof join eval 3/3 runs 2/3 runs total cost $0.015 $0.007 <- less than half the cost The cheap model saves more than half the cost, but its answers are less reliable. It passed 13 of 18 checks, compared with 18 of 18 for the default model. Share the Agent Once you are happy with a configuration, you might want to share it with your teammates. Sharing a Python agent usually means your teammates have to download the code, install the dependencies, and run a setup script before they can use it. docker-agent makes sharing agents as easy as sharing Docker images. Teammates run it with one command instead of setting up a Python project. To push the YAML file to a registry such as Docker Hub after docker login , run: docker agent share push embeddings.yaml docker.io/