Agentimus exposes two distinct HTTP surfaces:
- A private admin REST API under the
agentimus/v1namespace, used by the Vue admin. Every route requires themanage_optionscapability and the standard WordPress REST nonce (X-WP-Nonce/apiFetch). - A public, unauthenticated discovery surface — the generated
/.well-known/*documents plus the front-end agent files (/llms.txt,.mdtwins, the fallback sitemap,security.txt). These are served for anonymous agents and crawlers, with permissive CORS.
This page documents both, with exact routes, parameters, response shapes, content types, and CORS behaviour. Everything below is derived from inc/Rest.php, inc/Endpoints.php, inc/Discovery/* and inc/Sitemap.php.
Admin REST API (agentimus/v1)
Base URL, auth and registration
All admin routes live under the agentimus/v1 namespace (Agentimus\Rest::NAMESPACE), so the base is:
/wp-json/agentimus/v1/<route>
Routes are registered on rest_api_init by Agentimus\Rest::register(). Every callback is gated by permission_callback => can_manage(), which is simply current_user_can( 'manage_options' ). Because the admin uses cookie authentication, requests must also carry a valid REST nonce. From the admin bundle this is transparent — apiFetch sends X-WP-Nonce automatically. From an external script you must send the nonce yourself:
curl -H "X-WP-Nonce: <nonce>" \
--cookie "<wordpress-admin-cookies>" \
https://example.com/wp-json/agentimus/v1/settings
The nonce is minted with wp_create_nonce( 'wp_rest' ).
GET / POST /settings
Read or persist the plugin settings.
GET /settings takes no parameters and returns both the current settings and a fresh readiness report:
{
"settings": { "enable_llms_txt": true, "enable_schema": true, "...": "..." },
"readiness": [ { "id": "...", "label": "...", "status": "pass", "detail": "...", "fix": "...", "action": null } ]
}
settings is the full sanitized option array (Settings::all()). readiness is the same array returned by GET /readiness (see below).
POST /settings (WP_REST_Server::EDITABLE, i.e. POST/PUT/PATCH) saves settings. The handler is deliberately lenient about the request envelope — it accepts any of these bodies:
- a bare JSON object of settings:
{ "enable_schema": false } - a wrapped object:
{ "settings": { "enable_schema": false } } - form params under a
settingskey
The input is passed through Settings::update(), which sanitizes and merges against defaults, then the response echoes the saved (re-read) values plus a fresh readiness report:
{ "settings": { "...": "..." }, "readiness": [ ... ], "saved": true }
Partial updates are supported for most flags, but note that boolean feature toggles have specific merge semantics in Settings::update() (some absent keys reset to default, others are treated as “off”); send the full settings object if you need deterministic results.
POST /settings/reset
Restores factory defaults via Settings::reset(). No parameters. The onboarding flag lives in its own option, so a reset does not re-trigger the setup wizard.
{ "settings": { "...factory defaults..." }, "readiness": [ ... ], "reset": true }
POST /onboarding
Marks the first-run setup wizard complete (or skipped) so it never shows again. It writes update_option( 'agentimus_onboarded', AGENTIMUS_VERSION ) — a separate option from the settings, so a factory reset will not resurrect the wizard. No parameters.
{ "onboarded": true }
GET /readiness
Returns the readiness report (Agentimus\Readiness::report()) — the same array embedded in the /settings responses. It is a flat list of check rows, each normalized to:
| Field | Type | Meaning |
|---|---|---|
id |
string | Stable check id (also used as a UI anchor). |
label |
string | Human label. |
status |
"pass" | "warn" | "fail" |
Outcome. |
detail |
string | One-line explanation of the current state. |
fix |
string | Guidance on how to resolve a warn/fail (may be empty). |
action |
object | null | Optional UI action descriptor (e.g. a deep link to Settings). |
The set of checks is filterable via agentimus_readiness_checks (a Pro add-on can append its own rows), so integrators should treat the list as open-ended and key off id.
GET /preview/schema
Returns the exact JSON-LD @graph the front end would emit for the site, or for a chosen post — regardless of whether schema output is currently active. This is a preview tool: it shows what would ship even when schema is disabled or ceded to an SEO plugin.
Parameters
| Name | In | Type | Default | Notes |
|---|---|---|---|---|
post |
query | integer | 0 |
absint-sanitized. 0/absent → the site-wide identity graph. A non-zero id previews that post’s node. |
A post id that does not exist, or whose post type is not in the agent-visible selection (Content::post_types()), returns WP_Error agentimus_preview_not_found with HTTP 404.
Response shape
{
"active": false,
"reason": "seo_plugin",
"seoPlugin": true,
"target": { "type": "post", "id": 42, "label": "Hello world", "url": "https://example.com/hello-world/" },
"postIncluded": true,
"postNote": "",
"livePublic": true,
"graph": { "@context": "https://schema.org", "@graph": [ ... ] },
"json": "{ pretty, slash-unescaped JSON string }"
}
Key fields:
active—trueonly whenenable_schemais on and no SEO plugin owns schema output.reason—"disabled","seo_plugin", or"ok"; lets the UI explain an empty live<head>.target—typeis"site"or"post"; carries the label and public URL.postIncluded—falsefor a password-protected post (its body is never exposed as schema — only the site-level nodes ship).postNote— human note for draft/pending/scheduled (“not yet live”) or password-protected targets.livePublic— whether the target is reachable at a public URL right now (published and non-gated), which gates URL-based validators.graph— the JSON-LD document array (ornullwhen there is nothing to emit).json— the same graph as a pretty, slash-unescaped string for reading/validating (the live<head>escapes slashes for safe embedding; the data is identical).
An unpublished post is previewed with its would-be per-post node (built via Schema::build_document( $post, false, true )), flagged by postNote.
GET /preview/markdown
Returns the Markdown a page/post is served as — the .md twin / the Accept: text/markdown response. Markdown is per-page: the site target (post = 0) has no Markdown.
Parameters
| Name | In | Type | Default | Notes |
|---|---|---|---|---|
post |
query | integer | 0 |
absint-sanitized. 0/absent returns an empty site row. |
A non-existent or out-of-scope post id returns WP_Error agentimus_preview_not_found (HTTP 404), same as the schema preview.
Response shape
{
"active": true,
"reason": "ok",
"target": { "type": "post", "id": 42, "label": "Hello world", "url": "https://example.com/hello-world/" },
"postIncluded": true,
"postNote": "",
"livePublic": true,
"markdown": "# Hello world\n\n...",
"mdUrl": "https://example.com/hello-world.md"
}
active/reason— whether Markdown delivery (enable_markdown) is switched on ("ok"or"disabled").- Markdown is genuine served content (no “would-be” preview): a draft yields
postIncluded: falseand apostNotesaying nothing is served yet; a password-protected post yieldspostIncluded: falseand is never served as Markdown. mdUrlmirrors the front-end resolution — the permalink with.mdappended (untrailingslashit( $permalink ) . '.md').- For
post = 0, the response returns the site target withpostIncluded: falseand a note that Markdown is per-page.
GET /preview/targets
Returns the pages and posts the preview can describe — the in-scope content, most-recently-modified first, optionally filtered by title.
Parameters
| Name | In | Type | Default | Notes |
|---|---|---|---|---|
search |
query | string | "" |
sanitize_text_field; passed to WP_Query’s s. |
The query is limited to Content::post_types(), statuses publish, private, draft, pending, future, 50 rows, ordered by modified DESC.
Response shape
{
"targets": [
{
"id": 42,
"type": "post",
"typeLabel": "Posts",
"label": "Hello world",
"status": "publish",
"url": "https://example.com/hello-world/"
}
]
}
Discovery read & validation routes
Registered separately in inc/Discovery/Module.php, still under agentimus/v1:
| Route | Method | Auth | Purpose |
|---|---|---|---|
/discovery |
GET | Public (__return_true) |
The live discovery envelope — the same array serialized to /.well-known/discovery.json. Records an activity hit (rest:discovery). |
/discovery/hub |
GET | manage_options |
The admin Discovery Hub payload (Hub::data()): resources, adapters, MCP surface, validation notices, counts. |
/validate |
GET | manage_options |
CI-friendly validation: { ok, resources, notices } where ok is false if any notice has level error. |
GET /wp-json/agentimus/v1/discovery is the JSON-over-REST equivalent of the well-known document, useful for agents that prefer the WP REST root over /.well-known/.
Other admin route groups
Two further groups register under agentimus/v1; all require manage_options and the REST nonce. They are outside the scope of this page but listed for completeness:
- Activity (
inc/Activity/Module.php):GET/DELETE /activity,GET /activity/day(requires adateparam,YYYY-MM-DD),POST /activity/block,POST /activity/allow. - AI Visibility (
inc/Visibility/Rest.php, the opt-in monitoring add-on):GET/POST /visibility/config,GET /visibility/dashboard,POST /visibility/run,POST /visibility/test,POST /visibility/reveal-key,POST /visibility/clear.
Public discovery endpoints (/.well-known/*)
inc/Discovery/WellKnown.php is the single front controller for /.well-known/*. It is deliberately not greedy: it only routes the names it recognises. Anything else under /.well-known/ (ACME challenges, other plugins’ docs, unknown names) falls through to WordPress’s normal handling and 404s untouched.
How routing works
Two mechanisms bring a request to the router:
- Narrow rewrite rules (registered on
initbyWellKnown::add_rules(), flushed on activation) route only the exact names the plugin serves, so requests reachtemplate_redirecteven on servers that 404 the path at disk level. The routed names come fromWellKnown::routed_names()— filterable viaagentimus_well_known_routed. - The 404-intercept path — a name that is not in the rewrite set (e.g.
security.txt) still reachesWellKnown::route()through WordPress’s normal 404 template load, and the router serves it if it owns it.
Precedence inside route():
- A real file on disk always wins. If
<site-root>/.well-known/<name>exists (and resolves safely inside the.well-knowndirectory), it is streamed as-is viareadfile()— the plugin never shadows a real file. - Otherwise, one of the plugin’s generated documents is emitted.
- Otherwise, a provider-registered document (via
Registry::add_well_known()) is served — as a redirect, a streamed file, or a callback body. - If the plugin routed the request but produced nothing,
maybe_clean_404()forces a clean 404 (rather than WordPress’s canonical redirect to the homepage).
Site-root resolution is handled by Agentimus\Paths::site_root() (the public document root that maps to home_url(), which is not always ABSPATH).
The routed documents
All generated documents are served by WellKnown::send(), which sets:
Content-Type: <type>; charset=UTF-8X-Content-Type-Options: nosniffAccess-Control-Allow-Origin: *Cache-Control: public, max-age=3600
Path (/.well-known/…) |
Content type | Served when |
|---|---|---|
discovery.json |
application/json |
Always (cached 1h). |
agent-card.json |
application/json |
Always — the generated A2A agent card. |
agent.json |
application/json |
Always — alias of agent-card.json. |
mcp.json |
application/json |
Always — the experimental MCP/tools manifest. |
openapi.json |
application/json |
Always — OpenAPI 3.1 description of the existing public REST read API. |
api-catalog |
application/linkset+json |
Always — RFC 9727 API catalog as an RFC 9264 link set. |
mcp/server-card.json |
application/json |
Only when a real MCP server is detected; otherwise 404. |
mcp/{id}/server-card.json |
application/json |
Per-server card for a tool-bearing server matching {id}; else 404. |
agent-skills/index.json |
application/json |
Only when executable skills exist; otherwise 404. |
oauth-protected-resource |
application/json |
Only when an OAuth authorization server is configured (RFC 9728); else 404. |
tdmrep.json |
application/json |
Only when enable_tdmrep is on and AI training is reserved; else 404. |
http-message-signatures-directory |
application/json |
Only when response signing is enabled (the Web Bot Auth key directory); else 404. |
security.txt |
text/plain |
Only when opted in, a Contact is configured, and no real file exists (see below). |
discovery.json
The master index, built by Agentimus\Discovery\Envelope::build() and cached for an hour. It is a projection of the registry plus site identity — nothing is hand-maintained. The wire format is frozen at spec_version "1.0" and carries exactly these top-level keys, in order: $schema, spec_version, site, identity, documents, well_known, apis, agents, resources, capabilities, trust.
documentsmaps standard site documents (sitemap, robots, feed,openapi, and — when enabled —llms/llms_fullandchanges(the change feed), plushumans/securitywhen present) to URLs. Filterable viaagentimus_documents.well_knownindexes every/.well-known/*resource the site exposes (generated, provider-managed, and real files on disk), annotated with the governing spec label.- The whole envelope is filterable via
agentimus_envelope; the JSON Schema URL viaagentimus_schema_url.
Experimental surfaces (the MCP descriptor and tool list) are intentionally not in the frozen core — they are served at /.well-known/mcp.json and linked from well_known.
Change feed (/agentimus-changes.json)
A public JSON feed of recently changed content, served by the front controller (inc/Endpoints.php, not WellKnown) and advertised as documents.changes. Built by Agentimus\Changes, gated by the enable_changes setting (on by default), with a short Cache-Control: max-age=300.
GET /agentimus-changes.jsonreturns the newest window of items (default 200,agentimus_changes_max), newest change first.?since=<ISO-8601 or Unix timestamp>filters to items changed strictly after that instant.- Each item is
{ id, type, title, url, modified, published, action, markdown, _links }, whereactioniscreated,updatedordeletedand_links.restpoints at the canonicalwp/v2resource. Adeleteditem — a tombstone recorded on unpublish/trash/delete and kept ~90 days (agentimus_tombstone_retain_days) — carries just the URL to evict and when.
agent-card.json / agent.json
The generated A2A agent card (Envelope::agent_card_json(), delegated to WellKnownDocs). agent.json is a straight alias for agents that look there first.
mcp.json and MCP server cards
Agentimus does not run an MCP server — it discovers and advertises the ones a shared MCP adapter library has registered (inc/Discovery/McpSurface.php). mcp.json carries the site identity, the MCP descriptor (available, source, endpoint, transport, auth, tools, servers, status: "experimental") and the deduped tool list.
When a live server is detected, standard single-server cards are served at mcp/server-card.json (the richest server, pinnable via agentimus_mcp_card_server) and, per server, at mcp/{id}/server-card.json. With no server, both return an empty body → a clean 404. The MCP descriptor is filterable via agentimus_mcp.
openapi.json
An OpenAPI 3.1 document describing the site’s existing public read API — WordPress’s REST API for the agent-indexed content types (inc/Discovery/OpenApi.php). It adds no endpoints and changes no behaviour; it is a machine-readable contract pointing at REST that already exists. One list + single-item read collection is generated per content type that is both agent-indexed and show_in_rest. It is also advertised from the HTML <head> as rel="service-desc".
api-catalog, oauth-protected-resource, tdmrep.json
api-catalog(RFC 9727,application/linkset+json) — the document complement to therel="api-catalog"Link header; points agents at the site’s API descriptions.oauth-protected-resource(RFC 9728) — served only when the owner has declared an auth server (oauth_auth_server); otherwise absent.tdmrep.json— the W3C TDM Reservation Protocol opt-out file (inc/Tdmrep.php). Served only whenenable_tdmrepis on and the site reserves its content from AI training (content_signal.ai_trainis off). An “allow” site serves no file — absence already means “not reserved”.
Response signing (Web Bot Auth)
When signing is enabled (enable_signing, on by default but feature-detected against libsodium), the low-volume discovery JSON docs are signed with RFC 9421 Signature/Signature-Input headers over an RFC 9530 Content-Digest (inc/Discovery/Signer.php). The Ed25519 public key is published as a JWKS-style directory at /.well-known/http-message-signatures-directory. The signed surfaces default to discovery.json, agent-card.json, agent.json, mcp.json and are filterable via agentimus_signed_surfaces (keep it to JSON docs — never cached HTML/llms.txt).
security.txt
inc/Discovery/SecurityTxt.php is the reference example of the “extend, never override” contract. It generates an RFC 9116 security.txt (served text/plain) only when all of the following hold: the feature is opted in (enable_security_txt, off by default), at least one Contact is configured, no real /.well-known/security.txt file exists, and no other plugin already claimed the slot in the registry. It registers through the public wpdiscovery_register action at a late priority (99), so a first-class provider always wins. Because security.txt is intentionally absent from routed_names(), it is served via the 404-intercept path rather than a rewrite rule — which also means the CORS preflight (below) does not answer OPTIONS for it, though the actual GET still carries Access-Control-Allow-Origin: *.
CORS and preflight
Every generated document (and every streamed on-disk file) carries Access-Control-Allow-Origin: * on the GET/HEAD response — these docs are public by design, so browser-based agents can fetch them cross-origin.
For a strict cross-origin agent that sends a preflight, WellKnown::route() answers OPTIONS — but only for names the plugin actually serves (serves() = the routed names, the nested allow-list, or a per-server MCP card path). The preflight (WellKnown::preflight()) returns:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, HEAD, OPTIONS
Access-Control-Allow-Headers: *
Access-Control-Max-Age: 86400
Content-Length: 0
An OPTIONS for a name the plugin does not own is left entirely alone (never shadowed). Only GET/HEAD are otherwise handled; other methods return without action.
Advertising the discovery index
inc/Discovery/Module.php emits two Link headers on every front-end response so an agent can find the entry point from a single header:
Link: <https://example.com/.well-known/discovery.json>; rel="service-desc"; type="application/json"
Link: <https://example.com/.well-known/discovery.json>; rel="discovery"; type="application/json"
Front-end agent files
inc/Endpoints.php is the front controller for the non-.well-known agent files. It routes on template_redirect (priority 0), only for GET/HEAD and never for admin, feed, embed, REST, AJAX or XML-RPC requests. Bodies are emitted by Endpoints::send(), which sets X-Content-Type-Options: nosniff, Access-Control-Allow-Origin: *, and Vary: Accept.
llms.txt and llms-full.txt
| Path | Content type | Gated by |
|---|---|---|
/llms.txt |
text/plain |
enable_llms_txt |
/llms-full.txt |
text/plain |
enable_llms_full |
Both are stable URLs, so send() sets Cache-Control: public, max-age=3600. Content is assembled by Agentimus\LlmsText. The heavy full-text edition is re-warmed out-of-band after content changes via a debounced WP-Cron event (agentimus_warm_llms_full).
Markdown twins (.md and content negotiation)
Agentimus serves a Markdown representation of content two ways, both gated by enable_markdown and served as text/markdown:
- Explicit
.mdURLs —/<slug>.md, and/index.md(or a bare/.md) for the site index. The path is resolved back to a post viaurl_to_postid(); only posts whose type is in scope (Content::post_types()) are served, otherwise WordPress 404s normally. Accept: text/markdownnegotiation — on a resolved singular view (or the front page/archive/search, which map to the site index).
Because negotiated Markdown shares a URL with the HTML page, all Markdown responses are sent with Cache-Control: no-store, max-age=0 and Vary: Accept. The current page’s Markdown twin is advertised on singular, in-scope views as a Link header:
Link: <https://example.com/hello-world.md>; rel="alternate"; type="text/markdown"
Fallback sitemap
Agentimus never competes for the sitemap — inc/Sitemap.php detects the existing one (WordPress core, or a known SEO plugin) and links it. Only when nobody else provides one and enable_sitemap is on does it serve its own gap-filling sitemap:
| Path | Content type | Notes |
|---|---|---|
/agentimus-sitemap.xml |
application/xml |
The sitemap index. |
/agentimus-sitemap-{type}-{page}.xml |
application/xml |
One sub-sitemap per content type, per page. |
These are routed by Endpoints::route() and served only while Sitemap::detect()['source'] === 'agentimus'; any other /agentimus-sitemap* path 404s. As stable URLs they are cached (public, max-age=3600) and served with the standard Access-Control-Allow-Origin: *. Published, password-protected posts are excluded.
robots.txt, AI-usage signals and head links
Endpoints also augments other standard surfaces without clobbering them:
robots.txt(via therobots_txtfilter, priority 20) — injects aContent-Signal:directive into the existingUser-agent: *group, appends a named model-training crawler blocklist, and adds aSitemap:line if none is present. Gated by site visibility +enable_robots.- AI-usage headers (
send_headers, priority 99) — whenenable_ai_headeris on and training is reserved, emitstdm-reservation: 1(plus optionaltdm-policy, andX-Robots-Tag: noai, noimageaiwhenai_noai_headeris on). These are skipped on the “please read me” surfaces (llms.txt,llms-full.txt,robots.txt,.md,/.well-known/, feeds). - HTML
<head>links (wp_head, priority 2) — mirrorsrel="describedby"(/llms.txt) andrel="service-desc"(/.well-known/openapi.json) into the markup for crawlers that read HTML but not headers. - Discovery Link headers (
send_headers, priority 99) — advertisesrel="api-catalog"(the REST root),rel="describedby"(/llms.txt), and the current page’srel="alternate"Markdown twin, each de-duplicated against any header a theme already set.
Coexistence: ceding a surface
Any of the front-end surfaces can be handed to another producer (a theme or plugin that emits its own llms.txt, Markdown, robots rules or Link headers) with a single filter, rather than sniffing for the plugin:
add_filter( 'agentimus_yield_surface', function ( $yield, $surface ) {
// My theme already serves these — let it.
return in_array( $surface, array( 'llms_txt', 'markdown' ), true ) ? true : $yield;
}, 10, 2 );
Valid $surface keys are llms_txt, llms_full, markdown, link_headers, and robots. When a surface is ceded, Agentimus stands down entirely for it — it neither serves the file nor advertises it.