DEV Community

Cover image for Hey Agent, Fix My Build": Building a First-Line Support Desk on n8n. Part 3
Sergey Byvshev
Sergey Byvshev

Posted on

Hey Agent, Fix My Build": Building a First-Line Support Desk on n8n. Part 3

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.

Diagnosis is already handled by the agent from Part 1 and Part 2 — 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.

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.

That's why we decided to go further and make it possible to simply say:

Hey agent, fix my build.

In this part I'll cover how we:

  • taught the system to distinguish two phases of a request — analysis and execution — and extended the classifier with execute categories;
  • added Postgres as state storage: every thread now has its own small state machine;
  • moved to Agentgateway for MCP server access, with JWT authentication and per-tool authorization;
  • chose how to run coding agents in Kubernetes;
  • built two executor workflows — for CI/CD and for incidents — plus a callback that returns the agent's result back into the thread.

All workflows and system prompts are published separately — link at the end of the article.
Agent request example

Previously on this series

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 Part 1 and Part 2.

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.

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.

What the full cycle looks like now

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).


The full path looks like this:

  1. The first message in a thread goes into the analysis phase: the read-only agent from the previous parts investigates the problem and posts a report.
  2. If a fix requires changes, the task moves to verdict_ready and the system waits for a human.
  3. The engineer reads the report in the thread and replies "go ahead" (or any other message asking for the fix) — the request enters the execution phase.
  4. 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.
  5. On completion the Job calls a webhook, the callback closes the task and posts the final report into the same thread.

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 verdict_ready, is reclassified into its execute variant and routed to the executor. The LLM proposes a category, but the _execute 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.

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.

State storage: Postgres

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?

For that we created an agent_tasks table in an external Postgres. One thread — one active row:

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])))
);


ALTER TABLE ONLY public.agent_tasks
ADD CONSTRAINT agent_tasks_pkey PRIMARY KEY (task_id);

CREATE UNIQUE INDEX agent_tasks_active_thread_uniq ON public.agent_tasks
USING btree (thread_root_id)
WHERE (status <> ALL (ARRAY['done'::text, 'failed'::text, 'cancelled'::text]));

CREATE INDEX agent_tasks_thread_created_idx ON public.agent_tasks
USING btree (thread_root_id, created_at DESC);
Enter fullscreen mode Exit fullscreen mode

A few details here carry most of the weight:

  • The CHECK constraint on status is the state machine. An invalid transition simply won't be written.
  • A partial unique index 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.
  • fail_count is the retry budget: a failed task can be revived by the next message in the thread, but not indefinitely.
  • The execution_* fields 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.

The classifier: two new categories

Two execute categories were added to devopsChatAssistant from Part 1:

  • ci_cd_error_execute — applying fixes following the analysis of a failed pipeline;
  • incident_executor — applying fixes following an incident investigation. As mentioned above, these categories are chosen not by the model but by a database query: if the thread already has a task in verdict_ready, 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 (_category_overridden, _model_category) stay in the execution logs — very helpful when the system's behaviour looks strange.

Another useful addition is the idempotent FindOrCreateAgentTask query. A single statement either finds the thread's live task, creates a new one, or revives a failed one (as long as fail_count isn't exhausted). Worth calling out separately: rescuing tasks stuck in executing 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.

Analyzers: one investigation, two documents

The CI/CD and incident assistants stayed strictly read-only, but now each investigation writes two different artifacts to the database.

verdict — the report for humans. 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.

agent_brief — the machine contract for the executor. Exactly one cause, one repository, one goal and a self-contained instruction:

{
  "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"
}
Enter fullscreen mode Exit fullscreen mode

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: instructions are written as if the executor had no access to the conversation — because it genuinely doesn't.

The needs_code_change flag that moves a task to verdict_ready is also set by a parser rather than by the model: only if the model declared that changes are needed and at least one cause with a repository and instructions survived validation. If no changes are needed, the task closes as done and the thread is released.

The incident assistant's agent_brief has an extra field, action_kind, with two values:

  • code_change — the fix lives in a repository: the agent clones it, applies the change and opens a PR;
  • k8s_operation — the fix lives in the cluster: restart a workload, roll back a configuration, change the replica count.

Agent Gateway

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.

We moved to Agentgateway alongside Envoy Gateway. What it gives us:

  • authentication via JWT tokens or through an IdP (Keycloak, Auth0 and so on);
  • authorization down to an individual tool — rules are written in CEL and can rely on token claims;
  • federation of several MCP servers behind one endpoint: tools automatically get the server name as a prefix (kubernetes_pods_get, grafana_get_datasource), so an agent can be granted tool groups by the server they belong to. 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.

Installing Agentgateway:

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

Enter fullscreen mode Exit fullscreen mode

Agentgateway has a dependency: you need one of the Gateway API implementations installed in the cluster.

A minimal configuration:

---
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayParameters
metadata:
  name: tool-gw
  namespace: ai-infra
spec:
  service:
    spec:
      type: ClusterIP
---
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayBackend
metadata:
name: mcp
namespace: ai-infra
spec:
mcp:
failureMode: FailOpen
targets:
- name: grafana
static:
host: grafana-mcp.ai-infra.svc.cluster.local
port: 80
protocol: StreamableHTTP
Enter fullscreen mode Exit fullscreen mode

This is the bare minimum to get access working. The next thing to add is an AgentgatewayPolicy with JWT or IdP authentication. All MCP servers deployed in the cluster are listed in the AgentgatewayBackend.

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 Authorization 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.

Running agents in Kubernetes

Now the interesting part: where and how to run the coding agent that will make the changes. We looked at several options:

  1. kube-foundry
  2. kagent
  3. plain Kubernetes Jobs

kube-foundry

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.

It installs in a couple of commands:

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_...
Enter fullscreen mode Exit fullscreen mode

A task is described by a SoftwareTask resource:

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
Enter fullscreen mode Exit fullscreen mode

From there the operator drives the task through the Pending → Running → Completed phases and puts the PR link into the resource status. For a quick start it's an excellent option with minimal plumbing.

Why we ended up with plain Jobs

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.

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.


FROM node:24-trixie-slim
ARG IMAGE_VERSION
ENV IMAGE_VERSION=${IMAGE_VERSION}
ENV DEBIAN_FRONTEND=noninteractive

ENV ANTHROPIC_MODEL=anthropic/claude-opus-5

ARG GH_VERSION=2.97.0
ARG TARGETARCH

RUN apt-get update
&& apt-get install -y --no-install-recommends
ca-certificates
curl
gettext-base
git
jq
less
&& case "${TARGETARCH}" in
amd64) GH_ARCH=amd64 ;;
arm64) GH_ARCH=arm64 ;;
) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;;

esac
&& curl -fsSL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}linux${GH_ARCH}.tar.gz"
| tar -xz -C /tmp
&& mv "/tmp/gh_${GH_VERSION}linux${GH_ARCH}/bin/gh" /usr/local/bin/gh
&& rm -rf "/tmp/gh_${GH_VERSION}linux${GH_ARCH}"
&& rm -rf /var/lib/apt/lists/

ARG CLAUDE_CODE_VERSION=2.1.220
RUN npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"

COPY --chmod=0755 agent-entrypoint.sh /usr/local/bin/agent-entrypoint.sh

RUN mkdir -p /workspace && chown -R node:node /workspace
WORKDIR /workspace
USER node

ENTRYPOINT ["/usr/local/bin/agent-entrypoint.sh"]
Enter fullscreen mode Exit fullscreen mode

Job template (abbreviated; full version is in the repository):

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
Enter fullscreen mode Exit fullscreen mode

Note backoffLimit: 0 and activeDeadlineSeconds: 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.

The trap of too many tools
ALLOWED_TOOLS deserves a separate note. This allowlist limits what the agent may call — but the descriptions of all 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.

The MCP config is mounted from a shared ConfigMap:

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}"
          }
        }
      }
    }
Enter fullscreen mode Exit fullscreen mode

RBAC

And the governing principle of the whole execution side: the security boundary is RBAC, not the prompt. 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:

  • The ServiceAccount n8n uses to create Jobs — has create on batch/jobs in a single namespace and nothing else;
  • The agent's own ServiceAccount — granted a separate Role in the target namespace: reading state, patch/update on deployments and scale, delete on pods (that's how a restart is done). Secrets, PVCs and anything cluster-scoped are deliberately absent.

Code: Role and RoleBinding for both ServiceAccounts

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.

CI/CD Executor

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.


A few decisions here that pay for themselves in reliability:

Atomic task claim. The first step is a single UPDATE ... WHERE status = 'verdict_ready' RETURNING *: 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.

Structured outcomes instead of exceptions. 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.

The manifest is code. The Job template lives in git, not in a JavaScript node, and is rendered by plain ${PLACEHOLDER} 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.

Validation at render time runs in both directions: every {TOKEN} 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.

Incident Executor

DevopsIncidentExecutor follows the same scheme but branches on action_kind from the brief: code_change ends in a pull request, k8s_operation in a verified cluster state. The differences from its CI/CD sibling:

  • the task claim is typed — ... AND task_type = 'incident', so the executors can't steal each other's tasks;
  • activeDeadlineSeconds is stricter: 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;
  • for k8s_operation the target is described explicitly in instructions (context, namespace, workload, verification criterion) — the agent has no access to the investigation conversation and nothing to infer from.

Callback

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:

{
  "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"
  }
}
Enter fullscreen mode Exit fullscreen mode

The receiving workflow is small, but it's exactly what closes the loop. Without it every task stays in executing forever and the thread stops accepting new requests. A few principles worth stealing even if you never run a coding agent:

  • Correlate first, trust second. The channel_id for the reply is read from the row in the database, not from the payload. Whoever calls the webhook controls what is said, but not where.
  • A terminal status is a lock release. The UPDATE carries the condition WHERE status NOT IN ('done','failed','cancelled'): a repeated callback won't re-close the task or overwrite a resolution that already happened.
  • The report gets chunked. 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.

What we ended up with

  • After a couple of months of the execution side running in production:
  • A typical executor run takes a minute and a half to two minutes and 15–20 agent iterations; one fix costs roughly $1.5 in LLM calls.
  • 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.
  • The total cost of the system rose to about $400 a month, 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.

What we haven't managed yet

As usual, an honest list of the rough edges we're living with:

  • Complex cases still have to be fixed by hand. 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.
  • There's no concurrency limit. 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.
  • For k8s_operation the goal is described in prose. 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.

Conclusion

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.

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.

All the manifests and workflows described here are available in the repository: https://github.com/javdet/automagicops-workflows

Top comments (0)