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