<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[AutoMagicOps]]></title><description><![CDATA[How one DevOps engineer automates a growing infrastructure with n8n, MCP servers and AI agents. Production setups, working configs, and what actually broke.]]></description><link>https://auto-magic-ops.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/69ba2dfec22d3eeb8a318159/f1816c56-2e82-498b-b7be-6e5f297a240c.png</url><title>AutoMagicOps</title><link>https://auto-magic-ops.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 09 Nov 2026 11:58:42 GMT</lastBuildDate><atom:link href="https://auto-magic-ops.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How to plan an infrastructure task without losing your mind: a breakdown using an AWS deployment as an example]]></title><description><![CDATA[The ticket fits on one line: "deploy a new web service to AWS and add jobs to deploy it." Inside that line are a network, containers, a database, secrets…]]></description><link>https://auto-magic-ops.hashnode.dev/how-to-plan-an-infrastructure-task-without-losing-your-mind-a-breakdown-using-an-aws-deployment-as-an-example</link><guid isPermaLink="true">https://auto-magic-ops.hashnode.dev/how-to-plan-an-infrastructure-task-without-losing-your-mind-a-breakdown-using-an-aws-deployment-as-an-example</guid><category><![CDATA[ai agents]]></category><category><![CDATA[Devops]]></category><category><![CDATA[AWS]]></category><category><![CDATA[Terraform]]></category><category><![CDATA[IaC (Infrastructure as Code)]]></category><category><![CDATA[Nib]]></category><category><![CDATA[llm]]></category><dc:creator><![CDATA[Sergey Byvshev]]></dc:creator><pubDate>Mon, 09 Nov 2026 07:05:53 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/dcbb6e88-8d03-456f-a059-77fed59ef8e4.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The ticket fits on one line: "deploy a new web service to AWS and add jobs to deploy it." Inside that line are a network, containers, a database, secrets, a CDN, two workflows and dozens of small steps that an engineer usually keeps in their head. That works while nothing interrupts you. Then you join a call, come back, and can't remember whether you checked the security group. And when someone asks how long it will take, the honest answer is "a day or two."</p>
<p>I've been building IT infrastructure for about 15 years, and today I lead a DevOps team at a game studio. Tasks like "spin up a new service" or "change system X" land on my desk every week, and over the years I've settled into a fairly stable way of planning them. I tried to write that method down and then give it to an agent.</p>
<h2>Part 1. How an engineer plans a task</h2>
<h3>The task</h3>
<p>Let's take a concrete one:</p>
<blockquote>
<p>Deploy a new web service to AWS. It has a React frontend and a Python backend, and requires PostgreSQL to run. The application repository is <a href="https://github.com/javdet/nib-demo-app">demo-app</a>. This should result in jobs in GitHub for deploying the infrastructure and the application.</p>
</blockquote>
<p>Nothing unusual. Tasks like this come in all the time, and anyone who works with infrastructure has a rough idea of what's behind them. Let's walk through what happens in an engineer's head when this ticket arrives, step by step and slower than it happens in real life.</p>
<h3>Step 1. Collect context</h3>
<p>Before planning anything, the engineer answers three questions:</p>
<ol>
<li><strong>What is the project?</strong> Who uses it, how critical it is, whether it has environments besides production.</li>
<li><strong>How does the current infrastructure work?</strong> Which AWS accounts exist, where the Terraform state lives, how the networks are laid out, what CI/CD is already there.</li>
<li><strong>What do we know about the products in the task?</strong> Here that's AWS, PostgreSQL and GitHub Actions: which AWS services fit, which Postgres version to use, how Actions will reach the cloud.</li>
</ol>
<p>Some of the answers are already in their head. The rest they pull from git and the wiki: usually details of how the infrastructure is built and the company's internal rules for cloud, databases and CI/CD.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/dd27bcd3-7683-40ba-ae98-ba1ec91e3cb4.png" alt="" style="display:block;margin:0 auto" />

<p>This step already shows the first problem, and we'll come back to it: a large part of the context isn't written down anywhere except in one person's head.</p>
<h3>Step 2. Outline the stages</h3>
<p>Once the context is collected, the task falls apart into large stages:</p>
<ol>
<li>Write infrastructure code for the AWS resources.</li>
<li>Write the build and deploy workflows.</li>
<li>Create the resources in AWS.</li>
<li>Deploy the application.</li>
<li>Check that everything works.</li>
</ol>
<p>The stages depend on each other: you can't deploy the app before the cluster exists, and you can't create the cluster before the code exists. The dependencies matter more than the list itself, because they decide what can run in parallel and what can't.</p>
<h3>Step 3. Break the stages into basic actions</h3>
<p>Each stage is a set of simple actions you can do in one go:</p>
<ul>
<li>edit a file;</li>
<li>run a CLI command;</li>
<li>make an API call;</li>
<li>change a setting in a UI.</li>
</ul>
<p>For example, "create the resources in AWS" becomes a chain: create a bucket for the state, give GitHub Actions access to the account, run <code>terraform plan</code>, read the diff, run <code>terraform apply</code>, look at the outputs.</p>
<h3>Step 4. Lean on rules and experience</h3>
<p>To put the actions in the right order, the engineer relies on two things:</p>
<ol>
<li><strong>Team rules.</strong> How we name resources, where we keep state, which tags are required, what we never do in production.</li>
<li><strong>Their own experience.</strong> They've done something similar before and remember where it hurt last time.</li>
</ol>
<h3>Step 5. Build the plan: action, check, rollback</h3>
<p>These pieces add up to a detailed plan. Usually it lives in the engineer's head, less often on paper. A good plan has three parts:</p>
<ul>
<li><strong>actions</strong>: what exactly we do;</li>
<li><strong>checks</strong> before closing each stage: how to know the stage is really done, and not just "seems fine";</li>
<li><strong>a rollback plan</strong> in case something goes wrong.</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/c42b148b-d49f-4dbf-a991-4890e9932d8a.gif" alt="" style="display:block;margin:0 auto" />

<table>
<thead>
<tr>
<th>Action</th>
<th><strong>Check</strong></th>
<th>Rollback</th>
</tr>
</thead>
<tbody><tr>
<td><code>terraform plan</code> for the network and database</td>
<td>the diff only creates resources, not a single <code>destroy</code></td>
<td>not needed, nothing has changed</td>
</tr>
<tr>
<td><code>terraform apply</code></td>
<td>the outputs are there, RDS is <code>available</code>, the ALB responds</td>
<td><code>terraform destroy</code> the new resources (mind the deletion protection on the database)</td>
</tr>
<tr>
<td>run the deploy workflow</td>
<td>the job is green, the smoke test passes, the site opens</td>
<td>roll the ECS service back to the previous task definition revision</td>
</tr>
</tbody></table>
<p>The Rollback column is the one people leave empty most often. Be honest: when did you last write a rollback plan <em>before</em> you needed it?</p>
<h3>Step 6. Execute</h3>
<p>Then the engineer walks the whole list: action, check, action, check. If a check fails, they roll back or fix the problem, and check again.</p>
<p>Tedious, right?</p>
<h3>Where this process breaks</h3>
<p>On paper it's a good process. In practice it breaks in three places.</p>
<p><strong>1. The action list lives in your head, and items fall out of it.</strong> A call, an alert, a colleague asking you to look at their PR. An hour later you come back to the task and you're no longer sure whether you closed the database port to the internet. A plan in your head doesn't survive context switches, and you can't hand it to a colleague if you get sick in the middle of the task.</p>
<p>This happened to me when I was moving asset buckets from one cloud to another. I copied the data and set up access, but forgot to add CORS. It's a couple of lines of config, and it feels so obvious that it never makes it onto any list.</p>
<p><strong>2. Checks get skipped.</strong> You want to save time: "the site opened, so it works." The fact that the database is reachable from the internet, or that backups aren't set up, comes up a month later. Checks are the first thing to go under a deadline, because skipping them doesn't break anything right away.</p>
<p><strong>3. Nobody knows how big the task is.</strong> Ask "how long will it take?" and you hear "a day or two." That's not an estimate. Until the task is broken into actions, its size is invisible: to you, to your team lead, and to whoever filed the ticket.</p>
<p>All three have the same cause: the plan exists only in the head of the person doing the work, so you can't hand it over and you can't measure it.</p>
<h3>What if an agent builds the plan?</h3>
<p>So here's the natural question: what if an agent does the breakdown, the planning, the execution and the checks? The engineer still controls the stages, decides which steps to run, and accepts the final result.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/9561f58f-181c-4ba3-acf5-6ac0e9c2c887.gif" alt="" style="display:block;margin:0 auto" />

<p>That's why I built <a href="https://github.com/javdet/nib"><strong>nib</strong></a> (Neuro Infrastructure Builder). It's a tool for infrastructure work: it splits a task into stages, writes concrete actions with checks for each stage, builds a rollback plan, and then either runs the steps itself or gives the operator detailed instructions.</p>
<p>Inside, nib follows the same logic as the engineer from Part 1. The work is split into modes, and each mode has its own role:</p>
<table>
<thead>
<tr>
<th>Mode</th>
<th>What it does</th>
<th>In the engineer's head</th>
</tr>
</thead>
<tbody><tr>
<td>Decompose</td>
<td>asks clarifying questions, finds the stages and the dependencies between them</td>
<td>steps 1–2</td>
</tr>
<tr>
<td>Plan</td>
<td>one agent per stage writes actions and checks, all in parallel; a separate agent builds the rollback</td>
<td>steps 3–5</td>
</tr>
<tr>
<td>Execute</td>
<td>runs a specific action through the connected tools</td>
<td>step 6</td>
</tr>
</tbody></table>
<p>The context, rules and experience the engineer relies on exist in nib too: a knowledge base, rules and skills. In this post we only use the knowledge base. The rest is a topic for the next part.</p>
<hr />
<h2>Part 2. From git clone to a working site</h2>
<p>Now let's run the same task through nib. The whole run is on <a href="https://youtu.be/wdI2RSFrc80">video</a>:</p>
<h3>What you need</h3>
<ul>
<li>Docker;</li>
<li>an API key for an LLM provider. nib works with any OpenAI-compatible API, and the installer has presets for OpenAI, OpenRouter, Google Gemini, Qwen, DeepSeek, Grok and Kimi;</li>
<li>an AWS account. I strongly recommend a separate sandbox account: the agent will need keys that can create resources;</li>
<li>a GitHub account and a fork of <a href="https://github.com/javdet/nib-demo-app">demo-app</a>, if you want to repeat this exact task.</li>
</ul>
<h3>Installation</h3>
<p>nib runs locally with Docker Compose or in Kubernetes with a Helm chart. The local option is enough to try it:</p>
<pre><code class="language-shell">git clone https://github.com/javdet/nib.git
cd nib
make setup
</code></pre>
<p><code>make setup</code> is an interactive script. It asks for the LLM provider, the model and the key (I use OpenRouter), then the name of your first project and, optionally, its environments, clouds and regions. You can skip those fields, but nib plans better with them, and you can add more projects later. Then the script generates tokens, writes <code>.env</code> and the config, and starts four containers: Postgres, the knowledge base server, the backend and the frontend.</p>
<p>At the end it prints the UI address (<code>http://localhost:8080</code> by default; if the port is busy, it picks a free one) and an API token. You paste this token on the login screen. It's also saved in <code>.env</code> as <code>NIB_API_TOKEN</code>, so it's hard to lose.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/a9497b96-e255-4941-8582-de9535324c26.png" alt="" style="display:block;margin:0 auto" />

<h3>Knowledge base: what to fill in for a new project</h3>
<p>The first place to open after you log in is <strong>Knowledgebase</strong>. This is the "context" from step 1: everything the engineer knows about the company and the project, and the agent doesn't.</p>
<p>The <strong>Company Information</strong> card has the basic fields: company name and description, version control system and git base URL, git user, CI/CD system, issue tracker, wiki and team messenger. For our task the key ones are GitHub as the VCS and GitHub Actions as CI/CD, because they decide which workflows the agent will write.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/302d7c6a-a765-498e-af09-d50d226fa94e.png" alt="" style="display:block;margin:0 auto" />

<p>You don't need to upload documents for a new project yet: there's nothing to describe. On a live project, this is where you put what usually sits in the wiki and in people's heads: the network diagram, naming conventions, where state lives, what's special about each environment. You can upload a Markdown document or ask the agent to generate one from a list of repositories.</p>
<p>When a plan finishes, nib writes a report and updates the knowledge base from it, so the next task starts with what the agent learned in the previous one.</p>
<h3>Connecting GitHub, AWS and search over MCP</h3>
<p>On its own, the agent can only think. To create a repository, look at AWS resources or find documentation, it needs tools. In nib they connect over <strong>MCP</strong> (Model Context Protocol). It's an open protocol: an LLM application gets a list of tools from an external server and calls them. One MCP server covers one system: GitHub, AWS, search.</p>
<p>If you don't know where to start, ask nib itself, for example: "How do I connect you to AWS over MCP?" Its answer is in the video.</p>
<p>This task needs three servers:</p>
<ul>
<li><strong>GitHub</strong>: the repository, PRs and workflows;</li>
<li><strong>AWS</strong>: reading and creating resources in the account;</li>
<li><strong>SearXNG</strong>: web search, so the agent can check current documentation instead of relying only on what the model already knows.</li>
</ul>
<p>nib supports only the HTTP transport for MCP (streamable HTTP), so we run all three servers as containers on the same network as nib. Add them to <code>docker-compose.yml</code>.</p>
<p><strong>GitHub MCP</strong> is the official server from GitHub:</p>
<details data-node-type="hn-details-summary">
<summary>docker-compose.yml</summary>
<pre class="not-prose"><code class="language-yaml">github-mcp:
  image: ghcr.io/github/github-mcp-server:latest
  restart: unless-stopped
  command:
    - http
    - --port=9082
  networks:
    - nib
</code></pre><p><code>aws-mcp:<br />image: public.ecr.aws/awslabs-mcp/awslabs/aws-api-mcp-server:latest<br />restart: unless-stopped<br />environment:<br />AWS_API_MCP_TRANSPORT: "streamable-http"<br />AWS_API_MCP_HOST: "0.0.0.0"<br />AWS_API_MCP_PORT: "9081"<br />AUTH_TYPE: "no-auth"<br />AWS_REGION: "${AWS_REGION:-us-east-2}"<br />AWS_API_MCP_ALLOWED_HOSTS: "aws-mcp,localhost,127.0.0.1"<br />AWS_API_MCP_ALLOWED_ORIGINS: "aws-mcp,localhost,127.0.0.1"<br />READ_OPERATIONS_ONLY: "false"<br />REQUIRE_MUTATION_CONSENT: "false"<br />AWS_ACCESS_KEY_ID: "${AWS_ACCESS_KEY_ID}"<br />AWS_SECRET_ACCESS_KEY: "${AWS_SECRET_ACCESS_KEY}"<br />AWS_SESSION_TOKEN: "${AWS_SESSION_TOKEN:-}"<br />networks:<br />- nib</code></p><p><code>searxng:<br />image: </code><a target="_self" rel="noopener noreferrer nofollow" class="cursor-pointer" href="http://docker.io/searxng/searxng:latest" style="pointer-events:none"><code>docker.io/searxng/searxng:latest</code></a><code><br />restart: unless-stopped<br />environment:<br />SEARXNG_PORT: "9083"<br />SEARXNG_BASE_URL: "</code><a target="_self" rel="noopener noreferrer nofollow" class="cursor-pointer" href="http://searxng:9083/" style="pointer-events:none"><code>http://searxng:9083/</code></a><code>"<br />SEARXNG_SECRET: "${SEARXNG_SECRET}"<br />volumes:<br />- ./searxng:/etc/searxng:rw<br />networks:<br />- nib</code></p><p><code>searxng-mcp:<br />image: </code><a target="_self" rel="noopener noreferrer nofollow" class="cursor-pointer" href="http://docker.io/isokoliuk/mcp-searxng:latest" style="pointer-events:none"><code>docker.io/isokoliuk/mcp-searxng:latest</code></a><code><br />restart: unless-stopped<br />environment:<br />SEARXNG_URL: "</code><a target="_self" rel="noopener noreferrer nofollow" class="cursor-pointer" href="http://searxng:9083" style="pointer-events:none"><code>http://searxng:9083</code></a><code>"<br />SEARXNG_DEFAULT_RESPONSE_FORMAT: "json"<br />SEARXNG_MAX_RESULT_CHARS: "500"<br />SEARXNG_TIMEOUT_MS: "15000"<br />MCP_HTTP_PORT: "9084"<br />MCP_HTTP_HOST: "0.0.0.0"<br />MCP_HTTP_STATELESS: "true"<br />networks:<br />- nib</code></p><p></p><p></p>
</details>

<p>The response format is an easy place to trip up. The MCP wrapper asks SearXNG for results in JSON, but by default SearXNG returns only HTML. The <code>json</code> format has to be enabled in <code>searxng/settings.yml</code>. The nib repository already ships this file with the right setting, which is why the volume is mounted from <code>./searxng</code>.</p>
<h4>Registering the servers in nib</h4>
<p>Now nib needs to know where to find these servers. You edit the MCP config in <strong>Tools → Config</strong> (or through <strong>Tools → MCP Servers → Add</strong>):</p>
<details data-node-type="hn-details-summary">
<summary>mcp.json</summary>
<pre class="not-prose"><code class="language-json">{
  "mcpServers": {
    "knowledge-base": {
      "url": "http://kb-mcp:8081/mcp"
    },
    "aws": {
      "type": "http",
      "url": "http://aws-mcp:9081/mcp"
    },
    "searxng": {
      "type": "http",
      "url": "http://searxng-mcp:9084/mcp"
    },
    "github": {
      "type": "http",
      "url": "http://github-mcp:9082/",
      "headers": {
        "Authorization": "Bearer ${GITHUB_TOKEN}",
        "X-MCP-Toolsets": "all",
        "X-MCP-Readonly": "false"
      }
    }
  }
}</code></pre>
</details>

<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/e0cf0494-0da7-4b7a-bee9-a3c0c677ac93.png" alt="" style="display:block;margin:0 auto" />

<p><code>knowledge-base</code> is the built-in knowledge base server, and it's already in the config after installation. We add the other three.</p>
<p>Note the <code>${GITHUB_TOKEN}</code> in the header. Tokens never go into the config as plain text: nib fills them in from <strong>Variables → Secrets</strong> at request time. Each secret has a list of allowed hosts, so the GitHub token goes only to <code>github-mcp</code> and nowhere else.</p>
<p>So secrets live in two places, and it's easy to mix them up:</p>
<ul>
<li><strong>AWS keys</strong> are needed by the <code>aws-mcp</code> container itself, so they're in <code>.env</code> and reach the container as environment variables;</li>
<li><strong>the GitHub token</strong> travels in the HTTP header of the request from nib to the MCP server, so it's in nib's secrets.</li>
</ul>
<h4>Checking the connection</h4>
<p>After restarting the containers, open <strong>Tools → MCP Servers</strong>. For each server you can see the list of tools it exposes. If the list is empty or the server shows an error, the problem is in the network, the address or the token, and there's no point going further: an agent without tools plans blind.</p>
<p>In the video, the very first check fails: GitHub MCP returns an error. The container is up and the address is right, but the <code>GITHUB_TOKEN</code> secret doesn't exist yet, and without it the server returns nothing. We create the secret in <strong>Variables → Secrets</strong>, check again, and the tool list appears.</p>
<p>You create the token in your GitHub user settings, under Developer settings (<a href="https://github.com/settings/tokens">github.com/settings/tokens</a>). I used a classic token with the <code>repo</code> and <code>workflow</code> scopes. The second one is easy to miss: without it GitHub rejects pushes to <code>.github/workflows/</code>, and the agent can't commit its workflows.</p>
<p>In my case GitHub exposed a couple of dozen tools, SearXNG five, and AWS only two. That's expected. AWS API MCP Server gives the agent a generic AWS CLI call plus a helper that picks the right command, so all of AWS fits into two tools. GitHub is the opposite, with one tool per operation, and the next section deals with that.</p>
<h3>Tool categories and a base set per mode</h3>
<p>Three MCP servers give the agent a lot of tools. If you send all of them with every call, the model gets confused, the context grows, and you pay tokens for descriptions of tools this task will never use. Planning cloud resources doesn't need tools for logs and metrics. nib has two mechanisms for this.</p>
<p><strong>Tool categories.</strong> Tools are grouped into categories (for example, everything about AWS, everything about git). During decomposition the agent decides which categories the task touches and gets access only to those. Each action in the plan gets an even narrower set: only the categories marked on that action. You don't have to sort the tools by hand. The <strong>Auto-fill with AI</strong> button on the <strong>Tool categories</strong> card does a first pass that you can then fix.</p>
<p><strong>A base set for each mode.</strong> You can think of nib's modes as subagents: some break the task down, some work out the stages, some run actions. By default every mode gets every tool. In <strong>Tools → Included tools</strong> you can narrow this: modes that should only read and plan don't need tools that change things. The <strong>Distribute with AI</strong> button can only remove tools, so the read-only modes lose the tools that change things, and nothing extra gets added.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/57a0e179-6895-48b1-b802-f288930f3e8b.png" alt="" style="display:block;margin:0 auto" />

<h3>The first plan</h3>
<p>Everything is ready, so we can plan. As a reminder, we're deploying <a href="https://github.com/javdet/nib-demo-app">nib-demo-app</a>, an online store for art supplies with a Python backend, a React frontend and a PostgreSQL database. Click <strong>New plan</strong> and paste the task from Part 1 as is.</p>
<p>From here, nib repeats what the engineer did in Part 1.</p>
<p><strong>Collecting context.</strong> First the decompose agent asks clarifying questions, and for most of them it suggests answers you can pick with a click. It asked me two questions an engineer would ask whoever filed the ticket in the first five minutes:</p>
<ul>
<li><strong>How should the service be published?</strong> Is there a custom domain, or is the address that CloudFront generates enough? The answer decides whether DNS records and a TLS certificate show up in the plan.</li>
<li><strong>How should the jobs run?</strong> Automatically on merge to the main branch, or manually? Really, this is a question about whether the infrastructure can change without a human.</li>
</ul>
<p>I don't have a domain, so the site lives on the CloudFront address, and the jobs run automatically.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/4e61263a-aa2b-42cd-bc91-42c531be493d.png" alt="" style="display:block;margin:0 auto" />

<p><strong>Stages and dependencies.</strong> After the answers, the agent shows a <strong>Summary</strong> (how it understood the task) and a <strong>DAG</strong> of the stages and the dependencies between them. A DAG (directed acyclic graph) is a "what comes after what" diagram with no loops. Here you see for the first time what was implicit in the engineer's head: which stages can run in parallel and which ones block the rest. If you have no questions about the stages, move on to detailed planning.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/d52e7348-718c-435e-a486-4babdfdb9528.png" alt="" style="display:block;margin:0 auto" />

<p>*<em>Actions, checks, rollback. The Process plan here</em>* button starts planning. A separate agent works on each stage, in parallel with the others, and at the end the rollback plan is built. The whole thing took about two minutes. The result was four stages and a rollback plan:</p>
<table>
<thead>
<tr>
<th>Stage</th>
<th>Steps</th>
<th>Checks</th>
</tr>
</thead>
<tbody><tr>
<td>Establish AWS deployment trust and GitHub prod environment</td>
<td>5</td>
<td>4</td>
</tr>
<tr>
<td>Implement Terraform and GitHub deployment jobs</td>
<td>2</td>
<td>4</td>
</tr>
<tr>
<td>Run the approved infrastructure deployment job for AWS</td>
<td>2</td>
<td>4</td>
</tr>
<tr>
<td>Run the approved application deployment job for AWS</td>
<td>1</td>
<td>3</td>
</tr>
</tbody></table>
<p>Look at the last row: there are one and a half times more checks than actions. When the plan lives in your head, this is the part that shrinks first under a deadline.</p>
<p>Steps come in four types: run a CLI command, make an API request, work with code, or change something in a web UI. Each step has a detailed description of what to do, and if an action can cause downtime for a system that's already running, the plan warns about it separately.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/5ad783c3-5619-40a4-996d-85e234c50a7a.png" alt="" style="display:block;margin:0 auto" />

<p>This answers the third problem from Part 1. nib doesn't estimate in hours, but you see the amount of work before you start: four stages, ten steps, fifteen checks, and it's clear where you need code and where an API call is enough. You can take that to whoever asked "how long will it take," and the conversation will be more concrete than "a day or two."</p>
<h3>Preparing access: the agent works over MCP</h3>
<p>Each step has a run button, each stage has <strong>Execute all</strong>, and you can also start a step from the chat by asking the agent. A checkbox next to a step marks it as done, and you can leave a comment on any step. Only one action runs at a time. This is a deliberate limit, so that two agents don't change the same infrastructure in parallel.</p>
<p>The first stage is all API and CLI calls, so the agent ran it on its own, over MCP:</p>
<ol>
<li><em>Reconcile the GitHub Actions OIDC provider in AWS account</em>: the OIDC provider that GitHub Actions will use to get access to AWS;</li>
<li><em>Reconcile the remote Terraform state bucket for the prod stack</em>: an S3 bucket for the Terraform state;</li>
<li><em>Reconcile the infrastructure deployment role used by Terraform</em>: the role Terraform uses to create resources;</li>
<li><em>Reconcile the application deployment role for publishing and rolling out</em>: a separate role for shipping the application;</li>
<li><em>Create or reconcile the protected GitHub production environment</em>: a protected <code>production</code> environment in the repository.</li>
</ol>
<p>If you know Kubernetes, you know the word reconcile: bring the system to the desired state. If the resource doesn't exist, create it. If it exists but is configured differently, fix it. If it's already right, do nothing. A step like this is safe to run again, which helps if the plan stops halfway. The two separate roles are deliberate too: the role that ships the application has no reason to be able to create networks and databases.</p>
<p>After each step, the agent reports in the chat what it did. To make the demo clearer, I ran the stage checks myself: I opened the AWS console and made sure the OIDC provider was created, the bucket was there, and the environment had appeared in the repository. The agent can run the checks too; you just have to ask. Remember the OIDC provider check. We'll need it later.</p>
<details data-node-type="hn-details-summary">
<summary>Executing actions</summary>
<img class="rounded-lg max-w-full h-auto" src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/c8637e0a-720d-46d9-bdd9-5d133cc0d763.png" style="display:block;margin:0 auto" /><img class="rounded-lg max-w-full h-auto" src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/95bc0a92-c183-4440-be7c-27f12d99c943.png" style="display:block;margin:0 auto" />
</details>

<h3>Code: Terraform and workflows</h3>
<p>In the second stage, API calls are no longer enough: we need to write Terraform and workflows. For steps like these, nib has an <strong>executor</strong> that runs a coding agent in a separate container. The agent clones the repository, makes the changes, commits, pushes and opens a pull request.</p>
<p>The first time you run a code step, nib sends you to the executor settings page (<strong>Tools → Executor</strong>). The executor has three options: off (the default), <strong>Local</strong>, which means containers on your machine through Docker, and <strong>Remote</strong>, which runs as Kubernetes Jobs. Local is enough to start: the agent runs right on your computer, and you can see the container logs in the UI.</p>
<p>Pick the agent type and the authentication method. The tokens we need aren't in the secrets list yet, so we create them:</p>
<ul>
<li><code>CLAUDE_CODE_OAUTH_TOKEN</code>: the Claude Code subscription token, which you get from <code>claude setup-token</code>. You can use an Anthropic API key instead;</li>
<li>a GitHub token, so the agent can push its changes. It's the same one as for MCP.</li>
</ul>
<p>You can keep the default agent image and model. Go back to the plan and run the step.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/a0888d28-8ab0-418f-a605-59e6eefe3ea4.png" alt="" style="display:block;margin:0 auto" />

<h4>What the agent wrote</h4>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/6772d769-db85-4661-9019-12872924f6be.png" alt="" style="display:block;margin:0 auto" />

<p>The PR has 20 files: two workflows, Terraform code in <code>infra/terraform</code> and an updated README. In short, here's the architecture:</p>
<ul>
<li><strong>CloudFront</strong> in front of everything: the frontend's static files come from a private <strong>S3</strong> bucket, and <code>/api/*</code> requests go to an <strong>ALB</strong>;</li>
<li>the <strong>ALB</strong> accepts traffic only from the CloudFront managed prefix list, so you can't reach it directly from the internet;</li>
<li>the backend runs on <strong>ECS Fargate</strong> in private subnets and accepts traffic only from the ALB;</li>
<li><strong>RDS PostgreSQL 16</strong> sits in isolated subnets with no route to the internet. It's Multi-AZ, encrypted, with backups and deletion protection. Terraform generates the password and puts it straight into <strong>Secrets Manager</strong>;</li>
<li>GitHub Actions reach AWS over <strong>OIDC</strong>: the job assumes an IAM role and gets temporary credentials, so not a single long-lived AWS key is stored in GitHub. This is where the provider and the roles from the first stage come in.</li>
</ul>
<p>And two workflows:</p>
<ul>
<li><strong>Deploy Infrastructure</strong> runs <code>terraform plan</code> and <code>terraform apply</code>. Apply runs only manually, with an explicit flag, in the <code>production</code> GitHub environment, where you can require approval. That's how the agent used my answer about running jobs;</li>
<li><strong>Deploy Application</strong> builds the backend image, pushes it to ECR tagged with the commit, updates the ECS service, builds the frontend, uploads it to S3, invalidates the CloudFront cache and finally runs a smoke test against the CloudFront address.</li>
</ul>
<p>Two decisions in this code are good examples of the "experience" from Part 1 that usually isn't written down anywhere.</p>
<p><strong>The ECS service is created with</strong> <code>desired_count = 0</code><strong>.</strong> When <code>terraform apply</code> runs, ECR doesn't have a single application image yet. If you ask for two tasks right away, ECS will keep trying to start an image that doesn't exist. So Terraform creates the service empty and ignores later changes to the image and the task count, and the deploy workflow publishes the first real image and scales the service to two. Without this detail, Terraform would undo the deploy on every run.</p>
<p><strong>The cost is stated openly.</strong> A Multi-AZ database and a NAT gateway in every availability zone are a production setup, and they cost money even with no traffic. The agent put this in the README, next to the order in which to run the workflows. For a demo it's overkill, for production it's right, and it's a decision you should make on purpose instead of learning about it from the bill.</p>
<p>The changes look reasonable, so we merge the PR into the main branch.</p>
<h3>First run, first failure</h3>
<p>We run <strong>Deploy Infrastructure</strong>, and the first attempt fails.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/b11ad236-9b0f-4c7b-a3ff-e49ff249e5a5.png" alt="" style="display:block;margin:0 auto" />

<p>This happens to experienced engineers too, so it's no big deal. Instead of reading the log myself, I show nib the error and ask it to figure it out. It finds the cause: the OIDC provider created in the first stage doesn't list all the thumbprints.</p>
<p>A thumbprint is a fingerprint of the identity provider's TLS certificate. AWS uses the list of thumbprints to verify that the token really came from GitHub. If the right thumbprint isn't on the list, AWS doesn't trust the token, and the job can't assume the role and get into the account.</p>
<details data-node-type="hn-details-summary">
<summary>Chat</summary>
<img class="rounded-lg max-w-full h-auto" src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/d2980e30-6bd1-43e1-a1a5-1f606181d7f7.png" style="display:block;margin:0 auto" />
</details>

<p>We add the missing thumbprints and rerun the workflow. On the second try the infrastructure is created: the log shows the `terraform plan` and how many resources were created. We check that the database is ready and the load balancer is in place, and mark the steps as done.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/ac0dffec-6f64-43a7-aef8-57949a940c72.png" alt="" style="display:block;margin:0 auto" />

<h3>Deploying the app: a second failure, then the site</h3>
<p>We run <strong>Deploy Application</strong>, and it also fails the first time: the healthcheck doesn't pass. The deploy finished too quickly, and the app wasn't ready by the time of the check. This is expected, and the next step of the plan says exactly that: rerun the deploy. So we do.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/3abebd9a-07ed-497c-8d2f-99e014760b5f.png" alt="" style="display:block;margin:0 auto" />

<p>On the second run the deploy passes and the smoke test is green. We open the CloudFront address and see the art-supply store, the service we set out to deploy.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/7b33f368-21ff-40c0-a065-922e8f5874fa.png" alt="" style="display:block;margin:0 auto" />

<p>We mark the remaining steps and click <strong>Finish</strong>. nib closes the plan, writes a report on the work done and updates the knowledge base. Next time a task about this project comes in, the agent will already know that it has a VPC, an ECS cluster and a database, and won't start from zero.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/cf5c0052-4e89-4d14-b071-79bdbf217e97.png" alt="" style="display:block;margin:0 auto" />

<h2>Wrapping up</h2>
<p>We took an ordinary task: deploy a web service to AWS and set up GitHub jobs to deploy it. In two minutes nib split it into four stages with ten steps, fifteen checks and a rollback plan. The agent prepared AWS and GitHub access on its own over MCP, the executor wrote the Terraform and the workflows, and we worked through two failures on the way, the OIDC provider and the healthcheck, together with nib. At the end we opened the site and saw the store working.</p>
<p>Back to the three problems from Part 1:</p>
<ul>
<li><strong>Items don't get lost.</strong> The action list is written down in the plan. You can see it, hand it to a colleague and pick up where you left off.</li>
<li><strong>Checks don't get skipped.</strong> They're written into every stage in advance, and the agent isn't tempted to save time on them. Still, the OIDC story showed that a check is only as useful as it is precise.</li>
<li><strong>The size is visible before you start.</strong> You know how many stages and steps are ahead before the first command runs.</li>
</ul>
<p>The decisions stay with you: you choose which steps to run, you read the PR, and you accept the result.</p>
<p>Even if you don't plan to use agents, try writing the plan for your next task in three columns: action, check, rollback. That alone solves the first problem and makes the second one much easier.</p>
<p>In Part 1 I said that an engineer relies on team rules and their own experience. You can give nib the same things. In the next post I'll show how to connect your own rules, skills and knowledge base, so the plans come out more accurate and closer to how your team works.</p>
<p>The repository is here: <a href="https://github.com/javdet/nib">github.com/javdet/nib</a>. Try it on a task of your own, and tell me in the comments: which infrastructure task would you trust to an agent first, and which one never?</p>
]]></content:encoded></item><item><title><![CDATA[Hey Agent, Fix My Build": Building a First-Line Support Desk on n8n. Part 3]]></title><description><![CDATA[We're one step away from shipping a new version of our game. The number of changes across services and infrastructure has multiplied, and everyone is…]]></description><link>https://auto-magic-ops.hashnode.dev/hey-agent-fix-my-build-building-a-first-line-support-desk-on-n8n-part-3</link><guid isPermaLink="true">https://auto-magic-ops.hashnode.dev/hey-agent-fix-my-build-building-a-first-line-support-desk-on-n8n-part-3</guid><dc:creator><![CDATA[Sergey Byvshev]]></dc:creator><pubDate>Wed, 19 Aug 2026 01:23:55 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/6825176d-7f43-4ab1-8908-26a3261ec829.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>We're one step away from shipping a new version of our game. The number of changes across services and infrastructure has multiplied, and everyone is racing the deadline. The alert channels are a solid wall of red, and in the support channel everybody urgently wants to know why the build broke — and, naturally, to have it fixed right now.</p>
<p>Diagnosis is already handled by the agent from <a href="https://auto-magic-ops.hashnode.dev/building-an-ai-first-line-for-devops-support-on-n8n-for-250-month-part-1">Part 1</a> and <a href="https://auto-magic-ops.hashnode.dev/building-an-ai-first-line-for-devops-support-on-n8n-for-250-month-part-2">Part 2</a> — but that's only half the job. The tedious part starts after: the engineer has to work out where exactly the change belongs, judge whether they can make it themselves or need a developer, find the right repository among dozens of similar ones, apply the fix, push it through a PR and a review. During a release crunch those "just fifteen more minutes" per request add up to hours, and the context switching finishes off whatever concentration is left.</p>
<p>Sound familiar? Here's what it looked like for us: the agent produces a diagnosis in two minutes — "the workflow isn't passing the environment input" — and then the engineer spends another half hour figuring out which of the reusable workflows to fix it in.</p>
<p>That's why we decided to go further and make it possible to simply say:</p>
<blockquote>
<p>Hey agent, fix my build.</p>
</blockquote>
<p>In this part I'll cover how we:</p>
<ul>
<li><p>taught the system to distinguish two phases of a request — <em>analysis</em> and <em>execution</em> — and extended the classifier with execute categories;</p>
</li>
<li><p>added Postgres as state storage: every thread now has its own small state machine;</p>
</li>
<li><p>moved to <a href="https://agentgateway.dev/">Agentgateway</a> for MCP server access, with JWT authentication and per-tool authorization;</p>
</li>
<li><p>chose how to run coding agents in Kubernetes;</p>
</li>
<li><p>built two executor workflows — for CI/CD and for incidents — plus a callback that returns the agent's result back into the thread.</p>
</li>
</ul>
<p>All workflows and system prompts are published separately — link at the end of the article.</p>
<details>
<summary>Agent request example</summary>
<img class="rounded-lg max-w-full h-auto" src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/79eb3f1a-7279-4085-ac06-2b99ab3240b1.png" /><img class="rounded-lg max-w-full h-auto" src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/72778b92-05e5-4df5-9e28-824df2d625ca.png" /><img class="rounded-lg max-w-full h-auto" src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/435e4de5-47d3-44de-b456-42c4148b2223.png" />
</details>

<h2>Previously on this series</h2>
<p>A quick recap for anyone joining now. We run a first-line support desk on n8n: a bot in Slack receives requests, an LLM classifier sorts them into categories, and specialized branches then handle CI/CD failures, incidents, infrastructure questions and ticket creation in Jira. Everything runs on MCP tools (GitHub, Kubernetes, Grafana, DigitalOcean, Qdrant) and costs roughly $250 a month. Details are in <a href="https://auto-magic-ops.hashnode.dev/building-an-ai-first-line-for-devops-support-on-n8n-for-250-month-part-1">Part 1</a> and <a href="https://auto-magic-ops.hashnode.dev/building-an-ai-first-line-for-devops-support-on-n8n-for-250-month-part-2">Part 2</a>.</p>
<p>The key limitation of that system: the agents were strictly read-only. They looked at logs, metrics and code, wrote reports — and changed nothing. Now we're giving the agent hands. For CI/CD problems an executor agent can fix the code and open a PR itself; for incidents it can restart a workload or adjust a configuration.</p>
<p>Spoiler: "giving the agent hands" turned out to be 20% about prompts and 80% about plumbing. It required extending the classifier, adding a database, callbacks and several new workflows. Let's go through it in order.</p>
<h2>What the full cycle looks like now</h2>
<p>The main architectural change is that a request now has a lifecycle. Each Slack thread maps to a single task that moves through a set of states (this applies only to incident and CI/CD requests).</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/d731c82a-4776-456f-8378-1e00a3046f4b.png" alt="" style="display:block;margin:0 auto" />

<p>The full path looks like this:</p>
<ol>
<li><p>The first message in a thread goes into the <strong>analysis phase</strong>: the read-only agent from the previous parts investigates the problem and posts a report.</p>
</li>
<li><p>If a fix requires changes, the task moves to <code>verdict_ready</code> and the system waits for a human.</p>
</li>
<li><p>The engineer reads the report in the thread and replies "go ahead" (or any other message asking for the fix) — the request enters the <strong>execution phase</strong>.</p>
</li>
<li><p>The executor workflow launches a Kubernetes Job with a coding agent inside, which makes the changes and opens a PR — or carefully fixes something directly in the cluster.</p>
</li>
<li><p>On completion the Job calls a webhook, the callback closes the task and posts the final report into the same thread.</p>
</li>
</ol>
<p>The phase is decided by the status in the database, not by the model. The first message in a thread goes to analysis; any subsequent message, while the task sits in <code>verdict_ready</code>, is reclassified into its execute variant and routed to the executor. The LLM proposes a category, but the <code>_execute</code> suffix is computed in code from the status. A model hallucination can neither trigger execution early nor push a task with a ready verdict back into analysis.</p>
<p>The human stays in the loop. No task reaches execution without an explicit reply from an engineer in the thread. The agent never decides on its own that it's "time to fix things" — it proposes, a human approves. A cheap safeguard, but a fundamental one.</p>
<h2>State storage: Postgres</h2>
<p>While the agents were read-only, state could be ignored: a workflow crashed — fine, the user would ask again. The moment an agent gets the right to make changes, questions appear that you can't answer without a database. Are we about to start two agents on the same task? How many times may a failed execution be retried? Who changed what, when, and at what cost?</p>
<p>For that we created an <code>agent_tasks</code> table in an external Postgres. One thread — one active row:</p>
<details>
<summary>Schema</summary>
<pre class="not-prose"><code class="language-sql">CREATE TABLE public.agent_tasks (
    task_id uuid DEFAULT gen_random_uuid() NOT NULL,
    thread_root_id text NOT NULL,
    channel_id text NOT NULL,
    requested_by text NOT NULL,
    task_type text NOT NULL,
    status text NOT NULL,
    repos jsonb DEFAULT '[]'::jsonb NOT NULL,
    verdict jsonb,   
    agent_brief jsonb, 
    fail_count integer DEFAULT 0 NOT NULL,
    k8s_job_name text,
    error text,
    execution jsonb,
    execution_status text,
    execution_exit_code integer,
    execution_result text,
    execution_session_id text,
    execution_cost_usd numeric(12,6),
    execution_duration_ms bigint,
    pr_url text,
    created_at timestamp with time zone DEFAULT now(),
    updated_at timestamp with time zone DEFAULT now(),
    finished_at timestamp with time zone,
    CONSTRAINT agent_tasks_status_chk CHECK ((status = ANY (ARRAY['new'::text,
        'analyzing'::text, 'verdict_ready'::text, 'executing'::text,
        'pr_created'::text, 'done'::text, 'failed'::text, 'cancelled'::text])))
);
</code></pre><p><code>ALTER TABLE ONLY public.agent_tasks<br />ADD CONSTRAINT agent_tasks_pkey PRIMARY KEY (task_id);</code></p><p><code>CREATE UNIQUE INDEX agent_tasks_active_thread_uniq ON public.agent_tasks<br />USING btree (thread_root_id)<br />WHERE (status &lt;&gt; ALL (ARRAY['done'::text, 'failed'::text, 'cancelled'::text]));</code></p><p><code>CREATE INDEX agent_tasks_thread_created_idx ON public.agent_tasks<br />USING btree (thread_root_id, created_at DESC);</code></p><p></p>
</details>

<p>A few details here carry most of the weight:</p>
<ul>
<li><p><strong>The CHECK constraint on</strong> <code>status</code> <strong>is the state machine.</strong> An invalid transition simply won't be written.</p>
</li>
<li><p><strong>A partial unique index</strong> guarantees exactly one unfinished task per thread. Two consecutive messages physically cannot spawn two parallel executions — even if there is a race somewhere in the workflow code.</p>
</li>
<li><p><code>fail_count</code> is the retry budget: a failed task can be revived by the next message in the thread, but not indefinitely.</p>
</li>
<li><p><strong>The</strong> <code>execution_*</code> <strong>fields</strong> are the audit trail: cost, duration, session id and the PR link. After a month in production, these are exactly what tells you what the whole undertaking costs.</p>
</li>
</ul>
<h2>The classifier: two new categories</h2>
<p>Two execute categories were added to <code>devopsChatAssistant</code> from Part 1:</p>
<ul>
<li><p><code>ci_cd_error_execute</code> — applying fixes following the analysis of a failed pipeline;</p>
</li>
<li><p><code>incident_executor</code> — applying fixes following an incident investigation.</p>
</li>
</ul>
<p>As mentioned above, these categories are chosen not by the model but by a database query: if the thread already has a task in <code>verdict_ready</code>, the request goes to the execute branch. The classifier turned into a "generate → validate → act" pattern: the model proposes a category, a Code node re-checks it against the row in the database and overrides it when the two disagree. Debug flags (<code>_category_overridden</code>, <code>_model_category</code>) stay in the execution logs — very helpful when the system's behaviour looks strange.</p>
<p>Another useful addition is the idempotent <code>FindOrCreateAgentTask</code> query. A single statement either finds the thread's live task, creates a new one, or revives a failed one (as long as <code>fail_count</code> isn't exhausted). Worth calling out separately: rescuing tasks stuck in <code>executing</code> for more than 30 minutes. An executor that died without writing a terminal status no longer blocks the thread forever. We added that logic right after the first time a Job quietly died on OOM and the thread hung until a manual UPDATE in the database.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/92425410-ccd5-4352-9879-fadbfc9d68e0.png" alt="" style="display:block;margin:0 auto" />

<h2>Analyzers: one investigation, two documents</h2>
<p>The CI/CD and incident assistants stayed strictly read-only, but now each investigation writes two different artifacts to the database.</p>
<p><code>verdict</code> <strong>— the report for humans.</strong> It keeps every hypothesis the model considered, the evidence for each, a human-readable description of the fix, and the assumptions made. The engineer needs it in the thread right now — and so does whoever asks three weeks later why the agent opened that odd PR.</p>
<p><code>agent_brief</code> <strong>— the machine contract for the executor.</strong> Exactly one cause, one repository, one goal and a self-contained instruction:</p>
<pre><code class="language-json">{
  "version": 1,
  "category": "ci_cd_error",
  "objective": "fix(ci): pass the environment input to the deploy job",
  "instructions": "A step-by-step, self-contained description of the change...",
  "risk": "low",
  "repo": "acme/backend-api",
  "extra_repos": [],
  "base_branch": "main"
}
</code></pre>
<p>Why the split? We found out quickly that feeding the executor the full report with alternative hypotheses is a bad idea: the agent starts "choosing" between options and behaves non-deterministically. So the verdict with all its reasoning stays with the humans, and the executor gets a distilled version with no room for interpretation. One important nuance: <code>instructions</code> are written as if the executor had no access to the conversation — because it genuinely doesn't.</p>
<p>The <code>needs_code_change</code> flag that moves a task to <code>verdict_ready</code> is also set by a parser rather than by the model: only if the model declared that changes are needed <strong>and</strong> at least one cause with a repository and instructions survived validation. If no changes are needed, the task closes as <code>done</code> and the thread is released.</p>
<p>The incident assistant's <code>agent_brief</code> has an extra field, <code>action_kind</code>, with two values:</p>
<ul>
<li><p><code>code_change</code> — the fix lives in a repository: the agent clones it, applies the change and opens a PR;</p>
</li>
<li><p><code>k8s_operation</code> — the fix lives in the cluster: restart a workload, roll back a configuration, change the replica count.</p>
</li>
</ul>
<h2>Agent Gateway</h2>
<p>Our single entry point to the MCP servers used to be Envoy Gateway. It works, but it isn't especially convenient: it doesn't let you draw flexible boundaries between different servers and individual tools. And as soon as agents gain write access, the question "who has access to which tool" stops being theoretical.</p>
<p>We moved to <a href="https://github.com/agentgateway/agentgateway">Agentgateway</a> alongside Envoy Gateway. What it gives us:</p>
<ul>
<li><p>authentication via JWT tokens or through an IdP (Keycloak, Auth0 and so on);</p>
</li>
<li><p>authorization down to an individual tool — rules are written in CEL and can rely on token claims;</p>
</li>
<li><p>federation of several MCP servers behind one endpoint: tools automatically get the server name as a prefix (<code>kubernetes_pods_get</code>, <code>grafana_get_datasource</code>), so an agent can be granted tool groups by the server they belong to.</p>
</li>
</ul>
<p>In practice this means the read-only analyst and the executor go through the same gateway but with different tokens — and see different sets of tools.</p>
<p>Installing Agentgateway:</p>
<pre><code class="language-shell">helm upgrade -i agentgateway-crds oci://cr.agentgateway.dev/charts/agentgateway-crds \
  --create-namespace --namespace agentgateway-system \
  --version v1.2.1 \
  --set controller.image.pullPolicy=Always

helm upgrade -i agentgateway oci://cr.agentgateway.dev/charts/agentgateway \
  --namespace agentgateway-system \
  --version v1.2.1 \
  --set controller.image.pullPolicy=Always \
  --set controller.extraEnv.KGW_ENABLE_GATEWAY_API_EXPERIMENTAL_FEATURES=true
</code></pre>
<p>Agentgateway has a dependency: you need one of the Gateway API implementations installed in the cluster.</p>
<p>A minimal configuration:</p>
<details>
<summary>Manifests.</summary>
<pre class="not-prose"><code class="language-yaml">---
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayParameters
metadata:
  name: tool-gw
  namespace: ai-infra
spec:
  service:
    spec:
      type: ClusterIP

<hr />
<p>apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayBackend
metadata:
name: mcp
namespace: ai-infra
spec:
mcp:
failureMode: FailOpen
targets:</p>
<ul>
<li>name: grafana
static:
host: grafana-mcp.ai-infra.svc.cluster.local
port: 80
protocol: StreamableHTTP</li>
</ul>
<hr />
<p>apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: mcp
namespace: ai-infra
spec:
parentRefs:</p>
<ul>
<li>name: tool-gw
namespace: ai-infra
rules:</li>
<li>matches:</li>
<li>path:
type: PathPrefix
value: /mcp
backendRefs:</li>
<li>group: agentgateway.dev
kind: AgentgatewayBackend
name: mcp</li></ul></code></pre><hr /><p></p></details>

<p>This is the bare minimum to get access working. The next thing to add is an <a href="https://deepwiki.com/agentgateway/agentgateway/15.2-agentgatewaypolicy-crd">AgentgatewayPolicy</a> with JWT or IdP authentication. All MCP servers deployed in the cluster are listed in the <code>AgentgatewayBackend</code>.</p>
<p>The fly in the ointment is the GitHub MCP. We needed to pass a client's GitHub token through the gateway all the way to the MCP server, but the <code>Authorization</code> header is already taken by Agentgateway's own JWT, and we couldn't get pass-through authentication working for this case. In the end the GitHub MCP is wired to the agents directly, bypassing the gateway — not the most elegant solution, but a working one. If someone has beaten this scenario, tell me in the comments; I'm honestly curious.</p>
<h2>Running agents in Kubernetes</h2>
<p>Now the interesting part: where and how to run the coding agent that will make the changes. We looked at several options:</p>
<ol>
<li><p><a href="https://github.com/kube-foundry/kube-foundry">kube-foundry</a></p>
</li>
<li><p><a href="https://github.com/kagent-dev/kagent">kagent</a></p>
</li>
<li><p>plain Kubernetes Jobs</p>
</li>
</ol>
<h3>kube-foundry</h3>
<p>Kube Foundry is an operator that turns a cluster into a "factory" for coding agents: it accepts tasks through a CRD, spins up an isolated pod with an agent for each one (Claude Code by default, with Codex and OpenCode also supported), and the agent clones the repository, performs the task and opens a PR.</p>
<p>It installs in a couple of commands:</p>
<pre><code class="language-shell">helm repo add kube-foundry https://kube-foundry.github.io/kube-foundry
helm repo update
helm install kube-foundry kube-foundry/kube-foundry \
  --namespace kube-foundry --create-namespace

kubectl create secret generic factory-creds \
  --namespace kube-foundry \
  --from-literal=ANTHROPIC_API_KEY=sk-ant-... \
  --from-literal=GITHUB_TOKEN=ghp_...
</code></pre>
<p>A task is described by a <code>SoftwareTask</code> resource:</p>
<pre><code class="language-yaml">apiVersion: factory.factory.io/v1alpha1
kind: SoftwareTask
metadata:
  # имя задаёт и рабочую ветку: factory/pg17-change
  name: pg17-change
  namespace: ai-infra
spec:
  agent: claude-code
  repo: https://github.com/your-org/ansible-roles
  # базовая ветка для клонирования и таргет для PR
  branch: main
  task: Update ansible variable postgres_version to 17
  credentials:
    secretRef: factory-creds
  maxRetries: 1
  resources:
    cpu: "1"
    memory: 1Gi
    timeoutMinutes: 30
</code></pre>
<p>From there the operator drives the task through the <code>Pending → Running → Completed</code> phases and puts the PR link into the resource status. For a quick start it's an excellent option with minimal plumbing.</p>
<h3>Why we ended up with plain Jobs</h3>
<p>Unfortunately, the ready-made solutions lacked the flexibility our requirements needed. The main problem: there's nowhere to pass a list of allowed and denied tools, and without that the agent's context balloons (more on this below). On top of that, repository cloning and PR creation didn't always work reliably in our cases. kagent, meanwhile, is aimed more at agents that live permanently in the cluster than at one-shot "clone it, fix it, open a PR" tasks.</p>
<p>So we settled on plain Kubernetes Jobs: full control over the image, the entrypoint, RBAC and the lifecycle. We built our own image — Claude Code, git and gh, plus an entrypoint script that clones the repository, runs the agent with the prompt from an environment variable, pushes the branch, opens a PR and, once finished, calls a webhook with the result. The Job receives every parameter it needs, including that callback webhook.</p>
<details>
<summary>Dockerfile</summary>
<pre class="not-prose"><code class="language-plaintext">
FROM node:24-trixie-slim
ARG IMAGE_VERSION
ENV IMAGE_VERSION=${IMAGE_VERSION}
ENV DEBIAN_FRONTEND=noninteractive

<p>ENV ANTHROPIC_MODEL=anthropic/claude-opus-5</p>
<p>ARG GH_VERSION=2.97.0
ARG TARGETARCH</p>
<p>RUN apt-get update
&amp;&amp; apt-get install -y --no-install-recommends
ca-certificates
curl
gettext-base
git
jq
less
&amp;&amp; case "${TARGETARCH}" in
amd64) GH_ARCH=amd64 ;;
arm64) GH_ARCH=arm64 ;;
) echo "unsupported TARGETARCH: ${TARGETARCH}" &gt;&amp;2; exit 1 ;;</p>
<p>esac
&amp;&amp; curl -fsSL "<a href="https://github.com/cli/cli/releases/download/v$%7BGH_VERSION%7D/gh_$%7BGH_VERSION%7Dlinux$%7BGH_ARCH%7D.tar.gz">https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}linux${GH_ARCH}.tar.gz</a>"
| tar -xz -C /tmp
&amp;&amp; mv "/tmp/gh_${GH_VERSION}linux${GH_ARCH}/bin/gh" /usr/local/bin/gh
&amp;&amp; rm -rf "/tmp/gh_${GH_VERSION}linux${GH_ARCH}"
&amp;&amp; rm -rf /var/lib/apt/lists/</p>
<p>ARG CLAUDE_CODE_VERSION=2.1.220
RUN npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"</p>
<p>COPY --chmod=0755 agent-entrypoint.sh /usr/local/bin/agent-entrypoint.sh</p>
<p>RUN mkdir -p /workspace &amp;&amp; chown -R node:node /workspace
WORKDIR /workspace
USER node</p>
<p>ENTRYPOINT ["/usr/local/bin/agent-entrypoint.sh"]</p></code></pre><p></p><p></p>
</details>

<p>Job template (abbreviated; full version is in the repository):</p>
<details>
<summary>job.yaml</summary>
<pre class="not-prose"><code class="language-yaml">apiVersion: batch/v1
kind: Job
metadata:
  name: ${JOB_NAME}
  namespace: ${NAMESPACE}
  labels:
    app.kubernetes.io/name: claude-code-agent
    app.kubernetes.io/part-of: ai-infra
    devops-ai/task-type: ${TASK_TYPE_LABEL}
    devops-ai/thread-root: ${THREAD_ROOT_LABEL}
spec:
  backoffLimit: 0
  activeDeadlineSeconds: 1800
  template:
    metadata:
      annotations:
        devops-ai/task-id: ${TASK_ID}
    spec:
      restartPolicy: Never
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        fsGroup: 1000
      containers:
        - name: agent
          image: registry.example.com/infra/claude-code-agent:v1.0.1
          env:
            - name: PROMPT
              value: ${PROMPT}
            - name: REPO_URL
              value: ${REPO_URL}
            - name: BASE_BRANCH
              value: ${BASE_BRANCH}
            - name: TARGET_BRANCH
              value: ${TARGET_BRANCH}
            - name: PR_TITLE
              value: ${PR_TITLE}
            - name: MCP_CONFIG_FILE
              value: /config/mcp.json
            - name: ALLOWED_TOOLS
              value: Read,Write,Edit,Grep,Glob,Bash,mcp__tool-gw__kubernetes_pods_get,mcp__tool-gw__kubernetes_pods_list,mcp__tool-gw__kubernetes_resources_get,actions_get,get_file_contents,search_code
            - name: PERMISSION_MODE
              value: acceptEdits
            - name: TIMEOUT_SECONDS
              value: "1500"
            - name: THREAD_ROOT_ID
              value: ${THREAD_ROOT_ID}
            - name: WEBHOOK_URL
              value: ${WEBHOOK_URL}   # коллбэк в n8n, URL — секрет
            - name: ANTHROPIC_API_KEY
              valueFrom:
                secretKeyRef: { name: agent-secret, key: ANTHROPIC_API_KEY }
            - name: GITHUB_TOKEN
              valueFrom:
                secretKeyRef: { name: agent-secret, key: GITHUB_TOKEN }
          resources:
            requests: { cpu: 500m, memory: 1Gi, ephemeral-storage: 10Gi }
            limits: { cpu: "1", memory: 2Gi, ephemeral-storage: 10Gi }
          volumeMounts:
            - { name: workspace, mountPath: /workspace }
            - { name: config, mountPath: /config, readOnly: true }
      volumes:
        - name: workspace
          emptyDir: {}
        - name: config
          configMap:
            name: agent-mcp-config</code></pre>
</details>

<p>Note <code>backoffLimit: 0</code> and <code>activeDeadlineSeconds</code>: retrying an agent that makes changes means getting a second uncontrolled change, and a fix that has been running for half an hour isn't fixing anything anymore.</p>
<h3>The trap of too many tools</h3>
<p><code>ALLOWED_TOOLS</code> deserves a separate note. This allowlist limits what the agent may call — but the descriptions of <em>all</em> tools the MCP server exposes are still loaded into the context. You can't trim the tool list on the Claude Code side, so it has to be cut at the Agentgateway level. Passing the full list can leave the agent unable to start at all: that's exactly what happened to us with 320 available tools — the context overflowed before the first useful action.</p>
<p>The MCP config is mounted from a shared ConfigMap:</p>
<details>
<summary>configmap.yaml</summary>
<pre class="not-prose"><code class="language-yaml">apiVersion: v1
kind: ConfigMap
metadata:
  name: agent-mcp-config
  namespace: ai-infra
data:
  mcp.json: |
    {
      "mcpServers": {
        "tool-gw": {
          "type": "http",
          "url": "http://tool-gw.ai-infra.svc.cluster.local:8080/mcp",
          "headers": {
            "Authorization": "Bearer ${MCP_GW_TOKEN}"
          }
        }
      }
    }</code></pre>
</details>

<h3>RBAC</h3>
<p>And the governing principle of the whole execution side: <strong>the security boundary is RBAC, not the prompt.</strong> Instructions like "prefer reversible operations" and "never delete a PVC" are wishes addressed to a language model, which can be wrong or be talked out of them. The real boundary is the ServiceAccount permissions, and there are two of them here, each with its own role:</p>
<ul>
<li><p><strong>The ServiceAccount n8n uses to create Jobs</strong> — has <code>create</code> on <code>batch/jobs</code> in a single namespace and nothing else;</p>
</li>
<li><p><strong>The agent's own ServiceAccount</strong> — granted a separate Role in the target namespace: reading state, <code>patch</code>/<code>update</code> on deployments and scale, <code>delete</code> on pods (that's how a restart is done). Secrets, PVCs and anything cluster-scoped are deliberately absent.</p>
</li>
</ul>
<blockquote>
<p>Code: Role and RoleBinding for both ServiceAccounts</p>
</blockquote>
<p>Write these permissions as if the prompt didn't exist. A new verb gets added only when a real incident has proved there's no way around it.</p>
<h1>CI/CD Executor</h1>
<p>When a message asking to apply the proposed changes appears in a thread whose task is in verdict_ready, the DevopsCICDExecutor workflow starts. There isn't a single LLM call inside it — everything is deterministic: claim the task in the database, render the manifest, make one REST call to the Kubernetes API. The intelligence already did its work in the analysis phase; the next intelligence will start inside the Job.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/9d79c09d-c4eb-4ec3-9355-fe2c28274698.png" alt="" style="display:block;margin:0 auto" />

<p>A few decisions here that pay for themselves in reliability:</p>
<p><strong>Atomic task claim.</strong> The first step is a single <code>UPDATE ... WHERE status = 'verdict_ready' RETURNING *</code>: this reads the task and locks it at the same time. Zero rows in the response means a parallel run already won the race — the workflow simply exits quietly instead of launching a duplicate.</p>
<p><strong>Structured outcomes instead of exceptions.</strong> Every path ends in one of three outcomes: ready (the brief is valid, launch the Job), failed (the task was claimed but can't be executed — status failed, fail_count + 1) or rejected (nothing was claimed, or the Job already exists — the database isn't touched). The difference between failed and rejected isn't cosmetic: fail_count is a retry budget, and burning it on a race that never even attempted execution would be a shame.</p>
<p>The manifest is code. The Job template lives in git, not in a JavaScript node, and is rendered by plain <code>${PLACEHOLDER}</code> substitution. The agent′s ServiceAccount, secrets, limits and image tag go through review like any other infrastructure change—because the rendered manifest is the full extent of the agent′s power.</p>
<p>Validation at render time runs in both directions: every <code>{TOKEN}</code> in the template must have a value, and every required placeholder must be present in the template. The second check wasn't born out of good times: a template with a hardcoded metadata.name renders perfectly and only fails on the second run with HTTP 409 — and debugging that from n8n logs is a below-average way to spend an afternoon.</p>
<h2>Incident Executor</h2>
<p><code>DevopsIncidentExecutor</code> follows the same scheme but branches on <code>action_kind</code> from the brief: <code>code_change</code> ends in a pull request, <code>k8s_operation</code> in a verified cluster state. The differences from its CI/CD sibling:</p>
<ul>
<li><p><strong>the task claim is typed</strong> — <code>... AND task_type = 'incident'</code>, so the executors can't steal each other's tasks;</p>
</li>
<li><p><code>activeDeadlineSeconds</code> <strong>is stricter</strong>: an incident fix that has been running for half an hour isn't fixing anything anymore — it should be killed and handed back to a human;</p>
</li>
<li><p><strong>for</strong> <code>k8s_operation</code> <strong>the target is described explicitly in</strong> <code>instructions</code> (context, namespace, workload, verification criterion) — the agent has no access to the investigation conversation and nothing to infer from.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/f83ce06f-a271-49e1-b3da-d5b93dd862e0.png" alt="" style="display:block;margin:0 auto" />

<h2>Callback</h2>
<p>Anything that launches a long-running Job must, sooner or later, hear its result. The naive option — polling the Kubernetes API and scraping pod logs — is fragile: logs rotate, pods get collected by the garbage collector, and the polling loop itself is one more thing that can die. So the Job reports its own result: when the agent finishes (successfully or not), the entrypoint sends n8n a webhook like this:</p>
<pre><code class="language-json">{
  "status": "success | failed",
  "exit_code": 0,
  "result": "Agent Progress Report (markdown)",
  "log_tail": "The last lines of the log are added only in case of an error.",
  "thread_root_id": "Correlation key: it is used to locate the thread and row in the database",
  "job": {
    "name": "claude-code-agent-incident-bc8498aa-msq5etlu",
    "pod": "claude-code-agent-incident-bc8498aa-msq5etlu-c52mk",
    "namespace": "ai-infra"
  },
  "repo": {
    "url": "The repository where the changes took place",
    "base_branch": "main",
    "target_branch": "agent/claude-code-agent-incident-bc8498aa-msq5etlu",
    "pushed": true,
    "pr_url": "Link to PR"
  },
  "usage": {
    "duration_ms": 98826,
    "num_turns": 16,
    "total_cost_usd": 1.491589,
    "session_id": "ec08771e-0e84-4063-9668-a9e5e8f3e756"
  }
}
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/f0e8fcd7-7a52-4680-8ff6-c495a705864a.png" alt="" style="display:block;margin:0 auto" />

<p>The receiving workflow is small, but it's exactly what closes the loop. Without it every task stays in <code>executing</code> forever and the thread stops accepting new requests. A few principles worth stealing even if you never run a coding agent:</p>
<ul>
<li><p><strong>Correlate first, trust second.</strong> The <code>channel_id</code> for the reply is read from the row in the database, not from the payload. Whoever calls the webhook controls <em>what</em> is said, but not <em>where</em>.</p>
</li>
<li><p><strong>A terminal status is a lock release.</strong> The UPDATE carries the condition <code>WHERE status NOT IN ('done','failed','cancelled')</code>: a repeated callback won't re-close the task or overwrite a resolution that already happened.</p>
</li>
<li><p><strong>The report gets chunked.</strong> An agent report easily exceeds the Slack message limit, so the message builder splits it on line boundaries and posts it as a chain in the same thread.</p>
</li>
</ul>
<h2>What we ended up with</h2>
<p>After a couple of months of the execution side running in production:</p>
<ul>
<li><p>A typical executor run takes a minute and a half to two minutes and 15–20 agent iterations; one fix costs roughly <strong>$1.5</strong> in LLM calls.</p>
</li>
<li><p>The path from "the build is broken" to an open PR is a matter of minutes, with no context switch for the engineer: the diagnosis already exists from the analysis phase, and the changes arrive ready for review.</p>
</li>
<li><p>The total cost of the system rose to about <strong>$400 a month</strong>, up from $250. Set against the engineering hours that the "find the repo — apply the fix — open a PR" routine eats during a release crunch, that's still a small price.</p>
</li>
</ul>
<h2>What we haven't managed yet</h2>
<p>As usual, an honest list of the rough edges we're living with:</p>
<ul>
<li><p><strong>Complex cases still have to be fixed by hand.</strong> Most likely because the agent lacks the full picture: the brief is self-contained but narrow, and the context of the whole system doesn't fit into it.</p>
</li>
<li><p><strong>There's no concurrency limit.</strong> Ten threads reaching the execution phase at once means ten Jobs in the cluster. For now we're covered by a ResourceQuota on the namespace, but a proper queue is asking to be built.</p>
</li>
<li><p><strong>For</strong> <code>k8s_operation</code> <strong>the goal is described in prose.</strong> There's no structured field with target resources, so the executor can't validate that the agent stayed within bounds — here the only real hope is RBAC.</p>
</li>
</ul>
<h2>Conclusion</h2>
<p>The main takeaway from these two months: "giving the agent hands" is first and foremost engineering plumbing, not prompt engineering. A state machine in the database, idempotent queries, atomic claims, a contract between analysis and execution, RBAC as the only real boundary — that's what a system you can trust with a "do it" button is made of. The agent itself is almost a replaceable part in that construction.</p>
<p>The second takeaway: the human in the loop isn't a bottleneck, it's a feature. The agent proposes, the engineer approves with a single message, the changes arrive as a PR against a protected branch. Almost no speed is lost, and sleeping at night is considerably easier.</p>
<p>All the manifests and workflows described here are available in the repository: <a href="https://github.com/javdet/automagicops-workflows">https://github.com/javdet/automagicops-workflows</a></p>
]]></content:encoded></item><item><title><![CDATA[Building an AI First-Line for DevOps Support on n8n for $250/Month - Part 2]]></title><description><![CDATA[In Part 1, I walked through how chat requests in Slack land in n8n, get classified by category, and are routed into the right processing branch. Under the…]]></description><link>https://auto-magic-ops.hashnode.dev/building-an-ai-first-line-for-devops-support-on-n8n-for-250-month-part-2</link><guid isPermaLink="true">https://auto-magic-ops.hashnode.dev/building-an-ai-first-line-for-devops-support-on-n8n-for-250-month-part-2</guid><dc:creator><![CDATA[Sergey Byvshev]]></dc:creator><pubDate>Fri, 14 Aug 2026 13:51:42 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/751424a9-0fc7-4a2f-8380-25d45d476170.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In <a href="https://hashnode.com/edit/cmssz8tp600000bko9punbrel">Part 1</a>, I walked through how chat requests in Slack land in n8n, get classified by category, and are routed into the right processing branch. Under the hood of the classifier sits an LLM with access to Slack over MCP — it reads the thread context and decides what the new request is about. Around it run a few helper sub-workflows:</p>
<ul>
<li><p><code>attachmentsAnalyzer</code> — parses screenshots and text logs;</p>
</li>
<li><p><code>httpProbeTool</code> — performs endpoint availability checks correctly, without taking the agent chain down with it;</p>
</li>
<li><p><code>errorReporter</code> — covers us when the workflow itself fails so the requester isn't left in the dark.</p>
</li>
</ul>
<p>Using the CI/CD assistant as an example, I showed how those building blocks compose into a processing branch. Today — the three remaining branches. Each handles a different class of requests, but architecturally they follow the same template. Thanks to that, adding a new branch takes a couple of hours rather than reinventing the wheel from scratch.</p>
<p>In this part:</p>
<ul>
<li><p><strong>Incident investigator</strong> — the most temperamental category, with the lowest rate of fully autonomous resolution and the most interesting edge cases;</p>
</li>
<li><p><strong>Task manager</strong> — handling infrastructure modification requests with automatic ticket creation in Jira;</p>
</li>
<li><p><strong>Infrastructure knowledge assistant</strong> — answers to "where is X configured?" with a small trick involving auto-generated READMEs in IaC repos.</p>
</li>
</ul>
<p>All workflows and system prompts are published separately — link at the end.</p>
<h2>Incident Investigator</h2>
<p>This is probably the most "temperamental" branch of them all. The <code>incident</code> category catches pretty much anything that means "something just broke": a hung Postgres, 502s on the ingress controller, sudden OOMs in some consumer, mysterious "all my requests are slow but my neighbor's are fine." The spectrum is huge, there's no universal recipe — so the rate of fully autonomous resolution here is the lowest across all branches.</p>
<p>But even when the automation doesn't close the problem end-to-end, the dossier it puts together saves the on-call engineer 10–15 minutes at the start: firing alerts are already pulled, metrics and logs are already eyeballed, hypotheses are already framed. When you get dragged into an on-call rotation on a Saturday morning, those 10 minutes are sometimes the difference between "I had time to wake up" and "I'm already answering in chat with coffee in one hand."</p>
<img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6bb2e9nby0ysfksxk3g3.png" alt="Incident" style="display:block;margin:0 auto" />

<h3>Input data</h3>
<p>The sub-workflow expects the same input structure as the CI/CD assistant from Part 1. To save you from jumping between articles — a short description of the fields:</p>
<pre><code class="language-json">{
  "message": "Chat request text",
  "post_id": "Message ID — needed to reply into that exact thread",
  "channel_id": "Slack channel ID",
  "channel_name": "Channel name, passed into the prompt for context",
  "user_name": "Author of the request, mentioned in the final reply",
  "user_id": "User ID",
  "file_ids": ["Attachment IDs, if any"],
  "category": "incident",
  "confidence": 0.95,
  "summary": "Short summary from the classifier",
  "is_thread": true,
  "thread_root_id": "ID of the thread's root message",
  "on_call_user": "On-call engineer name — comes in handy for escalation"
}
</code></pre>
<p>First, <code>attachmentsAnalyzer2</code> runs — the same sub-workflow familiar from Part 1 that parses screenshots and logs. Triggering it only requires passing <code>file_ids</code>. If there are no attachments, the empty-attachments branch goes through a Merge node, and the pipeline doesn't crash on missing data.</p>
<h3>Collecting variables in SetVars</h3>
<p>Next, the <code>SetVars</code> node assembles everything that'll be needed both in the agent's system prompt and when posting the reply back to Slack. This part is worth pausing on, because these variables effectively parameterize the system prompt without forcing you to edit it by hand:</p>
<ul>
<li><p><code>K8S_CLUSTERS</code> — list of available contexts (we have two: dev and prod, both in DigitalOcean Frankfurt);</p>
</li>
<li><p><code>K8S_NAMESPACE</code> — the main namespace with production workload;</p>
</li>
<li><p><code>GITHUB_ORG</code> — GitHub organization name, so the agent doesn't try to search code across the entire internet;</p>
</li>
<li><p><code>prometheus_uid</code> and <code>loki_uid</code> — Grafana datasource UIDs; without them, the agent has no idea where to knock for metrics and logs;</p>
</li>
<li><p><code>reply_root_id</code> — ID of the message the agent will reply into (either the thread's root post or the original request post itself).</p>
</li>
</ul>
<p>These values then get substituted into the system prompt via <code>{{ $('SetVars').first().json.* }}</code>. When a new cluster comes up or the namespace changes, you just edit values in a single node — instead of crawling through the big block of prompt text.</p>
<h3>Querying the agent</h3>
<p>The user prompt is built with the same template as the CI/CD assistant:</p>
<pre><code class="language-handlebars">Investigate incident from {{ $json.user_name }}
in channel {{ $json.channel_name }}{{ $json.is_thread ? ' (message in thread, thread_ts=' + $json.thread_root_id + ' — read the history through Slack conversations.replies first)' : '' }}

{{ $json.message }}{{ $json.attachments_context &amp;&amp; $json.attachments_context.trim().length &gt; 0 ? '\n\nAdditional information from attachments:\n' + $json.attachments_context : '' }}
</code></pre>
<p>Three blocks inside it: the original message, an explicit instruction to read the thread history (if the request came from a thread), and context from attachments if any were present.</p>
<p>I use GPT-5.5 from OpenAI as the model. On this task, Sonnet and Gemini show comparable quality — the choice is more out of habit. What actually moves the needle isn't the model but the completeness of the system prompt and the toolset.</p>
<h3>Tools the agent has access to</h3>
<p>For the agent to make sense of an incident, it has to see the infrastructure through an engineer's eyes. When our on-call goes through a breakdown, the usual path is: "what do the alerts say → what's in the service logs → what's in the metrics → what releases went out → what's in the IaC." A corresponding MCP tool is wired up for each step:</p>
<ul>
<li><p><strong>Kubernetes MCP</strong> — look at pods, events, read container logs. The system prompt explicitly says: don't call <code>pods_log</code> for the same pod more than twice. Without that constraint, the agent loves to loop trying to "double-check just one more time."</p>
</li>
<li><p><strong>Grafana MCP</strong> — queries into Prometheus (<code>query_prometheus</code>) and Loki (<code>query_loki_logs</code>). Same tool also covers dashboard search, in case the agent wants to drop a link to a ready-made panel into the reply.</p>
</li>
<li><p><strong>DigitalOcean MCP</strong> — needed when the incident touches the infra layer: App Platform, droplets, the DOKS cluster, load balancers.</p>
</li>
<li><p><strong>GitHub MCP</strong> — look at the latest commits, Actions runs, open PRs. Especially useful for the "everything broke right after deploy" scenario — and these scenarios, like for everyone else, aren't rare.</p>
</li>
<li><p><strong>Slack MCP</strong> — read-only. Mostly used for <code>conversations.replies</code> at the very start of the investigation. We don't trust the agent with sending the reply itself — that's done by a separate node after the output is received. If something goes wrong at the posting step, the thread just stays without a final reply, but the execution doesn't crash.</p>
</li>
<li><p><strong>Qdrant Vector Store</strong> — knowledge base on our infrastructure: component descriptions, service relationships, naming conventions, useful labels. Used when the agent runs into the name of an unfamiliar service and wants to figure out where it lives and what it talks to.</p>
</li>
</ul>
<p>And as a separate line item — <code>prometheusAlertSearch</code>. This is a custom sub-workflow that the agent reaches for almost always within the first 2–3 steps of an investigation. It's worth describing in more detail.</p>
<h3>Alert tool: prometheusAlertSearch</h3>
<img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/r2gvyekxy8pnvuy3q7fn.png" alt="alerts" style="display:block;margin:0 auto" />

<p>The idea is simple: the lion's share of complaints from developers essentially mirror an alert that's already firing in Prometheus. "Postgres is kinda slow" usually arrives at the exact moment when <code>PostgresHighLatency</code> has already been firing for ten minutes. It's logical to first check whether the cause is sitting right on the surface — and only then go digging through logs.</p>
<p>The sub-workflow accepts keywords and a match mode:</p>
<pre><code class="language-json">{
  "keywords": ["postgres", "pgbouncer"],
  "match_mode": "any"
}
</code></pre>
<p>Inside, it queries Prometheus's <code>/api/v1/alerts</code> and, among the firing alerts, picks the ones where the keywords appear in the name, labels, or annotations. <code>match_mode: "any"</code> (default) is an OR across the keywords; <code>"all"</code> is AND.</p>
<p>It connects to the agent through the <code>toolWorkflow</code> node as a regular MCP-tool. The description for the agent is critically important — without it, the agent doesn't understand when and how to use this tool:</p>
<blockquote>
<p>Search currently firing alerts in Prometheus by keywords. Use early in investigation to find correlated active alerts across the platform.</p>
</blockquote>
<p>Input:</p>
<ul>
<li><p>keywords (array of strings, required): lowercase substrings matched against alert names, all label keys/values, and all annotation values. Examples: ["postgres"], ["http","5xx"], ["kafka","redpanda"].</p>
</li>
<li><p>match_mode (string, optional): "any" (default, OR) or "all" (AND).</p>
</li>
</ul>
<p>Returns: { ok, total_firing, matched_count, returned_count, truncated, alerts:[...] } Each alert has alertname, severity, labels, summary, description, activeAt, value. Output capped at 25 alerts — if truncated=true, refine keywords.</p>
<img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/28y6zzzbk9gmlmjev30z.png" alt="Image description" style="display:block;margin:0 auto" />

<p>Without an explicit "call this in the first 2–3 steps," the agent likes to first wander into logs, then into cluster events, then somewhere else — and only at the end remember about alerts. A hard hint in the tool description visibly changes the behavior.</p>
<h3>Response format</h3>
<p>The system prompt defines a strict output format:</p>
<blockquote>
<h3>What happened</h3>
</blockquote>
<p>Short description of the incident</p>
<h3>Timeline of events</h3>
<p>Events from 10 minutes before the problem appeared</p>
<h3>Likely causes</h3>
<p>At most two hypotheses</p>
<h3>What to try</h3>
<p>Step-by-step actions for each hypothesis</p>
<p>The structure mirrors the familiar incident-review format — easy to lift into a postmortem if the incident turns out to be significant, without rewriting. Average processing time per request and token consumption: roughly under 2 minutes end-to-end and 40–50K tokens depending on how deep the agent has to go. Cost-wise — peanuts compared to engineer time, especially considering that some of these requests used to require a call rather than just a chat exchange.</p>
<h2>Task Manager</h2>
<p>The "infrastructure modification" category covers requests like "roll us out a new service," "give us access to Grafana," "add a bucket for analytics." Full automation here is off the table: nearly every such request needs approval, estimation, and just plain human attention. But what definitely <em>can</em> be automated is turning free-form text into a properly formatted ticket.</p>
<p>The previous typical scenario looked like this: a developer writes in chat, the on-call reads it, asks clarifying questions, and writes all of it into Jira in their own words. Now Jira gets the request straight away — and the on-call gets a notification with a ready-made link.</p>
<img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ipurczzes06ogs8gkic7.png" alt="Image description" style="display:block;margin:0 auto" />

<p>The input is the same structure with fields from the classifier. Then — the familiar sequence: attachment parsing through <code>attachmentsAnalyzer2</code>, variable collection in <code>SetVars</code>, handoff to the LLM agent.</p>
<h3>Output format</h3>
<p>The distinctive part here is the strictly defined response structure. The agent must return JSON:</p>
<pre><code class="language-json">{
  "summary": "&lt;area&gt;: &lt;what needs to be done&gt;",
  "description": "&lt;1-3 sentences with details&gt;",
  "label": "&lt;one of the predefined directions&gt;"
}
</code></pre>
<p><code>label</code> is picked from a constrained dictionary: <code>kubernetes</code>, <code>monitoring</code>, <code>network</code>, <code>access</code>, <code>database</code>, <code>ci-cd</code>, and so on. This simplifies further task routing by engineer competence and gathering analytics on "who's working on what and how much of it."</p>
<p>For the agent to write decent <code>summary</code> and <code>description</code>, it has access to:</p>
<ul>
<li><p><strong>Qdrant Vector Store</strong> — to see how the area the request belongs to is generally structured at our place. This is needed so the agent doesn't invent a new name where an existing one already exists.</p>
</li>
<li><p><strong>Slack MCP</strong> — if the request came in a thread, read the backstory. People often clarify details exactly in the thread, while the first message is a single line.</p>
</li>
<li><p><strong>HTTP Request</strong> — in case the request contains a link to someone's PR, a Confluence doc, or an external spec.</p>
</li>
</ul>
<h3>Validation and task creation</h3>
<p>After receiving the JSON, validation runs: we check for required fields and a permitted <code>label</code> value. If something's off — a fallback message goes out ("couldn't formulate the task automatically, please take a look"), and the on-call handles it manually. If everything's fine — the task is filed in Jira through n8n's built-in node. A short message comes back to Slack with the task title and a link to it. The on-call then sees a ready ticket with the right label and decides: take it on, reassign, or ask for clarifications.</p>
<p>An important point: automatic task creation doesn't cancel manual validation. Sometimes the classifier gets it wrong and reads an incident as a modification (especially when the author writes in the "we need X to work" style instead of "X is not working"). That's why the system prompt explicitly carries a rule: if it's unclear from the text whether new work is actually needed or whether this is about something existing — ask "could you clarify what exactly needs to be done?" as a separate message, and don't create the task. A cheap measure that noticeably cuts down on junk tickets.</p>
<h2>Infrastructure Knowledge Assistant</h2>
<p>The last workflow for today is the "calmest" one. The <code>question</code> category catches requests like "where is the connection limit configured in pgbouncer?", "where do alloy logs from droplets go?", "what region is the <code>assets-prod</code> bucket in?". Sometimes from new joiners on the team, sometimes from the same DevOps engineers who forgot where what lives. (It happens, I won't lie.)</p>
<img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2jex68qirk2x9isa2i3p.png" alt="Image description" style="display:block;margin:0 auto" />

<p>The structure is almost identical to the incident investigator: the same input JSON structure, the same chain with attachment parsing, the same <code>SetVars</code>. Toolset: GitHub MCP, Slack MCP, Kubernetes MCP, Grafana MCP, DigitalOcean MCP, Qdrant Vector Store, and HTTP Request for pulling official component documentation when the required parameter isn't in the code and you have to look up vendor defaults.</p>
<p>I won't dwell on the same nodes — let me tell you about one trick without which the agent would hit a wall pretty quickly.</p>
<h3>A skill that writes READMEs in IaC repositories</h3>
<p>The initial hypothesis was: give the agent GitHub MCP and a knowledge base in Qdrant — and it'll figure it out. In practice, it turned out that the structure in IaC repositories is almost always non-obvious: somewhere Terragrunt is mixed with Helmfile, somewhere Ansible playbooks live with inventories two subdirectories deep, somewhere Terraform modules are laid out under names that only make sense to us. The agent burned a ton of tokens and time just to figure out where to look in the first place.</p>
<p>The solution came from the Claude Code Skills format: I wrote a separate skill that runs locally in the IDE and generates/updates the README in every infrastructure repo. The skill reads the directory structure, identifies entry points, and describes them in a unified format. The output looks like this:</p>
<pre><code class="language-markdown"># infra

Main IaC repository: terragrunt code for cloud resources,
Ansible inventories and playbooks for VMs, Helm releases and
manifests for k8s.

## Directory structure

- `terraform/` — terragrunt code, organized by cloud and region:
  - `do/fra1/` — DigitalOcean Frankfurt resources (DOKS clusters,
    buckets, load balancers, droplets for DBs).
  - `yc/ru-central1/` — Yandex Cloud resources.
- `ansible/` — playbooks and inventories for Droplet management:
  - `playbooks/` — entry points (`postgres.yml`, `redpanda.yml`, ...).
  - `inventories/{stage,prod}/` — per-environment inventories with
    `group_vars/` next to them.
- `helm/` — Helmfile releases of infrastructure components in k8s
  (ingress-nginx, cert-manager, kube-prometheus-stack, etc.).
- `manifests/&lt;cluster&gt;/` — manifests applied on top of Helm releases:
  alerts, ServiceMonitors, standalone CRDs.

## Naming conventions

- VM: `&lt;project&gt;-&lt;env&gt;-&lt;kind&gt;-[&lt;purpose&gt;]-&lt;index&gt;.example.com`
- clusters: `&lt;env&gt;-&lt;region&gt;-01` (e.g. `prod-fra1-01`)
- buckets: `s3-&lt;project&gt;-&lt;purpose&gt;-&lt;env&gt;` (e.g. `s3-project-assets-prod`)
</code></pre>
<p>From the agent's perspective, this changes everything: the first thing it does is read <code>README.md</code> through <code>get_file_contents</code>, understand the structure, and then go into the right subdirectory for a specific file. The number of GitHub MCP calls dropped roughly 3×, and answer quality went up noticeably — especially on questions of the "where is X configured" type.</p>
<p>I'll publish the skill itself in the repo — it's simple and easy to adapt to someone else's structure.</p>
<h2>What we got in total</h2>
<p>After all three branches rolled into production and lived through a couple of months of real traffic:</p>
<ul>
<li><p><strong>Incident investigator</strong> — closes about a quarter of requests fully; in the rest, the on-call gets a ready-made breakdown with hypotheses and saves 10–15 minutes at the start.</p>
</li>
<li><p><strong>Task manager</strong> — practically every modification request makes it into Jira with a meaningful summary and the right label set; manual fixes are needed rarely, usually around the description wording.</p>
</li>
<li><p><strong>Knowledge assistant</strong> — closes roughly half of the questions without an engineer; for the rest, the agent's answer is still useful as a starting point for the on-call.</p>
</li>
<li><p><strong>Combined cost</strong> at our flow stays around $250/month on LLM spend. Considering the system works 24/7 and doesn't take vacations — laughably small money.</p>
</li>
</ul>
<h2>What hasn't worked out yet</h2>
<p>To avoid creating the illusion that everything's smooth — a few rough edges we're still living with:</p>
<ul>
<li><p><strong>Objective quality metrics</strong> — for now, the only feedback channel is occasional comments along the lines of "no, the agent guessed wrong here." I want to wire up more structured feedback — for example, through emoji reactions in Slack with automatic stats collection.</p>
</li>
<li><p><strong>Long incident threads</strong> — the agent reliably gets lost when a thread already has 30+ messages. Right now, I cap the context depth hard in the prompt, but it's a tradeoff: sometimes important context from the start gets dropped.</p>
</li>
<li><p><strong>The "security" category</strong> — it doesn't exist in the classifier, but requests like "does this comply with GDPR?" come in periodically. For now, I shove them into <code>question</code>, but answer quality there is shaky.</p>
</li>
</ul>
<h2>Plans for the future</h2>
<p>What's in the queue for the coming months:</p>
<ul>
<li><p><strong>Expand the agent toolset to "actions, not just reads"</strong> — carefully give access to restarting pods, applying pre-vetted manifests, restarting systemd services. Obviously, this is minefield territory, so it'll be done through explicit engineer confirmation in Slack — without confirmation, no changes get applied.</p>
</li>
<li><p><strong>Add handling for maintenance announcements</strong> — for now, such messages just get tagged and ignored, but they could be pushed into a separate digest channel with an automatic "what's planned for this week" summary.</p>
</li>
<li><p><strong>Wire up a dedicated branch for security questions</strong> — with its own knowledge base on our compliance docs and security policy.</p>
</li>
<li><p><strong>Add analytics over processed requests</strong> — which categories are growing, where the autonomous resolution rate is what, how many tokens go to which category. Without numbers, it's hard to understand what should actually be improved first.</p>
</li>
</ul>
<h2>Wrap-up</h2>
<p>In short: AI agents driven by n8n with MCP tools turned out to be a very workable way to offload a significant chunk of routine work from the on-call engineer. Not a silver bullet — but something close to a modest, hardworking intern who works around the clock and costs about as much as a couple of lunches.</p>
<p>The key is not to try automating everything at once. Better to ship one branch, live closely with it, understand its weak spots — and only then extend the approach to the other categories. From my first working CI/CD assistant to the full set of all branches took about three months — and I have no regrets about that pacing.</p>
<hr />
<p>Link to the repo with workflows: <a href="https://github.com/javdet/automagicops-workflows">https://github.com/javdet/automagicops-workflows</a> Which category of requests automates best in your setup? And have you ever had the situation where an AI agent confidently nailed the wrong diagnosis and led the engineer down the wrong path? Share in the comments — especially curious to compare where everyone’s stepping on rakes.</p>
]]></content:encoded></item><item><title><![CDATA[Building an AI First-Line for DevOps Support on n8n for $250/Month — Part 1]]></title><description><![CDATA[How a small DevOps team offloaded chat triage, CI/CD diagnostics, and attachment parsing to an AI agent — and what's still rough about it. If you, like me…]]></description><link>https://auto-magic-ops.hashnode.dev/building-an-ai-first-line-for-devops-support-on-n8n-for-250-month-part-1</link><guid isPermaLink="true">https://auto-magic-ops.hashnode.dev/building-an-ai-first-line-for-devops-support-on-n8n-for-250-month-part-1</guid><dc:creator><![CDATA[Sergey Byvshev]]></dc:creator><pubDate>Fri, 14 Aug 2026 13:22:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/74fd33a1-1762-4f72-a210-47711eedeb93.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>How a small DevOps team offloaded chat triage, CI/CD diagnostics, and attachment parsing to an AI agent — and what's still rough about it.</strong></p>
<p>If you, like me, run infrastructure for a small team, you've probably been in this spot: the engineering org keeps growing while the DevOps headcount stays the same. With the rise of vibe-coding, that imbalance became especially obvious — our dev team at the studio grew roughly 1.5× in a couple of months, because every product manager wanted their own mini-application. On top of that, we got an extra headache from increasingly frequent availability issues from certain regions.</p>
<p>As a result, the flow of messages in our Slack support channel grew to the point where a significant chunk of an engineer's day was spent triaging them. And the most frustrating part: not every request actually fell under DevOps responsibility, but each one still required at least a shallow diagnostic to figure that out.</p>
<p>That's how the idea to offload the first line to an AI agent came up. By that point, we'd already <a href="#">automated incident analysis triggered by alerts</a> (covered in a previous article), and the approach had proven itself. Extending the same pattern to manual chat requests was the logical next step.</p>
<p>In this article — the first of two — I'll walk through how we:</p>
<ul>
<li><p>classified the past year's request flow and picked categories worth automating;</p>
</li>
<li><p>built a classifier in n8n with Slack integration over MCP;</p>
</li>
<li><p>implemented a CI/CD incident assistant as the first production-ready branch;</p>
</li>
<li><p>added attachment parsing (error screenshots, log files);</p>
</li>
<li><p>set up proper error reporting for the workflows themselves.</p>
</li>
</ul>
<p>Part two will cover the remaining branches: the infrastructure incident assistant, the knowledge assistant for routine questions, and the handler for infrastructure modification tasks.</p>
<p>All workflows and system prompts are published separately — link at the end of the article.</p>
<h2>Preparation: classifying requests</h2>
<p>Before automating anything, you need to understand <em>what</em>. I exported the request history from our Slack channels for the past year and bucketed it into categories. The result:</p>
<ol>
<li><p><strong>Infrastructure modification</strong> — changes to existing infrastructure, adding standard resources.</p>
</li>
<li><p><strong>New installation</strong> — deploying new systems and integrations that didn't exist before.</p>
</li>
<li><p><strong>Incident</strong> — something stopped working in the current infrastructure.</p>
</li>
<li><p><strong>CI/CD</strong> — failing builds, broken tests, broken deploys.</p>
</li>
<li><p><strong>Question</strong> — general questions about our infrastructure.</p>
</li>
<li><p><strong>Announcement</strong> — informational messages: planned maintenance, for example.</p>
</li>
<li><p><strong>Other</strong> — anything that didn't fit above.</p>
</li>
</ol>
<p>The bulk of requests fell into categories 3–5 — those were the obvious starting point. Categories 1–2 require approvals and almost always need an engineer in the loop, so there's no point automating them. Announcements don't need agent handling at all — it's enough to recognize them correctly and not page the on-call.</p>
<p>But before building any handling branches, we needed to reliably identify which category a new request belongs to.</p>
<h2>The classifier</h2>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/36e17bdd-c472-4126-94e3-7ce0d3c5d6ca.png" alt="" style="display:block;margin:0 auto" />

<p>At first glance, the setup looked straightforward: create a Slack app with a bot user, subscribe to <code>app_mention</code> events, point them at an n8n webhook, run the payload through an LLM, get the category.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/a1ce39a8-f52c-40ce-b410-1942612f3edd.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/f0afcfb5-e640-466c-8653-553a317fe07b.png" alt="" style="display:block;margin:0 auto" />

<p>The first nuance surfaced quickly: an incoming message can either start a new thread or be a reply inside an existing one. In the second case, without thread context the classifier will misfire — a one-liner like "same problem on my end" makes no sense in isolation.</p>
<p>Instead of calling the Slack API directly from n8n, I offloaded this to the agent — we already have <strong>slack-mcp</strong>, which can read messages from channels and threads. The agent itself decides whether to pull the thread history and does so when the context calls for it. The system prompt needs to describe how to do this, plus a few other things:</p>
<ul>
<li><p>category descriptions with examples;</p>
</li>
<li><p>the expected output format:</p>
</li>
</ul>
<pre><code class="language-json">{
  "category": "&lt;category_key&gt;",
  "confidence": &lt;0.0-1.0&gt;,
  "summary": "&lt;one-sentence summary in the same language as the user message&gt;",
  "acknowledge": "&lt;a short response that you accepted the request and started working on it&gt;",
  "is_thread": &lt;true|false&gt;,
  "parent_thread_ts": "&lt;thread_ts to use when replying — ALWAYS set&gt;"
}
</code></pre>
<p>In the user prompt, I additionally pass <code>channel_id</code>, <code>channel_name</code>, <code>message_ts</code>, and <code>user_name</code> — this helps the classifier orient itself in the message.</p>
<p>For the model I use Sonnet or GPT-5 Codex — on classification, both show comparable quality.</p>
<p>At this stage I don't yet touch attachments — screenshots and log files come into play further down, inside the logic of specific branches.</p>
<p>Once the agent's response is received and its fields are validated, we need to determine the on-call engineer — they may be needed if automated handling can't close the request. On-call rotations live in Google Calendar, so I had to configure OAuth2 access to it following the <a href="https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/">n8n docs</a>.</p>
<p>After the category and on-call are determined, the corresponding sub-workflow kicks off. In parallel, an <em>acknowledge</em> message goes to Slack so the author can see the request was received and is being worked on. That's an important detail — without it, the person keeps typing "hey, is anyone looking at this?" into the thread, which defeats the whole point.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/25db4a29-248a-49bc-8bf1-6a9bc3e2543a.png" alt="" style="display:block;margin:0 auto" />

<h2>CI/CD assistant</h2>
<p>CI/CD is one of the most common categories, so that's where I started. A solid share of these issues can be resolved without an engineer: builds that fell over because of a temporarily unreachable repository, flaky tests, misconfigured pipelines, expired tokens.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/d2207ab4-52ee-4e78-a0b8-c6ec33f8d5f1.png" alt="" style="display:block;margin:0 auto" />

<p>The sub-workflow expects an input structure with the request data:</p>
<pre><code class="language-json">{
  "message": "Chat request text",
  "message_ts": "Slack message timestamp",
  "channel_id": "Slack channel ID",
  "channel_name": "Slack channel name",
  "user_name": "Sender's display name",
  "user_id": "Sender's Slack user ID",
  "file_ids": ["List of attachment IDs"],
  "category": "One of the categories",
  "confidence": "Confidence score",
  "summary": "Short request description",
  "is_thread": "Whether the message came from inside a thread",
  "thread_ts": "Parent message timestamp, if this is a thread reply",
  "on_call_user": "On-call engineer's name"
}
</code></pre>
<h3>Parsing attachments</h3>
<p>Most CI requests arrive in the format "build failed" + an error screenshot. That description clearly isn't enough to identify a specific build, so before the main agent runs, a helper sub-workflow — <code>attachmentsAnalyzer</code> — kicks off first.</p>
<p>It processes attached files:</p>
<ul>
<li><p><strong>Images</strong> (error screenshots) — sent to <code>gpt-4o-mini</code> to extract text and describe context.</p>
</li>
<li><p><strong>Text files</strong> (logs) — if the size doesn't exceed the limit set in the <code>Config</code> node, the content is passed along as extra context.</p>
</li>
</ul>
<p>The output is a compact text block:</p>
<pre><code class="language-json">{
  "attachments_context": "...human-readable block...",
  "attachments_count": 1
}
</code></pre>
<p>I deliberately split this out into its own workflow — it's reused in other handling branches. If <code>attachmentsAnalyzer</code> fails, the main workflow keeps going without the extra context instead of falling over entirely.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/4322601f-c2f9-443a-bb6a-d23aa008dced.png" alt="" style="display:block;margin:0 auto" />

<h3>Gathering context and calling the agent</h3>
<p>Before forming the LLM request, the <code>SetVars</code> node assembles everything needed:</p>
<ul>
<li><p>the raw request data;</p>
</li>
<li><p>the output of <code>attachmentsAnalyzer</code>;</p>
</li>
<li><p>auxiliary context for the system prompt: GitHub organization names, Kubernetes contexts and namespaces, Grafana data source names.</p>
</li>
</ul>
<p>The agent itself works with a set of MCP tools:</p>
<ul>
<li><p><strong>GitHub MCP</strong> — access to Actions build logs, PRs, source code.</p>
</li>
<li><p><strong>Slack MCP</strong> — reading messages in a thread when the initial request doesn't carry enough context.</p>
</li>
<li><p><strong>Grafana / Kubernetes MCP</strong> — looking up cluster logs and events on deploy-related issues.</p>
</li>
</ul>
<p>The system prompt should cover:</p>
<ul>
<li><p>which teams exist and which repository groups belong to them — speeds up identifying the right repo;</p>
</li>
<li><p>how to pull additional context from a Slack thread;</p>
</li>
<li><p>the DevOps team's scope of responsibility — if an error falls within it, the assistant additionally tags the on-call at the end of the investigation;</p>
</li>
<li><p>a general description of the available tools and when to use each;</p>
</li>
<li><p>a few worked examples;</p>
</li>
<li><p>the output format.</p>
</li>
</ul>
<p>The user prompt is templated like this:</p>
<pre><code class="language-plaintext">Investigate the issue from {{ $json.user_name }} in channel {{ $json.channel_name }}{{ $json.is_thread ? ' (message is in a thread, thread_ts=' + $json.thread_ts + ' — first read the history via Slack conversations.replies)' : '' }}
{{ $json.message }}{{ $json.attachments_context &amp;&amp; $json.attachments_context.trim().length &gt; 0 ? '\n\nAdditional information from attachments:\n' + $json.attachments_context : '' }}
</code></pre>
<p>Alongside the message itself, this passes who sent it and which channel it came from, whether it's a thread reply, and any attachments if present.</p>
<h3>The HTTP-probe workaround</h3>
<p>One of the common reasons builds fail is an unreachable external resource: a dependency repository, a proxy, a registry. The natural move would be to give the agent a built-in <code>HTTP Request</code> tool and let it check availability. In practice, that didn't work — the built-in n8n node doesn't handle timeouts and network errors gracefully, and on a failure it brings down the whole agent chain.</p>
<p>So I wrapped the check in a separate sub-workflow <code>httpProbeTool</code> that always returns a structured result: success, failure with a reason, or timeout. The agent uses it like any other tool.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/83c19c55-e5ce-4b10-ac41-54f0b3d20628.png" alt="" style="display:block;margin:0 auto" />

<p>Once the agent responds, there's a short format validation step, and the message gets posted into the Slack thread.</p>
<h2>Handling workflow errors</h2>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/9533b1ad-871b-4342-be7c-b38cb961e08b.png" alt="" style="display:block;margin:0 auto" />

<p>When you build a system that handles real user requests, reliability is critical. If a workflow falls over for any reason — LLM quota exhausted, MCP server unreachable, invalid JSON — nobody in chat will know, and the request just hangs there.</p>
<p>This is especially relevant in the first weeks after launch, when you're constantly tweaking things.</p>
<p>The solution is simple: a dedicated workflow specified in the main workflows' settings as the <code>Error Workflow</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/b227f37a-e0ef-4fde-8e4e-07235677169b.png" alt="" style="display:block;margin:0 auto" />

<p>What it does:</p>
<ol>
<li><p>Pulls information about the failed execution from the n8n API (you'll need to generate an API key for this).</p>
</li>
<li><p>Through the <code>Extract Thread Context</code> node, determines <code>channel_id</code> and <code>thread_ts</code> of the original message.</p>
</li>
<li><p>Posts a short error message directly into the request's thread, so the author isn't left in the dark.</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/f57f77ad-1f01-477d-98f1-f162fe7cb7dc.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/40ba10d6-c878-4911-a8e0-a1c8787cd359.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/b4a2f2cb-bdf4-4b45-a698-15e58599ccba.png" alt="" style="display:block;margin:0 auto" />

<p>A more verbose error report also goes into the DevOps team's internal channel — this lets us react quickly to regressions.</p>
<h2>What we got</h2>
<p>After a couple of months running in production, the picture looks like this:</p>
<ul>
<li><p><strong>Average response time</strong> — up to 3 minutes from the moment a message appears in the channel.</p>
</li>
<li><p><strong>~25% of requests</strong> are fully closed without an engineer.</p>
</li>
<li><p><strong>~40% of requests</strong> are resolved faster than usual — the agent does a preliminary diagnostic, and the on-call gets ready-made context.</p>
</li>
<li><p><strong>Cost</strong> at our volume (several dozen requests per week) — up to $250/month on LLM usage.</p>
</li>
</ul>
<p><strong>Example creation issue by request in Slack</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/6f53d67a-40ed-4e60-90e7-ae4d4cdb3523.png" alt="" style="display:block;margin:0 auto" />

<p><strong>Example CI request resolved without an engineer</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/2825f832-ad10-43a8-a0d5-6654bc790106.png" alt="" style="display:block;margin:0 auto" />

<p><strong>Example investigating a build crash based on a screenshot</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/69ba2dfec22d3eeb8a318159/7156c436-1de3-40da-b2cc-eb41fdb2b401.png" alt="" style="display:block;margin:0 auto" />

<p>Compared to an engineer's hourly rate, this looks like a very cost-effective team addition — especially given the agent works around the clock and doesn't pull the on-call away from their main work.</p>
<h2>What's still rough</h2>
<p>To avoid leaving the impression that everything is smooth, here are the rough edges we still live with:</p>
<ul>
<li><p>The agent sometimes gets lost in long threads with dozens of messages — we have to limit context depth in the system prompt explicitly.</p>
</li>
<li><p>The <code>Incident</code> category has the lowest autonomous resolution rate so far — too many non-standard situations. We're working on expanding the MCP toolset.</p>
</li>
<li><p>It's hard to objectively evaluate the quality of answers to "infrastructure questions" — we need a feedback mechanism from engineers (planning Slack reactions as the simplest signal).</p>
</li>
</ul>
<p>Part two will cover the remaining branches: the infrastructure incident assistant, the knowledge assistant with RAG search over our docs, and the handler for modification tasks with automatic ticket creation.</p>
<hr />
<p><strong>How do you offload first-line support on your team?</strong> Are you using off-the-shelf products (like PagerDuty AIOps) or building your own? Which request categories automate best in your environment — share in the comments, I'm curious to compare the distribution.</p>
<p>Repository with workflows and system prompts: <a href="https://github.com/javdet/automagicops-workflows">https://github.com/javdet/automagicops-workflows</a></p>
]]></content:encoded></item></channel></rss>