AAgent Content
✎ Suggest◎ Sign in⚙ Admin

Instructions to Agents — API Access & Site Administration

Audience: a non-human agent (LLM agent, CI job, monitoring bot, script) that needs to
read, manage, or administer this site programmatically. This document is complete and
self-contained: together with the site's base URL — and, for admin work, a bearer
token issued by a human administrator — nothing else is required.

1. What this site is

The site is a content platform organised as a three-level hierarchy:

Everything below is relative to BASE_URL — the origin this page was served from
(e.g. https://example-site.example.com).

There are two API surfaces:

Surface Prefix Authentication
Public read API /api/* None — reflects exactly what anonymous visitors see
Admin API /admin/api/* Bearer token (agents) or browser session (humans)

2. Public API — no authentication required

All public endpoints are plain HTTPS requests with JSON responses unless noted.
Use them to verify the public surface ("is the site up", "is the new item visible").

Operation Endpoint
Site registry (all views + default view id) GET /api/views
One view (title, description, sections) GET /api/views/:viewId
Items of a section (published only) GET /api/views/:viewId/sections/:sectionId/items
A revealed "outdated" section's items …/sections/:sectionId/items?includeOutdated=true
One item's metadata GET /api/views/:viewId/items/:slug
One item's rendered body GET /api/views/:viewId/items/:slug/body
Download an item's source document GET /api/views/:viewId/items/:slug/download
An item's site-hosted thumbnail image GET /api/views/:viewId/items/:slug/thumbnail
Search within a view GET /api/views/:viewId/search?q=<query>
Submit a content suggestion (challenge-protected) POST /api/suggestions
Mailing-list subscribe / confirm POST /api/subscribe, POST /api/subscribe/confirm
Mailing-list unsubscribe / confirm POST /api/unsubscribe, POST /api/unsubscribe/confirm

Start any session with GET /api/views: it returns the registry of view ids you will
need for every other call.

Notes:

3. Getting admin access

Admin access is granted only by a human administrator. There is no self-service
registration, and agents can never mint credentials for themselves or other agents.

  1. A human administrator signs in to the admin UI and opens /admin/agents
    ("Agent tokens"), or runs the operator CLI (npm run admin:agent:create).
  2. They issue a credential with an agent id, a label, and an expiration
    (30/90/365 days, a custom date, or never-expires).
  3. The plaintext bearer token is shown exactly once. The administrator hands it to you
    through a secure channel, together with the site's BASE_URL.

The token is an opaque string of the form:

<agentId>.<secret>

The part before the first dot is your public agent id (e.g. monitor-bot); the rest is
a secret. The server stores only an HMAC hash of the token — if you lose it, it cannot
be recovered, only re-issued.

Your token carries the admin:* scope: every admin API operation is available to you
EXCEPT token management (§6).

4. How to authenticate

Send the token in the Authorization header of every request:

Authorization: Bearer <agentId>.<secret>

Rules:

Verify your access on handoff with a harmless read:

curl -sS -H "Authorization: Bearer $TOKEN" "$BASE_URL/admin/api/views"
# expect: HTTP 200 with the site registry JSON

5. Admin API — full endpoint inventory

Mutations take JSON bodies (Content-Type: application/json) unless noted. The public
catalog regenerates synchronously after every successful write — your change is
publicly visible on the next request; there is no cache to wait for.

Reads (monitoring surface)

Operation Endpoint
Site registry (all views, incl. drafts + hidden) GET /admin/api/views
Sections of a view GET /admin/api/views/:viewId/sections
Items in a section (all statuses) GET /admin/api/views/:viewId/sections/:sectionId/items
One item's metadata record (any status, any visibility) GET /admin/api/views/:viewId/sections/:sectionId/items/:slug
One item's stored body (any status, any visibility) GET /admin/api/views/:viewId/sections/:sectionId/items/:slug/body
Download an item (any status, any visibility) GET /admin/api/views/:viewId/sections/:sectionId/items/:slug/download
An item's thumbnail image (any status, any visibility) GET /admin/api/views/:viewId/sections/:sectionId/items/:slug/thumbnail
Suggestions queue (?status=, ?months=) GET /admin/api/suggestions
Mailing-list subscribers (read-only) GET /admin/api/subscribers

The admin body read is the counterpart to the public GET /api/views/:viewId/items/:slug/body. The public one resolves items through the published catalog, so it cannot return an unpublished item or anything inside an admin-only view. Use the admin one whenever you need to read content you have staged but not published.

The single-item metadata read returns the bare item record — the same shape PATCH and move return, so a read-modify-write round trip is symmetric. Reach for it instead of listing a whole section and filtering client-side; sections grow, and the listing does not.

Writes (management surface)

Operation Endpoint
Create view POST /admin/api/views
Update view PATCH /admin/api/views/:viewId
Delete view DELETE /admin/api/views/:viewId
Rebuild a view's catalog (idempotent) POST /admin/api/views/:viewId/catalog/regenerate
Create section POST /admin/api/views/:viewId/sections
Update section PATCH /admin/api/views/:viewId/sections/:sectionId
Reorder sections POST /admin/api/views/:viewId/sections/reorder
Delete section DELETE /admin/api/views/:viewId/sections/:sectionId
Create item (multipart: metadata field + body file + optional thumbnail file) POST /admin/api/views/:viewId/sections/:sectionId/items
Update item metadata PATCH /admin/api/views/:viewId/sections/:sectionId/items/:slug
Replace item body PUT /admin/api/views/:viewId/sections/:sectionId/items/:slug/body
Upload / replace item thumbnail PUT /admin/api/views/:viewId/sections/:sectionId/items/:slug/thumbnail
Remove item thumbnail DELETE /admin/api/views/:viewId/sections/:sectionId/items/:slug/thumbnail
Archive item (soft removal) POST /admin/api/views/:viewId/sections/:sectionId/items/:slug/archive
Restore archived item POST /admin/api/views/:viewId/sections/:sectionId/items/:slug/restore
Move item between sections, or between views POST /admin/api/views/:viewId/sections/:sectionId/items/:slug/move
Delete item DELETE /admin/api/views/:viewId/sections/:sectionId/items/:slug
Review a suggestion (status, notes) PATCH /admin/api/suggestions/:id
Promote an accepted suggestion POST /admin/api/suggestions/:id/promote
Gemini search re-index POST /admin/api/gemini/resync (only when semantic search is enabled; otherwise 503)

Worked example — creating a view:

curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  --data '{
    "version": 1,
    "id": "agent-created-view",
    "title": "Agent Created View",
    "description": "Created through agent admin authentication.",
    "source": { "backend": "azure-blob", "root": "agent-created-view" },
    "sections": [],
    "status": "draft"
  }' \
  "$BASE_URL/admin/api/views"

Item creation is multipart/form-data, and the part shapes are not interchangeable:

Sending metadata as a file part is the most common mistake. The request is rejected
with 400 and the message "Unexpected file part 'metadata'". Retrying will not
help — fix the part layout instead. Any file part other than body and thumbnail is
refused the same way.

Worked example — creating an item:

# Write the sidecar to a file, then have curl read the field value FROM the file.
cat > sidecar.json <<'JSON'
{"slug":"my-article","title":"My Article","summary":"One-line summary — with an em-dash."}
JSON

curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -F "metadata=<sidecar.json" \
  -F "body=@article.html;type=text/html" \
  "$BASE_URL/admin/api/views/$VIEW_ID/sections/$SECTION_ID/items"

Pass the metadata with -F "metadata=<file.json", not -F "metadata=$SHELL_VAR".
The < form makes curl read the field's value straight from the file, byte for byte.
Interpolating a shell variable instead truncates the value at the first non-ASCII
character, so a single em-dash or curly quote anywhere in a summary produces
400 VALIDATION_ERROR: metadata: Unterminated string in JSON at position … — the JSON
arrives cut in half. This bites constantly in practice, because editorial prose is full
of such characters, and the error message points at the JSON rather than at the
transport, which is what makes it hard to diagnose. (Note the difference between the two
sigils: <file reads a text field's value from a file, @file sends a file part.
metadata always wants the former — see the part-shape rules above.)

slug and title are required; summary, author, authorType, authorUrl,
publishedAt, tags, thumbnailUrl, sourceUrl, externalUrl, videoPublishedAt,
and status are optional. Everything else (version, view_id, section_id,
body_reference, source_type, sha256, thumbnail, createdAt, updatedAt) is
server-managed — do not send it. A successful create returns 201 with the stored item.

Replacing an item's body (PUT .../items/:slug/body) uses the same layout, except
metadata is optional there, and a thumbnail part replaces the image at the same time.

Images and thumbnails

An article's inline images need no special handling: they live inside the HTML
document you upload as body, whether as <img src="https://…"> pointing at a remote
host or as a data: URI embedded in the markup. Nothing about them is separate content
as far as this API is concerned.

The thumbnail — the single image representing the item on cards, list rows, and
social previews — is different, because it has to be addressable on its own. There are
two ways to give an item one, and you should understand both before choosing:

thumbnailUrl (metadata field) thumbnail (uploaded file)
What it is An absolute http(s) URL on someone else's server An image file this site stores and serves
How you set it A string in the metadata JSON A thumbnail file part
Served from The third party GET /api/views/:viewId/items/:slug/thumbnail
Survives the source going away No Yes

Prefer uploading a file whenever you have the image bytes. A thumbnailUrl is a
dependency on a host you do not control: when it rewrites its URLs or removes the image,
the card silently breaks and nobody finds out until a human notices. Reach for
thumbnailUrl only when you genuinely have nothing to upload.

When an item has both, the uploaded file wins everywhere the site renders artwork.
Setting thumbnailUrl on an item that already has an uploaded image therefore changes
nothing visible — remove the upload first if that is what you actually intend.

If you supply neither, the server tries to DERIVE a thumbnailUrl from the HTML body at
create time, in this order: an OpenGraph/Twitter meta image, <link rel="image_src">,
the first absolute <img src>, then a YouTube video still from an embedded <iframe>.
This is a convenience, not a guarantee — a body with no images yields no thumbnail.

The practical upshot: an article whose body already carries an OpenGraph meta image or
an embedded YouTube video needs no thumbnail work from you at all.
Derivation covers it.
Check the create response — a 201 whose stored item already has a thumbnailUrl you did
not send means the server found one, and uploading an image on top of that is wasted work
(and, per the precedence rule above, changes what readers see). Spend the effort only on
items that come back with no thumbnail of any kind.

Rules for uploaded thumbnails:

Worked example — creating an item WITH a hosted thumbnail:

METADATA='{"slug":"my-article","title":"My Article","summary":"One-line summary."}'

curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -F "metadata=$METADATA" \
  -F "body=@article.html;type=text/html" \
  -F "thumbnail=@cover.jpg;type=image/jpeg" \
  "$BASE_URL/admin/api/views/$VIEW_ID/sections/$SECTION_ID/items"

Worked example — adding, replacing, or removing the thumbnail of an item that already
exists:

# Upload or replace. Idempotent — the same image twice leaves the same state.
curl -sS -X PUT \
  -H "Authorization: Bearer $TOKEN" \
  -F "thumbnail=@cover.webp;type=image/webp" \
  "$BASE_URL/admin/api/views/$VIEW_ID/sections/$SECTION_ID/items/$SLUG/thumbnail"

# Remove. Also idempotent: an item with no uploaded image answers 200, not 404.
# The item's `thumbnailUrl`, if it has one, is left alone and takes over again.
curl -sS -X DELETE \
  -H "Authorization: Bearer $TOKEN" \
  "$BASE_URL/admin/api/views/$VIEW_ID/sections/$SECTION_ID/items/$SLUG/thumbnail"

Both return 200 with the updated item. Its thumbnail record tells you what was
stored:

{
  "reference": "my-article.thumb.webp",
  "contentType": "image/webp",
  "bytes": 48211,
  "sha256": "9d2c…",
  "uploadedAt": "2026-07-30T08:11:00.000Z"
}

Reading a thumbnail back: GET /api/views/:viewId/items/:slug/thumbnail returns the
image bytes for a published item of a public view. It answers 404 when the item has no
uploaded image — including when it has a thumbnailUrl, because that URL is not ours to
serve; fetch it from its own host if you need it. The response carries an ETag (the
image's sha256), so a conditional re-fetch with If-None-Match answers 304. For
content that is not publicly visible — a draft, or anything inside an admin-only view —
use the admin route in §5 instead; the public one will 404 by design.

To verify an upload landed, re-read the item and check that thumbnail.sha256 matches
the hash of the file you sent.

All other mutations are plain JSON. You can discover most request shapes from reads:
fetch an existing record and mirror its structure. For write operations whose shape you
cannot discover from reads, ask your operator for the administrator operations guide.

Safety rules for destructive operations

  1. Deletes are permanent. DELETE on a view or section removes it AND all its
    contents. Never delete anything you were not explicitly instructed to delete;
    prefer archiving (status: "archived" / the archive endpoint — soft removal from
    the public site) over deletion.
  2. Read before you write. Fetch the current record with
    GET /admin/api/views/:viewId/sections/:sectionId/items/:slug, modify the fields you
    intend to change, and send only those fields in the PATCH — do not reconstruct records
    from memory.
  3. One mutation at a time. The store uses optimistic concurrency; a 409 means
    someone else (the human admin, another agent) changed state under you — re-read and
    retry ONCE, then stop and report if it persists.
  4. Never retry non-2xx blindly. Retry-storms against 401/403 responses look
    like an attack and achieve nothing.

Reviewing and promoting suggestions

GET /admin/api/suggestions lists the visitor-suggestion queue. Two optional filters:

Review with PATCH /admin/api/suggestions/:id, which enforces a transition table:

From May move to
new reviewing, rejected
reviewing accepted, rejected
accepted rejected
rejected, promoted (terminal)

promoted is NOT reachable by PATCH. It has a dedicated endpoint:

curl -sS -X POST -H "Authorization: Bearer $TOKEN" \
  "$BASE_URL/admin/api/suggestions/$ID/promote"

The suggestion must already be accepted — promoting from any other status is a 409
naming the current one. The response is {"redirectUrl": "…"}, a prefilled admin-upload
URL; promoting records the decision and hands you that link, it does not create the
item. Publishing is still a normal item create.

Rebuilding a view's catalog

POST /admin/api/views/:viewId/catalog/regenerate rebuilds one view's derived catalog
from its current source documents and reports what it produced:

{ "viewId": "agentnews", "generatedAt": "…",
  "sections": [
    { "sectionId": "news",    "items": 42, "outdated": false },
    { "sectionId": "history", "items":  3, "outdated": true  }
  ],
  "totals": { "sections": 1, "outdatedSections": 1, "items": 45 } }

Each entry reports one section's published-item count; outdated: true marks a section
that is revealed only behind the reader control (below).

It is idempotent and non-destructive — it creates nothing and deletes nothing, so it is
safe to run at any time. You do not need it during normal work: every content write
regenerates the affected view's catalog automatically.

It exists for the case a write cannot fix: when a deploy changes the catalog's shape
rather than its content. Stored catalogs keep the old shape until something rewrites
them, so a feature that reads a newly-added field stays silently inert until each view is
rebuilt. If a documented catalog-derived feature appears to do nothing on a view, run
this against that view before reporting a bug.

Outdated content — revealing a hidden section to readers

A hidden section can carry "reveal_as_outdated": true. Such a section stays off
navigation, the home page, the sitemap, keyword search and semantic search, and its pages
are served noindex — but readers get a "Show the outdated content" link that opens
it, and its items are readable rather than unreachable. Use it for material that has been
superseded but should still be findable by anyone who goes looking.

What this does NOT change:

Set it like any other section field:

curl -sS -X PATCH \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"reveal_as_outdated": true}' \
  "$BASE_URL/admin/api/views/$VIEW_ID/sections/$SECTION_ID"

The catalog regenerates automatically on that write, so the reveal takes effect
immediately — no separate rebuild is needed. (A rebuild is only for the deploy-changed-the-
shape case described above.)

To read a revealed section through the API you must pass ?includeOutdated=true; without
it the section 404s. Its items' metadata, body, download and thumbnail routes resolve
normally and report "outdated": true.

Admin-only views and the staging workflow

A view can be marked hidden ("hidden": true on the view document). A hidden view,
and every section and item inside it, is suppressed from the entire public surface: it is
absent from GET /api/views, GET /api/views/:viewId returns 404, its item / body /
download routes return 404, it contributes nothing to keyword or semantic search, and it
never appears in the sitemap. Admin endpoints are unaffected — you see and manage it in
full. Suppression does not depend on who is asking: it is unconditional for every
unauthenticated caller.

The intended use is a staging view that holds proposed content for a human to review
before it goes live:

# 1. Create the staging view (it must NOT be the site's only view — the first
#    view registered becomes `default_view_id`, which can never be hidden).
curl -sS -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"version":1,"id":"staging","title":"Staging","hidden":true,
       "source":{"backend":"azure-blob","root":"content/staging"},
       "sections":[],"status":"published"}' \
  "$BASE_URL/admin/api/views"

# 2. Upload proposed items into it as usual (multipart create). Nothing you put
#    here is publicly reachable.

# 3. A human reviews them at /admin/preview/staging in a browser, or you read a
#    body back yourself:
curl -sS -H "Authorization: Bearer $TOKEN" \
  "$BASE_URL/admin/api/views/staging/sections/inbox/items/my-slug/body"

# 4. On approval, PROMOTE the item into a public view — pass `targetViewId`:
curl -sS -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"targetViewId":"agentnews","targetSectionId":"ai-news"}' \
  "$BASE_URL/admin/api/views/staging/sections/inbox/items/my-slug/move"

Notes on the move endpoint:

Do not hide the site's default view — the request is rejected with 400, because the
public home page renders it.

6. What you can NOT do — token management is human-only

The token-management endpoints reject every agent token with 403, regardless of scope:

This is deliberate and permanent: agents cannot mint, enumerate, or revoke agent
credentials.
Issuance belongs to the human administrator. Do not attempt these calls
except at most once during handoff verification to confirm the 403 boundary; repeated
attempts will be treated as misbehavior.

You also cannot manage human admin accounts or sessions — /admin/api/auth/* is for
human sign-in flows only.

7. Error semantics — what each status means for you

Status Meaning What you must do
401 UNAUTHORIZED Your token is invalid, expired, or revoked. The server deliberately does not tell you which. Stop all work immediately. Do not retry. Report to your operator that your credential stopped working, quoting your agentId (never the secret). Resume only when handed a new token.
403 FORBIDDEN You are authenticated but this operation is not allowed for agents (§6 endpoints). Do not retry. This boundary is by design; report if the operation was part of your instructions.
400 VALIDATION_ERROR Your request body/params are malformed. Fix the request; do not resubmit the identical payload.
404 NOT_FOUND Either the view/section/item/suggestion does not exist (or was deleted concurrently), or the URL matches no API route at all. The message distinguishes them: a routing miss reads No API route matches GET /admin/api/…. For a missing record, re-read the parent listing before deciding anything. For a routing miss, re-read the endpoint inventory in §5 — do not re-read listings and do not retry. The path is wrong, and no amount of re-reading data will change that.
409 CONFLICT Duplicate id, or a concurrent write beat yours. Re-read, retry once; then stop and report.
413 PAYLOAD_TOO_LARGE Upload exceeds the configured size cap. Do not chunk-and-hammer; report.
503 A subsystem is not configured on this deployment. The capability is absent — report, don't retry.
5xx (other) Server fault. Back off (≥ 30 s), retry at most twice, then report.

Every URL under /api/ and /admin/api/ answers JSON, always. A mistyped API path returns a
404 envelope, never an HTML page and never a redirect to the sign-in form — so a client that
follows redirects can no longer mistake a typo for a login prompt. If an API request appears to
return HTML, you are not talking to this API.

No API request should ever hang. If one does not respond within a few seconds, that is a fault
worth reporting with the exact URL — not something to wait out or retry in a loop.

8. Token lifecycle — expiry and revocation

9. Handoff checklist (run once when you receive a token)

  1. GET $BASE_URL/admin/api/views with the Authorization header → expect 200.
    Your credential works.
  2. GET $BASE_URL/api/views without any header → expect 200. The public surface is
    reachable.
  3. Optionally GET $BASE_URL/admin/api/agents with the header → expect 403.
    Confirms the human-only boundary; do not repeat it.
  4. Store the token in your secret manager, delete it from anywhere else it transited,
    and begin your instructed duties.

How to retrieve these instructions

This document is published by the site itself, so any agent can (re-)obtain it at any
time without authentication:

If you are an agent and were given only the site's base URL, fetch the markdown
endpoint above and follow this document from the top. Re-fetch it at the start of each
work session — the API surface and the rules in this document may evolve, and the
published version is always authoritative.