Files
proxy-manager/skills/proxy-manager/SKILL.md
T
pastilhasandClaude Opus 5 62ccce86a6 the skill's name is an identifier, not a label
lowercase-hyphenated and equal to its directory, which is what both harnesses
require to LOAD it. carrying a human title there meant the skill was listed to an
agent and then failed to open. the prose moves to `title`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:11:36 +00:00

8.5 KiB

name, title, description, version
name title description version
proxy-manager Proxy Manager Use for anything about this machine's domains, proxying or TLS — which hostnames it serves, where one forwards, whether a site is down, when a certificate expires, and creating, editing, enabling or deleting any of it. Carries the pass-through route into Nginx Proxy Manager's own API, the one 404 that means something else, and what to confirm before doing. 1

Officer proxies through Nginx Proxy Manager. This plugin holds NPM's admin credential and hands you NPM's own API through it.

The route

/api/proxy-manager/npm/<anything>   ->   <npm>/api/<anything>

Method, path, query string and body are forwarded unchanged, and NPM's own status and body come back unchanged — including its errors. A token is attached for you.

curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \
  "$OFFICER_API_URL/api/proxy-manager/npm/nginx/proxy-hosts?expand=certificate,access_list"

The contract is NPM's API, not ours. If NPM can do it, this can do it — docs/npm-api.md in the plugin has the endpoints, and NPM's own docs cover the rest. Do not go looking for a curated Officer route for each operation; there isn't one, and that is the design.

Never reach NPM directly. Not by its port, not by docker exec, not by finding the credential. The credential is deliberately not yours, this route is the sanctioned path, and going around it is unauthorised, unlogged and unbounded. If this route refuses you, that refusal is the answer.

The panels use different routes

/api/proxy-manager/hosts, /certificates and /status also exist. They sort and compute daysUntilExpiry for the screen. Read them if you want the same shaped answer the panel shows; use /npm/* for anything else.

What you can do

Everything NPM can, which is roughly:

GET/POST/PUT/DELETE /npm/nginx/proxy-hosts[/:id] the proxy hosts
POST /npm/nginx/proxy-hosts/:id/enable · /disable in and out of service
…/redirection-hosts, …/dead-hosts, …/streams the other three kinds of host
…/certificates[/:id], …/certificates/:id/renew Let's Encrypt and uploaded
GET /npm/nginx/certificates/dns-providers which DNS providers this NPM can drive, with their credential field names
…/access-lists[/:id] who may reach a host

A proxy host carries domain_names, forward_scheme, forward_host, forward_port, enabled, ssl_forced, certificate_id (0 means none), access_list_id, and meta.nginx_online — which is the field to read when somebody says a site is down, not whether the row exists.

Confirm before you change something

Do what is asked. The owner runs this machine; a request to create, edit, enable, disable or delete a host is a legitimate request and refusing it is not caution, it is being unhelpful about somebody's own server.

But say what will happen first, and wait, when the change is not trivially reversible:

  • Deleting a proxy host, a redirection, a stream, a certificate or an access list. Name exactly what goes and what it currently serves. A deleted host is not recoverable from here.
  • Editing an existing host's domains or upstream. That is a live site changing where it points.
  • Disabling. One click back, so a short confirmation is enough — but a domain goes dark meanwhile.
  • Anything touching a certificate that a live host is using.

Creating something new needs no ceremony: report what you made.

Never infer a change from a question. "Is foo.example.com up?" asks about meta.nginx_online. It is not an instruction to restart, disable or recreate anything.

Issuing a certificate

Verified against NPM 2.15.1 on 2026-08-25, by reading its own source. Most documentation and most memory of this API are wrong in the same way — see the warning below.

If the name already resolves to this machine's public address, Let's Encrypt can reach it on port 80 and there is nothing else to arrange:

curl -s -X POST -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \
  -H 'content-type: application/json' \
  -d '{"provider":"letsencrypt","domain_names":["sub.example.com"]}' \
  "$OFFICER_API_URL/api/proxy-manager/npm/nginx/certificates"

That is the whole body. Then attach it to a host by PUTting that host with certificate_id set to the new certificate's id, and ssl_forced: true if it should redirect.

Do NOT send letsencrypt_email or letsencrypt_agree. They are what every tutorial shows and NPM 2.15 does not accept them: meta is additionalProperties: false, so the request is rejected outright. The email comes from the NPM ACCOUNT — internal/certificate.js looks up the owning user and refuses with "A valid email address must be set on your user account" if it has none. That is a setting in NPM's own UI, not something this API can supply.

If the name does NOT resolve publicly — a private or VPN-only hostname — Let's Encrypt cannot visit it, and the only proof it accepts is a DNS record:

{
  "provider": "letsencrypt",
  "domain_names": ["private.example.com"],
  "meta": {
    "dns_challenge": true,
    "dns_provider": "cloudflare",              // an id from /npm/nginx/certificates/dns-providers
    "dns_provider_credentials": "dns_cloudflare_api_token = …",   // that provider's ini template, filled in
    "propagation_seconds": 60
  }
}

You cannot fill in dns_provider_credentials yourself, and must not try. Officer stores DNS provider keys encrypted and never returns them — not to you, not to the owner. Reaching for one is the refusal-shaped mistake this skill warns about.

So there is a different route for this, and it is the only one that works:

curl -s -X POST -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \
  -H 'content-type: application/json' \
  -d '{"domain_names":["private.example.com"]}' \
  "$OFFICER_API_URL/api/proxy-manager/domains/certificate"

Note the path: /domains/certificate, not /npm/…. It is not a pass-through. The platform works out which DNS provider owns that name from the Domains list, decrypts the stored key, builds the request above and hands back NPM's own answer. You ask for a certificate and never see the key that got it.

propagation_seconds may be passed and defaults to 120 — long, because a challenge record that has not spread yet fails in a way that reads as a bad credential. Expect this to take minutes, not seconds, and do not retry a request that has not come back.

What it refuses, and what each refusal means:

  • 400 "Not in the domain list" — the apex is not in Domains. Officer does not know whose DNS it is. The owner adds it in the Domains section; you cannot.
  • 409 "No API key stored" — the domain is known, its provider is known, and no key has been entered. Again the owner's to do, from the same section.
  • 400 "cannot span two DNS providers" — one certificate carries one credential. Split the request.

Certificates, read the way Let's Encrypt behaves

Renewal runs at 30 days, so under 30 is normal and not worth mentioning. Under 14 means renewal has actually failed and the domain goes dark at zero — raise that unprompted. expires_on is null on some uploaded certificates; that is unknown, not urgent.

Nothing warns anybody. There is no notification for this yet. If you notice one inside 14 days, say so in the conversation, because nothing else will.

The 404 that is not a 404

NPM answers an unauthenticated read with 404, not 401. Its router only registers a resource once the request carries a token, so "not signed in" and "no such endpoint" are identical responses.

The pass-through already re-mints its token and retries once, so a 404 that reaches you is about your request rather than about the token. Read it as "no such thing" — but if every route 404s, the stored credential is wrong and the owner needs to fix it at /proxy-manager?config=1.

It is not a rule about the whole API: GET /npm/nginx/certificates/dns-providers answers a proper 403 unauthenticated.

Two answers that are not failures

  • 409 unconfigured — no NPM connection is stored. Nothing is broken; the owner has not connected one. Only the owner can, at /proxy-manager?config=1.
  • 403 — either the account lacks proxy-manager, or it has it at read level, which permits GET and refuses every write. Do not look for another way round; report it.