Skip to main content
C carlos.enredando.me CTO · Advisor · Builder
The Agent Had Better Tools Than I Did
A wall of antique hand tools — photo by Lachlan Donald on Unsplash.

The Agent Had Better Tools Than I Did

·2476 words·12 mins
Carlos Prados
Author
Carlos Prados
Telecommunications Engineer, Entrepreneur, CTO & CIO, Team Leader & Manager, IoT-M2M-Big Data Consultant, Pre-sales Engineer, Product-Service Manager & Strategist.

TL;DR
#

Two posts ago I wrote about building a CLI for an AI instead of for me. One post ago, my colleagues’ assistants stopped reaching for curl and the thing hit v1.0. This is what happened next, and it starts with an uncomfortable observation:

The agent could pull, type-check, diff and deploy the platform’s code. The human was editing the same code in a browser textarea.

og is now v2.4.0, and there are two editor plugins — one for Neovim, one for VS Code. The lessons, in five lines:

  • An API is not the unit of work. The artifact is. A platform’s embedded JavaScript needs a lifecycle — pull, type, validate, diff, watch, deploy — not one more endpoint.
  • Generate the types from the documentation. Hand-maintained declarations got four signatures wrong. The generated ones found a real defect on a live tenant.
  • A diff is only as good as its identity model. Match by position and inserting one widget reports “everything changed”, which is the output that makes a diff useless.
  • Nothing you compile tells you what breaks when it runs. Six real bugs surfaced only by running the software — including one where a missing command printed a help page and both plugins rendered it as a successful diff.
  • Silence is the wrong answer to a failure. Every one of those bugs exited zero.

Repos, all public: og-cli · og.nvim · og-vscode.


The gap I built for myself
#

By June, an LLM driving og mcp could do a lot to a live OpenGate tenant: search a fleet, triage alarms, launch operation jobs, pull a workspace apart, rewrite a chart formatter and publish it back. Ninety-three tools, typed arguments, structured results, a prompt shipped with the server so the model knows the query syntax before its first call.

Meanwhile, the way a human edits an automation rule on that same platform is: open the web UI, find the rule, click into a code box, and type JavaScript into it. No types. No linter. No local file. No diff against what is currently deployed. No git. You save, and either the fleet behaves or it doesn’t.

That asymmetry is absurd on its face, and it’s worth naming why it happened: I had spent all my effort on the surface an agent talks to, because that was the interesting problem. The surface a person talks to was somebody else’s product, so I treated it as fixed.

It isn’t fixed. The platform’s code lives in JSON documents the API will hand you on request. The only reason it isn’t editable like code is that nobody had done the plumbing.

The unit of work is the artifact
#

OpenGate has four families that carry executable JavaScript inside a JSON document: automation rules, connector functions, provision functions, and widgets inside dashboards. Different endpoints, different shapes, same problem — the code is a string in a config tree.

The move that made the rest possible was refusing to treat these as four features. Each family is declared once as a descriptor — where its code lives, what its metadata file is called, which keys carry its name and id — and the entire lifecycle is written once against that struct:

og rules pull <id> --dir r/          # explode into files, with typings
og rules validate r/env-anomaly      # before anything is sent
og rules diff r/env-anomaly          # local tree vs the live tenant
og rules watch r/env-anomaly         # deploy on save
og rules deploy r/env-anomaly --update

Swap rules for connectors, provision or dashboard and the verbs are the same, because they are the same code. Adding the fifth family is a table entry, not a feature.

Two verbs there are less obvious than they look.

diff takes --against <profile>, so “what differs between staging and production” is one command instead of two exports and a text editor. And it takes --exit-code, which is what makes it usable from a script or a git hook.

watch deploys on every save, which is exactly as dangerous as it sounds. Against a profile marked production: true it refuses to start without --allow-production. And on a genuine conflict — the tenant changed underneath you since your pull — it refuses, with no --force. Overwriting a colleague’s edit should not be one keystroke away.

That conflict check needed something the tool had never recorded: a base. A pull now writes a .og/ sync store with what it actually fetched, so a change can be classified three ways — you changed it, they changed it, or both. Without a base, every classification is Unknown and the conflict guard is a blind overwrite that happens to print a reassuring message. I found that by testing it: it had been “working” for a week.

Types nobody wrote by hand
#

The thing that makes editing rule JavaScript in an editor genuinely better — not just “the same, in a nicer font” — is that a pull also writes og-globals.d.ts and a jsconfig.json into the artifact directory. Your editor’s own TypeScript service reads them, and the plugins are not involved at all.

The declarations cover about 450 platform functions and, crucially, your organization’s real datastream identifiers, read live from its data models. Type entity['this.does.not.exist'] and it is underlined before you deploy anything.

I hand-maintained those declarations for exactly one iteration. It got four signatures wrong.

So they are generated now, by a small tool that parses the official OpenGate documentation. When the platform’s documentation team answered a handoff document with the gaps, regenerating moved the catalogue from 3 typed return types to 126, from 129 typed parameters to 376, and added 41 @deprecated symbols carrying the documentation’s own wording — including the warning that two connector-function helpers reverse their arguments relative to each other.

Two details from that round are worth stealing.

The generator refuses to write when a documented family yields no page. The rules documentation had been renamed with no alias. The generator kept exiting zero, wrote three families instead of four, and left a stale declaration file on disk claiming to be current. A family disappearing is not a condition that deserves silence.

The widget API surface is consumed, never generated. opengate-js 16.0.0 ships 246 declarations built from its own JSDoc. Generating a second copy from the documentation pages would have been strictly worse: two sources of truth, and mine is the one that goes stale. og writes only the injected globals and the platform’s async wrapper parameters, and gets out of the way.

And then the part I did not expect. Pointed at a real tenant, the generated typings flagged isInsertAction(entity) || isUpdateAction(entity) in a live provision rule. The platform team confirmed the documentation was right and my code was wrong: entity is an ambient global, so those arguments had never done anything. They were deployed away. The first defect the typings found and closed in production — on code that had been running fine, in the sense that nothing had visibly caught fire.

A diff is only as good as its identity model
#

A workspace is a tree: workspace → dashboards → widgets. Rendering a diff over it is where I made the mistake that I suspect most people make, which is to match children by position.

Insert one widget at the top of a dashboard and every subsequent widget shifts by one. Match by index and the diff says everything changed. That is not a wrong answer that costs you a minute; it is the output that makes people stop reading diffs.

So children are matched by identity, and a position change is reported separately as a move — which still counts as a change in both the marker column and the JSON status, because anything else traps a consumer filtering on status.

The same rule decides how og dashboard show --path resolves a local file to its remote counterpart. Widget directories carry an NN__ prefix, which is the grid order at the moment of the pull. Match on it and one reorder on the platform orphans every path in a local tree. Where identity is genuinely ambiguous — same widget type, neither carrying an id — the path is reported as not found rather than guessed.

There is a related boundary that took a while to get right. A widget is not the smallest addressable unit; its dashboard is. A widget is a grid item — the platform cannot name it, fetch it, or update it on its own. So there is no og widget deploy. You edit a widget’s JavaScript exactly as you edit a rule’s, and the deploy and the comparison move up one level, and both say so. Resisting the symmetry there was the right call: an API you invent for tidiness is an API that lies about the platform underneath.

Two editors, one binary
#

The Neovim plugin and the VS Code extension are both thin shells over the og binary. Every platform interaction is a child process. No HTTP, no authentication, no knowledge of OpenGate’s API lives in either one.

That is a deliberate refusal of the obvious design. Reimplementing one call in Lua or TypeScript makes two sources of truth, and the copy is always the one that rots. It also means a capability lands in both editors the day it lands in the binary, and a bug is fixed in one place.

What the plugins add is everything around the edit: browsing the tenant, pulling on click, diffing in the editor’s own diff view, validating, deploying with a confirmation, and logging in without leaving the window.

They are levelled in capability, not in chrome. og.nvim browses with vim.ui.select, which LazyVim, Telescope, fzf-lua and snacks all override — so it inherits whatever finder the user already has and imposes no layout. og-vscode browses with a sidebar tree, because that is what a VS Code user reaches for. A hand-rolled tree in Neovim would have been more code and a worse citizen.

Two security decisions, stated plainly because they are easy to get wrong:

  • Passwords travel in the environment, never in argv. Arguments are readable by anything that can list processes.
  • Neither plugin stores a credential. og writes the token to its own profile at 0600, and that is the only copy.

And one ergonomic decision that turned into a CLI feature: the session is checked before the work, not after the 401. A 401 collapses “you never logged in” and “your session expired an hour ago”, which need completely different things from the reader. So og whoami now answers that locally, offline, instantly, with usable exit codes — and both plugins ask first.

What only running the software finds
#

I shipped three releases of the CLI and two plugins in a handful of days. Every genuinely interesting bug in that stretch was invisible to the compiler, the tests and the linter. Here they are, because the pattern in them is the actual lesson:

A missing subcommand exits zero. og dashboard diff did not exist yet. Cobra answers an unknown subcommand by printing that family’s help page and exiting 0. Both plugins dutifully rendered the help page as a diff, and the VS Code extension confirmed a real dashboard deploy against it. Nothing looked broken from the outside — the diff had text in it, the deploy succeeded, the user was reassured. When a family gains a verb the others have, check the exit code, not the output.

VS Code shipped a new TypeScript. VS Code 1.135 bundles TypeScript 6, which reports target: es5 as an error. So every artifact directory og had ever generated showed a red squiggle in the editor the typings exist to serve. The fix TypeScript itself suggests breaks TypeScript 5, so only the target moved and lib stayed put.

An unparseable version is not a missing binary. A source build prints og dev (commit: unknown). Both plugins read that as “CLI not found” while it sat happily on the PATH. Running and being parseable are different questions, and conflating them told users to install something they already had.

One Accept header everywhere. GitHub’s API returned 415 to the download path because the same header was sent to two endpoints that want different ones.

A diff that resolved the wrong file. Invoked from the tree, it picked the metadata file instead of the code.

A diagnostic that printed the filename twice. Cosmetic, and precisely the kind of thing you never see until you are looking at it in anger.

Five of those six exit zero. That is the pattern: the expensive failures are the quiet ones, and no amount of type-checking finds them, because nothing about them is ill-typed. It’s the same reason og validate exists at all — I’d rather a tool refuse loudly than proceed politely into a mess.

What I’d tell you to steal
#

If you maintain a platform where users write code inside your UI — rules, transforms, formatters, hooks, whatever you call them — this is the whole post compressed:

  1. Give the code a lifecycle, not an endpoint. Pull, validate, diff, watch, deploy. The verbs matter more than the transport.
  2. Declare your artifact families once. If adding the fifth one is a table entry, you got the abstraction right. If it’s a new adapter, you didn’t.
  3. Generate the types from the documentation, and make the generator refuse to write on a gap. Mine caught a defect in production code that had been running for months.
  4. Match by identity, never by position. A diff that cries wolf on a reorder is a diff nobody reads.
  5. Keep the editor plugins thin. One implementation, in the binary. Everything else is a child process.
  6. Then go run it. Not the tests — the software, in a real editor, against a real tenant. Every bug worth the name in this cycle was found that way, and most of them exited zero on the way past.

The thread running from the first of these posts to this one is simpler than it looked at the time. Building for an agent forced me to make every operation typed, structured, self-describing and safe to retry. That work didn’t just make the platform driveable by an AI — it turned out to be exactly what was needed to hand a human an editor over the same platform. The agent got there first only because I built its side first.

og is a personal, unofficial project, not an Amplía Soluciones product, and it writes to real OpenGate tenants — you are responsible for what you deploy. It is also small enough to read in an afternoon, and the patterns in it are the ones I’d reach for anywhere humans and agents operate the same system side by side.

Built and dogfooded with a coding agent, which broke it in ways I wouldn’t have thought to try.