It's 3 AM. A production service is misbehaving, you're on-call, and you'd love an agent that can pull the service's health and tee up a restart for you. The catch is obvious: an agent with raw access to a DevOps API is a liability. One bad call could scale you into a huge bill or delete an incident record you needed.
So the real question isn't "can the agent reach the API." It's "which calls should it be allowed to make at all, and how should the dangerous ones be treated differently from the safe ones." That decision is what Skillgate handles, and it's the part we actually build and run in this post.
Scope, up front
This post is about the classification and curation layer: turning an OpenAPI spec into a governed set of skills. Skillgate decides which endpoints become tools, marks which are read-only, flags which writes should require approval, and denies the destructive ones outright.
Wiring an approval flag to a live human-approval pause, and making that pause survive a crash, is the job of the Agent OS runtime, not Skillgate. We link to it at the end. The demo here does not implement that runtime, and this post does not pretend it does.
The problem Skillgate solves
Point an LLM at a DevOps API and you have three bad options:
- Expose nothing. The agent is useless.
- Expose everything. Now the model can call
DELETEandscaleon a whim. - Hand-whitelist every route. It works until the API changes, then it rots.
Skillgate replaces all three with opt-in curation plus automatic risk classification. You choose a small surface, and every endpoint on it gets a class based on its method and shape.
From REST endpoint to agent skill
Skillgate's input is an ordinary REST API described by an OpenAPI spec. Nothing about the API is agent-aware. It's the same deploy, scaling, and incident routes your platform already exposes. Each endpoint is described in the standard OpenAPI shape: a method, a path, some parameters, a description, and tags. A representative operation from the DevOps spec looks like this:
{
"operationId": "getServiceLogs",
"summary": "Get service logs",
"method": "get",
"path": "/api/services/{serviceId}/logs",
"tags": ["services", "read-only"],
"parameters": [
{ "name": "serviceId", "in": "path", "type": "string", "required": true },
{ "name": "lines", "in": "query", "type": "integer" }
]
}
Skillgate reads each operation like that and generates one agent skill from it. The skill it produced for this endpoint is:
{
"name": "getServiceLogs",
"description": "Get service logs",
"method": "GET",
"path": "/api/services/{serviceId}/logs",
"parameters": [
{ "name": "serviceId", "in": "path", "type": "string", "required": true },
{ "name": "lines", "in": "query", "type": "integer" }
],
"class": "read",
"readOnly": true,
"requiresApproval": false
}
The mapping is direct:
- The operation's name and description become the skill's name and description. That text is what a model reads to decide when to reach for the skill, which is why
strictDescriptions: truein the config matters. - The endpoint's parameters become the skill's typed inputs. The model fills
serviceIdandlinesthe same way it fills any function's arguments. - The HTTP method decides the class.
GETbecomes a read skill,POST,PUT, andPATCHbecome write skills that pick up an approval flag, andDELETEis destructive. - The
invokeconfig from the next section (base URL and headers) is how the skill runs. When the skill is called, Skillgate makes the real HTTP request to that endpoint and hands the response back.
So "turning a REST API into an agent" is concretely this: one tool per selected endpoint, with typed inputs mirrored from the spec, a risk class assigned from the method, and a live HTTP call behind it. The model planning over those tools is the runtime step that needs an LLM attached. The transformation itself is what Skillgate does, and the report later in this post is the proof of it.
The configuration
The whole setup is one call. This is the real config from the project:
Skillgate.fromOpenApi(devOpsOpenApiSpec, {
agentName: 'devops-on-call',
invoke: {
baseUrl: process.env.DEVOPS_API_BASE_URL || 'http://localhost:4000',
headers: {
Authorization: 'Bearer ${DEVOPS_API_TOKEN}',
'Content-Type': 'application/json',
},
ssrfProtection: false,
},
include: {
mode: 'opt-in',
tags: ['services', 'deployments', 'incidents'],
},
warnAbove: 12,
maxTools: 24,
strictDescriptions: true,
})
A few choices worth calling out:
-
include.mode: 'opt-in'with atagsfilter means nothing becomes a skill unless you asked for it. Only endpoints taggedservices,deployments, orincidentsare considered. -
warnAboveandmaxToolsare the tool-count guardrails. More on those below. -
ssrfProtectionis off here because the target is a first-party service on localhost. Turn it on when your base URL points at an external host.
The spec itself is loaded from a local TypeScript file in this demo, not fetched from a URL or a running server.
What Skillgate produced
The DevOps spec has ten endpoints: five GET, four POST, one DELETE. After the opt-in tag filter and classification, Skillgate registered:
- 9 included skills
- 1 denied skill
Of the nine included: five are read-only with no approval flag, and four are writes flagged as requiring approval. The denied one is the DELETE.
Here are the endpoints it exposed:
GET /api/services
GET /api/services/{serviceId}/status
GET /api/services/{serviceId}/logs
GET /api/services/{serviceId}/metrics
POST /api/services/{serviceId}/restart
POST /api/services/{serviceId}/scale
POST /api/deployments
POST /api/deployments/{deploymentId}/rollback
GET /api/incidents
The proof: the report
Skillgate can print exactly what it decided. This is a trimmed slice of the real report() output, one read skill, one write skill, and the denied one:
{
"included": [
{
"name": "getServiceStatus",
"method": "GET",
"path": "/api/services/{serviceId}/status",
"class": "read",
"readOnly": true,
"requiresApproval": false
},
{
"name": "restartService",
"method": "POST",
"path": "/api/services/{serviceId}/restart",
"class": "write",
"readOnly": false,
"requiresApproval": true
}
],
"denied": [
{
"name": "deleteIncident",
"method": "DELETE",
"path": "/api/incidents/{incidentId}",
"class": "destructive",
"denied": true,
"denyReason": "destructive method DELETE denied (set classify.allowDestructive to allow)"
}
],
"warnings": []
}
The full report lists all nine included skills plus the denied one. Every entry carries its class, its readOnly flag, and its requiresApproval flag, so the governance decision is visible and auditable before the model ever sees a tool.
How the classification works
The model is simple and predictable:
-
GETbecomes areadskill. Safe, no approval flag. -
POSTbecomes awriteskill withrequiresApproval: true. -
DELETEis classeddestructiveand denied unless you setallowDestructive.
One honest detail about admin and internal routes. In this demo they aren't denied by an explicit rule, they're simply never tagged into the opt-in surface, so the tag filter leaves them out. Both paths get you to the same place: they don't become tools.
The flag is a contract, not the enforcement
This is the part the first draft of this post got wrong, so it's worth being precise.
requiresApproval: true is metadata that Skillgate attaches to a skill. It's the contract that says "this operation should not run unhelped." The enforcement, pausing a run, capturing a human decision, and resuming after a crash, lives in the Agent OS runtime, not in Skillgate.
So in this demo, Skillgate has already done the valuable part: it separated the safe operations from the sensitive ones and blocked the destructive one, before any model touched the list. Turning the approval flag into a real pause is the next layer, covered in the Agent OS and Gamma docs linked at the end.
Tool-count guardrails
warnAbove: 12 and maxTools: 24 exist to stop tool explosion. Models pick poorly once the tool list gets long, so Skillgate warns past twelve tools and refuses past twenty-four.
With nine skills, this demo sits comfortably under the line, so the guardrail doesn't fire here. It's the same mechanism that would stop you dumping a two-hundred-endpoint API onto a model, which is the whole subject of the companion CMS post.
Run it
npm install --legacy-peer-deps
npm run build
npm run dev
The app runs on http://localhost:3000, with the HazelJS Inspector at http://localhost:3000/__hazel.
Print the governance decision:
curl -s http://localhost:3000/devops/skillgate/report
One honest setup note: no LLM provider is configured out of the box. On boot you'll see "No AI providers configured. Set API keys or enable Ollama with OLLAMA_ENABLED=true." That means this runs as a classification demo. Skillgate does its curation and produces the report whether or not a model is attached. Add a provider when you want to drive an actual agent loop against these skills.
Relevant environment variables, all optional for the demo:
-
PORT(defaults to 3000) -
DEVOPS_API_BASE_URL(defaults tohttp://localhost:4000) -
DEVOPS_API_TOKEN(used in the auth header, not required for the demo)
What's real today, and what's next
Real and reproducible right now:
- OpenAPI in, curated and classified skills out
- Read, write, and destructive classes assigned automatically
- The destructive
DELETEdenied by default, with a clear reason - A printable report of every decision
The runtime layer you would add next:
- Turning the
requiresApprovalflag into a live human-approval pause - Making that pause crash-safe with durable runs and SQL-backed state
- Attaching an LLM so the agent can actually plan and call these skills
Complete Project: DevOPs on call Agent
Takeaway
HazelJS Skillgate is the gate that decides which endpoints become tools and how each one is classed, before the model touches anything. That curation and classification is genuinely useful on its own: it's the difference between handing an agent a raw API and handing it a short, labeled, pre-governed set of skills. The enforcement of those labels is a separate and well-documented layer, and that's exactly how it should be.
Learn more
- HazelJS Agent OS: https://hazeljs.ai/agent-os
- Skillgate guide: https://hazeljs.ai/docs/guides/skillgate
- Agent OS Gamma (durable runs, crash-safe HITL, SQL stores): https://hazeljs.ai/blog/agent-os-gamma-durable-runs
Top comments (0)