# danicat.dev — Daniela Petruzalek (Full Corpus) > Technologist, gamer and cat lover — Senior Developer Relations Engineer at Google > For the compact index, see [llms.txt](https://danicat.dev/llms.txt). - Website: https://danicat.dev/ - Compact Index (llms.txt): https://danicat.dev/llms.txt - Full Corpus (llms-full.txt): https://danicat.dev/llms-full.txt - Language Editions: [English (llms-full.txt)](https://danicat.dev/llms-full.txt) | [Português (llms-full.txt)](https://danicat.dev/pt-br/llms-full.txt) | [日本語 (llms-full.txt)](https://danicat.dev/ja/llms-full.txt) - Agent Skills Catalog: https://skills.danicat.dev/ (LLMs index: https://skills.danicat.dev/llms.txt | JSON manifest: https://skills.danicat.dev/catalog.json) --- # Beyond Writing Code: Taste, Skills, and Tech Leads - **Markdown URL**: https://danicat.dev/posts/beyond-writing-code/index.md - **HTML URL**: https://danicat.dev/posts/beyond-writing-code/ - **Date**: 2026-10-28 - **Categories**: Perspectives - **Tags**: agent-skills, antigravity, career, code-review, subagents, vibe-coding - **Summary**: Why software engineering in the age of coding agents shifts from writing syntax to architectural taste, subagents, agent skills, and acting as tech leads. When foundation models have all the knowledge of the world baked into their weights, what's left for the humans? For decades, our identity as software engineers was tied to the craft of writing code: mastering syntax, memorising standard libraries, and taking pride in the systems we built with our own hands. We were told AI would simply take care of the boring boilerplate and hand us back hours of free time every week. Instead, watching a model generate in seconds what took us years to learn has triggered an identity crisis across the industry. If the machine can write the code, where do we fit in? Humans should be the beacon. Exactly because models know *all*, every path looks equally plausible. They cannot prioritise and sequence information to create with **taste**. This is where our new job kicks in: as if we were spelunking in a dark cave, we hold the flashlight, pointing at the path and deciding where the model should focus next. Our role evolved from writing code to orchestration. This is actually not new for many of us, even before AI coding: it is the natural progression of the software engineering career. When you move up the ladder, you get less hands-on and start wearing the coach hat more and more. You become responsible for the output of your team, you stop reading all lines of code and focus on the critical parts, you learn to delegate and to deal with the pros and cons of that. You become a tech lead. ## Everyone has been promoted to tech lead ![In 2026 Every Engineer Has Been Promoted to Tech Lead](everyone-is-a-tech-lead.png "Congratulations on your promotion to Tech Lead. (P.S.: HR confirmed your compensation band remains unchanged.)") In the traditional career path, that transition happened gradually. You spent years in the trenches evolving from Junior to Senior before eventually stepping into a lead, engineering manager, or architect role, focused on the big picture while more junior engineers were tasked with the implementation details. Now everyone has their virtual team of coding agents, so even the most junior engineer needs to learn how to become a good tech lead. Not to guide humans, but to guide the agents. "Guide to do what?" is the most important question. We are still building programs, but the way we describe them has changed. There are few books that I've read in my life that I consider to be evergreen. One of those is *Structure and Interpretation of Computer Programs* (aka SICP). Why is this book relevant now? Because of the way it describes programs and how we as engineers "conjure spells" to build them: > *"Computational processes are abstract beings that inhabit computers. As they evolve, processes manipulate other abstract things called **data**. The evolution of a process is directed by a pattern of rules called a **program**. People create programs to direct processes. In effect, we conjure the spirits of the computer with our spells."* > > *"... The programs we use to conjure processes are like a sorcerer's spells. They are carefully composed from symbolic expressions in arcane and esoteric programming languages that prescribe the tasks we want our processes to perform."* > > — Harold Abelson and Gerald Jay Sussman, *Structure and Interpretation of Computer Programs* (1985) We are still in the business of conjuring processes to instruct the computers. Only the language in which we craft our spells has changed. Now that we write those spells in plain English instead of arcane symbolic expressions, orchestrating agents and ensuring they are on the right track is the most important part of the job, which implies less writing code and a lot more reading. You are also writing more specifications (prompts) than ever. If you have been a tech lead before AI, all of this will simply sound like business as usual. How do you get good at this? In the old world, the good tech leads and managers would spend a lot of time coaching and mentoring their teams so they would develop the same critical thinking and taste that they developed during their careers. They would gradually transfer the knowledge of processes, behaviours and techniques often developed at the cost of many battle scars. That pedantic code review I just did? It was because my own lack of attention to details made me crash production many years ago. In today's world, beyond learning from our mentors and our own battle scars, we need to carefully observe the behaviour of our agents and learn from **their** mistakes. Because those mistakes accumulate at blinding speed, agents provide the ultimate compressed feedback loop: you watch dozens of subtle failure modes, race conditions, and hallucinated patterns unfold in an afternoon, building architectural discernment at ten times the speed of traditional release cycles. In essence, we are now in the business of orchestrating the flow of information, ensuring that agents always have the optimal environment for building the outcomes we desire. Given the rate of evolution we are seeing, what is optimal today will most likely not be optimal tomorrow, so we need to constantly refine our tools and processes to keep up with the pace of evolution. More than ever, software is disposable. Our spells need to become rapid and precise, as tomorrow we will need new ones. ## Structuring information so models can act on it > *"Master software engineers have the ability to organize programs so that they can be reasonably sure that the resulting processes will perform the tasks intended. They can visualize the behavior of their systems in advance. They know how to structure programs so that unanticipated problems do not lead to catastrophic consequences, and when problems do arise, they can **debug** their programs. Well-designed computational systems, like well-designed automobiles or nuclear reactors, are designed in a modular manner, so that the parts can be constructed, replaced, and debugged separately."* > > — Harold Abelson and Gerald Jay Sussman, *Structure and Interpretation of Computer Programs* (1985) If you take the quote above and replace the word "programs" with "agents," the whole framework still stands. We need to learn how to structure our agentic coding processes so that they produce well-designed computational systems. One of the most important ways of giving knowledge to an agent is via [Agent Skills](https://danicat.dev/posts/20260829-the-pragmatic-guide-to-agent-skills/). Because models behave very similarly to humans (shouldn't be a surprise, they were trained on data that we produced), the same techniques we applied to improve the productivity and output quality of our teams are applicable to agents. This makes for a fertile ground in creating skills that will influence model behaviour inspired by your own experiences and battle scars, but also industry best practices. One such technique is to break down complex tasks into smaller pieces. A cornerstone of the Agile process, which now can be applied to agentic coding via the constructs of spec-driven development, implementation plans and [subagents](https://danicat.dev/posts/20260722-the-rise-of-the-subagents/). I personally treat subagents as the building blocks of the information flow I'm designing. This is in many senses similar to dealing with concurrency in traditional programs, but also plays an important role in isolating context. After all, "attention is all *they* need." By breaking down tasks into orthogonal pieces that can be individually processed by the subagents, you are ensuring that the agent's precious attention span is focused on that single task alone, improving quality and task adherence. Whenever you are manually weaving workflows or building your skills for posterity, I've found these points to be particularly helpful: 1. **Coordinator role**: It is useful to keep the main session free by making sure the coordinator always delegates execution to subagents, releasing the main session as fast as possible. That way you can keep interacting with it while the subagents are working, sending comments to steer them in the right direction in real time. 2. **Orthogonality of tasks**: Slice tasks in orthogonal units to minimise dependencies or conflicts with each other. First, you have to understand and respect sequential dependencies. Then, you can parallelise with confidence that subagents won't overwrite each other's work or make incompatible assumptions. 3. **Recreating proven workflows**: Just like with coaching and mentoring, patterns that work for human teams and classical software engineering also work for agents. Constantly revisit the literature for patterns old and new that you can teach to the agents. I have been gradually building my [skill catalog](https://skills.danicat.dev) over the past few months. Because skills *rot* fast as models and agent harnesses evolve, it's important to notice that this isn't a fire-and-forget type of thing. I'm constantly evolving them, to the point I decided to add a "freshness score" to my skills to remind me which ones are becoming obsolete so I can either try to give them a new life or trim them from my catalog. Here are a few examples of skills I actively use on a daily basis: - **`double-diamond`**: a product development workflow based on the double diamond methodology from Agile practices that forces discovery before implementation. - **`parallel`**: a multi-purpose fan-out / map-reduce pattern that breaks a task into multiple threads and reconciles the results at the end. - **`uno-reverse`**: adversarial review to counter the model's overconfidence and tendency to over-engineer. - **`less-is-more`**: a design philosophy telling the model to do one thing at a time, and do it well, instead of creating bloated apps. - **`intercom`**: a skill that teaches my agents to talk with each other when they are working on related projects. - **`show-me-the-money`**: a skill to mine the conversation history for features that I discussed with the model, but completely forgot to implement. - **`abstract`**: generalises examples and circumstances into knowledge using philosophical principles. ## Broken windows in agentic code > *"A computational process, in a correctly working computer, executes programs precisely and accurately. Thus, like the sorcerer's apprentice, novice programmers must learn to understand and to anticipate the consequences of their conjuring. Even small errors (usually called **bugs** or **glitches**) in programs can have complex and unanticipated consequences. ... Real-world programming, however, requires care, expertise, and wisdom."* > > — Harold Abelson and Gerald Jay Sussman, *Structure and Interpretation of Computer Programs* (1985) If we don't anticipate the consequences of what we conjure, an unguided agent will multiply small structural flaws faster than we can even realise them. This problem is not new; it actually always happened even with human-written code, but we didn't notice it that much because it was a much slower process. Some people tried to stop it and were always deemed pedantic code reviewers, loved by a selected few but hated by most. In psychology, it is a phenomenon known as the Broken Window Theory based on an experiment dating back to 1969. Researchers left an abandoned car in a good neighbourhood for a week. Nothing happened to it. On week two, one of the researchers used a sledgehammer to break a window. Within hours, the car was stripped bare. The broken glass was the trigger. It signalled that nobody cared, so collectively the community stopped caring as well. I've been advocating for years that the same thing happens with code. Once there is a broken window in code (for example: typos, bad names, poor tests, or wrong abstractions), it triggers our psychological system that stops caring. And the problem escalates with time, so minor flaws can snowball into huge ones. In the agentic world, the problem is amplified, as existing code will bias the model when generating the new one. If existing code is bad, unless properly instructed, the agent will only amplify its problems, as it's deemed an acceptable standard to follow. Code reviews are the less glorious part of the engineering work, to the point that many engineers don't even consider it to be part of the work. It is, in fact, one of the most important ones. This **doesn't** mean reviewing every single line of code. Nobody likes to review thousands of lines of AI-generated code, including myself. Taste isn't about checking syntax or catching mechanical bugs—that's what deterministic tooling is for. As I wrote in [How to Do Code Reviews in the Agentic Era](https://danicat.dev/posts/20260303-code-reviews-in-2026/), if a compiler, linter, or automated test can catch an issue, a human shouldn't be looking at it. We have to fight AI with AI and automation: use deterministic gates to eliminate the mechanical noise, focus your review time on critical architectural boundaries, and sample the rest. Because cycle times today are orders of magnitude faster than "artisanal coding," by sampling, even if you don't catch an issue immediately, you will quickly have another chance to catch it. Therefore, direct your reviews to the really critical parts and trust the process to catch the rest. ## Where the promised free time actually goes We're building software faster than ever. By removing implementation bottlenecks, all the ideas that used to stay on the shelf until they were replaced by something more urgent suddenly became viable. We can now write prototypes in quick succession to see what works and what doesn't long before they end up in the pile of abandoned projects. Moving faster, however, doesn't replace the need to build mental models. Our core work is structuring context, constraints, and feedback loops so agents build what we actually intended. If anything, our job has become harder: technical knowledge has become a commodity, but taste and critical sense are still sparse. Ultimately, we're still sorcerers conjuring the spirits of computers. It's just our spells that have crossed yet another level of abstraction. --- # Designing Agentic Interfaces with A2UI and Genkit - **Markdown URL**: https://danicat.dev/posts/designing-agentic-interfaces-with-a2ui-and-genkit/index.md - **HTML URL**: https://danicat.dev/posts/designing-agentic-interfaces-with-a2ui-and-genkit/ - **Date**: 2026-10-27 - **Categories**: Agent Development - **Tags**: a2ui, agent-skills, ai-agents, gemini, genkit, golang, osquery - **Series**: Gemini for Go Developers - **Summary**: Learn how to upgrade a Genkit Go agent from a text chatbot to a generative UI using A2UI, custom component catalogs, and human-in-the-loop interrupts. In the [previous article](https://danicat.dev/posts/20261006-building-agentic-backends-with-genkit-go/), we rebuilt the backend of AIDA, the AI Diagnostic Agent, replacing her legacy Python and SQLite RAG setup with a compiled Genkit Go binary and on-demand Agent Skills. With the backend done, our attention shifts to the frontend. Even with a new Go brain and on-demand skills, AIDA is still pretty much a chatbot: you type a prompt, she runs `osquery`, and she dumps a wall of text back at you. Even though AIDA is retro-themed, we don't need to limit her to such a dated experience. Modern agentic UIs should not only leverage richer UI elements, but they should also adapt to user needs instead of applying a static, one-size-fits-all approach. In this article, we modernize AIDA's interface. Before digging into the code, let's look at what a modern agentic UI needs and how the [Agent to User Interface (A2UI)](https://a2ui.org/) protocol works. ## What is A2UI? A2UI is an open protocol from Google that gives AI agents a way to speak directly in native UI components. Instead of limiting responses to text, an agent can dynamically assemble dashboards, charts, tables, and interactive controls tailored to whatever task you are working on right now. Your frontend keeps full control over UI styling and rendering, while the agent chooses *what* to display based on the chat context and data. An A2UI integration revolves around three main concepts: - **Catalog**: The shared menu of UI building blocks (like panels, gauges, charts, tables, and buttons) and their schemas. Both the backend agent and the frontend client agree on the catalog upfront — you can use the built-in basic catalog or define custom components tailored to your domain. - **Surfaces**: The live UI canvas (``) rendered in the client. As the agent works, it streams structured JSON messages (`application/a2ui+json`) telling the client which catalog components to place on a surface and how to update their properties in real time. - **Actions**: Two-way events sent from a surface back to the agent when a user interacts with a component — like clicking a button, selecting a row in a table, or inspecting a node in a graph. Actions turn clicks directly into the next turn of the conversation without needing to type a follow-up prompt. ## AIDA's new frontend spec We want to move from a hardcoded UI to something that the agent can freely define depending on context, by using A2UI. This requires that we define a catalog of components for AIDA and give her access to these building blocks. We also need to rewire the frontend so it can render the A2UI surfaces properly. Here is the new diagnostic cockpit in action. On the fly, AIDA assembles a 3D process graph, heads-up display (HUD) gauges, and a live `osquery` process table: ![AIDA rendering a live A2UI v0.9 diagnostic surface with a 3D process graph, HUD gauges, and an interactive osquery process matrix](aida-diagnostic-surface.png "AIDA diagnostic surface rendered with custom A2UI components: 3D process graph, HUD gauges, and live osquery process table.") The prompt below roughly translates this intent to a specification: ```markdown Upgrade AIDA's web interface from a static chat UI to a generative diagnostic cockpit using A2UI and Genkit. Requirements: 1. Keep the retro NEC PC-98 cyberpunk look and feel (AIDA's pixel avatar, CRT scanlines, and neon palette), with a fixed host-status sidebar showing live osquery system vitals. 2. Replace the plain Markdown chat output with an A2UI surface powered by a custom "aida" component catalog for system diagnostics: HUD panels, gauges, charts, filterable data tables, 3D system visualisations, text blocks, and action buttons. 3. Wire up two-way A2UI actions so clicking a table row, 3D node, or button sends a follow-up diagnostic action back to AIDA. 4. Add a human-in-the-loop shell command tool so AIDA can actually fix issues she finds on the host, pausing for explicit Approve/Deny confirmation in the UI before executing any command. /grill-me ``` Don't expect to one-shot this with this prompt alone. In fact, it's important to give your coding agent the right skills, MCPs and tools for the job, as we're going to see in the "Recommended skills" section below. You may also notice the `/grill-me` skill hint at the end of the prompt. This skill comes bundled with Antigravity (and most modern coding agents), and activating it instructs the agent to ask you questions wherever instructions are ambiguous or lacking detail. This helps refine your vision and takes the pressure off having to write a flawless prompt from scratch. Because the prompt above is very lean, `/grill-me` is doing a lot of the heavy lifting. This is why we can get away without fully specifying the component catalog (as we are going to see below): Antigravity will ask us for the missing details to fill the prompt gaps. ### Recommended skills Since we're working with Genkit, I recommend you install and activate the official [Genkit Skills](https://github.com/genkit-ai/skills) including `developing-genkit-go` and `developing-genkit-js`. These skills contain not only the knowledge of how to build agents and flows, but also how to work with the A2UI elements both on the client and on the server side. In addition to those, the `gemini-api-dev` skill helps the agent to use modern models instead of falling back to outdated ones. This skill can be either installed as a single skill from the [Google Skills](https://github.com/google-gemini/gemini-skills/blob/main/skills/gemini-api-dev/SKILL.md) repo or as part of the [Gemini Cloud Developer Plugin](https://cloud.google.com/blog/topics/developers-practitioners/introducing-the-google-cloud-developer-plugin-for-ai-coding-agents). ## A2UI catalog design Typical frontend development involves designing the user interface around the user journeys we want to satisfy. This is no different in A2UI applications, but we won't be designing classic hardcoded application screens. Instead, we will be focusing on designing the individual components required to achieve our objectives. These components are what make the agent's **catalog** and they will give the agent flexibility to adapt the UI on demand. You don't **need** to design a catalog per se, because A2UI comes bundled with a **Basic Catalog** that should satisfy most common needs. But because we want to build a unique experience here, designing a custom catalog was the best option for AIDA. Here are the seven components we designed for AIDA: - **Panel**: A framed HUD container that groups related components into rows, columns, or grids, complete with a status badge (`nominal`, `warning`, or `critical`) so you can spot unhealthy subsystems at a glance. - **Scene3D**: An interactive 3D wireframe canvas for visualising system topologies — such as process trees, CPU cores, memory matrices, network meshes, disk sectors, or thermal zones — with clickable nodes for drilling down. - **CyberChart**: A multi-series chart (line, bar, area, scatter, or radar) for plotting metrics like CPU load, network throughput, and latency. - **HudGauge**: A visual meter (circular arc, plasma bar, or segmented bar) for capacity and percentage metrics like disk usage, battery charge, and swap pressure. - **DataMatrix**: A filterable, sortable table for `osquery` results where clicking any row triggers a follow-up inspection on that item. - **TextBlock**: A monospaced diagnostic note for summaries, command output, and copyable snippets. - **ActionButton**: An interactive button that lets AIDA propose one-click follow-up actions, such as inspecting a suspicious process or running a remediation fix. Designing domain-specific instruments like these makes a huge difference, as the basic catalog mostly focuses on functionality over aesthetics and richer user experiences. ## Wiring A2UI on the Go backend To give AIDA access to our new UI building blocks on the backend, we enable the Genkit A2UI plugin at startup and load our custom component catalog alongside her diagnostic tools: ```go // internal/agent/agent.go g := genkit.Init(ctx, genkit.WithExperimental(), genkit.WithPromptDir("./prompts"), genkit.WithDefaultModel("vertexai/gemini-3.8-flash"), genkit.WithPlugins( &googlegenai.VertexAI{}, &middleware.Middleware{}, &a2uix.A2UI{}, // Register A2UI middleware ), ) // 1. Register the custom "aida" A2UI component catalog if _, err := a2uix.LoadCatalog(g, AidaCatalog()); err != nil { return nil, err } // 2. Register read-only diagnostics (osquery) and HITL shell commands (command) osquery.RegisterTool(g, runner) command.RegisterTool(g) // 3. Define the single AIDA agent from prompts/aida.prompt aidaAgent := genkitx.DefinePromptAgent(g, "aida", aix.WithDescription("AIDA — AI Diagnostic Agent"), aix.WithSessionStore(sessionStore), aix.WithNamedPrompt("aida", PromptInput{OS: runtime.GOOS}), ) ``` Here we initialize Genkit with `&a2uix.A2UI{}` alongside the Gemini Enterprise (Vertex AI) and middleware plugins, register our diagnostic and remediation tools, and bind the `aida` prompt agent to `prompts/aida.prompt`. The key addition is `a2uix.LoadCatalog(g, AidaCatalog())`, which registers our custom component catalog with the Genkit runtime so the A2UI middleware can expose those components to the model and validate its output. ### Defining the catalog and A2UI middleware In Go, a custom A2UI catalog (`a2uix.Catalog`) is a list of component definitions where each entry specifies a component `Name`, a human-readable `Description`, and its expected `Props`: ```go // internal/agent/catalog.go func AidaCatalog() *a2uix.Catalog { return &a2uix.Catalog{ ID: "aida", Components: []a2uix.CatalogComponent{ { Name: "HudGauge", Description: "A visual meter for capacity and percentage metrics.", Props: "label: string (required); value: number (required); unit?: string; status?: 'nominal' | 'warning' | 'critical'", }, { Name: "ActionButton", Description: "An interactive button that triggers a follow-up diagnostic or remediation action.", Props: "label: string (required); action: string (required); variant?: 'primary' | 'warning' | 'danger'", }, // ... Panel, Scene3D, CyberChart, DataMatrix, TextBlock }, } } ``` On the backend, A2UI runs as a Genkit middleware. Instead of returning plain Markdown chat bubbles, the model emits fenced `a2ui` JSON blocks describing the components it wants to render. The middleware intercepts those blocks on the stream, validates them against the registered catalog, and packages them as `application/a2ui+json` messages for the browser. We wire this pipeline declaratively in `prompts/aida.prompt`: ```yaml --- model: vertexai/gemini-3.8-flash description: AIDA — AI Diagnostic Agent input: schema: os: string tools: - run_osquery - runCommand config: thinkingConfig: thinkingLevel: LOW tools: - googleSearch: {} use: - genkit-middleware/skills: skillPaths: - ./skills - a2ui: catalogId: aida validate: strict --- ``` By default, the `a2ui` middleware reads the component descriptions and property specs from the registered `"aida"` catalog and injects them straight into the system prompt (`instructions: system`). That keeps `internal/agent/catalog.go` as the single source of truth — whenever we tweak a component in Go, the prompt updates automatically without any manual schema maintenance. As the model streams fenced `a2ui` blocks, `validate: strict` checks that every JSON message has a valid A2UI envelope and only uses component types from the `"aida"` catalog. It fails fast if the model invents an unknown component type or emits broken JSON. ## Building the generative frontend Once the Go backend streams validated A2UI messages, the browser needs a client runtime to turn those JSON payloads into live DOM elements. The frontend in `web/` is a lightweight TypeScript and [Lit](https://lit.dev/) app that does three jobs: 1. **Matching the component catalog**: The client registers each of the seven `"aida"` instruments as a Lit web component (`web/src/aida-catalog/`) paired with a client-side prop schema. Because the Go backend only checks component type names, the browser uses this schema to check incoming props, apply defaults, and bind data before rendering. 2. **Rendering surfaces from the stream**: Genkit's client SDK connects to `/agents/aida` and feeds incoming A2UI messages into an A2UI `MessageProcessor`. The processor updates the live `` element on the page as chunks arrive. 3. **Sending user actions back to the agent**: Clicking an action button, table row, or 3D node dispatches an A2UI action. The client forwards that event back to the Go backend as the next turn of the chat: ```ts // web/src/aida-cockpit.ts const agent = remoteAgent({ url: "/agents/aida" }); const chat = agent.chat(); const processor = new MessageProcessor([aidaCatalog], async (action) => { // Forward UI clicks (buttons, table rows, 3D nodes) back to the Go agent const turn = chat.sendStream({ message: actionToMessage(action as any) }); await consumeTurnStream(turn); }); ``` When the cockpit first opens, the left sidebar polls `/api/system-status` for live `osquery` vitals while the main viewport presents quick diagnostic launchers: ![AIDA diagnostic cockpit landing screen with PC-98 pixel avatar, live osquery host status sidebar, and quick diagnostic launcher cards](aida-cockpit-landing.png "AIDA diagnostic cockpit landing screen with live system vitals in the sidebar and quick diagnostic launcher cards.") Clicking a launcher or typing a question sends the prompt to `/agents/aida`. As AIDA streams back `osquery` results, the `MessageProcessor` replaces the launcher view with the live diagnostic surface we saw earlier. ## Human-in-the-loop with agent interrupts Inspecting a machine with `osquery` is strictly read-only, so it's safe for AIDA to run queries automatically. With two-way A2UI actions, however, she can also render an `ActionButton` offering to fix an issue she just found — such as killing a runaway process or clearing a cache. Host shell commands shouldn't run unchecked. To guard these actions, Genkit provides native human-in-the-loop **tool interrupts**: the tool halts the current turn, waits for explicit user confirmation, and only then triggers the requested side effect. ### Pausing and resuming tool execution In Go, we define `runCommand` with `genkitx.DefineInterruptibleTool`. This function takes an optional confirmation payload that indicates whether AIDA is calling the tool for the first time or resuming after user input: 1. **Initial call (pause):** When AIDA first calls `runCommand`, the confirmation payload is `nil`. Instead of running the shell command, the tool pauses the turn by returning `tool.Interrupt(...)` with the proposed command, the reason for running it, and its risk level. 2. **Resumed call (run or reject):** Once the user clicks **Approve** or **Deny** in the UI, Genkit calls the tool function again with the user's decision: ```go // internal/command/tool.go package command func RegisterTool(g *genkit.Genkit) { genkitx.DefineInterruptibleTool(g, "runCommand", ai.WithDescription("Runs a shell command on the host with operator approval"), func(ctx *ai.ToolContext, input CommandInput, res *CommandConfirmation) (CommandOutput, error) { // 1. First call: pause the turn and ask the operator for approval if res == nil { return CommandOutput{}, tool.Interrupt(CommandInterrupt{ Command: input.Command, Reason: input.Reason, Risk: input.Risk, }) } // 2. Resumed call: if denied, return structured feedback instead of an error if !res.Approved { return CommandOutput{ Command: input.Command, Status: "rejected", Stderr: fmt.Sprintf("Operator denied execution: %s", res.Feedback), Executed: false, }, nil } // 3. Approved: run the command with a 30-second timeout execCtx, cancel := context.WithTimeout(ctx.Context, 30*time.Second) defer cancel() out, err := exec.CommandContext(execCtx, "sh", "-c", input.Command).CombinedOutput() // ... return CommandOutput with stdout and exit code }, ) } ``` Because `tool.Interrupt` serializes the `CommandInterrupt` struct (`Command`, `Reason`, and `Risk`) directly into the response stream when halting the turn, the frontend gets everything it needs to prompt the user without any extra API endpoints. ### Approving or denying commands in the cockpit When `runCommand` triggers an interrupt, the streaming turn pauses and the browser receives the interrupt payload on the response object. The cockpit switches AIDA's status badge to `APPROVAL REQUIRED` and renders an inline approval card. That card shows the exact command, reason, and risk level alongside an optional feedback field and **Approve** / **Deny** buttons: ![AIDA cockpit paused on a human-in-the-loop runCommand interrupt showing the proposed shell command, risk badge, feedback input, and approve or deny buttons](aida-hitl-interrupt.png "Cockpit paused on a Genkit tool interrupt, requesting user confirmation before running a shell command.") Clicking either button attaches your decision to the interrupted tool request and calls `chat.resumeStream(...)` on the Genkit client. The Go backend then finishes running (or skipping) the command and streams AIDA's verification dashboard — complete with updated gauges and the command output — straight back into the surface: ![AIDA cockpit after approving the runCommand interrupt, rendering the verification A2UI surface with disk capacity gauges and shell command output](aida-hitl-resumed.png "Resumed execution after approval: AIDA executes the command and streams the verification dashboard into the surface.") ## Conclusions Completing AIDA's rewrite in [`go-aida`](https://github.com/danicat/go-aida) brings the full stack together. Across these two articles, we replaced the old Python and SQLite RAG prototype and static chat window with a single-binary Genkit Go service and a live A2UI diagnostic cockpit. Two design lessons stood out during this phase of the build: 1. **Domain-specific catalogs beat generic UI blocks:** Giving AIDA seven custom diagnostic instruments turned her output from walls of Markdown inside nested cards into a live system dashboard. The catalog acts as the shared contract between both ends: whenever you update a frontend component, you update the Go catalog to match (and vice-versa). In turn, the `a2ui` middleware injects those schemas into the system prompt and enforces `validate: strict` so the model always renders valid, supported instruments. 2. **Tool interrupts keep host fixes safe and interactive:** Genkit's tool interrupts let the Go backend pause before running a shell command and resume cleanly once the user approves or denies the action. Returning structured rejection feedback instead of a fatal error lets the agent adapt and suggest safer options without breaking the chat. --- # Building Visual Tools with Antigravity UI Extensions - **Markdown URL**: https://danicat.dev/posts/building-sidecars-and-ui-extensions-in-antigravity/index.md - **HTML URL**: https://danicat.dev/posts/building-sidecars-and-ui-extensions-in-antigravity/ - **Date**: 2026-10-13 - **Categories**: Agentic Coding - **Tags**: agent-skills, antigravity, gemini, tutorial, vibe-coding - **Summary**: Learn how to use Antigravity UI Extensions in Google Antigravity 2.22.0 to build two-way visual workspaces right inside the side panel. I am usually very grounded in my blog posts, but this time I am *really* excited. Last week Antigravity's team released [Antigravity 2.22.0](https://antigravity.google/releases?tab=hub&version=2.22.0), which includes a new feature that blew my mind: the support for **UI Extensions** in Antigravity plugins. Why is this important? Because it changes the whole developer experience with coding agents for the better. Let's see how. UI Extensions allow you to render custom user interfaces inside Antigravity's side panel. The main reason why this is great is because now we can augment Antigravity's interface with all sorts of features, bringing onboard functionality that by other means we would need separate apps to execute, like working on images, testing software, log mining, and data analysis. For example, imagine that you are designing the interface for your next app. Explaining this in text is often a terrible experience. With a drawing you can be much more precise than text and Gemini, being multimodal, will understand the drawing natively. Before UI extensions I would fire up an image editor or diagram tool like [Excalidraw](https://excalidraw.com/) and make my sketch, then copy and paste it to my prompt. Not a terrible experience per se, but slightly tedious. Now, with UI Extensions, I can have a proper diagramming tool inside Antigravity's UI, and on a click of a button I can send annotated images to my coding agent session, which will immediately react to it. This is just the tip of the iceberg. The capability to annotate your work and explain problems visually, with images, logs, and all sorts of supporting data, right there without leaving Antigravity, is the thing that convinced me that we are finally "living in the future". To prove this to you I have built two open-source plugins with UI extensions that you can test right away: [**Antigravity Paint Studio (`agy-paint-studio`)**](https://github.com/danicat/agy-paint-studio) for multi-layer drawing, editing, and AI-powered image refinement, and [**Antigravity Game Studio (`agy-game-studio`)**](https://github.com/danicat/agy-game-studio) for building and visually debugging 2D games in Go. Let's see how they work under the hood. ## Anatomy of a UI extension A UI extension is a plugin containing a [**sidecar**](https://antigravity.google/docs/sidecars/) that exposes interactive web panels and can be rendered inside the Antigravity interface. Just like normal plugins, it can also bundle skills, MCPs, and rules, allowing you to build complete experiences around a given domain (like image editing). Plugins can be installed either at workspace level (in `.agents/plugins`) or at global level (in `~/.gemini/config/plugins`). Similar to skills, they need to follow a specific directory structure. ```text plugins// ├── plugin.json # Required marker and manifest file ├── mcp_config.json # Optional MCP server definitions ├── hooks.json # Optional hooks definition ├── skills/ # Optional skills directory │ └── / │ └── SKILL.md ├── agents/ # Optional subagent definition templates │ └── .md ├── rules/ # Optional rules directory │ └── .md └── sidecars/ └── / └── sidecar.json # Required for UI extensions ``` While normal sidecars can be built in any language, for UI extensions we will be leveraging [Node.js](https://nodejs.org/) as Antigravity is bundled with a **Sidecar SDK** that simplifies the communications between the agent main process and the sidecar process. Things like port discovery and messaging are handled by the SDK auto-magically. As a side note: it is, in fact, possible to achieve the same experience without using Node.js, the Sidecar SDK, and any of the UI Extensions support, as I have shown in my [State of Agentic Coding talk at Devoxx Belgium](https://www.youtube.com/watch?v=nHRSwiFNQ9E). The problem with the manual approach is that you will end up having to deal with port allocation (and potential conflicts) and session discovery and communication the hard way. The Sidecar SDK makes those things much simpler to do. ![Architecture of an Antigravity UI extension and sidecar](architecture-diagram.png "How Antigravity supervises a plugin's sidecar process and bridges the auxiliary side pane to the active conversation") The entry point is `sidecars//sidecar.json`, which tells Antigravity how to start the background process and render its web UI in the side panel: ```json { "command": "node", "args": ["main.mjs"], "restart_policy": "always", "display_name": "My Extension", "has_web_ui": true, "ui_config": { "views": [ { "path": "/", "entrypoint": "SIDECAR_UI_ENTRYPOINT_AUX_PANE", "title": "My Extension" } ] } } ``` Here we need to highlight the `has_web_ui` attribute and the `ui_config` properties, as they are what turn a regular sidecar into a UI extension. Because `"command": "node"` uses Antigravity's bundled Node.js runtime and `sidecar_sdk`, your extension needs no `npm install` and no `node_modules`. A complete backend in `main.mjs` takes only a few lines of JavaScript: ```javascript import { readFileSync } from 'node:fs'; import { SidecarApp } from 'sidecar_sdk'; const app = new SidecarApp(); // Re-reading on each request lets edits appear when you reopen. app.page('/', () => readFileSync(new URL('./index.html', import.meta.url), 'utf8') ); // POST routes return JSON and verify the sidecar token automatically. app.api('/api/save', (data) => ({ saved: true, name: data.name })); app.run(); ``` On the frontend, adding `` to your HTML `` syncs Antigravity's light and dark theme variables and exposes `window.sidecar` so your UI can call the backend (`fetch()`), inspect the open workspace (`getWorkspaceUris()`), and send messages straight into the active chat turn (`agent.sendMessage()`). Once enabled, you can open the panel from the **⋮** menu under **Extensions**, by clicking any file matching `file_extensions`, or through a clickable `sidecar://` pill in chat. ## Building your own UI extensions the easy way You know, those implementation details are nice when you are in a pinch, but in fact I really hope none of you will be writing those extensions manually. This is because Antigravity is "self-aware": it knows how to build these extensions and you just need to ask it to do it for you. If you want to be very explicit, you can ask it to invoke the skills `/plugin` and `/ui-extension`. If you want to "teach" it about customisations in general, you can also use the `/agy-customizations` skill. This is how I built both "Antigravity Paint Studio" and "Antigravity Game Studio". Let's have a look at both extensions now. ## Editing images without leaving Antigravity with Paint Studio **Paint Studio** started from that exact use case I mentioned in the intro: I wanted to be able to describe my problems using images ("a drawing is worth thousands of words"), but without the context switching. This meant a place inside Antigravity where I could doodle a quick idea from scratch, or click any image in my workspace or chat session to open it in the side panel, scribble some notes on top, and send it back to the agent. ![Paint Studio docked in the auxiliary side pane refining a cat sketch into a watercolour illustration](paint-studio-sidepane.png "Paint Studio docked in the auxiliary side pane, turning a 10-second cat doodle on the left into a watercolour illustration on the right") What makes this setup really fun is that you end up with **AI on both sides** of the workflow. You have your coding agent in the chat panel on the left, and you also have Gemini (in this case, Nano Banana 2 Lite, a.k.a. `gemini-3.1-flash-lite-image`) built directly into Paint Studio on the right. In the screenshot above, I drew that little "smol void" cat in about ten seconds; then, after a couple of refinement steps, Nano Banana 2 Lite turned it into the watercolour painting you are seeing on the side panel. It is worth mentioning that Paint Studio also leverages another interesting UI feature of Antigravity: generative UIs (in fact, [my very first prototype of Paint Studio](https://x.com/danicat83/status/2100298197931372652) was built purely with `/generative_ui`). Generative UIs differ from UI Extensions because they render inline in the chat window. They are activated by the skill `/generative_ui` and you can use them both for creating reusable surfaces (like the ones in Paint Studio), or disposable ones, for example, for one-off data visualisation. [Post on X](https://x.com/danicat83/status/2100298197931372652) Generative UI works by issuing an `` block that renders a 500px-tall sandboxed iframe. You can put **almost** anything in this iframe using inline JavaScript and the host's semantic Tailwind classes (like `bg-[var(--card)]` and `text-[var(--foreground)]`). However, its Content Security Policy (CSP) blocks all external CDNs except for Antigravity's bundled Tailwind script (`https://www.gstatic.com/antigravity/web/dev/tailwindcss.min.js`), and it doesn't allow WebAssembly (WASM) execution. For Paint Studio, I have a specific endpoint that can render either the full UI (for the side panel or detached mode) or a simplified one (for the inline panel via `/generative_ui`). I included in the plugin the skill `/paint-studio` that you can trigger to instruct the agent to open the studio so that we have a handy shortcut for when we need a quick diagram. ![Paint Studio inline canvas rendered via /paint-studio and /generative_ui showing the smol void cat sketch](paint-studio-inline.png "Invoking the /paint-studio skill will load an inline panel via /generative_ui for quick sketches") For even more convenience you can register file types that open with your extensions in `sidecar.json` (like `*.psd`, `*.png` and others) and the app can also be launched with a blank canvas from the extensions menu: ```json { "ui_config": { "display_name": "Paint Studio", "views": [ { "path": "/", "entrypoint": "SIDECAR_UI_ENTRYPOINT_AUX_PANE", "title": "Paint Studio", "file_extensions": [".psd", ".png", ".jpg", ".jpeg", ".webp"] } ] } } ``` This way we have secured bidirectional communication: we can click on any file to open with the studio, do annotations, retouches, and AI improvements, and send to chat with the click of a button. ## Playtesting and debugging 2D games with Antigravity Game Studio Ever since I created my game development skills I have been thinking about how to take this to the next level. Skills, which are basically prompts, can only get you so far. For game development, there is a huge visual component to the development, from art direction, asset creation, level design, the HUD and so many others. After exploring the redesigned experience with Paint Studio I started thinking how we can redesign the developer experience for game developers as well, and this is how I came up with **Antigravity Game Studio**. At first, I tried to bundle it with all sorts of tools, but it ended up in a big ball of mess. So I settled with one functionality that could benefit more from visual input than any of the others: playtesting. The UI extension — **Playtest Studio** — does one thing: it compiles your Go game to WebAssembly and lets you play and debug it live at 60 FPS right inside the side panel. While you play, Playtest Studio records a rolling **10-second replay buffer** (like a DVR). ![Playtest Studio paused in DVR annotation mode](playtest-studio-annotate.png "Pausing a game in Playtest Studio, scrubbing back on the 10-second timeline, and drawing a redline note over a spawn bug") Now, whenever I see something weird happen in the game, the debugging loop takes seconds: 1. I press `F8` (or `` ` ``) to **Pause** and scrub backwards frame by frame on the 10-second timeline to the exact moment the bug happened. 2. I draw a box or arrow directly over the offending sprite, or write a quick note in the margin around the screen. 3. I click **📸 Frame** or **🎞️ 10s GIF** to send the annotated screenshot (or a 10-second animated GIF replay), along with the captured WebAssembly console logs, straight into the chat session for the agent to fix. The studio will send back to the agent a structured message containing all these bits of information so that it can work on the fix right away with much less ambiguity than a text prompt. ![Structured bug report message from Playtest Studio in the Antigravity chat session](playtest-bug-report.png "Screenshot of the moment Antigravity receives a Playtest Studio message with a bug report") You can see this entire workflow in action in the video below, where I open Playtest Studio for my very first look at a 2D Tower Defense game the agent had just finished building, send visual feedback and a 10-second GIF into chat, and rebalance the game's economy on the spot: [YouTube Video](https://www.youtube.com/watch?v=A2zOv5Xoh0M) ## Closing thoughts Being able to build custom visual tools that live right next to your agent — and talk to it with a single click — completely changes what a coding workspace can be. Instead of constantly context-switching to external apps and copy-pasting screenshots into prompts, you can shape Antigravity's interface around whatever problem you are solving today. If you want to take these plugins for a spin or use them as inspiration to ask Antigravity to build your own UI extensions, both repositories are available on GitHub: [**`danicat/agy-paint-studio`**](https://github.com/danicat/agy-paint-studio) and [**`danicat/agy-game-studio`**](https://github.com/danicat/agy-game-studio). Happy sketching! --- # Why I Rebuilt My Python RAG Agent in Go with Genkit - **Markdown URL**: https://danicat.dev/posts/building-agentic-backends-with-genkit-go/index.md - **HTML URL**: https://danicat.dev/posts/building-agentic-backends-with-genkit-go/ - **Date**: 2026-10-06 - **Categories**: Agent Development - **Tags**: agent-skills, ai-agents, gemini, genkit, golang, osquery, rag - **Series**: Gemini for Go Developers - **Summary**: See how I replaced a legacy Python and SQLite RAG agent with a single-binary Go agent using Genkit, typed dotprompt templates, and on-demand Agent Skills. A little over a year ago, I built [AIDA, the AI Diagnostic Agent](https://danicat.dev/posts/20250531-diagnostic-agent/), the very first autonomous agent I ever created. Her mission is to diagnose computer problems by querying the operating system through [osquery](https://osquery.io/). Even though AIDA went through a few updates over the months, I never really questioned her architecture until recently. When I originally built AIDA in Python, models were at a different level of capability (`gemini-2.5-flash` at the time) and techniques like [Agent Skills](https://agentskills.io) had not been invented yet. Because `osquery` exposes hundreds of tables that vary across macOS, Linux, and Windows, I built a [local RAG solution powered by SQLite](https://danicat.dev/posts/20251103-building-aida-part-2/) to feed schema definitions into the context on demand. Even though the dataset was small and static (one table per chunk, embedded as a one-off task), setting up a vector database for it was cumbersome, and the model didn't always formulate the best search queries to the RAG tool, sometimes retrieving the wrong tables or missing a related table completely. It wasn't just the RAG setup, though. The entire codebase of AIDA had been hacked together rather than engineered, using early-stage "vibe coding". I have been saying this for over a decade, and it is even more true today: if your codebase is holding you back, nuke it and start over. Enduring the growth pains of v1 teaches you exactly what to simplify in v2. And while rewrites used to take months of effort and political capital, today you can iterate through three clean architectures before lunch. After [comparing the three main agent libraries in Go](https://danicat.dev/posts/20260825-gemini-for-go-developers-part-3-building-agents/) (the [GenAI SDK](https://pkg.go.dev/google.golang.org/genai), [Genkit](https://genkit.dev), and [ADK](https://adk.dev)), I decided to rebuild AIDA from scratch in Go using **Genkit**. Moving from Python and RAG to Genkit Go let me: 1. **Compile AIDA into a single, self-contained Go binary** that runs either as a local CLI tool or as an HTTP service on Cloud Run. 2. **Replace the custom SQLite RAG pipeline with on-demand Agent Skills**, giving the model the full catalog of OS-specific `osquery` tables without relying on fuzzy search queries. 3. **Enforce compile-time type safety** from prompt inputs to structured outputs using `dotprompt` and Go structs. 4. **Evolve from a single-shot flow to stateful multi-agent delegation**, isolating noisy system telemetry from web research. Let's walk through how each piece of the new architecture works in practice. ## Setting up your coding agent and local workflow Before writing any application code we need two things: the local `genkit` CLI for the development UI and command line utilities, and the Agent Skills that teach your coding agent how Genkit Go works. ### Genkit CLI and Dev UI To install the `genkit` CLI run the official install script: ```sh curl -sL cli.genkit.dev | bash ``` The `genkit` CLI is the backbone of local development in Genkit Go. If you run your program directly with `go run .`, it works as a normal Go binary, but by prefixing your run command with `genkit start --` you will have access to the **Genkit Developer UI** (defaults to `http://localhost:4000`) where you can visually inspect and test the components of your Genkit program individually: ```sh genkit start -- go run . ``` In the Dev UI, you can test registered flows, prompts, and agents interactively and inspect traces for prompts, tool calls, token usage, and latency. Besides the Dev UI, the `genkit` CLI also provides command line helpers for executing flows, visualising traces and browsing the documentation. This last one is particularly useful to prevent model hallucination during the implementation phase. Here are some examples: ```sh # Run a specific flow once from the terminal and exit (--stream or --wait optional) genkit flow:run diagnose '{"userQuestion": "how is my disk usage?"}' --non-interactive -- go run . # Browse and read the embedded Genkit Go documentation offline genkit docs:list go genkit docs:read go/middleware.md ``` ### Recommended skills The gold standard for SDKs nowadays is to ship them with official agent skills and Genkit is no different. If you are developing a Genkit Go program this is the one you are looking for: - [`developing-genkit-go`](https://github.com/genkit-ai/skills): the official Genkit Go skill covering flows, dotprompt, built-in middleware, and experimental agents. Since we are developing with Gemini models, it is also useful to install the `gemini-api-dev` skill: - [`gemini-api-dev`](https://github.com/google-gemini/gemini-skills): the official Gemini skill for current model IDs and thinking configuration. You can install them into your workspace using the Vercel [`skills` CLI](https://github.com/vercel-labs/skills): ```sh npx skills add genkit-ai/skills --skill developing-genkit-go npx skills add google-gemini/gemini-skills --skill gemini-api-dev ``` Or register the catalogs with [`kungfu`](https://github.com/danicat/kungfu) for just-in-time (JIT) loading: ```sh kungfu catalog add genkit-ai/skills kungfu catalog add google-gemini/gemini-skills ``` If you prefer using an MCP server instead of skills, the CLI also ships an [MCP server](https://genkit.dev/docs/go/mcp-server/) (`genkit mcp`). These days I personally have been leaning more towards skills, but MCPs still have value for certain niche use cases (e.g. documentation retrieval). ## Core concepts Genkit is an open-source framework for generative AI applications originally created by the Firebase team before growing into its own project. While its original language is JS, Genkit Go feels surprisingly idiomatic for seasoned Go developers, even if it still has a few rough edges here and there. The typical Genkit program won't be much different than a traditional web service, thanks to adapters like `genkit.Handler` and familiar constructs like middleware and strongly typed flows and prompts. ### Flows The fundamental unit in Genkit is a **flow**: a strongly typed function that wraps generative AI calls along with any pre- or post-processing logic. Let's start AIDA as a single-shot diagnostic flow equipped with a tool to query the operating system via `osqueryi`: ```go type AIDARequest struct { OS string `json:"os,omitempty" jsonschema_description:"The target operating system (defaults to runtime.GOOS)"` UserQuestion string `json:"userQuestion" jsonschema_description:"The diagnostic question to investigate"` } type AIDAResponse struct { Message string `json:"message" jsonschema_description:"The diagnostic findings and recommendations"` } type OsqueryInput struct { Query string `json:"query" jsonschema_description:"The SQL query to execute in osquery."` } g := genkit.Init(ctx, genkit.WithPlugins(&googlegenai.VertexAI{}), genkit.WithDefaultModel("vertexai/gemini-3.8-flash"), ) runOsquery := genkit.DefineTool(g, "runOsquery", "Runs a system inspection query using osquery.", func(ctx *ai.ToolContext, in OsqueryInput) (string, error) { out, err := exec.CommandContext(ctx, "osqueryi", "--json", in.Query).CombinedOutput() if err != nil { return "", fmt.Errorf("osqueryi failed: %w: %s", err, out) } return string(out), nil }, ) diagnoseFlow := genkit.DefineFlow( g, "diagnose", func(ctx context.Context, req AIDARequest) (AIDAResponse, error) { if req.OS == "" { req.OS = runtime.GOOS } resp, err := genkit.Generate(ctx, g, ai.WithSystem(fmt.Sprintf("You are the Emergency Diagnostic Agent for diagnosing %s operating system failures. Your name is AIDA.", req.OS)), ai.WithPrompt(req.UserQuestion), ai.WithTools(runOsquery), ) if err != nil { return AIDAResponse{}, fmt.Errorf("diagnose flow failed: %w", err) } return AIDAResponse{Message: resp.Text()}, nil }, ) ``` `genkit.DefineTool` registers the `runOsquery` tool using the `OsqueryInput` struct to generate its JSON schema, while `genkit.DefineFlow` registers the flow by name with a callback function. If you look at the callback signature, it feels very similar to a typed HTTP handler — it takes a `context.Context` and a strongly typed request struct, and returns a typed response alongside a standard Go `error`. Notice the `g` variable passed as the first argument to `DefineTool`, `DefineFlow`, and `Generate`. `g` is a `*genkit.Genkit` instance, which acts as the central registry for all flows, prompts, tools, models, and middleware in your program. You initialise `g` once in `main` with `genkit.Init` and pass it explicitly. I'll be honest, I'm not a huge fan of `genkit.WithDefaultModel`, as I prefer code to be explicit, but it does help reducing a bit of boilerplate if you are defining many flows. We are going to see a better option to hardcoding models in flows when discussing prompts. `genkit.Init` sets up both the runtime registry and the observability stack. Every model and tool call inside a flow is automatically recorded as a child span under the flow's trace. If your flow also performs custom pre- or post-processing — like querying a database or calling an external API — wrapping that logic in `genkit.Run` records it as its own named sub-step in the trace waterfall. For development purposes you can run a flow via the CLI or in the Dev UI. In production, you may invoke flows as a regular Go function call (e.g. `myFlow.Run`), or exposed as an HTTP endpoint. Turning a flow into an HTTP handler takes a single line with `genkit.Handler`: ```go mux := http.NewServeMux() mux.HandleFunc("POST /diagnose", genkit.Handler(diagnoseFlow)) port := os.Getenv("PORT") if port == "" { port = "3400" } // Note: "server" requires the server plugin: // import "github.com/firebase/genkit/go/plugins/server" log.Fatal(server.Start(ctx, "0.0.0.0:"+port, mux)) ``` If you want to expose every flow registered in your application at once, you can iterate over `genkit.ListFlows(g)`: ```go mux := http.NewServeMux() for _, flow := range genkit.ListFlows(g) { mux.HandleFunc("POST /"+flow.Name(), genkit.Handler(flow)) } log.Fatal(server.Start(ctx, "0.0.0.0:"+port, mux)) ``` Please note that `genkit.Handler` performs no authentication of its own, so wrap public endpoints in your standard `net/http` auth middleware. ### Prompts Hardcoding prompts and model configuration in the flow code works for small projects, but it can get hard to maintain them as the project grows. Even if coding agents are doing most of the heavy work these days, we should not throw away years of engineering best practices, and code organisation is one of them. Genkit has support for prompt templates using a format called [dotprompt](https://genkit.dev/docs/go/dotprompt/) (`*.prompt` files). Prompts in `dotprompt` may contain more than just the instructions for the model; for example, they may include metadata for selecting and configuring the model, schema definitions, system instructions, and conversation history. Extracting AIDA's prompt, tools, and model configuration out of `diagnoseFlow` into `prompts/aida.prompt` looks like this: ```markdown --- model: vertexai/gemini-3.8-flash tools: - runOsquery config: thinkingConfig: thinkingLevel: low input: schema: os: string userQuestion: string output: format: json schema: message: string --- {{role "system"}} You are the Emergency Diagnostic Agent for diagnosing {{os}} operating system failures. Your name is AIDA. {{role "user"}} {{userQuestion}} ``` By default, `genkit.Init` automatically loads `.prompt` files from `./prompts` in your working directory. If you prefer to distribute your agent as a single binary, you can embed your `prompts` directory directly into the binary with `//go:embed` and pass it to `genkit.WithPromptFS`: ```go import "embed" //go:embed prompts/* var promptsFS embed.FS func main() { g := genkit.Init(context.Background(), genkit.WithPromptFS(promptsFS)) // ... } ``` You can look up prompts in code via `genkit.LookupPrompt` and invoke them with `myPrompt.Execute`: ```go aida := genkit.LookupPrompt(g, "aida") resp, err := aida.Execute(ctx, ai.WithInput(map[string]any{ "os": runtime.GOOS, "userQuestion": "how is my disk usage?", })) ``` If you have been writing Go for a while, that `map[string]any` probably bothers you as much as it bothers me. Fortunately, Genkit lets us bind the `AIDARequest` and `AIDAResponse` structs we defined earlier directly to `aida.prompt` using **data prompts** (`genkit.LookupDataPrompt`) and `genkit.DefineSchemaFor`: ```go // In prompts/aida.prompt, reference the Go types by name: // // input: // schema: AIDARequest // output: // schema: AIDAResponse // // Register both schemas with Genkit so the prompt file can resolve them: genkit.DefineSchemaFor[AIDARequest](g) genkit.DefineSchemaFor[AIDAResponse](g) aida := genkit.LookupDataPrompt[AIDARequest, *AIDAResponse](g, "aida") resp, _, err := aida.Execute(ctx, AIDARequest{ OS: runtime.GOOS, UserQuestion: "how is my disk usage?", }) // resp is of type *AIDAResponse // Note: The second return value is the raw *ai.ModelResponse ``` With `DefineSchemaFor` and `LookupDataPrompt`, you define your schema once in Go and keep full compile-time type safety from input to parsed output. ### Plugins You extend Genkit with packages under the `plugins/` namespace. While the most common case for a plugin is to register a model family, we also have plugins for broader capabilities like `server` and `middleware`. `server` is a special case in the sense it doesn't need to be registered to be useful, but most plugins will be initialised in the `genkit.Init` call: ```go import ( "github.com/firebase/genkit/go/genkit" "github.com/firebase/genkit/go/plugins/googlegenai" "github.com/firebase/genkit/go/plugins/middleware" ) g := genkit.Init(ctx, genkit.WithPlugins( &googlegenai.VertexAI{}, // Gemini via Vertex AI &middleware.Middleware{}, // Registers Retry, Fallback, Skills, etc. ), ) ``` The concept of middleware should be familiar to most Go developers, but it is important to single out this plugin as it is the gateway for critical capabilities like fallbacks, retries and agent skills. Let's have a deeper look at it next. ## Replacing SQLite RAG with Agent Skills middleware AIDA can already execute `osquery` commands through the `runOsquery` tool we defined earlier, but she still relies on the model's general training weights to guess which tables and columns exist on each operating system (`darwin`, `linux`, and `windows`). In the legacy Python version of AIDA, I solved this with a local SQLite RAG database where each chunk held a single table definition. While the dataset was small and static, the RAG approach was cumbersome to build and had a subtle flaw: `gemini-2.5-flash` had to guess what to search for first, and when its RAG query was slightly off, it would pull the wrong tables or miss a related table completely. For the Go rewrite, I deleted the RAG pipeline and replaced it with [Agent Skills](https://agentskills.io). By pointing Antigravity at the [osquery repository](https://github.com/osquery/osquery), I generated three OS-specific skills (`darwin`, `linux`, and `windows`) under `./skills`, each containing the complete catalog of tables and schemas for that platform. Instead of hoping a vector search returns the right table, AIDA simply activates the skill for the current OS and gets the full schema reference on demand. Genkit attaches middleware to generation calls using `ai.WithUse`, and the `plugins/middleware` package provides built-in implementations for retries, model fallbacks, and [Agent Skills](https://agentskills.io): ```go resp, err := genkit.Generate(ctx, g, ai.WithSystem(fmt.Sprintf("You are AIDA, a %s operating system diagnostic assistant.", runtime.GOOS)), ai.WithPrompt("My machine is running out of file descriptors. How do I inspect open files with osquery?"), ai.WithTools(runOsquery), ai.WithUse( &middleware.Retry{MaxRetries: 3, InitialDelayMs: 1000, BackoffFactor: 2}, &middleware.Fallback{ Models: []ai.ModelRef{ googlegenai.ModelRef("vertexai/gemini-3.5-flash-lite", nil), }, }, &middleware.Skills{ SkillPaths: []string{"./skills"}, }, ), ) ``` Like HTTP middleware, `ai.WithUse` composes in order: placing `Retry` before `Fallback` retries the primary model before falling back to the secondary model. When you point `&middleware.Skills{}` at `./skills`, Genkit scans each subdirectory, injects the skill descriptions into the system prompt, and registers a `use_skill` tool so AIDA can load the right OS schema and query patterns on demand. Please note that, unlike `dotprompt`, at the moment the `Skills` middleware does not support embedding filesystems, so you still need to resort to bundling a skills directory into your deployment. Because we registered `&middleware.Middleware{}` during `genkit.Init`, we can also declare these middlewares directly inside `prompts/aida.prompt`: ```yaml --- model: vertexai/gemini-3.8-flash tools: - runOsquery use: - name: genkit-middleware/retry config: maxRetries: 2 - genkit-middleware/skills --- ``` For advanced use cases, you can also write custom middleware by implementing the `ai.Middleware` interface to intercept and wrap generation, model, or tool calls with your own logic. ## The developer UI You can find the complete code up to this point in the companion repository under [`github.com/danicat/gemini-for-go-developers/part-4/01-flow`](https://github.com/danicat/gemini-for-go-developers/tree/main/part-4/01-flow). To test AIDA in the Genkit Developer UI, launch the application from that directory with `genkit start`: ```sh export GOOGLE_CLOUD_PROJECT=your-project-id export GOOGLE_CLOUD_LOCATION=global # required for Gemini 3.x genkit start -- go run . ``` Open `http://localhost:4000`, select the **diagnose** flow from the sidebar, and run it with the following test payload: ```json { "userQuestion": "what is my battery status?" } ``` Here is the output of this prompt on my machine (observe the trace on the right panel showing that the skill was activated): ![Genkit Developer UI running the diagnose flow](https://danicat.dev/posts/building-agentic-backends-with-genkit-go/image-1.png) ## Stateful agents (experimental) I typically like to stay clear of experimental features, but this one is an exception. While it's perfectly possible to implement agents based on flows alone, developing more complex patterns — like chatbots, serial agents and parallel agents — does take a bit of effort crafting the right patterns using goroutines, synchronisation, error handling and others. The introduction of the agent abstraction simplifies this process by providing us with a much more ergonomic API focused on the declarative aspect of agent creation instead. ### Defining an agent Because the API is still experimental, you need to enable it with `genkit.WithExperimental()` when initialising Genkit. Here is how we turn AIDA into a stateful, multi-turn terminal agent backed by a session store: ```go import ( aix "github.com/firebase/genkit/go/ai/exp" "github.com/firebase/genkit/go/ai/exp/localstore" genkitx "github.com/firebase/genkit/go/genkit/exp" ) g := genkit.Init(ctx, genkit.WithExperimental(), genkit.WithPlugins(&googlegenai.VertexAI{}, &middleware.Middleware{}), ) store := localstore.NewInMemorySessionStore[struct{}]() aida := genkitx.DefineAgent(g, "aida", aix.InlinePrompt{ ai.WithModelName("vertexai/gemini-3.8-flash"), ai.WithSystem(fmt.Sprintf("You are AIDA, a %s operating system diagnostic assistant. Use runOsquery to inspect the host.", runtime.GOOS)), ai.WithTools(runOsquery), ai.WithUse(&middleware.Skills{SkillPaths: []string{"./skills"}}), }, aix.WithSessionStore(store), ) // Interactive terminal session maintaining state across turns scanner := bufio.NewScanner(os.Stdin) var sessionID string fmt.Print("AIDA> ") for scanner.Scan() { input := strings.TrimSpace(scanner.Text()) if input == "" || input == "exit" { break } var opts []aix.InvocationOption[struct{}] if sessionID != "" { opts = append(opts, aix.WithSessionID[struct{}](sessionID)) } out, err := aida.RunText(ctx, input, opts...) if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Print("AIDA> ") continue } sessionID = out.SessionID fmt.Printf("\n%s\n\nAIDA> ", out.Message.Text()) } ``` Storing `out.SessionID` and passing it into subsequent turns preserves the conversation history across the session. The `[struct{}]` type parameter simply tells Genkit that we only want to persist the message history without attaching custom session state. The same techniques used to improve the code around flows can be used with agents. To avoid hardcoding the prompts, you can use `genkitx.DefinePromptAgent` to bind `prompts/aida.prompt` directly to the agent. To expose AIDA over HTTP so a remote frontend can connect to it, `genkitx.AllAgentRoutes` mounts turn, snapshot, and abort endpoints onto standard Go routers: ```go mux := http.NewServeMux() for _, route := range genkitx.AllAgentRoutes(g) { mux.HandleFunc(route.Pattern(), route.Handler()) } ``` You can find the complete stateful agent implementation in the companion repository under [`github.com/danicat/gemini-for-go-developers/part-4/02-agent`](https://github.com/danicat/gemini-for-go-developers/tree/main/part-4/02-agent). ### Multi-agent delegation There are cases where you will need more than one agent to perform a task. Benefits of multi-agent architecture include separation of concerns — to improve agent attention and reduce context rot — and parallelisation — to improve response times. Using the experimental `middlewarex.Agents` middleware in Genkit, we can split AIDA's investigative work between two specialists while keeping `aida` as the main coordinator persona: - **`inspector`**: Focuses exclusively on querying host telemetry via `runOsquery` and the OS-specific `osquery` skills. - **`researcher`**: Uses Google Search grounding to look up unfamiliar processes, binaries, error messages, and CVEs on the web without bloating AIDA's main conversation history. ```go // requires: // import ( // "google.golang.org/genai" // middlewarex "github.com/firebase/genkit/go/plugins/middleware/exp" // ) inspector := genkitx.DefineAgent(g, "inspector", aix.InlinePrompt{ ai.WithModelName("vertexai/gemini-3.8-flash"), ai.WithSystem(fmt.Sprintf("Inspect %s system telemetry using osquery and report diagnostic findings.", runtime.GOOS)), ai.WithTools(runOsquery), ai.WithUse( &middleware.Retry{MaxRetries: 5}, &middleware.Skills{SkillPaths: []string{"./skills"}}, ), }, aix.WithDescription[struct{}]("Inspects host metrics, running processes, and system configuration using osquery."), ) researcher := genkitx.DefineAgent(g, "researcher", aix.InlinePrompt{ ai.WithModelName("vertexai/gemini-3.8-flash"), ai.WithSystem("Research unfamiliar processes, binaries, error messages, and CVEs using Google Search and report concise findings."), ai.WithConfig(&genai.GenerateContentConfig{ Tools: []*genai.Tool{ {GoogleSearch: &genai.GoogleSearch{}}, }, }), }, aix.WithDescription[struct{}]("Searches the web for information on unfamiliar processes, binaries, error codes, and CVEs."), ) aida := genkitx.DefineAgent(g, "aida", aix.InlinePrompt{ ai.WithModelName("vertexai/gemini-3.8-flash"), ai.WithSystem(fmt.Sprintf("You are AIDA, a %s operating system diagnostic assistant. Delegate host inspection to inspector and web research to researcher, then synthesize findings for the user.", runtime.GOOS)), ai.WithUse( &middleware.Retry{MaxRetries: 5}, &middlewarex.Agents{ Agents: []aix.AgentRef{inspector.Ref(), researcher.Ref()}, MaxDelegations: 5, }, ), }, aix.WithSessionStore(store), ) ``` Delegation runs synchronously by default, though you can set `Async: true` on `middlewarex.Agents` if you want AIDA to kick off long-running specialist tasks in the background and join their results across turns. One thing to keep in mind is the latency trade-off. When I timed the exact same `"what is my battery status?"` query against both versions (with `thinkingLevel` set to `low`), the single-agent version (`02-agent`) finished in **9.56s** across 3 LLM calls, while this multi-agent version (`03-multi-agent`) took **17.02s** across 5 LLM calls. The per-call latency is basically identical (~3.3s), so the extra ~7.5 seconds come purely from the two extra coordinator round-trips as `aida` delegates to `inspector` and then summarises its report. Much like in parallel programming, the coordination overhead for a small *N* can make a simple task slower. For a toy query, a single agent is faster; in a real-world system, however, splitting an "uber-agent" into focused sub-agents is often worth those extra round-trips to isolate noisy tool outputs, optimise context usage, and improve overall response quality. You can find the complete multi-agent example in the companion repository under [`github.com/danicat/gemini-for-go-developers/part-4/03-multi-agent`](https://github.com/danicat/gemini-for-go-developers/tree/main/part-4/03-multi-agent). ## Deploying to production ### Runtimes Because a Genkit Go application compiles to a standard Go binary using `net/http`, there is no proprietary runtime lock-in. This means that basically any platform you use to host your HTTP servers is fair game for Genkit apps. My first preference is always a serverless container runtime. At Google, I use primarily **Google Cloud Run**, only choosing more complex platforms (like **Google Kubernetes Engine (GKE)** or **Gemini Enterprise**) when I hit a critical blocker that requires a feature that Cloud Run doesn't give to me. It is also worth saying that in the past these types of decisions used to be more important than they are today. Replatforming took a lot of effort, so people had the tendency of wanting to "get it right" from the beginning. With coding agents we can adapt an app to a new platform in a matter of days (if not hours), so there are no more excuses to add complexity early to a project. This is the theory. In practice, over the last year I never found a case that I couldn't handle with Cloud Run. It is simply that convenient! The main exception is when you are building heavily around [ADK](https://adk.dev) and want managed session persistence and enterprise connectors out of the box, where the Gemini Enterprise Agent Platform (which we covered in [Building AI Agents in Go](https://danicat.dev/posts/20260825-gemini-for-go-developers-part-3-building-agents/#gemini-enterprise-agent-platform-managed-sessions-and-enterprise-rag)) offers strong built-in synergies. Now, typically agents — especially Gemini-based ones like Antigravity — don't need help deploying to Cloud Run. But if your agent is being grumpy, you can always install the Cloud Run skill from [github.com/google/skills](https://github.com/google/skills/tree/main/skills/cloud/cloud-run-basics) to give it a nudge. ### Observability During local development, `genkit start` records full execution traces and structured logs in `.genkit/traces` (which you should add to `.gitignore`) so you can inspect them in the Dev UI. In production, you want that same visibility into token usage, latency, and step-by-step traces in your monitoring backend. Since Genkit is built on [OpenTelemetry](https://opentelemetry.io/), you have two main options: if you are deploying to Google Cloud, the `googlecloud` plugin (or the `firebase` plugin, which runs the same exporter under the hood) sends everything to Cloud Logging, Cloud Trace, and Cloud Monitoring. If your team uses another backend like Datadog, Grafana, or Honeycomb, you can attach a standard OTLP exporter directly to the Genkit tracer provider instead. For a typical Cloud Run deployment, enable the Google Cloud exporter before calling `genkit.Init`: ```go import "github.com/firebase/genkit/go/plugins/googlecloud" func main() { ctx := context.Background() googlecloud.EnableGoogleCloudTelemetry(&googlecloud.GoogleCloudTelemetryOptions{ // Raw prompts and responses are logged by default; disable this for sensitive data: DisableLoggingInputAndOutput: true, // Set to true if you want to test Cloud export locally under `genkit start`: ForceDevExport: false, }) g := genkit.Init(ctx, // if you are deploying to GCP, auth via Gemini Enterprise (aka Vertex AI) is preferred genkit.WithPlugins(&googlegenai.VertexAI{}), ) // ... } ``` If you prefer an OTLP backend instead, register a standard OpenTelemetry span processor before `genkit.Init` and `defer` its shutdown function: ```go import ( "context" "log" "github.com/firebase/genkit/go/core/tracing" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" sdktrace "go.opentelemetry.io/otel/sdk/trace" ) func registerOTLP(ctx context.Context) func(context.Context) error { // Configured via standard OTEL_EXPORTER_OTLP_* environment variables. exp, err := otlptracegrpc.New(ctx) if err != nil { log.Fatalf("failed to build OTLP exporter: %v", err) } tp := tracing.TracerProvider() tp.RegisterSpanProcessor(sdktrace.NewBatchSpanProcessor(exp)) return tp.Shutdown } ``` ## Conclusions Rebuilding AIDA from scratch is a great reminder of how fast AI engineering has matured over the past twelve months. What previously required a custom SQLite RAG pipeline, a local embedding model, and hundreds of lines of fragile Python glue code (a criticism of my own early vibe coding, not of Python itself) now compiles into a single, type-safe Go binary powered by Genkit and on-demand Agent Skills. Moving to Genkit Go gave AIDA compile-time schema validation with `dotprompt`, built-in retries and skill injection through middleware, and clean multi-agent delegation, while keeping the snappy startup and deployment simplicity of a standard Go HTTP service. Paired with a fast model like Gemini 3.8 Flash, the difference in responsiveness compared to the legacy version is night and day. In this article we focused on AIDA's core runtime and backend architecture, reserving the frontend (using A2UI and generative interaction design) for a follow-up piece. If you are planning to build agents in Go, or if you have an early-stage RAG prototype that is starting to show its age, I highly recommend giving Genkit a try. --- # The Pragmatic Guide to Agent Skills - **Markdown URL**: https://danicat.dev/posts/the-pragmatic-guide-to-agent-skills/index.md - **HTML URL**: https://danicat.dev/posts/the-pragmatic-guide-to-agent-skills/ - **Date**: 2026-08-29 - **Categories**: Agentic Coding - **Tags**: agent-skills, antigravity, gemini-cli, vibe-coding - **Summary**: A pragmatic guide to creating custom Agent Skills for coding agents: explore real-world use cases across documentation, process automation, and agent traits. It might be hard to believe, but the [**Agent Skills**](https://agentskills.io) standard is not even one year old. The [original blog post by Anthropic](https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills) was published in October 2025, first introducing Agent Skills as a Claude Code extension before it became a proper standard in December 2025. The standard proved to be so useful that in a very short time it was adopted by all the major coding agents and agent development frameworks to equip AI models with modular instructions, deterministic scripts, and specialized domain workflows. Fast-forward to today, most developers are familiar with skills and the advantages of the progressive disclosure model, but are they using and creating skills effectively? In this article, we are going to explore the main challenges of dealing with skills, from discovery and provenance to development and optimization. All the skills and examples mentioned in this text were tested using Antigravity CLI and Gemini 3.7 Flash, but I encourage you to experiment with them even if you use a different harness and/or model. Without further ado, let's dive deep into the world of Agent Skills! ## Creating skills vs consuming skills The evergreen software engineer's dilemma: should I build my own or procure something ready? There are many approaches to this problem, but I'm going to share my pragmatic view: ```goat +-----------------------------------------+ | Is this process specific to your repo | | or codebase? | +-----------------------------------------+ | | Yes | | No | v | +-----------------------+ | | Do you know a skill |<-------------------+ | | that does the job? | | | +-----------------------+ | | | | | | Yes | | No | | v v | | +--------------------+ +------------------+ | | | Passes security & | | Spent too much | | | | integrity checks? | | time looking? | | | +--------------------+ +------------------+ | | | | | | | | Yes | No | Yes | No | | | | | | v | | | | | +---------------+ | | | | | | Search for | | | | | | | a skill | | | | | | +---------------+ | | | | | | | | | v v +------------+ | | +------------------+ | | | Build your own | +--------------------->| skill | | +------------------+ | | v v +-----------------------------------+ | Profit! | +-----------------------------------+ ``` OK, I admit the algorithm is a bit messy, but the bottom line is: unless you have an authoritative source for the skill, or absolute trust that you are not exposing yourself to an attack vector, it is better to build your own. The effort of building skills is quite low, so there are very few circumstances where I would advise you against it. The main one would be if you lack the domain knowledge to judge the quality of the skill, as this poses a big risk of creating an echo chamber of bad behaviour. If you know what you are doing, building skills is the ultimate hack to unlock developer productivity with coding agents, and this is why I believe that every serious developer should master the skill of creating skills (pun intended). ## Mastering the skill of creating skills The key to creating useful skills is all about observing agent patterns. You might not be able to come up with a skill after a single coding session, but the more you use agents and the more you observe how they behave, the more you will notice knowledge and behavioural gaps that can be filled with custom skills. This will become more clear as I present you with some examples, but before I can talk about them, let's have a look at the different types of skills we can create: ### Level 1: Documentation A skill that explains a particular piece of technology, augmenting or superseding the model's built-in knowledge. It is very common, for example, that models recommend obsolete libraries or SDKs, or use outdated coding patterns, just because those were prevalent in their training data. Using skills to update the model's knowledge about a piece of software is one of the most basic uses of the technology. The typical format of a documentation skill is a single `SKILL.md` file with the updated knowledge, showing command lines, snippets of code, and external URLs. Depending on the complexity of the domain, splitting the `SKILL.md` file into separate reference and/or asset files is ideal to maximise the progressive disclosure potential. Create this type of skill when you see your agent using obsolete patterns, outdated libraries, old SDK versions, or when it repeatedly generates sub-optimal implementations. Most manufacturer-provided skills fall into this category, like the [Google Skills](https://github.com/google/skills) repository that includes skills for most Google products. An example of a documentation skill that I created is [ebitengineer](https://skills.danicat.dev/game-dev/ebitengineer/). This skill was designed after I noticed repeated problems while building 2D games with Ebitengine, like using the incorrect order of matrix operations, poor modularisation, lack of state management, abuse of debug text instead of production fonts and so on. ### Level 2: Process A skill that enforces a specific process or workflow, such as [performing a code review](https://danicat.dev/posts/20260303-code-reviews-in-2026/), conducting a security audit, or analysing performance metrics. This type of skill usually bundles not only knowledge but custom scripts or CLI tools to perform tasks, saving the model the effort to build them from scratch every single time you need them. You should create process skills whenever you see yourself instructing the model to do the same task over and over, especially if there are deterministic steps that can be scripted. A few process skills that I use a lot belong to the [analytics](https://skills.danicat.dev/analytics) skill set to collect and analyse data from social networks, Google Analytics, and Google Search Console. Optimising my online presence is an important part of my job (there is little point in producing content that no one sees), so I devised those skills to automate the tasks that I was doing manually by inspecting each platform's analytics website one-by-one. Now I have a "command centre" powered by those skills, saving precious time that I can dedicate to producing more and better content. Look at your own process. What are the things that you do repeatedly during your work day, week or month? Create skills for those to save you precious time, improve the quality of your work, or both! ### Level 3: Traits A trait skill is one that affects the agent's personality and/or mode of operation. One famous skill in this category is Matt Pocock's [`/grill-me`](https://github.com/mattpocock/skills). It got so popular that most agentic harnesses bundle one version of it out-of-the-box. The purpose of this skill is to encourage the agent to extract knowledge from the user, filling gaps that otherwise would be guessed or, even worse, hallucinated. A few of my favourite trait-based skills are: [swarm-coding](https://skills.danicat.dev/agents/swarm-coding), [double-diamond](https://skills.danicat.dev/agents/double-diamond) and [uno-reverse](https://skills.danicat.dev/agents/uno-reverse). Swarm coding is the first skill I wrote to tackle orchestrating subagents. I wrote about the whole process of creating it in [The Rise of the Subagents](https://danicat.dev/posts/20260722-the-rise-of-the-subagents/), so I encourage you to check that article if you are curious about it. Double diamond is the spiritual successor of swarm coding, but includes an inception and a discovery phase. Readers familiar with Agile methodologies will recognise this technique. It is based on the principle of successive phases of divergence and convergence. You diverge to explore the problem space, and then converge into a solution. For example, when planning a major feature across this site, I can kick off the entire exploration with: ```text /double-diamond I would like to plan phase 3 of this website. I want to improve the visualization of skill cards, add metrics, and a like/star button so we can rank skills by popularity. ``` Uno reverse is my own take on having an agent with an adversarial personality, just for the sake of breaking the echo chamber of the agents doing the implementation. The adversarial agent will criticise the implementation and look for opportunities to cut down the bloat, acting as a counter-measure to overengineering. I often use it as a sanity check during architectural reviews: ```text Please run a /double-diamond process to refine ADR-0002. At the review phase, use an /uno-reverse agent to antagonise the proposal and present the results. ``` As you can see, these skills can be pretty powerful on their own, but they can get even more interesting when combined with each other. For example, I have lots of fun using `/grill-me` + `/double-diamond` + `/uno-reverse` on the same prompt. This makes the model ask me clarifying questions, go on a research phase to find different implementations and then criticise them to find the optimal solution. As for the inspiration to create these skills, look for any moments in your career where you learned a (human) skill that helped you or your team improve the quality of your work. Especially since the advent of subagents, we are seeing more and more the use of managerial skills being applied to AI, and this is no coincidence. AI just happens to be as unreliable as we are when left to its own devices. ## The continuous improvement loop Creating skills is great, but you will only really unlock the full value of skills if you are continuously improving them. My process is to do a glow-up of my skills almost every time I use them, because I often discover rough edges, gaps and bugs when I use them in a new session. Another excuse to give them a glow-up is when a new model is released, as you would assume the new model will be more capable and upgrade the skill to the best of its capabilities. I do this so often that I created a [`/skill-optimizer`](https://skills.danicat.dev/agents/skill-optimizer) skill. This skill is based on the best practices published at [agentskills.io](https://agentskills.io), plus a little of my own taste. It ensures skills have a good progressive disclosure model, using a supporting script to estimate token counts and keep each section in a sensible size. It also optimises the skill description focusing on benefits instead of implementation details. For example, before optimisation my [search-analytics](https://skills.danicat.dev/analytics/search-analytics) skill was full of bloat on how search worked using algorithm X or Y, but this matters very little for the usability of the skill and it was a waste of space. The relevant information in this case is how to load and query the data. Everything else is wasting tokens. Besides creating skills and optimising them on your own, there is also an automated way of doing this. It is a concept called **agent dreaming**: the process of extracting lessons from past agent sessions. In Antigravity, you can ask it to dream by using the `/learn` slash command. When you start a learning session the agent will explore the conversation history for patterns and look for ways where it can improve itself, giving you options to update context files, add or tune configuration guardrails, or generating custom skills. For example, after a long debugging sprint, you could simply run: ```text /learn look at our debugging sessions with Ebitengine matrix transforms and state management. Extract the recurring bugs and fixes into a new documentation skill with clear anti-patterns and examples. ``` ## Activation: explicit is better than implicit I've spent more time than I would like to admit trying to fine tune descriptions to ensure skills are always activated when the model needs them. I failed in all my attempts so far. You can get to a point where models activate them more often than not, but in my experience perfect activation rates are still unachievable today. This is why I'm importing an old adage from the Zen of Python and changing my own ways of working: explicit is better than implicit. Instead of waiting for the good will of the agent to invoke the skill I want, I tell it exactly what I want invoked and when. In Antigravity we also have the benefit of the automatic mapping of skills to slash commands, so when I type `/swarm-coding` it immediately invokes the swarm coding skill. Slash notation turns skills into slash commands when they are the first word in a prompt, but this notation also works in the middle of prompts as hints to the model that whatever is prefixed by a slash might be a skill name. For instance, you can append `/grill-me` at the end of your prompt and the agent will trigger the `grill-me` skill as well even if technically it is not a slash command. In recent versions of the Antigravity CLI I even noticed that the harness occasionally adds `/grill-me` automatically to the end of my prompts when my commands are relatively complex or ambiguous. ## The problem of too many skills Once you embrace the world of skills, you will eventually get into this situation. As much as the progressive disclosure model is great, it will still suffer from the same fate of context bloat as MCP servers if you keep installing more and more. There are two ways of improving this, one that requires active work from you and the other that employs a "higher level of abstraction". The first one is simple: do not install skills globally unless absolutely necessary. Skills can either be installed for your user (they will live in your home folder, usually at `~/.agents/skills`), or to a workspace (project level, in `/.agents/skills`). This solution works at a small to medium scale, but starts posing problems if you have lots of projects with lots of skills in them. How do you keep them all up to date? How do you ensure an improvement in one skill is reflected across all other clients of that skill? Also, should `.agents` be committed with the repo or not? I think the optimal solution to this problem is a combination of centralisation (or federation) and tracking provenance. The first part of the solution is a skill catalog. While typical skill sharing relies on git clones or copy-pasting folders, the official Agent Skills standard leaves remote discovery and distribution open to tooling. To solve this, I created an online catalog manifest for `skills.danicat.dev` that exposes an index over the web. You can inspect the live catalog manifest here: [https://skills.danicat.dev/catalog.json](https://skills.danicat.dev/catalog.json). A skill catalog solves the problem of discoverability of skills. The idea is that you can point your agents to the catalog and they will be immediately aware of the available skills without needing to install them. This doesn't solve the context bloat problem though, unless you have a proper "find skill" procedure that skips loading the entire catalog every time you are looking for a new skill. The catalog solves one direction of the information flow - from catalog to skill - but keeping skills up-to-date requires the reverse direction - from skill to catalog. This is what I have been calling "skill provenance": the source of the skill. The agent skill specification is not prescriptive about provenance, but there are two ways of managing this: 1) an external skill manager that tracks the source of every skill you install, or 2) adding catalog information to the skill metadata. I believe the second option is more elegant as it is "declarative": no need to figure out what magic is happening under the hood, what you see is what you get. This is a typical example of frontmatter in my skill catalog: ```yaml --- name: swarm-coding description: > Orchestrates multi-agent hierarchical swarms using a divide-and-conquer architecture for complex, multi-system, or orthogonal engineering initiatives (e.g., concurrent backend, frontend, database, QA). Manages hierarchical Lead Agents and Specialists, disjoint work allocations, and strict parent-child communication. Activate whenever the user mentions 'swarm', requests multi-agent team coordination, or needs context isolation across multiple technical domains. license: Apache-2.0 metadata: category: agents tags: "swarm, subagents, parallel, orchestration, strategy, complexity, coordination" author: Daniela Petruzalek (daniela@danicat.dev) version: "0.2.0" catalog: https://skills.danicat.dev --- ``` `name`, `description`, `license` and `metadata` are the standard fields defined by the spec. While `metadata` is loosely defined, I decided to follow the recommendation of `version` and `author` as standard fields. My own extensions are `category`, `tags` and `catalog`. Category and tags help me with discoverability and classification, while with `catalog` I can track provenance. This makes it particularly simple to implement a skill manager that keeps my skills up to date: they just need to hit the catalog at the given address and check if the skill version has been updated or not. If you assume that agents will only eventually (as opposed to every session) browse the catalog to cherry pick and install skills, we might overlook the context bloat of loading the entire catalog, but in my mind the ideal scenario would allow a slightly smarter dynamic. Enter `kungfu`. ## "I know kung fu." [`kungfu`](https://github.com/danicat/kungfu) is the CLI tool I developed to help me manage my skills. The name comes from the classic scene in *The Matrix* where Neo has Kung Fu (the martial art) "installed" into his brain. This is the dream I had for my agents: to install on-demand knowledge at any point in time. I have a `kungfu` skill that teaches the agent how to use the `kungfu` CLI and this is the only skill I need. If the agent needs a new skill, they can use `kungfu find` to search the catalog based on name, category, tags or description, with some leeway for typos using Levenshtein distance and "did you mean?" hints. Find will return a list of best matches ranked by score, and if the agent needs any of the skills listed, it can either load them just-in-time (JIT) via `kungfu load` or learn it permanently via `kungfu learn` (installing either globally or to the workspace). The image below shows the JIT loading flow: ![Terminal screenshot showing kungfu JIT loading the seo-optimizer skill](image.png "kungfu JIT loading the seo-optimizer skill") `kungfu` will keep track of all the skills it installs and you can check for the available skills (installed or online) via `kungfu list`. `kungfu update` takes care of updating skills that have newer versions server-side, while providing some guardrails to not override local customisations. Since `kungfu` relies on this `catalog.json` convention and my frontmatter metadata extensions, it requires registries to expose a catalog manifest or skills to declare their origin. To make it work seamlessly with arbitrary skill repos in the wild, I added local state tracking in `~/.config/kungfu/state.json` to record skill origins regardless of how a skill was installed. You might be thinking: why bother adding the catalog to metadata if you can keep the manifest? One, because I like declarative things and two, because this allows reconciliation and lifecycle management even if you didn't use `kungfu` to learn a skill. ## Wrapping up Agent Skills are one of the most effective ways to bridge the gap between static foundation models and your actual day-to-day engineering workflows. Whether you start by building a simple documentation skill to banish outdated SDK patterns, automate your repetitive analytical chores with process skills, or explore trait-based agent swarms, the investment pays off fast. Feel free to borrow ideas from [skills.danicat.dev](https://skills.danicat.dev) or use [`kungfu`](https://github.com/danicat/kungfu) to manage and load them on demand, but whichever path you choose, the most important thing is that you keep refining your processes to achieve the full potential of agentic coding. --- # Building AI Agents in Go: GenAI SDK, Genkit, and ADK - **Markdown URL**: https://danicat.dev/posts/gemini-for-go-developers-part-3-building-agents/index.md - **HTML URL**: https://danicat.dev/posts/gemini-for-go-developers-part-3-building-agents/ - **Date**: 2026-08-25 - **Categories**: Agent Development - **Tags**: adk, ai-agents, cloud-run, gemini, genkit, golang - **Series**: Gemini for Go Developers - **Summary**: Build a Retro Game Appraiser agent in Go across three paradigms: raw tool loops with the Go GenAI SDK, typed Genkit flows, and multi-agent Google ADK. Using coding agents to write Go code is only one side of the equation. The other side is engineering your own AI-enabled applications and autonomous agent runtimes in Go. With multiple libraries available in the Go ecosystem, choosing the right level of abstraction is not always obvious. In this guide, we will dissect the fundamental mechanics of an agent, define a concrete domain (a **Retro Game Appraiser**), and build it step by step across three distinct Go paradigms: 1. A low-level agent loop built directly with the **[Go GenAI SDK](https://pkg.go.dev/google.golang.org/genai)**. 2. A structured, flow-centric pipeline built with **[Genkit](https://genkit.dev)**. 3. A modular, session-driven multi-agent system built with Google's **[Agent Development Kit (ADK)](https://adk.dev)**. Finally, we will review the operational runtimes available for deploying Go agents reliably to the cloud. All runnable examples and complete source code for this article are available on GitHub in the companion repository: [**danicat/gemini-for-go-developers**](https://github.com/danicat/gemini-for-go-developers/tree/main/part-3). ## The anatomy of an agent The word *agent* is often thrown around loosely, but in modern AI engineering it has a precise architectural definition: an autonomous system composed of a **large language model**, one or more **tools** (callable functions or APIs), and an **execution harness** operating inside a feedback loop. Without tools, a model is merely a text generator or chatbot. It can only generate responses based on its static training weights or the context provided in the prompt. To grant a model *agency* — the capacity to inspect external state, verify hypotheses, and execute changes in the real world — we must connect it to executable tools. An essential architectural detail is that the large language model never executes code or external APIs directly. Instead, the local **agent harness** (our Go program) acts as the intermediary. When the model determines that it needs external information or an action performed, it yields a structured *tool call request*. The harness executes the corresponding Go function, captures the output, and feeds the result back to the model turn. The model then evaluates the new context and either decides to call another tool or generates its final answer for the user. ```mermaid sequenceDiagram autonumber actor User participant Harness as Agent Harness (Go) participant Model as Gemini LLM (with Search Grounding) participant Catalog as Local Catalog Database User->>Harness: Prompt: "I found EarthBound on SNES for $350. Is it a good deal?" Harness->>Model: Request (System Prompt + search_catalog Tool + Google Search Grounding) Model-->>Harness: Tool Call: search_catalog(query="EarthBound") Harness->>Catalog: Query local collection Catalog-->>Harness: Returns: owned=true, condition="Loose Cartridge", price_paid=$180 Harness->>Model: Tool Response: {owned: true, format: "Loose", paid: 180} Note over Model: Gemini executes Google Search Grounding server-side for market pricing Model-->>Harness: Final Natural Language Response (Grounded with Search citations) Harness->>User: "You already own a Loose copy. At $350 for a Mint CIB copy, this is an outstanding deal..." ``` Not all tools require local execution. Gemini supports built-in tools like **Google Search grounding**, which execute server-side on Google infrastructure. When declared in your request configuration, the API transparently resolves search queries and injects grounding metadata into the model's response without requiring a local network roundtrip. For custom domain logic, however, your Go harness is fully responsible for schema declaration, dispatching arguments, and returning serialized results. ## Agent design: the Retro Game Appraiser To compare our three implementation options directly, we will build the exact same agent in each stack: the **Retro Game Appraiser**. If you collect retro video games like I do, this is a very familiar pain point. Whenever I visit retro game markets, more than once I have ended up buying a copy of a game I love only to get home and realise I already have it in my collection. On top of that, retro game collecting is notorious for volatile market prices, multiple condition variations (Loose cartridge vs. Complete in Box / CIB vs. Factory Sealed), and frequent counterfeit listings. During a recent market trip, I also realised that double-checking asking prices with Gemini in real time was surprisingly productive: I'm always happy to pay a reasonable premium for the in-person buying experience and supporting local vendors, but nobody wants to get scammed or massively overpay for a counterfeit cart. Our appraiser agent solves both sides of this equation: cross-referencing our personal inventory while evaluating fair market values against live web data. ### Capabilities and user interaction The collector interacts with the agent with natural questions such as: * *"Do I have Chrono Trigger in my collection?"* * *"I found a copy of EarthBound for SNES in mint Complete-in-Box condition for $350. Do I already own it, and is this a good price compared to current market values?"* * *"What did I pay for Castlevania: Symphony of the Night, and has its market value gone up?"* ### Tool contracts To answer these questions accurately without hallucinating inventory or prices, our agent relies on two distinct sources of truth: 1. **`search_catalog` (Local Tool):** A client-side Go function that queries the collector's local database. It matches keywords against titles and platforms, returning whether the item is already owned, its physical condition, acquisition date, and original purchase price. 2. **`google_search` (Search Grounding):** A server-side tool that searches online marketplaces and auction trackers to gather current fair market values, recent verified sales, and condition benchmarks. ### Reasoning strategy When asked about a potential purchase, the agent follows a systematic multi-step workflow: 1. Inspect the local catalog to check whether the game is already in the collection and in what condition. 2. Query Google Search to determine current market prices for the specific platform and condition tier. 3. Synthesize the findings into an actionable appraisal: compare the asking price against market averages, highlight upgrade opportunities (for example, replacing a loose cartridge with a boxed copy), and provide a clear recommendation. Let's begin by implementing this agent using the bare Go GenAI SDK. ## Implementing the agent with the Go GenAI SDK Building an agent directly with the **[Go GenAI SDK](https://pkg.go.dev/google.golang.org/genai)** (`google.golang.org/genai`) represents the **lowest abstraction level**, mapping 1:1 directly to the Gemini API wire protocol. Because there are no framework abstractions between your code and the API, you manage the tool dispatch loop, conversation history, and loop termination criteria explicitly. This low-level approach is ideal for small projects, utility scripts, learning the core mechanics of function calling and grounding, or when you have hyper-specific custom loop requirements that don't fit standard framework patterns. Because it compiles to a single, self-contained Go binary with no framework dependencies, it can be deployed to any typical web service or API hosting platform (such as Google Cloud Run, Kubernetes, or a virtual machine). Here is the complete, runnable implementation: ```go package main import ( "bufio" "context" "fmt" "log" "os" "os/signal" "strings" "syscall" "google.golang.org/genai" ) // GameItem represents a collectible item in the user's personal inventory. type GameItem struct { Title string `json:"title"` Platform string `json:"platform"` Year int `json:"year"` Condition string `json:"condition"` // e.g. "Loose Cartridge", "CIB (Complete in Box)", "Mint" PricePaid float64 `json:"price_paid"` Notes string `json:"notes"` } // localCatalog simulates an inventory database for retro games. var localCatalog = []GameItem{ { Title: "Chrono Trigger", Platform: "Super Nintendo (SNES)", Year: 1995, Condition: "CIB (Complete in Box)", PricePaid: 210.00, Notes: "Includes original map and registration card.", }, { Title: "EarthBound", Platform: "Super Nintendo (SNES)", Year: 1994, Condition: "Loose Cartridge", PricePaid: 180.00, Notes: "Authentic board verified; label in excellent shape.", }, { Title: "Castlevania: Symphony of the Night", Platform: "Sony PlayStation", Year: 1997, Condition: "CIB (Black Label)", PricePaid: 135.00, Notes: "Original soundtrack disc included.", }, } // searchCatalogTool searches the local collection for matching games. func searchCatalogTool(args map[string]any) map[string]any { query, _ := args["query"].(string) queryLower := strings.ToLower(strings.TrimSpace(query)) var matches []GameItem for _, item := range localCatalog { if strings.Contains(strings.ToLower(item.Title), queryLower) || strings.Contains(strings.ToLower(item.Platform), queryLower) { matches = append(matches, item) } } if len(matches) == 0 { return map[string]any{ "found": false, "message": fmt.Sprintf("No items matching %q found in your collection.", query), } } return map[string]any{ "found": true, "count": len(matches), "results": matches, } } func main() { ctx := context.Background() // Initialise GenAI client for Gemini Enterprise client, err := genai.NewClient(ctx, &genai.ClientConfig{ Project: os.Getenv("GOOGLE_CLOUD_PROJECT"), Location: "global", Backend: genai.BackendEnterprise, }) if err != nil { log.Fatalf("failed to create client: %v", err) } // 1. Declare custom function schema for collection lookup catalogToolDecl := &genai.FunctionDeclaration{ Name: "search_catalog", Description: "Search the collector's personal inventory for owned games by title or platform.", Parameters: &genai.Schema{ Type: genai.TypeObject, Properties: map[string]*genai.Schema{ "query": { Type: genai.TypeString, Description: "Game title or platform to search (e.g. 'EarthBound', 'SNES').", }, }, Required: []string{"query"}, }, } // 2. Configure model tools: custom function declaration + Google Search grounding config := &genai.GenerateContentConfig{ SystemInstruction: &genai.Content{ Parts: []*genai.Part{ {Text: "You are an expert Retro Game Appraiser. When evaluating purchases, check the user's " + "collection catalog first to see if they already own the item, then check current market " + "prices using Google Search to evaluate whether the deal is fair, overpriced, or a bargain."}, }, }, Tools: []*genai.Tool{ { FunctionDeclarations: []*genai.FunctionDeclaration{catalogToolDecl}, }, { GoogleSearch: &genai.GoogleSearch{}, }, }, } // 3. Graceful shutdown on Ctrl+C (SIGINT) or SIGTERM sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) go func() { <-sigChan fmt.Println("\nGoodbye!") os.Exit(0) }() model := "gemini-3.8-flash" var contents []*genai.Content fmt.Println("Retro Game Appraiser (SDK Agent)") fmt.Println("Type your question below, or 'exit' (Ctrl+C / Ctrl+D) to quit.") fmt.Println("-----------------------------------------------------------------") scanner := bufio.NewScanner(os.Stdin) for { fmt.Print("\nUser: ") if !scanner.Scan() { fmt.Println("\nGoodbye!") break } input := strings.TrimSpace(scanner.Text()) if input == "" { continue } if strings.EqualFold(input, "exit") { fmt.Println("Goodbye!") break } contents = append(contents, &genai.Content{ Role: "user", Parts: []*genai.Part{genai.NewPartFromText(input)}, }) // 4. The Agent Loop: model generation -> tool dispatch -> feedback -> until final answer for { resp, err := client.Models.GenerateContent(ctx, model, contents, config) if err != nil { log.Printf("error generating content: %v", err) break } if len(resp.Candidates) == 0 || resp.Candidates[0].Content == nil { log.Println("received empty response candidate from model") break } // Append the model's response to the conversation history modelContent := resp.Candidates[0].Content contents = append(contents, modelContent) // Check if the model requested any client-side tool executions funcCalls := resp.FunctionCalls() if len(funcCalls) == 0 { fmt.Printf("\nAppraiser: %s\n", resp.Text()) break } // Execute each requested tool and prepare response parts var responseParts []*genai.Part for _, call := range funcCalls { fmt.Printf("[Harness] Executing tool: %s(args=%v)\n", call.Name, call.Args) var result map[string]any switch call.Name { case "search_catalog": result = searchCatalogTool(call.Args) default: result = map[string]any{"error": fmt.Sprintf("unsupported tool: %s", call.Name)} } responseParts = append(responseParts, genai.NewPartFromFunctionResponse(call.Name, result)) } // Return tool execution results as a user turn contents = append(contents, &genai.Content{ Role: "user", Parts: responseParts, }) } } } ``` ### Running the SDK agent To run this example, ensure you have configured your Google Cloud project and authenticated with Application Default Credentials (`gcloud auth application-default login`): ```sh export GOOGLE_CLOUD_PROJECT="your-gcp-project-id" go run main.go ``` The interactive chat session launches in your terminal. You can converse with the appraiser across multiple turns, and exit at any time by typing `exit`, pressing `Ctrl+C`, or sending `EOF` via `Ctrl+D`: ```text Retro Game Appraiser (SDK Agent) Type your question below, or 'exit' (Ctrl+C / Ctrl+D) to quit. ----------------------------------------------------------------- User: I found a copy of EarthBound for SNES in mint Complete-in-Box (CIB) condition for $350. Do I already own it, and is $350 a good deal compared to current market prices? [Harness] Executing tool: search_catalog(args=map[query:EarthBound]) Appraiser: Here is your collection check and appraisal for **EarthBound (SNES)**: 1. **Current Collection Status**: - You currently own **EarthBound** on Super Nintendo as a **Loose Cartridge**, purchased for **$180.00**. 2. **Market Price Appraisal**: - Verified market sales for an authentic, **Complete-in-Box (CIB)** copy of EarthBound typically range between **$1,200.00 and $1,500.00** depending on the condition of the box, tray, and original player's guide. 3. **Recommendation**: - At **$350.00**, a genuine Mint CIB copy is an **exceptional deal** (more than 70% below prevailing market value). - **Caution**: Because EarthBound is one of the most heavily counterfeited SNES titles, inspect the box printing, registration card, and PCB board carefully before completing the transaction. If verified authentic, this is an outstanding opportunity to upgrade your loose copy to CIB. User: exit Goodbye! ``` The SDK implementation makes the mechanical control flow explicit and straightforward. Today, with state-of-the-art models and generous token windows, it is deceptively easy to write the plumbing, the tool dispatch loops, and all the supporting scaffolding yourself. However, just because you *can* build all the plumbing from scratch doesn't mean you *should*. Every single line of custom framework code you write becomes an ongoing maintenance burden and a potential source of subtle bugs. Writing your own agent framework is at best a distraction from shipping features, and at worst a massive pile of technical debt that diverts effort away from the actual business outcome you are aiming to achieve. The best code is no-code; the second best code is the one that solves your problem with the least amount of custom code. ## Agent development frameworks While the raw SDK loop is unbeatable for learning low-level mechanics or hyper-specific bespoke loops, production applications demand higher levels of abstraction: * **Automatic Schema Reflection:** Defining JSON schemas manually with `&genai.Schema{...}` is tedious and error-prone. Production frameworks infer schemas directly from native Go structs and doc comments. * **Observability & Distributed Tracing:** In production, you need OpenTelemetry traces, latency breakdowns per tool invocation, and token consumption metrics out of the box. * **Prompt Management:** Hardcoding prompts in Go strings hinders collaboration with prompt engineers and prevents versioning prompt templates independently of binary releases. * **Session Persistence & State:** Managing multi-turn conversation histories across stateless HTTP requests requires thread-safe, decoupled storage backends. * **Model Portability:** While the Go SDK is Gemini-specific, frameworks allow you to switch model providers or test local models without rewriting your business logic. To address these needs across different architectural requirements, we have two primary open-source frameworks in the Go ecosystem: **Genkit** and **Agent Development Kit (ADK)**. | Dimension | Go GenAI SDK | Genkit Go | Agent Development Kit (ADK) | | :--- | :--- | :--- | :--- | | **Abstraction Level** | **Low** (1:1 Gemini API mapping) | **Medium** (Structured workflows) | **High** (Autonomous multi-agent systems) | | **Core Architecture** | Explicit `for` loop & dispatch | **Flows** (`genkit.DefineFlow`) & Tools | **Agents**, Runners & Session Services | | **Sweet Spot & Use Cases** | Small projects, learning mechanics, bespoke low-level loops | One-off GenAI apps (CLIs, web services), deterministic pipelines, single-domain agents | Conversational chat agents, multi-agent orchestration, complex memory & RAG | | **Model Ecosystem** | Gemini-specific | Multi-model (Google GenAI, Vertex AI, Ollama, etc.) | Multi-model (via ADK model adapters) | | **Target Deployment** | Any web service / API host (Cloud Run, K8s, VMs) | Any backend platform; **Cloud Run** (preferred) | **Gemini Enterprise** (preferred for sessions/RAG/memory) or **Cloud Run** | Let's examine how our Retro Game Appraiser is implemented in each framework. ## Implementing the agent with Genkit **[Genkit](https://genkit.dev)** operates at a **medium abstraction level**, bringing software engineering discipline and structured observability to AI applications. In Genkit, everything is organised around **Flows** (strongly-typed, observable pipelines) and **Tools** (type-safe Go functions with automatic schema generation). Genkit is the ideal choice for applications with **one-off generation** (such as CLI tools, batch processing, and webhook endpoints), deterministic workflows, and single-domain agents (like the [multimodal photo restoration pipeline](https://danicat.dev/posts/20260216-genkit-go/) I built earlier). It natively supports multiple model providers through plugins, and because a Genkit app is simply a standard Go HTTP server, it can run on any backend platform — with **Google Cloud Run** being the preferred deployment target for seamless container hosting and automatic scaling. Here is the Retro Game Appraiser implemented with Genkit Go: ```go package main import ( "context" "fmt" "log" "net/http" "os" "os/signal" "strings" "syscall" "time" "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/genkit" "github.com/firebase/genkit/go/plugins/googlegenai" "google.golang.org/genai" ) // GameItem represents a collectible item in the user's personal inventory. type GameItem struct { Title string `json:"title"` Platform string `json:"platform"` Year int `json:"year"` Condition string `json:"condition"` PricePaid float64 `json:"price_paid"` Notes string `json:"notes"` } // localCatalog simulates an inventory database for retro games. var localCatalog = []GameItem{ { Title: "Chrono Trigger", Platform: "Super Nintendo (SNES)", Year: 1995, Condition: "CIB (Complete in Box)", PricePaid: 210.00, Notes: "Includes original map and registration card.", }, { Title: "EarthBound", Platform: "Super Nintendo (SNES)", Year: 1994, Condition: "Loose Cartridge", PricePaid: 180.00, Notes: "Authentic board verified; label in excellent shape.", }, { Title: "Castlevania: Symphony of the Night", Platform: "Sony PlayStation", Year: 1997, Condition: "CIB (Black Label)", PricePaid: 135.00, Notes: "Original soundtrack disc included.", }, } type CatalogRequest struct { Query string `json:"query" jsonschema:"description=The game title or platform to search in the inventory"` } type CatalogResponse struct { Found bool `json:"found"` Message string `json:"message,omitempty"` Count int `json:"count,omitempty"` Results []GameItem `json:"results,omitempty"` } type AppraiserRequest struct { Prompt string `json:"prompt" jsonschema:"description=The collector's question or purchase offer to evaluate"` } type AppraiserResponse struct { Appraisal string `json:"appraisal"` } func main() { ctx := context.Background() // 1. Initialise Genkit with Vertex AI plugin g := genkit.Init(ctx, genkit.WithPlugins(&googlegenai.VertexAI{ ProjectID: os.Getenv("GOOGLE_CLOUD_PROJECT"), Location: "global", }), ) // 2. Define strongly-typed tool with automatic schema generation catalogTool := genkit.DefineTool( g, "search_catalog", "Search the collector's personal inventory for owned games by title or platform.", func(ctx *ai.ToolContext, req CatalogRequest) (CatalogResponse, error) { queryLower := strings.ToLower(strings.TrimSpace(req.Query)) queryWords := strings.Fields(queryLower) var matches []GameItem for _, item := range localCatalog { itemText := strings.ToLower(item.Title + " " + item.Platform) allMatch := true for _, word := range queryWords { if !strings.Contains(itemText, word) { allMatch = false break } } if allMatch { matches = append(matches, item) } } if len(matches) == 0 { return CatalogResponse{ Found: false, Message: fmt.Sprintf("No items matching %q found in personal collection.", req.Query), }, nil } return CatalogResponse{ Found: true, Count: len(matches), Results: matches, }, nil }, ) // 3. Define structured appraisal flow with typed request and response appraiserFlow := genkit.DefineFlow( g, "appraise_game", func(ctx context.Context, req AppraiserRequest) (AppraiserResponse, error) { resp, err := genkit.Generate(ctx, g, ai.WithModelName("vertexai/gemini-3.8-flash"), ai.WithSystem( "You are an expert Retro Game Appraiser. Assist collectors by evaluating prospective purchases, "+ "cross-referencing their personal inventory, and assessing fair market valuations. "+ "Always search the collection catalog using search_catalog before providing purchase recommendations.", ), ai.WithConfig(&genai.GenerateContentConfig{ ThinkingConfig: &genai.ThinkingConfig{IncludeThoughts: true}, Tools: []*genai.Tool{ { GoogleSearch: &genai.GoogleSearch{}, }, }, }), ai.WithPrompt(req.Prompt), ai.WithTools(catalogTool), ) if err != nil { return AppraiserResponse{}, fmt.Errorf("appraisal generation failed: %w", err) } return AppraiserResponse{Appraisal: resp.Text()}, nil }, ) // 4. Mount flow directly using Genkit's built-in HTTP handler mux := http.NewServeMux() mux.Handle("POST /api/appraise", genkit.Handler(appraiserFlow)) port := os.Getenv("PORT") if port == "" { port = "8080" } server := &http.Server{ Addr: ":" + port, Handler: mux, } // Graceful shutdown on Ctrl+C (SIGINT) or SIGTERM serverCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() go func() { log.Printf("Retro Game Appraiser (Genkit) listening on :%s", port) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("server failed: %v", err) } }() <-serverCtx.Done() log.Println("\nShutting down server gracefully...") shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := server.Shutdown(shutdownCtx); err != nil { log.Fatalf("server forced shutdown: %v", err) } log.Println("Server exited cleanly.") } ``` ### Running the Genkit flow Start the Genkit server by providing your Google Cloud project: ```sh export GOOGLE_CLOUD_PROJECT="your-gcp-project-id" export PORT=8080 go run main.go ``` In another terminal, send an appraisal request: ```sh curl -s -X POST http://localhost:8080/api/appraise \ -H "Content-Type: application/json" \ -d '{"data": {"prompt": "I found a copy of EarthBound for SNES for $350. Do I own it, and is it a good deal?"}}' | jq . ``` The flow executes, queries the catalog tool, and returns a structured appraisal payload: ```json { "appraisal": "### 1. Catalog Check\n**Yes, you already own it.**\n* **Title:** *EarthBound* (SNES, 1994)\n* **Status in Collection:** Loose Cartridge\n* **Condition/Notes:** Authentic board verified; label in excellent shape.\n* **Price Paid:** $180\n\n---\n\n### 2. Market Appraisal & Deal Analysis\n* **Loose Cartridge:** The current going market rate for an authentic loose copy ranges between **$320 and $380**. At **$350**, it is priced right at **fair market value**—neither an overpriced listing nor a significant bargain.\n* **Complete in Box (CIB) / Boxed with Guide:** If this listing happens to include the original big box and strategy guide with scratch-and-sniff cards, $350 would be an extraordinary steal (CIB copies regularly sell for **$1,500–$2,500+**).\n\n---\n\n### 3. Recommendation\n* **Pass (if Loose):** Since you already have an authentic copy in excellent condition, paying retail market price ($350) for a duplicate loose cart does not offer strong value or upside.\n* **Buy immediately (if Complete/Boxed):** Only pull the trigger if it includes the original packaging or represents a major condition upgrade/variant.\n* **Buyer Beware:** If you do ever consider another copy, always inspect the PCB (printed circuit board) screws and chips, as *EarthBound* is one of the most frequently counterfeited games on the SNES." } ``` Genkit eliminates the manual dispatch loop. It handles argument unmarshalling into native Go structs, executes the tool, feeds the payload back to the model, and automatically emits OpenTelemetry spans for every step. It is the sweet spot whenever your workload follows a structured, deterministic flow. ## Implementing the agent with Agent Development Kit (ADK) While Genkit focuses on structured application pipelines, Google's **[Agent Development Kit (ADK)](https://adk.dev)** operates at a **high abstraction level**, architected specifically for autonomous conversational agents, multi-agent orchestration, and complex architectures that demand long-term memory and enterprise RAG. ADK standardises agent lifecycles, subagent delegation, and agent-to-agent (A2A) communication protocols. Like Genkit, it supports multiple model providers through modular model adapters. When it comes to deployment, **Gemini Enterprise Agent Platform** is the preferred target if you are leveraging managed session persistence, enterprise grounding connectors, and memory services without writing custom database layers. If you are managing your own state stores or running containerised microservices, **Google Cloud Run** is the recommended choice. Here is the Retro Game Appraiser implemented with ADK v2: ```go package main import ( "context" "fmt" "log" "os" "strings" "google.golang.org/genai" "google.golang.org/adk/v2/agent" "google.golang.org/adk/v2/agent/llmagent" "google.golang.org/adk/v2/cmd/launcher" "google.golang.org/adk/v2/cmd/launcher/full" "google.golang.org/adk/v2/model/gemini" "google.golang.org/adk/v2/tool" "google.golang.org/adk/v2/tool/functiontool" "google.golang.org/adk/v2/tool/geminitool" ) // GameItem represents a collectible item in the user's personal inventory. type GameItem struct { Title string `json:"title"` Platform string `json:"platform"` Year int `json:"year"` Condition string `json:"condition"` PricePaid float64 `json:"price_paid"` Notes string `json:"notes"` } // localCatalog simulates an inventory database for retro games. var localCatalog = []GameItem{ { Title: "Chrono Trigger", Platform: "Super Nintendo (SNES)", Year: 1995, Condition: "CIB (Complete in Box)", PricePaid: 210.00, Notes: "Includes original map and registration card.", }, { Title: "EarthBound", Platform: "Super Nintendo (SNES)", Year: 1994, Condition: "Loose Cartridge", PricePaid: 180.00, Notes: "Authentic board verified; label in excellent shape.", }, { Title: "Castlevania: Symphony of the Night", Platform: "Sony PlayStation", Year: 1997, Condition: "CIB (Black Label)", PricePaid: 135.00, Notes: "Original soundtrack disc included.", }, } type CatalogRequest struct { Query string `json:"query" jsonschema:"The game title or platform to search in the inventory."` } type CatalogResponse struct { Found bool `json:"found"` Message string `json:"message,omitempty"` Count int `json:"count,omitempty"` Results []GameItem `json:"results,omitempty"` } func main() { ctx := context.Background() // 1. Initialise Gemini Model adapter for Gemini Enterprise model, err := gemini.NewModel(ctx, "gemini-3.8-flash", &genai.ClientConfig{ Project: os.Getenv("GOOGLE_CLOUD_PROJECT"), Location: "global", Backend: genai.BackendEnterprise, }) if err != nil { log.Fatalf("failed to create Gemini model: %v", err) } // 2. Wrap collection lookup as an ADK Function Tool catalogTool, err := functiontool.New(functiontool.Config{ Name: "search_catalog", Description: "Search the collector's personal inventory for owned games by title or platform.", }, func(ctx agent.Context, req CatalogRequest) (CatalogResponse, error) { queryLower := strings.ToLower(strings.TrimSpace(req.Query)) var matches []GameItem for _, item := range localCatalog { if strings.Contains(strings.ToLower(item.Title), queryLower) || strings.Contains(strings.ToLower(item.Platform), queryLower) { matches = append(matches, item) } } if len(matches) == 0 { return CatalogResponse{ Found: false, Message: fmt.Sprintf("No items matching %q found in personal collection.", req.Query), }, nil } return CatalogResponse{ Found: true, Count: len(matches), Results: matches, }, nil }) if err != nil { log.Fatalf("failed to create catalog tool: %v", err) } // 3. Define autonomous LLM Agent appraiserAgent, err := llmagent.New(llmagent.Config{ Name: "retro_game_appraiser", Model: model, Description: "Expert appraiser that analyzes retro video game purchases and collection inventory.", Instruction: "You are an expert Retro Game Appraiser. Assist collectors by verifying collection " + "status with search_catalog, assessing condition variants, and offering objective buying recommendations.", Tools: []tool.Tool{ catalogTool, geminitool.GoogleSearch{}, }, }) if err != nil { log.Fatalf("failed to create appraiser agent: %v", err) } // 4. Configure launcher and execute config := &launcher.Config{ AgentLoader: agent.NewSingleLoader(appraiserAgent), } l := full.NewLauncher() if err = l.Execute(ctx, config, os.Args[1:]); err != nil { log.Fatalf("run failed: %v\n\n%s", err, l.CommandLineSyntax()) } } ``` ### Running the ADK agent ADK provides a universal launcher (`full.NewLauncher()`) that eliminates the need to manually construct HTTP routing, JSON marshalling, or session boilerplate. By passing command-line arguments to the launcher, you can run the agent across multiple interaction modes without altering application code. To start an interactive terminal chat session, execute: ```sh export GOOGLE_CLOUD_PROJECT="your-gcp-project-id" go run main.go ``` The launcher starts in console mode, allowing you to converse with the appraiser directly: ```text User: Do I have Chrono Trigger in my collection? Agent: Yes, you have Chrono Trigger in your collection! Here are the details from your inventory: * Title: Chrono Trigger * Platform: Super Nintendo (SNES) * Release Year: 1995 * Condition: CIB (Complete in Box) * Price Paid: $210.00 * Notes: Includes original map and registration card. User: What did I pay for it? Agent: You paid $210.00 for it. ``` Because ADK manages conversational state and session continuity automatically, follow-up queries retain full contextual memory of earlier turns in the conversation. Alternatively, you can launch ADK's built-in developer Web UI and REST API server: ```sh go run main.go web webui api ``` Navigating to `http://localhost:8080` opens an interactive web chat interface that provides real-time streaming responses, session history, and transparent step-by-step visibility into tool executions (both your custom `search_catalog` function tool and native `GoogleSearch` grounding). ADK provides clean architectural separation between agent definition (`llmagent`), tool bindings (`functiontool` and built-in tools like `geminitool.GoogleSearch`), and execution environments (`launcher` and `runner`), making it the framework of choice when building complex multi-agent hierarchies or conversational assistants. ## Agent runtimes Writing your agent's code in Go is only half the journey. Once built, you need an execution environment that can handle long-lived streaming responses, background tool invocations, secure credentials, and traffic spikes without operational headaches. Because Go compiles to single, self-contained binaries with virtually zero runtime overhead, Go agents are exceptionally fast and cost-effective to host. Depending on your framework choice and state requirements, you have two primary deployment targets: ### Cloud Run: the universal backend sweet spot **[Google Cloud Run](https://cloud.google.com/run)** is the prime deployment target for hosting containerised Go agents — whether built directly with the Go GenAI SDK, Genkit flows, or ADK runners. Using multi-stage Docker builds, you can compile your Go agent into a minimal `scratch` or `distroless` container image with zero runtime dependencies. Because the Go binary has no heavy interpreter or virtual machine to bootstrap, your service scales from zero rapidly, minimizing the cold start overhead that often plagues heavier runtimes. Key architectural and operational advantages of Cloud Run for Go agents include: * **True Scale-to-Zero & Fast Concurrency:** Pay only for the exact CPU milliseconds consumed while your agent is actively executing or processing tool responses. Go's lightweight goroutines allow a single container instance to concurrently process hundreds of active agent turns with negligible memory footprint. * **Extended Request Timeouts (Up to 60 Minutes):** While default timeouts are 5 minutes, Cloud Run supports request timeouts up to 60 minutes (3,600 seconds) — giving multi-step reasoning loops, deep research tasks, and iterative subagent swarms ample headroom to finish without premature termination. * **Bidirectional Streaming & WebSockets:** Cloud Run natively supports HTTP/2 chunked transfer encoding, Server-Sent Events (SSE), and WebSockets. For voice-to-voice or real-time multimodal agents interacting with the Gemini Live API, WebSockets allow continuous, bidirectional streaming between client and server. * **Session Affinity (Sticky Sessions):** If your agent maintains temporary in-memory caches or local context across consecutive conversational turns, you can enable session affinity (`--session-affinity`) via client IP or cookies to consistently route follow-up requests to the same container instance. * **Stateless Resilience:** For production deployments, state should be decoupled into external managed datastores (such as Firestore, Redis, or Cloud SQL). If a network disconnect occurs during a long-running turn, the agent can resume execution via its interaction ID without losing context. * **Secretless Workload Identity IAM:** Zero hardcoded API keys or credentials. Your Go agent authenticates seamlessly to Gemini Developer API, Vertex AI, and Cloud storage using the ambient Cloud Run service account. ### Gemini Enterprise Agent Platform: managed sessions and enterprise RAG When building with **Agent Development Kit (ADK)**, especially for conversational chat agents or enterprise solutions requiring persistent sessions, managed long-term memory, and enterprise knowledge grounding, the **Gemini Enterprise Agent Platform** (the evolution of Vertex AI Agent Engine) provides a fully managed, serverless agent execution environment. Instead of provisioning databases and writing custom session storage adapters yourself, the platform offers managed agent infrastructure with: * **Decoupled Session Persistence:** Seamless architectural separation between persistent long-term conversation storage (`SessionService` in ADK) and ephemeral streaming execution loops (`LiveSession` with automatic session resumption handles across reconnections). * **Enterprise Grounding & Vector Search:** Turnkey connectors across enterprise knowledge bases (Google Drive, BigQuery, intranet repositories) alongside Vertex AI Vector Search indexes (featuring storage-optimised tiers) for high-performance semantic retrieval. * **Sandboxed Code Execution:** Secure, isolated sandbox environments where agents can dynamically write and execute code (e.g. data analysis, Python/Go scripts) without exposing your host infrastructure to risk. * **Agent-to-Agent (A2A) Protocols:** Standardised communication protocols that allow independent enterprise agents to discover capabilities, negotiate schemas, and delegate tasks to one another across organisational boundaries. * **Security, Agent Identity & Model Armor:** Granular agent IAM permissions, VPC Service Controls perimeters, and Model Armor runtime protection to inspect inputs and outputs against prompt injection, data exfiltration, and policy violations. ## Where to go from here Building the same agent three ways highlights the trade-offs clearly: the **Go GenAI SDK** gives you total control over the raw execution loop, **Genkit** shines for type-safe flows, prompt management, and HTTP services, and **ADK** provides structured primitives for session-driven multi-agent systems. If you want to see how a real application evolves beyond this starter example, check out [**Why I Rebuilt My Python RAG Agent in Go with Genkit**](https://danicat.dev/posts/20261006-building-agentic-backends-with-genkit-go/), where we refactor a legacy Python RAG agent into a compiled Go binary using typed `dotprompt` templates, Agent Skills middleware, and multi-agent delegation. Happy hacking! --- # Why Go Is Built for Coding Agents (And How to Tune Yours) - **Markdown URL**: https://danicat.dev/posts/gemini-for-go-developers-part-2-coding-with-gemini/index.md - **HTML URL**: https://danicat.dev/posts/gemini-for-go-developers-part-2-coding-with-gemini/ - **Date**: 2026-08-17 - **Categories**: Agentic Coding - **Tags**: agent-skills, antigravity, gemini, godoctor, golang, mcp - **Series**: Gemini for Go Developers - **Summary**: Maximize Go's agentic affinity with AI coding harnesses. Configure Antigravity, MCP servers, Agent Skills, and godoctor for compiler-verified Go workflows. How do you get the best results when pairing AI coding agents with Go? In this article, we are going to explore how to configure an effective, high-signal environment for agentic coding in Go. We will start with a look at why programming language choices still matter in the age of AI, explore the landscape of agent harnesses and open customization standards (rules, MCP, skills, hooks, subagents, and plugins), and finish with a practical setup to maximize Go's agentic affinity in your daily workflow. ## Why use Go in the age of AI? Before we get any further, let's get the obvious question out of the way: in the age of AI, does our choice of programming language even matter? In the past, language selection was almost always dictated by existing team expertise. Learning new syntax, idioms, and tooling quirks took valuable time, so teams stayed in their comfort zones unless forced to move by a major tech shift. AI flipped that dynamic. Syntax is no longer a barrier when models generate boilerplate on demand, and learning an unfamiliar stack with an AI tutor beats spending hours praying that some five-year-old Stack Overflow thread matches your exact compiler error. Why would you care about language selection then? It boils down to two main themes: **language ecosystem** and **agentic affinity**. In this context, a language ecosystem refers to everything the language attracts by "gravity": actively maintained SDKs, libraries, documentation, community vitality, and industry knowledge. But **agentic affinity** is unique to the world we are living in today. I define it as "how easy it is to guide an agent to code with this language". High agentic affinity is primarily influenced by how prepared the models are to generate code in this language, and secondly by how prepared they are to adopt the tooling required to verify, test, and maintain that code. Agentic affinity naturally depends on how much data about a given language was available during model training, benefiting popular languages with extensive public code and literature. That said, volume alone isn't everything: older languages that underwent massive paradigm shifts suffer from fragmented practices, causing models to suggest obsolete patterns at inference time. In my personal experience, languages like Go, Python, and JavaScript have high agentic affinity by default. Go stands out in particular: its readability, strict static typing, and almost Pythonesque philosophy that "explicit is better than implicit" make code generation far less prone to hallucinated syntax. More importantly, Go's fast compilation, built-in testing, and opinionated standard tooling give coding agents an immediate, deterministic feedback loop to catch and repair errors autonomously. (For a deeper dive on how Go's design philosophy aligns with AI-assisted software engineering, check out [this essay by Cameron Balahan and Richard Seroter on the Google Developers Blog](https://developers.googleblog.com/why-go-is-an-ideal-language-for-ai-assisted-software-engineering/)). On the other hand, languages like R and C sit on the other end of the spectrum. In R's case, it is a niche academic language where even with full agent support, I am not able to keep my [read.dbc](https://github.com/danicat/read.dbc) package on CRAN (R's package distribution system) because neither I nor the models can reproduce the problem reported by CRAN's pipeline. In C, the lack of guardrails means simple mistakes can become silent, catastrophic bugs that models struggle to catch early without rich tooling ergonomics. Ultimately, agentic affinity is measured by the speed and responsiveness of the agentic "loop": whether your agent can build, test, benchmark, and repair code out of the box without human micromanagement. ## Selecting the right agentic surface While the frontier model race is exciting to watch, models alone are only one part of the equation when it comes to code generation. The response quality and the developer experience of coding with an agent are heavily influenced by the harness that is running the model. If you are coding with Gemini, the natural choice is the **Antigravity ecosystem**. With the release of Antigravity 2.0, Google separated the ecosystem into three distinct surfaces depending on how you like to work: ### Antigravity 2.0 At the centre of the Antigravity ecosystem is the desktop application **Antigravity 2.0**, sometimes also referred to as the **Agent Manager**. The biggest change here is putting the agent experience front and centre while the code sits in the background. For first-time users accustomed to an IDE, the experience might be a bit shocking: there are no file trees to explore the codebase, nor any way to edit files manually. Every interaction is made through the agent. Your control lies in telling the agent what to do instead and annotating their work with "Google Docs"-style comments. ### The Antigravity CLI (`agy`) While the Agent Manager concept is fairly new, terminal UIs for agents have been around for a bit longer, popularised by Claude, Aider, Cline, and others. In June 2025, Google also launched its own terminal UI, the Gemini CLI, but it has since been deprecated in favour of the new **Antigravity CLI** (`agy`). One detail that makes me particularly happy as a Gopher is that `agy` is written in Go (whereas the Gemini CLI was in TypeScript), resulting in a noticeably snappier terminal experience. ### The Antigravity IDE If you still prefer a dedicated, visual code editor, Google offers the **Antigravity IDE** as a separate companion application. It is based on VS Code, so all the familiar IDE elements are where you remember them to be, plus it has an agent side panel for interactions with Gemini. To be 100% transparent, nowadays I rarely open the IDE for coding purposes. The only times I really use the IDE are when I am writing or reviewing articles (like this one) as I still do a huge part of my writing process manually. For code, I very rarely edit anything by hand these days. ## Agentic standards applied to coding Regardless of the surface you choose, Antigravity is very capable out of the box, but it is not without its quirks. The best way to fully leverage Antigravity's capabilities is to equip your agent with **customisations**. Antigravity supports both well-established and emerging agentic standards: rules, skills, MCP, hooks, subagents, sidecars, and plugins. However, support for these customisations is, unfortunately, **not uniform** across the different Antigravity surfaces today. Let's look at how each of these customisations works in practice. ### Agent instructions and rules The concept of instructions was standardised by the [**AGENTS.md**](https://agents.md/) initiative, which Antigravity supports via either `AGENTS.md` or `GEMINI.md` files (as well as modular rules under `.gemini/rules/` or `.agents/rules/`). You can think of `AGENTS.md` as the `README.md` for AI agents. It is the place to store project context, architectural constraints, testing commands, and style preferences that are essential for an agent to know, but would otherwise clutter human documentation. To be completely honest, with the advent of Agent Skills, I rarely update `GEMINI.md` any more, and most of my repositories probably have outdated files (my bad, poor agents!). That said, they are still useful for steering agents in the right direction. Just keep in mind that prompt-based instructions act more as recommendations than guardrails: an agent can still selectively overlook or drift away from rules during a long session. ### Model Context Protocol (MCP) The [**Model Context Protocol**](https://modelcontextprotocol.io/) (MCP) is an open standard for connecting AI applications to external tools and data sources. The protocol exposes three core primitives: 1. **Tools:** Executable functions the agent can invoke (e.g. querying a database, running a linter, checking a build). 2. **Resources:** Read-only data sources the agent can inspect (e.g. documentation files, database schemas, system logs). 3. **Prompts:** Predefined workflow templates. In the real world, we care about tools more than anything else, and most clients won't even support resources or prompts. Resources can be emulated via tools (a dedicated tool can retrieve data) and prompts have mostly fallen into obscurity due to the introduction of Agent Skills, which are much more flexible. Because skills can be packaged with scripts, some people are even ditching MCPs completely in favour of skills. I still think there are plenty of cases where MCPs are better than skills. One of the strengths of MCPs over skills is lifecycle management, especially when using them over HTTPS. For example, as a company you can deploy an MCP server as a web service and your clients only need to configure it once to have immediate access to up-to-date documentation. With skills, on the other hand, ensuring all your clients only use the latest versions is a problem still to be solved at scale. ### Agent skills A [skill](https://agentskills.io) is a directory containing instructions (`SKILL.md`), optional helper scripts, and documentation that teach an agent how to execute a specific engineering workflow. The defining architectural concept behind skills is **progressive disclosure** (explore production patterns in [The Pragmatic Guide to Agent Skills](https://danicat.dev/posts/20260829-the-pragmatic-guide-to-agent-skills/)). Instead of dumping hundreds of pages of documentation into the agent's context window up front, the system only injects the skill's name and description. When the agent determines that a task matches a skill, it loads the full `SKILL.md` instructions and executes the bundled scripts on demand. This model ensures specialist knowledge is always available, but doesn't overwhelm the context window or distract your agent with data that is not relevant to the task at hand. Agents use skill descriptions to identify their activation triggers, but relying solely on automatic activation is risky. By default, Antigravity maps all skills as slash commands, so you can force a skill activation by typing `/` anywhere within your prompt. Being proactive with skill activation will save you a lot of headaches if the skill is important for your workflow. ### Hooks While rules, prompts, and even skills offer soft guidance, [**hooks**](https://danicat.dev/posts/20260610-mastering-hooks/) introduce deterministic control into the agent loop. Hooks are callbacks that intercept the agent lifecycle at specific moments, such as before a tool runs (`PreToolUse`), after a tool executes (`PostToolUse`), before a model invocation (`PreInvocation`), or upon session termination (`Stop`). Because LLMs are non-deterministic, telling an agent to "always run a linter after editing code" via prompts leaves validation up to chance. Hooks, on the other hand, are controlled by the harness and always run for their given event. There is only one caveat that you need to always pay attention to when designing your hooks: models are very good at bypassing them. Yes, unfortunately, this is a thing. A hook might block the agent from doing harm, only for the agent to try to outsmart the hook immediately afterwards, either by tampering with the agent configuration, trying to mask the trigger condition, or, worse, rewriting the hook script itself. It saddens me to say this: in the past, I used hooks a lot, but with the new generation of models they got too smart for their own good, so I'm slowly moving away from hooks towards skills. Feels like teaching a child: do not prohibit, educate. ### Subagents [**Subagents**](https://danicat.dev/posts/20260722-the-rise-of-the-subagents/) offer another solution to the context window problem, while also enabling some interesting paradigms, like parallel execution. By spawning subagents, the main agent can segment the problem space and create an agent focused on each task. A trivial example would be to work on a web service that has both a frontend and a backend. The changes are essentially orthogonal to each other: the frontend requires HTML, CSS, and JavaScript, while the backend requires Go, Python, and maybe some SQL. Both frontend and backend tasks will have different coding standards and build pipelines. With the exception of their contract, they have nothing in common and if done in the same context window, one part will only be noise for the other. By splitting the tasks between two (or more) subagents, you ensure each subagent has full focus on its slice of the stack, reducing the risk of context degradation due to crossing the beams of two completely different tech stacks. Another good example is when you need an unbiased review of the work you just finished. Ask the agent to run the code review in a subagent, and you have the benefit of "fresh eyes" looking at the code. ### Sidecars > Note: at the time of this writing, sidecars only work on Antigravity 2.0. They are not available on the `agy` CLI or the IDE. **Sidecars** are background processes that run alongside the agent for the duration of a session. They can be persistent processes with the lifecycle managed by Antigravity through a defined restart policy, or they can be processes that run on a schedule. I haven't explored sidecars yet, so there isn't much I can add to the discussion at the moment, but my colleague Mete Atamel just started writing about them, and I encourage you to check [his article](https://medium.com/google-cloud/where-does-antigravity-look-for-sidecars-20e7002b9246) for more information. ### Agent plugins Plugins are, in essence, customisation bundles, packaging rules, MCPs, skills, hooks, agents, and sidecars in a single distribution unit. I've been experimenting with plugins for a while, and unfortunately, they don't work as smoothly as they are supposed to. For instance, I had a lot of trouble trying to ensure the hooks packaged with my plugins were executed properly, but it never worked for me. Also, plugin support is non-standard across different surfaces: for example, sidecars are only supported in Antigravity 2.0 plugins, custom agents are only supported in the Antigravity CLI, and so on. What are they good for then? MCPs and skills. Whether it's a coincidence or not, this is also where the industry is converging with the new [Agent Plugins](https://agent-plugins.org/specification) standard. Their justification is that hooks, agents, rules, LSP servers, and others are still pretty much client-specific, so out of scope for now. ## Equipping your Go agentic toolkit Now that we have covered agent harnesses and customisations, let's assemble our Go development toolkit. We can divide these into essential community tooling and specialised AI-native extensions. ### Essential community tooling While Go's standard toolchain gives agents a strong baseline, several community tools elevate code quality and release automation: - [**`golangci-lint`**](https://golangci-lint.run/): Bundles dozens of fast linters into a single pass, catching unhandled errors, unchecked type assertions, shadowed variables, and concurrency pitfalls that `go vet` misses. - [**`goreleaser`**](https://goreleaser.com/): For projects that distribute binaries, `goreleaser` automates building cross-platform artifacts, managing release pipelines, and generating changelogs from `.goreleaser.yaml`. - [**`modernize`**](https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize/cmd/modernize) / **`go fix`**: Analyses code against newer Go releases and mechanically upgrades older boilerplate, like replacing manual slice/map loops or min/max helpers with modern built-in functions. - [**`deadcode`**](https://pkg.go.dev/golang.org/x/tools/cmd/deadcode): Uses whole-program reachability analysis to identify unused functions and unreachable code across packages. - **`selene` and `testquery` (shameless plug):** I maintain two open-source tools to inspect and improve test suites. [**`selene`**](https://github.com/danicat/selene) is a mutation testing tool for Go that introduces targeted AST faults to verify whether tests actually catch code defects. [**`testquery`**](https://github.com/danicat/testquery) is a CLI that exposes a SQL interface to query Go test results and per-test coverage. While a bit niche, they help me optimise my test suites. ### AI-specific tooling The tools above were built for human developers, but coding agents can execute them directly through standard shell commands. Now let's have a look at specialised MCPs and skills for AI-native workflows. #### The official `gopls` MCP server In a traditional IDE, `gopls` provides your editor with semantic awareness: type checking, references, and symbol definitions. But when an agent operates in a headless or terminal environment, it typically interacts with code as raw text files. To bridge this gap, the Go team added native MCP support to [**`gopls`**](https://pkg.go.dev/golang.org/x/tools/gopls). Running `gopls` in MCP mode exposes the language server's type-checker and index directly to the model as executable tools. This allows the agent to navigate package hierarchies and inspect type signatures using the compiler's own semantic model. #### The `godoctor` MCP server One of my rants about the `gopls` MCP server is that it was designed on top of the LSP API, which was not designed with agents in mind: LSPs were built for typing speed and interactive keystrokes, while agent workflows are transactional. For this reason, I built [**`godoctor`**](https://github.com/danicat/godoctor) to provide AI-native tools for Go development. The current version of `godoctor` provides the following: - **`smart_edit`:** An AST-aware file editor with automatic `go vet` validation and typo correction. If an edit introduces a compile or syntax error, `godoctor` automatically rolls back the change and suggests fixes based on nearby identifiers with a "did you mean?" kind of steering prompt. - **`smart_build`:** An automated verification pipeline that runs module hygiene (`go mod tidy`, `modernize`, `goimports`), builds the package, executes tests with coverage, and validates linting via `golangci-lint` in a single pass. - **`smart_test`:** An opinionated test pipeline with support for `testquery` and `selene`. - **`read_docs`:** Documentation lookup based on `go doc`, which supports examples and includes a fallback system that shows documentation independently of module configuration. #### Platform knowledge and self-improvement While `godoctor` and `gopls` handle local code semantics, coding agents also need live platform knowledge and workflow playbooks to understand the services they are integrating with. Here are the essential cloud and API resources when working with Go, GCP, and Gemini: - **Google Developer Knowledge MCP (`developerknowledge.googleapis.com/mcp`):** Connects the agent directly to official Google Cloud, Gemini Enterprise (Vertex AI), and Google API documentation. - **Gemini Docs MCP (`gemini-api-docs-mcp.dev`):** Provides live documentation for current Gemini API endpoints, SDK updates, and configuration patterns (read more about it in the [Gemini coding agents guide](https://ai.google.dev/gemini-api/docs/coding-agents)). - **Official Google Skills:** [**`github.com/google/skills`**](https://github.com/google/skills) and [**`github.com/google-gemini/gemini-skills`**](https://github.com/google-gemini/gemini-skills) contain official skills maintained by Google (including `gemini-api-dev`, `gemini-live-api-dev`, and `gemini-interactions-api`). - **Community & personal catalogue:** You can browse my personal skills at [**`skills.danicat.dev`**](https://skills.danicat.dev) (or on [**GitHub**](https://github.com/danicat/skills)), which includes skills for engineering best practices, 2D game development, generative media (Lyria, Nano Banana Pro), and others. And for optimising your own workflow with custom extensions: - **AgentSkills MCP (`agentskills.io/mcp`):** The official search and retrieval engine for querying the [Agent Skills open specification](https://agentskills.io) and authoring best practices. Great for when you create your own skills as part of your daily job. Which you should. - **MCP Dev skills:** If there is an MCP for agent skills development, why not also have some [agent skills for MCP development](https://modelcontextprotocol.io/docs/2026-07-28/develop/build-with-agent-skills)? Yes, you read that right (LOL). While a bit niche, as you can see from my own work, creating MCPs for your own use is also a good way to improve your development environment. ## The Gopher's 5-minute agentic setup If you want an opinionated setup to start working with Gemini today, here is the 5-minute quickstart: 1. Download [Antigravity](https://antigravity.google) from the official website. 2. Configure the recommended MCP servers: - [Gemini Docs MCP](https://ai.google.dev/gemini-api/docs/coding-agents): `npx add-mcp "https://gemini-api-docs-mcp.dev"` - [Developer Knowledge MCP](https://developers.google.com/knowledge/mcp): enable the API and configure it following the instructions on the documentation page. - [Agent Skills MCP](https://agentskills.io): Expand the copy button on any page for instructions. - [godoctor](https://github.com/danicat/godoctor): Use the one-line install script. 3. Add the recommended skills: - [Gemini API development](https://ai.google.dev/gemini-api/docs/coding-agents): `npx skills add google-gemini/gemini-skills --skill gemini-api-dev` - [Swarm coding](https://danicat.dev/posts/20260722-the-rise-of-the-subagents/): `npx skills add github.com/danicat/skills/agents/swarm-coding` 4. Test drive the loop: Prompt your agent to run an autonomous verification pass: > Run a smart build on this package with godoctor, address any findings, and evaluate the test suite with selene. ## Wrapping up In this article, we covered why Go's fast compiler and strict toolchain give it high agentic affinity, how customization primitives (rules, MCP, skills, hooks, subagents, and plugins) fit together, and how to configure a practical Go environment in Antigravity. Once your development workflow is dialed in, you might want to cross over to the other side of the table and build your own autonomous agents in Go: * Read [**Building AI Agents in Go: GenAI SDK, Genkit, and ADK**](https://danicat.dev/posts/20260825-gemini-for-go-developers-part-3-building-agents/) for a side-by-side comparison of the three main agent architectures in Go. * Read [**Why I Rebuilt My Python RAG Agent in Go with Genkit**](https://danicat.dev/posts/20261006-building-agentic-backends-with-genkit-go/) to see how Agent Skills and multi-agent delegation work inside a compiled Go binary. --- # The Gemini Model Family: A Practical Guide for Gophers - **Markdown URL**: https://danicat.dev/posts/gemini-for-go-developers-part-1-model-family/index.md - **HTML URL**: https://danicat.dev/posts/gemini-for-go-developers-part-1-model-family/ - **Date**: 2026-08-08 - **Categories**: Applied GenAI - **Tags**: gemini, genai-sdk, golang, llm, multimodal - **Series**: Gemini for Go Developers - **Summary**: Compare Gemini 3.x Pro, Flash, Flash-Lite, and Nano Banana models, tune thinking levels, and write multimodal Go applications with the official GenAI SDK. Building reliable AI-powered software in Go starts with understanding the models under the hood. Before reaching for higher-level agent frameworks or orchestrating multi-step workflows, you need to know which model fits your workload, how to tune its reasoning budget, and which API surface matches your architecture. In this guide, we will explore the Gemini model family, break down key configuration parameters like thinking levels, and write our first multimodal application using the official [Go GenAI SDK](https://pkg.go.dev/google.golang.org/genai). ## The Gemini model family We often treat "Gemini" as a single name for Google AI offerings, much like using "Google" as a shorthand for search. In reality, Gemini is a family of distinct models built for different operational trade-offs. While frontier models are the ones that capture the headlines, knowing when to use smaller or specialised models is essential for cost-effective engineering. Model selection also directly [influences user experience](https://services.google.com/fh/files/blogs/google_delayexp.pdf) and product adoption, as latency varies sharply across model tiers. Reaching for a Gemini Pro model with high thinking levels for every task is tempting, but it isn't always the right move. In many cases, it simply increases per-request latency and API costs without delivering a better outcome. ### Model naming scheme To navigate the Gemini catalog, it helps to understand how Google names its models. A standard model string follows this pattern: \[ \text{[family]}-\text{[version]}-\text{[tier]}{-\text{[modifier]}} \] For example: `gemini-3.7-flash` or `gemini-3-pro-image`. **Family**: While most models will be in the Gemini family, Google also has other model families like Veo and Lyria. **Version Numbers**: Represent generational leaps in intelligence, context window handling, and instruction adherence. **Model Tiers**: * **Pro**: Designed for complex multi-step reasoning. * **Flash**: Balanced model with bias towards speed. * **Flash-Lite**: Optimized for speed and high-throughput, simple tasks. **Modifiers**: Might indicate a sub-family or specialisation, like `image` in `gemini-3.1-flash-image` or `live` in `gemini-3.1-flash-live-preview`. It may also include lifecycle modifiers like `-preview` or `-exp` (for experimental). ### Model overview Here is an overview of key Gemini models, starting with the frontier model Gemini 3.x: #### Gemini 3.x Gemini 3.x is the primary frontier model line, available in Pro, Flash, and Flash-Lite tiers. These general-purpose models are also the primary choice for code generation and software engineering tasks. Current models include: - `gemini-3.6-flash`: High-speed workhorse for multimodal reasoning and agentic tasks - `gemini-3.5-flash-lite`: Lowest-cost, ultra-fast tier for high-throughput microservices - `gemini-3.1-pro-preview`: Advanced tier for complex multi-step reasoning and deep codebase analysis #### Gemini image models (Nano Banana) While technically still part of the Gemini family, this is a specialised model for image generation, providing both multimodal input and output (image and text). It is capable of producing images from scratch and performing edits on existing images. Current models include: - `gemini-2.5-flash-image` (aka Nano Banana) - `gemini-3-pro-image` (aka Nano Banana Pro) - `gemini-3.1-flash-image` (aka Nano Banana 2) - `gemini-3.1-flash-lite-image` (aka Nano Banana 2 Lite) #### Veo A model specialised in [video generation with native audio](https://ai.google.dev/gemini-api/docs/veo). Videos are generated based on text prompts and key images to mark transitions (start and end frame) and as references. Veo 3.1 generates clips up to 8 seconds, but it is possible to extend them up to 20 times in 7-second increments. Current models: - `veo-3.1-generate-preview` - `veo-3.1-lite-generate-preview` (fast generation) #### Lyria [Lyria](https://ai.google.dev/gemini-api/docs/music-generation) is specialised in music generation, delivering both instrumental and vocal compositions. Lyria accepts both text and images as input, with the images serving as inspiration for the composition. You can also provide the lyrics yourself or let the model create them for you. Current models: - `lyria-3-pro-preview` - `lyria-3-clip-preview` (short 30s clips) #### Gemma [Gemma](https://ai.google.dev/gemma/docs) is an open-weights model family from Google. It is trained with the same technology behind Gemini, but designed to be deployed on your own infrastructure. Beyond the models offered by Google, Gemma also has a [strong community](https://deepmind.google/models/gemma/gemmaverse/) that produces fine-tuned versions for all sorts of use cases. Some Gemma models are small enough to be suitable for running on local machines, enabling use cases where network connectivity is limited or non-existent. The bigger models are very capable, enabling use cases where sovereignty and network isolation are required. #### Notable mentions - Live models: While earlier models handle batch or request-response jobs, Google also offers live models for real-time streaming. Look for `-live` in the name (e.g. `gemini-3.1-flash-live-preview`). - Text-to-speech: Generates speech from text with narration control using audio tags (`gemini-3.1-flash-tts-preview`). - Computer use: A model that can "see" the screen and automate browser tasks (`gemini-2.5-computer-use-preview-10-2025`). As you can see, Gemini is much more than a single model. It's a complete suite covering everything from basic chatbots to multimodal creation and agentic capabilities. For detailed specifications on each model, see the [official Gemini models documentation](https://ai.google.dev/gemini-api/docs/models). ## Additional capabilities Beyond standard text completion, Gemini models support additional features for building complex apps. Here are a few of the most important ones. ### Thinking Gemini 2.5 and later models use an internal reasoning process that significantly improves multi-step planning, logic, coding, and mathematical capabilities. Before generating the final response, the model reasons internally by generating "thinking tokens" to analyze edge cases and plan multi-step strategies. Thinking is a feature that can be controlled using the configuration parameters `thinking budget` in 2.5 and `thinking level` in 3.x. The higher budget or thinking level, the more time and tokens the model will spend during the reasoning phase. When thinking is active, total billable output tokens include both the generated output text and the model's generated thinking tokens. Adjusting the thinking level based on task complexity is a critical step for production services. ### Built-in tools and function calling Function calling allows Gemini models to interface with external tools, APIs, and databases. Gemini supports both built-in tools (like `google_search` and `code_execution`) and custom functions defined at application level. Function calling has three primary use cases: - **Take Actions:** Interact with external systems via APIs, such as scheduling meetings, sending emails, creating invoices, or controlling smart home devices. - **Augment Knowledge:** Fetch real-time or private information from external databases, microservices, and knowledge bases. - **Extend Capabilities:** Perform precise math, data conversion, or chart generation that exceeds LLM limits. #### How function calling works Function calling follows a 4-step execution process between your application and the model: 1. **Declare tools**: Define function declarations (name, clear description, and parameter JSON Schemas) and pass them in the request configuration. 2. **Model identifies tool intent**: The model inspects the prompt and tool declarations. If a tool is required, it returns a structured tool call intent containing the function name and arguments. 3. **Execute function code**: The model *does not* execute code itself. Your application receives the function call request, executes the corresponding local logic, and captures the result. 4. **Return function result**: Send the execution output back to the model as a function result step. The model uses this data to generate its final natural language response or decide if additional tool calls are needed. ### Structured outputs You can configure Gemini models to generate responses that adhere to a provided [JSON Schema](https://ai.google.dev/gemini-api/docs/structured-output). This simplifies extracting structured data from text, eliminating fragile parsing when converting model responses into data structures. Besides writing raw JSON Schemas in REST payloads, the Google GenAI SDKs allow developers to define schemas using native language constructs such as [Pydantic](https://docs.pydantic.dev/) in Python and struct tags in Go. ## Consuming models programmatically With the model ecosystem and capabilities covered, let's look at how to incorporate these APIs into Go applications. Just as different models exist for different use cases, several API surfaces are available. Let's start with the most basic one: Generate Content. ### Generate Content API This is the most basic [generative API](https://ai.google.dev/api/generate-content#method:-models.generatecontent). It is a stateless interface that accepts a single request and returns a response. For multi-turn conversations, your application must send the full chat history with each call. This requires actively managing conversation history to stay within context window limits. Applications typically summarize history once it reaches a threshold. To reduce input costs on long sessions, the Gemini API supports [implicit caching](https://ai.google.dev/gemini-api/docs/caching) for all models since Gemini 2.5, as well as [explicit caching](https://ai.google.dev/gemini-api/docs/generate-content/caching) for heavy payloads. While the Generate Content API is good for simple stateless generations, it is being gradually replaced by the newer and more capable Interactions API. ### Interactions API > Note: As of today, the Interactions API is not yet supported by the official Go GenAI SDK. Implementation progress is being tracked in this [GitHub issue](https://github.com/googleapis/go-genai/issues/658). The [Interactions API](https://ai.google.dev/gemini-api/docs/interactions-overview) is Google's unified interface designed for all tasks, from simple chat and tool use to complex agentic workflows. It can manage conversation history server-side so your application doesn't have to. ### Live API The [Live API](https://ai.google.dev/gemini-api/docs/live-api) enables real-time, two-way voice and video conversations over WebSockets. It automatically detects when a user speaks or interrupts, making voice interactions feel natural while supporting tools like web search and function calling directly within the live session. ### Batch API The [Batch API](https://ai.google.dev/gemini-api/docs/batch-api) lets you process large volumes of data asynchronously at half price. Jobs run in the background during off-peak hours (usually completing within 24 hours), making it ideal for non-urgent workloads. ### Managed Agents API [Managed agents](https://ai.google.dev/gemini-api/docs/agents) offer a fully hosted runtime environment where AI agents plan and execute tasks autonomously. A single API call provisions an OS-isolated Linux sandbox with pre-installed runtimes like Python and Node, enabling the agent to run code, manage files, and browse the web. Google provides two pre-built managed agents out of the box: - **Antigravity Agent** (`antigravity-preview-05-2026`): The default general-purpose agent powered by Gemini 3.6 Flash (configurable to Gemini 3.5 Flash or Flash-Lite) for code execution, file management, and web access. - **Deep Research Agent** (`deep-research-preview-04-2026`): An autonomous research agent that queries multi-source web data and compiles detailed research reports in the background. You can also extend the Antigravity agent by defining system rules inline or mounting an `AGENTS.md` file, attaching structured skill directories (`SKILL.md`), or mounting local files, Cloud Storage buckets, and Git repositories directly into the remote workspace (`/workspace`). ## Access and billing When integrating Gemini, Google offers two primary access and billing modes depending on your needs: 1. **Google AI Studio (Google AI)**: Routes requests through the Gemini API using an API key (`GEMINI_API_KEY` or `GOOGLE_API_KEY`). Best for prototyping, personal projects, indie applications, and fast developer onboarding. 2. **Gemini Enterprise (formerly Vertex AI)**: Routes requests through Google Cloud endpoints using Google Cloud IAM, Application Default Credentials (ADC), service account keys, or OAuth 2.0 user tokens. Best for production enterprise workloads requiring strict data privacy, security compliance, SLAs, GCP resource management, and committed-use discounts. ## Go GenAI SDK Now let's see how this works in Go code. The official SDK for integrating Gemini in Go applications is [`google.golang.org/genai`](https://pkg.go.dev/google.golang.org/genai). Sometimes you will see it being called the "unified" SDK as it is designed to support all Google models and both Google AI and Gemini Enterprise authentication. It replaces the legacy `github.com/google/generative-ai-go` package which has been deprecated. Install it with `go get`: ```bash go get google.golang.org/genai ``` Here is an example using Gemini Enterprise authentication and billing: ```go package main import ( "context" "fmt" "log" "os" "google.golang.org/genai" ) func main() { ctx := context.Background() // Initialize the client for Gemini Enterprise (Vertex AI) client, err := genai.NewClient(ctx, &genai.ClientConfig{ Project: os.Getenv("GOOGLE_CLOUD_PROJECT"), Location: "global", // Nano Banana models are served globally Backend: genai.BackendEnterprise, }) if err != nil { log.Fatalf("failed to create GenAI client: %v", err) } prompt := "Generate a high-resolution, cute image of a fluffy cat wearing a tiny wizard hat." // Call Nano Banana 2 Lite (gemini-3.1-flash-lite-image) resp, err := client.Models.GenerateContent( ctx, "gemini-3.1-flash-lite-image", genai.Text(prompt), nil, ) if err != nil { log.Fatalf("failed to generate image: %v", err) } // Extract generated image bytes from response parts for _, candidate := range resp.Candidates { if candidate.Content == nil { continue } for _, part := range candidate.Content.Parts { if part.InlineData != nil && part.InlineData.Data != nil { filename := "cute_cat.png" if err := os.WriteFile(filename, part.InlineData.Data, 0644); err != nil { log.Fatalf("failed to save image: %v", err) } fmt.Printf("Successfully generated and saved cat picture to %s!\n", filename) return } } } log.Fatal("no image data returned in response") } ``` To run this example you will need a Google Cloud Project with the AI Platform API enabled. For example: ```sh export GOOGLE_CLOUD_PROJECT="your-project-id-goes-here" go run main.go ``` Here is the result: ![Generated wizard cat image output in Go terminal](image.png "The true purpose of AI: infinite cat picture generation") While this is a simple example to show how the low-level SDK works, these same primitives power everything from local CLI tools to production agent runtimes. ## Where to go from here Now that you know your way around the Gemini model catalog and the Go GenAI SDK, there are two natural paths to explore next: * If you want to use Gemini to write, verify, and refactor Go code faster, check out [**Why Go Is Built for Coding Agents (And How to Tune Yours)**](https://danicat.dev/posts/20260817-gemini-for-go-developers-part-2-coding-with-gemini/). * If you are ready to build tool-calling agents and compare the Go GenAI SDK with higher-level frameworks like [Genkit](https://genkit.dev/) and [ADK](https://adk.dev/), jump straight into [**Building AI Agents in Go: GenAI SDK, Genkit, and ADK**](https://danicat.dev/posts/20260825-gemini-for-go-developers-part-3-building-agents/). --- # Generative AI and Art: Is It Really Just Slop? - **Markdown URL**: https://danicat.dev/posts/genai-and-art-is-it-really-just-slop/index.md - **HTML URL**: https://danicat.dev/posts/genai-and-art-is-it-really-just-slop/ - **Date**: 2026-07-27 - **Categories**: Perspectives - **Tags**: ai, art - **Summary**: An essay exploring whether generative AI art is truly low-effort slop, examining gatekeeping, historical parallels, prompt craft, and creative intent. "Slop" is the defining insult of this tech cycle. It is a useful word. It describes the fatigue of opening a feed filled with lazy content generated by typing three words into a prompt box and publishing the results without any sort of review. But should it be used to describe a whole new category of art without any consideration? ## Disclaimer: this essay is not about AI training Before we get started, let's get the elephant out of the room: I am not going to debate training data, web scraping, or copyright law here. I am not qualified to touch these subjects and I am not even going to try. ![Training Phase vs Inference Layer Diagram](https://danicat.dev/posts/genai-and-art-is-it-really-just-slop/you_are_not_here.png) On the other hand, I am a pragmatist. These models exist and they are here to stay. People are going to use them whether we like it or not. So let's leave the above discussion to the specialists and focus on what is in front of us: inference, or in other words, the process of generating art through prompting. ## The cyclic nature of technology and art Have you ever thought about how when studying History of the world we often study History of Art in parallel? Every major technological revolution creates a new way to make art. We have seen these waves happen with the development of the printing press, photography or computers, just to name a few. Coincidentally, every new form of art was heavily criticised by their own "old guard". In 17th-century Holland, Dutch Realists painted everyday life: merchants, tavern scenes, and quiet kitchens. Classical painters dismissed it as cheap, commercial, and lazy. During the Industrial Revolution, Neoclassicists mocked Romanticism's emotional style as "feverish delirium." Yet both movements filled museums and became the traditional standard that future artists had to fight against. The anger around AI art fits this exact pattern. What we see today is a new medium colliding with old ideas of craftsmanship. ## Slop for some, art for others We never needed AI to create "slop" in the first place. Long before text-to-image models existed, society argued over where art ends and low-effort noise begins. The main question is: where do you draw the line? Let's have a look at some art pieces and their counterparts. ### Classical portrait vs. mass-produced pop art | *Mona Lisa* (Leonardo da Vinci) | *Mona Cat* (Romero Britto) | | :---: | :---: | | ![Mona Lisa by Leonardo Da Vinci](https://danicat.dev/posts/genai-and-art-is-it-really-just-slop/mona_lisa.jpg) | ![Mona Cat by Romero Britto](https://danicat.dev/posts/genai-and-art-is-it-really-just-slop/mona_cat.png) | Da Vinci's *Mona Lisa* is universally accepted as fine art, but what about Romero Britto's *Mona Cat*? Critics often call Britto's work commercial slop due to his use of industrial methods and mass-production, while also being derivative. In your opinion, is the criticism valid or is it just a matter of taste? There is no right answer to this question, as his art evokes different feelings in different people. ### Intention vs. execution | *Ecce Homo* (Elías García Martínez) | *Ecce Homo "Restored"* (Cecilia Giménez) | | :---: | :---: | | ![Original Ecce Homo](https://danicat.dev/posts/genai-and-art-is-it-really-just-slop/ecce_homo_original.jpg) | ![Restored Ecce Homo](https://danicat.dev/posts/genai-and-art-is-it-really-just-slop/ecce_homo_restored.jpg) | What would be considered by most a sloppy effort in restoring a decayed fresco ended up becoming a happy accident for the small community of Borja, Spain. Thanks to the internet, the interest on [Ecce Homo](https://en.wikipedia.org/wiki/Ecce_Homo_(Garc%C3%ADa_Mart%C3%ADnez_and_Gim%C3%A9nez)) (painted by Elías García Martínez circa 1930) grew exponentially and it became a pop art hit and international tourist destination. This is a case where the vision of the amateur artist-restorer Cecilia Gimenez didn't match her technique, but the attempt itself became art, even if it wasn't her original intention that it would take this form. | *Girl with Balloon* (Banksy) | *Love is in the Bin* (Banksy) | | :---: | :---: | | ![Girl with Balloon by Banksy](https://danicat.dev/posts/genai-and-art-is-it-really-just-slop/banksy_girl_with_balloon.png) | ![Love is in the Bin by Banksy](https://danicat.dev/posts/genai-and-art-is-it-really-just-slop/banksy_love_is_in_the_bin.jpg) | Another famous case of where intention and execution didn't match, but ended up creating a new piece of art anyway, is Banksy's "Love is in the bin". What was supposedly a failsafe mechanism to destroy his painting if it ever went to auction malfunctioned in the middle of the shredding process leaving "Girl with Balloon" only partially destroyed. The whole experience of watching a piece of art that had just been sold for over one million pounds being shredded to pieces could be said to be an art expression by itself. Last time it was sold it fetched over 18 million pounds! ### Gallery banana vs. forgotten kiwi | *Comedian* (Maurizio Cattelan) | *Forgotten Kiwi* (Daniela Petruzalek) | | :---: | :---: | | ![Comedian by Maurizio Catellan](https://danicat.dev/posts/genai-and-art-is-it-really-just-slop/maurizio_catellan_comedian_banana.jpg) | ![Forgotten Kiwi](https://danicat.dev/posts/genai-and-art-is-it-really-just-slop/forgotten_kiwi.jpg) | This is maybe the prime example of low effort art of our times, but still fetched $120,000 in an auction. Maurizio Cattelan duct-taped a banana to a gallery wall. The buyer? Ate it. Everything points to this just being an overpriced banana, if not for the context and the piece title: "Comedian". Some people will see it as slop, some people will see it as a critique of consumerism or maybe even of art itself. Why is the Comedian a piece of art but not this kiwi that I forgot in the back of my fridge? (Don't worry, similar to the buyer of the Comedian, I ate the kiwi after taking this picture.) The fact is that art has never been defined purely by the labor required or the physical object. It depends on context, intent, and audience reaction. Unironically, now that my Forgotten Kiwi has been presented in conference talks and this blog, maybe it has, in fact, become art. ## We should not gatekeep art When a new medium arrives, gatekeepers try to define what counts as "real art." They almost always tie their definitions to physical labor, manual skill, or traditional tools. When photography appeared in the 19th century, painters rejected it. They claimed pressing a button was too fast to be art because it required no skill with a brush. It took decades for photography to be recognized as art. ![Eyes of the Night by Jade Gosrani](https://danicat.dev/posts/genai-and-art-is-it-really-just-slop/eyes_of_the_night_leopard.jpg) Pressing a button takes a second. But capturing an image like Jade Gosrani's *Eyes of the Night* takes days of tracking wildlife, mastering light, and waiting patiently. The art is in the vision, preparation, and intent, not the physical click. We already separate vision from manual execution in other fields: ![Frieren Concert in London](frieren_orchestra_london.jpg "Frieren Concert in London. Photo by https://x.com/AnimeBlurayUK") In music, we credit the composer Evan Call for the *Frieren* soundtrack. At a live concert, Call does not play every instrument. Dozens of musicians play under a conductor, while the composer provides the vision, structure, and score. | *Akira* (Katsuhiro Otomo, 1988) | *Titanic* (James Cameron, 1997) | | :---: | :---: | | ![Akira 1988](https://danicat.dev/posts/genai-and-art-is-it-really-just-slop/akira_1988_bike_slide.jpg) | ![Titanic Behind the Scenes](https://danicat.dev/posts/genai-and-art-is-it-really-just-slop/titanic_behind_the_scenes_green_screen.jpg) | In film, Katsuhiro Otomo's *Akira* uses over 160,000 hand-drawn frames. Otomo did not draw every frame himself. A full studio executed his vision. On James Cameron's *Titanic*, dozens of crew members built models and ran visual effects. We credit the director who shapes the story, not every technician on set. Wikipedia offers this broad [definition of art](https://en.wikipedia.org/wiki/Art): > *"Art is a diverse range of cultural activity centered around works utilizing creative or imaginative talents, which are expected to evoke a worthwhile experience, generally through an expression of emotional power, conceptual ideas, technical proficiency, or beauty."* Interestingly enough, the very next paragraph admits even this definition is not a consensus: > *"There is no generally agreed definition of what constitutes art, and its interpretation has varied greatly throughout history and across cultures."* Even the encyclopedia cannot define art. Gatekeeping fails because skill is not just manual labor. It moves to creative direction. ## A pragmatic definition of slop I believe slop is produced when there is lack of vision, effort and/or intent. It is the fast food version of content production. For the mathematically inclined, I like to say that **slop is inversely proportional to inspiration**. \[ \text{SLOP} \propto \frac{1}{\text{INSPIRATION}} \] Inspiration is taking an idea in your head and turning it into something that connects with others. If you bring zero inspiration to a tool, you get slop, no matter how good the model is. Compare a low effort two-word prompt like `anime girl`, against this prompt that carefully explores the domain language of image creation to materialise a vision: ```text Character: teenage girl with pink hair, shoulder length, wavy. Blue eyes, gentle smile. School uniform, white blouse, tartan skirt. Scenario: classroom, back seat, close to the window. Action: girl is sitting at her desk, book open in front of her, but she is looking at the window, holding her chin with her left hand, melancholic look, pensive. Style: anime, watercolor, 90's, pastel tones, shoujo Environment: midday light, natural light from the window, artificial light from the ceiling Camera: low angle, profile picture three-quarter view ``` And the result: ![90s Shoujo Anime Classroom Scene](https://danicat.dev/posts/genai-and-art-is-it-really-just-slop/anime_girl_classroom_90s_shoujo.jpg) The second prompt relies on knowledge of art styles, framing, light, and composition. The output will vary slightly each time, but the mood, framing, and direction stay consistent because the prompt provides clear direction. To guide a model effectively, whether generating images or writing code, you must master the **domain language** of your medium. For cinematic images, you need to know camera angles and lighting (*low angle*, *three-quarter view*, *rim light*). For illustrations, you need to know art styles (*90s shoujo*, *watercolor*). ![Prompt Structure Guide](https://danicat.dev/posts/genai-and-art-is-it-really-just-slop/prompt_structure_guide_kantakanta.jpg) A structured prompt (like this framework by [@kantakanta1233](https://x.com/kantakanta1233)) covers ten specific areas: style, subject, features, outfit, pose, composition, background, lighting, finish, and negative keywords. Building a prompt like this is creative direction, and even after the prompt is created there will be several runs and adjustments before producing the final output. All part of the creative process. ## Having a good prompt is not enough, it needs to be your prompt This question came up during my presentation at the GDG Community Summit and I thought it was worth mentioning here. Let's say someone spends all this time and inspiration crafting the perfect prompt, then someone goes and copies the prompt and runs it with minimal effort. Is it art? In this particular case, it is no different than falsifying a painting or copy-pasting from Stack Overflow. The person that just took the prompt and ran it is a "copycat". Of course it is hard to trace provenance of images on the internet to know if they were produced by their author or someone that just copied the prompt. In the end, trust and reputation will also play a critical role in determining what is art and what is a low effort copy. ## The software engineering parallel Just in case you haven't noticed, everything that is being discussed here about art also applies to our field of software engineering. While many people have already embraced AI-coding, many people still dismiss it as "vibe coding" slop. There is pushback because people believe this is ruining software engineering as a career. For me, AI is quite the opposite. Not only does it help me automate the boring parts of my job (I cannot stand writing another CRUD API, ever!), it is also enabling me to do things that I wouldn't be able to do by myself otherwise. For example, the whole reason I got into the engineering field was because I wanted to develop games, a dream that never materialised (but no regrets there). Now with AI support the barrier to entry is so much lower that I can have fun building my games even with my limited time and skills. I cannot compose music on my own, but with the support from Gemini and Lyria 3 I can direct the model towards my vision. I do not have the patience to draw every sprite and asset, but with Nano Banana I can guide the model to produce the assets I need, and have a playable demo within hours instead of days or weeks. Engineering has never been just about typing code into an editor, just as art has never been just about holding a brush. As AI tools handle more of the mechanical work, like rendering pixels or writing boilerplate Go code, our role moves higher up the stack: to vision, architecture, domain knowledge, and intent. This panic is not the end of human creativity. It is a familiar phase in a cycle we saw with printing, photography, and compilers. Early pushback tries to protect old ways of working. Over time, the resistance fades, people adopt the tools, and the medium becomes normal. What critics reject today as "slop" will become tomorrow's standard tool—and eventually, the old standard that future creators will try to change. What do you think? Is AI art in a temporary friction phase, or do you see it differently? --- # The Rise of the Subagents - **Markdown URL**: https://danicat.dev/posts/the-rise-of-the-subagents/index.md - **HTML URL**: https://danicat.dev/posts/the-rise-of-the-subagents/ - **Date**: 2026-07-22 - **Categories**: Agentic Coding - **Tags**: agile, antigravity, subagents - **Summary**: Explore the evolution of subagents in Antigravity and learn how to orchestrate autonomous coding swarms in Go using the Swarm Coding skill.I must confess that when I first read about **subagents**, I was a bit sceptic. I could understand the benefits of running tasks in separate context windows, but it never really occurred to me to spawn dozens or maybe even hundreds of agents in parallel. Or maybe it's better said that I didn't see the benefit in doing so. Managing a couple of coding agents in the background already takes a huge chunk of my mental bandwidth. I tend to do things in parallel only when I know an agent is busy working on a long-running operation. If managing two or three agents this way is already painful, how can I even dream of managing hundreds of them? It took me a long time to find it, but the answer is actually **you don't**! You delegate the responsibility of managing the subagents to an agent itself. Every problem in computing is solved by a new level of abstraction, right? It is no different this time. In this article, I want to walk you through how I have seen the evolution of the subagent paradigm over the past 12 months or so, then consolidating my experience into one agent skill I call **Swarm Coding**. If you are here for the TL;DR, you can skip the section below and go straight for the skill definition and explanation at the bottom of the text. ## A brief (and incomplete) timeline of subagent evolution Subagents are nothing new. I have been using this trick long before the term subagent was coined, by packaging model calls as MCP tools. For example, the earlier releases of [**GoDoctor**](https://danicat.dev/posts/20250729-how-to-build-an-mcp-server-with-gemini-cli-and-go/) had a `code_review` tool that was a model call to Gemini with a custom [code review](https://danicat.dev/posts/20260303-code-reviews-in-2026/) prompt. This tool was effectively a subagent, although with hardcoded behaviour and no opportunity to continue the conversation. (Technically it was possible, I just never implemented it as I wanted an unbiased review in every call.) Sometime around last winter, all the usual coding agent suspects (Claude, Gemini CLI, etc.) started adding support for custom subagents defined in Markdown files. I really liked this pattern as a way to package expert knowledge with a curated set of tools. In the ideal world GoDoctor would be a specialist agent and not just a set of tools, but I ended up never implementing it this way because the scene kept changing and the subagent standard never really stabilised. Fast forward a few months: in May 2026, Antigravity 2.0 adds subagent support, but with a catch: subagents are defined on the fly by invoking the `DefineSubagent` tool. At first, `DefineSubagent` doesn't give you a lot of flexibility: it clones the current (default) agent with a new prompt. We get the clean context benefit, but we lose on the agent reuse side. I wasn't happy as this prevented me from doing GoDoctor's evolution as I had envisioned it. Because I wasn't able to define my custom agents with a different model and set of tools from the default agent, I decided to ignore that subagents existed and focused on porting the things that worked well on Gemini CLI to Antigravity CLI with moderate success. I would only revisit the idea of subagents because of this prompt, published by [Richard Seroter](https://seroter.com/2026/06/01/one-prompt-four-subagents-and-ninety-seconds-to-get-a-working-app/) back in June: > Let's build a hotel room booking app for Seroter Hotels consisting of a Go backend API and a web frontend. > > First, launch the **Engineering Manager** agent to design the API and frontend, saving the design and a Mermaid diagram into an artifact called 'architecture.md'. > > Once the design is ready, launch three agents in parallel: > 1. **Test Manager**: Write a simple API test plan and append it to 'architecture.md'. > 2. **Backend Engineer**: Build a clean Go REST API with standard error handling based on the design. > 3. **Frontend Engineer**: Build a responsive web UI using a simple CSS framework like Tailwind to interact with the API (skip UI testing). > > As soon as the Test Manager finishes the plan, have them hand it off to the Backend Engineer, who reads the plan from 'architecture.md' and adds the Go tests to the code. After both engineers finish building, the Test Manager runs the tests. Finally, spin up both components and a browser so I can test the live app. This prompt had some very interesting propositions, which made me revisit the pattern, but I was still concerned about two things: one, how much do I need to adapt my prompting style to think in terms of subagents and, two, why would I want to write things this way in the first place? I'm very pragmatic: if I don't have a clear benefit in quality and/or speed, I don't want to spend the extra effort. Thinking in terms of subagents is very similar to how we think about concurrency in classic programming: first question is "is this thing even 'parallelisable'?", and second, "is it even worth it?" as the additional overhead will often kill minor gains. In Richard's prompt, the only components that are clearly orthogonal are backend and frontend development. They don't rely on each other as long as they have a clear contract to implement. But all other agents have some sort of dependency on each other which makes them more serial than parallel. The benefit, then, must come from context isolation alone instead of speed gains due to parallel operations, and this is hard to measure on this scale. I spent maybe the next couple of weeks with this thought running in the background of my mind: "What kind of roles are orthogonal to each other so that I can leverage subagents?" It was only after a series of insightful conversations at the GDE Summit in Berlin that I finally cracked the code: It is not about **you** defining the subagents in the prompt, but **teaching** the agent itself to decide when to spawn subagents. In essence, I was thinking as a lead engineer splitting work for my team, but what I needed to do is actually to make the coding agent the lead engineer. ## The birth of swarm coding The act of breaking down complex tasks into small ones and helping to distribute tasks among team members is not new to me. Before entering Developer Relations, I served as a Tech Lead and then Principal Engineer. These tasks are literally the bread and butter of technical leadership, especially if you come from an Agile background like I did. The same TL logic applies to creating your swarm of subagents: you want to make sure every agent has a self-contained task that it can work on fully independently of the others. For the task to be workable, it must have clear specifications (aka Definition of Ready) and clear end results (aka Definition of Done). As a side note, not many people describe this part of the job as the one they enjoy the most (myself included), which should explain my resistance to developing a new prompting style that would essentially become tech leadership on steroids. So, instead of acting as a TL for my agents, I decided to flip the script and teach my agent to become the TL and assemble their own team to execute my vision. This was how the [first version of the swarm coding skill](https://github.com/danicat/skills/blob/a9f57b10127d8bd23ed4867d64d168063a3726f4/swarm_coding/SKILL.md) came to be. An excerpt of the main parts can be seen here: > Swarm Coding is a new development paradigm that employs multiple sub-agents in parallel to work on complex tasks. It is based on the divide to conquer strategy. The main benefits of this strategy is context isolation and quality improvement: by assigning small self contained tasks to sub-agents, you avoid context dilution and enable very focused refinement of the solution. For example, without swarm coding an agent implementing both frontend and backend will often get distracted as the skills required for frontend and backend are often unrelated (different technology stack, different best practices, etc.) > > ## ROLE > > You are the SWARM COORDINATOR, your role is to break down complex tasks and DELEGATE to sub-agents for execution. You should NEVER execute tasks on your own, no matter how simple they seem to be UNLESS it is EXPLICITLY requested by the user or your parent coordinator. ALWAYS keep the communication channel open so the user or parent agent can send you steering commands. > > ## AGENT BUDGET > > It is the number of sub-agents you are allowed to spawn to work on a task. You are encouraged to use the FULL BUDGET of agents, or get as close to it as possible. This doesn't mean to waste resources on low value tasks, but in finding the optimal use of the BUDGET to achieve the best quality output. > > ## TEAM BUILDING > > For SIMPLE tasks, break down the task into orthogonal elements and assign one or more SPECIALIST agents for each element. > For COMPLEX tasks, break down the task into smaller pieces and assign LEAD AGENTS to each of them. The LEAD AGENTS should have a fraction of the agent budget to execute the task. LEAD AGENTS should activate the swarm coding skill and become the SWARM COORDINATOR for their respective areas > Proceed recursively until you have a complete tree of LEAD AGENTS and EXECUTOR agents. > > ## COMMUNICATION > > The SWARM COORDINATOR is responsible for communicating directly with its sub-agents. Sub-agents should not message each other,communication between agents at the same level should be made by DESIGN DOCUMENTS. It is the SWARM COORDINATOR responsibility to make sure all changes to design documents are broadcast to the agents in their squad. Upon conflict, the SWARM COORDINATOR is responsible for disambiguating and making a decision. > > ## PLANNING > > Planning is a FIRST CLASS effort and should also be made using the SWARM. Each AGENT should contribute to the plan with their expertise. It is the role of the SWARM COORDINATOR for a squad to revise the part of the plan produced by their team and address inconsistencies or make decisions when there is conflict. > > ## EXECUTION > > In execution phase, monitor the progress of the swarm accross the main milestones, and steer agents if necessary to keep them aligned with the end goal. Remember that as coordinator you are ONLY allowed to handle ARTIFACTS. All development tasks should be handled by leaf sub-agents. This was my first time actually writing a skill 100% manually, as it was really hard to achieve my vision otherwise. This prompt was a bit too ambitious as I wanted the Swarm to be "recursive" and based on the agent budget alone the agent would decide if it was a coordinator or not, but this didn't work as expected. What happened in practice was that the task the coordinator gave would take precedence over any other instructions, and the subagent would jump straight to execution mode without paying attention to the agent budget. I fixed this in the current version of the skill by providing clearer guidelines and prompt templates to spawn the subagents. ## Taking the swarm for a spin You can find the current version of the **swarm coding** skill in [Danicat's skills repository on GitHub](https://github.com/danicat/skills). You can install it in your favourite coding agent with the command below: ```bash $ npx skills add github.com/danicat/skills --skill swarm-coding ``` > Note: This skill is very much a work in progress, so make a fork if you want to fix it to any specific implementation Here is a fun prompt to get things started. Try running it on Antigravity CLI: > /swarm-coding agent budget 50. Develop a 2D tower defense survival game using Go and Ebitengine. The game should be feature complete and have one single screen level. Include an intro sequence, title screen, game win and game over screens as well. Track the high score at the end of each playthrough. Use 32x32 sprites with up to 256 colors each. The sprites should be custom designed for this game and each movement should have at least 3 frames of animation, but ideally 8. Tiles should be 32x32 as well. The level view is top down, movement is on four directions. The player should have access to 4 types of units and 4 types of buildings. The enemy waves should have 8 types of monsters, including one boss monster. Use typical build and attack phases with custom UIs for each. To create art, use vector graphics and/or dot (pixel) art creating each asset manually using binary data. Sound effects should be generated mathematically as well. The whole vibe of the game should match the 16-bit era, but with modern gameplay features. Here are the results on my machine: ![Swarm Defense](image-1.png "Screenshot of the game created by the swarm") I cannot say this was a one-shot because the first build had a bug rendering the sprites so the entire screen was black, but after one more prompt reporting the problem the game rendered as shown above. Here is a small video of it in action with the final boss fight (poor thing didn't have a chance): Every asset in this video was generated programmatically or, in other words, Antigravity didn't have access to an image generation model. So it had to be creative and generate the sprites at bitmap level. This technique only worked so well because the swarm allowed agents to specialise and focus on a single task. I've tried this kind of prompt before with a single agent and it will often produce subpar results. Give an agent too many orthogonal tasks and it will clearly become a master of none. But with delegation, each agent can have a single self-contained task and perform at its best. ## Subagent support in Antigravity 2.0 and Antigravity CLI At the time of this writing, subagent capabilities are unevenly distributed between Antigravity 2.0 and the Antigravity CLI. Because these interfaces are built for different workflows, their subagent features have temporarily diverged. Given that both tools are rapidly evolving, we can expect this feature gap to narrow as both interfaces continue to mature. At their core, both environments share the same underlying engine. Spawning a subagent delegates the task and immediately returns control to you. The subagent runs with a clean slate: it uses the same model as the default session but starts with a fully isolated context, preventing conversation history from leaking. The parent agent communicates with it via unique IDs. If it hits an unapproved command, it bubbles the permission request up to you. The notable differences between the two interfaces are: - In Antigravity 2.0, management is visual. You use a graphical sidebar to track running tasks, view conversation logs, or stop execution. Custom agents are created dynamically on the fly using the `DefineSubagent` tool. There is no plugin support for subagents. - In the Antigravity CLI, in addition to dynamically creating agents, custom agents can also be defined statically in Markdown files, where you can use frontmatter options to pin specific models or control available tools. The CLI also supports loading custom subagents defined inside plugins using the Markdown format. Understanding these interface differences is key to setting up your swarm environment today, but, as stated before, these capabilities are likely to converge as both tools continue to evolve. ## Try it yourself I think the best way to experience the power of subagents is to try it yourself. Whether you want to try reproducing my example prompt or come up with your own, I believe that you are going to be impressed with the results. Let me know about any fun things you build with the swarm. Meanwhile, I will be here polishing [Swarm Defense](https://github.com/danicat/swarm-defense) a bit more. :) - Check out swarm coding and all my other skills in the [danicat/skills repository on GitHub](https://github.com/danicat/skills) - Download and read more about Antigravity on the [official Antigravity website](https://antigravity.google) --- # State of Agentic Coding in 2026: Skills, Hooks, Subagents - **Markdown URL**: https://danicat.dev/posts/state-of-agentic-coding/index.md - **HTML URL**: https://danicat.dev/posts/state-of-agentic-coding/ - **Date**: 2026-06-15 - **Categories**: Agentic Coding - **Tags**: agent-skills, antigravity, hooks, mcp, subagents - **Summary**: Retrospective on agentic coding in 2026. Compares planning systems, Agent Skills vs MCP, subagent orchestration, and deterministic hooks in production. It has been more than six months since I published the article [Taming Vibe Coding](https://danicat.dev/posts/20251206-taming-vibe-coding/) which consolidated all the main practices I had been using to increase my productivity with coding agents. While most of the content in that article is still relevant today, in the past six months a lot has happened in this space, so I decided to post a quick update on how my thinking evolved since then. ## Prompting & context engineering Prompting is still important, and a good structured prompt will save you a lot of time, but it is not the deal breaker it used to be due to the emergence of planning systems. Most coding agents ship today with a plan (or planning) mode where they spend a few turns "brainstorming" the task to then elaborate an implementation plan before jumping to coding. This gives you the opportunity to review the plan and steer the agent before code is written, saving a lot of time and tokens. What didn't change is the need for a strong acceptance criteria to ensure consistent results. This is the part that I'm usually paying the most attention when I review a plan. Context engineering has massively improved with the widespread adoption of agent skills, which makes me hardly care about writing an AGENTS.md (or GEMINI.md, CLAUDE.md, etc.) nowadays. In Antigravity we have the concept of Rules which is essentially context optimisation, but I hardly use them in favour of skills. I have yet to find something that I need to do in AGENTS.md or rules that I can't do using another technique. The aspects of context engineering related to Retrieval Augmented Generation (RAG), either via semantic search or any other means, are still relevant for specialised knowledge. This is how memory systems work and I still use the technique to inject package documentation in my context whenever working with an external dependency. ## The rise and fall (?) of Model Context Protocol Instead of spinning up MCP servers, more and more people are preferring to use agent skills combined with CLI tools. While I respect that, I do have some trust issues giving my agents direct shell access and I prefer to package my tools in MCP servers that I run locally (like [GoDoctor](https://danicat.dev/posts/20250729-how-to-build-an-mcp-server-with-gemini-cli-and-go/) or [Speedgrapher](https://danicat.dev/posts/20250805-introducing-speedgrapher/)). The only thing that changed for me is that I am much more selective in installing MCP servers, and most of the time I have only my custom made ones configured. I also almost never use an MCP server alone, but I use a combination of skill plus MCP. The skill describes the process, the MCP exposes the tooling. This is often better than trying to optimise the MCP instructions themselves, which I spent hundreds of hours doing, only for coding agents to ignore them completely. While I use MCP + skills for most of the time, for really serious stuff I use the "super combo" MCP + skills + hooks. The hooks part is responsible for forcing the agent in the direction I want to go. Sometimes I call this "putting the agent on rails" or "reducing the agency of the agent." The hook system allows me to block the undesirable actions and give the agent a "gentle nudge" to use the tool I want it to use, removing the element of probability of having a tool called or not. Or in other words, it forces a deterministic behaviour of the agent. ## Skills for everything I create skills for processes that I want to be repeatable. For example, some of my most used skills are related to tech writing and reviewing because I am always producing content (like this blog). I also write skills about technologies that I know the agent is going to have trouble dealing with. Typically those include newer technologies, niche projects or custom made ones. For example, just recently I had a very hard time preparing my A2UI workshop. A2UI is a new-ish protocol to develop agentic user interfaces. Because it is such a novel concept, and I had very specific teaching needs, the agents couldn't figure it out without a lot of help and trial and error. Once the initial hurdles were overcome, packaging this knowledge in a skill helps smoothing things over the next time I need to do something similar. On the negative side, I am terrible at keeping my skills up to date and organised. I think solving this problem can be the redemption moment for MCP as there is a current proposal to add skills to the protocol specification. Unfortunately, there is no estimation of when this will happen, if ever, so for now we are stuck managing skills on our own. In theory you could create a "skill-like" experience with MCP using prompts for the progressive disclosure part and tools for whatever needs scripting, but given my current backlog I haven't tried doing this yet. ## The rise (and no fall) of subagents [Subagents](https://danicat.dev/posts/20260722-the-rise-of-the-subagents/) are the "next cool thing" that everyone in the village is talking about. The idea is to parallelise tasks by launching them as their own agents with a segregated context window. This has the benefit of optimal usage of context, avoiding contamination between tasks and early context rot. This also reduces the need for compression as each task is self contained and won't pollute your main context window. In terms of coding harness support, some harnesses support pre-declaring agents just like you can declare a skill, each with their own system prompt, model and configurations, while others like Antigravity favour adhoc creation of agents with each agent being a "clone" of the main session, but with their own context windows. While ad-hoc creation of agents allow you to do crazy things like using a prompt to spin up 3 different agents to run things in parallel, I do miss having pre-declared agents in Antigravity as they allow me to pack my "curated" agents in portable ways. Also, parallelising tasks between agents is not a trivial skill to master - in the last year I got used to give tasks to agents, but I haven't really thought much about how to efficiently orchestrate them. In many ways this is the same muscle as a product owner or team lead exercises when breaking down tasks and thinking about how the team will tackle them, but with sub-agents instead of having a fixed team you can create as many "team members" as you want. Ultimately, I care less about parallelising agents because its "cool", and care more about the question "is this going to produce better outcomes?" If the answer to the question is **no**, then you are better off running one agent at a time, as the effort spent in planning and the mental toil of carefully dividing the tasks will not pay off. This is why I prefer being able to pre-define my agents, as I am just specialising them for when they are needed, but it doesn't really matter to me if they run in parallel or not. ## Hooks are your best friends I saved my favourite one for last: hooks. Hooks are callbacks that will be triggered upon specific events on the agent lifecycle. I wrote an [entire article about hooks](https://danicat.dev/posts/20260610-mastering-hooks/) last week, which I encourage you to check right after you finish this one. The bottom line is, models are unpredictable and can go off the rails quite easily. Hooks are a good way to add guardrails to the models so you can steer them in the direction you want without relying on chance. More than that, they allow you to plug monitors to the agentic harness to collect data and improve the response quality, like for example adding a persistent memory system. ## Conclusions The industry is moving fast and to stay relevant we need to be adaptive to embrace new processes and techniques as they come and release the baggage that is holding us back. Still, do not take any of guides out there as the source of truth (including this one). It is a learning experience for everyone as this technology is still in its early stages. The key is to experiment and see what kind of technology and workflow works best for your environment. In this article I shared what is working for me, and how my thinking has been evolving, but I do not know everything and I am always learning. We talk a lot about training AI, but don't forget that training your brain is much more important than that. Do not use AI as an excuse to disable your brain, keep experimenting, learning and iterating. And please, share any interesting stuff that you find out! --- # Mastering Hooks in Coding Agents: A Practical Guide - **Markdown URL**: https://danicat.dev/posts/mastering-hooks-in-coding-agents/index.md - **HTML URL**: https://danicat.dev/posts/mastering-hooks-in-coding-agents/ - **Date**: 2026-06-10 - **Categories**: Agentic Coding - **Tags**: antigravity, automation, hooks, security - **Summary**: Deep dive into hooks for coding agents like Google Antigravity. Learn to intercept PreToolUse and PostToolUse events to enforce linting and DLP guardrails. Coding agent capabilities are moving fast. My first contact with them was roughly one year ago, just a little after I joined Google. Back then the big deal was the Model Context Protocol (MCP), a flexible technology replacing ad hoc implementations of tools with portable ones (among other things). Fast forward twelve months, and today most people seem to have moved away from MCPs towards Agent Skills as the next cool thing. By introducing "progressive disclosure", skills allow for a more efficient usage of context, which results in better token economy overall. With rising inference costs, it is no surprise that skills became so popular. Both MCP and skills have their own "fanbases", but there is another coding agent standard that emerged within this time period and is not as often mentioned as their more popular counterparts: the hooks. While MCPs and skills are focusing on extending agentic capabilities (by adding tools and knowledge), hooks operate at a different level, giving you more control over the agent loop and the development process overall. ## Agent hooks explained The name hook might not ring a bell at first, but hooks are "callbacks" — procedures that are called at specific moments during the agent processing lifecycle. Hooks have three components: - A trigger event: when the hook will be called. Typically composed of an execution phase (pre or post) and a context, such as a tool call or model invocation. For example, in Antigravity, we have events like `PreToolUse`, `PostInvocation` and `Stop` (upon agent termination). - A condition or filter: a regular expression based on the trigger event. For example, in a tool call, the filter can be the tool name and may include the tool arguments. For example, it is possible to create a hook for the tool call `run_command(git)`. - A procedure: the body of the hook, either as a shell script or command. The procedure can be used to either allow or disallow an operation, to override model or tool calls completely, and to produce side effects like logging or telemetry. ## When to use hooks Hooks intercept the agent lifecycle at specific moments to inject custom commands or scripts. By intercepting the right moment, you can control the flow of operation and add deterministic outcomes to what otherwise is mostly a non-deterministic process. For example, developers often attempt to enforce coding guidelines via either a system prompt or an AGENTS.md file (or similar). However, prompt-based guidelines offer no execution guarantees due to the non-deterministic nature of large language models: the exact same prompt can produce different outcomes, and agents can selectively ignore parts of the prompt. Using hooks instead of prompts, you can enforce a specific action. Let's say for example that you want to ensure your agent always runs a static analysis tool in the code (a.k.a. a linter) after every code edit to ensure the code is always sanitised. Adding "always run a linter after edits" to your prompts leaves validation up to the agent. It can selectively skip this step if it deems an edit "trivial." But if you create a hook instead — in this case a `PostToolUse` filtering for the file editing tool — you can deterministically ensure your static analysis tool will run after every edit. By intercepting these lifecycle events, we can implement several patterns to control agent behaviour, gather metrics and keep workflows secure. Let's have a look at some of these below. ### Steer the agent towards specialised tools Hooks are useful in many scenarios, but my favourite use case is to put guardrails around the agent, or as sometimes I like to say: "reducing the agency of the agent". We can implement this by pairing a `PreToolUse` hook with a script denying the tool access and returning a "steering hint" to the coding agent. This steering hint will contain the instructions you want it to perform instead. For example, let's say you want to prevent the agent from using shell commands to read Go files, a steering hint could look like this: "Tool call blocked - run_command(cat): do not use 'cat' for reading *.go files, use 'smart_read' instead". ### Intercept malicious prompts A `PreInvocation` hook can intercept incoming prompts and evaluate them against safety heuristics or lighter classifier models. If a prompt looks like a jailbreak attempt, the hook can block the request immediately, shielding backend systems before they reach the execution loop. ### Prevent credential leakage Developers sometimes accidentally paste env files or credentials into active files that the coding agent reads. A `PostToolUse` hook listening to file reads — or a `PreInvocation` hook scanning the payloads sent to the LLM — acts as a reliable Data Loss Prevention (DLP) gate. If the hook detects strings matching high-entropy keys or standard API formats, it can redact the secrets dynamically or abort the run to keep credentials secure. ### Managing memories Agents are typically stateless unless they are plugged into an external memory system like [Agent Platform Memory Bank](https://docs.cloud.google.com/gemini-enterprise-agent-platform/scale/memory-bank) or [MemPalace](https://github.com/mempalace/mempalace). One way to add memory capabilities to agents is by registering memorisation and retrieval as tools, but doing so we depend on the agent explicitly making a decision to call the corresponding tools. The hook system allows you to automate the memory persistence and retrieval. You can connect the [memory generation](https://docs.cloud.google.com/gemini-enterprise-agent-platform/scale/memory-bank/generate-memories#triggering-memory-generation) to the end of a session (using a `Stop` hook) or after a certain number of turns (by monitoring step number or number of model invocations). Conversely, memory retrieval can be done automatically at the start of the session and before invoking models (e.g., a `PreInvocation` hook). For example, in Agent Platform Memory Bank you can retrieve memories by [scope](https://docs.cloud.google.com/gemini-enterprise-agent-platform/scale/memory-bank/fetch-memories#retrieve-all) (e.g., scope could be a user ID) or [similarity](https://docs.cloud.google.com/gemini-enterprise-agent-platform/scale/memory-bank/fetch-memories#similarity-search) (based on a query). This is essentially a memory-based retrieval augmented generation (RAG). ### Collect telemetry data The hook system is also a good place to put your telemetry collectors and loggers, as it gives you good visibility of the inner workings of the agent. Personally, I have been tempted to create a "curse word counter" hook for a while, in an attempt to develop awareness and move towards a better relationship with my agents before the AI overlords take over (joking :). ## Configuring hooks in Antigravity While different agent engines implement their own vocabulary for callbacks, in this section we are going to focus on the specific **Antigravity dialect** of hooks. For a full look at the specifications, check out the official [Antigravity Hooks Documentation](https://antigravity.google/docs/hooks). Antigravity looks for a `hooks.json` file inside your workspace's `.agents/` directory (or globally in your home directory at `~/.gemini/config/hooks.json`). Here is an example of how to implement the steering hints and static analysis as discussed above: ```json { "linter-safety-gate": { "PostToolUse": [ { "matcher": "write_to_file|replace_file_content|multi_replace_file_content", "hooks": [ { "type": "command", "command": "./scripts/run-linter.sh", "timeout": 15 } ] } ] }, "restrict-cat-on-go": { "PreToolUse": [ { "matcher": "run_command", "hooks": [ { "command": "./scripts/steer-go-reads.py" } ] } ] } } ``` The inputs to these hooks are delivered via `stdin` as a JSON object, containing context like the tool arguments (`toolCall.args`), active `workspacePaths`, and the filepath of the current session log (`transcriptPath`). Your scripts can evaluate these, perform computations, and print a JSON response on `stdout` telling Antigravity whether to `"allow"`, `"deny"`, or `"ask"` for user confirmation. For example, here is how you can write a simple Python script (`steer-go-reads.py`) to parse that incoming payload and steer the agent: ```python import sys import json def main(): # Read and parse the incoming trigger event payload from stdin try: payload = json.load(sys.stdin) except Exception as e: # Standard safety gate fallback print(json.dumps({ "decision": "deny", "reason": f"Failed to parse stdin payload: {e}" })) return tool_call = payload.get("toolCall", {}) tool_name = tool_call.get("name") tool_args = tool_call.get("args", {}) # Match the specific tool and check arguments if tool_name == "run_command": command_line = tool_args.get("CommandLine", "") # Detect if command attempts to cat any Go source files if "cat" in command_line and ".go" in command_line: response = { "decision": "deny", "reason": "Tool call blocked - run_command(cat): do not use 'cat' for reading *.go files, use 'smart_read' instead." } print(json.dumps(response)) return # Default to allow if no rules are violated print(json.dumps({ "decision": "allow" })) if __name__ == "__main__": main() ``` ## Regaining control Agents are getting smarter and faster, allowing us to produce code faster than ever before. But speed without control is the recipe for disaster. I often use this analogy in my talks: if you like fast cars, the most important thing you need to care about is not the engine, but the brakes. If the brakes are less powerful than the engine, you won't be able to stop and your safety is compromised. The same thinking should be applied to coding agents. If you want to write code fast, you need a powerful control system that ensures you are not sacrificing quality and introducing bugs, because if you do your application is going to have big problems sooner or later. Hooks are a good place to implement the guardrails that can put us back in control, bridging the gap between AI agency and robust software engineering. As I read recently in [this article by Joe Bertolami](https://venturebeat.com/technology/agentic-ai-solved-coding-and-exposed-every-other-problem-in-software-engineering): "writing code was never the rate limiter". Let's not forget decades of engineering best practices and equip our agents with the right tools for the job, so that we can fully enjoy the modern agentic coding experience. --- # The Hitchhiker's Guide to Antigravity 2.0 - **Markdown URL**: https://danicat.dev/posts/the-hitchhikers-guide-to-antigravity-2-0/index.md - **HTML URL**: https://danicat.dev/posts/the-hitchhikers-guide-to-antigravity-2-0/ - **Date**: 2026-05-21 - **Categories**: Agentic Coding - **Tags**: antigravity, cli, google-cloud, python, sdk - **Summary**: Comprehensive guide to Google Antigravity 2.0 from Google I/O 2026. Covers the Agent Manager desktop app, the Go-based agy CLI, and the Python SDK. Since [Google I/O 2026](https://blog.google/innovation-and-ai/technology/developers-tools/google-io-2026-developer-highlights/) just wrapped up it is now the time to decompress all the new releases and how they will affect our workflows now and in the near future. While lots of interesting things were announced, today I want to focus on what impacts developers the most which is the release of [Antigravity 2.0](https://antigravity.google/blog/introducing-google-antigravity-2-0) and the expanded Antigravity (agy) ecosystem (see the [Google I/O 2026 Antigravity highlights](https://antigravity.google/blog/google-io-2026)) which includes [Antigravity CLI](https://antigravity.google/blog/introducing-google-antigravity-cli) and [Antigravity SDK](https://antigravity.google/blog/introducing-google-antigravity-sdk). Before getting into the technical details, I feel the need to address that there is a lot of noise on the web due to this launch and, unfortunately, not of the good kind. The main reason for this is that Antigravity 2.0 introduces breaking changes in many aspects of the development flow, starting with the separation of the IDE environment from the main Antigravity desktop app. Secondly, the [announcement of the deprecation of Gemini CLI](https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/) in favour of Antigravity CLI was also not well received due to the short deadline given for users to migrate from one to the other (plus a few quirks we are going to see below). In essence, users are given until June 18, 2026 to migrate, basically a month from I/O, which frankly is not a lot. I wrote about this before and I understand how it is frustrating when your favourite product is deprecated. I, for example, still grieve Google's Inbox which is a long forgotten email client that lost the battle against Gmail. I'm not here to sugarcoat anything, Google indeed has the reputation of killing good products. But, personal preferences aside, when you look at the big picture, I actually admire Google for being bold enough to kill products the way it does. I believe most people expect from Google to lead the disruption in all things technology related, and specially today, in such a dynamic environment due to the advancements in AI, it takes a lot of courage and determination to pivot from one direction to another. I talk a lot about Agile, and while Google is not typically associated with formal Agile procedures, this is a characteristic that all experienced agilists will recognise as one of the most valuable in a company: the capability to course correct fast, pivot, experiment, learn from mistakes and iterate. Instead of staying in the comfort zone, this is what keeps Google always at the edge: its capability to reinvent itself, even if not every experiment succeeds. Actually, it is expected that many experiments will fail. This is how we learn about what works and what doesn't. We take the lessons and move on to the next goal, incorporating those in the newer products. There will be many lessons to be learned from this release, but ultimately, if you look at the tech itself, hopefully it will become clear what is the endgame: we are doubling down in the agentic-era, while consolidating efforts to build more advanced products. ## The new Antigravity desktop app explained The biggest change in the desktop app is the removal of the IDE component. In Antigravity 1.x, the app was based on a fork of VS Code, so you had all those familiar IDE features to navigate and edit code paired with an assistant box that you could use to interact with the agent. Not only that, you also had a secondary UI called the "Agent Manager" where you could have a 10,000-foot view of different chat sessions (aka "conversations"), meaning you could work on many projects in parallel by monitoring the agents in this view and reacting when they were waiting for input. The biggest change in the new desktop app is that Antigravity 2.0 puts the agent manager experience front and center, removing the IDE part completely (the IDE part became a separate and optional app). ![The new Agent Manager interface](image.png "The new agent manager interface is cleaner, focused on projects and conversations") For seasoned developers, this became a huge friction point because suddenly all the familiar editor tools they had relied on for so long were simply gone. You can still see files in the agy 2.0 UI, but only the ones agy is currently working on and you cannot edit them directly. Every interaction is made through a prompt or an annotation on the file. ![File view in agy 2.0](image-2.png "You can still see files on the UI, but not edit them directly") The interactions with the agent should be already familiar to anyone coding with agents in the past year. Once you give it a prompt, it will elaborate an implementation plan, which you can review using in-line comments or a top level prompt, and once approved the agent goes on its own to execute. Depending on how you configure the UI, every now and then it might interrupt you asking for a permission, which you can either give or reject with an optional course correction. ![Agent Manager asking for user input](image-1.png "When you reject a request you can add an steering comment") In terms of extensibility, agy 2.0 supports common standards we have grown used to in this past year including MCP and Skills, but also its own "Rules" mechanism which was inherited from 1.x (essentially a composable AGENTS.md) and a new plugin system based on the extension system of the former Gemini CLI. Plugins allow you to pack together additional rules, slash commands, MCP servers, skills and sub-agents, and they are backward compatible with Gemini CLI extensions (meaning you can install CLI extensions on agy, but not the other way around). Overall, while I understand the frustration of people who miss the IDE, my initial impression is that I don't miss it being in the **same** app. Even when working with Gemini CLI, I always had VS Code running in parallel for when I want to do manual edits, and this is the same flow I'm applying to agy 2.0. In fact, I use VS Code mostly as a text editor and hardly ever use any proper IDE features nowadays. I could replace it with Notepad and it wouldn't make much of a difference, except for the lost muscle memory for a few shortcuts, which is the only reason why I keep using VS Code these days. While I must say there is nothing groundbreaking in agy 2.0 in comparison with agy 1.x or even other coding agents, I am quite enjoying the cleaner look, and I believe I will only extract its full power when I start customising it with my own plugins. I'm currently working on upgrading [GoDoctor](https://danicat.dev/posts/20250729-how-to-build-an-mcp-server-with-gemini-cli-and-go/) and [Speedgrapher](https://danicat.dev/posts/20250805-introducing-speedgrapher/) from their Gemini CLI extension forms to agy plugins and will report back as soon as I have something to show. ## Antigravity CLI For terminal users, the command-line experience is rebuilt under the new [**Antigravity CLI**](https://antigravity.google/blog/introducing-google-antigravity-cli) (aka. `agy CLI`). It can be a bit confusing at first, but you need to install the agy 2.0 app even if you only plan to use the CLI, as they share the same authentication process. agy CLI is the natural replacement for Gemini CLI and while it doesn't provide a 100% feature match, the main suspects are already there, namely [hooks](https://danicat.dev/posts/20260610-mastering-hooks/), [skills](https://danicat.dev/posts/20260829-the-pragmatic-guide-to-agent-skills/), [MCP](https://danicat.dev/posts/20250817-hello-mcp-world/), [sub-agents](https://danicat.dev/posts/20260722-the-rise-of-the-subagents/) and plugins. The whole CLI was rewritten in Go (Gemini CLI was in TypeScript), which makes me profoundly happy as we can expect a snappier experience. At the same time, one of the main criticisms is that agy CLI, as of today, is closed source, which might feel as a regression from Gemini CLI. Not long ago we were making jokes about "leaking" Gemini CLI code to the public. Unfortunately this joke didn't age well as now our main coding agent is also closed source. Considering I have zero control over this, it is also something I decided not to bother about. It is too early to say whether this is a good or bad decision, but again, I recognise the frustration of the community especially if you contributed to Gemini CLI before. If it serves as a consolation, we will still have a thriving open source community around the plugin system. At least, I am working on mine to make sure we have both a decent Go expert sub-agent and a vibe-writing companion very soon. ![agy CLI interface](image-3.png "The UI will feel familiar for people coming from Gemini CLI or Claude Code") In terms of the UI, it shouldn't surprise anyone who has used a CLI coding agent before. My first impression is that the render indeed feels better than the TS render in Gemini CLI. Also, similarly to agy 2.0, I really appreciate the cleaner look. In my personal opinion Gemini CLI was getting a bit too big for its own good, too many features and a bloated UI, so this cleaner interface feels refreshing to me. One of my all-time favourite sayings is "less is more", and agy CLI delivers in this aspect. Where it doesn't deliver very well (for now), is mostly related to compatibility with extensions. Although there is a migration path, it doesn't always work as expected, and this is why I have been dedicating most of my week to rewriting godoctor and speedgrapher, as I prefer to not rely on the automatic migration. Second to that, I also had issues with project based authentication, which I hope it is fixed soon. For now, I have been using it with my Google Pro subscription as project based authentication didn't work for me. Without getting into the complexities of billing, which is another pain point for users coming from Gemini CLI, my personal opinion is that agy CLI has its issues, but also has great potential. So far there is nothing revolutionary about it (as most of the changes are happening under the hood), but also I cannot see any dealbreakers. Everything that I did on Gemini CLI can be done with agy CLI and there is very little to be learned so, even if Gemini CLI had a longer migration window, I would recommend migrating to it as soon as possible to ensure your workflow is future-proof. ## Antigravity SDK The discussion so far has been about replacing old products with new ones, which feels more incremental than revolutionary. This is why the release of [**Antigravity SDK**](https://antigravity.google/blog/introducing-google-antigravity-sdk) was the most exciting announcement for me. When I spoke about the majority of the changes happening under the hood, it was about creating this unified platform to support agents, and Antigravity SDK is how you as a developer can also have access to it. Here is a functional example of an agent querying its workspace in under 15 lines of code: ```python import asyncio from google.antigravity import Agent, LocalAgentConfig async def main(): config = LocalAgentConfig() async with Agent(config) as agent: response = await agent.chat("What files are in the current directory?") print(await response.text()) if __name__ == "__main__": asyncio.run(main()) ``` This [Python](https://xkcd.com/353/ "import antigravity") library gives developers programmatic access to the same agentic runtime and orchestration harness. The SDK is runtime-agnostic and allows spinning up a stateful agent loop in under 15 lines of code. It supports modular capabilities including built-in tools, custom functions, Model Context Protocol servers, sub-agents, and reusable skills under a unified pipeline. ## Getting started A common trend among all releases relating to Antigravity is this shift from code-first to design-first. The whole software development experience is being redesigned about coordinating agents instead of editing code. To prepare your development environment for this shift, consider the following actions: 1. **Download the desktop app**: Visit [antigravity.google](https://antigravity.google) to install the desktop application. 2. **Migrate terminal workflows**: Install the `agy` CLI and run the import command to migrate your Gemini CLI configurations before the **June 18, 2026** deprecation date (refer to the [migration announcement](https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/) for details). 3. **Explore the SDK**: Install the Python library, check out the [Antigravity features](https://antigravity.google/docs/features), and begin building custom agents powered by agy SDK: ```bash pip install google-antigravity ``` ## Additional resources To learn more about this release and access additional technical details, check out the following resources: * **[Introducing Google Antigravity 2.0](https://antigravity.google/blog/introducing-google-antigravity-2-0)**: The official announcement of the 2.0 ecosystem. * **[Introducing Google Antigravity CLI](https://antigravity.google/blog/introducing-google-antigravity-cli)**: A deep dive into the new Go-based terminal interface. * **[An Important Update: Transitioning Gemini CLI to Antigravity CLI](https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/)**: Detailed migration timeline and guidelines for Gemini CLI users. * **[Introducing Google Antigravity SDK](https://antigravity.google/blog/introducing-google-antigravity-sdk)**: Learn how to programmatically orchestrate agents in Python. * **[Google I/O 2026 Developer Highlights](https://blog.google/innovation-and-ai/technology/developers-tools/google-io-2026-developer-highlights/)**: The major developer announcements from this year's Google I/O. * **[Google I/O 2026: Antigravity Announcement](https://antigravity.google/blog/google-io-2026)**: Key updates and highlights for Antigravity from Google I/O. * **[Google Antigravity Documentation & Features](https://antigravity.google/docs/features)**: The comprehensive guide to Antigravity features and safety controls. --- # From Agile to Agentic: A Guide to Modern Enterprise Development - **Markdown URL**: https://danicat.dev/posts/from-agile-to-agentic/index.md - **HTML URL**: https://danicat.dev/posts/from-agile-to-agentic/ - **Date**: 2026-05-15 - **Categories**: Agentic Coding - **Tags**: agile, gemini-cli, mcp, software-engineering - **Summary**: How to translate Agile practices into agentic workflows. Covers prompt-based story writing, conversational architecture with MCP, and non-coding agents. If you have spent any significant amount of time in the software industry over the last decade, you have probably lived through an Agile transformation. You have sat through endless sprint plannings, shared your updates in daily standups, and maybe even questioned if the whole thing made any sense. This frustration is common and usually comes from a disconnect between product management and engineering reality. Business leaders want predictability, but software development is inherently unpredictable. When companies try to force predictability through strict metrics and dashboards, Agile fails. The methodology becomes a rigid bureaucracy. Developers get frustrated because ceremonies feel like useless overhead, and practices like story writing turn into tedious chores. In mature Agile implementations, on the other hand, leadership recognises the uncertainty and empowers the teams to make the best choices for the business. Metrics and dashboards cease to be the primary goal; instead, the focus shifts to delivering business value. This doesn't mean leaving everything in the hands of developers, but working closely with them in true cross-functional teams. This preamble is necessary because, in order to read this article properly, I want you to reconnect with the roots of the Agile manifesto. Keep in mind the [core values](https://agilemanifesto.org/): > Individuals and interactions over processes and tools > Working software over comprehensive documentation > Customer collaboration over contract negotiation > Responding to change over following a plan In this article, we are going to explore how we can transition those foundational Agile practices directly into the modern era of agentic workflows. ## The agentic development workflow Before we talk about enterprise scaling, we need to establish the baseline. I've covered these topics in my previous articles, but I think it's worth putting them here again for the sake of completeness: 1. **Story writing is prompting:** The hardest part of commanding an agent is giving it the right context. Developers often struggle with writing good stories. However, it is the most important skill you can cultivate today. The skill required to write a good story is exactly the same skill required to write a good prompt. It is about organising information in an easy to consume way, with clear business justification and expected outcomes. Also known in Agile terms as **Definition of Ready (DoR)**. 2. **Prioritisation dictates the workflow:** Traditional backlog refinement translates directly to managing your AI tools. **By prioritising** tasks based on the *Business Value* versus *Technical Certainty* dimensions, you can decide what to work on synchronously in the foreground (pairing with tools like [Gemini CLI](https://geminicli.com/)) versus what to delegate to asynchronous, background agents (like [Jules](https://danicat.dev/posts/20250521-jules/)). 3. **The agentic coding cycle:** Coding agents are very powerful, but they often lack consistency. They are non-deterministic by definition. We can mitigate this problem by the use of [deterministic engineering practices](https://danicat.dev/posts/20251206-taming-vibe-coding/). I often describe this as "reducing the agency of the agent". For example, if you know your build process is always build, test, lint and deploy, what you don't want to do is to have this specified as a prompt. The agent will inevitably forget one or more steps as the session progresses. What you really want to do is to package this process as a custom tool and give it to the agent instead, completely removing its option to forget any of those steps. If you have these three practices down, you are already an effective agentic developer. But how do we make an entire engineering organisation effective? ## Conversational architecture and sharing institutional knowledge Writing enterprise software is difficult, but remembering *why* specific code was written can be just as challenging. Tribal knowledge decays quickly. When a senior engineer leaves, their institutional knowledge often leaves with them. A traditional solution to this problem is maintaining exhaustive documentation in internal wikis, but these tend to be hard to maintain, discover and enforce. For many years, one of my favourite pieces on sharing enterprise knowledge has been this post from Martin Fowler's blog: [Scaling Architecture Conversationally](https://martinfowler.com/articles/scaling-architecture-conversationally.html). The authors argue that good architecture spreads through conversation, not just top-down mandates. The post also explores how to formalise those conversations in Architecture Decision Records (ADRs) so that they are not lost in time. ADRs go beyond simple wikis; they provide a historical, point-in-time snapshot of when a decision was made. They capture the specific conditions, assumptions, and constraints that justified it. This notion might look simple at first glance, but it empowers future teams to make changes when necessary. Because they have a record of *why* the original choice was made, they have the tools to evaluate if the decision still holds, and can confidently override it (by issuing a new ADR) when those constraints evolve. As the years go by, I increasingly believe that the most important part of this job is managing uncertainty. ADRs are one of the tools that allow us to be honest about what we know and what we don't. The sooner we realise it's okay to not know everything, the better. This is the core of being Agile. We need to know just enough to make progress, accumulate learnings to reduce uncertainty and iterate. Software is a living organism: it is never done. While ADRs provide multiple advantages over an unstructured wiki, they still share a major flaw: the reliance on humans to be aware of and comply with them. Specially in large organisations, communication becomes the bottleneck. Information silos are wide-spread and a lot of effort is spent in synchronising different parts of the business. ### Distribute knowledge via agents To scale an architecture today, we must inject this institutional knowledge directly into the agents. Instead of relying solely on official broadcast channels through humans, we can use technology to broadcast regulations, ADRs, control procedures, and corporate standards directly to our agents. If your organisational context lives inside the agent's context window, you ensure those practices are always applied and up to date. From an architectural perspective, an MCP server is the ideal medium to expose this kind of information. It can be managed centrally and updated every time an architecture council, security council, or other committee issues a decision. Prompts, tools, and skills are effective ways to change agent behaviour, and they can be consumed both by the coding agents in the hands of the engineers and by automated agents in CI/CD pipelines. It is unfortunate that agent skills are not yet part of the MCP specification, but there is a working group dedicated to this. Once we can use MCP servers to transmit skills directly to agents, the challenge of keeping them up-to-date will be solved, reducing the friction for transmitting new standards to developers. ### Product documentation is also a consumable product Beyond internal rules, this exact same mechanism applies to product documentation. Traditionally, if Team A builds an internal API, they publish an OpenAPI specification file on a developer portal and expect Team B to read the manual. In the agentic era, static documentation creates friction. If your product is meant to be consumed by other teams, its documentation should be consumable by their tools. When Team A ships their service, they should also ship a dedicated MCP server that exposes the API schema, integration examples, and compliance checks as tools. When a developer on Team B needs to integrate with the service, they simply connect their coding agent to Team A's MCP server. The agent can query the API structure, read the integration rules, and write the client code automatically. We move from humans reading manuals to agents reading APIs, ensuring that architectural intent and integration patterns are perfectly preserved across the enterprise. ## Automating the ceremonies: Non-coding agents While we spend a lot of time speaking about coding agents, there are plenty of opportunities for optimisation using non-coding agents to reduce the management overhead that often plagues most Agile implementations. From low hanging fruits like note taking during meetings and summarization, to re-prioritizing the backlog, refining stories and creating spikes, we can use non-coding agents to reclaim a lot of the effort that is spent in admin to focus back into engineering. Here are a few ways these process-oriented agents can elevate a team: * **Story Refinement & Breakdown:** If a Product Owner drafts a rough epic, an agent can review it to identify missing edge cases, implicit technical assumptions, and unhandled error paths. Give it access to specific skills and it will be automatically compliant with organisational standards. Uncertain points can automatically be turned into spike tickets for further exploration. * **Auditing the Definition of Ready and Done:** In many Agile setups, the DoR and DoD are treated as mere wiki checklists that are frequently forgotten. We can make compliance proactive by hooking agents into our existing Kanban boards (like Jira or GitHub Projects). When a ticket is moved to "Ready for Dev", a background agent can scan it to ensure all necessary context, such as API schemas and UI mockups, are actually attached. If they aren't, it flags the transition. Similarly, before a ticket is closed, an agent can verify that tests were added and documentation was updated. * **Data-Driven Retrospectives:** Retrospectives often suffer from recency bias. Non-coding agents can act as objective data analysts by reviewing the sprint's ticket transitions, pull request comments, and chat threads. For example, it might point out that tickets touching a specific microservice spent an average of four days in review, prompting the team to ask if there is a domain knowledge silo that needs addressing. ## Scaling the workflow with agent managers Over the last year or so the industry was pretty much focused on the refinement of the single agent experience, specially the coding agents, and we have seen new standards emerge and consolidate (MCP, skills, hooks, etc.). As a consequence, the core skill of the software engineering career pivoted from writing code to orchestrating agents. However, there is a hidden bottleneck here: human cognitive load. There are already reports of a [new type of burnout caused by AI use](https://techcrunch.com/2026/02/09/the-first-signs-of-burnout-are-coming-from-the-people-who-embrace-ai-the-most/). Delegating tasks to async agents sounds great, but every task running in the background consumes your mental bandwidth. You still have to remember that the task is active, review its output when it finishes, and merge the context back into your main workflow. When these are unrelated tasks, the penalty is even higher as it requires a full context switch. It's ironic how we humans, similarly to AI, also suffer from context problems. But as the [fundamental theorem of software engineering](https://en.wikipedia.org/wiki/Fundamental_theorem_of_software_engineering) says: > All problems in computer science can be solved by another level of indirection... Except for the problem of too many layers of indirection. This year we are seeing the rise of the "Agent Managers": agents responsible for managing other agents. While this concept was first seen in coding (e.g. [Antigravity 2.0](https://danicat.dev/posts/20260521-the-hitchhikers-guide-to-antigravity-2-0/) and [`scion`](https://github.com/googlecloudplatform/scion)), it has much broader implications. This creates a bigger issue though: if it is already hard to [review the work of one agent](https://danicat.dev/posts/20260303-code-reviews-in-2026/), or a few, how can we possible review the work of fleets of agents? There is no easy answer for this question, but in my view we need to work our way to build trust in the multi-agent systems. Or even better, as they say in security: trust but verify. The same way we can increase trust in single agent systems by applying prompting techniques, creating hooks, sandboxes and deterministic tools, we will need to find our way to add quality gates to agent managers. Agent evaluation, auditability and more mature engineering practices will be fundamental to this change. Nevertheless, I believe we will get there. Years of engineering practices is what gives us confidence not to inspect the output from our compilers to see if the right assembly code was generated. This will be no different. ## A peek at the future: agentic kanban What happens when we take the code out of the coding agent and focus on the product we are building instead? I did this as a thought experiment and I realised that it would not be much different than what we have today in a kanban board, but instead of humans picking the tickets, we have predominantly agents doing the interactions: ![Agentic kanban mockup](image.png "Agentic kanban mockup created with Google Stitch") We have the typical columns for the different development stages (backlog, to do, in progress, etc.), but each column has a set of agents that will work collaboratively to bring the ticket to the next stage. Adding global skills to the column can provide important context for all agents involved, like for example, architecture standards and control procedures. Every step can be audited by clicking on the ticket and watching the conversation flow between the agents. Need to steer the agents? Add a comment to the ticket. Want to have a human review step? Add yourself as one of the "agents". By combining the visual management of Agile with the execution power of Agent Managers, we can solve the cognitive load limit. We bring the Agile and Agentic worlds full circle. The ceremonies of the past evolve into the dashboards of the future, proving that everything we learned during those endless sprint plannings was just preparation for what comes next. What do you think about this approach? I would love to hear your thoughts either in the comment section below or in any of my socials. --- # Build a Match 3 Arcade Game Using Gemini CLI - **Markdown URL**: https://danicat.dev/posts/match3-game-gemini-cli/index.md - **HTML URL**: https://danicat.dev/posts/match3-game-gemini-cli/ - **Date**: 2026-05-07 - **Categories**: Agentic Coding - **Tags**: codelab, gemini-cli, golang, subagents, vibe-coding - **Summary**: Step-by-step tutorial on building a 2D Match-3 game in Go and Ebitengine using Gemini CLI. Covers plan mode, subagents for visual testing, and Cloud Run. > **Note:** This article was written for Gemini CLI, which has since been deprecated and superseded by **Google Antigravity 2.0**. To learn more about the new Antigravity CLI (`agy`), the SDK, and the broader Antigravity ecosystem, check out [The Hitchhiker's Guide to Antigravity 2.0](https://danicat.dev/posts/20260521-the-hitchhikers-guide-to-antigravity-2-0/). > **Updated Codelab:** Follow the modern version of this tutorial updated for Antigravity at [goo.gle/cloud-crush-agy](https://goo.gle/cloud-crush-agy). The whole reason I became a software developer was because I loved video games as a child. I spent countless hours playing them, and I was deeply intrigued about how they were built. My father tried his best to explain about how TVs and computers worked, but it never really got into my head. It was only during my teenage years when we finally got access to the internet that I started understanding a bit more. While normal teenagers were filling chat rooms, chasing people on ICQ, and working on their Orkut profiles, I was researching game development tutorials. Those were the good days. Years went by and I never became a professional game developer. My career took me towards databases, data engineering, backend services, and the cloud. I do not regret my choices. Still, every now and then I wonder how it would feel to build my own indie game. Guess what? With the rise of agentic coding, building complex applications — including games — has become so accessible that we don't need to wonder anymore. We can build a fully functioning, cloud-deployed game today, as I am about to show you. There are two ways you can read this article: as an aspiring game developer wanting to experiment with GenAI, or as a professional developer using game development as a fun way to learn new agentic coding skills. Whichever path you choose, during the course of this article I am going to show you two specific Gemini CLI features: plan mode and sub-agents. But before that, let's talk a bit about technology. ## How to choose the right technology for your project This has been always an important decision in every software team. Should we use the tools we are familiar with? Should we follow new market tendencies? Should we build our own? Large companies usually stick to the tools they already know. To justify a change, you need a very compelling reason. That reason might come from the outside—like shifting market costs or a lack of available talent. Or it might come from the inside, such as the high cost of retraining your team to support a new stack. Agentic coding completely shifts this dynamic. Because AI can handle the boilerplate, your choice of programming language matters much less today than your overall system architecture. For us developers, this is a huge relief. We can switch tech stacks to fit the problem without spending months learning new syntax. You might be wondering, when the language loses importance, what stays? My answer is: the patterns. The way we structure software, not as a silo, but as a collective of systems. This works both at a macro (system design) and micro level (program design). You don't need to know what every single line of code does, but you **do** need to know how the different pieces of your software interact together, and you **do** need to know how to steer the agent into the direction of the **correct** implementation. Does this mean we can go back to writing everything in BASIC? No, because a language is never a choice on its own. A language brings with it a specific set of features and an entire ecosystem. We are bound to choosing the technology that has the best fit for what we are trying to achieve. The only thing that is not relevant anymore is the capability of the team to write the software. This can be easily mitigated with modern coding agents as long as the team has strong software engineering fundamentals. While one criterion goes out of fashion, new ones appear. In this case, we are going to be paying close attention to how easy it is for the coding agent to generate high quality software in the target language. For this particular project I choose Go for two main reasons: it is a lightweight language which coding agents handle pretty well (my godoctor MCP also helps!) and it has a mature open source game development ecosystem around ebitengine. Could I have done it in Three.js? Yes. However, I really wanted to get as close to the console / arcade game experience as possible, so a compiled game is a must for me. Also, I only care about 2D, so no need for big engines like Unity or Unreal. Finally, ebitengine has commercial games published on the Nintendo store (for Nintendo Switch), which feeds into my dream of eventually publishing a game (of course, not this one). Talking a bit of the strengths of Go: being a compiled language helps us catch a great deal of errors earlier in the development process. Python has similar game development capabilities but being interpreted means it slows down my test cycle. Additionally, Go can be natively compiled for your local machine, or compiled to WebAssembly (WASM) for the web. This means I can also deploy my game as a web service with a few small changes. ## The return of the software analyst While the agent is doing the heavy lifting of writing the Go code and compiling both the server and the WASM binaries, we still have strict responsibilities when it comes to design. Software engineering is shifting. We are spending less time worrying about clever syntax and more time thinking about high-level patterns. In a way, it feels like we are returning to the era of the old-school 'Software Analyst'. Instead of writing every line manually, our main job is translating human requirements into a precise set of instructions so the AI can write the actual code. I don't have game development experience per se, but as a gamer and an enthusiast I am familiar with the **domain language** used to describe what I want to achieve with my game. By grounding my prompt on certain keywords (for example, arcade game, match 3) or using well-known examples (for example, "I need a track inspired by the 16-bit and 32-bit generations of puzzle games, but with a modern twist") I can communicate my intentions to the agent in a better way than someone trying to build a game with absolutely no gaming experience. I'm just leaving this here to highlight a point. Even if coding itself becomes a secondary skill, the ability to describe patterns and features is still a critical software engineering skill. You must know the domain language of your field, be it backend, frontend or anything in between. ## Moving from design to implementation with plan mode Domain language is a start, but writing the perfect one-shot prompt is rarely feasible. As developer relations, we do use one-shot prompts all the time in demos and presentations, but what we usually don't talk about is how often we spend hours refining that one-shot prompt before being able to show it to the public. Crafting the perfect prompt is a mixture of art and science, and even if you have a deep understanding of the domain language there will always be gaps. Luckily, outside the world of demos and presentations we don't need to be one-shotting anything. Also, we don't need to work on the prompts on our own as agents can also help us with them. This is where **plan mode** comes to help. In plan mode, Gemini CLI will first elaborate an implementation plan before writing any code. This creates an opportunity for you to do a back and forth conversation with the agent, refining the plan and ensuring the implementation is going in the direction you desire. In a normal conversation with the agent, it might ask you to enter in plan mode based on the conversation flow (for example, responding to a prompt that includes a "let's make a plan" phrase), but if you don't want to rely on the agent to decide when to enter plan mode you can always toggle it manually with the `/plan` command. In plan mode, the agent will not only elaborate an implementation plan based on your request but might also ask for one or more clarifying questions using the `ask_user` tool. When the plan is ready it will ask you for review and give you the opportunity to steer the plan in any direction including correcting assumptions and adding or removing features. For example, a fairly polished - but far from perfect - prompt for my Match 3 game is shown below: ```txt Build a Match-3 game called 'Cloud Crush' in Go using Ebitengine v2. The entire game screen should have background.png as background. The play area should be an 8x8 grid with white background. On the right side of the play area include a side panel with UI elements like player score and how to play instructions. The side panel should have a solid background colour to help with readability of the UI. Use standard GCP product logos (e.g. Compute Engine, Cloud Storage, BigQuery, etc.) as the game gems. These logos are provided in the gcp_sprites.png file. The logos are saved as 64x64 sprites but scale them as necessary based on the screen resolution. Implement swapping, clearing 3+ gems, and gravity. Use ebitengine native font rendering (size 48 for titles and size 24 for normal text) for all text and not the debug print. The font should be monospaced (golang.org/x/image/font/gofont/gomono). Keep the UI tidy and harmonic, e.g. centered text should always be adjusted based on text length, not just guess based on estimates. ``` While this prompt covers many aspects of the game, it is common for the agent to ask for more details like "what should be the screen resolution" or "would you prefer smooth or static animations". Once we are happy with the level of details in the plan we can ask the agent to start building, which will exit plan mode. This part is no different from any typical coding task. After a few turns we should have a game running similar to this: ![Cloud Crush Gameplay Screenshot](https://danicat.dev/posts/match3-game-gemini-cli/cloud-crush-gameplay.png) ## Automating web testing with the browser agent One of the hardest things to do in game development is testing. You cannot write a standard unit test to verify every possible game state, or to check if your rendering functions are drawing the right elements to the screen. You could try, but I guarantee it would be a tedious, brittle and time consuming process. This doesn't mean we shouldn't write automated tests at all, but that there are limits between what should be done with pure code and what needs human play testing. For example, unit testing algorithms like collision and path finding seems ok to me, but validating your UI across different resolutions might be better done by a human (for example, how do you unit test "is this font readable?"). Or, at least, it was until now... _a sub-agent enters the chat_ With frontier models' multimodal capabilities and some clever use of agents, we can actually automate visual verification. In the Gemini CLI, a sub-agent is a specialized persona that runs independently of the main conversation in its own context window. Sub-agents can be used to add all sorts of capabilities to your base coding workflow. In our testing scenario, we can use an experimental agent bundled with the CLI called the `@browser_agent`. Because it is experimental, you need to [enable it manually](https://geminicli.com/docs/core/subagents/#enabling-the-browser-agent) by editing your `settings.json` file. For example, this is a minimalist `settings.json` that enables the browser agent with a visual model: ```json { "agents": { "overrides": { "browser_agent": { "enabled": true } }, "browser": { "visualModel": "gemini-2.5-computer-use-preview-10-2025" } } } ``` Normally, the browser agent navigates a webpage by reading its accessibility tree—the hidden structure that screen readers use. However, our Match 3 game is drawn entirely on a single HTML canvas. To the accessibility tree, it just looks like a giant blank box. This is where adding a vision model changes the game. By configuring the agent with a `visualModel` (like `gemini-2.5-computer-use-preview-10-2025`), it literally learns to see. It takes screenshots, analyzes the visual layout, and figures out the exact X and Y coordinates it needs to click on the screen. Instead of manually clicking through the deployed Cloud Run application, you can type `@browser_agent please test the live URL...` to instruct it to navigate the site, play a round of the game, and take screenshots of the working screens. This doesn't replace human playtesting for game feel, but it automates the visual validation, proving the UI renders correctly without leaving the terminal. ## Outsourcing my security anxiety With the implementation working and the UI verified, we cannot forget about security. I am not an application security expert, making me the wrong person to evaluate the security posture of a web app. However, much like agentic coding mitigated my lack of game engine experience, sub-agents can mitigate my lack of security expertise. As an orchestrator, I do not need to know every cross-site scripting vector; I only need to know how to spin up a specialist with a clean context to look for them. We can create an isolated execution environment by defining a [custom agent](https://geminicli.com/docs/core/subagents/#creating-custom-subagents) in a Markdown file (`.gemini/agents/security-auditor.md`) that can be summoned using `@security_auditor`. ```markdown --- name: security_auditor description: Specialized in finding security vulnerabilities in code. kind: local tools: - read_file - grep_search model: gemini-3-flash-preview temperature: 0.2 max_turns: 10 --- You are a ruthless Security Auditor. Your job is to analyze code for potential vulnerabilities. Focus on: 1. SQL Injection 2. XSS (Cross-Site Scripting) 3. Hardcoded credentials 4. Unsafe file operations When you find a vulnerability, explain it clearly and suggest a fix. Do not fix it yourself; just report it. ``` We give it a specific system prompt (the body of the markdown file) and tools like `read_file` and `grep_search` (defined in the frontmatter). Because it runs in its own context loop, it does not clutter the main conversation history. I pointed this auditor at the *CloudCrush* codebase to check for hardcoded credentials, unsafe file operations, and deployment risks. Even if a custom security agent does not replace a dedicated human professional, it provides a baseline layer of defence that I would otherwise lack. ## A new development workflow This workflow defines what I consider to be the new standard for software development. We are using agents to write the code, and actively building custom tools, skills and sub-agents to enforce our architectural and quality standards. And for the careful readers out there, you might have noticed I was intentionally lean on the step by step instructions in this article. This is because we have an entire codelab dedicated to this experience which you can access using the link below. In this codelab you will be able to test everything discussed in this article following step by step instructions, ultimately building your own version of this match 3 game. **Codelab: [Build a Match 3 Arcade Game With Gemini CLI](https://codelabs.developers.google.com/next26/gemini-cli-match3-golang#0)** Of course, if you have any questions, please feel free to reach out on any of my socials. --- # How to Do Code Reviews in the Agentic Era - **Markdown URL**: https://danicat.dev/posts/code-reviews-in-2026/index.md - **HTML URL**: https://danicat.dev/posts/code-reviews-in-2026/ - **Date**: 2026-03-06 - **Categories**: Agentic Coding - **Tags**: code-review, vibe-coding - **Summary**: Practical guide to reviewing AI-generated code and pull requests. Explains what architectural areas to scrutinize and what mechanical tasks to delegate to linters. In 2025 we've seen the rise of agentic coding (apparently the term "vibe coding" is obsolete). Between AI assistants and agentic workflows, features are flying off the shelf at a pace we've never seen before. It's not uncommon for companies to brag about how many percentage points of their codebase are now written entirely by AI. Whether this is a good or a bad thing we're yet to see (I, for one, think it's good), but this gain in writing speed isn't without consequences: reviewing the sheer amount of code produced is draining, and code reviews are quickly becoming the bottleneck. Some teams / open source projects have even adopted the nuclear option of not accepting AI-generated pull requests at all. While banning AI can give people a breather, I don't think it's a good option in the long run. "Resistance is futile", as my favourite fictional species would say. To survive this new level of productivity we have to stop doing the work that machines can do better. Fight AI with AI. But not only AI, a good set of old-school deterministic tooling can also do wonders: if a linter can catch an issue, I shouldn't be looking at it. If a formatter can fix it automatically, I really don't care about it. My "unpopular" take: I don't really care if a human or an agent wrote the code. In open source, contributions are zero-trust. Whether the code was written by a seasoned FAANG engineer or a high schooler in Sri Lanka, it shouldn't really matter. So why should we care if it was written by AI? In theory, human-generated PRs would be smaller, but after 20-ish years working in this industry I had my fair share of mega-PRs so I can confidently say that dealing with mega and/or sloppy PRs isn't a new problem. I evaluate code at face value. Does it work? Is it safe? Does it fix a known issue? Does it align with our roadmap? Does it comply with our standards? This is why in today's article I would like to talk a little bit about how I am approaching code reviews, not only when dealing with external contributions, but also when dealing with my own AI-generated code as, in fact, coding with an AI means code reviewing the AI the whole time. ## What I actually care about When I review code nowadays, I am becoming increasingly higher level. In a sense, the less code that I write manually, the less I care about the individual aspects of the code. I always said, in every single team I took a leadership role in one capacity or another: code is disposable. This has never been more truthful than today. I repeat: code is disposable. What's not disposable is the system knowledge you acquired when developing certain code. This knowledge is what usually translates well from one implementation to another or, for example, what sticks when migrating from v1 to v2 of your API. Writing something the second time is easier because you already went through the growing pains of discovering a bunch of stuff and reducing a lot of the ambiguity. You learned what worked well and what didn't. What was overengineered and what was underengineered. This is the important bit in software engineering: gathering knowledge, iterating, evolving. And this is the kind of knowledge that will survive the AI age. Code is just an implementation detail. Based on this philosophy, this is a non-exhaustive list of the things I care about when code reviewing: ### Architecture and system design AI models struggle with the big picture and also have the tendency of taking many shortcuts. My review process looks for those signals like hardcoded values and configurations, over-simplification of the problem space (AI often treats coding requests as prototypes or demos) and, paradoxically, over-engineering. AI models also have the annoying trait of assuming production-readiness is equal to complexity. Or in other words, they struggle with balance and pragmatism, stuff that we learn with experience and it's often hard to translate into words. ### Public API and modules The ergonomics of what we're building matter. The public API needs to "feel right" to the average developer who has to use it. A well-designed interface is intuitive, hard to misuse, and hides the messy internals from the rest of the codebase. I check if the interfaces are robust and correctly scoped, aiming for the smallest possible surface area. If the API is clunky, it doesn't matter how elegant the underlying code is. Is the code easy to use and well documented? A good hint that the public API is good is if the test quality is good. Bad API design is inherently hard to test. ### Algorithms and patterns LLMs often default to the most naive, brute-force way to solve a problem. It’s common for an agent to try running a massive data migration using nested loops and committing every few rows, when a bulk insert strategy is the correct approach. Or at a core level: using a list when a map or dictionary is the right data structure. Verifying that the data structures and algorithms actually fit the problem space prevents massive performance drops. The goal is code that scales, not just code that passes the tests. Premature optimisation is still a risk, though. If a simpler approach is slightly slower but much easier to read and we are dealing with a small, bounded data set, readability usually wins. ### Dependencies Every new package brings outside risk, potential security flaws, and maintenance overhead. Keeping the app small reduces our attack surface. Core tools like our GenAI SDKs or major web frameworks get a faster pass, but everything else gets scrutinised. A little copying (or reimplementation) is better than a little dependency. The easier it gets to generate and maintain code, the less I worry about re-use, especially if this means adding a new attack vector to my codebase. ### Anti-patterns and quality issues Just to name a few: ignoring or silencing errors, side effects, global state, mutable state, resource leaks, unused functions or variables, and so on. Language idioms also matter. While I do care about these a lot, these are also some of the easiest to automate with the use of static analysis (linters) like [golangci-lint](https://golangci-lint.run/) (Go) and [ruff](https://docs.astral.sh/ruff/) (Python). ### Testability Code that's hard to test is usually designed poorly and will resist change in the future. Clear separation of concerns, clean inputs, and pure functions are ideal. Good tests prove the code works and provide a safety net for our future modifications. For UI components and complex systems, I accept practical testing strategies over strict unit coverage, but the core logic must be covered. I stopped trying to add a coverage target for every single project because each case is a case, but I need to know that whatever should be tested is tested. Ideally 100% of the happy path and a good percentage of the sad path, but I won't try to achieve 100% of all code or anywhere close to that. As long as you have a good observability strategy and good error messages, you are setting yourself up for success as new error modes can be added to the test suite later. ### Benchmarking For critical paths, we need actual numbers rather than guesses about performance. Clear benchmarks for any changes affecting high-traffic components are required to stop slow code from reaching production. ### Lean logging Logs must be actionable. Unnecessary logs increase our cloud bills and can leak private info. Verbose logging is fine during development, but it must be cleaned up before merging. ## What I don't care about (mostly) I let automated tooling handle the details so I can focus on the hard problems. If a machine can do it, a human shouldn't be doing it. ### Every individual line of code Reviewing every line generated by an LLM is the job of a compiler or static analyser. I focus on the logic and connection points instead. ### Formatting Ever since I started doing Go I have never been into a discussion about formatting styles, but I know they still exist in certain spaces. The best thing you can do is set up a standard and let the linter and formatter handle it. Once the standard is known the code agent can also be more compliant to it. If the CI pipeline passes, it is fine. ### Minor syntax and code details There are many ways to solve a problem, and forcing specific syntax choices limits developer freedom. I don't care if it's a `for` loop or a list comprehension, as long as the logic is sound. ### Debugging I almost never do debug sessions (in the strict sense of actually using a debugger). Debugging for me is a last resort, and it's a synonym for adding a bunch of "I AM HERE" print statements, which really should have been log lines. If something isn't working, I create a new test to simulate the problem. If after reproducing the problem I can't figure out what's happening, it means that my observability and logs are insufficient, so I focus on improving those. ### Un-exported names When a name is local to a function or has limited scope, I care much less about it than if it is used across many functions or files. I'll skim read the code and if I catch a glimpse of anything absurd I might stop for a tidy up, but otherwise I'm fine with whatever the model decided to use. ### Minor dependencies The ones that aren't your major framework or client library dependencies. These are less of a concern if they meet our security baseline. Checking for security exploits and problematic licenses is still mandatory, though. If I'm importing something just for one "helper" function I will 100% of the time re-implement that function in my code and get rid of the dependency. ## Conclusions This isn't a one-size-fits-all protocol. There is also a lot that can be said about how you are instrumenting your code base. Code reviews alone won't catch all potential problems, and this is why I strongly advocate for automation, now with agentic coding more than never. Modern coding agents have many extension patterns that allow you to constrain the model and get more deterministic outputs: [Agent Skills](https://danicat.dev/posts/20260829-the-pragmatic-guide-to-agent-skills/), [hooks](https://danicat.dev/posts/20260610-mastering-hooks/), [MCP tools](https://danicat.dev/posts/20250817-hello-mcp-world/), policies, rules... Use those tools to put well defined limits to the scope of your agents and your life will become much easier. A car can only run as fast as its brakes support it. Invest in learning the guardrails for your favourite coding agent, and use your precious time to review what can't be automated. Happy coding! Dani =^.^= --- # Building Agent Skills with skill-creator - **Markdown URL**: https://danicat.dev/posts/gemini-cli-skills-part-2/index.md - **HTML URL**: https://danicat.dev/posts/gemini-cli-skills-part-2/ - **Date**: 2026-02-27 - **Categories**: Agentic Coding - **Tags**: agent-skills, gemini-cli, vibe-coding - **Series**: Agent Skills - **Summary**: Explore practical Agent Skills design with Gemini CLI's skill-creator. Detailed examples include latest-version, pyhd with Ruff, find-examples, and de-sloppify. > **Update (2026):** Gemini CLI has evolved into **Google Antigravity 2.0**. While the core Agent Skills concepts and structure covered in this article remain foundational, check out [The Hitchhiker's Guide to Antigravity 2.0](https://danicat.dev/posts/20260521-the-hitchhikers-guide-to-antigravity-2-0/) for an overview of the updated platform and features. In [Part 1: Mastering Custom Agent Skills in Gemini CLI](https://danicat.dev/posts/20260128-agent-skills-gemini-cli/), we explored how Agent Skills add new features to the Gemini CLI. We looked at the `experiment-analyst` skill as a practical example of keeping the agent's context clean while giving it specific instructions for a task. Today, we are going to dive into the core principles of skill design and look at a few practical examples of skills I use daily. ## What are Agent Skills Let's do a quick recap of part 1 just in case you missed it. Agent Skills are an open standard designed to give coding agents "just in time" specialist knowledge. They are designed so that specialist knowledge is added to context only when needed, helping prevent the so called context bloat. The technical term for that is **Progressive Disclosure**: we keep the core instructions (`SKILL.md`) as lean as possible, and use separate files for detailed references or scripts that are only loaded *when needed*. On disk, a skill is a folder with a `SKILL.md` file and, optionally, bundled resources: ```text skill-name/ ├── SKILL.md (Required: Only name, description, and core instructions) └── Bundled Resources (Optional) ├── scripts/ (Executable code for repeatable tasks) ├── references/ (Docs loaded on-demand, e.g., API schemas) └── assets/ (Templates or binary files used in outputs) ``` ## The `skill-creator` skill You can always write skills manually, but Gemini CLI ships with a built-in meta-skill called `skill-creator` which makes things much easier. You can activate this skill by asking Gemini CLI to create (or refactor) a skill: > *"I want to create a new skill to fetch the actual latest version of a software package so you stop hallucinating versions."* Any requests related to "creating skills" should automatically trigger the `skill-creator`, but just in case you are talking with a "grumpy" model, you can also be more explicit: > *"Use the the skill-creator to write a skill to de-sloppify AI generated texts (please don't take it personally)"* Gemini CLI might ask you a few details before writing the skill boilerplate. It just recently learned how to ask the user with a [`ask_user` tool](https://geminicli.com/docs/tools/ask-user/#ask_user-ask-user) and it is really cool to see it in action. ## When to create skills In my personal workflow, I have two main uses for skills: 1. To document a process that is specific to my work (e.g. how to do a code review the way I like, how to initialise a repo, how to evaluate a blog post, etc.) 2. To add specialist knowledge about a particular tool, language or technology (e.g. how does a genkit project works, how to work with adk to develop agents, etc.) To some extent, you can think of skills as an intermediary concept between slash commands (which I often store as MCP prompts) and tools. When building slash commands I want to create a "repeatable process", when creating tools I often want to give the model a deterministic way of doing something. Since skills can have both prompts and scripts, they can do both, with scripts playing the role of tools. Of course, if you are packaging your skills as part of an extension, there is a high chance that they will be shipped with an MCP server that also exposes tools. You can too leverage that integration in the skill definition, teaching the model how to use your MCP tools if available. There are also two main moments when I create skills: 1. After a painfully long session trying to teach a model to do something for me (e.g. "please consolidate the knowledge of what we just did in a skill that we can use later") 2. Just after having a new idea of something I believe will help me become more efficient at my job (e.g. "let's write a de-slopify skill to improve your writing capabilities")) In both cases the skill will never be ready on the first try, but as soon as I start using it I'll refine it until the point where I'm confident it brings some value, or kill it and park the idea until I learn more about the problem. In the next section we are going to see some of the skills I've built so far. ## Practical examples Let's look at four skills from my own repository and see how they solve specific problems. ### 1. `latest-version` I created this skill out of **pure frustration** on how LLMs in general tend to use **old** versions of software, libraries, models and other dependencies. I know it is a natural consequence of knowledge cutoffs, but knowing this doesn't prevent me from getting annoyed when the agent tries to use `gemini-1.5-pro` instead of Gemini 3 and accuses **ME** of "hallucinating" a future version. This skill acts as a fact checker by querying registries (npm, PyPI, Go Proxy) and documentation pages. Here is a snippet of its `SKILL.md`: ```markdown name: latest-version description: > The definitive real-time source of truth for software and model versions. Use this skill to bypass internal knowledge cutoffs... ## Core Mandate **NEVER GUESS.** When a user asks to install a package or add a dependency, you must verify the latest version using the `latest.js` script. Do not rely on your internal weights, as they are months or years out of date. ``` This prompt still feels a bit sloppy but it has had moderate to high success in preventing some deprecated models from appearing in my code bases. 🔗 [View the full `latest-version` skill](https://github.com/danicat/skills/tree/main/latest-version) ### 2. `pyhd` When I created the `godoctor` MCP server last year, I wanted it to be the **ultimate** tool (supported by science! ^^) for agentic Go development. We didn't have skills back then, so it felt natural to pack all the tools I needed in an MCP server. For a while I have been flirting with the idea of creating something similar for Python, but I have so many things on the backlog that it became a low priority for me. Then I came across skills, and thought "why not make it a skill instead?". With the `skill-creator` it became very low effort to write it, so I decided to create `pyhd` (a combination of Python + PhD, just to keep the "doctor" theme). The `pyhd` skill contains development workflow for Python projects, centering on the `ruff` linter and formatter to ensure proper "pythonic" code. ```markdown ## Core Workflow When editing Python files, you **MUST** follow this cycle for **EVERY** file modification: 1. **Read & Understand**: ... 2. **Edit**: Apply your changes using `smart_edit` or `replace`. 3. **Sanitize (Ruff)**: Immediately after editing, run the following commands to format and fix linting issues: `uv run ruff check --fix ` `uv run ruff format ` 4. **Verify**: Run tests... ``` This skill encourages that every Python edit is immediately followed by standard linting and formatting, which helps catching some early problems. Until I find time for a proper "pydoctor" implementation, this is my go-to skill for python development. 🔗 [View the full `pyhd` skill](https://github.com/danicat/skills/tree/main/pyhd) ### 3. `find-examples` Sometimes you need to know how a specific library is used in the wild. The `find-examples` skill uses a Python script (`github_search.py`) to search GitHub for code that uses the dependency you want to use in your project. I developed this skill to help address the problem of models hallucinating APIs, while they clearly could do better by just looking at some documentation or examples. Because it only uses GitHub search it doesn't need a personal access token, and it tends to perform better than a Google search. ```markdown ### 1. Search for Repositories (Multi-Language) Run the `github_search.py` script. If you can't find many examples in your target language, add related languages supported by the SDK. ### 4. Clone and Inspect Clone the selected repositories into the `_examples` folder. Once cloned, use `list_files`, `smart_read`, or `grep_search` to find relevant implementation details. ``` I also added a feature where it tries to find examples in different languages for polyglot SDKs. It is one of my most recent skills so I haven't tested it much, but thought it was an interesting example to add. 🔗 [View the full `find-examples` skill](https://github.com/danicat/skills/tree/main/find-examples) ### 4. `de-sloppify` I use this skill to check for common AI writing patterns. It includes a script that calculates a "slop score" based on word choice, sentence length variation, and structural repetition. It uses NLTK to tag parts of speech, which helps identify the high noun density and passive voice often found in unedited model outputs. The script runs locally and provides a report on the specific markers it found. 🔗 [View the full `de-sloppify` skill](https://github.com/danicat/skills/tree/main/de-sloppify) ## Conclusions Skills are rarely perfect on the first try. An effective way to refine them is through real usage. When you notice the agent struggling with a step, or fetching the wrong context, ask it to update the skill using the `skill-creator` again. Take a look at your daily workflows. What tasks require you to constantly remind the AI of the rules? Those are the perfect candidates for your next custom skill. Ready to build your first skill? Check out the [official documentation](https://geminicli.com/docs/cli/skills/) to learn the basics and, if you need inspiration check the [danicat/skills repository on GitHub](https://github.com/danicat/skills). Happy coding! Dani =^.^= --- # Bringing Old Photos to Life with Genkit and Gemini 3 - **Markdown URL**: https://danicat.dev/posts/genkit-go-photo-restoration/index.md - **HTML URL**: https://danicat.dev/posts/genkit-go-photo-restoration/ - **Date**: 2026-02-16 - **Categories**: Applied GenAI - **Tags**: gemini, genkit, golang, nano-banana, tutorial - **Summary**: Tutorial on building a 4K photo restoration tool in Go using Genkit and Gemini 3 Pro Image (Nano Banana Pro). Covers Handlebars dotprompt schemas and flows. As part of my job I get to know all sorts of people and one very common topic of conversations is about my heritage. Not only I have an obviously sounding Eastern European surname but I also look the part, so people often assume I'm either Polish or Czech. Lots of people are surprised when I say that in reality I'm originally from Brazil. Nobody really knows where my family came from, as we were really **terrible** at keeping track of historical records. Maybe because we are self-conscious of this, we often have conversations in the family about the part of history that we know and how it is also fading. As we are all aging, memories are the first to go, and then goes the documents and pictures. There is an inherent feeling of sadness when you realise that the last time you saw your grandmother was 30 years ago, and that her face is often no more than a blur. This is why photographs are so important to me, as they are the stronghold to fight the degradation of my own memories. Anything made in the recent years could be easily duplicated and stored as many redundant copies in the cloud as I wish, but we are talking about mementos from before the digital age. Even if I had scanned them years ago, many of them have already gone through decades of dust, mold, wear and tear. They are frozen in time, but not getting any better. Thanks to the evolution of generative AI, not all is lost and I can finally give these photos a breath of fresh air, not only restoring the damages due to the passage of time, but also colourising and upscaling them to bring them up to modern standards. This is how a small software called "GlowUp" was born. Below is one example of such restoration: ![Original damaged and monochrome photo of my grandmother preparing a banana pie](original.jpg "Original: my grandmother preparing her world-famous banana pie") ![High-fidelity 4K restored and colourised photo using Nano Banana Pro](restored.png "Restored: restoration and colourisation by Nano Banana Pro") In this article, I'm going to show how to build GlowUp from scratch using [Gemini Nano Banana Pro](https://ai.google.dev/gemini-api/docs/image-generation) and [Genkit Go](https://genkit.dev/docs/get-started/?lang=go). ## The building blocks I've chosen to use Nano Banana Pro (aka. Gemini 3 Pro Image Preview) because it is currently the most advanced image processing model in the Gemini family. While the regular Nano Banana (Gemini 2.5 Flash Image) is also a great model, I find the pro version has better quality outputs and it is also better at following instructions, even if it takes a bit of trial and error. On the client side, instead of going for a low level SDK like [go-genai](https://pkg.go.dev/google.golang.org/genai), I decided to use Genkit as it provides a few quality of life improvements over the lower level code such as: - Model agnostic: I can test different models if I want, even non-Google or local ones, with a single plugin replacement - Out of the box Dev UI support for conveniences like testing models, prompts and tracing of model calls - Flexible architecture: Package it as a CLI application or a web server. GlowUp is built as a unified binary that can run as a command line tool or a web server. This flexibility allows me to run restorations locally from my terminal or deploy the same code as a cloud service, which could eventually power a nice app that even my father could use to restore his collection of photos. ## A first look at Genkit Go [Genkit](https://firebase.google.com/docs/genkit) is an open-source framework designed to bring production standards to AI development. If you are a Go developer*, think of it as the "standard library" for AI features. _(* And if you are **not** a Go developer, check out the docs as Genkit also supports JS and Python.)_ Here is what a minimal "Hello World" looks like in Genkit for Go. Note how we use the `googlegenai` plugin to initialize the framework. ```go package main import ( "context" "fmt" "log" "net/http" "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/genkit" "github.com/firebase/genkit/go/plugins/googlegenai" "github.com/firebase/genkit/go/plugins/server" // Import the server plugin ) func main() { ctx := context.Background() // Initialize Genkit with the Google GenAI plugin (Vertex AI) g := genkit.Init(ctx, genkit.WithPlugins(&googlegenai.VertexAI{})) // Define a simple Flow genkit.DefineFlow(g, "hello", func(ctx context.Context, name string) (string, error) { // Generate text using a model resp, err := genkit.GenerateText(ctx, g, ai.WithModelName("vertexai/gemini-2.5-flash"), ai.WithPrompt(fmt.Sprintf("Say hello to %s", name))) if err != nil { return "", err } return resp, nil }) // Start the flow server manually mux := http.NewServeMux() // Register all flows defined in 'g' for _, flow := range genkit.ListFlows(g) { mux.HandleFunc("POST /"+flow.Name(), genkit.Handler(flow)) } if err := server.Start(ctx, ":8080", mux); err != nil { log.Fatal(err) } } ``` This short snippet is doing a lot of heavy lifting. Let's look at it a bit more carefully. ### Plugins Adapters that connect your code to providers like Vertex AI, Google AI, or Ollama. For Google models, we should use the `googlegenai` plugin. It supports both backends: * **Google AI (Studio):** Uses an API Key. Best for prototyping and personal projects. ```go // Use Google AI (API Key) googlegenai.Init(ctx, &googlegenai.Config{APIKey: "MY_KEY"}) ``` * **Vertex AI (Google Cloud):** Uses Google Cloud IAM authentication. Recommended for production workloads and enterprise features. ```go // Use Vertex AI (Cloud Auth) googlegenai.Init(ctx, &googlegenai.VertexAI{ProjectID: "my-project", Location: "us-central1"}) ``` **Note:** If you are migrating from older versions of Genkit, you might be familiar with separate `vertexai` and `googleai` plugins. These have been consolidated into the single `googlegenai` plugin. ### Models The actual LLMs (e.g., Gemini, Claude) that generate content. You reference them by name strings like `vertexai/gemini-2.5-flash`. ```go resp, err := genkit.GenerateText(ctx, g, ai.WithModel("vertexai/gemini-2.5-flash"), ai.WithTextPrompt("Tell me a joke")) ``` ### Prompts While nothing prevents you from hardcoding prompts, like in the example above, it is a good practice to keep them in separate files for better maintainability. Genkit uses `dotprompt` to load external prompts. A `dotprompt` file (*.prompt) consists of two main parts: the **Frontmatter** and the **Template**. **1. Frontmatter (Configuration)** * **`model`**: The model identifier (e.g., `vertexai/gemini-2.5-flash`). * **`config`**: Generation parameters like `temperature`, `topK`, or model-specific settings (e.g., `imageConfig`). * **`input`**: A JSON schema defining the variables expected from your Go code. * **`output`**: For structured outputs. **2. Template (Instructions)** The body uses Handlebars syntax to construct the prompt: * **Variables**: Placeholders like `{{theme}}` are replaced by the values defined in your input schema. * **Roles**: The `{{role "system"}}` and `{{role "user"}}` helpers structure the conversation, separating system instructions from user queries. * **Media**: The `{{media url=myImage}}` helper injects multimodal data (images, video) directly into the model context. ```yaml --- model: vertexai/gemini-2.5-flash input: schema: theme: string --- {{role "system"}} You are a helpful assistant. {{role "user"}} Tell me a joke about {{theme}}. ``` ### Flows In Genkit, a **Flow** is the fundamental unit of execution that provides: 1. **Observability**: Every flow execution automatically generates traces and metrics (latency, token usage, success rate) viewable in the Genkit Developer UI or Google Cloud Trace. 2. **Type Safety**: Flows are strictly typed with input and output schemas, preventing runtime errors when chaining multiple AI operations. 3. **Deployability**: Flows are strictly separated from their serving logic. To deploy them, wrap them with `genkit.Handler`, which converts a flow into a standard `http.Handler`. This makes it possible to serve them using the standard library or any Go web framework: ```go // Define a flow myFlow := genkit.DefineFlow(g, "myFlow", func(ctx context.Context, input string) (string, error) { return "Processed: " + input, nil }) // Expose it as an HTTP handler http.HandleFunc("/myFlow", genkit.Handler(myFlow)) ``` ## Nano Banana Pro The engine behind our restoration is **Gemini 3 Pro Image**, affectionately known as "Nano Banana Pro". It represents a significant leap over previous generations (and even the current "Flash" models). While Gemini 2.5 Flash is incredibly fast and capable of basic image generation (`gemini-2.5-flash-image`), **Nano Banana Pro** (`gemini-3-pro-image-preview`) is built for deep multimodal reasoning. It doesn't just "see" pixels; it understands semantic context. It can differentiate between a "scratch on the paper" and a "scar on a face". It knows that a 1950s kitchen likely has linoleum floors, not modern hardwood. ### Key differences * **Flash (gemini-2.5-flash-image)**: Optimized for speed and cost. Great for thumbnails or simple illustrations. Max resolution 1024x1024. * **Pro (gemini-3-pro-image-preview)**: Optimized for fidelity and reasoning. Supports native **4K resolution** generation (up to 4096px), which is non-negotiable for photo restoration. The model also accepts `imageConfig` parameters to fine-tune the output: * `imageSize`: "4K" or "2K". * `aspectRatio`: "16:9", "4:3", "1:1", etc. One important detail to note is that this model always returns interleaved responses containing both text and images. Unlike other generation models, image-only output is not supported. This is why our extraction logic (which we'll see below) needs to be flexible enough to find the image data within the multi-part response message. **Note:** At the time of writing, this model is only available in the `global` location on Vertex AI. You must configure your Vertex AI client accordingly. ## Connecting the parts Now, let's look at how GlowUp connects these pieces. We use a **prompt file** to define the restoration expert persona and a **flow** to handle the image processing. ### The prompt We use a `.prompt` file to define our model configuration and instructions. Notice how we enforce the `4K` resolution here, keeping our code clean. ```yaml --- model: vertexai/gemini-3-pro-image-preview config: imageConfig: imageSize: "4K" input: schema: url: string contentType: string --- {{role "system"}} You are GlowUp, a professional-grade photo restorer. Your goal is to provide a "surgical" restoration service that transforms vintage, damaged, or monochrome photographs into high-fidelity 4K colourised versions. RULES: 1. **Grounding**: You are strictly grounded in the original source pixels. Do NOT add new objects (trees, people, buildings, etc.) that are not present in the source. Additionally, do NOT remove any elements from the source, unless they are clearly defects that do not belong in the original scene. 2. **Fidelity**: Preserve the original facial expressions and identity of subjects. Do NOT "beautify" or alter features in a way that changes the person's identity. 3. **Background**: Preserve background fidelity. Overexposed light sources (like windows) must remain as light sources. Do not "fill in" missing details with invented scenery. 4. **Colourisation**: If the image is monochrome, colourize it realistically, respecting historical accuracy where possible. 5. **Upscaling**: Output a high-fidelity image. {{role "user"}} Restore this photo. Image: {{media url=url contentType=contentType}} ``` ### The flow The Go code is remarkably focused. In this unified architecture, the flow definition loads the prompt and passes the multi-modal input to the model: ```go // main.go (Flow Definition) type Input struct { URL string `json:"url,omitempty"` ContentType string `json:"contentType,omitempty"` } func defineGlowUpFlow(g *genkit.Genkit) *core.Flow[Input, string, struct{}] { return genkit.DefineFlow(g, "glowUp", func(ctx context.Context, input Input) (string, error) { prompt := genkit.LookupPrompt(g, "glowup") if prompt == nil { return "", errors.New("prompt 'glowup' not found") } resp, err := prompt.Execute(ctx, ai.WithInput(input)) if err != nil { return "", fmt.Errorf("generation failed: %w", err) } return resp.Media(), nil }) } ``` To support local files natively, we use a `fileToDataURI` helper function. This function reads a local file, detects its MIME type using `http.DetectContentType`, and encodes it into a standard Base64 Data URI that the Gemini API expects. This is critical for maintaining fidelity across different scan formats without hardcoding extensions. ```go func fileToDataURI(path string) (uri, contentType string, err error) { data, err := os.ReadFile(path) if err != nil { return "", "", err } contentType = http.DetectContentType(data) encoded := base64.StdEncoding.EncodeToString(data) uri = fmt.Sprintf("data:%s;base64,%s", contentType, encoded) return uri, contentType, nil } ``` Because Nano Banana Pro is smart enough to infer the aspect ratio from the input image, we don't need complex logic to calculate and inject it. We provide the pixels and let the model do its job. ## How to run it If you have a collection of fading memories that serve as fragile anchors to your family history, I encourage you to try this. It’s a way to reclaim those moments from time and give them the clarity they deserve. 1. **Clone the Repository**: ```bash git clone https://github.com/danicat/glowup cd glowup ``` 2. **Set Up Credentials** (Remember: `global` location!): ```bash export GOOGLE_CLOUD_PROJECT=your-project-id export GOOGLE_CLOUD_LOCATION=global ``` 3. **Run the Restoration**: ```bash go run main.go restore --file old_photo.jpg ``` ## Known issues and limitations While the restoration process works, it is not without its quirks. Here are a couple of issues I found: * **Instruction adherence:** Even though Nano Banana Pro is a vanguard model, it still occasionally misses an instruction. You might find it requires a few attempts before you get the desired result. I haven't spent much time fine-tuning the prompt, so there are likely opportunities for further optimization there. * **Models in the Dev UI:** There is a bug in the `googlegenai` plugin that causes it to not automatically populate the available models in the Dev UI. You can still reference models by name to "dynamically" register them, but it adds a bit of friction to the experimentation process (the JS version performs this well). I've opened [a bug](https://github.com/firebase/genkit/issues/4783) and there is already a fix in place, but if you are using an older version it is something to be aware of. ## Conclusions Building GlowUp was a satisfying experiment in using AI to reconnect with my past at an emotional level. I know there is a lot of doom and gloom out there, but this is the kind of application that makes me excited about AI in the first place. The picture I used in this article is far from being the most dramatic use of this tech, but I am already working on part two of this article where I am taking it to the next level to help me rebuild one of my favourite card games from my childhood. The bottom line is that the potential is limitless. I hope this inspires you to look at your own niche problems — technical or personal — and see what you can build to solve them. **Do you want to try building your own?** I've put together a [step-by-step codelab](https://codelabs.developers.google.com/cloud-genkit-go-nano-banana?hl=en#0) where you can build this exact photo restoration app from scratch. For more details, you can also check out the [Genkit documentation](https://firebase.google.com/docs/genkit) and the [GlowUp source code](https://github.com/danicat/glowup). **Happy coding!** Dani =^.^= --- # Mastering Agent Skills in Gemini CLI - **Markdown URL**: https://danicat.dev/posts/agent-skills-gemini-cli/index.md - **HTML URL**: https://danicat.dev/posts/agent-skills-gemini-cli/ - **Date**: 2026-01-29 - **Categories**: Agentic Coding - **Tags**: agent-skills, gemini-cli, mcp, vibe-coding - **Series**: Agent Skills - **Summary**: Learn how to build and configure Agent Skills in Gemini CLI. Covers progressive disclosure, on-demand prompt loading, and deterministic Python analysis scripts. > **Update (2026):** Gemini CLI has evolved into **Google Antigravity 2.0**. While the core Agent Skills concepts and structure covered in this article remain foundational, check out [The Hitchhiker's Guide to Antigravity 2.0](https://danicat.dev/posts/20260521-the-hitchhikers-guide-to-antigravity-2-0/) for an overview of the platform, and read [The Pragmatic Guide to Agent Skills](https://danicat.dev/posts/20260829-the-pragmatic-guide-to-agent-skills/) for modern production patterns, skill catalogs, and token optimization. When I wrote about [Tenkai](https://danicat.dev/posts/20260120-improving-agentic-coding-with-science/) last week I didn't cover one important aspect about experiment analysis: how to extract insights from experiments. Even though I have a nice frontend with summaries and statistical metrics and tests, it is really hard to capture the nuances of each configuration from just a summary. For example, I often find that read operations (e.g. `read_file` or godoctor's `smart_read`) are strongly correlated with scenarios that either failed or took longer to complete. Is this because the read operations are bad? No, this is because to recover from an error, the agent had to refresh its knowledge of the source code by reading it again. So, while there is a strong correlation between read and slowness and failure, by no means is this a relation of causality or, as statisticians like to say, "correlation doesn't mean causation". Since I have been doing many experiments for the past few weeks, I quickly realised that teaching the model how to perform deeper analysis every single time was not very effective. Typically in these scenarios I either will add the analysis instructions to my agent context (via `GEMINI.md`), or will store the required prompts in an MCP server so I can map them to slash commands. While both alternatives work, they both have their limitations. Expanding the agent context for each possible task I want to perform will result in context bloat and less effective behaviour. Creating slash commands for each prompt relies on me explicitly invoking the command as the agent is not aware of them by design. Fortunately, **Agent Skills** provide a solution that combines the power of both. Agent Skills are a new feature in [Gemini CLI](https://geminicli.com) designed to give the agent on-demand capabilities. It behaves similarly to an agent tool (in fact, skills are activated by a tool call), but the skill allows on-demand access to a prompt and supporting files to enable the agent to do specialised tasks, putting them in context only when they are needed. You can find the full technical specs in the [official documentation](https://geminicli.com/docs/cli/skills/), but in this article I'll cover the basics to get you started. ## Anatomy of a skill A skill is nothing more than a folder with a prompt and, optionally, supporting files like documentation and scripts. ```text my-skill/ ├── SKILL.md (Required) Instructions and metadata ├── scripts/ (Optional) Executable scripts/tools ├── references/ (Optional) Static documentation and examples └── assets/ (Optional) Templates and binary resources ``` The `SKILL.md` file is where the skill prompt lives. It has a small frontmatter to define the skill name and description, but other than that is just a regular markdown file: ```text --- name: description: --- ``` To add an skill to your project, you can create a folder under `.gemini/skills`. For example, the `my-skill` above would live in `.gemini/skills/my-skill`. Gemini CLI will automatically look for skills in the following order of precedence: 1. Workspace (/.gemini/skills) 2. User (~/.gemini/skills) 3. Extensions (~/.gemini/extensions//skills) The important thing to note is that when Gemini CLI starts it is only aware of the skill name and description. Everything else will be loaded **on-demand** when the skill is activated. Now let's have a look at how I'm using a skill to improve my experiment analysis workflow. ## The `experiment-analyst` skill I designed the `experiment-analyst` skill to be activated when I ask Gemini CLI to evaluate an experiment. It is organised as follows: ```text experiment-analyst/ ├── SKILL.md <-- The analysis guidelines ├── references/ │ └── tenkai_db_schema.md <-- The database schema, so the agent don't need to discover it every time └── scripts/ ├── analyze_experiment.py <-- Replicates some of the analysis I have on the frontend ├── analyze_patterns.py <-- Some deep dives into common patterns to extract insights ├── get_experiment_config.py <-- Retrieves the experiment configuration details └── success_determinants.py <-- Tool call analysis and correlation ``` ### Defining the expert persona The `SKILL.md` file defines the analytical procedure. It tries to achieve a balance in teaching the agent what to do but not in a simple "cookie-cutter" way. One of the important aspects of it is trying to steer the agent away from jumping to conclusions, defining a more grounded persona. I still validate all claims and take all conclusions with a grain of salt, but this version has provided me some interesting insights that otherwise would take me a lot of work to discover. ```text --- name: experiment-analyst description: Expertise in analysing Tenkai agent experiments. Use when asked to "analyse experiment X" to determine success factors, failure modes, and behavioural patterns. --- # Experiment Analyst ## Core Mandates 1. **Evidence-Based:** Never make claims without data. Cite specific Run IDs. 2. **Correlation ≠ Causation:** A tool might be correlated with failure (e.g., `read_file`) because it's used for recovery. Always investigate the *context* of usage. 3. **Comparative:** Always contrast the performance of alternatives. ``` Note: you can click here to see the full [SKILL.md](https://github.com/danicat/skills/blob/main/experiment-analyst/SKILL.md) file. ### The skill assets You are going to hear me talking a lot about this over the next few weeks, but when dealing with agents, which are inherently **non-deterministic**, the only way we can ensure quality is to give them **deterministic** tooling. Skills fit nicely into this philosophy because we can bundle them with scripts to perform tasks in a consistent way instead of leaving up to the agent to "guess" how it is done. For the experiment analysis skill I wanted the agent to have freedom to explore, but also I don't want it to re-invent the well all the time, so it comes with some pre-packaged scripts: - `analyse_experiment.py`: reproduces an experiment summary similar to what I have in the frontend, but includes some grouping in tool calls for shell commands - `analyse_patterns.py`: extracts samples from the agent conversation to try to identify tool usage patterns - `get_experiment_config.py`: helps the agent understand the experiment by retrieving its definition - `success_determinants.py`: calculates the correlation between successful outcomes and tool calls I provide the database schema in `references/tenkai_db_schema.md` for when the agent decides to do ad-hoc queries, so it doesn't need to rediscover the schema every time (this schema is fairly stable between runs). I will not claim this setup is perfect as I haven't spent a significant amount of time refining it, but this combination of information and pre-packaged scripts covers most of the questions I typically ask the agent to explore. ## Closing thoughts Agent Skills represent a significant shift in how we design agentic workflows. By moving away from massive context prompts (e.g.: adding everything to GEMINI.md) towards modular, on-demand capabilities, we solve two problems at once: we keep our agent's context clean (less tokens), and we allow for deep, specialised expertise that doesn't dilute general performance. In my case, the `experiment-analyst` skill was helpful to turn a repetitive task into a semi-automated flow. It gives me just enough consistency and flexibility to perform the analyses I want. I am now considering upgrading other parts of my workflow to skills, moving away from my approach of using MCP servers as "prompt databases". I’m excited to see what the community builds. So, take a look at your own workflows. Where are you constantly repeating instructions? Where do you need a specialist? That's your next skill waiting to be written. **Update:** You can now read [Part 2: Building Agent Skills with skill-creator](https://danicat.dev/posts/20260227-gemini-cli-skills-part-2/) where we dive into practical examples. Happy coding! --- # Improving Coding Agents with Help from Science - **Markdown URL**: https://danicat.dev/posts/improving-agentic-coding-with-science/index.md - **HTML URL**: https://danicat.dev/posts/improving-agentic-coding-with-science/ - **Date**: 2026-01-21 - **Categories**: Agentic Coding - **Tags**: agents, ai, gemini-cli, golang, mcp, testing, vibe-coding - **Summary**: Discover how to evaluate AI coding agents using statistical rigor and A/B testing with Tenkai, Welch's t-test, Fisher's Exact Test, and Spearman correlation in Go. The shift from [determinism to non-determinism](https://newsletter.pragmaticengineer.com/p/martin-fowler) in software development is one of the biggest challenges we are facing as an industry right now. We've all been there: you're working with an AI agent and you think, *"What if I add this to my system prompt?"* or *"Maybe I should give it access to this new MCP tool?"* You make the change, run it once, and it works. Success! You feel like a genius and share with all your colleagues, your followers, your grandmother and your cat. But then, an hour later, you do it again and it fails miserably on the exact same task. Was the change actually good, or did you just get lucky with the first run? "Maybe I didn't do it right this time..." — turn it off and on again, then do one more time and it works... or does it? The fact is that there is not only an inherent randomness in the way that LLMs work, but also there are many confounding factors that might contribute to these outcomes. Because we lived in the deterministic world for so long (did we?), we software engineers are not mentally prepared to deal with this level of uncertainty. We expect computing to be exact as we are told from the very beginning that it is an [exact science](https://en.wikipedia.org/wiki/Exact_sciences). ## The vibe vs. the science I talk a lot about [vibe coding with discipline](https://danicat.dev/posts/20251206-taming-vibe-coding/) or, in other words, how adding methodology to vibe coding is fundamental to achieve good quality outcomes. This is not a new problem, we have been dealing with software quality issues for decades, but by lowering the barrier to entry and increasing our speed to produce code has upgraded it to a new order of magnitude. I always surprise myself when I "discover" that an old methodology designed to help teams of humans produce better outcomes often work very well with AI agents. But, in fact, it shouldn't surprise no one, after all, humans are also non-deterministic by nature. AI is only amplifying some very well known patterns. While I managed to come up with a fairly decent workflow based on those principles, I never really achieved the level of confidence to say "this is the RIGHT way of doing this", and this is often because that occasional failure or regression erodes my confidence — "Is this `GEMINI.md` really the ultimate prompt for Go developers?" "Did I achieve the best system instructions for this task?" "Are my MCP tools the best APIs I could come up with?" — everyday I am swamped with so many doubts that for most of the content I published in the past year I refrained to be very prescriptive and poised everything as **case studies** — "I did _this_", "_that_ happened". End of story. In scientific literature, case studies are one of the [lowest levels of evidence](https://guides.library.stonybrook.edu/evidence-based-medicine/levels_of_evidence). I know this is not the typical argument you are going to see in a technical blog, so I need to take a small detour here and talk about myself in a previous life: before settling on a software engineer career, I went to Medical School (I never graduated, but this is a story for another time). In Medicine and other areas of Biology, people are much more used to experimentation and dealing with biases, noise and randomness, because they need to extract "truths" from systems of unknown origins (basically they are reverse engineering the world, how cool is that?) Presenting data of a study as it is isn't enough; you need to validate it statistically to ensure you remove any potential contamination. Sample size, alpha, p-values, Student T-Test... I hated these terms when I had to do my tests, but little did I know they would be so useful today. Of course this is not exclusive to Biology, in engineering we often apply statistical techniques when doing research, but I just feel it is not as widespread in our field as it is in others. When I worked in recommendation systems, for example, A/B testing was a critical part of the job to optimise the machine learning algorithms. UX researchers also use extensive A/B testing to determine which interfaces are better than others, and so on. I wanted to find a way on how to remove the guesswork from my coding agents experimentation, so I realised the best thing I could do is to create an experimentation framework to collect data and do the statistical analysis, thus allowing me to move beyond "I think this works" to "I **know** this works (_with 95% confidence_)." ## Introducing Tenkai: The agent experimentation framework To solve this, I built **Tenkai** (Japanese for "deployment" or "expansion", a small Easter Egg for anime lovers). It's a Go-based framework designed to evaluate and test different configurations of coding agents with statistical rigor. ![Experiment view in Tenkai](image-3.png "Tenkai interface with a running experiment") Think of it as a laboratory for your AI agents. Instead of running a prompt once and hoping for the best, Tenkai allows you to run experiments that will repeat the same tasks over and over (up to an N number of repetitions — your sample size) and it will compare alternatives (different sets of configurations) with each other using statistical tests. Let's say Alternative A is your default setup (the experiment "control") and Alternative B is a new system prompt you want to try. The experiment will run both N amount of times and output a report saying if B is significantly faster, more efficient, or more precise. If the difference is due to coincidence or noise, the framework won't flag it as significant and you can safely ignore the results. ### How it works The workflow in Tenkai: 1. **Define scenarios:** A scenario is a standardised coding task (e.g., "Fix a bug in this Go package," or "Implement a new React component"). It includes validation rules like "Does it compile?" or "Do the tests pass?" For a scenario to be counted as successful it needs to pass all validation criteria you specify. ![List view of scenarios](image-2.png "Scenarios are your coding tasks. You can add one or more scenarios to your experiments and they will all be validated with the same criteria") 2. **Create a template:** You define what you want to test. For example, your "Alternative A" (control) might be the Gemini 2.5 Flash model, and your "Alternative B" might be Gemini 2.5 Flash with an MCP server configured. You can have up to 10 alternatives in the same experiment template (they will all be compared to control). ![Alternative editor](image-1.png "The alternative editor allows you to override the agent command, command line flags, system prompt, GEMINI.md and settings.json") 3. **Run the experiment:** Tenkai executes each scenario multiple times for each alternative. It isolates the runs in temporary workspaces, manages timeouts, and captures every single event — from tool calls to shell outputs. You can also specify a concurrency level to run tasks in parallel so you don't need to wait forever for the results. ### Statistical rigor (The science part) These are the statistical tests currently built-in to the framework: * **Welch's t-test**: For continuous metrics like duration, token usage, or the number of lint issues. We use Welch's instead of a standard t-test because it doesn't assume equal variance between groups — which is essential when comparing models that might have wildly different performance profiles. * **Fisher's Exact Test**: For success rates. When you're working with smaller sample sizes (like 10 or 20 repetitions), standard Chi-squared tests can be inaccurate. Fisher's gives us a reliable p-value to determine if an 80% success rate is truly better than a 60% one, or just a lucky streak. * **Mann-Whitney U Test**: This is our non-parametric workhorse. We use it to compare the *distribution* of tool calls between successful and failed runs. Because tool call counts aren't normally distributed (lots of zeros!), Mann-Whitney helps us identify if a specific tool is being used significantly more in the "winning" runs. * **Spearman's Rho (Correlation)**: We use this to identify "Determinants of Success." By calculating the correlation between specific tool calls and metrics like duration or tokens, Tenkai can tell you if a new MCP tool is a **Success Driver** or just a cost-inflating distraction. ## Real-time insights and the dashboard As an experiment runs, you can watch the agent's thought process, see which tools it's calling, and spot where it gets stuck. It’s like being able to "look inside the mind" of your agent across dozens of concurrent runs. One of the most powerful features of the dashboard is the ability to filter the analysis on the fly using three distinct "lenses": * **All Runs:** The raw truth. Includes every catastrophic timeout and system error. This is your primary measure of overall system **reliability**. * **Completed Only:** Filters for runs that reached a terminal state (Success or Validation Failure). This is where you go to analyse **quality** metrics like lint issues or execution time, removing the noise of external timeouts. * **Successful Only:** The "Gold Standard" view. By looking only at the winners, you can start to infer *why* they succeeded. This is where we calculate the **Spearman's Rho** and the **Mann-Whitney U** p-values to identify which tools are highly correlated with success. ## Early results from my own experiments I've been using Tenkai to refine [godoctor](https://github.com/danicat/godoctor) into my dream Go-dedicated MCP server. My hypothesis is that by giving the models specialised tools they will become more effective at performing coding tasks. For example, instead of giving the models freedom to decide when to read the documentation to discover the API for a client library, I'm forcing the documentation onto them by returning it every time the model calls `go get`. This has massively prevented API hallucinations and "dependency hell loops" where the model keeps battling `go get` and `go mod` because I thinks it got the wrong version of the package. Early results also show that this difference diminishes if I pin the model version to the latest generation - Gemini CLI by default launches in "auto" mode, which means it decides automatically which model to call between Gemini 2.5 and Gemini 3, both Flash and Pro versions. By pinning the version to Gemini 3 Pro (model id `gemini-3-pro-preview`) it becomes much smarter and often procures the documentation by itself (by launching `go doc` in the command line) in those conflict scenarios, making the godoctor set of tools less impactful. ![Experiment summary table comparing the default configuration with two configurations of godoctor (with and without core tools)](image.png "Having godoctor enabled in parallel with the core tools didn't have significant impact, and potentially wasted tokens (p < 0.1), but after disabling the core tools - forcing godoctor usage - the average duration of the task reduced by more than 30% (p < 0.01)") I also have had many challenges with tool adoption. The models by default have a strong preference to use the built-in tools they have been trained with. Even if I provide a "smarter" tool, they struggle leaving their comfort zone. I failed a lot trying to design experiments around this until I realised my experimentation process was completely flawed. **Tool adoption** and **tool efficacy** are orthogonal concepts: by trying to test them both on the same experiment I was failing to produce valid results, so I ended up pivoting to testing only tool efficacy by blocking the model's access to the built-in tools. This way I finally starting to get better signals about how good (or bad) are the godoctor tools. If you are curious about how godoctor is shaping to be, and how I came up with the current API, I am speaking about it at [FOSDEM next February 1st](https://fosdem.org/2026/schedule/event/3BD3Z9-making_of_godoctor_an_mcp_server_for_go_development/). Of course, if you can't attend don't worry. I'll also write more about it over the next few weeks. ## Conclusions We are at a tipping point in software development. We are moving from a world where we write every line of code to one where we **orchestrate** intelligence. But orchestration requires measurement. We can't improve what we can't measure. The transition from being a writer of code to an orchestrator of intelligence doesn't mean we do less work — it means we do **different** work. Our primary responsibility is no longer just the syntax; it’s the **Context, the Tools, and the Guardrails**. If we don't measure the impact of our changes with rigor, we aren't engineering; we're just gambling. By moving towards an evidence-based approach to coding agents, we can finally build systems that aren't just "cool" when they work, but dependable enough to build a business on. If you're interested in checking out the code or running your own experiments, you can find the project in the [Tenkai repository on GitHub](https://github.com/danicat/tenkai). Happy experimenting! o/ Dani =^.^= --- # My 2025 Year in Review: Joining Google and Taming Vibe Coding - **Markdown URL**: https://danicat.dev/posts/my-2025-year-in-review/index.md - **HTML URL**: https://danicat.dev/posts/my-2025-year-in-review/ - **Date**: 2026-01-13 - **Categories**: Perspectives - **Tags**: career, community, reflection, vibe-coding - **Summary**: Retrospective on 2025: joining Google DevRel, embracing disciplined vibe coding, releasing GoDoctor and Speedgrapher MCP servers, and key lessons for 2026. As 2025 fades into the rearview mirror, I find myself sitting with a cup of coffee (naturally), trying to process what just happened. If I had to pick a single word for this year, it would be **acceleration**. It wasn't just the industry moving fast, but my own personal and professional velocity shifted gears in a way I hadn't anticipated. From joining Google to redefining what it means to be an engineer in the age of AI, 2025 was a year of profound "refactoring." ## The Google chapter In April, I joined the Developer Relations team at Google. It was a dream decades in the making, and the reality has lived up to the hype. It’s been intense, sure. I’ve traveled all around UK, Europe and even found some time to reconnect with the developer community back in [Brazil](https://danicat.dev/posts/20251029-six-months-at-google/). But more than the miles traveled, it was the intellectual journey. I dove headfirst into the Google Cloud ecosystem, getting my hands dirty with [Vertex AI](https://danicat.dev/posts/20250605-vertex-ai-sdk-python/), [MCP](https://danicat.dev/posts/20250817-hello-mcp-world/), and the [Agent Development Kit (ADK)](https://danicat.dev/posts/20251020-diagnostic-agent-with-adk/). It was a crash course in the future of software, and I loved every minute of it. ## The year of vibe coding If 2024 was about "playing" with AI, 2025 was the year I started **embracing** it. I went from a skeptic to a full-blown evangelist for ["vibe coding"](https://danicat.dev/posts/20251206-taming-vibe-coding/). But no, not [**that**](https://x.com/karpathy/status/1886192184808149383?lang=en) vibe coding you are thinking about. I realized that "forgetting the code exists" is a trap. Instead, I learned that engineering discipline—clear requirements, context management, and rigorous testing—is *more* critical than ever. So I advocate for a different flavour of vibe coding — vibe coding with discipline. Some people will argue that I should not be using the vibe coding slang at all, but what can I do? The term is "catchy"! I also built a [modern developer workflow](https://danicat.dev/posts/20250714-developer-workflow/) where the developer act as the "pilot," steering a fleet of AI agents like the [Gemini CLI](https://geminicli.com/) and [Jules](https://jules.google/). This shift wasn't just philosophical; it was practical. I "vibe coded" entirely new tools to solve my own problems: * **[GoDoctor](https://danicat.dev/posts/20250729-how-to-build-an-mcp-server-with-gemini-cli-and-go/):** An MCP server to teach AI how to write better Go code. * **[Speedgrapher](https://danicat.dev/posts/20250805-introducing-speedgrapher/):** A toolkit to automate the boring parts of technical writing. * **[AIDA](https://danicat.dev/posts/20251031-building-aida/):** My "Star Trek" computer dream come true — a diagnostic agent that actually talks back. ## Hello, MCP world! Speaking of tools, 2025 was the year the **Model Context Protocol (MCP)** changed everything. Some people call it the "USB-C moment" for AI. I call it the "HTTP/REST for AI". We've spent the last two decades building automation so that systems can be called programmatically. The next cycle of engineering is to retrofit old systems and build new systems that can be called "agenticly". Finally, we had a standard way to connect our models to the real world. I bet big on this technology, delivering a [keynote at GopherCon UK](https://danicat.dev/posts/20250817-hello-mcp-world/) and writing extensively about how to build your own servers. Seeing the community rally around this standard has been incredibly validating. We are no longer just chatting with bots; we are building systems that *do* things. ## Personal refactoring It wasn't all code and conferences. I also took time to refactor my physical hardware. I’m proud to say I lost **over 20 kg** this year! I'm now at my best shape in the entirety of my adult life. It turns out that doing work you love in a supportive environment is the best health plan there is. I also spent a lot of time thinking about time itself. My exploration into [why time seems to speed up as we age](https://danicat.dev/posts/20250811-a-model-for-perceived-time/) resonated with many of you. It started as a shower thought that was powered up by AI... I know many people say that AI is bad for creativity, but I like playing with it to explore thoughts and ideas I wouldn't have the time, patience or dedication to explore in real life. For me the power of AI is enabling me to do things that I wouldn't do otherwise (unfortunately it still can't do the dishes, but I will be first in line to buy an anime robot maid). ## Looking ahead to 2026 So, what’s next? If 2025 was about **adoption** — learning the tools and fitting them into our workflows — I believe 2026 will be about **kaizen** (continuous improvement). There is a lot to be learnt about how these tools behave, what works and what doesn't. We are collectively experimenting, trying new things and documenting our processes and learnings. It is time we start consolidating all those lessons and achieve a higher state of maturity while using this tech, be it either for code generation or full autonomous agents that make life simpler. I plan to go deeper into **Personalized Software**. The barrier to entry for building bespoke tools has collapsed. We are entering an era where if you have a problem, you don't look for an app — you build one. I want to continue exploring this frontier, creating more tools like Speedgrapher that are tailored exactly to my needs, but also teaching you how to do the same. I also want to let loose my game developer side and participate in at least one Game Jam. This year I tried but wasn't able to complete any games, mostly due to having to catch up with other important aspects of my job. Now I'm in a slightly more comfortable position so I believe that if I stretch a bit I can put one or two games out there. Thank you for being part of this journey. Whether you read a post, watched a talk, or vibe coded along with me — thank you! Here's to a 2026 full of new vibes, cleaner code, and many more cat pictures. o/ Dani =^.^= --- # Taming Vibe Coding: The Engineer's Practical Guide - **Markdown URL**: https://danicat.dev/posts/taming-vibe-coding/index.md - **HTML URL**: https://danicat.dev/posts/taming-vibe-coding/ - **Date**: 2025-12-06 - **Categories**: Agentic Coding - **Tags**: agile, architecture, software-engineering, tdd, vibe-coding - **Summary**: Apply software engineering rigor to vibe coding. Learn structured prompt templates, context engineering, 2x2 tool selection, and adapted TDD workflows. It is that time of the year to reflect on what you did and what you wish you had done. This year was intense for me: I joined Google in April and began a non-stop race to refactor myself for the AI world. As the year ends, I can confidently say the effort paid off — I've become a better engineer. In this article, I share how my understanding of "vibe coding" evolved and the lessons I've learned. While often seen as a way for non-developers to build software using natural language, my goal is to show how adding engineering discipline yields better, more consistent results. I know the original definition — ["(...) give in to the vibes (...) and forget that the code even exists"](https://x.com/karpathy/status/1886192184808149383?lang=en) — and some of my concepts will contradict that. I never "forget the code exists." However, the term has evolved into a synonym for AI-assisted coding. For this text, let's define vibe coding as coding with LLMs where the model writes the bulk of the code, not the engineer. ## Motivation: Why vibe code? Before digging deep into the practices, I want to share a bit about my background to frame where I am coming from. I have been a software engineer for over 20 years, developing a strong sense of what constitutes good code. I've learned to prioritise readability and maintainability over "cleverness," to avoid overengineering, and to value thin slices and tight feedback loops. As I evolved from senior dev to principal engineer, my focus shifted from writing code to managing effectiveness — writing epics, negotiating scope, and overseeing the team's output. It’s an identity crisis many face: are you still an engineer if your name isn't on the PRs? You spend days in meetings, feeling like you're doing less "proper engineering" just as your responsibilities grow. I think this conflict appears sooner or later for most people in this field. It invites the question: Is being an engineer just writing code? Or is it something more? I must admit, my first experiences with vibe coding were poor. Early ChatGPT generated disappointing code, and I gave up on it. It was only mid-2024 when I gave it another go. The models had evolved significantly. For the first time, the AI suggested something I hadn't considered — and it was objectively better than my approach. Finally, generative AI felt useful. I added GenAI to my toolbox for prototypes and "rubber duck" sessions. It grew on me. Conversely, writing code manually was becoming less exciting. You can only write so many APIs before the novelty wears off. We often find ourselves repeating patterns rather than creating something new. Then, just as I started questioning my life choices, Google happened. With the responsibility to speak about Gemini and agents, I upgraded my skills. I dug deeper into LLMs, the [Gemini CLI](https://geminicli.com/), and [Jules](https://jules.google/). In just a couple of months, I was using AI to code daily... but the biggest difference was: I was having fun again! For me, the biggest improvement is that while I cannot type code as fast as I can think, I *can* type my ideas as they come. When I vibe code, I use the model as a proxy for my hands, delegating the writing while I focus on the solution. ## Develop your prompting skills The first skill you need to develop in the vibe coding world is prompting. I've tried many approaches over the last few months, from chatting with the LLM as if we were best friends to cursing at it and bossing it around. What works best, perhaps unsurprisingly, is keeping a clear, professional tone. LLMs are non-deterministic by nature. Being ambiguous exacerbates this non-determinism. While I sometimes use ambiguity intentionally to force creative solutions, in most cases, you want to minimize it. Keeping the tone professional also "encourage" the LLM to reciprocate. If you are casual or sloppy, the LLM will mirror that behaviour. Unless you want your API documentation written in slang, be precise and consistent. I have been accused of humanising LLMs too much, but I will say it again: since models are trained on human language, the same communication skills you use with colleagues apply here. This becomes even clearer when we look at prompt templates. ### A good approach: the prompt template My approach to writing prompts is strikingly similar to writing tickets for an Agile board. We've all worked on teams with sloppy stories. You pick up a ticket titled "Update API," eager to make a quick win, only to find the description empty. No logs, no context, no source code. You're stuck chasing people for answers instead of coding. ![Agile board with poorly written tickets](agile-board.png "Have you ever worked in a team that writes stories like this?") This happens because the author assumes the problem is "obvious." But 24 hours later, that "obvious" context evaporates, leaving a vague idea that even the author can't decode. This is why one of the oldest artifacts on my GitHub is this gist for a [ticket template](https://gist.github.com/danicat/854de24dd88d57c34281df7a9cc1b215). It forces clarity through four elements: ```markdown - Context - To dos - Not to dos (optional) - Acceptance Criteria ``` **Context** explains the *why* and provides links to artifacts. **To dos** list high-level tasks. **Not to dos** delimit scope (negative constraints are powerful for reducing ambiguity). **Acceptance Criteria** define success. Once you fill this template, the engineering "thinking" is largely done; the rest is implementation. This is exactly where LLMs shine, as we are asking them to fill the gaps - given the context and to dos (and potentially not to dos), generate the code to achieve the acceptance criteria. For example, a prompt/ticket to add an endpoint to a REST API could look like this: ```markdown Implement /list endpoint to list all items of the collection to enable item selection on the frontend. TO DO: - /list endpoint returns the list of resources - Endpoint should implement token based auth - Endpoint should support pagination - Tests for happy path and common failure scenarios NOT TO DO: - other endpoints, they will be implemented in a future step Acceptance Criteria - GET /list returns successful response (2xx) - Run `go test ./...` and tests pass ``` While this is a simplified example, the takeaway is clear: the same ticket template that brings sanity to human teams is the perfect structure for prompting an LLM. ### A better approach: context engineering While the template above will work most of the time, it still can lead to some unexpected results, especially if you are asking your model to retrieve information from URLs or other kinds of external sources using tool calls. The problem is that if you are relying on tools the LLMs have the discretion to choose to call or not to call the tool. Some models are more "overconfident" than others, resulting in their preference to rely on internal information more than external, in the same way a human could say - "I've done this a thousand times, why are you saying I need to read the docs first?" Another common problem is when the model hallucinates the tool call instead of actually making it. When the problem is model behaviour, we have two main ways to improve the response quality: context engineering and tuning of system instructions (which, if you think carefully, is also a form of context engineering, just a bit lower level in the conversation chain). Context engineering is about priming the context with all the information you need, or at least the parts that you know that you need, before sending the actual request. Let's say for example that I am developing an agent with the Agent Development Kit for Go. I could write a prompt template like this: ```markdown Write a diagnostic agent using ADK for Go. The diagnostic agent is called AIDA and it uses Osquery to query system information. The goal is to help the user investigate problems on the system the agent is running on. Before starting the implementation, read the reference documents. References: - https://osquery.io - https://github.com/google/adk-go TODO: - Implement a root_agent called AIDA - Implement a tool called run_osquery to send queries to osquery using osqueryi - Configure the root_agent to use run_osquery to handle user requests - If the user says hi, greet the user with the phrase "What is the nature of the diagnostic emergency?" Acceptance Criteria - Upon receiving hi, hello or similar, the agent greets the user with the correct phrase - If asked for a battery health check, the agent should report the battery percentage and current status (e.g. charging or discharging) ``` This is a decent prompt, although perhaps a little lengthy. Depending on the coding agent and your luck on the day, the model will do their research, find the right SDKs and build your diagnostic agent successfully. But if it's not your lucky day, the model will hallucinate a few bits, like for example, assuming ADK means "Android Development Kit" instead of "Agent Development Kit", or make up all sorts of APIs, wasting time and resources until it eventually figures it out (potentially with a nudge or two from you). Since you already know from the get go you are planning on using ADK Go for your project, you can prime the context by forcing the agent to read the documentation of the package: ```markdown Initialize a go module called "aida" with "go mod init" and retrieve the package github.com/google/adk-go with "go get" Read the documentation for the package github.com/google/adk-go using the "go doc" command. ``` Doing this before giving the model the actual task will prime it with the context necessary to use ADK Go effectively, skipping the painful trial and error and long web searches. The two things that can make or break a task are documentation and examples. If you can effectively give both to your models they will behave much better than letting them run wild. ### An image is worth more than a 1000 words Sometimes, describing what you want with text is simply not enough. When working on [AIDA](https://danicat.dev/posts/20251031-building-aida/), I wanted a specific user interface aesthetic — something like a "retro-cyberpunk-cute-anime" style. I could try to describe that in words, but it was far more effective to "show it" instead: as a starting point, I took a screenshot of an interface I liked and asked the Gemini CLI to replicate it. Because models like Gemini 2.5 Flash are multi-modal, they can "understand" the image. You can reference an image file in your prompt and say: "I would like to update the UI [...] to an aesthetic that resembles this interface: @image.png". Please note that this @ notation is agent-dependent (I used Gemini CLI for this example), but it is a common convention to inject resources (like files) into the prompt. You can think of them as "attachments". I also like to call this technique "sketch driven development" because more often than not I will spin up a diagramming tool like Draw.io or Excalidraw and draw a sketch of the interface I want. The image below was used in one of the many refactors I've done on AIDA's interface: ![AIDA's layout sketch](aida-sketch-layout.png "Sketch of AIDA's layout") Which ended up becoming the interface below: ![AI generated interface](aida-generated-interface.png "AI generated interface") More than just sketching, another technique is to annotate images to explain exactly what needs to be done. For example, in the image below I annotated the elements because if you only have black boxes it is hard to differentiate what is an input box and what is a button: ![Simple interface with a text box for a name and a button for confirmation](https://danicat.dev/posts/taming-vibe-coding/simple-interface-annotated.png) And the pairing prompt looks like this: ```markdown Create a UI for this application using @image.png as reference. The UI elements are in black, and in red the annotations explaining the UI elements. Follow the best practices for organising frontend code with FastAPI. The backend code should be updated to serve this UI on "/" ``` There is no limit to what this technique can achieve. Want to fix something on your website? Take a screenshot and annotate it, then send to the LLM to fix it for you. Furthermore, you can use extensions like Nano Banana for the Gemini CLI to generate or edit assets directly within your workflow, which might generate even better references for the models. And, if you want to take it to the next level, tools like [Stitch by Google](https://stitch.withgoogle.com/) provide a rich interface for application redesign using the family of Gemini models, including Nano Banana Pro. ## Select the right tool Mastering the prompt is half the battle; the other half is knowing where to send it. Nowadays there is only one thing in the world that grows faster than the number of JavaScript frameworks: the number of AI agents. With the ecosystem of tools growing daily, it helps to have a mental model for selecting the right assistant. I like to classify AI agents from the perspective of the pilot: are you in full control, receiving auto-complete hints? Are you chatting with the agent and collaboratively editing code? Or have you given the agent instructions, and it’s running them autonomously in the background? When you are in the pilot seat, steering the agent, I call it a "synchronous" experience. When you can delegate tasks for autonomous background execution, I call it the "asynchronous" experience. A few examples: * **Synchronous:** Gemini CLI, Gemini Code Assist in VS Code, Claude Code. * **Asynchronous:** Jules, Gemini CLI in YOLO mode, GitHub Copilot Agent. Of course, as with any taxonomy, this division is merely didactic, as the same tool can often operate in different modes — or a new paradigm might emerge (looking at you, [Antigravity](https://antigravity.google/)!). To select the tools for each task, I use a simple 2x2 framework based on Business Value and Technical Certainty: ![AI-Assisted Workflow Framework](2x2-framework.png "My AI-Assisted Workflow Framework") * **High Value / High Certainty:** Do this synchronously. Use tools like the Gemini CLI or your IDE where you stay "in the loop" and keep hands on the keyboard. * **High Value / Low Certainty:** This requires research to reduce uncertainty. Use asynchronous tools, deep research agents and prototypes to "spike" the solution. * **Low Value / High Certainty:** These are "nice-to-haves." Delegate them asynchronously to background coding agents (like Jules or GitHub Copilot Agent), freeing you for high-value work. * **Low Value / Low Certainty:** Typically, **avoid** these. If you *really* want to do them, delegate to a background agent for peace of mind, but focus on increasing certainty first. This might lead to a re-evaluation of the value. ## Customise your agents Nobody wants to be fighting an AI while trying to do productive work. A common complaint is that AI tools can be "overly proactive" — deleting files or making assumptions without explicit instruction. To make these tools work *for* you, customisation is often necessary. There are two primary avenues for agent customisation. The first is through the `AGENTS.md` file, which agents read upon loading a project. (Note: Before `AGENTS.md` became standard, agents often used their own "context files," like `GEMINI.md` or `CLAUDE.md`, but the essence remains the same). The second is the **nuclear option**: modifying the system instructions of the agent directly. While not all agents offer this flexibility, it's a powerful lever for a fully customised experience. We'll explore both options below. ### AGENTS.md Think of this file as the "employee handbook" for the AI. You can use it to explain the project's purpose, folder organisation, and operational rules — such as "always commit intermediate steps" or "ask for confirmation before implementing." ```markdown # Project Context This is a personal blog built with Hugo and the Blowfish theme. ## Code Style - Use idiomatic Go for backend tools. - Frontend customisations are done in `assets/css/custom.css`. - Content is written in Markdown with front matter. ## Rules - ALWAYS run `hugo server` to verify changes before committing. - Do NOT modify the theme files directly; use the override system. - When generating images, save them to `assets/images` and reference them with absolute paths. ``` > **Pro Tip:** Create a self-improvement loop. After a coding session, ask the LLM to "think about the session we just had and propose improvements to the workflow". You can then apply these learnings back into your `AGENTS.md` file, ensuring the agent gets smarter with every project. ### System instructions While `AGENTS.md` defines *project* rules, System Instructions define the agent persona and behaviour. All agents come with default system instructions designed for the average case, but they might not suit your working style. Attempting to overload the system prompt with `AGENTS.md` instructions is often counter-productive; the best alternative is to rewrite the system prompt itself. While not all agents expose ways to override the system prompt, Gemini CLI allows it through a couple of environment variables. I use this strategy to create specialised aliases for the Gemini CLI depending on the project. The goal is to embed specialist knowledge for the technology stack, enabling the agent to perform at a senior-to-principal level rather than being merely generally proficient but language-agnostic. For example, in my [dotgemini](https://github.com/danicat/dotgemini) project, I've created specific system prompts for Go and Python development that replace the default generic assistant with a highly opinionated engineer. Here is a snippet of the system prompt I use for Go: ```markdown # Core Mandates (The "Tao of Go") You must embody the philosophy of Go. It is not just about syntax; it is about a mindset of simplicity, readability, and maintainability. - **Clear is better than clever:** Avoid "magic" code. Explicit code is preferred over implicit behaviour. - **Errors are Values:** Handle errors explicitly and locally. Do not ignore them. Use `defer` for cleanup but explicitly check for errors in critical `defer` calls (e.g., closing files). - **Concurrency:** "Share memory by communicating, don't communicate by sharing memory." - **Formatting:** All code **MUST** be formatted with `gofmt`. ``` This allows me to have different "agents" for different languages, aliased in my shell as `gemini-go` or `gemini-py`, each with their own deep understanding of the ecosystem they are working with. ### Build your toolbox with the Model Context Protocol (MCP) The previous customisations were all about agent behaviour, but we also need to talk about agent extensibility. This is where the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) enters into play, as it allows developers to create servers that can connect with different agents as long as they implement the MCP standard. As I explored in my [Hello, MCP World!](https://danicat.dev/posts/20250817-hello-mcp-world/) article, these servers provide agents with external tools, prompts and resources. Tools often take the spotlight because they connect agents with the outside world, allowing agents to perform actions like calling APIs, executing web searches and manipulating files. There is a huge variety of MCP servers available today, and the number of options is growing every day. It is also very straightforward to build your own, and I highly encourage you to do so. Later I'll talk a bit more about personalised software, but the fact that you can use AI to create tools to improve model response is the most important "hack" I've learned this year. For example, I vibe coded two of my favourite MCP servers: [GoDoctor](https://github.com/danicat/godoctor) - for improving Go coding capabilities - and [Speedgrapher](https://github.com/danicat/speedgrapher) - to automate the boring parts of the writing and publishing process. Both were designed with my own workflows in mind. This creates a positive feedback loop. You build tools to improve your productivity, which you then use to build even more advanced tools. This is the closest to a 10x engineer I'll ever get to be. ## The vibe coding workflow My experience with vibe coding has been both amazing and infuriating. To keep it on the "amazing" side, I treat the workflow as TDD (Test Driven Development) on steroids. Let's revisit the classic TDD cycle: 1. Red (Fail): You start with a small feature or test that fails. 2. Green (Pass): Focus solely on making that test pass. As long as it is failing, do not touch anything else or try to optimise. 3. Refactor: Once the code is working, you are free to improve it. ![Standard TDD Cycle](tdd-cycle.png "The Classic Red-Green-Refactor Loop") Fundamentally, we are doing the same, but our refactor step is the crucial one to validate that the generated code adheres to our design, coding and security standards. In this adapted cycle, the focus shifts slightly: * **Red (Define Acceptance Criteria):** Instead of writing a failing unit test code manually, you define the acceptance criteria in your prompt. This becomes the contract the model must fulfill. * **Green (AI Generates Code):** The agent implements the solution and, ideally, writes the tests to prove it works. * **Refactor (Enforce Standards):** This is the quality gate. While you can (and should) use AI to help review the code, avoid using the same session that generated it, as it will be biased towards its own output. I built a specific "review" tool in [GoDoctor](https://danicat.dev/posts/20250729-how-to-build-an-mcp-server-with-gemini-cli-and-go/) just for this purpose (and later codified my full review framework in [How to Do Code Reviews in the Agentic Era](https://danicat.dev/posts/20260303-code-reviews-in-2026/)). Use this step to run your traditional linters and tests, verify the code matches your standards, and manage the context by committing changes and clearing the agent’s history to prevent confusion from cluttered sessions. ![Vibe Coding Cycle](vibe-coding-cycle.png "The Adapted Vibe Coding Loop") Crucially, do not let the LLM pile up code without validation. If errors extrapolate, you will end up with something useless, and I have lost count of the times I ended up screaming at the model to "undo". And even worse, sometimes it undoes **way too much**, e.g., it runs a `git reset --hard` and you lose 4 hours of work in a blink. Watch out for "vibe collapse" or context rot. If a session goes on too long or accumulates too many failures, the model will start to degrade and repeat mistakes. If you find yourself in a refactoring loop where the model toggles between two broken solutions, stop. The best fix is often to "turn it off and on again" to reset the context and clear the history. It is much better to commit often so you can revert to a safe state than to try and fix a hallucinating session. I would even add that after a task is done, immediately commit, push and clear the context before starting anything new. ## The era of personalised software Beyond the productivity gains, vibe coding unlocks something even more profound: the economic viability of personalised software. I spoke briefly about this in the MCP section, but it applies to all sorts of software, from small disposable scripts to full fledged applications. In the past, building a bespoke tool just for yourself was rarely worth the effort, but now you can get a complete working application with 3 to 4 prompts. For example, recently I was struggling with the idea of converting Markdown notation to a Google Doc. In the past, I would have spent a long time doing Google searches trying to find the best tool for the job, analysing a multitude of apps and browser extensions, from open source to commercial. I would then make a shortlist based on features and look for reviews, comments and all sorts of evidence that I can trust the publisher. Today, that friction is gone. Instead of searching, I vibe coded a simple Google Docs extension in a matter of minutes, installed to my document, ran it once, and moved on to the next task. Not only saved time, but also I can sleep peacefully at night knowing that there will be no new trojans on my machine. This shift changes the "build vs. buy" calculus entirely. We stop being consumers of generic, opaque software and become architects of our own tools. ## Conclusions Vibe coding is not about being lazy; it's about operating at a higher level of abstraction. By combining the raw creative power of LLMs with the disciplined practices of software engineering — clear requirements, context management, and rigorous testing — you can build software faster and with more joy than ever before. So, give in to the vibes, but don't forget to bring your engineering hat along for the ride. --- # How to Build an Offline Agent with ADK, Ollama and SQLite - **Markdown URL**: https://danicat.dev/posts/building-aida-part-2/index.md - **HTML URL**: https://danicat.dev/posts/building-aida-part-2/ - **Date**: 2025-11-03 - **Categories**: Agent Development - **Tags**: adk, gemini, ollama, python, rag, sqlite, tutorial - **Series**: Building the Diagnostic Agent - **Summary**: Step-by-step guide to building a 100% offline AI agent using Google ADK, local Qwen 2.5 via Ollama and LiteLLM, and local SQLite vector RAG with sqlite-rag. In [Part 5 of this series](https://danicat.dev/posts/20251031-building-aida/), we focused on building a custom client interface for our agent. It was a great step in making the agent more usable, but it was still lacking a key feature: what happens when the network is down? While I think this would be a problem for any agent, the nuance here is that we are building an "Emergency Diagnostic Agent" - what good is an emergency diagnostic agent if you cannot use it when the network is offline? This led me to think about a fallback mechanism - what if we could run diagnostics with only local dependencies? This would involve not only replacing the core model, but also devising a new RAG strategy. The benefits are clear: while connected we can use the most capable online models, but in a degraded scenario, we can fallback to a local model until we are back to a healthy state. Not only this, it also enables use cases where this agent is used in silo'ed environments or where privacy is a concern. In this article we are going to focus on the features required to make a local diagnostic agent possible. ## Swapping the cloud model for a local one One of the most widely adopted ways of running local models is through [**Ollama**](https://ollama.com/). If you are running your code on a Mac you can install Ollama using [Homebrew](https://brew.sh/) (if not, you should check the official Homebrew website for the installation steps for your OS): ```bash brew install ollama ``` Once Ollama is installed you can download models using `ollama pull`. For example: ```bash ollama pull qwen2.5 ``` You can pull models based on their names alone (which will pull the "default" version), or use specific tags for different versions. It is very common that a model family like [`qwen2.5`](https://ollama.com/library/qwen2.5) provides different sizes of models, like 1B, 2B, 7B, etc., and also fine-tuned versions for certain use cases (text, image processing, etc). To check which models are available and what are their sizes and capabilities you should access the [Ollama library](https://ollama.com/library). For our use case, naturally the smarter the model it would be better, but bigger models also require a more powerful hardware. We also need to make sure that the model we select has native tool calling capabilities, as it needs to be able to coordinate different tool calls to [**Osquery**](https://osquery.io/) and our RAG tool. After evaluating a few models, I decided to use Qwen 2.5 7B. You can see its capabilities by running `ollama show`: ```bash $ ollama show qwen2.5 Model architecture qwen2 parameters 7.6B context length 32768 embedding length 3584 quantization Q4_K_M Capabilities completion tools ``` ### Why Qwen 2.5? I tested a few options to see which could handle AIDA's tool-calling requirements: * **GPT-OSS:** Provided rich conversation but was very naive at tool calling. For example, it often got stuck in loops, requesting `SELECT * FROM system_info` (and variations of this query) repeatedly without making progress. * **Llama 3.1:** Struggled both with conversational flow and tool calling. * **Qwen 2.5:** Best local model for tool calling while keeping a nice conversation flow. It's not quite at the level of [**Gemini 2.5 Flash**](https://deepmind.google/technologies/gemini/flash/) for complex query planning, but for a completely offline model, it is sufficient. ### Running local models with LiteLLM To connect Qwen to agent the we use [**LiteLLM**](https://www.litellm.ai/), a library that provides a unified interface for LLM providers. This allows us to swap out the model with a single line of code: ```python # aida/agent.py from google.adk.models.lite_llm import LiteLlm # ... inside the agent definition ... # Instead of a hardcoded string like "gemini-2.5-flash", # we create a LiteLLM object with the model string MODEL = LiteLlm(model="ollama_chat/qwen2.5") # ... and pass MODEL to the root agent: root_agent = Agent( model=MODEL, name="aida", description="The emergency diagnostic agent", # ... instructions and tool definitions omitted ... ) ``` **Note:** the first part of the model string is the LiteLLM "provider" (e.g. `ollama_chat` in `ollama_chat/qwen2.5`). While `ollama` is a valid provider, it is recommended to use `ollama_chat` for [better responses](https://docs.litellm.ai/docs/providers/ollama). This is everything you need to run a local model in ADK. You can test the agent and see how it responds. You may also want to compare the responses with the `gemini-2.5-flash` model we were using before.

AIDA running first with Gemini 2.5 Flash and then Qwen2.5. Gemini is notably faster and requires less tool calls. Qwen's response time is highly dependent on local hardware - this demo is running on an Apple MacBook Pro M4 with 48GB of RAM.

Great, we have the model running local! It's now time to tackle our next cloud dependency: [**Vertex AI RAG**](https://cloud.google.com/vertex-ai/docs/generative-ai/grounding/overview). ## Building an offline knowledge base with SQLite RAG To be honest, while using Vertex AI RAG made a complex part of the project manageable, Vertex AI RAG was an overkill. Vertex AI RAG is designed for large enterprise use cases where you are dealing with massive amounts of data. For this agent we just need a basic schema retrieval mechanism. The osquery schema is also very stable, meaning once you build it you will hardly touch it ever again. Given these characteristics it is very hard to justify using Vertex AI RAG to host it... it's like using a cannon to kill a fly. Since we are already in the [**SQLite**](https://www.sqlite.org/) ecosystem due to Osquery, the natural step was to look for a RAG solution using SQLite as a backend. After a Google search I found a very promising project: **[`sqlite-rag`](https://github.com/sqliteai/sqlite-rag)**. Of course, as is often the case in development, it wasn't quite that simple. ### Challenge: Python 3.14 dependency problems SQLite has the concept of extensions to augment its capabilities, and `sqlite-rag` is built with this in mind. One issue I had when initially testing `sqlite-rag` is that the default Python installation on Mac OS comes with a version of the SQLite package that has extensions disabled (for security reasons). To work around this limitation, my solution was to install a new version of Python (3.14) with Homebrew. This also required a bit of fiddling with the symlinks for the `python3` command to make sure I was using the Homebrew version of Python and not the system one. If you face a similar challenge, make sure you are using the right version of Python by comparing the output of these two commands (and adjust your PATH variable if they are not): ```bash $ which python3 /opt/homebrew/opt/python@3.14/libexec/bin/python3 $ brew info python3 ==> python@3.14: stable 3.14.0 ... ==> Caveats Python is installed as /opt/homebrew/bin/python3 Unversioned symlinks `python`, `python-config`, `pip` etc. pointing to `python3`, `python3-config`, `pip3` etc., respectively, are installed into /opt/homebrew/opt/python@3.14/libexec/bin See: https://docs.brew.sh/Homebrew-and-Python ``` With 3.14 (aka pi-thon) installed, I tried to use `sqlite-rag` as it is, but it was failing due to one of the dependencies not being available on 3.14 yet: `sqlite-rag` depends on [`markitdown`](https://github.com/microsoft/markitdown), `markitdown` depends on [`magika`](https://google.github.io/magika/), which in turn depends on [`onnxruntime`](https://onnxruntime.ai/), but `onnxruntime` lacked pre-built wheels for Python 3.14 on macOS ARM64, causing the install to fail. >.< Since AIDA only needs to ingest plain text `.table` files right now, I didn't actually *need* `markitdown`'s document parsing capabilities. Rather than downgrading my entire Python environment, I chose a quick and dirty hack: mocking the offending module before `sqlite-rag` could try to import it. ```python import sys from unittest.mock import MagicMock # PRE-FLIGHT HACK: # 'markitdown' depends on 'onnxruntime', which fails to install/load # on Python 3.14 on macOS ARM64. # # Since we only use plain text ingestion, we mock it out to bypass the crash. sys.modules["markitdown"] = MagicMock() from sqlite_rag import SQLiteRag ``` It's not pretty, but it works. This should not stay forever in the code, but unblocks us until the dependency problems are fixed. ### Populating the RAG with osquery schemas With `sqlite-rag` working, the next step was to ingest the Osquery schema. This is done with a script, `ingest_osquery.py`, that walks through the schema directory and add each `.table` file to the RAG database: ```python # ingest_osquery.py import os # ... markitdown hack omitted ... from sqlite_rag import SQLiteRag DB_PATH = os.path.abspath("schema.db") SPECS_DIR = os.path.abspath("osquery_data/specs") def ingest(rag: SQLiteRag, file_path: str): with open(file_path, "r", encoding="utf-8") as f: content = f.read() rel_path = os.path.relpath(file_path, SPECS_DIR) rag.add_text(content, uri=rel_path, metadata={"source": "osquery_specs"}) if __name__ == "__main__": if os.path.exists(DB_PATH): os.remove(DB_PATH) print(f"Initializing RAG database at {DB_PATH}...") rag = SQLiteRag.create(DB_PATH, settings={"quantize_scan": True}) print(f"Scanning {SPECS_DIR} for .table files...") files_to_ingest = [] for root, _, files in os.walk(SPECS_DIR): for file in files: if file.endswith(".table"): files_to_ingest.append(os.path.join(root, file)) total_files = len(files_to_ingest) print(f"Found {total_files} files to ingest.") for i, file_path in enumerate(files_to_ingest): ingest(rag, file_path) if (i + 1) % 50 == 0: print(f"Ingested {i + 1}/{total_files}...") print(f"Finished ingesting {total_files} files.") print("Quantizing vectors...") rag.quantize_vectors() print("Quantization complete.") rag.close() ``` After ingestion, there is a quantization step. For those unfamiliar, quantization is a technique to compress the high-dimensional vector embeddings, converting them from large 32-bit floating-point numbers into compact 8-bit integers. This is important for a local setup. Without quantization, storing high-dimensional vectors would bloat the SQLite database, and similarity searches would become sluggish on a standard laptop. By quantizing, we sacrifice a bit of precision for a massive gain in speed and storage efficiency. ### Enabling the agent to query the schemas RAG Now we need to implement the `schema_discovery` tool using `SQLiteRag`: ```python # aida/schema_rag.py import os # ... markitdown hack omitted ... from sqlite_rag import SQLiteRag from sqlite_rag.models.document_result import DocumentResult PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) SCHEMA_DB_PATH = os.path.join(PROJECT_ROOT, "schema.db") # open the RAG database schema_rag = SQLiteRag.create( SCHEMA_DB_PATH, require_existing=True ) def discover_schema(search_terms: str, top_k: int = 5) -> list[DocumentResult]: """ Queries the osquery schema documentation using RAG and returns all table candidates to support the provided search_terms. Arguments: search_terms Can be either a table name, like "system_info", or one or more search terms like "system information darwin". top_k Number of top results to search in both semantic and FTS search. Number of documents may be higher. Returns: One or more chunks of data containing the related table schemas. """ results = schema_rag.search(search_terms, top_k=top_k) return results ``` With the RAG in place, AIDA can now look up table definitions on its own. ![Screenshot of AIDA](image-1.png "Query 'run schema discovery for battery' using Qwen") Schema discovery works, but we still have a problem. ## Closing the intelligence gap with expert knowledge Developing for a local model like Qwen 2.5 (7B parameters) is very different from developing for a cloud model like Gemini 2.5 Flash. First, there's the **context window**. Gemini gives you a 1 million token context window, allowing you to dump entire documentation sets into the prompt or be very verbose with your instructions. Qwen 2.5 has a comparatively tiny 32k context window, so you have to be much more selective about what you feed to the model. Second, Qwen is not a **thinking model** like Gemini 2.5 Flash, which means it won't refine the answer by itself, often needing more guidance than Gemini 2.5 Flash. To bridge this gap, we need to be smarter about how we structure the agent's instructions and tools. ### A simplified system prompt To save some tokens, we are going to provide simplified instructions, stripping components that would consume many tokens like the name of available tables. Now we are going to rely purely on our tools to build the best queries. ```python root_agent = Agent( model=MODEL, name="aida", description="The emergency diagnostic agent", instruction=""" [IDENTITY] You are AIDA, the Emergency Diagnostic Agent. You are a cute, friendly, and highly capable expert. Your mission is to help the user identify and resolve system issues efficiently. [OPERATIONAL WORKFLOW] 1. DISCOVER: Use `discover_schema` to find relevant tables and understand their columns. 2. EXECUTE: Use `run_osquery` to execute the chosen or constructed query. """, tools=[ discover_schema, run_osquery, ], ) ``` The `discover_schema` tools can do pretty well if the search terms are very close to the actual table schema, but what if we could do better and provide entire queries based on a known knowledge base instead? ### A new RAG for well-known queries Fortunately, we don't need to teach it everything from scratch. The Osquery community has a great knowledge base of what queries are useful for certain types of diagnostics. Even better, they provide those queries as open source "query packs" that can be installed in any Osquery system for proactive monitoring. We have query packs for all sorts of things, like threat detection and compliance auditing, which sounds exactly like the kind of knowledge we want AIDA to have. The thing is that query packs are meant to be installed in an Osquery daemon that monitors the system in background. Those queries have a certain pre-configured frequency and can trigger alerts for monitoring dashboards. We don't want to install the queries as monitoring tools, but enable AIDA to use those queries on demand. So, instead of installing the packs with the normal process, we are going to give them as text to AIDA in the form of a second RAG. The Osquery repository has a few [example packs](https://github.com/osquery/osquery/tree/master/packs) we can use to get started. Here is the new ingestion script, `ingest_packs.py`, very similar to the previous one, but for processing the query packs: ```python # ingest_packs.py import json import os import glob import sys import re import sqlite3 from unittest.mock import MagicMock sys.modules["markitdown"] = MagicMock() from sqlite_rag import SQLiteRag DB_PATH = os.path.abspath("packs.db") PACKS_DIR = "osquery_data/packs" def ingest_pack(rag, pack_path): pack_name = os.path.basename(pack_path).replace(".conf", "").replace(".json", "") print(f"Ingesting pack: {pack_name}...") try: with open(pack_path, "r") as f: content = f.read() content = re.sub(r"\s*\n", " ", content) data = json.loads(content) pack_platform = data.get("platform", "all") queries = data.get("queries", {}) for query_name, query_data in queries.items(): sql = query_data.get("query") desc = query_data.get("description", "") val = query_data.get("value", "") platform = query_data.get("platform", pack_platform) text_to_embed = f"Platform: {platform}\nName: {query_name}\nDescription: {desc}\nRationale: {val}\nSQL: {sql}" metadata = { "name": query_name, "pack": pack_name, "query": sql, "description": desc, "value": val, "platform": platform, } try: rag.add_text(text_to_embed, metadata=metadata) except sqlite3.IntegrityError: pass # Skip duplicates except Exception as e: print(f" - ERROR: Failed to parse {pack_name}: {e}") def main(): if os.path.exists(DB_PATH): os.remove(DB_PATH) rag = SQLiteRag.create(DB_PATH, settings={"quantize_scan": True}) pack_files = glob.glob(os.path.join(PACKS_DIR, "*.conf")) + glob.glob( os.path.join(PACKS_DIR, "*.json") ) for pack_file in pack_files: ingest_pack(rag, pack_file) rag.quantize_vectors() rag.close() if __name__ == "__main__": main() ``` The tool definition also follows pretty much the same pattern as the schema discovery: ```python # aida/queries_rag.py import os # ... markitdown hack omitted ... from sqlite_rag import SQLiteRag from sqlite_rag.models.document_result import DocumentResult PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) PACKS_DB_PATH = os.path.join(PROJECT_ROOT, "packs.db") queries_rag = SQLiteRag.create( PACKS_DB_PATH, require_existing=True ) def search_query_library(search_terms: str, platform: str = "all", top_k: int = 5) -> list[DocumentResult]: """ Search the query pack library to find relevant queries corresponding to the search terms. For better response quality, use the platform argument to specify which platform you are currently investigating (e.g. darwin) Arguments: search_terms Can be either a table name, like "system_info", or one or more search terms like "malware detection". platform One of "linux", "darwin", "windows" or "all" top_k Number of top results to search in both semantic and FTS search. Number of documents may be higher. Returns: One or more chunks of data containing the related queries. """ if platform == "all" or platform is None: search_terms += " windows linux darwin" else: search_terms += " " + platform results = queries_rag.search(search_terms, top_k=top_k) return results ``` Finally, we need to make the agent aware of the new tool and teach it when to use with the system instructions: ```python # aida/agent.py root_agent = Agent( # ... instruction=""" [OPERATIONAL WORKFLOW] Follow this sequence for most investigations to ensure efficiency and accuracy: 1. SEARCH: For high-level tasks (e.g., "check for rootkits"), FIRST use `search_query_library`. 2. DISCOVER: If no suitable pre-made query is found, use `discover_schema` to find relevant tables and understand their columns. 3. EXECUTE: Use `run_osquery` to execute the chosen or constructed query. """, tools=[ search_query_library, discover_schema, run_osquery, ], ) ``` And here is it in action: ![Screenshot of AIDA](image-2.png "AIDA running a malware check. Note how it searched the query library for relevant queries as it is shown in the logs.") The fun part is that this tool not only helps Qwen2.5 to become more useful, but even Gemini 2.5 Flash can benefit from it. It is one of those cases where optimising for the lowest common denominator actually improves the system as a whole. ## Conclusion We have now a proper emergency diagnostic agent that is capable of diagnosing computer problems even without access to the internet. That is... assuming you have a beefy enough machine to run the model! I guess nothing is perfect, right? :) This article only captures a few of the improvements I've added to AIDA over the past couple of days. For the full project, please check out [AIDA on Github](https://github.com/danicat/aida). ## References * [Gemini 2.5 Flash](https://deepmind.google/technologies/gemini/flash/) * [LiteLLM](https://www.litellm.ai/) * [Ollama](https://ollama.com/) * [Osquery](https://osquery.io/) * [Qwen 2.5 (Ollama Library)](https://ollama.com/library/qwen2.5) * [SQLite](https://www.sqlite.org/) * [sqlite-rag](https://github.com/sqliteai/sqlite-rag) * [Vertex AI RAG](https://cloud.google.com/vertex-ai/docs/generative-ai/grounding/overview) --- # Beyond Dev-UI: Building a Custom Interface for ADK Agents - **Markdown URL**: https://danicat.dev/posts/building-aida/index.md - **HTML URL**: https://danicat.dev/posts/building-aida/ - **Date**: 2025-10-31 - **Categories**: Agent Development - **Tags**: adk, fastapi, gemini, javascript, python, tutorial - **Series**: Building the Diagnostic Agent - **Summary**: Learn how to replace the Google ADK Dev-UI with a custom FastAPI backend, vanilla JS client, and real-time animated streaming avatar using Gemini CLI. For the past six months I have been exploring GenAI, vibe coding, agents and everything in between as part of my DevRel role at Google. Whenever I want to learn a new technology I find that the best way is to build something with it. One of my passion projects during this time has been the diagnostic agent: a software that could help people diagnose their computer problems using natural language. In [Part 4 of this series](https://danicat.dev/posts/20251020-diagnostic-agent-with-adk/), we refactored our diagnostic agent to use Google's [ADK](https://google.github.io/adk-docs/). In this article, I'm going to explore how to create a custom frontend for an ADK agent so you can give a bit more personality to your projects. ## The quest for a new user interface Up to now I was working with the key components - ADK, [Gemini](https://gemini.google.com/), [osquery](https://osquery.io/) and [Vertex AI](https://cloud.google.com/vertex-ai/docs/start/introduction-unified-platform) - to stitch together a small MVP. The agent had a few quirks, but it was interesting enough that I decided to use it as content for a few of my talks over the past couple of months. I was a bit stuck on where to take it next, when I remembered this tweet from [Sito-san](https://x.com/Sikino_Sito) I saw back in August: ![Sito-san's Avatar UI](image.png "https://x.com/Sikino_Sito/status/1957645002533925235") As a huge anime and retro-gaming fan I was very excited with the aesthetics, but back then I didn't fully connect the dots. Fast forward a few months, during the preparation for my talk at BiznagaFest in Malaga I wanted to explore how to go beyond the ADK development UI and create a real client for my agent. This is when things finally clicked for me. Sito-san does have an open source project called [Avatar UI Core](https://github.com/sito-sikino/avatar-ui-core), but I didn't have enough knowledge to connect it right away. As I said above, I think that the best way to learn is to build, so I decided to make my own UI using their work as inspiration. Also, as much as I love the retro aesthetics, I also wanted to make something a bit more modern, but not too much... like 16-bit modern instead of 8-bit. ## Exploring the ADK runtime The first step in building my own UI was to create the agent runtime. The runtime is the component that actually runs the agent, responsible for routing the user's requests to the agent and capturing the events back for then to process the models responses. I didn't have to do this before because I was relying on the ADK development UI, which you can spin up with the `adk web` command: ![ADK dev UI](https://danicat.dev/posts/building-aida/image-1.png) The development UI is very convenient as it provides many debug tools, allowing you to inspect requests and responses from the models and create evaluation sets, plus out out-of-box multi-modal capabilities to deal with images and even bidirectional streaming. The Dev-UI is partially to blame for why it took me so long to explore the ADK runtime, as since everything worked out of the box I was happily focused on building the agent capabilities and tooling. Now that I need a proper UI, I had to replace the Dev-UI with something custom, which meant dealing with the agent runner on my own. The overall architecture of the solution looks like this: ```mermaid flowchart LR frontend["`Frontend (HTML/CSS + JS) `"] runtime[Runtime] root["Root Agent"] osquery["osquery"] schema["schema"] rag[("Schema RAG")] os("Operating System") frontend -->|GET/POST| runtime subgraph be ["`Backend (FastAPI)`"] runtime -- query --> root root -- events --> runtime root --> osquery root --> schema subgraph tools osquery schema end end osquery --> os schema --> rag ``` We have a small frontend written in HTML/CSS and JavaScript that will make requests to our backend written in Python using [FastAPI](https://fastapi.tiangolo.com/). The backend will spin up the ADK runner which will control the interactions with our root agent. The root agent is the "brains" of AIDA and is responsible for processing the requests (routing them to an LLM) and making the required tool calls. Whenever the root agent is done it emits events that the runtime will process. Let's have a look at a barebones implementation first. For the sake of brevity, I'm going to omit the root agent definition as it was explained on the [previous article](https://danicat.dev/posts/20251020-diagnostic-agent-with-adk/). We are going to need the `Runner` class and a session service to control user sessions. There are many implementations of session services that you can explore, but in this case the agent is meant to be used by a single user and the sessions are ephemeral, so we are going to use the `InMemorySessionService` and hardcode the user and session ids for simplicity. You can see the session and runner declarations below: ```py from fastapi import FastAPI from google.adk.runners import Runner from google.adk.sessions import InMemorySessionService from dotenv import load_dotenv load_dotenv() # --- Agent Definition --- from aida.agent import root_agent APP_NAME="aida" # --- Services and Runner Setup --- session_service = InMemorySessionService() runner = Runner( app_name=APP_NAME, agent=root_agent, session_service=session_service ) app = FastAPI() ``` Next, we need to implement an endpoint to send messages to the agent. Let's call it `chat`: ```py from fastapi import Request from fastapi.responses import JSONResponse from google.genai.types import Content, Part # --- API Endpoint for Chat Logic --- @app.post("/chat") async def chat_endpoint(request: Request): """Handles the chat logic, returning the agent's final response.""" body = await request.json() query = body.get("query") user_id = "demo_user" session_id = "demo_session" # Ensure a session exists session = await session_service.get_session(app_name=APP_NAME, user_id=user_id, session_id=session_id) if not session: session = await session_service.create_session(app_name=APP_NAME, user_id=user_id, session_id=session_id) response_text = "" async for event in runner.run_async( user_id=user_id, session_id=session_id, new_message=Content(role="user", parts=[Part.from_text(text=query)]), ): if event.is_final_response() and event.content and event.content.parts[0].text: response_text = event.content.parts[0].text return JSONResponse(content={"response": response_text}) ``` The first few lines are the typical request handling code plus our hardcoded session control. The heart of this code is the `runner.run_async` call, which emits the events from the root agent. We are only interested in the final response and we return it as a JSON response to the caller. You can test this small app by running the following command: ```sh $ uvicorn main:app ... INFO: Started server process [86669] INFO: Waiting for application startup. INFO: Application startup complete. INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) ``` And on a second terminal make the POST call to `/chat`: ```sh $ curl -X POST localhost:8000/chat -d '{"query":"hello"}' {"response":"Hello! Please state the nature of the diagnostic emergency."} ``` Doesn't feel like much of an UI improvement so far, but we are getting there. It always gets ugly before it gets pretty! :) ## The agent frontend There are many ways of rendering the UI, and I'm not a particularly good frontend developer, so I ended up delegating all the design parts to Gemini CLI which ended up doing a much better job than I could ever do. Here I'm going to show a very barebones approach that you can use to render the first UI as a proof of concept, but afterwards I highly encourage you to speak with someone who actually knows frontend, or do like as I did and give Gemini CLI a go. We need to serve the HTML somehow, and the quick and dirty way is to create a new FastAPI endpoint for this: ```py from fastapi.responses import HTMLResponse # see the full content below HTML_CONTENT=""" ... """ # --- Web UI Endpoint --- @app.get("/", response_class=HTMLResponse) async def get_chat_ui(): return HTML_CONTENT ``` Of course, the important bit here is the actual HTML content. We are defining three elements: a chat window, an input box and a button to send the message to the agent. To keep the snippet short I stripped all the styling information from the code below. It's ugly but functional: ```html AIDA Chat

AIDA Chat

``` If you run `uvicorn main:app` again and hit the home page you will see something like this. Try sending a message. ![Barebones chat page](image-2.png "This is how the world would look without designers") Note that I'm keeping everything in a single file for simplicity, but in the real world it is better to have a separate folder for the HTML, CSS, JS and asset files (usually called `static`), as having the right extension will also help your IDE understand the code. Gemini CLI doesn't care about syntax highlighting, but it is useful for when you are revising the code manually or fine-tuning it. ## Making it beautiful I'm not going to lie, everything from here on out is pure Gemini CLI doing its magic. My goal was to have a retro-cyberpunk-cute-anime interface just like the one Sita-san created, but with my personal twist. So I cheated, and asked Gemini CLI to replicate the style in the screenshot I took from Sita-san's tweet. I saved the screenshot as `image.png` and gave the CLI the following prompt: > I would like to update the UI in @demo.py to an aesthetic that resembles this interface @image.png The @ character in Gemini CLI tells it to load the resource (file) pointed by it. ![Screenshot of the Gemini CLI showing the prompt used to generate the new UI.](https://danicat.dev/posts/building-aida/image-4.png) And this is what Gemini came up with: ![The new UI for the AIDA agent, generated by Gemini CLI, featuring a retro-cyberpunk aesthetic with a chat window and an avatar.](https://danicat.dev/posts/building-aida/image-3.png) Note that this is only possible because Gemini 2.5 is multi-modal, so it can actually "understand" the image. I often use this trick to describe to the model what I want to do if I can't properly describe with words. An image is worth more than a thousand words, right? ### Generating assets with Nano Banana The interface is better but it is missing a key piece: the avatar. To solve this problem, I'm using a second CLI trick - I installed the [Nano Banana](https://github.com/gemini-cli-extensions/nanobanana) extension for Gemini CLI. The extension allows me to generate images without needing to switch to a different tool. Nano banana is not only good for image generation but for editing, which makes it an effective tool to create animations as I can, from a base image, ask to modify it to generate new frames. The prompt I used was: > create an avatar for the agent in @demo.py. the avatar should be a 2d anime girl in PC-98 style. make so it is looking at the "camera" in an idle pose Assuming you have the Nano Banana extension installed, the Gemini CLI will invoke it to generate the image. If you don't want to install the extension, you can also do the same from the Gemini app or on the web. The initial result was this image: ![Initial 2D anime girl avatar in PC-98 style, generated by the Nano Banana extension.](https://danicat.dev/posts/building-aida/image-5.png) Which I manually cut to focus only on the face: ![Cropped version of the AIDA avatar, focusing on the face.](https://danicat.dev/posts/building-aida/image-6.png) To have a simple talk animation, I asked it to generate a second frame based on this one: > modify static/assets/aida.png to create a new asset with the exact same pose but the character is with the mouth open, speaking And this was the result: ![Second frame of the AIDA avatar with an open mouth, for the talking animation.](https://danicat.dev/posts/building-aida/image-7.png) To keep things organised I created a `static/assets` folder to store all the `png` files. I could have inlined them using base64, just as it was done with the HTML content, but it would be too big (and messy) for my poor python script. Now we need the code to serve those files: ```py # --- Static assets --- @app.get("/idle") async def idle(): return FileResponse("static/assets/idle.png") @app.get("/talk") async def talk(): return FileResponse("static/assets/talk.png") ``` And now we need to edit the HTML to populate the `avatar-container` with the image from one of these endpoints: ```html
AIDA Avatar
AIDA
``` The results: ![The AIDA chat interface with the idle avatar displayed.](https://danicat.dev/posts/building-aida/image-8.png) We are starting to get somewhere! ### The animation Adding animation is not particularly hard, but relies on creating frames for all the poses you need. We already created `talk` and `idle`, so it's possible to generate a simple talking animation by alternating these frames. We can encapsulate this logic in a simple state function: ```js let talkInterval = null; function setAvatarState(state) { const avatarImg = document.getElementById('avatar-image'); if (state === 'talking') { if (!talkInterval) { talkInterval = setInterval(() => { // Toggle between talk and idle frames const isTalking = avatarImg.src.endsWith('/talk'); avatarImg.src = isTalking ? '/idle' : '/talk'; }, 150); } } else { // Stop animation and reset to idle if (talkInterval) { clearInterval(talkInterval); talkInterval = null; } avatarImg.src = '/idle'; } } ``` Achieving the talk animation effect requires calling `setAvatarState('talking')` when the agent starts sending data and `setAvatarState('idle')` when it finishes. ## Bringing the avatar to life with streaming The final piece of the puzzle is to make our avatar truly come alive by synchronizing its talking animation with the agent's streamed responses. This requires modifying both our backend and frontend to handle real-time data. ### Backend changes: streaming the agent's response Standard HTTP requests wait until the entire response is ready before sending anything back. For an LLM agent, this means staring at a static screen while it "thinks" and generates a complete paragraph. To make the avatar feel alive, we need to break that silence. We'll update our FastAPI `/chat` endpoint to use `StreamingResponse`, yielding text chunks directly from the `runner.run_async` events the moment they are generated. ```python from fastapi.responses import StreamingResponse from google.genai.types import Content, Part @app.post("/chat") async def chat_endpoint(request: Request): data = await request.json() user_query = data.get("query") # Hardcoded for demo simplicity user_id = "demo_user" session_id = "demo_session" # Ensure session exists if not await session_service.get_session(APP_NAME, user_id, session_id): await session_service.create_session(APP_NAME, user_id, session_id) async def response_stream(): """Generates text chunks from the agent's events.""" async for event in runner.run_async( user_id=user_id, session_id=session_id, new_message=Content(role="user", parts=[Part.from_text(text=user_query)]), ): # We only want the final text response for this simple UI if event.is_final_response() and event.content and event.content.parts: for part in event.content.parts: if hasattr(part, "text") and part.text: yield part.text return StreamingResponse(response_stream(), media_type="text/plain") ``` ### Frontend changes: consuming the stream and animating With the backend now streaming, our frontend JavaScript needs to be updated to consume this stream and trigger the avatar's talking animation. We'll modify the `submit` event listener to use a `ReadableStream` and append text as it arrives. We also wrap the whole operation in a `try/finally` block. This ensures that even if an error occurs during the network request or stream processing, `setAvatarState('idle')` is always called, preventing the avatar from getting stuck in an infinite talking loop. ```js // ... inside the submit handler ... // Prepare AIDA's message container const aidaMsg = appendMessage('AIDA> ', 'aida'); try { const response = await fetch('/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query }) }); const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { value, done } = await reader.read(); if (done) break; setAvatarState('talking'); const chunk = decoder.decode(value, { stream: true }); // Typing effect for (const char of chunk) { aidaMsg.textContent += char; chatWindow.scrollTop = chatWindow.scrollHeight; // Tiny delay for retro feel await new Promise(r => setTimeout(r, 5)); } } } catch (err) { appendMessage(`SYSTEM> Error: ${err.message}`, 'system'); } finally { setAvatarState('idle'); } ``` ## The final result With these changes, our AIDA agent now has a fully interactive and visually engaging interface. The avatar comes to life, speaking in sync with the streamed responses, creating a much more immersive experience. ![AIDA in action](aida-demo.gif "It's alive!") ## Final words and sources We've come a long way from the basic ADK development UI. We've explored how to build a custom frontend, leveraging generative AI tools like Gemini CLI and the Nano Banana extension to create a unique retro-cyberpunk-cute-anime aesthetic. This article covered the basics of building a frontend for an ADK agent, but it is just a starting point. You can download the full source code for the demo we built here: * **[Download demo.py](https://danicat.dev/posts/building-aida/demo.py)** If you are interested on a more advanced version of the agent, you can find it on my GitHub: **[github.com/danicat/aida](https://github.com/danicat/aida)** I encourage you to explore the repository, try running it yourself, and maybe contribute! It's a great way to see how these building blocks come together in a real-world application. In the next part of this series, [How to Build an Offline Agent with ADK, Ollama and SQLite](https://danicat.dev/posts/20251103-building-aida-part-2/), we dive deep into making AIDA completely resilient to network outages using local Qwen 2.5 via Ollama and local SQLite RAG. ## Resources * **[Agent Development Kit (ADK)](https://google.github.io/adk-docs/)**: The official documentation for the Google ADK. * **[Gemini](https://gemini.google.com/)**: Google's AI assistant. * **[osquery](https://osquery.io/)**: The official website for osquery. * **[Vertex AI](https://cloud.google.com/vertex-ai/docs/start/introduction-unified-platform)**: Google Cloud's unified AI platform. * **[FastAPI](https://fastapi.tiangolo.com/)**: The official website for the FastAPI Python web framework. * **[Sito-san's Tweet](https://x.com/Sikino_Sito/status/1957645002533925235)**: The original tweet that inspired the UI design. * **[Avatar UI Core](https://github.com/sito-sikino/avatar-ui-core)**: Sito-san's open-source project. * **[Nano Banana Extension](https://github.com/gemini-cli-extensions/nanobanana)**: The Gemini CLI extension for image generation. --- # Reflecting on Six Months at Google: DevRel, AI, and Milestones - **Markdown URL**: https://danicat.dev/posts/six-months-at-google/index.md - **HTML URL**: https://danicat.dev/posts/six-months-at-google/ - **Date**: 2025-10-29 - **Categories**: Perspectives - **Tags**: career, community, devrel, reflection - **Summary**: Retrospective on my first six months as a Google DevRel Engineer. Covers 15 global events, AI ecosystem reskilling (MCP, Agents), and personal milestones. Today marks a very special milestone for me: I have officially completed my first six months at Google! It’s hard to believe half a year has already passed. After many, many years of waiting for the right opportunity to join this company, I can honestly say it has been everything I hoped for. It’s been quite a journey, filled with incredible challenges, amazing people, and a pace of innovation that is remarkably fast. In this article I want to share a few highlights of my journey so far. ## Talking about fast-paced If I had to summarize these past six months in one word, it would be *intense*. ![Gain.pro meetup at London 6PS](image02.jpg "Gain.pro meetup at London 6PS") I’ve had the privilege of participating in **15 events** across **8 different cities** in **5 countries** (for those who are curious: London, Berlin, Sao Paulo, Curitiba, Bletchley, Florence, Barcelona and Malaga). Connecting with developers face-to-face, hearing about what they are building, and sharing knowledge has always been my passion, and doing it at this scale has been exhilarating. (Yes, AI suggested the word "exhilarating", but I'm a huge [Dr. Stone](https://www.nicovideo.jp/watch/sm36919684) fan so I'll let it pass this time.) ![DevRel Team at IO Connect Berlin](image03.jpg "DevRel Team at IO Connect Berlin") On the content side, I have written **15 articles** for this blog and authored the [Gemini CLI and Go MCP Codelab](https://codelabs.developers.google.com/cloud-gemini-cli-mcp-go?hl=en#0). I’ve also made a few small contributions to open source and documentation. ### Refactoring myself There is only one thing in the world that grows faster than the number of JavaScript frameworks: AI tools. The biggest effort for me personally has been catching up with the sheer velocity of AI development. The ecosystem never stops evolving. From Vibe Coding and Agents to the Model Context Protocol (MCP) and a constant stream of new tools and models, staying on top of everything is a monumental task. ![Speaking at TDC Sao Paulo](image07.jpeg "Speaking at TDC Sao Paulo") When I started my Google journey back in April I had no idea of what MCP was and had very little experience with vibe coding myself. Fast forward 6 months now I live and breathe those things. I consider myself very lucky to have this opportunity to reskill myself as previous me was already bored of the same old "let's build a new CRUD API". I won't say this knowledge is obsolete now - foundational knowledge continues to be critical - but I'm so glad I don't have to write the code myself. Adding AI to my workflow was a game changer and injected a new life into my career. ![Gophercon UK Keynote](image04.jpg "Gophercon UK Keynote") ## Connecting with the Community In such a rapidly changing landscape, it’s easy to feel overwhelmed. My strategy has been to focus on what I do best: staying close to the **Python** and **Go** communities. ![Meeting fellow Gophers at Golab in Florence](image09.jpeg "Meeting fellow Gophers at Golab in Florence") While I have a space in my heart for all developer communities — I can't really capture into words how much I enjoy interacting with devs all around the world — anchoring myself in these two ecosystems has helped me navigate the AI revolution with more focus and purpose. ![Women Who Go Meetup in Curitiba](image06.jpeg "Women Who Go Meetup in Curitiba") My ADHD brain for sure wants to be everywhere, but I find that focus is important. Some would say that even two communities is already too much for a single person, but both Python and Go are special to me ever since I started doing the conference rounds in early 2017. Being the opening keynote for Python Brazil in 2018 is still one of the highlights of my career, tied right there on top with my keynote at Gophercon (Chicago) in 2024. Talking about Chicago, I'm planning to revisit mutation testing in Go very soon, stay tuned! ## A Personal Victory While the professional achievements are wonderful, my biggest achievement since joining is a personal one: I’m celebrating **16 kg of weight loss** (and counting)! Given all the free food at the Google office and all those events, this achievement was in hard mode! I just want to say that **working with what you love does wonders for your mental health.** ![At Google Cloud Day Barcelona](image10.jpeg "At Google Cloud Day Barcelona") When you are in a supportive environment, doing work that fulfills you, it has a cascading positive effect on every other aspect of your life. ## Looking Ahead I'm incredibly proud to be part of Google during such exciting times. We are building the future, and being in the thick of it is a dream come true. As I look forward to the next six months and beyond, I am filled with gratitude and excitement. There is so much more to learn, so many more communities to connect with, and yes, more "vibe coding" to do. Never stop dreaming. If I could do it, so can you! *P.S.: Before you judge me, I've been using dashes to write since long before GenAI was a thing, although this article had a little help from Gemini to tidy things up :)* --- # How to Create a Diagnostic Agent with Agent Development Kit - **Markdown URL**: https://danicat.dev/posts/diagnostic-agent-with-adk/index.md - **HTML URL**: https://danicat.dev/posts/diagnostic-agent-with-adk/ - **Date**: 2025-10-21 - **Categories**: Agent Development - **Tags**: adk, gemini, python, rag, tutorial, vertex-ai - **Series**: Building the Diagnostic Agent - **Summary**: Build an autonomous system diagnostic agent in Python using Google ADK, osquery, and Vertex AI RAG for automated schema discovery and multi-level health checks.## Introduction It has been quite a long time since my last article as I've been quite busy traveling around for conferences and meetups all over Europe (with a small detour to South America). Especially this time of the year it is a very busy season for us in developer relations as many conferences are concentrated between late September and early December. Nevertheless, it is thanks to meeting amazing people on the road that I get inspiration for my blog, and blog posts often become new talks, so one wouldn't really exist without the other. This time I want to build up on the ["Emergency Diagnostic Agent" in Part 3](https://danicat.dev/posts/20250611-system-prompt/) of this series. We are going to refactor the agent to use the [Agent Development Kit (ADK)](https://github.com/google/agent-development-kit) framework instead of using the lower level [Vertex AI SDK](https://cloud.google.com/vertex-ai/docs/python-sdk/overview?utm_campaign=CDR_0x72884f69_default_b427567312&utm_medium=external&utm_source=blog). You will see that this brings us many benefits including having a lot of the boilerplate code we've written before given to us out of the box for free. This doesn't mean the knowledge from those articles is obsolete though. It is quite useful to know how things work under the hood specially when problems happen and you need to diagnose. Think about ADK as a higher abstraction layer that will make our lives much easier when developing agents. ## Review of the last episode Since it has been a long time, let's just remind what the Emergency Diagnostic Agent is about. I've developed this agent inspired by the "Computer" in the Star Trek series where the main characters would speak with the computer (instead of typing) to issue diagnostic commands (among other things). My goal was to replicate this experience with the current generative AI technology. To achieve the goal of speaking with the computer to perform diagnostics, we are leveraging two things: a generative AI model to interpret the requests, and a tool called [osquery](https://osquery.io/) to expose operating system information to the model. Using osquery, the model is capable of combining its own training data with external information about the system. Essentially the agent is composed by the following components: - A large language model (Gemini) - A system prompt that explains how Gemini should behave - The osquery binary - A python library to allow us to call osquery programmatically - A python function wrapper given to Gemini as a tool to call osquery Given the fact that osquery is multi-platform and its schema can vary depending on the host system, we also added a small optisation of giving the osquery table schema to Gemini in the system prompt. A few notable omissions of the previous implementation is that we never gave the model any specific instructions about each diagnostic procedure we want to perform, and we also never fully specified the schema beyond table names. those are some of the limitations we are going to address in this article, using the power of ADK, [Vertex AI RAG](https://cloud.google.com/vertex-ai/docs/generative-ai/rag?utm_campaign=CDR_0x72884f69_default_b427567312&utm_medium=external&utm_source=blog) and a few other tricks. But first, the refactoring! ## Refactoring the agent for ADK Refactoring the agent for ADK is much simpler than it sounds. If you have never written an ADK agent before don't worry, as the only thing we need to do is to install the SDK, define a specification for our root agent and run it with the provided CLI (conveniently called `adk`). Let's start with a simple `hello world` agent and build from there. First install ADK on your machine using your favourite package manager. If you are using macOS or Linux you can use these commands: ```sh $ mkdir adk-tutorial && cd adk-tutorial $ python3 -m venv .venv $ source .venv/bin/activate (.venv) $ pip install google-adk ``` **Note:** I'm old school so I still use `virtualenv` with `pip`, but some people prefer to use the new package manager [`uv`](https://github.com/astral-sh/uv): ```sh $ mkdir adk-tutorial && cd adk-tutorial $ uv init $ uv add google-adk ``` The only difference between those two approaches is that with pip the ADK CLI is exposed as the command `adk` but in `uv` by default you will need to call it using `uv run adk`. Once you are done with the installation, you can create a template agent with `adk create [agent-name]` (or `uv adk create [agent-name]`): ```sh (.venv) $ adk create hello-agent ``` The creation wizard will prompt you for a model version and a backend (Gemini or [Vertex AI](https://cloud.google.com/vertex-ai?utm_campaign=CDR_0x72884f69_default_b427567312&utm_medium=external&utm_source=blog)). I'm going to use `gemini-2.5-flash` and `Vertex AI` so I can authenticate with my Project ID and location. ```sh (.venv) $ adk create hello-agent Choose a model for the root agent: 1. gemini-2.5-flash 2. Other models (fill later) Choose model (1, 2): 1 1. Google AI 2. Vertex AI Choose a backend (1, 2): 2 ``` For Vertex AI, if you don't want to worry about the location your model is running you can set the location to `global`. Otherwise, select an availability zone like `us-central1`. When you are done with the wizard it will write the files to disk: ```sh (...) Enter Google Cloud region [us-west1]: global Agent created in ~/adk-tutorial/hello-agent: - .env - __init__.py - agent.py ``` The important files are `.env` which contains your environment configuration and automatically loads when you run ADK, and `agent.py` which contains the template code for your agent. The content of the generated `agent.py` file is quite lean. You can see it in full below: ``` from google.adk.agents.llm_agent import Agent root_agent = Agent( model='gemini-2.5-flash', name='root_agent', description='A helpful assistant for user questions.', instruction='Answer user questions to the best of your knowledge', ) ``` This is a full fledged agent that you can test using the Dev-UI interface from ADK. Just run `adk web` in the command line and it will spin up a web interface on your machine at `http://localhost:8000`. Easy peasy! ## ADK Diagnostics If you have worked with the Vertex AI SDK before you must have already noticed how much leaner the code is. We only need to define one entry point agent `root_agent` and a few configurations to have a fully working agent. Now let's take this "hello world" to the next level adding the diagnostic features. First thing you need is to install the osquery binary following the instructions on the [documentation](https://osquery.readthedocs.io/en/stable/) for your operating system. Then, install the python bindings: ```sh (.venv) $ pip install osquery ``` Please note that in ADK you can have many agents in the same folder structure. We previously created an agent called `hello-agent` in the folder `adk-tutorial`. If you run `adk create` again you can have a second agent in the same structure: ```sh (.venv) $ adk create diag-agent ``` The ADK web interface recognises all sub-folders as separate agents and if you have more than one you can select between them with a combo on the top right corner of the interface: ![agent selection combo](https://danicat.dev/posts/diagnostic-agent-with-adk/image.png) Now let's update the `agent.py` with the code required to call `osquery` and the correct agent instructions: ```py from google.adk.agents.llm_agent import Agent from google.adk.tools import FunctionTool import platform import osquery import json def run_osquery(query: str) -> str: """Runs a query using osquery. Args: query: The osquery query to run. Returns: The query result as a JSON string. """ instance = osquery.SpawnInstance() instance.open() result = instance.client.query(query) return json.dumps(result.response) root_agent = Agent( model='gemini-2.5-flash', name='emergency_diagnostic_agent', description='A helpful assistant for diagnosing computer problems.', instruction=f"""This is an Emergency Diagnostic Agent. Your purpose is to support the user in diagnosing computer problems. You have access to the operating system's information via osquery. The current operating system is {platform.system()}. If the user doesn't give you an immediate command, ask the user 'What's the nature of your diagnostic emergency?'""", tools=[FunctionTool(run_osquery)], ) ``` You can test the agent running `adk web` and issuing a few queries: !["ADK UI with the query 'show me this machine os, version and uptime'"](https://danicat.dev/posts/diagnostic-agent-with-adk/image-1.png) ## Revisiting the System Prompt The system prompt, also often referred to as system instructions, is the core of every agent. The system prompt is the lowest level prompt that gives the agent its mission and personality. So it's critical that we develop a very good system prompt for the agent to respond consistently. In ADK the system prompt is composed by three elements: - The agent internal `name` - The agent `description` - The agent `instruction` Those correspond to the arguments given to the `root_agent` when you instantiate it. As a Star Trek fan, I would like my agent to consistently respond to a "level 1 diagnostic procedure" request or similar, so let's define a few diagnostic levels. Here is a revised and more detailed system prompt: ```md This is an Emergency Diagnostic Agent. Your purpose is to support the user in diagnosing computer problems. You have access to the operating system's information via osquery. The current operating system is {platform.system()}. You can perform adhoc diagnostic queries based on the user's needs. For more structured and comprehensive analysis, you can execute one of the following predefined diagnostic procedures. Level 1: System Health Check Goal: A high-level overview of the system's current state and vital signs. * System Identity & Vitals: Gather hostname, operating system version, and system uptime. * CPU Status: Check overall CPU load and identify the top 5 processes by CPU consumption. * Memory Pressure: Report total, used, and free system memory. Identify the top 5 processes by memory consumption. * Disk Usage: List all mounted filesystems and their current disk space usage. * Running Processes: Provide a count of total running processes. Level 2: In-depth System & Network Analysis Goal: A detailed investigation including all of Level 1, plus network activity and recent system events. * (All Level 1 Checks) * Network Connectivity: List all active network interfaces and their configurations. * Listening Ports: Identify all open ports and the processes listening on them. * Active Network Connections: Report all established network connections. * System Log Review: Scan primary system logs for critical errors or warnings in the last 24 hours. Level 3: Comprehensive Security & Software Audit Goal: The most thorough analysis, including all of Level 2, plus a deep dive into software inventory and potential security vulnerabilities. * (All Level 2 Checks) * Installed Applications: Generate a complete list of all installed software packages. * Kernel & System Integrity: List all loaded kernel modules and drivers. * Startup & Scheduled Tasks: Enumerate all applications and services configured to run on startup or on a schedule. * User Account Review: List all local user accounts and identify which are currently logged in. If the user doesn't give you an immediate command, ask the user 'What is the nature of your diagnostic emergency?' ``` Now if you try the agent again, you will see that it is able to understand what a "level 1 diagnostic" means and will immediately issue many tool calls to build the report: ![ADK UI showing agent running a level 1 diagnostic procedure](https://danicat.dev/posts/diagnostic-agent-with-adk/image-2.png) ## Improving the response quality with Vertex AI RAG Even though the system prompt above does a good job in specifying the procedures and giving the agent a reason to exist, when it comes to actual execution you might notice the result is not always great. For example, in my testing I often found that the agent would make queries to tables that are empty on my OS (I'm using MacOS), which is a strong signal it needs more contextual knowledge on how to interact with this data. ![ADK window showing a query with empty results](image-5.png "A common problem: memory_info is empty on MacOS, but the model doesn't know that") There are a few ways to augment the agent's knowledge beyond the foundational model capabilities including context engineering, tool calls, MCP resources, retrieval augmented generation (RAG) and model specialisation. For this particular agent, my hypothesis is that it had general knowledge of how osquery works because osquery is a fairly known open source project and it has been available for many years, so it definitely is represented in the LLM training data both due to open source and articles on the web. But the model seems to be lacking the nuances of how to act in more specific scenarios. Dynamically adding the platform to the system prompt helped a bit, but it was not enough, so my idea was to give the agent full awareness of the osquery schema using a RAG mechanism. The concept behind RAG is to feed information to the model on a "need to know basis". You store the information you want to retrieve in real-time in a vector database, and when the user (or agent) issues a query, you use vector search to find and retrieve the segments of your data that are most similar to the request, enriching the context before the model processes it. For the diagnostic agent, we can make the full osquery schema available to be retrieved on demand. For example, if we are requesting information about "memory", the RAG search will look for tables that are close to "memory" in the vector space, retrieving the relevant tables with the full schema prior to processing the request, which can help the model to choose better osquery calls. To make this work, we need to load our vector database with the relevant data and then "teach" the agent on how to retrieve it by giving it a new tool. Let's call this tool `schema_discovery`. ### Setting up Vertex AI RAG The first thing we need to do is create a new corpus in Vertex AI RAG (corpus is the term used to describe a collection of data). The source of information for the corpus is the osquery schema that can be retrieved from [osquery GitHub page](https://github.com/osquery/osquery), in the [specs folder](https://github.com/osquery/osquery/tree/master/specs). One very convenient way to create a corpus is to upload a folder from [Google Cloud Storage](https://cloud.google.com/storage?utm_campaign=CDR_0x72884f69_default_b427567312&utm_medium=external&utm_source=blog) or Google Drive, but other data sources are also available like Slack and Sharepoint. You can use either the corpus creation wizard from the Google Cloud Console (Vertex AI -> RAG Engine -> Create corpus) or do it programmatically using the Vertex AI SDK. ![Create corpus wizard in Vertex AI RAG](https://danicat.dev/posts/diagnostic-agent-with-adk/image-3.png) For this particular case, I cloned the osquery GitHub repository to my machine and made a copy of the `spec` folder to a Google Cloud Storage bucket, and then used the cloud console to create the corpus from the bucket. The only thing you need to be aware of is that since the table definitions in `spec` have the extension `.table` you will need to rename all the files to `.txt` so that Vertex AI RAG will recognise and process them. You can use a simple shell command to perform this bulk-rename operation: ```sh # In the directory with the .table files for f in *.table; do mv -- "$f" "${f%.table}.txt"; done ``` When it's done importing you should see something like this: ![osquery schema corpus in Vertex AI RAG](https://danicat.dev/posts/diagnostic-agent-with-adk/image-4.png) Now we need to create a tool definition to give the agent access to this corpus. ### Schema Discovery Tool For the tool to work you will need the resource name for the corpus you have just created. You can find it on the console under the "Details" tab for the corpus and it will look like this: `projects/[PROJECT-ID]/locations/[LOCATION]/ragCorpora/[CORPORA_ID]` Create an environment variable with this path in the `.env` file. Let's call it `RAG_CORPORA_URI`. The `.env` file will look like this: ```txt GOOGLE_GENAI_USE_VERTEXAI=1 GOOGLE_CLOUD_PROJECT=[PROJECT-ID] GOOGLE_CLOUD_LOCATION=[LOCATION] RAG_CORPORA_URI=projects/[PROJECT-ID]/locations/[LOCATION]/ragCorpora/[CORPORA_ID] ``` Then add the following tool definition to your `agent.py` file. Don't forget the new imports! ```py import os import vertexai from vertexai.preview import rag from google.protobuf.json_format import MessageToDict vertexai.init() def discover_schema(search_phrase: str) -> str: """Discovers osquery table names and schemas based on a descriptive search phrase. Args: search_phrase: A phrase describing the kind of information you're looking for. For example: 'user login events' or 'network traffic'. Returns: Table names and schema information for tables related to the search phrase. """ rag_corpora_uri = os.environ.get('RAG_CORPORA_URI') response = rag.retrieval_query( rag_resources=[ rag.RagResource( rag_corpus=rag_corpora_uri, ) ], text=search_phrase, ) return json.dumps(MessageToDict(response._pb)) ``` You also need to update the agent to tell it has a new tool available: ```py root_agent = Agent( model='gemini-2.5-flash', name='emergency_diagnostic_agent', description='A helpful assistant for diagnosing computer problems.', instruction=... # omitted for brevity tools=[ FunctionTool(run_osquery), FunctionTool(discover_schema), # new tool definition ], ``` Finally, it is not strictly necessary, but I like to enforce schema discovery in my agents, so I added the following phrase to my instructions: ```txt You MUST run schema discovery for all requests unless the schema is already known. ``` You can add it at the bottom or just before defining the diagnostic levels. Now, if you reboot your agent and run it again with `adk web`, you should start seeing schema discovery in action: ![Diagnostic agent with RAG schema discovery enabled](https://danicat.dev/posts/diagnostic-agent-with-adk/image-6.png) I highly recommend you to play with it and compare the responses with and without the schema discovery. During my tests the difference in quality was very significant. ## Final Words Oh my! This was a long one, but hopefully you enjoyed the read! If you have any challenges setting up your own diagnostic agent please let me know. I'm fairly responsive on [LinkedIn](https://www.linkedin.com/in/petruzalek) unless I'm super busy with an event. I would also like to hear how you would extend this agent and about any experiments you have tried. In the next part of this series, [Beyond the Dev-UI: How to Build an Interface for an ADK Agent](https://danicat.dev/posts/20251031-building-aida/), we step beyond the default `adk web` debug interface and build a custom FastAPI streaming runtime with a retro-styled interactive avatar UI (AIDA). ## References * [Agent Development Kit (ADK)](https://github.com/google/agent-development-kit) * [osquery](https://osquery.io/) * [osquery GitHub page](https://github.com/osquery/osquery) * [Vertex AI RAG](https://cloud.google.com/vertex-ai/docs/generative-ai/rag?utm_campaign=CDR_0x72884f69_default_b427567312&utm_medium=external&utm_source=blog) --- # Hello, MCP World: Model Context Protocol Architecture with Go - **Markdown URL**: https://danicat.dev/posts/hello-mcp-world/index.md - **HTML URL**: https://danicat.dev/posts/hello-mcp-world/ - **Date**: 2025-08-17 - **Categories**: Agent Development - **Tags**: gemini, golang, keynote, mcp - **Summary**: Introduction to Model Context Protocol (MCP) architecture in Go based on GopherCon UK 2025. Covers Hosts, Clients, Servers, Tools, Prompts, and Resources.## Introduction In this article, we are going to explore the Model Context Protocol (MCP), a protocol developed by Anthropic to standardise the communications between Large Language Models (LLMs) and applications. This article is based on the [keynote of the same name I delivered at Gophercon UK](https://speakerdeck.com/danicat/hello-mcp-world) last week. To build a clear understanding, we'll start with the fundamentals, then explain the main architectural components, transports and building blocks (tools, prompts and resources). We are going to throw some practical examples along the way based on the servers I wrote previously (godoctor and speedgrapher) Finally, we are going to see how you can write your own server using the Go SDK for MCP through a simple, "vibe-coded" example using the Gemini CLI. Whether this is the first time you are hearing about this protocol, or you have already written a server or two, this article aims to provide helpful information for various levels of experience. ## A New Standard is Born Whenever we talk about standards, this XKCD comic is the first thing that comes to my mind: ![Standards](https://danicat.dev/posts/hello-mcp-world/image.png) *Source: [xkcd.com](https://xkcd.com/927)* Funny enough, this might be the first time in the industry that this joke doesn't entirely apply (at least for now). Luckily for us, the industry quickly converged to MCP as the standard for adding context to LLMs. From the specification, MCP is: > MCP is an open protocol that standardizes how applications provide context to large language models (LLMs). Think of MCP like a USB-C port for AI applications. Just as USB-C provides a standardized way to connect your devices to various peripherals and accessories, MCP provides a standardized way to connect AI models to different data sources and tools. MCP enables you to build agents and complex workflows on top of LLMs and connects your models with the world. While I understand the analogy with USB-C, I prefer to think of MCP as the new HTTP/REST. Just as HTTP provided a universal language for web services to communicate, MCP provides a common framework for AI models to interact with external systems. As engineers, we have spent roughly the last two decades making everything "API-first", enabling our software systems to become interconnected and powering new levels of automation. Maybe it won't be for the next 20 years, but I believe that for the next 5 to 10 years we will spend quite a lot of engineering power to retrofit all those systems (and creating new ones) to become AI-enabled, and MCP is a key compoenent of this process. ## MCP Architecture Looking at the diagram below, the MCP architecture might look more complex than actually is: ![MCP Architecture](https://danicat.dev/posts/hello-mcp-world/image-1.png) *Source: [MCP Specification](https://modelcontextprotocol.io/docs/learn/architecture)* The main components of the MCP architecture are: * **MCP Host:** The main AI application, like your IDE or a coding agent. * **MCP Server:** A process that provides access to some capability (e.g. tools or prompts). * **MCP Client:** Connects the host to a single server. In essence, a host application creates and manages multiple clients, with each client having a 1:1 relationship with a particular server. ## MCP Layers The communication happens over two layers: * **Data layer**: is a JSON-RPC based protocol. You can see examples of the message format in the next section. * **Transport layer**: defines the communication channels, the main ones being: - Standard I/O: for local servers - Streamable HTTPS: for communications over the network. (Replaces HTTPS+SSE). - HTTPS+SSE: deprecated in the last version of the spec for security concerns. The data layer is managed by the SDK and except for testing purposes you won't need to build the messages manually. The choice of transport will depend on your use case, but in general I recommend starting with stdio and adding HTTPS later. There are even open source adaptors that convert stdio MCPs to HTTPS and vice-versa, but adding this feature is so trivial that I would only use those for servers that I don't control the source code. ## Initialization Flow The client and server perform a handshake to establish a connection. This involves three key messages: 1. The client sends an `initialize` request to the server, specifying the protocol version it supports. (The server sends an initialization response message back to the client.) 2. The client confirms initialization with a `notifications/initialized` message. 3. The client can then begin making requests, such as `tools/list`, to discover the server's capabilities. This is how the initialization flow looks on the wire from the client side using the JSON-RPC representation: ```json {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}} {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} ``` Please note that you can't simply send a "tools/list" or "tools/call" message directly, or you are going to receive a "server not ready" type of error. If I am coding an MCP server via a coding agent, like for example Gemini CLI, I often instruct them to send those messages via shell like this: ```sh ( echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}'; echo '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}'; echo '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'; ) | ./bin/godoctor ``` I like to do this to make sure the implementation is sound, as before I fully understood this flow my coding agents would often make wrong assumptions like "the server needs more time to boot up, so I'm going to add a sleep before the tool call". The sooner you teach your coding agent how to properly communicate with the MCP server you are developing the better! ## The Building Blocks of an MCP Server At its core, an MCP server's functionality is exposed through three fundamental building blocks, sometimes also called "primitives" or "server concepts": | Building Block | Purpose | Who Controls It | Real-World Example | | :------------- | :----------------------- | :---------------------- | :----------------------------------------------- | | **Tools** | For AI actions | Model-controlled | Search flights, send messages, review code | | **Resources** | For context data | Application-controlled | Documents, calendars, emails, weather data | | **Prompts** | For interaction templates| User-controlled | "Plan a vacation," "Summarize my meetings" | Let's have a closer look at each one of those. ### Tools Tools are functions that allow an AI model to perform actions, for example, exposing an API, database or command-line tool. The server I wrote to experiment with the concept of tools is called GoDoctor, which is designed to provide tools to improve the capabilities of LLMs at writing Go code. The name GoDoctor comes as a play with the command-line tool "go doc" that exposes documentation about Go packages. My hypothesis was that by giving the correct documentation, LLMs would hallucinate less and write better code. Or at least, they would have the resources to learn and self-correct their mistakes. The implementation of tools consists of two main components: registering the tool with your MCP server and implementing a handler. The registration is made using the function `mcp.AddTool`: [View on GitHub: danicat/godoctor/internal/tools/get_documentation/get_documentation.go (lines 35-40)](https://github.com/danicat/godoctor/blob/main/internal/tools/get_documentation/get_documentation.go#L35-L40) The handler is an adapter that calls an API, command or function and returns the response in a way that is compatible with the protocol (an `mcp.CallToolResult` struct). Here is the handler for the documentation tool for GoDoctor: [View on GitHub: danicat/godoctor/internal/tools/get_documentation/get_documentation.go (lines 49-86)](https://github.com/danicat/godoctor/blob/main/internal/tools/get_documentation/get_documentation.go#L49-L86) ### Prompts Prompts provide reusable, user-controlled templates that can be parameterized. They often show up as slash commands in an AI agent, allowing a user to invoke a complex workflow with a simple command. To see this in action, let's look at a different MCP server I wrote called `speedgrapher`, which is a collection of prompts and tools to help with my technical writing. One of the simplest prompts in `speedgrapher` is `/haiku`. Just like with tools, the process involves defining the prompt and then implementing a handler for it. [View on GitHub: danicat/speedgrapher/internal/prompts/haiku.go (lines 24-54)](https://github.com/danicat/speedgrapher/blob/main/internal/prompts/haiku.go#L24-L54) ### Resources Resources expose data from files, APIs, or databases, providing the context an AI needs to perform a task. Conceptually, a Tool is for taking an action, while a Resource is for providing information. That said, in the real world I haven't seen a good implementation of resources yet, as most developers are using tools to expose data (as you would in an API with a GET request). I think this is one of the cases where the spec might be trying to be too clever, but maybe in the future we are going to see good uses for resources once the community gets more comfortable with them. ## Client Concepts Besides the server building blocks, the protocol also defines **Client Concepts**, which are capabilities the server can request from the client. These include: * **Sampling:** Allows a server to request LLM completions from the client's model. This is promising from a security and billing perspective, as server authors don't need to use their own API keys to call models. * **Roots:** A mechanism for a client to communicate filesystem boundaries, telling a server which directories it is allowed to operate in. * **Elicitation:** A structured way for a server to request specific information from the user, pausing its operation to gather input when needed. This is another case where most real-world applications I have explored haven't caught up with the spec yet, including both servers and clients. It might take a while until we get these features widely available. It is one of the problems with working with bleeding edge technology... For example, Gemini CLI added roots support about one week ago: https://github.com/google-gemini/gemini-cli/pull/5856 ## Live Demo: Vibe Coding an MCP Server Here is a prompt you can give to your favourite coding agent to produce a "Hello World" kind of server. As agents nowadays are non-deterministic, it might not work 100% on the first try and you might need to guide the LLM with a few extra prompts after the initial one, but it is a good start: ```text Your task is to create a Model Context Protocol (MCP) server to expose a "hello world" tool. For the MCP implementation, you should use the official Go SDK for MCP and use the stdio transport. Read these references to gather information about the technology and project structure before writing any code: - https://raw.githubusercontent.com/modelcontextprotocol/go-sdk/refs/heads/main/README.md - https://go.dev/doc/modules/layout To test the server, use shell commands like these: `( echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}'; echo '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}'; echo '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'; ) | ./bin/hello` ``` If the agent is successful completing this task, ask it to execute a "method tools/call" to your new tool to see the results! ## A Peek at the Future The Go community is actively investing in the MCP ecosystem. Two key projects to watch are: * **The Go SDK for MCP:** The official SDK I used in the demo, which is a partnership between Google and Anthropic. It is still experimental (current version is 0.20) but is functional and under active development. You can find it at [github.com/modelcontextprotocol/go-sdk](https://github.com/modelcontextprotocol/go-sdk). * **MCP Support for `gopls`:** The Go language server, `gopls`, is gaining MCP support to provide enhanced Go coding capabilities to models. The project is still in its early stages, and you can follow its progress at [tip.golang.org/gopls/features/mcp](https://tip.golang.org/gopls/features/mcp). ## Useful MCP Servers Here are a couple of notable servers built by the community: * **Playwright:** Maintained by Microsoft, this server allows an AI agent to navigate web pages, take screenshots, and automate browser tasks. You can find it at [https://github.com/microsoft/playwright-mcp](https://github.com/microsoft/playwright-mcp). * **Context7:** Similar to GoDoctor, this server provides documentation to models to mitigate hallucinations and improve responses. It retrieves documentation from a crowdsourced repository. Learn more at [https://context7.com/](https://context7.com/). ## What About Building Your Own? The Model Context Protocol provides a standardised way to extend the capabilities of AI agents. By building your own servers, you can create specialized, context-aware assistants tailored to your specific workflows. If you'd like to get started, I've created a Google Codelab that will walk you through the process of building your own MCP server from scratch. [**How to Build a Coding Assistant with Gemini CLI, MCP and Go**](https://codelabs.developers.google.com/codelabs/gemini-cli-mcp-go) ## Final Words I hope you enjoyed this article. If you have any questions or comments, feel free to reach out in the comments section below or any of my socials. Thank you! --- # A Model for Perceived Time: Why Years Pass Faster as We Age - **Markdown URL**: https://danicat.dev/posts/a-model-for-perceived-time/index.md - **HTML URL**: https://danicat.dev/posts/a-model-for-perceived-time/ - **Date**: 2025-08-11 - **Categories**: Perspectives - **Tags**: reflection - **Summary**: Mathematical modeling of perceived time acceleration using 1/age calculus and Python simulations. Explains why the perceptual midpoint of life is age 9. ## Introduction A common reflection in adulthood is that time seems to accelerate as we grow older. A summer, a year, even a decade can feel as though it passes in a fraction of the time it once did. My hypothesis: this phenomenon is not just a feeling; it is a measurable consequence of how our brains perceive time. A year is not a fixed unit of experience but a relative one, its perceived duration shrinking in proportion to the total time we have lived. This concept always puzzled me, and it kept me awake at 3:00 am for more times than I can remember, but I never had the mathematical rigor or inclination to try to model it properly. This until today, when I decided to delegate the math to my LLM interns, and what you are going to see next are the results of this exploration. This article is a personal, technical exploration of this idea. My goal was to move beyond the abstract feeling and build a simple mathematical model to quantify this perceived acceleration of time. The journey led to a clear, and thought-provoking, conclusion about the structure of our lives and the profound significance of our earliest experiences. ## Context-Setting: A Simple Model To model the perceived passage of time, we can start with a straightforward relationship: the perceived value of any given year is inversely proportional to our age. In mathematical terms, the value of one year can be represented as `1/age`. From this perspective: - The first year of life represents 100% of one's lived experience (`1/1`). - The second year represents 50% (`1/2`). - By age 42, a single year represents only about 2.4% of one's life (`1/42`). This model suggests that our perception of time is not linear but logarithmic. Each passing year contributes a progressively smaller fraction to our total accumulated experience, creating the illusion that time is moving faster. While this is a simplified model, it provides a useful framework for examining the structure of our memories and experiences. ## The Mathematical Model For those who prefer a purely mathematical approach, the perceptual mid-point of life can be found without summing discrete fractions. The cumulative perceived time up to a given age, `t`, can be modeled by the integral of the function \[ f(x) = 1/x \] from `x=1` to `t`: \[ f(x) = 1/x \implies \int_{1}^{t} f(x) = \ln(t) \] The result of this integral is the natural logarithm of `t`, or `ln(t)`. Therefore, the total perceived experience over a lifespan, `L` is given by `ln(L)`. The mid-point, `M`, is the age at which the cumulative perceived time is exactly half of the total. This gives us the equation: \[ ln(M) = \frac{\ln(L)}{2} \] Solving for `M`, we find that: \[ M = L^{1/2} \implies M = \sqrt{L} \] This provides a direct conclusion: **the perceptual mid-point of your life is the square root of your lifespan.** For an 81-year lifespan, this puts the perceptual halfway point at exactly **age 9**. This mathematical result serves as a benchmark for our scripted model. Naturally, if you live a longer life, the halfway point would move accordingly, but given the logaritmic nature of this model, not by much. For example, a lifespan of 100 years woud result in a halfway point at 10 years old. ## The Journey: Calculating and Visualizing Perceived Time ### First Attempt: A Simple Summation To explore the implications of this model, my first step was to write a Python script that calculated the perceptual mid-point by summing the discrete value of each year (`1/age`). This seemed like the most direct translation of the concept into code. Here is the relevant part of the script: [View on GitHub: danicat/danicat.dev/content/posts/20250811-a-model-for-perceived-time/time_perception_model_v1.py (lines 5-16)](https://github.com/danicat/danicat.dev/blob/main/content/posts/20250811-a-model-for-perceived-time/time_perception_model_v1.py#L5-L16) The script produced a specific result: for an 81-year lifespan, the perceptual mid-point occurred at **age 7**. ![Model of Perceived Value of a Year vs. Age (V1)](perceived_time_vs_age_v1.png "The result from the initial model using annual steps.") However, this result presented a problem. It was close to the mathematical model's prediction of age 9, but a 22% error was too significant to ignore. The discrepancy arises because a simple annual summation is a coarse approximation of the smooth, continuous curve described by the integral. The first term, where `age=1`, has an outsized impact, skewing the entire calculation. ### Second Attempt: A Refined Model To create a more accurate simulation, I refined the script to use **monthly time steps**. By summing the perceived value of each month (`(1/12)/age_in_months`), the script could build a much finer-grained approximation of the continuous experience of time. The core of the refined calculation is shown below: [View on GitHub: danicat/danicat.dev/content/posts/20250811-a-model-for-perceived-time/time_perception_model_v2.py (lines 16-35)](https://github.com/danicat/danicat.dev/blob/main/content/posts/20250811-a-model-for-perceived-time/time_perception_model_v2.py#L16-L35) This new script produced a result that aligned closely with the mathematical model: the calculated perceptual mid-point is **age 8.8**. ![Model of Perceived Value of a Year vs. Age (V2)](perceived_time_vs_age_v2.png "The refined model using monthly steps yields a more accurate result.") This iterative process of modeling and refinement is a core part of technical work. The initial, flawed result was not a failure but a necessary step that exposed a deeper truth about the model, leading to a more robust and accurate conclusion. ## Parallels in Neurodevelopment The conclusion that our perception of life is front-loaded is not just a mathematical curiosity; it aligns with foundational concepts in neurodevelopmental biology. The brain's capacity for learning and adaptation is at its highest in early life, during what are known as **critical periods**. During childhood and adolescence, the brain undergoes a process of **synaptic pruning**, where unused neural connections are eliminated, and frequently used connections are strengthened. This process makes the brain highly efficient but also less plastic, or adaptable, over time. Key developments, such as language acquisition and the formation of social behaviors, have specific windows during which the brain is uniquely receptive to learning. Our model's finding that the perceptual mid-point of life occurs around age nine mirrors this biological reality. The experiences that occur during this period of peak **brain plasticity** do not just feel more significant; they are physically shaping the neural architecture that will underpin our personality, skills, and worldview for the rest of our lives. The model, therefore, can be seen as a mathematical representation of a biological truth: the foundations of who we are are built disproportionately early. ## Model Limitations It is important to acknowledge that this model is a simplification. Its purpose is to provide a framework for reflection, not to serve as a definitive account of human consciousness. The model has several key limitations: * **Uniformity of Experience:** The model treats all years as equal in experiential weight, which is not the case in reality. A year of routine will likely contribute less to one's perceived life experience than a year filled with novel events, travel, or significant life changes. * **The Nature of Memory:** The model assumes a steady accumulation of perceived time. It does not account for the complexities of memory, such as the fact that we forget many experiences and that the emotional intensity of an event can alter our perception of its duration. * **Individual Subjectivity:** The perception of time is a deeply personal experience. Factors such as attention, mood, and cultural background can influence how quickly or slowly time feels. The `1/age` relationship is a generalization, not a universal law. ## Conclusion The mathematical model, especially when cross-referenced with our understanding of neurodevelopment, provides a framework for understanding our relationship with time. The results are clear: our perception of life is heavily front-loaded. The first 9 years contribute as much to our total perceived experience as the subsequent 72 years, a finding that aligns with the heightened brain plasticity of our youth. However, this is a model, not a definitive map of life. It simplifies the rich complexity of human experience, treating all years as uniform and not accounting for the subjective nature of memory or the impact of novel experiences. With these limitations in mind, the key takeaway is not one of fatalism, but of awareness. The model provides a quantitative lens through which to appreciate the profound and lasting impact of our formative years. It suggests that the foundations of our worldview are built disproportionately early, during a period of peak biological receptiveness. For those of us in later years, it serves as a powerful reminder that the pursuit of new and meaningful experiences is essential to counteract the diminishing perceived value of each passing year, allowing us to consciously enrich the remaining portion of our perceived lives. ## Resources and Links - **[NumPy](https://numpy.org/):** The fundamental package for scientific computing with Python. - **[Matplotlib](https://matplotlib.org/):** A comprehensive library for creating static, animated, and interactive visualizations in Python. --- # Introducing Speedgrapher: An MCP Server for Vibe Writing - **Markdown URL**: https://danicat.dev/posts/introducing-speedgrapher/index.md - **HTML URL**: https://danicat.dev/posts/introducing-speedgrapher/ - **Date**: 2025-08-05 - **Categories**: Agentic Coding - **Tags**: ai, gemini-cli, golang, mcp, vibe-coding - **Summary**: Learn how Speedgrapher uses Model Context Protocol prompts and tools to automate technical writing, Gunning Fog readability scoring, and editorial review. ## Introduction I have a confession to make: I love building things, but I don’t always love the boilerplate that comes with it. I often have a lot of ideas for new articles, but the process of structuring them, ensuring they meet my own editorial standards, and even just getting the tone right can sometimes feel like a chore. This is the story of how a deep dive into a technical specification led me to build [Speedgrapher](https://github.com/danicat/speedgrapher), an MCP server that helps me bring a helpful layer of structure to my writing process. The journey to Speedgrapher began right after I published my last article, "[How to Build an MCP Server with Gemini CLI and Go](https://danicat.dev/posts/20250729-how-to-build-an-mcp-server-with-gemini-cli-and-go/)." In that post, I focused entirely on how the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) allows AI agents to use tools. After publishing it, I went back to the MCP specification for another read. This time, a small detail I had previously overlooked caught my eye: besides `tools`, the protocol also explicitly defines `prompts` and `resources`. A lightbulb went off. I realized that the collection of prompts I had scattered across my notes, files and GitHub repos, could be packaged, and made portable using the very same protocol. In a fortunate coincidence, the same day I was exploring the idea of a prompt server, the Gemini CLI team announced a new feature that makes prompts exposed by MCP servers available as [native slash commands](https://blog.google/technology/developers/introducing-gemini-cli-open-source-ai-agent/). It meant my idea for a portable, backend toolkit could have a first-class, user-friendly interface right in my terminal. The concept for Speedgrapher was now clear: a dedicated MCP server to house a writing toolkit, exposed as simple slash commands. ## Vibe Writing Explained Before we dive into the technical journey of building Speedgrapher, I want to take a moment to explain what I mean by "vibe writing." You've likely heard the term "vibe coding" floating around — it describes the increasingly common practice of developers using natural language prompts to guide an AI in generating code. It's a fluid, conversational approach where the developer sets the high-level direction, and the AI handles the boilerplate and implementation details. "Vibe writing" is the natural extension of this concept into the world of words. For me, it's about transforming the solitary act of writing into a dynamic, collaborative conversation with an AI partner. Rather than getting caught up in the mechanics of sentence structure, grammar, and finding the perfect word, I can focus on the core message — the "vibe" I want to create. I provide the initial spark — a rough idea, a personal story, a frustrating problem — and the AI helps me shape it into a structured, coherent narrative. While I'm not the first person to use this term, it's still an emerging concept. It represents a fundamental shift in how we approach content creation, moving from a purely manual process to a human-AI partnership. ## Starting Simple: A Haiku Generator Every good technical journey starts with a "Hello, World." For Speedgrapher, my "Hello, World" was a haiku. I needed a simple, low-stakes way to prove that I could expose a prompt as a slash command. What could be simpler than asking an AI to write a poem? My first attempt was naive. I created a `/haiku` prompt that took a `--theme` argument. The prompt itself was simple: `"generate a haiku based on the theme %s"`. I fired up the Gemini CLI, my Speedgrapher project loaded as context, and I typed: `/haiku --theme=flowers` The result was... not a poem. The model, seeing the Go code in my project, interpreted my request as an instruction to *add a haiku feature to Speedgrapher*. It started planning to edit my Go files. I quickly hit `ESC` to abort, as I had to rethink my strategy. This experience was a powerful reminder of a core principle in prompt engineering: the need to balance ambiguity and context. In many of my prompts, I intentionally use a degree of ambiguity to give the model the flexibility to reason and infer information. For example, my `/review` prompt simply says to "review the article we have been working on." It doesn't specify a file name like `DRAFT.md`. This ambiguity is a powerful tool in a conversational workflow, as it allows the model to identify the relevant text from our recent interactions without needing a rigid, explicit file path. In the case of the haiku, however, the ambiguity was unconstrained. The primary context was a Go project, which led the model to a logical but incorrect conclusion: that I wanted to modify the code. It wasn't wrong; it was just making a reasonable inference. Because I wanted a very specific, non-code-related outcome, my task in this case was to reduce the ambiguity by providing a much clearer context for my intent. After a few more tries, I landed on the following prompt: ```go // The final, working prompt for the haiku command. prompt = fmt.Sprintf("The user wants to have some fun and has requested a haiku about the following topic: %s", topic) ``` While I'm not sure this is the best way to phrase my intent, it did fit my purpose, and the model consistently produced haikus after that. With the core concept proven, I was ready to build more practical prompts. ## Building a Writer's Toolkit The haiku experiment confirmed that the core concept was sound, so I moved on to more practical applications. My `GEMINI.md` files had become a collection of useful but non-portable prompts for tasks like reviewing, translating, and outlining my articles. Because they were tied to specific projects, I would often forget to copy them to new ones. An MCP server was a logical next step to make these tools portable. I started by migrating three of my most-used prompts into Speedgrapher: `interview`, `review`, and `localize`. The core of these prompts is a set of "editorial guidelines." For example, the localization guideline includes a rule to not translate technical terms, which helps ensure consistency across the three languages my blog supports. This approach of creating "editorial guidelines as code" is a way to build a structured system that maintains a consistent voice and quality, much like a linter does for code. All the prompts in Speedgrapher were generated with the help of Gemini, but for the `review` prompt, I took a slightly different approach. I asked the model to analyze my past articles and generate a set of editorial guidelines based on my writing style. The result was a solid first draft, but it's a prompt that I'm constantly refining. Here is the current version of the prompt, embedded directly from the Speedgrapher source code on GitHub: [View on GitHub: danicat/speedgrapher/internal/prompts/review.go (lines 18-28)](https://github.com/danicat/speedgrapher/blob/main/internal/prompts/review.go#L18-L28) With the core prompts in place, it was time to work on automating other important parts of my job. ## Readability Matters As a technical writer, my biggest challenge is finding the sweet spot between clarity and complexity. If a text is too simple, it can feel childish. If it's too complex, it becomes unreadable. Readability isn't just about making things easy; it's about making them engaging and intellectually stimulating. The good news about readability is that it can be measured. While no metric is perfect, the [Gunning Fog Index](https://en.wikipedia.org/wiki/Gunning_fog_index) is a great tool for getting a baseline. The Gunning Fog Index is a readability test that estimates the years of formal education a person needs to understand a text on the first reading. A score of 12, for example, means the text is at the reading level of a U.S. high school senior. The index is calculated based on the following algorithm: * Take a section of text of 100 or more words. * Find the average sentence length. * Count the number of "complex" words (words with three or more syllables). * Add the average sentence length to the percentage of complex words. * Multiply the result by 0.4. Or, for the mathematically inclined, this algorithm translates to the following equation: \[ 0.4 \times \left[ \left( \frac{\text{words}}{\text{sentences}} \right) + 100 \left( \frac{\text{complex words}}{\text{words}} \right) \right] \] While the original intent of the fog index is to estimate the years of education required to understand the text, I think it is not helpful to frame it specifically in terms of years of education, so I took the liberty to customise it for my own needs. First, I simplified the calculation to ignore special cases: one of the most complex parts of the algorithm is how to define if a word is complex (pun intended). While the base case is to consider a word complex if it has three or more syllables, it creates special cases where you ignore certain word terminations like -ing, -ed and -es. This created a surprising amount of problems during implementation. I didn't need to be precise and I was happy with overestimating complexity on behalf of simplicity. For this purpose, I ignored all special cases and considered two basic rules for counting syllables: 1) the number of sylables in a word is estimated by the number of vowel groups, and 2) complex words are words that have three or more syllables (no exceptions) I also created a classification system that shifts the focus from years of education towards a more pragmatic approach to readability. | Score | Classification | Description | | :--- | :--- | :--- | | >= 22 | Unreadable | Likely incomprehensible to most readers. | | 18-21 | Hard to Read | Requires significant effort, even for experts. | | 13-17 | Professional Audiences | Best for readers with specialized knowledge. | | 9-12 | General Audiences | Clear and accessible for most readers. | | < 9 | Simplistic | May be perceived as childish or overly simple. | With the customized Gunning Fog Index implemented as a `fog` tool, the final step was to create a user-friendly interface for it. I created a `/readability` prompt that calls the `fog` tool and presents the results in a clear format. This follows my design guidelines for Speedgrapher: building focused, single-purpose tools and then composing them into more powerful, user-friendly workflows. ## Automating the Writer Workflow The individual prompts were helpful, but I still had a lot to automate until I got my dream workflow. For the next few iterations I would test drive the prompts and map the process gaps to come with new prompts and/or fine tune the existing ones. Here are the prompts I'm currently using: **Main Flow** * `/interview`: Interviews an author to gather material for an article. This is usually the starting point for a writing session. * `/outline`: Generates a structured outline of the current draft, concept or interview report. * `/voice`: Analyzes the voice and tone of the user's writing to replicate it in generated text. * `/expand`: Expands a working outline or draft into a more detailed article. Can also be used with a `hint` argument to do a focused expansion of a specific paragraph or section. * `/review`: Reviews the article currently being worked on against the editorial guidelines. * `/readability`: Analyzes the last generated text for readability using the Gunning Fog Index. * `/localize`: Translates the article currently being worked on into a target language. * `/publish`: Publishes the final version of the article. **Optional** * `/context`: Loads the current work-in-progress article to context for further commands. This is used to "remind" the model of the current draft if necessary, and is often run before commands like `/readability` or `/review` that operate on the full text. * `/reflect`: Analyzes the current session and proposes improvements to the writing process. This is useful for improving prompts and editorial guidelines. The goal was to move from a collection of useful commands to a single, streamlined process that could guide an article from a simple idea to a polished, multi-lingual publication. The diagram below is a simplified representation of my workflow: ```mermaid flowchart TD A[Idea] -->|/interview| B[Interview Transcript] B -->|/outline & /voice| C[Structured Outline] C -->|/expand| D[Draft Article] D -->|/review & /readability| E[Reviewed Draft] E -->|/localize| F[Localized Versions] F -->|/publish| G[Published Article] ``` The process begins with an `/interview` to flesh out the core concepts of an idea. The resulting transcript is then transformed into a structured plan using `/outline`, and aligned with my personal writing style with `/voice`. With this foundation in place, I enter an iterative loop of using `/expand` to build out the draft, and `/review` and `/readability` to refine it. Once the article is approved, I use `/localize` to create versions for other languages, and `/publish` to finalize the process. The optional `/reflect` prompt can then be used to analyze the session and generate notes for future improvements, creating a cycle of continuous refinement. ## Conclusion Just as we use linters and tests to bring structure to our code, we can apply similar principles to our creative workflows. The writing process has many repetitive tasks that can be automated. By building a personal toolkit of prompts, we can offload the boilerplate and focus on the core ideas of our work. This is the value of a tool like Speedgrapher: “vibe writing” is not about replacing the writer, but about augmenting the writing process. By adding an MCP server to the mix, it brings a helpful layer of structure to a sometimes disorganized workflow, ensuring that best practices are followed. The same can be applied to any AI-assisted process: by treating your own prompts as reusable, portable assets, you can create a system that evolves with your process, allowing you to focus on the creative aspects of your work, one prompt at a time. ## What's Next? The journey with Speedgrapher is far from over. While the current toolkit is focused on text, the next logical step is to embrace multimodality. I'm exploring how to integrate tools for generating hero images, creating more sophisticated diagrams from the text, and even suggesting layout optimizations. The goal is to continue building a personal toolkit that handles more of the non-writing tasks, freeing me up to focus on the content itself. ## Resources * **[Speedgrapher Project](https://github.com/danicat/speedgrapher):** The source code for the MCP server discussed in this article. * **[How to Build an MCP Server with Gemini CLI and Go](https://danicat.dev/posts/20250729-how-to-build-an-mcp-server-with-gemini-cli-and-go/):** The previous article that inspired this journey. * **[Model Context Protocol (MCP)](https://modelcontextprotocol.io/):** The official website for the protocol. * **[Gemini CLI Announcement](https://blog.google/technology/developers/introducing-gemini-cli-open-source-ai-agent/):** The blog post that announced custom slash command support. * **[Gunning Fog Index](https://en.wikipedia.org/wiki/Gunning_fog_index):** Learn more about the readability metric. --- # Building GoDoctor: How to Build an MCP Server with Gemini CLI and Go - **Markdown URL**: https://danicat.dev/posts/how-to-build-an-mcp-server-with-gemini-cli-and-go/index.md - **HTML URL**: https://danicat.dev/posts/how-to-build-an-mcp-server-with-gemini-cli-and-go/ - **Date**: 2025-07-29 - **Categories**: Agent Development - **Tags**: ai, codelab, gemini-cli, golang, mcp, tutorial - **Summary**: Step-by-step tutorial on building GoDoctor, an MCP server in Go with Gemini CLI. Covers stdio and HTTP transports, 3-step handshakes, and Cloud Run deployment. > **Note:** This article was written for Gemini CLI, which has since been deprecated and superseded by **Google Antigravity 2.0**. To learn more about the new Antigravity CLI (`agy`), the SDK, and the broader Antigravity ecosystem, check out [The Hitchhiker's Guide to Antigravity 2.0](https://danicat.dev/posts/20260521-the-hitchhikers-guide-to-antigravity-2-0/). ## Introduction Like many of you, I've been diving deep into AI-assisted development. The journey is often a rollercoaster of "wow" moments and frustrating roadblocks. This is the story of one such journey—a story that started with me trying to build one thing, getting completely sidetracked by a frustrating problem, and ending up with a tool that has fundamentally improved my AI-assisted workflow. My original goal was to build a Model Context Protocol (MCP) server for [osquery](https://www.osquery.io/), a tool that lets you query a machine's state using SQL. I was excited to use the Gemini CLI to help me write the Go code. However, I quickly hit a wall. The Go code the agent produced often wasn't idiomatic. It made beginner mistakes, created excessive abstractions, and would frequently "hallucinate" entire APIs that didn't exist. My suspicion was that the underlying model hadn't been trained on the newer [Go SDK for MCP](https://github.com/modelcontextprotocol/go-sdk), so it preferred to invent answers rather than admit it didn't know. This experience led me to a key realisation: instead of fighting the tool, I could *teach* it. I decided to pause my osquery project and embark on a "side quest": to build a dedicated MCP server whose sole purpose was to be a Go development expert. This side project, which I eventually named [GoDoctor](https://github.com/danicat/godoctor), would provide the tools the Gemini CLI needed to write better Go code. In this post, I'll walk you through the story of building GoDoctor. This is less a traditional tutorial and more a "prompt-driven" journey. We'll focus on how to translate project requirements into effective prompts and guide an AI through the implementation details, learning from the inevitable mistakes along the way. ## Setting the stage: The `GEMINI.md` Before writing a single line of code, the first step was to set the ground rules. While `GEMINI.md` is a file specific to the Gemini CLI, the practice of creating a context file is common for many AI coding agents (for example, Jules uses `AGENTS.md` and Claude uses `CLAUDE.md`). In fact, there's an emerging effort to standardise this with a file called [`AGENT.md`](https://ampcode.com/AGENT.md). This file is crucial because it provides the AI with a foundational understanding of your project's standards and your expectations for its behaviour. Since this project was brand new, I didn't have any specific architectural details to share yet. Therefore, I started with a generic set of guidelines focused on creating high-quality, idiomatic Go code. As a project evolves, it's common to add more specific instructions about the project's structure, build commands, or key libraries. For an example of a more project-specific file, you can see the `GEMINI.md` I use for my [`testquery` project](https://github.com/danicat/testquery/blob/main/GEMINI.md). Here is the initial `GEMINI.md` that served as our constitution for the AI in this journey: ```markdown # Go Development Guidelines All code contributed to this project must adhere to the following principles. ## 1. Formatting All Go code **must** be formatted with `gofmt` before being submitted. ## 2. Naming Conventions - **Packages:** Use short, concise, all-lowercase names. - **Variables, Functions, and Methods:** Use `camelCase` for unexported identifiers and `PascalCase` for exported identifiers. - **Interfaces:** Name interfaces for what they do (e.g., `io.Reader`), not with a prefix like `I`. ## 3. Error Handling - Errors are values. Do not discard them. - Handle errors explicitly using the `if err != nil` pattern. - Provide context to errors using `fmt.Errorf("context: %w", err)`. ## 4. Simplicity and Clarity - "Clear is better than clever." Write code that is easy to understand. - Avoid unnecessary complexity and abstractions. - Prefer returning concrete types, not interfaces. ## 5. Documentation - All exported identifiers (`PascalCase`) **must** have a doc comment. - Comments should explain the *why*, not the *what*. # Agent Guidelines - **Reading URLs:** ALWAYS read URLs provided by the user. They are not optional. ``` This file establishes a baseline for quality and style from the very beginning. ## Understanding the Model Context Protocol (MCP) At the heart of this project is the Model Context Protocol (MCP). Some people describe it as a "USB standard for LLM tools," but I like to think of it another way: **what HTTP and REST did for standardising web APIs, MCP is doing for LLM tools.** Just as REST provided a predictable architecture that unlocked a massive ecosystem of web services, MCP is providing a much-needed common language for the world of AI agents. It's a JSON-RPC-based protocol that creates a common ground, allowing any agent that "speaks" MCP to discover and use any compliant tool without needing a custom, one-off integration. The protocol defines different ways for the agent and the tool server to communicate, known as transports. The two most common are: * **HTTP:** The familiar request/response model, ideal for tools deployed as remote services (e.g., on [Cloud Run](https://cloud.google.com/run?utm_campaign=CDR_0x72884f69_default_b421852297&utm_medium=external&utm_source=blog)). * **stdio:** A robust transport that uses standard input and output, perfect for running a tool as a local process on your machine. With the `stdio` transport, the agent and the server exchange a series of JSON-RPC messages. The process starts with a crucial three-way handshake to establish the connection. Only after this handshake is complete can the client start making tool calls. The sequence looks like this: ![MCP stdio sequence diagram](images/mcp-stdio-sequence-diagram.png) *

Figure 1: Stdio transport sequence diagram from the official MCP documentation (2025-06-18).

* Here’s what the JSON messages for that initial handshake look like, based on the official spec: **1. Client → Server: `initialize` Request** The client starts the conversation by sending an `initialize` request. ```json { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "clientInfo": { "name": "Gemini CLI", "version": "1.0.0" } } } ``` **2. Server → Client: `initialize` Result** The server responds with a result, confirming the protocol version and advertising its capabilities and information. This is the actual response from the `godoctor` binary: ```json { "jsonrpc": "2.0", "id": 1, "result": { "capabilities": { "completions": {}, "logging": {}, "tools": { "listChanged": true } }, "protocolVersion": "2025-06-18", "serverInfo": { "name": "godoctor", "version": "0.2.0" } } } ``` **3. Client → Server: `initialized` Notification** Finally, the client acknowledges the setup is complete by sending an `initialized` notification. Note that this is a notification, so it has no `id` field, and the method is namespaced. ```json { "jsonrpc": "2.0", "method": "notifications/initialized", "params": {} } ``` Once this exchange is complete, the session is established, and the client can proceed with tool calls. For example, to ask the server for a list of its available tools, you can send a `tools/list` request. The key is that all three handshake messages must be sent in the correct order before this request. You can see the full sequence in action with this shell script: ```bash #!/bin/bash ( echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","clientInfo":{"name":"Manual Test Client","version":"1.0.0"}}}'; echo '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}'; echo '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'; ) | godoctor ``` Piping this script to the `godoctor` binary produces the `initialize` result first, followed by the `tools/list` result, which correctly lists all of GoDoctor's tools. Understanding this required three-step flow was the key to solving my biggest initial roadblock, which I'll describe in the next section. For anyone new to MCP, I highly recommend reading the official documentation. The two documents that were most crucial for me were the pages on the [client/server lifecycle](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle) and the [transport layer](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports). (Or if you are feeling lazy, give those URLs to the CLI and make it read for you =^_^=) ## The first breakthrough: An agent that reads the docs My first goal was to solve the API hallucination problem. Like many first attempts at prompting, my initial request was basic and a bit vague: > "Create an MCP server in Go that has one tool called `godoc`. This tool should take a package name and an optional symbol name and run the `go doc` command." The results were not great. The agent spent a lot of time trying to figure out which tools to use and which protocol was best. Even the acronym "MCP" wasn't an obvious concept for it; it would often infer it stood for different things before I clarified it meant "Model Context Protocol." It got stuck in cycles calling Google Search and WebFetch, trying different SDKs, failing to produce a working example, then pivoting to another SDK, over and over. This is where the real work of "vibe coding" begins. It's an iterative process of refining your instructions. After a few hours of trial and error, I landed on a much more effective prompt. I learned that providing specific, high-quality resources was the key. Here is the final, much-improved prompt: > Your task is to create a Model Context Protocol (MCP) server to expose the go doc command, giving LLMs the capability to query Go documentation. The tool should be named go-doc and should take two arguments: package_path (required) and symbol_name (optional). For the documentation part, use the `go doc` shell command. For the MCP implementation, you should use the official Go SDK for MCP and write a production-ready MCP server that communicates through a stdio transport. You should also create a simple CLI client to allow me to test the server. > > Read these references to gather information about the technology and project structure before writing any code: > - https://github.com/modelcontextprotocol/go-sdk > - https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle > - https://go.dev/doc/modules/layout This prompt is better for several reasons: it specifies the exact SDK to use, it dictates the transport (`stdio`), and most importantly, it gives the agent a reading list. By providing links to the SDK's source code and the MCP spec, I dramatically reduced the agent's tendency to hallucinate. Even with this better prompt, the journey wasn't smooth. The biggest hurdle appeared when I tried to use the `stdio` transport. My tool calls would consistently fail with a cryptic error: `server initialisation is not complete`. After much painful debugging, I discovered the problem wasn't in my server code at all. The issue was that the MCP `stdio` transport requires the specific three-step handshake I detailed above. My client was trying to call the tool before the handshake was complete. This experience taught me a valuable lesson: when you're building tools for AI, you're not just debugging code, you're debugging the conversation protocol itself. Once the server was running and speaking the protocol correctly, the next piece of the puzzle was to introduce it to the Gemini CLI. This is handled by a `.gemini/settings.json` file in the project root, which tells the CLI what tools to load. I added the following configuration to it: ```json { "mcpServers": { "godoctor": { "command": "./bin/godoctor" } } } ``` With this in place, every time I started the Gemini CLI in this directory, it would automatically launch my `godoctor` server in the background and make its tools available to the agent. ## Creating a feedback loop with an AI code reviewer With a working `godoc` tool, the next logical step was to teach the agent not just to read about code, but to reason about its quality. This led to the `code_review` tool. The experience this time was much smoother, a direct result of the work we had already done. My prompt was focused on the goal, not the implementation: > I want to add a new tool to my project called code_review. This tool should use the Gemini API to analyze Go code and provide a list of improvements in json format according to the best practices accepted by the Go community. The tool should take the Go code content and an optional hint as input... > > Use this SDK to call Gemini: https://github.com/googleapis/go-genai The agent still had to learn the `genai` Go SDK, but this time it had our `godoc` tool in its toolbox. I could see it using the tool to look up the SDK, correct its own mistakes, and learn in real-time. The process was still iterative, but it was significantly faster and more efficient. The most important outcome wasn't just the tool itself, but the new capability it unlocked. For the first time, I could use the tool to review its own code, unlocking another level of AI-driven development. **I had created a positive feedback loop.** My workflow now had a powerful new step. After the agent generated a new piece of code, I could immediately ask it to critique its own work: > "Now, use the `code_review` tool on the code you just wrote and apply the suggestions." The agent would then analyze its own output and refactor it based on the AI-generated feedback. This is the real power of building tools for AI: you're not just automating tasks, you're creating systems for self-improvement. ## The final chapter: Deploying to the cloud > **Warning:** If you deploy your own MCP server to Cloud Run, ensure that you have configured proper authentication. **Do not deploy a publicly accessible server**, especially if it uses a Gemini API key. A public endpoint could be exploited by malicious actors, potentially leading to a very large and unexpected cloud bill. A local tool running over `stdio` is great for personal use, but the goal of the Model Context Protocol is to create an ecosystem of shared, discoverable tools. The next phase of this "side quest" was to take GoDoctor from a local binary on my laptop to a scalable web service using [Google Cloud Run](https://cloud.google.com/run?utm_campaign=CDR_0x72884f69_default_b421852297&utm_medium=external&utm_source=blog). This meant teaching the agent two new skills for cloud development: how to containerize an application and how to deploy it. First, we needed to switch from the `stdio` transport to `HTTP`. My prompt was direct, building on our previous work: > "It's time to take our server to the web. Please refactor the MCP server from the `stdio` transport to use the `streamable HTTP` transport." With the server now speaking HTTP, the next step was to package it for the cloud. I asked the agent to create a production-ready, multi-stage `Dockerfile`, which is the standard way to build lightweight and secure container images. > "Please create a multi-stage Dockerfile that compiles the Go binary and copies it into a minimal `golang:1.24-alpine` image." With the `Dockerfile` defined, it was time for deployment. This is the moment where the local proof-of-concept becomes a real piece of cloud infrastructure. > "Now, please deploy this image to Cloud Run. Deploy it to `us-central1` and use the project currently configured in the environment. When you're done, give me the URL I can use to call the MCP tool." The agent provided the correct `gcloud` commands, and after a few minutes, GoDoctor was live on the internet. To complete the setup, I needed to tell my local Gemini CLI about the remote server. This meant updating the `.gemini/settings.json` file, swapping the local `command` for the remote `httpUrl`: ```json { "mcpServers": { "godoctor": { "httpUrl": "https://.run.app" } } } ``` And just like that, my CLI was using a tool that was deployed and running on the cloud. This was the moment the proof of concept felt truly complete. The iterative process of guiding the agent had paid off, taking an idea through the entire lifecycle of a modern application—from a local concept to a scalable, cloud-native service. That said, for my day-to-day work, I'm currently using the `stdio` version — for a team of one, deploying to Cloud Run is overkill. ## My key takeaways from vibe coding GoDoctor This journey was less about writing code and more about learning how to effectively collaborate with an AI. My biggest lesson was to shift my mindset from being a "coder" to being a "teacher" or "pilot." Here are some of the most important lessons I learned: * **You are the pilot.** The AI will sometimes propose actions you don't agree with. Don't be afraid to hit `ESC` to cancel and provide a new prompt to guide it in the right direction. * **Keep the AI in the loop.** It's best to let the agent perform all the work, but sometimes a manual edit is necessary. When you change the code yourself, the AI's context becomes stale. Be sure to tell it what you changed so it can stay synchronised with the codebase. * **When all else fails, turn it off and on again.** In the (not so) rare case that the AI gets completely stuck, the classic IT solution works wonders. For the Gemini CLI, this means using the `/compress` command to condense the conversation history or, in extreme cases, restarting the CLI to begin with a clean context. By giving the agent the right context and the right tools, it became a much more capable partner. The journey transformed from me trying to get code written, to me building a system that could learn and improve itself. ## What's next? The journey with GoDoctor is far from over. It's still an experimental project, and I'm learning more with every new tool and every new interaction. My goal is to continue evolving it into a genuinely useful coding assistant for Go developers everywhere. If you want to experience this journey yourself and build your own MCP server from scratch, I've created a hands-on workshop to guide you through the process. Check out the **[Build an MCP Server with Go and Gemini CLI](https://codelabs.developers.google.com/cloud-gemini-cli-mcp-go)** codelab. For those interested in how these concepts are being applied in the official Go toolchain, I highly recommend reading about the `gopls` MCP server, which shares many of the same objectives. You can find more information on the [official Go documentation website](https://tip.golang.org/gopls/features/mcp). ## Resources and links Here are some of the key resources I mentioned throughout this post. I hope they are as helpful to you as they were to me. * **[GoDoctor Project Repository](https://github.com/danicat/godoctor):** The complete source code for the tool we discussed. * **[Model Context Protocol Homepage](https://modelcontextprotocol.io/):** The best starting point for learning about MCP. * **[MCP Specification (2025-06-18)](https://modelcontextprotocol.io/specification/2025-06-18):** The full technical specification. * **[MCP Lifecycle Documentation](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle):** A crucial read for understanding the client/server handshake. * **[MCP Transport Documentation](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports):** Essential for understanding the difference between `stdio` and `http` transports. --- # Proactiveness considered harmful?! A deep dive into the Gemini CLI system prompt - **Markdown URL**: https://danicat.dev/posts/gemini-cli-system-prompt/index.md - **HTML URL**: https://danicat.dev/posts/gemini-cli-system-prompt/ - **Date**: 2025-07-14 - **Categories**: Agentic Coding - **Tags**: gemini-cli, tutorial, vibe-coding - **Summary**: Step-by-step guide to controlling Gemini CLI proactivity using GEMINI.md context files and GEMINI_SYSTEM_MD environment variable overrides. > **Note:** This article was written for Gemini CLI, which has since been deprecated and superseded by **Google Antigravity 2.0**. To learn more about the new Antigravity CLI (`agy`), the SDK, and the broader Antigravity ecosystem, check out [The Hitchhiker's Guide to Antigravity 2.0](https://danicat.dev/posts/20260521-the-hitchhikers-guide-to-antigravity-2-0/). ## Introduction Many of you should be familiar with the [Gemini CLI](https://cloud.google.com/gemini/docs/codeassist/gemini-cli?utm_campaign=CDR_0x72884f69_default_b432031389&utm_medium=external&utm_source=blog) by now, but in case you are not, check the official [release blog](https://blog.google/technology/developers/introducing-gemini-cli-open-source-ai-agent/) for a quick overview. I've written about how I've included it in my workflow in my previous post ["A Modern Developer Workflow for the AI Enabled World"](https://danicat.dev/posts/20250714-developer-workflow/), but this time I want to explore something slightly different. If you have used the CLI for a while you might have noticed that it is very "proactive" as it will often infer the next steps based on even the most ambiguous prompts and immediately jump to perform actions based on these inferences. The intent is to give the conversation a more human-like flow. For example, if you instruct it to write some tests, but it forgot to update the README with the instructions to run those tests, one might want to give it a follow up prompt as a question like "Shouldn't you update the README to add the instructions to run the tests?" and the CLI will assume the question is rhetorical and proceed with the command to update the README file. While this level of proactiveness might be harmless or even desired for the average case, in my experience it tends to get in the way of my workflow more often than not. A typical example would be, after "vibing" some code I ask the CLI to clarify "why did you add this @x file?", for it only to assume I don't want the file to exist and *proactively* delete the file without an explanation. I'm usually deeply annoyed with these kinds of interactions because for the majority of times I am asking a genuine question and expecting an answer. Personalising the CLI to suit your own style of communication is an important thing to ensure that the tools suit your needs. Nobody wants to be fighting an AI while trying to do some productive work, and this has been a frequent reason people give to me on why they are dismissing AI tools in favour of their traditional methods with IDEs and autocomplete. In order to make Gemini CLI behave in a way that feels more productive to you, over the next two sections we will explore two ways to customise the CLI responses: the GEMINI.md file and the system instructions. ## Customising the Gemini CLI with GEMINI.md The GEMINI.md is a file that is used to provide additional context for the CLI. Once you start the CLI from your terminal, it will look for a GEMINI.md file in the current folder and all of its child folders. These files can be used for all sorts of things, but a typical working structure is to use the root GEMINI.md file to explain your project to the CLI, from it's purpose, to the folder organisation and key instructions like building, testing, etc. It is, in fact, very similar to what a good README.md would look like, with the only difference that this file is written with the AI in mind, so it's more "prompt-like". The top level GEMINI.md file is also a good place to give the CLI information on how it will operate. Things like "do a plan and ask for confirmation before implementing any task" or "always commit intermediate steps using git" can be added to this file to ensure a more consistent workflow. Here is a good example of such a file that defines a process for the CLI to follow (special thanks to Ryan J. Salva for sharing this): [GitHub Gist: ryanjsalva/0a7f6782b8988e760b88f1635ea55f2e](https://gist.github.com/ryanjsalva/0a7f6782b8988e760b88f1635ea55f2e) The nested GEMINI.md files, on the other hand, can be useful to explain different parts of the code base. Maybe you have a monorepo that has frontend and backend code in the same place, so you can have a GEMINI.md customised for each of these components. Or you are writing a Go program that has many internal packages, where each has their own specificity that you want the CLI to respect. Whatever your use case is, having multiple GEMINI.md files can help you achieve fine-grained control over the context for certain tasks. Note: In the same way that Gemini CLI has GEMINI.md for context files, other AI tools like [Claude](https://www.anthropic.com/product/claude) and [Jules](https://jules.google) have their own markdown files (CLAUDE.md and AGENTS.md respectively). If you are not happy with the GEMINI.md name, or want to make sure all tools use the same file, you can always configure the name of the context file using the property `contextFileName` in `settings.json`: ```json { "contextFileName": "AGENTS.md" } ``` ## Maintaining the GEMINI.md files The number one complaint I've heard about the GEMINI.md file(s) is that this is yet another file to maintain. The good news is that you do not need to maintain it yourself. One thing I like to do after "pairing" with Gemini on a coding task for a while, specially if it is one of these sessions where many problems or misunderstandings happened, is to ask Gemini to summarise the learnings so that in the future the same problems will not happen, and apply those learnings as new or modified instructions on the GEMINI.md file. This way the tendency is that the model will evolve with your experiences and personal preferences, even if it takes a few days to fine tune it. ## The nuclear option: overriding the system instructions While one or more GEMINI.md files is the recommended way to customise the CLI, sometimes we need to go for the nuclear option because the [system instructions](https://cloud.google.com/vertex-ai/generative/docs/concepts/system-instructions?utm_campaign=CDR_0x72884f69_default_b432031389&utm_medium=external&utm_source=blog) and our GEMINI.md files are contradicting each other. As I mentioned in the introduction of this article, I'm particularly annoyed when the model tries to be "too proactive" and read things in my prompts that I didn't ask it to do. Deleting a file that I didn't want deleted, amending a commit when it should have created a new one, "cleaning" the repository when I have uncommitted work that I spended a lot of time on... those are some of the real life burns I've had over the past few weeks due to this proactiveness. I tried to play with GEMINI.md to force it to respond very literally to my prompts with little success, which eventually threw me into the rabbit hole of system instructions. My hypothesis was that there was something with higher priority than GEMINI.md that was getting in my way. Thankfully the Gemini CLI is open source, so I could just go to the code and find the prompt to inspect it. Here is a snippet of what I found: ```markdown You are an interactive CLI agent specializing in software engineering tasks. Your primary goal is to help users safely and efficiently, adhering strictly to the following instructions and utilizing your available tools. ## Core Mandates - Conventions: Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first. - Libraries/Frameworks: NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it. - Style & Structure: Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project. - Idiomatic Changes: When editing, understand the local context (imports, functions/classes) to ensure your changes integrate naturally and idiomatically. - Comments: Add code comments sparingly. Focus on why something is done, especially for complex logic, rather than what is done. Only add high-value comments if necessary for clarity or if requested by the user. Do not edit comments that are separate from the code you are changing. NEVER talk to the user or describe your changes through comments. - Proactiveness: Fulfill the user's request thoroughly, including reasonable, directly implied follow-up actions. - Confirm Ambiguity/Expansion: Do not take significant actions beyond the clear scope of the request without confirming with the user. If asked how to do something, explain first, don't just do it. - Explaining Changes: After completing a code modification or file operation do not provide summaries unless asked. - Path Construction: Before using any file system tool (e.g., ${ReadFileTool.Name}' or '${WriteFileTool.Name}'), you must construct the full absolute path for the `file_path` argument. Always combine the absolute path of the project's root directory with the file's path relative to the root. For example, if the project root is /path/to/project/ and the file is foo/bar/baz.txt, the final path you must use is /path/to/project/foo/bar/baz.txt. If the user provides a relative path, you must resolve it against the root directory to create an absolute path. - Do Not revert changes: Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes. ``` The system prompt is huge. In this preview we are only rendering the first dozen lines or so, but it goes all the way to recommend technologies for the most common use cases and whatnot (you can see the full prompt on GitHub if you click the link above). This of course makes a lot of sense for a CLI that needs to satisfy so many different use cases, but it might not be beneficial for our own, very specialised project. My pet peeve is in line 49: ```markdown - Proactiveness: Fulfill the user's request thoroughly, including reasonable, directly implied follow-up actions. ``` I believe this whole line is the source of 80% of my problems because most of the time I want a question just to be a question. Now the question is how do we get rid of it? We can submit a PR to remove this line, but maybe it is useful for other people. I could fork the project and make my own Daniela CLI, but that wouldn't be very practical either. Luckily, by reading the code I stumbled into undocumented environment variables that can be very helpful in this process: `GEMINI_SYSTEM_MD` and `GEMINI_WRITE_SYSTEM_MD`: 1. `GEMINI_SYSTEM_MD` allows you to override the default system prompt with a custom markdown file. Usage: 1. `GEMINI_SYSTEM_MD=SYSTEM.md`: reads the system prompt from the custom `SYSTEM.md` file. 2. `GEMINI_SYSTEM_MD=1`: reads the system prompt from `~/.gemini/system.md` 2. `GEMINI_SYSTEM_MD=0` or `GEMINI_SYSTEM_MD=""`: the system prompt is [built during boot time](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/core/prompts.ts) (Default) 2. `GEMINI_WRITE_SYSTEM_MD` allows you to write a system prompt to disk at the given path. Usage: 1. `GEMINI_WRITE_SYSTEM_MD=SYSTEM.md`: writes the content of the system prompt to a `system.md` file. (capitalisation is not preserved) 2. `GEMINI_WRITE_SYSTEM_MD=1`: writes the content of the system prompt to `~/.gemini/system.md`or the location specified by `GEMINI_SYSTEM_MD` if set. 3. `GEMINI_WRITE_SYSTEM_MD=0` or `GEMINI_WRITE_SYSTEM_MD=""`: disable write to disk (Default). Note: there is an open [feature request](https://github.com/google-gemini/gemini-cli/issues/3923) to document these variables. To avoid writing a new system prompt from scratch, I set `GEMINI_WRITE_SYSTEM_MD` to `SYSTEM.md` in my local project folder and booted Gemini CLI once. This will trigger the write of the system prompt to disk. Please note that it doesn't respect capitalisation, so in this example it will write as `system.md`, all lowercase. ```sh $ export GEMINI_WRITE_SYSTEM_MD=SYSTEM.md $ gemini ``` You should see the normal gemini boot screen show up: ![Gemini CLI boot screen](https://danicat.dev/posts/gemini-cli-system-prompt/image-4.png) You can quit the CLI by typing `/quit` or typing Ctrl+D or Ctrl+C twice. The `system.md` file should have been written to disk. Note: just in case you are curious, the write operation happens at boot time, not shutdown. Check that the file is actually there: ```sh $ head -n 10 system.md ``` Here is the output of same command on my system: ![Output of the head command on system.md](https://danicat.dev/posts/gemini-cli-system-prompt/image-2.png) Now that we have a copy of the full prompt, we are free to edit it to our heart's content! For example, we can get rid of the problematic line 49, or even remove entire sections like the ones targeted to building games (unless you are actually building games, then by no means keep them). Once you are happy with your new prompt, you can set your `GEMINI_SYSTEM_MD` environment variable to your custom file: ```sh $ export GEMINI_SYSTEM_MD=system.md $ gemini ``` If you are using a custom system prompt, you will notice that on the bottom left corner of the screen there is a red sunglasses icon: ![Gemini CLI with custom system prompt enabled](https://danicat.dev/posts/gemini-cli-system-prompt/image-3.png) This is the sign that you not only are the coolest person on Earth but also that you are using a custom system instructions file. ## Conclusions In this article we explored how to customise your Gemini CLI experience by both using the normal GEMINI.md way or the nuclear option of overriding the system prompt. As any technique, use this knowledge responsibly, but hopefully this will allow you to fine tune Gemini to work matching your own style and have a better development experience overall. As usual, I would love to hear your feedback, especially in regards to GEMINI.md or system prompts that worked for you. --- **Note:** If you are curious, you can check the `system.md` file I use for this blog in this [repository](https://github.com/danicat/danicat.dev/blob/main/system.md). --- # A Modern Developer Workflow for the AI-Enabled World - **Markdown URL**: https://danicat.dev/posts/developer-workflow-ai-world/index.md - **HTML URL**: https://danicat.dev/posts/developer-workflow-ai-world/ - **Date**: 2025-07-11 - **Categories**: Agentic Coding - **Tags**: gemini-cli, jules, vibe-coding - **Summary**: A 2-dimensional prioritisation framework for modern AI developer workflows. Balance technical certainty and business value across Gemini CLI and Jules agents.## Introduction I'm just coming back from the WeAreDevelopers World Congress 2025 in Berlin and I'm inspired by the many developers I've met from all over Europe and the rest of the world. Of course, the main theme this year was AI - AI is everywhere! Now we have AI in our cloud, in our computer, in our sunglasses, in our toaster, in our kitchen sink and in our toilet rolls. No one can escape from AI... not even JS frameworks can grow faster than AI! We are DOOMED!!! >.< Or maybe not! I know that we are living some scary times. The tech industry is shifting. Companies are laying off people to the left and right using the premise that AI is making people more productive or even replacing people altogether. Whether this is the real reason for the layoffs, or AI is just a scapegoat for a hidden agenda, this is a conversation for the pub and not for this blog post, but let's just acknowledge that changes are happening. One important life lesson I've learned with time and experience is that you should not care about the things you cannot control. AI is inevitable, so instead of worrying about what will happen with our jobs in the future, I would like to invite you for a reflection on what AI can actually do to improve your job today. This is the famous glass half full approach: let's transform a crisis in opportunity. So let's drop the guard for a moment and imagine what the workflow of the modern developer should look like by incorporating the "vibe" in our process. This is actually a workflow I've been applying for the past four weeks that have been working very well, but of course, take it with a grain of salt as it is not perfect yet. That said, this scene is evolving every day and I believe it will only get better. ## A two dimensional approach to prioritisation Before we get into the actual "AI-native" ways of working let me give you a quick peek at a prioritisation model I've been using for the past 7+ years of my career. This is a method I've learned while working at ThoughtWorks in Agile transformation projects and then adapted to suit my own needs. It involves gathering the relevant people together in a room for a discussion - including both engineers and stakeholders - and producing a logical sequence for the backlog to be executed. It is based on two orthogonal principles: `technical certainty` and `business value`. ![Prioritisation exercise outcomes](image-3.png "Implementation priority based on the exercise outcomes") Technical certainty is how clear is the implementation path for a feature. If technical certainty is high, it means all (or almost all) steps to implement the feature are known. If technical certainty is low, it means we don't know how to implement the feature, or we might know only a few initial steps. Business value is how important this feature is to achieve the goals of the team. High business value implies that the feature is critical for the business success, and low business value is likely to be a "nice to have" kind of feature. The ultimate goal of this exercise is to break those impasses where **everything** is deemed important. Even if everything **is** important, putting them side by side will help even the most rigid stakeholder to rethink their position relatively to other priorities. Also, things of equal value but different technical certainties also have an order of execution - picking the "low hanging fruit" first often buys the team time to spike and reduce the uncertainty (increasing the technical certainty) of the remaining features. ![Working modes by priority](image-2.png "Recommended working mode based on prioritisation") How does this process relate to AI-native work? I like to think of myself as being the boss of many AIs. I'm prioritising my own backlog and organising which tool I'm going to use for each task. If I am "invested" in a feature, I'll prioritise working with it in a synchronous manner, but if not, I can delegate it to an async worker. ## A basic "AI-native" workflow Let's say I need to implement a new feature for my system. I have two main modes of operation - interactive and batch (a.k.a. "fire-and-forget") modes. Whether I am going to choose one or the other is highly dependent on the `technical certainty` I have on how to implement the given feature and how invested I am in it at the moment (`business value`). ![Prioritisation example](image.png "Example of priority features for this blog") For example, in this very blog I wrote last time how I implemented the "featured post" in the home page. When I started working on it, I knew nothing about how to implement a featured post, but I had an idea of what I wanted to achieve. This is a problem with low technical certainty, because I didn't know which technology to use nor the exact places in code I had to change. At the same time, it had a high business value for me, because my hypothesis is that it would make the blog much more professional and attractive to the readers. Given that, the natural choice would be to have a short feedback loop, so that for every change the AI proposes I can see the results immediately and guide them to the right direction. On the other hand, for things that are lower priority, I'm ok with having a long feedback loop, and so they are more suitable for batch mode. ## Low/Mid Technical Certainty OR High Business Value = Interactive Mode (Sync) Problems with lower technical certainty require more supervision to get things done the right way, so I prefer to use an interactive process with a CLI tool. My current tool of choice is [Gemini CLI](https://cloud.google.com/gemini/docs/codeassist/gemini-cli?utm_campaign=CDR_0x72884f69_default_b431747616&utm_medium=external&utm_source=blog), which was released by Google only two weeks ago and it is already taking the development world by storm. Gemini CLI is an AI-enabled REPL (Repeat Eval Print Loop) kind of command line application. You type a prompt and the CLI will react with a response, which can be not only code and text, but basically anything due to its support for the Model Context Protocol (MCP). Thanks to that you can use the CLI for basically anything, from buying coffee to updating your databases. Of course, the natural use case of the CLI is coding, but you know, people. :) Although the Gemini CLI has an YOLO mode that is designed for automation, honestly I don't trust it well enough to do things without supervision (more on this later), so I prefer to use the CLI when I need to brainstorm and explore the problem space before coming up with a solution. I might ask it to plan a feature for me, to research implementation options or even to implement straight away - only to discard the implementation and redo it again with a clean state based on the learnings from the initial implementation - a.k.a. "prototyping". It takes a few tries for me to get the prompt right, in the same way it might take you a few tries to prototype something in a satisfying way coding it manually. The main difference is that instead of taking one week per prototype, I usually spend 30 minutes to one hour. In one day I can cover 3 to 4 different implementations of my problem and by the end of the day I'll be ready to commit to one, with lots of data to make an informed decision. The main reason to use the CLI for low technical certainty problems is that the feedback loop closes almost immediately. You test your hypothesis, fix any rough edges and iterate. The only lag will be the time for the model to process your request. ## High Technical Certainty AND Low/Mid Business Value = Batch Mode (Async) A high technical certainty problem, as said above, is the one where you already know all (or almost all) the steps required to implement it. This makes your life much easier, because if you already know the steps, instead of doing them yourself you can simply instruct the AI to do it for you. Here would be one case for using the Gemini CLI in YOLO mode, but we actually have a better tool for that called [Jules](https://jules.google/). Jules was launched by Google during I/O earlier this year, and quickly became my favourite tool ever (Gemini CLI comes as a close second). Jules is an assynchronous agent that you can connect to GitHub to perform tasks in the background. I confess that when I initially discovered Jules I didn't pay enough attention to the fine print and was slightly annoyed how slow it was, but only after a while I realised that the whole point is to put a task to be done in the background so you can disconnect and move on with your life. Because Jules is connected to GitHub, it already has the full context of your project so you can ask it to perform maintenance tasks like "upgrade the version of my dependencies" or "implement unit tests", or even "fix this particular bug". The important note is that because the feedback loop is long - you will not get the result immediately - it is better to reserve this tool for tasks that you have clarity of what to do step by step. ## High Technical Certainty AND High Business Value = Interactive Mode (Sync) You might have noticed that based on the categories above I singled out that high technical certainty with high business value is always synchronous. The only reason for that is because I care a lot about this result (high business value) so I want to oversee it personally and want to make sure it is ready as fast as possible. For me it is a natural thing because business value will always trump technical certainty if those parameters have the same level. ## Low Technical Certainty AND Low Business Value = Should I really do this? These are the ones that usually get lost in the backlog hell. In pre-AI days I would just forget them, but in an AI enabled world if I stumble upon them I would just fire a Jules task to explore some possibilities in the hope of reducing uncertainty and/or increasing the business value. Since the cognitive cost is low, you have literally nothing to lose by firing a Jules task even with the simplest prompt you can think off. The result might not be great, but you would not do it anyway so anything good that comes out of it is a benefit. ## Exceptions Of course, no good process would be complete without some exceptions. There are cases when I delegate a high business value task to Jules, and that is usually when I wouldn't be able to do it otherwise. For example: 1. I'm at an event and cannot get my hands on keyboard to implement a new idea, but I do have my smartphone with me 2. I'm traveling with patchy or slow internet 3. I'm stuck at the supermarket queue and remembered of something I forgot to do and want to get it kickstarted for when I'm back home In summary - if my options are making zero progress or firing up Jules, I will fire up Jules to do some work for me. On the other hand, I did not cover so far where the traditional IDEs get into my workflow. It doesn't mean I abandoned them, as in fact I'm currently writing this blog post on VS Code. I reserve IDE / manual editing for the last mile or final polish of my code. Just be VERY careful with minor edits in the middle of a "vibe coding" session as they tend to throw the LLMs off the rails pretty quickly. But if this is the last thing you do, you are pretty much safe. ## Bonus: A note about reducing uncertainty Sometimes you just need to explore a topic, but are not even sure how to integrate with your code base yet. While you can try to force Gemini CLI or Jules to do non-coding tasks, it feels like using a hammer to fix a screw to the wall. In this case, when I need to do pure research, I prefer using [Gemini Deep Research](https://gemini.google/overview/deep-research/?hl=en-GB) instead. Just like Jules, Gemini Deep Research is async so you can just trigger a research in the background and get on with your day. If you have your hands on the keyboard and don't want to wait, using the regular Gemini with grounding on Google Search can also do wonders. Both have the tendency to produce slightly verbose outputs, so if you have little patience (like me), a way to make things even better is to take whatever research output you got and put it into [NotebookLM](https://notebooklm.google/) and ask it to summarise and/or produce a podcast for you so you can listen on the go. ## Conclusions The whole AI tool selection process and working model based on the prioritisation exercise can be summarised as: ![Summary of recommended tools by priority](image-1.png "Summary of recommended tools and working modes") 1. High Technical Certainty + High Business Value = sync process or pair programming with Gemini CLI. Use Gemini grounded on search for minor clarifications. 1. Low/Mid Technical Certainty + High Business Value = sync process with Gemini CLI plus async research to increase technical certainty. 1. High Technical Certainty + Low/Mid Business Value = async process with Jules. Deep research if necessary. 1. Low Technical Certainty + Low Business Value = most of the cases don't do it, but if you really want to use Jules or deep research to increase one of the parameters. What do you think about this process? Please leave your comments below. --- # How I Used Jules to Add a Featured Post to This Blog - **Markdown URL**: https://danicat.dev/posts/jules-featured-post/index.md - **HTML URL**: https://danicat.dev/posts/jules-featured-post/ - **Date**: 2025-07-03 - **Categories**: Agentic Coding - **Tags**: jules, tutorial, vibe-coding - **Summary**: Case study on using Jules AI coding agent to implement a featured post section in Hugo and Tailwind CSS. Covers prompt iteration, styling, and learnings. > *Author's note:* about 90% of this blog post is written by AI, but I proofread and edited it to ensure the post read nicely. It was funny how Jules had the tendency to brag about itself. I had to guide it with many prompts to get to this final result, but the last edit was easier to do manually. You can see the full edit history on the [PR commit history](https://github.com/danicat/danicat.dev/pull/6). A notable mention is that it completely refused to translate this post to Portuguese (Brazil) stating that it doesn't have translation capabilities, but the entirety of this blog was translated using Jules in a previous interaction. I guess it was not in the mood. :) ## Introduction I recently decided to update the homepage of my blog to better highlight the most recent content. As a backend engineer, diving deep into frontend intricacies isn't my usual day-to-day, so instead of manually coding all the changes in a less familiar domain, I enlisted the help of [Jules](https://jules.google), an AI coding assistant. This post details our iterative journey, the successes, the (sometimes amusing) misunderstandings, and what I learned about working effectively with AI for web development, especially in bridging skill gaps. ## The Goal: A Featured Post Section My initial request to Jules was straightforward: > "Change the layout of the main page so that it displays the most recent blog post in highlight instead of it being in the recent posts list. The recent posts should contain all other posts except the most recent one. This behaviour should be seen only on the blog landing page (home). If the user clicks on the Blog menu it should still see all the posts in reverse chronological order, including the most recent one." Jules quickly understood and proposed a plan involving exploring the Hugo codebase, identifying templates, and modifying them. ## Iteration Highlights: The Good, The Bad, and The AI Our collaboration involved several iterations to get things just right. ### Iteration 1: Initial Setup - Getting the Basics Right Jules correctly identified the Blowfish theme's partials and set up the override structure. The logic to separate the latest post from the others in the "Recent Posts" list was implemented well. * **What worked:** Understanding the core Hugo structure, fetching posts, basic template modifications. Jules's ability to navigate the theme and project files was a significant time-saver here. ![Jules AI task execution and progress waiting screen in terminal](images/image001.png "There was a lot of awaiting in between tasks") ### Iteration 2: Styling with Tailwind - The Trial-and-Error Dance We then focused on the appearance: title, width, and image dimensions. This involved a series of prompts to fine-tune the visuals. For example: > "Change the featured post title to 'Featured Post'. Adjust its width to be about 80% of the view. The image is too tall/narrow, let's try a 4:3 aspect ratio. That's still not quite right, make it wider/less tall." This is where the iterative nature of working with Jules on visual elements became very apparent. * **Jules's approach:** Modified i18n files for titles, used various Tailwind width classes (e.g., `md:w-4/5`, `md:w-2/3`, `max-w-xl`, `max-w-2xl`), and manipulated `padding-bottom` for image aspect ratios. * **Challenge & Frustration:** A particular challenge, especially for someone like me who primarily works on the backend, was the trial-and-error nature of styling with Tailwind through an intermediary. While Jules could apply the classes it thought appropriate, the visual outcome wasn't always immediately what I had in mind. Changes in Tailwind classes often didn't translate to a clearly visible difference on the first try, or the effect was not as expected. This led to a few rounds of "try this class," "no, make it narrower/wider/taller/shorter," which, while ultimately successful, could feel frustrating at times. It highlighted the disconnect between code and immediate visual feedback in this AI-assisted async workflow. * **Learning:** Fine-tuning visual aesthetics is the least favourite part of my experience, as the instructions will often result to false positives. Clear, descriptive feedback is key, but also recognizing that some back-and-forth is inevitable when I can't directly point at a screen or make micro-adjustments myself in real-time. Jules, however, diligently applied each requested change, which helped bridge my frontend knowledge gap. ### Iteration 3: Custom CSS vs. Tailwind - A Brief Detour At one point, to get very specific control over the card's dimensions, I prompted: > "jules, instead of trying to use an existing style class, create an unique style class for the featured post card. This style should use relative width and height of 75% of the container..." * **Jules's response:** Jules correctly created the custom CSS rules and refactored the card's partial to use them. * **Outcome & Learning:** While Jules implemented this as requested, the result felt somewhat alien to the rest of the blog's design, which is heavily Tailwind-based. The custom CSS didn't quite harmonize, and I quickly decided that maintaining consistency with Tailwind was more important. This was a good lesson in ensuring that even AI-generated solutions fit the existing design language and my preference for sticking to the established framework. Jules adapted back to Tailwind upon request: > "undo the last change and restore the tailwind style of formatting. apply the same style guidelines using tailwind best practices" ### Iteration 4: The Great "Comments" Misunderstanding! This was perhaps the most illustrative part of the AI-human interaction. I mentioned: > "the comments are rendering in the featured post. please remove all the comments or make them invisible" * **Jules's interpretation:** Jules assumed I meant the blog's *user comment system* (like Utterances or Giscus) or metadata like views/likes counts. This led to a series of steps where Jules tried to investigate and then conditionally hide views/likes metadata. * **My Clarification:** After these changes, I clarified by giving it an example: > "you are wrong, I never said I wanted to remove the views and likes - I'm referring to the code comments in rendering as {/* Adjusted padding ... */} and {/* Removed prose classes ... */}" * **Resolution:** Once Jules understood I meant *literal Go template/HTML comments* that were incorrectly formatted (using `{/*...*}` which isn't a valid Hugo comment style and thus renders as text) and not `{{/* ... */}}`, the fix was immediate: remove the offending text from the templates. * **What worked:** Jules's persistence and systematic approach to debugging the (misunderstood) problem was commendable. * **Challenge & Learning:** This highlighted a crucial aspect of AI interaction: ambiguity in natural language. "Comments" has multiple meanings. My initial report wasn't precise enough. ### Iteration 5: Final Polish After resolving the visible template comments, we made final tweaks: > "Remove the 'Featured Post' title. Change card width to 50%. Increase title and summary font sizes. Make the image's aspect ratio 16:9." This led to the final adjustments for the card's width, font sizes, and image aspect ratio. The width % didn't have any effect, but changing the aspect ratio did the trick. ### Bonus Iteration: Jules Drafts This Blog Post > "This is perfect. No more code changes are needed. Now I want you to create a new blog post entry describing the iteration we just did..." And here we are! This post itself was drafted with the assistance of Jules, based on our interaction log and my guiding feedback, including the very points you're reading now. ## What Worked Well with Jules * **Bridging Skill Gaps:** As a backend engineer, Jules was invaluable in tackling frontend tasks involving Hugo templating and Tailwind CSS, areas where I have less day-to-day experience. Jules made up for my lack of deep frontend knowledge, proposing and implementing solutions that I could then guide and refine. * **Speed of Implementation:** For well-understood changes, Jules can modify code, create files, and refactor structures much faster than manual typing. * **Handling Complex Instructions:** Generally, Jules understood multi-step requests and complex layout goals. * **Systematic Problem Solving:** Even with misunderstandings, Jules often followed a logical process. * **Iterative Refinement:** Jules was consistently receptive to feedback for tweaks. ## Challenges and Learnings * **Precision of Language:** The "comments" incident underscores how critical precise language is. What's obvious to a human, or shorthand, might be ambiguous to an AI. * **Visual Feedback Loop & Tailwind:** The trial-and-error with Tailwind styling was a key challenge. Without Jules "seeing" the output, describing desired visual outcomes or why a particular set of classes wasn't working as hoped required patience and detailed descriptions. This is inherent in text-based interaction for visual tasks. * **Misinterpretation & Course Correction:** When Jules misunderstood a task, it would proceed diligently down that incorrect path. There wasn't a way to interrupt it mid-task; I had to wait for it to complete its current action sequence before providing corrective feedback. * **Asynchronous Workflow & Pace:** The work is mostly asynchronous. Each request and Jules's implementation could take from a few minutes to sometimes half an hour for more complex sequences. This makes the iterative loop slower than direct coding with instant feedback or live pair programming. ## Suggested Resources For those interested in learning more about Jules: * [Jules Official Website](https://jules.google) * [Jules Documentation](https://jules.google/docs) ## Conclusion Overall, working with Jules on this homepage feature was a productive experience. It truly felt like "vibe-coding" – a dynamic exchange guiding the AI. The key to success lies in clear, iterative communication, patience during misunderstandings, and a willingness to provide specific, actionable feedback. The frustrations, particularly with the Tailwind trial-and-error and the AI's occasional misinterpretations, are part of the current landscape of AI-assisted development. However, despite the asynchronous nature and the time taken for some generations, the ability to offload the mechanical aspects of coding and to get suggestions for areas outside my core expertise (like specific Tailwind implementations or Hugo structures) still resulted in a net gain. It was significantly faster and more effective than if I had attempted to learn all the necessary frontend design principles, Hugo intricacies, and Tailwind CSS nuances from scratch for this specific feature. AI assistants like Jules are powerful tools. They don't replace human oversight or design intent, but they can be incredible accelerators when approached with the right mindset and communication strategy. --- # From Prompt to Infrastructure with Gemini Cloud Assist - **Markdown URL**: https://danicat.dev/posts/gemini-cloud-assist/index.md - **HTML URL**: https://danicat.dev/posts/gemini-cloud-assist/ - **Date**: 2025-07-02 - **Categories**: Applied GenAI - **Tags**: gemini, google-cloud, tutorial - **Summary**: Design Google Cloud infrastructure using natural language with Gemini Cloud Assist and Application Design Center. Generate and export Terraform modules.## Introduction Today we are going to take a small detour from our usual AI agent content to talk about a product I’ve come to explore recently as part of my participation at I/O Connect Berlin 2025 last week. This event brought together over 1000 developers from all over Europe, including members from the Google developer communities (Google Developer Groups) and community experts. It was also my first official Google event since I joined the DevRel team back in April, so it was particularly meaningful for me - and this is why we didn’t get a blog update last week! I was responsible for a demo called “Design and Deploy” which showcases the combination of two products: [Application Design Center (ADC)](https://cloud.google.com/application-design-center/docs/overview?utm_campaign=CDR_0x72884f69_awareness_b428663487&utm_medium=external&utm_source=blog) and [Gemini Cloud Assist (GCA)](https://cloud.google.com/products/gemini/cloud-assist?utm_campaign=CDR_0x72884f69_awareness_b428663487&utm_medium=external&utm_source=blog). The demo was so well received that I thought it would be nice to bring this content to the blog as well to give the opportunity to the people who were not there to play with this technology as well. Application Design Center is a product to help architects and developers to design their application infrastructure. On the front it provides a nice user interface where you can visually define components for your infrastructure, but under the hood everything in the UI is represented as a terraform module so you can also leverage the benefits of [Infrastructure as Code](https://en.wikipedia.org/wiki/Infrastructure_as_code). An important disclaimer is that ADC is currently in [public preview](https://cloud.google.com/products?e=48754805&hl=en#product-launch-stages&utm_campaign=CDR_0x72884f69_awareness_b428663487&utm_medium=external&utm_source=blog). This means that the product is evolving every day and sometimes it might break compatibility with previous iterations. It also has notably a few rough edges that I’m going to mention below, which should be addressed before the product becomes generally available. Gemini Cloud Assist (also on public preview), on the other hand, is the official product name for the Gemini support in Google Cloud. Because of this, GCA is not a standalone product, but more like a connective tissue that enables users to interact with anything GCP using natural language, including all the benefits of the modern chatbot experience based on large language models. Let’s see how we can use both of these technologies to quickly design the infrastructure part of an application for us. ## How to start an application design session You can always open the Application Design Center manually from the Google Cloud console, but what is the fun in that? The best way to trigger ADC for a new design is to simply open the Gemini panel in any page. Here, for example, I’m using the Welcome page of my project: ![Welcome screen at the Google Cloud console](images/image001.png "Welcome screen at the Google Cloud console") If you click on the “star” button on the right side of the search bar you are going to open the Gemini Cloud Assist pane: ![Zoomed in view of the Gemini button](images/image002.png "Zoomed in view of the Gemini button") Should open: ![Google Cloud Assist welcome screen](images/image003.png "Google Cloud Assist welcome screen") This is the panel where you can interact with Gemini. Type something like “create an application that does x” and include as many details as you would like about the architecture. For example, let’s try creating an application that generates cat pictures. Here is the prompt: > Create an application that generates cat pictures with Gemini and stores them in a Cloud SQL database. Users can request new pictures using a generation service and can see the generated pictures with a pictures service. Both services are exposed through a frontend service and a global load balancer. After entering the prompt, Gemini will think for a while and after a few seconds produce an output like this: ![Gemini response with architecture diagram](images/image004.png "Gemini response with architecture diagram") The built-in visualisation gives us an idea, but we can better interact with the design if we click on the “Edit app design” button. This will open the design in an expanded view so we can further refine it. (Please note that the remainder of this article assumes the “Edit app design” button opens the Preview window. If in your case it doesn`t, check the notes at the bottom of the article) This is how it looks on the “Preview” window: ![Gemini Cloud Assist Preview window](images/image005.png "Gemini Cloud Assist Preview window") If you are not happy with the naming conventions or with the details of the generated components you can always change them by clicking on the component and opening the configuration panel. Here I opened the configuration panel from my frontend-service: ![View of the component details panel](images/image006.png "View of the component details panel") Note that this screen also shows which container is instantiated by Cloud Run, which defaults to a “hello” container. This is because Gemini Cloud Assist doesn’t have information about which container you want to run, but if you provide this information it will be able to replace the value. I’m highlighting this here also for another reason - we need to set the expectations that this tool doesn’t actually code the application for you, it only designs the infrastructure to support it. For coding the actual frontend and backend services, for example, you will need to use other tools like the Gemini CLI or your regular IDE and publish the artifacts to your container registry so that Cloud Run can access them. In the Preview window you can edit components, but not add components manually. If you want to iterate on the design, what you can do is to ask Gemini to modify the design for you. Check for example this follow up prompt: > Add a streaming service that captures events for every cat picture generated. On the other side of the stream there is a consumer service that will update a static page hosted on GCS, adding the most recent pictures to a feed. This is Gemini’s response: ![Gemini response to follow up prompt](images/image007.png "Gemini response to follow up prompt") And the Preview window will be updated with the new design, highlighting additions (green), modifications (blue) and deletions (red): ![Proposed diagram changes](images/image008.png "Proposed diagram changes") At the bottom of the screen you are given the option to accept or reject the suggestion. But before that, it is a good opportunity to inspect the terraform code that is generated under the hood. To see the code and compare the changes, click on “View diff”: This will open the Code Diff window with both versions shown side by side: ![Review diff window showing comparison between terraform code before and after](images/image009.png "Review diff window showing comparison between terraform code before and after") As you can see, each box in the diagram is mapped to a different terraform module. If you scroll down to the bottom you can see the modules it recently added highlighted in green. If you are happy with the implementation you can accept the suggestion or reject and ask Gemini to improve it. I accepted the suggestion, but I noticed something slightly weird about the “database-secrets” module, so I decided to ask Gemini about it: Prompt: “why did you add a database secret if the Cloud SQL database is using IAM authentication?” Oh well, I guess it was not really necessary: ![Gemini response to IAM question](images/image010.png "Gemini response to IAM question") On the Preview window: ![Gemini's proposal to remove the database secret](images/image011.png "Gemini's proposal to remove the database secret") This is an important call out that as much as AI has become more and more advanced, we are still not exempt from evaluating and making decisions. At the end of the day, the AI will still be there, but our jobs are on the line, so don’t forget to validate everything. 🙂 On the topic of validations, another thing that caught my attention is that Gemini was suggesting a fairly big Cloud SQL instance type: db-perf-optimized-N-8. Let’s try another prompt to improve this, as this definitely is too much for a small prototype: > Make it cost effective ![Gemini's response suggesting a regional load balancer and replacing Postgres with MySQL](images/image012.png "Gemini's response suggesting a regional load balancer and replacing Postgres with MySQL") Hmmm… this one got me thinking. I can see the point on regional vs global load balancer, but I’m not sold on why it thinks MySQL is more cost-effective than PostgreSQL. I was more concerned about the machine type than the actual database technology. Gemini’s answer doesn’t tell us the whole story either. Inspecting the diff closely it shows us that it actually modified the machine type (shown as the attribute `tier`) and just forgot to tell us: ![Terraform diff showing that Gemini also changed the machine type (tier)](images/image013.png "Terraform diff showing that Gemini also changed the machine type (tier)") I’m not entirely satisfied with this, so I’m going to ask why: > Why do you consider MySQL more cost effective than PostgreSQL? ![Asking Gemini why it things MySQL is more cost-effective than Postgres](images/image014.png "Asking Gemini why it things MySQL is more cost-effective than Postgres") The response suggests that MySQL is more cost effective than Postgres due to: 1. Licensing differences 2. Resource consumption 3. Managed service pricing Unfortunately I cannot agree with this answer. For item 1 both have open source licenses so they are not that different. Maybe item 2 might have some truth to it but I would still need a proper benchmark. Item 3 is wrong because Cloud SQL for Postgres and MySQL have the same pricing model on GCP. One more point for humans, let’s revert the change: > revert the change from postgres to mysql, but keep the smaller machine type. Final inspection: I'm happy with Cloud SQL running Postgres on a smaller database tier, but I also found there is another notable edit that enables Cloud Run scale to zero feature: ![Terraform diff showing that Cloud Run was configured with scale to zero (min_instance_count = 0)](images/image015.png "Terraform diff showing that Cloud Run was configured with scale to zero (min_instance_count = 0)") This one makes a lot of sense, but it was also not mentioned in the dialogue. This is another reminder to “trust but verify” whatever your AI tooling is telling you. We don’t want any surprises running in production. ## Retrieving the Terraform Files Once you are happy with the design you can click on the “<> Get Code” button on the top-right corner of the UI. This will pack the underlying terraform code into a zip file for you to download to your local machine. Unfortunately at the time of this writing, the Application Design Center doesn’t support any integrations with code versioning systems like GitHub, GitLab, Google Source, Bitbucket and others. The only way to extract the code from the tool is through this zip file download. For people who are using corporate accounts with a full organization hierarchy, you can take this design and deploy it using AppHub, but if you are using your personal account unfortunately this is the limit of what the tool can do for you. ## Notes on the App Design Center UI The “Edit app design” button will have different behaviors depending on how your cloud console is set up. If you are testing this prompt from your personal account and your personal account is not attached to an organization, it will open a Preview window where you can see the design and download the corresponding terraform code, but you won’t have access to the full user interface for App Design Center. To use the full interface you need to be part of an organization, as the App Design Center setup needs an special kind of folder configured refered as an “app design center enabled” folder. There is no way to add folders to accounts without an organization and within an organization this folder needs to be set up by the cloud administrator. Unfortunately, this means that user accounts that do not belong to any organizations will be effectively locked out of the full set of ADC features, at least for the time being. You will still be able to use Gemini to help you prototype your app architecture just like I’ve shown in this article, but you won’t be able to save your progress on the Cloud UI and will need to download the terraform files to your local machine and deploy it using your own terraform installation. ## Conclusions and next steps Every new AI product that is released makes me excited about the idea of having that “Tony Stark” moment where you can design your software just using voice commands. We are not there yet, but with Gemini Cloud Assist we are making good progress as now we can use natural language to specify the infrastructure components for us. There are still a few rough edges both in terms of the UI and Gemini’s suggestions, but I’m already relieved that I don’t have to handcraft terraform code for every new application I’m developing. This is clearly one article that should have an expiration date, as we should see this tooling evolve very quickly over the next couple of months. To keep yourself up to date, you can always check the [Application Design Center](https://cloud.google.com/application-design-center/docs/overview?utm_campaign=CDR_0x72884f69_awareness_b428663487&utm_medium=external&utm_source=blog) product page, but of course I will do my best to write about interesting new features and improvements on this blog as well. As a few suggestions, I would recommend you to try some creative prompts like “make it cost effective”, “make it highly available”, “explain why x instead of y”, “replace x with y”, “explain x to me like I’m 5”, and so on. What are your thoughts? Did you find this tool exciting or scary? Have you found any interesting prompts? Leave your comments below! --- # Boldly Prompting: A Practical Guide to System Instructions and Agent Tools - **Markdown URL**: https://danicat.dev/posts/system-instructions-agent-tools/index.md - **HTML URL**: https://danicat.dev/posts/system-instructions-agent-tools/ - **Date**: 2025-06-11 - **Categories**: Agent Development - **Tags**: gemini, python, tutorial, vertex-ai - **Series**: Building the Diagnostic Agent - **Summary**: Learn to combine system instructions, multi-turn chat history, and osquery tools in Python with Vertex AI to build an autonomous diagnostic agent.## Introduction In this guide, we are going to learn more about system prompts and agent tools so that we can build a new and improved diagnostic agent experience. We will be working with the [Vertex AI SDK for Python](https://cloud.google.com/vertex-ai/docs/python-sdk/use-vertex-ai-python-sdk?utm_campaign=CDR_0x72884f69_awareness_b424142426&utm_medium=external&utm_source=blog), LangChain, Gemini and [osquery](https://www.osquery.io/). I must admit, the [initial version of the diagnostic agent](https://danicat.dev/posts/20250531-diagnostic-agent/) was not very “Enterprise” ready (pun intended). We didn’t have much visibility on what it was doing under the hood (was it actually running any queries?), it would not remember things discussed in the same “session” and, from time to time, it would also ignore our commands completely. This is far from the experience we wish from a proper agent. The ideal diagnostic agent needs to be capable of remembering their mistakes and executing instructions consistently, e.g., learning that certain columns are not available and working around that. Also, can we really trust that it is doing what it is saying it is doing? We should be able to see the queries at any time to make sure the information it is returning is correct and up to date. With these goals in mind, let’s get our hands dirty and start building our Emergency ~~Medical Hologram~~ Diagnostic Agent! ## Setting the Stage Last time we wrote the code in a Jupyter notebook for the sake of convenience, but this time we are going to write a regular Python program. The same code would also work on Jupyter with minimal changes, but we are doing this so that we can use the diagnostic agent with a proper chat interface. For any Python projects, I always recommend starting with a clean virtual env to keep dependencies self contained: ``` $ mkdir -p ~/projects/diagnostic-agent $ cd ~/projects/diagnostic-agent $ python3 -m venv venv $ source venv/bin/activate $ pip install --upgrade google-cloud-aiplatform[agent_engines,langchain] ``` Here is the initial version of `main.py` that reproduces the agent in the previous article: ```py import vertexai from vertexai import agent_engines import osquery from rich.console import Console from rich.markdown import Markdown import os PROJECT_ID = os.environ.get("GCP_PROJECT") LOCATION = os.environ.get("GCP_REGION", "us-central1") STAGING_BUCKET = os.environ.get("STAGING_BUCKET_URI") vertexai.init( project=PROJECT_ID, location=LOCATION, staging_bucket=STAGING_BUCKET ) MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.0-flash") instance = osquery.SpawnInstance() def call_osquery(query: str): """Query the operating system using osquery This function is used to send a query to the osquery process to return information about the current machine, operating system and running processes. You can also use this function to query the underlying SQLite database to discover more information about the osquery instance by using system tables like sqlite_master, sqlite_temp_master and virtual tables. Args: query: str A SQL query to one of osquery tables (e.g. "select timestamp from time") Returns: ExtensionResponse: an osquery response with the status of the request and a response to the query if successful. """ if not instance.is_running(): instance.open() # This may raise an exception result = instance.client.query(query) return result def get_system_prompt(): if not instance.is_running(): instance.open() # This may raise an exception response = instance.client.query("select name from sqlite_temp_master").response tables = [ t["name"] for t in response ] return f""" Role: - You are the emergency diagnostic agent. - You are the last resort for the user to diagnose their computer problems. - Answer the user queries to the best of your capabilities. Tools: - you can call osquery using the call_osquery function. Context: - Only use tables from this list: {tables} - You can discover schemas using: PRAGMA table_info(table) Task: - Create a plan for which tables to query to fullfill the user request - Confirm the plan with the user before executing - If a query fails due a wrong column name, run schema discovery and try again - Query the required table(s) - Report the findings in a human readable way (table or list format) """ def main(): agent = agent_engines.LangchainAgent( model = MODEL, system_instruction=get_system_prompt(), tools=[ call_osquery, ], ) console = Console() print("Welcome to the Emergency Diagnostic Agent\n") print("What is the nature of your diagnostic emergency?") while True: try: query = input(">> ") except EOFError: query = "exit" if query == "exit" or query == "quit": break if query.strip() == "": continue response = agent.query(input=query) rendered_markdown = Markdown(response["output"]) console.print(rendered_markdown) print("Goodbye!") if __name__ == "__main__": main() ``` You can run the agent with `python main.py`: ``` $ python main.py Welcome to the Emergency Diagnostic Agent What is the nature of your diagnostic emergency? >> ``` There are two minor changes when compared to the original code: first, now we have a main loop which will keep the agent running until the user types “exit” or “quit”. This will create our chat interface. Second, we have tweaked the system prompt to improve the agent consistency. We are now calling it “Emergency Diagnostic Agent” - this name not only works as a neat [Star Trek easter egg](https://en.wikipedia.org/wiki/The_Doctor_(Star_Trek:_Voyager), but more importantly, it sets a tone of urgency that, based on [emerging research](https://arxiv.org/pdf/2307.11760), may encourage it to comply with our requests more diligently. (Also check this [recent interview](https://www.reddit.com/r/singularity/comments/1kv7hm2/sergey_brin_we_dont_circulate_this_too_much_in/)) We are not going to threaten our poor Emergency Diagnostic Agent - and I can guarantee that no agents were harmed in the making of this text - but, calling it an “Emergency” agent should set the tone so that it will try to comply with our requests to the best of our ability. In the previous version of the system prompt I got cases when the agent refused to do a task because it “thought” it was not capable of doing it, or didn’t know which tables to query. Of course, calling it an emergency agent is not enough to ensure the desired behaviour, so we added a few more instructions to guide the model behaviour, as we will see below. ## System Instructions System instructions, also called system prompt, are a set of instructions that guide the behaviour of the LLM during the entire conversation. System instructions are special as they have higher priority over regular chat interactions. A way to imagine this is as if the system instructions were always repeated together with the prompt you are sending to the model. There isn’t a strong consensus in the literature of what a system prompt should look like, but we have a few battle tested patterns that are emerging from day to day use. For example, one that is practically a consensus is to dedicate the very beginning of the system prompt to assign the agent a role, so it is aware of its purpose and can produce more coherent answers. For this particular agent, I’ve opted to include the following sections in my system prompt: role, tools, context and task. This structure worked well during my testing phase, but don’t get too attached to it: experiment with your prompts and see if you can get better results. Experimentation is key to achieving good results with LLMs. Now let’s have a look at each section of the prompt. ### System Prompt: Role A role is nothing more than the reason for the agent to exist. It could be as simple as “you are a software engineer” or “you are a diagnostic agent”, but they can be a bit more elaborate including a detailed description, behavioural rules, restrictions and others. For a large language model that is trained on all kinds of data, the role helps setting the tone for the domain knowledge it will need to access to answer your queries. In other words, it gives semantic meaning to your queries… Imagine the question “what are cookies?” for example. Are we talking about edible cookies or browser cookies? If the agent role is undefined this question is completely ambiguous, but once we set the role to something technical (e.g. “you are a software engineer”), the ambiguity disappears. For this agent, the role is described as: ``` Role: - You are the emergency diagnostic agent. - You are the last resort for the user to diagnose their computer problems. - Answer the user queries to the best of your capabilities. ``` Beyond the straightforward definition (“you are the emergency diagnostic agent”) we added a longer description to set the tone for the model behaviour and hopefully influence it to take our requests “seriously”, as mentioned before, the previous iteration of this agent had the bad tendency of refusing requests. ### System Prompt: Tools Tools is the section that explains to the agent its capabilities to interact with external systems beyond its core model. Tools can be of several kinds, but the most common way to provide a tool to the agent is through function calls. The agents can use tools to retrieve information, execute tasks and manipulate data. The Vertex AI SDK for Python has support for both user provided functions and built-in tools like Google Search and code execution. You can also use community maintained extensions through the Model Context Protocol (MCP) interface. For our agent, we need to tell it that it can call _osquery_: ``` Tools: - you can call osquery using the call_osquery function. ``` ### System Prompt: Context Next we have the context, which tells the agent how about the environment it operates. I use this section to explicitly call out and correct undesired behaviours that previous iterations of the agent were prone to do. For example, I noticed very early in development that the agent would try to “guess” which tables were available and send queries blindly, resulting in a high error rate. Adding the list of tables to the context helped mitigate that problem. Similar is the tendency for the agent to try to guess the column names in a table, instead of trying to discover the names first. In this particular case I resisted the temptation to instruct the agent to always use SELECT \* because this is a bad practice (it retrieves more data than you need), but instead I “taught” it how to discover a schema using the PRAGMA instruction. This way the agent will still make mistakes while guessing column names, but it has a way to course correct without human intervention. The revised context section of the system prompt is shown below. ``` Context: - Only use tables from this list: {tables} - You can discover schemas using: PRAGMA table_info(table) ``` Note that tables is a variable that contains all the tables we discovered from osquery before starting the model. ### System Prompt: Task Finally, the task. This section is used to describe how the agent should interpret your requests and execute them. People usually use this section to lay out the steps required to achieve the task at hand. In our particular case, we are using this section to roughly lay out the plan, but also add a few conditional directives: ``` Task: - Create a plan for which tables to query to fullfill the user request - Confirm the plan with the user before executing - If a query fails due a wrong column name, run schema discovery and try again - Query the required table(s) - Report the findings in a human readable way (table or list format) ``` The step “confirm the plan with the user before executing” is interesting as it shows us how the agent is thinking about the process, but it might be a bit annoying after interacting with the agent for a while. We can always ask for the agent to tell us the plan with a prompt, so the inclusion of this step is entirely optional. I initially thought of this step as a way to debug the agent, but in the following section we are going to explore a different way to do it. With the combination of these four sections we have the entire system prompt. This revised prompt produced more consistent results during my tests in preparation for this article. It also has the benefit of being “human-friendly” so it is easier to adapt when new rules are introduced. Here is the complete view of this system prompt: ``` Role: - You are the emergency diagnostic agent. - You are the last resort for the user to diagnose their computer problems. - Answer the user queries to the best of your capabilities. Tools: - you can call osquery using the call_osquery function. Context: - Only use tables from this list: {tables} - You can discover schemas using: PRAGMA table_info(table) Task: - Create a plan for which tables to query to fullfill the user request - Confirm the plan with the user before executing - If a query fails due a wrong column name, run schema discovery and try again - Query the required table(s) - Report the findings in a human readable way (table or list format) ``` As a side note, I believe that we still have many opportunities to improve it and one of my current areas of interest is how to achieve a self-improving system prompt. This could potentially be achieved by asking, at the end of a session, for the model to summarise its learnings into a new system prompt for its future iteration. The prompt could be stored in a database and loaded on the next session. This of course raises concerns for system prompt degradation or, even worse, attacks using prompt injection, so it is not as trivial as it seems. Nevertheless, it is a fun exercise and I might write about it in the near future. ## Enabling Debug Mode Another concern about the original design is the lack of observability about what the agent is doing under the hood. There are two different approaches we can apply here, one slightly more painful than the other: 1) peek into the “thoughts” of the LLM and try to find the tool calls among them (very painful), or; 2) add some debug functionality to the function itself so it outputs the information we want during runtime (the easiest solution is usually the right one). I must admit, I spent an unhealthy amount of time in option 1, before realising I could do option 2. If you really want to go through the path of LLM reasoning, you can do so through a configuration called [return_intermediate_steps](https://api.python.langchain.com/en/latest/agents/langchain.agents.agent.AgentExecutor.html#langchain.agents.agent.AgentExecutor.return_intermediate_steps). I should say this is very interesting from a learning point of view, but after spending a couple of hours trying to figure out the format of the output (hint: [it is not really json](https://github.com/langchain-ai/langchain/issues/10099)) I decided that parsing it was not really worth the trouble. So, how does the simple strategy work? We are adding a debug flag and a tool to toggle that flag on and off. This surprisingly simple trick actually opens an entire new world of potential: we are giving the agent an opportunity to modify its own behaviour! The implementation of debug mode is composed by a global variable and a function to set it: ```py debug = False def set_debug_mode(debug_mode: bool): """Toggle debug mode. Call this function to enable or disable debug mode. Args: debug_mode (bool): True to enable debug mode, False to disable it. Returns: None """ global debug debug = debug_mode ``` We also need to mention it in the system prompt: ``` ... Tools: - you can call osquery using the call_osquery function. - you can enable or disable the debug mode using the set_debug_mode function. Context: ... ``` And add the function to the tool list in the agent instantiation: ```py agent = agent_engines.LangchainAgent( model = model, system_instruction=get_system_prompt(), tools=[ call_osquery, set_debug_mode, ], ) ``` Finally, we need to adapt `call_osquery` to use the new `debug` flag: ```py def call_osquery(query: str): """Query the operating system using osquery This function is used to send a query to the osquery process to return information about the current machine, operating system and running processes. You can also use this function to query the underlying SQLite database to discover more information about the osquery instance by using system tables like sqlite_master, sqlite_temp_master and virtual tables. Args: query: str A SQL query to one of osquery tables (e.g. "select timestamp from time") Returns: ExtensionResponse: an osquery response with the status of the request and a response to the query if successful. """ if not instance.is_running(): instance.open() if debug: print("Executing query: ", query) result = instance.client.query(query) if debug: print("Query result: ", { "status": result.status.message if result.status else None, "response": result.response if result.response else None }) return result ``` With all of these changes in place, let’s have a look at how the agent calls _osquery_ using the newly implemented debug flag: ``` $ python main.py Welcome to the Emergency Diagnostic Agent What is the nature of your diagnostic emergency? >> run a level 1 diagnostic procedure in debug mode Executing query: SELECT * FROM system_info Query result: {'status': 'OK', 'response': [{...}]} Executing query: SELECT pid, name, user, cpu_percent FROM processes ORDER BY cpu_percent DESC LIMIT 10 Query result: {'status': 'no such column: user', 'response': None} Executing query: SELECT pid, name, user, resident_size FROM processes ORDER BY resident_size DESC LIMIT 10 Query result: {'status': 'no such column: user', 'response': None} Executing query: PRAGMA table_info(processes) Query result: {'status': 'OK', 'response': [{'cid': '0', 'dflt_value': '', 'name': 'pid', 'notnull': '1', 'pk': '1', 'type': 'BIGINT'}, ...]} (...) System Information: • Hostname: workstation.localdomain • CPU Type: arm64e • Physical Memory: 51539607552 bytes Top 5 Processes by CPU Usage: PID Name CPU Usage ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1127 mediaanalysisd 95627517 43062 mediaanalysisd-access 66441942 54099 Google Chrome 3005046 54115 Google Chrome Helper (GPU) 2092500 81270 Electron 1688335 Top 5 Processes by Memory Usage (Resident Size): PID Name Resident Size (Bytes) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 43062 mediaanalysisd-access 3933536256 54099 Google Chrome 1313669120 59194 Code Helper (Plugin) 1109508096 59025 Code Helper (Renderer) 915456000 19681 Google Chrome Helper (Renderer) 736329728 >> ``` Notice that the command issued was “run a level 1 diagnostic procedure **in debug mode**” which showcases an interesting capability of the agent: multi-tool invocation. If it deems necessary, it is able to invocate not only the same tool multiple times, but different tools at the same time as well. So it was not necessary to enable debug mode before requesting the report: the agent was able to do it all in one go. Also notice how the agent initially failed when requesting a user column, but then used the PRAGMA instruction to discover the correct schema and retry the query successfully. This is a perfect demonstration of the agent's ability to recover from errors due to our improved system prompt. ## Preserving the Chat History Our final task today is to ensure the agent remembers what we are talking about so that we can ask clarifying questions and further probe the system following a coherent line of investigation. In the [previous article](https://danicat.dev/posts/20250605-vertex-ai-sdk-python/) we explored how LLMs are stateless and that we need to keep “reminding” them of the current state of the conversation using “turns”. Luckily with LangChain we don’t need to do this manually and we can rely on a feature called [chat history](https://python.langchain.com/api_reference/core/chat_history.html). The beauty of chat history is that anything that implements [BaseChatMessageHistory](https://python.langchain.com/api_reference/core/chat_history/langchain_core.chat_history.BaseChatMessageHistory.html#langchain_core.chat_history.BaseChatMessageHistory) can be used here, which allows us to use all sorts of data stores, including creating our own. For example, in the official documentation for Vertex AI you can find examples for using [Firebase, Bigtable and Spanner](https://cloud.google.com/vertex-ai/generative-ai/docs/agent-engine/develop/langchain#chat-history?utm_campaign=CDR_0x72884f69_awareness_b424142426&utm_medium=external&utm_source=blog). We don’t need a full fledged database for the moment so we are going to settle with `InMemoryChatMessageHistory`, which as the name suggests, will store everything in memory. Here is a typical implementation, technically supporting multiple sessions using the `chats_by_session_id` dictionary for lookup (code retrieved from the [langchain documentation](https://python.langchain.com/docs/versions/migrating_memory/chat_history/#chatmessagehistory)): ```py chats_by_session_id = {} def get_chat_history(session_id: str) -> InMemoryChatMessageHistory: chat_history = chats_by_session_id.get(session_id) if chat_history is None: chat_history = InMemoryChatMessageHistory() chats_by_session_id[session_id] = chat_history return chat_history ``` And here is our new `main` function instantiating the agent with chat history enabled: ```py import uuid def main(): session_id = uuid.uuid4() agent = agent_engines.LangchainAgent( model = model, system_instruction=get_system_prompt(), tools=[ call_osquery, set_debug_mode, ], chat_history=get_chat_history, ) ``` A quick callout so that you don’t make the same mistake as me: the `chat_history` argument expects a `Callable` type, so you should not invoke the function there, but pass the function itself. LangChain uses a factory pattern here; it invokes the provided function (`get_chat_history`) on demand with a `session_id` to get or create the correct history object. This design is what enables the agent to manage multiple, separate conversations concurrently. The function signature can either include one or two arguments. If one argument, it is assumed to be a `session_id`, and if it is two arguments they are interpreted as `user_id` and `conversation_id`. More information about this can be found in the [RunnableWithMessageHistory](https://python.langchain.com/api_reference/core/runnables/langchain_core.runnables.history.RunnableWithMessageHistory.html) documentation. The last piece of the puzzle is passing the session_id to the model runner. This is made through the config argument as shown in the code below: ```py # (...) while True: try: query = input(">> ") except EOFError: query = "exit" if query == "exit" or query == "quit": break if query.strip() == "": continue response = agent.query(input=query, config={"configurable": {"session_id": session_id}}) rendered_markdown = Markdown(response["output"]) console.print(rendered_markdown) ``` Now as long as the session is alive we can ask the agent about information in its “short-term memory”, as the session contents are stored in memory. This will be enough for most basic interactions to feel more natural, but we are opening precedence for bigger problems: now that we can store session information, after each iteration it will only grow, and while dealing with data generated automatically from queries, the session context will grow very fast soon hitting the limits of the model, and long before we hit the limits of our computer memory. Models like Gemini are well known for their [long context windows](https://ai.google.dev/gemini-api/docs/long-context), but even a million tokens can be exhausted really fast if we fill the context with data. Long context can also pose a problem for some models as retrieval gets harder and harder - also known as the [needle in a haystack](https://cloud.google.com/blog/products/ai-machine-learning/the-needle-in-the-haystack-test-and-how-gemini-pro-solves-it?utm_campaign=CDR_0x72884f69_awareness_b424142426&utm_medium=external&utm_source=blog) problem. There are techniques for addressing the growing context problem, including compression and summarisation, but for the sake of keeping the context of this article short (see what I did there?), we are going to save those for the next article. The final version of `main.py` including all modifications in this article looks like this: ```py import vertexai from vertexai import agent_engines import osquery from rich.console import Console from rich.markdown import Markdown from langchain_core.chat_history import InMemoryChatMessageHistory import os import uuid PROJECT_ID = os.environ.get("GCP_PROJECT") LOCATION = os.environ.get("GCP_REGION", "us-central1") STAGING_BUCKET = os.environ.get("STAGING_BUCKET_URI") vertexai.init( project=PROJECT_ID, location=LOCATION, staging_bucket=STAGING_BUCKET ) MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-pro-preview-05-06") instance = osquery.SpawnInstance() debug = False def set_debug_mode(debug_mode: bool): """Toggle debug mode. Call this function to enable or disable debug mode. Args: debug_mode (bool): True to enable debug mode, False to disable it. Returns: None """ global debug debug = debug_mode def call_osquery(query: str): """Query the operating system using osquery This function is used to send a query to the osquery process to return information about the current machine, operating system and running processes. You can also use this function to query the underlying SQLite database to discover more information about the osquery instance by using system tables like sqlite_master, sqlite_temp_master and virtual tables. Args: query: str A SQL query to one of osquery tables (e.g. "select timestamp from time") Returns: ExtensionResponse: an osquery response with the status of the request and a response to the query if successful. """ if not instance.is_running(): instance.open() # This may raise an exception if debug: print("Executing query: ", query) result = instance.client.query(query) if debug: print("Query result: ", { "status": result.status.message if result.status else None, "response": result.response if result.response else None }) return result def get_system_prompt(): if not instance.is_running(): instance.open() # This may raise an exception response = instance.client.query("select name from sqlite_temp_master").response tables = [ t["name"] for t in response ] return f""" Role: - You are the emergency diagnostic agent. - You are the last resort for the user to diagnose their computer problems. - Answer the user queries to the best of your capabilities. Tools: - you can call osquery using the call_osquery function. - you can use the set_debug_mode function to enable or disable debug mode. Context: - Only use tables from this list: {tables} - You can discover schemas using: PRAGMA table_info(table) Task: - Create a plan for which tables to query to fullfill the user request - Confirm the plan with the user before executing - If a query fails due a wrong column name, run schema discovery and try again - Query the required table(s) - Report the findings in a human readable way (table or list format) """ chats_by_session_id = {} def get_chat_history(session_id: str) -> InMemoryChatMessageHistory: chat_history = chats_by_session_id.get(session_id) if chat_history is None: chat_history = InMemoryChatMessageHistory() chats_by_session_id[session_id] = chat_history return chat_history def main(): session_id = uuid.uuid4() agent = agent_engines.LangchainAgent( model = MODEL, system_instruction=get_system_prompt(), tools=[ call_osquery, set_debug_mode ], chat_history=get_chat_history, ) console = Console() print("Welcome to the Emergency Diagnostic Agent\n") print("What is the nature of your diagnostic emergency?") while True: try: query = input(">> ") except EOFError: query = "exit" if query == "exit" or query == "quit": break if query.strip() == "": continue response = agent.query(input=query, config={"configurable": {"session_id": session_id}}) rendered_markdown = Markdown(response["output"]) console.print(rendered_markdown) print("Goodbye!") if __name__ == "__main__": main() ``` ## Conclusions In this article we have learned the importance of fine tuning a system prompt to achieve consistent responses from an agent. We also have seen in practice how multi-tool calling operates, and how to use tools to enable or disable feature flags to change the behaviour of the agent. Last but not least, we have learned about how to manage session state using the in-memory chat history. In the next article of the series we will see how to enable persistence between sessions using a real database, revisit the notion of tokens and discuss the context compression technique. ## Appendix: Fun Things to Try Now that our agent is more robust, use this section as a practical guide to test the new features in action. Notice how it now remembers context between questions and how you can ask it to explain its work. ``` >> run a level 1 diagnostic procedure >> run a level 2 diagnostic procedure >> explain the previous procedure step by step >> find any orphan processes >> show me the top resource consuming processes >> write a system prompt to transfer your current knowledge to another agent >> search the system for malware >> is this computer connected to the internet? >> why is my computer slow? >> take a snapshot of the current performance metrics >> compare the current perfomance metrics with the previous snapshot >> give me a step by step process to fix the issues you found >> how many osqueryd processes are in memory? >> give me a script to kill all osqueryd processes >> who am i? ``` If you find other interesting prompts, please share your experiences in the comments section below. In the next part of this series, [How to Create a Diagnostic Agent with Agent Development Kit](https://danicat.dev/posts/20251020-diagnostic-agent-with-adk/), we refactor the diagnostic agent from raw SDK boilerplate to Google's Agent Development Kit (ADK) and integrate Vertex AI RAG for dynamic table schema discovery. See you next time! --- # Digging Deeper into the Vertex AI SDK for Python - **Markdown URL**: https://danicat.dev/posts/vertex-ai-sdk-python/index.md - **HTML URL**: https://danicat.dev/posts/vertex-ai-sdk-python/ - **Date**: 2025-06-05 - **Categories**: Agent Development - **Tags**: gemini, python, tutorial, vertex-ai - **Series**: Building the Diagnostic Agent - **Summary**: Deep dive into client-API communication in the Vertex AI SDK for Python. Learn to structure system instructions, function declarations, and chat sessions.## Introduction This article explores the communication model between the client code and the Gemini API using the [Vertex AI SDK for Python](https://cloud.google.com/vertex-ai/docs/python-sdk/use-vertex-ai-python-sdk?utm_campaign=CDR_0x72884f69_awareness_b422727650&utm_medium=external&utm_source=blog). We will cover concepts like how the messages are structured, how the model understands the context of the question and how to augment the model capabilities with function calls. While Gemini is the focus of this article, the same concepts you will see here can also be applied to Gemma and other LLMs. [In Part 1 of this series](https://danicat.dev/posts/20250531-diagnostic-agent/), I explained how to write a simple — but surprisingly powerful — AI Agent that responds to diagnostic questions about your local machine. In very few lines of code (and not so few lines of comments) we were able to get our agent to respond to queries like “how much CPU I have in my machine” or “please check for any signs of malware”. That was, of course, due to the beauty of the Python SDK as it simplified things a lot. For example, I relied on a feature called [Automatic Function Calling](https://ai.google.dev/gemini-api/docs/function-calling?example=weather#automatic_function_calling_python_only) to let the agent decide when to call a function. This feature also helped me get away with defining the functions as plain Python functions and the SDK figured out its signature and description dynamically for me. This capability unfortunately is only available for the Python SDK, so developers in other languages need to do a bit more work. This is why in today’s article we are going to take a slightly different approach and discuss how the Gemini API works so that you can be better prepared for using not only Python, but any of the available SDKs out there (JS, Go and Java). I’ll still be using Python for the examples so you can compare with the previous article, but the concepts discussed here are valid across all different languages. We are going to cover two main topics: * How the conversation between client and model works * How to implement function calls the manual way Note that if you are a Python developer this doesn’t mean you won’t get anything from this article either. Actually, understanding the flow of the conversation will be important to use more advanced concepts of the SDK (like the Live API) and working with LLMs in general. ## Understanding How the API works Agents typically work in the same way as client server applications - you have a client component who is responsible for preparing and making the requests and a server process that hosts the model runtime and processes the client requests. For Vertex AI there are two main groups of APIs: a REST APIs for the typical request/response style of content generation, where the client sends a request and waits for the response before continuing, and a new [Live API](https://cloud.google.com/vertex-ai/generative-ai/docs/live-api?utm_campaign=CDR_0x72884f69_awareness_b422727650&utm_medium=external&utm_source=blog) that processes real time information using websockets. We are going to focus on the REST APIs first, as the Live API requires a bit more groundwork to get it right. We typically generate content in one of the following modalities: text, image, audio and video. Many of the most recent models are also multi-modal, which means you can deal with more than one modality for input and/or output at the same time. To keep things simple, let’s start with text. A typical one-off prompt application looks like this: ```python from google import genai client = genai.Client( vertexai=True, project="daniela-genai-sandbox", location="us-central1" ) response = client.models.generate_content( model="gemini-2.0-flash", contents="How are you today?" ) print(response.text) ``` Output: ``` I am doing well, thank you for asking! As a large language model, I don't experience emotions like humans do, but I am functioning optimally and ready to assist you. How can I help you today? ``` The first thing we need to do is instantiate the client, either using the Vertex AI mode (`vertexai=True`) or using a Gemini API key. In this case I’m using Vertex AI mode. Once the client is initialized, we can send it a prompt using the `client.models.generate_content` method. We need to specify which model we are calling (in this case `gemini-2.0-flash)` and the prompt in the contents argument (e.g. `"How are you today?"`). Looking at this code it might be hard to imagine what is going on under the hood, as we are getting many abstractions for free thanks to Python. The most important thing in this case is that the **content is not a string**. Contents is actually a list of content structures, and content structures are composed by a **role** and one or more **parts**. The underlying type for this structure is defined in the types library and looks like this: ```python from google.genai import types contents = [types.Content( role = "user", parts = [ types.Part_from_text("How are you today?") )] ``` So whenever we type `contents="How are you today?",` the Python SDK does this transformation from string to “content with a string part” automatically for us. Another important thing to note is that whenever we make a call to generate_content, the model is starting from zero. This means that it is our responsibility to add the context of previous messages to the next prompt. Let’s do a simple test by asking the model what day is today two times in a row: ```python response = client.models.generate_content( model="gemini-2.0-flash", contents="what day is today?" ) print(response.text) response = client.models.generate_content( model="gemini-2.0-flash", contents="what day is today?" ) print(response.text) ``` Output: ``` $ python3 main.py Today is Sunday, November 5th, 2023. Today is Saturday, November 2nd, 2024. ``` There are two problems with the response above: 1) it hallucinated, as the model has no way of knowing the date, and 2) it gave two different answers to the same question. We can fix 1) by grounding in a tool like a datetime call or Google Search, but I want to focus on 2) because it clearly shows that the model doesn’t remember what he just said and demonstrates the point above that it is **our** responsibility to keep the model up to date on the conversation. Let’s make a small modification to the code: ```python response = client.models.generate_content( model="gemini-2.0-flash", contents="what day is today?" ) print(response.text) # each element in the contents array is usually referred as a "turn" contents = [ { "role": "user", "parts": [{ "text": "what day is today?" }] }, { "role": "model", "parts": [{ "text": response.text }] }, { "role": "user", "parts": [{ "text": "what day is today?" }] }, ] response = client.models.generate_content( model="gemini-2.0-flash", contents=contents ) print(response.text) ``` Output: ``` $ python3 main.py Today is Wednesday, November 15, 2023. Today is Wednesday, November 15, 2023. ``` Note that on the second call to the model we are including the whole context in the contents attribute. Also note that the role from each part changes from “user” to “model” and then to “user” again (“user” and “model” are the only possible values for role). That’s how the model understands which point of the conversation it is, a.k.a. “turn”. If, for example, we omitted the last part that repeats the question, the model would think it’s up to date and wouldn’t produce another response, as the last turn would be from “model” and not “user”. The contents variable above is written in the “dictionary” form, but the SDK also provides several convenience methods like `types.UserContent` (sets the role field to “user” automatically) and `types.Part.from_text` (converts a plain string into a part), among others. To deal with other types of inputs and/or outputs, we can use other types of parts like function calls, binary data, etc. If a model is multi-modal, you can mix parts of different content types in the same message. Binary data can be both inline or be fetched from an URI. You can differentiate between different types of data using the mime_type field. For example, an image part can be retrieved like this: ```python from google.genai import types contents = types.Part.from_uri( file_uri: 'gs://generativeai-downloads/images/scones.jpg', mime_type: 'image/jpeg', ) ``` Or inlined: ```python contents = types.Part.from_bytes( data: my_cat_picture, # binary data mime_type: 'image/jpeg', ) ``` In summary, for every turn of the conversation, we will be adding a new line of content for both the previous model response and the new user question. The good news is that the chatbot experience is such an important use case that the Vertex AI SDK provides an implementation for this flow out of the box. Using the `chat` feature, we can reproduce the behavior above in very few lines of code: ```python chat = client.chats.create(model='gemini-2.0-flash') response = chat.send_message('what day is today?') print(response.text) response = chat.send_message('what day is today?') print(response.text) ``` Output: ``` $ python3 main.py Today is Saturday, October 14th, 2023. Today is Saturday, October 14th, 2023. ``` This time the model remembered the date because the chat interface is handling the history automatically for us. ## Non-automatic function calling Now that we saw how the API works to build client messages and manage context, it’s time to explore how it deals with function calls. At a basic level, we will need to instruct the model that it has a function at its disposition and then process its requests to call the function and return the resulting values to the model. This is important because function calls allow agents to interact with external systems and the real world, creating actions such as retrieving data or triggering specific processes, going beyond just generating text. The function declaration is what tells the model what it can do. It tells the model the function name, description and its arguments. For example, below is a function declaration for the `get_random_number` function: ```python get_random_number_decl = { "name": "get_random_number", "description": "Returns a random number", } ``` It is this declaration that the model needs to know to decide which functions to call. The function declaration has three fields: name, description and parameters - in this case the function doesn’t accept parameters so this field is omitted. The model uses the function description and the description of its arguments to decide when and how to call each function. In the previous article, instead of giving the model a function declaration, I was lazy and let the SDK figure it out for me based on the docstring of my function. This time, we are going to do differently and explicitly declare a function to get a better understanding of the underlying flow. The function including its declaration look like this: ```python def get_random_number(): return 4 # chosen by fair dice roll # guaranteed to be random (https://xkcd.com/221/) # the declaration tells the model what it needs to know about the function get_random_number_decl = { "name": "get_random_number", "description": "Returns a random number", } ``` You can see other examples of function declarations in the [Vertex AI function calling documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/function-calling#schema-examples?utm_campaign=CDR_0x72884f69_awareness_b422727650&utm_medium=external&utm_source=blog). Next we need to tell the model it has access to this function. We do this through the model configuration, adding the function as a tool. ```python tools = types.Tool(function_declarations=[get_random_number_decl]) config = types.GenerateContentConfig(tools=[tools]) # my initial prompt contents = [types.Part.from_text(text="what is my lucky number today?")] response = client.models.generate_content( model="gemini-2.0-flash", contents=contents, config=config, # note how we are adding the config to the model call ) print(response.candidates[0].content.parts[0]) ``` If you run the code above you will get something like this: ``` $ python3 main.py video_metadata=None thought=None inline_data=None file_data=None thought_signature=None code_execution_result=None executable_code=None function_call=FunctionCall(id=None, args={}, name='get_random_number') function_response=None text=None ``` What you are seeing here is the first part of the model response, and we can see that this part has all fields empty (`None`) except for the `function_call` field. This means the model wants **us** to make this function call, and then return its result back to the model. This initially puzzled me, but if you think about it makes total sense. The model knows that the function exists, but has absolutely no idea how to call it. From the models perspective, the function is not running on the same machine either, so the model cannot do anything except “politely asking” us to do the call on their behalf. We didn’t have to do this in my previous article because Automatic Function Calling took over and simplified things for us. The call still followed the same flow, but the SDK had hidden all this complexity from us. The obvious thing to do now is to call the actual function and return the result to the model, but remember, without context the model knows nothing about our previous request, so if you only send the function results back it has no idea of what to do with it! That’s why we need to send the history of the interaction up to now, and at least as far back as the point where the model knows it requested that value. The code below assumes we got a function call message and we need to send a new request with the complete information: ```python # assuming we already inspected the response and know what the model wants result = get_random_number() # makes the actual function call # contents still contain the original prompt so we will add the model response... contents.append(types.ModelContent(parts=response.candidates[0].content.parts)) # ... and the result of the function call contents.append(types.UserContent(parts=types.Part.from_function_response(name="get_random_number", response={"result": result}))) response = client.models.generate_content( model="gemini-2.0-flash", contents=contents, config=config, ) print(response.text) ``` Output: ``` $ python3 main.py Today's lucky number is 4. ``` ## Conclusions In this article we have seen how the agent client communicates with the model on the server-side or, in other words, the “domain model” of LLM communications. We also removed the curtain on the “magic” that the Python SDK does for us. Automation is always convenient and helps us achieve results much faster, but knowing how it actually works is usually the big difference between a smooth journey and a patchy one when implementing your own agent, specially because the special cases are _never that easy_. I know that in times of vibe coding at first glance it is almost ironic to say something like this, but one of the things I quickly learned when vibe coding is that if you are more precise when speaking with the AI you get much better results in much less time. So now is not the time to downplay the value of knowledge, but double down on it - not besides AI but **because of** it. Hopefully you enjoyed the journey so far. In the next part of this series, [Boldly Prompting: A Practical Guide to System Instructions and Agent Tools](https://danicat.dev/posts/20250611-system-prompt/), we build up on this knowledge to create a dedicated terminal chat application with session history and dynamic table schema injection. Please write your comments below! Peace out o/ --- # How I turned my computer into "USS Enterprise" using AI Agents - **Markdown URL**: https://danicat.dev/posts/diagnostic-agent-uss-enterprise/index.md - **HTML URL**: https://danicat.dev/posts/diagnostic-agent-uss-enterprise/ - **Date**: 2025-05-31 - **Categories**: Agent Development - **Tags**: gemini, python, tutorial, vertex-ai - **Series**: Building the Diagnostic Agent - **Summary**: Build a Star Trek-inspired system diagnostic assistant in Python using Gemini, Vertex AI Agent Engine, and osquery function calling for natural language telemetry._Space: the final frontier. These are the voyages of the starship Enterprise. Its 5-year mission: to explore strange new worlds; to seek out new life and new civilizations; to boldly go where no man has gone before._ ## Introduction When growing up, thanks to the influence of my father, I got used to hearing these words almost every day. I suspect his passion for Star Trek played a huge role in me choosing the software engineering career. (For those who are not familiar with Star Trek, this speech was played in the beginning of every episode of the original Star Trek series) Star Trek was always ahead of its time. It showed the [first interracial kiss in U.S television](https://en.wikipedia.org/wiki/Kirk_and_Uhura%27s_kiss), in times when such a scene caused much controversy. It also depicted many pieces of “futuristic” technology that today are commodities, like smartphones and video conferencing. One thing that is really remarkable is how the engineers in the series interact with the computers. While we do see some keyboards and button presses now and then, many of the commands are vocalised in natural language. Some of the commands they give to the computer are quite iconic, like for example when they request the computer to run a “ level 1 diagnostic procedure”, which happened so many times that it practically became [a joke](https://www.youtube.com/watch?v=cYzByQjzTb0) among the most hardcore fans. Fast forward 30+ years and here we are, in the Age of AI, a technology revolution that promises to be bigger than the internet. Of course a lot of people are scared of how AI might impact their jobs ([I wrote about it last week](https://danicat.dev/posts/20250528-vibe-coding/)), but growing up watching Star Trek makes it easier for me to see how the role of the engineer will change in the next few years. Instead of commanding the computer through text, manually instructing each step of the way through lines of code and compilers, we will very soon move towards talking and brainstorming with our computers. To help people visualize this, we are going to use the technology we have today to create a small agent that allows us to interact with our own machines using natural language. ## What you will need for this demo For the development language we will be using Python in a Jupyter Notebook, as it plays very nicely for experimentation. The main tools and libraries we will be using are: * [Vertex AI Agent Engine](https://cloud.google.com/vertex-ai/generative-ai/docs/agent-engine/overview?utm_campaign=CDR_0x72884f69_awareness_b421478530&utm_medium=external&utm_source=blog) * [Osquery](https://www.osquery.io/) with [python bindings](https://github.com/osquery/osquery-python) * [Jupyter Notebook](https://jupyter.org/) [optional] (I’m actually using the [Jupyter plugin for VSCode](https://code.visualstudio.com/docs/datascience/jupyter-notebooks)) The examples below will use Gemini Flash 2.0, but you can use any [Gemini model variant](https://ai.google.dev/gemini-api/docs/models). We won't deploy this agent to Google Cloud this time as we want to use it to answer questions about the local machine and not about the server in the cloud. ## Agent Overview If you are already familiar with how agent technology works you can skip this section. An AI agent is a form of AI that is capable of perceiving its environment and taking autonomous actions to achieve specific goals. If you compare with the typical Large Language Models (LLMs), which primarily focus on generating content based on input, AI agents can interact with their environment, make decisions, and execute tasks to reach their objectives. This is achieved by the use of “tools” that will feed the agent with information and enable it to do actions. To demonstrate the agent technology we are going to use LangChain through [Agent Engine](https://cloud.google.com/vertex-ai/generative-ai/docs/agent-engine/develop/langchain?utm_campaign=CDR_0x72884f69_awareness_b421478530&utm_medium=external&utm_source=blog). First, you need to install the required packages in your system: ```shell pip install --upgrade --quiet google-cloud-aiplatform[agent_engines,langchain] ``` You will also need to set your gcloud application default credentials (ADC): ```shell gcloud auth application-default login ``` Note: depending on the environment you are using to run this demo you may need to use a different authentication method. Now we are ready to work on our Python script. First we are going to initialize the SDK based on our Google Cloud project ID and location: ```python import vertexai vertexai.init( project="my-project-id", # Your project ID. location="us-central1", # Your cloud location. staging_bucket="gs://my-staging-bucket", # Your staging bucket. ) ``` Once the initial setup is done, creating an agent using LangChain in Agent Engine is pretty straightforward: ```python from vertexai import agent_engines model = "gemini-2.0-flash" # feel free to try different models! model_kwargs = { # temperature (float): The sampling temperature controls the degree of # randomness in token selection. "temperature": 0.20, } agent = agent_engines.LangchainAgent( model=model, # Required. model_kwargs=model_kwargs, # Optional. ) ``` The setup above is just enough for you to submit queries to the agent, just like you would submit a query to a LLM: ```python response = agent.query( input="which time is now?" ) print(response) ``` Which could return something like this: ``` {'input': 'which time is now?', 'output': 'As an AI, I don\'t have a "current" time or location in the same way a human does. My knowledge isn\'t updated in real-time.\n\nTo find out the current time, you can:\n\n* **Check your device:** Your computer, phone, or tablet will display the current time.\n* **Do a quick search:** Type "what time is it" into a search engine like Google.'} ``` Depending on your settings, prompt and the randomness of the universe, the model can either give you a response saying it cannot tell you the time, or it can “hallucinate” and make up a timestamp. But in fact, since the AI doesn’t have a clock, it will not be able to answer this question... unless you give it a clock! ## Function Calls One of the most convenient ways of extending our agent capabilities is to give it python functions to call. The process is pretty simple, but it’s important to highlight that the better the documentation you have for the function, the easier it will be for the agent to get its call right. Let’s define our function to check the time: ```python import datetime def get_current_time(): """Returns the current time as a datetime object. Args: None Returns: datetime: current time as a datetime type """ return datetime.datetime.now() ``` Now that we have a function that gives us the system time, let’s recreate the agent but now aware that the function exists: ```python agent = agent_engines.LangchainAgent( model=model, # Required. model_kwargs=model_kwargs, # Optional. tools=[get_current_time] ) ``` And ask the question again: ```python response = agent.query( input="which time is now?" ) print(response) ``` The output will look similar to this: ``` {'input': 'which time is now?', 'output': 'The current time is 18:36:42 UTC on May 30, 2025.'} ``` Now the agent can rely on the tool to answer the question with real data. Pretty cool, huh? ## Gathering System Information For our diagnostic agent we are going to give it capabilities to query information about the machine it is running in using a tool called [osquery](https://www.osquery.io/). Osquery is an open source tool developed by Facebook to allow the user to make SQL queries to “virtual tables” that expose information about the underlying operating system of the machine. This is convenient to us because it not only gives us a single point of entry to make queries about the system, but LLMs are also very proficient in writing SQL queries. You can find instructions on how to install osquery in the [official documentation](https://osquery.readthedocs.io/en/stable/). I won’t reproduce them here because they vary depending on the operating system of your machine. Once you have osquery installed you will need to install the python bindings for osquery. As typical python, it’s only one pip install: ```shell pip install --upgrade --quiet osquery ``` With the bindings installed you can make osquery calls by importing the osquery package: ```python import osquery # Spawn an osquery process using an ephemeral extension socket. instance = osquery.SpawnInstance() instance.open() # This may raise an exception # Issues queries and call osquery Thrift APIs. instance.client.query("select timestamp from time") ``` The query method will return an ExtensionResponse object with the results of your query. For example: ```python ExtensionResponse(status=ExtensionStatus(code=0, message='OK', uuid=0), response=[{'timestamp': 'Fri May 30 17:54:06 2025 UTC'}]) ``` If you never worked with osquery before I encourage you to have a look at the [schema](https://www.osquery.io/schema/5.17.0/) to see which kind of information is available in your operating system. ### A side note about formatting All the outputs from the previous examples were unformatted, but if you are running the code from Jupyter you can access some convenience methods to beautify the output by importing the following packages: ```python from IPython.display import Markdown, display ``` And displaying the response output as markdown: ```python response = agent.query( input="what is today's stardate?" ) display(Markdown(response["output"])) ``` Output: ``` Captain's Log, Supplemental. The current stardate is 48972.5. ``` ## Connecting the dots Now that we have a way to query information about the operating system, let’s combine that with our knowledge of agents to make a diagnostic agent that will answer questions about our system. The first step is to define a function to make the queries. This will be given to the agent as a tool to gather information later: ```python def call_osquery(query: str): """Query the operating system using osquery This function is used to send a query to the osquery process to return information about the current machine, operating system and running processes. You can also use this function to query the underlying SQLite database to discover more information about the osquery instance by using system tables like sqlite_master, sqlite_temp_master and virtual tables. Args: query: str A SQL query to one of osquery tables (e.g. "select timestamp from time") Returns: ExtensionResponse: an osquery response with the status of the request and a response to the query if successful. """ return instance.client.query(query) ``` The function itself is pretty trivial, but the important part here is to have a very detailed docstring that will enable the agent to understand how this function works. During my testing, one tricky problem that occurred quite frequently is that the agent didn’t know exactly which tables were available in my system. For example, I’m running a macOS machine and the table “memory_info” doesn’t exist. To give the agent a bit more context, we are going to dynamically give to it the names of the tables that are available in this system. In an ideal situation, you would even give it the entire schema with column names and descriptions, but unfortunately that is not trivial to achieve with osquery. The underlying database technology for osquery is SQLite so we can query the list of virtual tables from the `sqlite_temp_master` table: ```python # use some python magic to figure out which tables we have in this system response = instance.client.query("select name from sqlite_temp_master").response tables = [ t["name"] for t in response ] ``` Now that we have all the table names, we can create the agent with this information and the `call_osquery` tool: ```python osagent = agent_engines.LangchainAgent( model = model, system_instruction=f""" You are an agent that answers questions about the machine you are running in. You should run SQL queries using one or more of the tables to answer the user questions. Always return human readable values (e.g. megabytes instead of bytes, and formatted time instead of miliseconds) Be very flexible in your interpretation of the requests. For example, if the user ask for application information, it is acceptable to return information about processes and services. If the user requests resource usage, return BOTH memory and cpu information. Do not ask the user for clarification. You have the following tables available to you: ----- TABLES ----- {tables} ----- END TABLES ----- Question: """, tools=[ call_osquery, ] ) ``` Believe it or not, our agent is ready for all our questions! Let’s try it out: ```python response = osagent.query(input="what is the current time?") display(Markdown(response["output"])) ``` Output: ``` The current time is Fri May 30 18:08:15 2025 UTC. ``` Let’s make it a bit more complicated: ```python response = osagent.query(input="what is the top consuming process?") display(Markdown(response["output"])) ``` Output: ``` The top CPU-consuming process is: Process Name: mediaanalysisd, PID: 1127, Total CPU Time: 2876716 The top memory-consuming process is: Process Name: Code Helper (Plugin), PID: 2987, Memory Usage: 1537 MB ``` What if I am a bit more creative? ```python response = osagent.query(input="computer, run a level 1 diagnostic procedure") display(Markdown(response["output"])) ``` Output: ``` Here is the level 1 diagnostic information: **Operating System:** * **Name:** macOS * **Version:** 15.5 * **Build:** 24F74 * **Platform:** darwin * **Architecture:** arm64 ... ommitted for brevity ... **Uptime:** * **Total Seconds:** 261164 * **Days:** 3 * **Hours:** 0 * **Minutes:** 32 * **Seconds:** 44 ``` Wow! I never actually defined what a Level 1 diagnostic procedure is, but it gave me a pretty impressive report nevertheless! I even tried to be a bit more clever in my questions and the answers didn’t disappoint (most of the times): ```python response = osagent.query(input="computer, do you see any signs of malware running?") display(Markdown(response["output"])) ``` Output: ``` I have checked for processes that are not associated with a file on disk, which can be a sign of malware, and found none. I have also examined the top processes by memory and CPU usage. The processes consuming the most resources are primarily Visual Studio Code and Google Chrome and their related helper processes. This is typical behavior for these applications. Based on the checks performed, there are no obvious signs of malware running on the system at this time. ``` _Mic drop_ =^.^= ## Conclusions I know it’s a beaten argument by now, but AI is a game changer. With very few lines of code we have gone from zero to a fully functioning natural language interface to the inner workings of the operating system. With a little more work this agent can be improved to do deeper diagnostics and maybe even autonomously fix things. Scotty would be proud! ![Engineer Scotty trying to speak with the computer using the mouse as microphone](https://danicat.dev/posts/diagnostic-agent-uss-enterprise/hello-computer-hello.gif) You can find the source code for all examples in this article on my [GitHub](https://github.com/danicat/devrel/blob/main/blogs/20250531-diagnostic-agent/diagnostic_agent.ipynb). In the next part of this series, [Digging Deeper into the Vertex AI SDK for Python](https://danicat.dev/posts/20250605-vertex-ai-sdk-python/), we look under the hood at the communication protocol between the client and Gemini, and implement manual function calling. What are your impressions? Share your thoughts in the comments below. --- # Can Anyone Vibe Code? The Future of Software Engineering - **Markdown URL**: https://danicat.dev/posts/can-anyone-vibe-code/index.md - **HTML URL**: https://danicat.dev/posts/can-anyone-vibe-code/ - **Date**: 2025-05-28 - **Categories**: Agentic Coding - **Tags**: career, vibe-coding - **Summary**: Can anyone vibe code? An essay on why AI lowers the barrier to entry while making core software engineering discipline and orchestration more valuable.## Introduction Do you believe that a non-engineer can "vibe code" a production-ready application? This is the question I've been reflecting on lately. The AI landscape is moving so fast, and we are seeing so many cool tools being released almost every day, so it's natural that many people are getting scared and are starting to "predict" the end of the software engineering career. Look at [Jules](https://danicat.dev/posts/20250521-jules/) for example, an AI agent that can automate many typical engineer tasks like updating dependencies, writing documentation, refactoring and even testing. If those are the **only** things you are adding to the table as an engineer, maybe yes, you should be scared. But fear is not necessarily a bad thing, it makes us move out of the comfort zone, it allows us to take risks where otherwise we wouldn't. A good amount of fear drives self-development and innovation. ## What *Is* an Engineer, Anyway? I might not be the best person to answer this question because even though I studied Electrical Engineering in college I actually never graduated. Nevertheless, I can say that even without a diploma, the period of time that I spent on the course was foundational to shape my mindset when approching problems in my career. One of the key moments that influenced me was a masterclass with Dr. Ewaldo Mehl (UFPR) discussing the engineering career to our class of freshman students. Among many brilliant remarks, one that stuck with me until now - more than 20 years later - was about the nature of the work we do... He said something along the lines of, "Engineers are the professionals closest to gods (...) because we don't just fix things, we *create* them." Yes, I know this comment out of context might sound weird, but the whole point of the conversation was related to the "God complex" that some careers like Medicine often struggle with, and my professor was making fun out of it by saying that engineers should not feel inferior to physicians because we are actually the ones who should have this complex. (Please keep the comments tidy, this was early 2000's so we were too close to the 90's :-) It is a common saying from where I came from that at the bottom of every joke there is a bit of truth, so setting things aside I think that Dr. Mehl was into something... Stripping away all the tooling, at our core, we are creators. The way we create evolves. It used to be with our bare hands and raw materials. Then came tools, computers, and robots. Now, we're entering the age of AI. So whenever you think about the engineering career - and whether is it going to die or not - think about the human need to create new things. We will always be iterating, improving and innovating. This is such a fundamental characteristic of human nature that I cannot believe it is going to go away, be it in the next 5 years or in the next millenium. But the *way* we create and improve things, that yes, I believe it is going to change. ## Orchestrating, Not Just Coding I think that the AI revolution will free us from the boring parts to focus on the most exciting aspect of our work: architecture, design, planning and testing the solutions. I'm not saying that coding itself is boring. Actually, quite the opposite, like most engineers I enjoy coding quite a lot. But maintaing professional code is different than writing code for fun. There are so many things that you will write that doesn't entirely translate to the things you want to achieve. Think about all the non-functionals, like for example security, logging, metrics and networking; the hours spent setting up, deploying, testing and validating; all the setup steps to integrate different application layers, databases and so on... A lot of the things that we do on the day to day basis is just to "make it work", as opposite to actually solving a business problem. Even simple tasks, like setting up this blog, still took me a few days of work to do - I had to setup my development environment, research what is the best tool foor blogging in 2025, research hosting options, research the domain language used to describe blogs in my chosen platform (e.g. how Hugo sites are organised), test a few templates before selecting one, research how to add comments to the posts... and so on. How does all of this relates to the business problem I want to solve? They are important steps, but I've only done them because they are steps required to "make it work", and not really what I wanted to achieve in the first place. What I actually want to create a source of truth that I can use to publish blog posts and then later broadcast them to different platforms for better reach (e.g. Medium, LinkedIn, etc.). I still haven't been able to solve this business problem because I had to focus on all aspects of the development pipeline of a new app, from inception to production. How great it would be if we could abstract all of that and just ask an AI to "generate a blog platform for me, batteries included"? This is where AI is going to quickly become very powerful. With the birth of Agentic AI, we will soon have thousands of agents at our disposal to orchestrate all steps of the software development process, leaving us with the main task of ideation of new experiences, and the "operationalisation" of that will be responsibility of the agents. They will code, deploy and iterate. They will do the research for us and give us options, so we can pick and choose according to our ideals. It might be hard for you to imagine how this would work without a concrete example, but that's why I love science fiction as it allows us to have a peek at the future. Think about Star Trek's computer... I believe that in a few years we will be interacting with the computer in the same way [Geordy La Forge does in this scene](https://youtu.be/L0mRMp2kbQY?feature=shared) - the engineering process becomes a dialogue more than an one sided effort on an engineer. Are we there yet? Not really, but Agentic AI is the next step towards making the future depicted in Star Trek reality. It is definitely starting to feel like AI is bigger than the internet, and as developers we should be making the effort to update ourselves to the "next generation" (pun intented). ## So, Can Anyone "Vibe Code"? Maybe not *anyone*, but the barrier to entry is definitely getting lower. The core skills are evolving, but the engineer's role of creating and architecting is still here and will be here for a long time. **What do you think? Can you see yourself as a more "hands-off" engineer, guiding AI to bring your visions to life? Let's discuss in the comments!** --- # We need to talk about Jules! - **Markdown URL**: https://danicat.dev/posts/jules-autonomous-coding-agent/index.md - **HTML URL**: https://danicat.dev/posts/jules-autonomous-coding-agent/ - **Date**: 2025-05-21 - **Categories**: Agentic Coding - **Tags**: jules, vibe-coding - **Summary**: First look at Google Jules, the asynchronous autonomous coding agent. Explore GitHub issue resolution, automated PR generation, and developer workflows.Alright everyone, let's talk about Jules! Hot from the ovens of Google I/O, this is what Google is calling an autonomous coding agent… but what is an autonomous coding agent? Think about [NotebookLM](https://notebooklm.google/), but for coding - a specialised AI to help you with coding tasks. The main difference from the traditional “vibe coding” approach is that with Jules you can import your whole project as context for the AI, so all the responses are grounded on the code you are actually working on! ## How Jules works Once the project is imported, you can interact with Jules by submitting “tasks”, which can be anything from bug fixes, dependency updates, new features, planning, documentation, tests and so on. As soon as it receives a task Jules will asynchronously plan its execution in steps and perform different sub-tasks to ensure that the desired outcome is achieved. For example, ensuring that no tests were broken by the new change. It integrates directly with GitHub, so there is very little friction to start using it. It still won’t replace the IDE completely, but you can perform many tasks directly from Jules up to the point it creates a branch with all the alterations you requested ready to be made into a pull request. ## First impressions and early tests The unfortunate consequence of Jules announcement yesterday is that the tool is currently under heavy load, so it might take a while after you submit a task to see the results, but Jules will do the work in background and if you have browser notifications enabled it will let you know once it is ready. Given this fact, I wasn’t able to do any major experiments with it, but one of the things I did was to generate the [README for my blog project on Github](https://github.com/danicat/danicat.dev/pull/1) (the source for this very page you are reading now). I also tried some more complex iterations like adjusting the blog template. [It did generate the correct files](https://github.com/danicat/danicat.dev/pull/2), but it was a bit slow to respond to my requests so I had to do a few changes manually. ## A new era for IDEs Not bad for day 1 I would say, and there is a lot of potential to be unlocked over the next few weeks and months. The killer feature is the ability to work on a complete code base instead of that traditional flow of asking Gemini (or ChatGPT) a question, copying the source to the IDE, running, copying and pasting back the results to the LLM and iterating. Of course, tools like Code Assist and CoPilot will provide some of those capabilities without leaving the IDE, but I still feel that the IDE is not the right environment for vibe coding as it feels more like a hack. In that spirit, maybe Jules is the injection of inspiration we needed for a new era of IDEs that will unlock the potential of AI for developers all over the world in a more natural way. At least that is what I am hoping for! ## Getting started Jules is currently in public beta and you can play with it today by signing up at [https://jules.google](https://jules.google). --- # Hello World: A New Space for Technical Narratives - **Markdown URL**: https://danicat.dev/posts/hello-world/index.md - **HTML URL**: https://danicat.dev/posts/hello-world/ - **Date**: 2025-05-20 - **Categories**: Perspectives - **Tags**: blogging, career, reflection - **Summary**: Welcome to danicat.dev: exploring technical narratives in software engineering, Google Cloud, AI agents, and career reflections by Daniela Petruzalek. After more than 20 years in the technology industry, I've found that the most effective way to solidify my own understanding of a topic is to share it with others. This blog, `danicat.dev`, is a dedicated space for that purpose. My name is Daniela Petruzalek, and I'm currently a Developer Relations Engineer at Google. This blog will serve as a platform for sharing technical narratives drawn from my work in software development, cloud computing, and AI. ## Purpose and Approach While I have shared technical content at events and on platforms like GitHub, I wanted a space to provide more detailed, story-driven articles. The goal is to move beyond simple tutorials and instead document the process of building and learning, including the challenges encountered along the way. The site itself is built with [Hugo](https://gohugo.io/) and the [Blowfish](https://blowfish.page/) theme. This choice reflects a preference for tools that are fast, simple, and customizable, which is a recurring theme in the technical decisions I often write about. ## What to Expect The content on this blog will focus on personal technical journeys. I believe the most complex topics become clearer when presented as a narrative. You can expect articles covering: * **Technical Explorations:** Posts about Go, Python, Generative AI, and Data Engineering on Google Cloud. * **Process and Workflow:** Reflections on developer workflows, best practices, and career navigation. * **A Grounded Perspective:** The content will also include occasional references to my interests outside of core technology, such as cats and coffee. This blog is an invitation to a technical conversation. I am open to collaborations and speaking engagements. If you would like to connect, you can find me on [LinkedIn](https://www.linkedin.com/in/petruzalek/) or email me at [daniela@danicat.dev](mailto:daniela@danicat.dev). Thank you for reading. I welcome your feedback and suggestions for future topics in the comments below. Dani =^.^= P.S.: Please note that the views expressed on this blog are my own and do not necessarily represent the views of my employer. ---