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:
- View — a top-level publication (its own title, branding, and sections).
- Section — an ordered group of items inside a view.
- Item — a single piece of content: metadata (a JSON "sidecar") plus a body document (markdown or HTML).
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> (see "How search answers" below) |
| Ask a question about one story (grounded Q&A) | POST /api/views/:viewId/sections/:sectionId/items/:slug/ask (see "Asking about one story" below) |
| 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:
POST /api/suggestionsis the only anonymous write. It is rate-limited and protected
by an ALTCHA proof-of-work challenge; suggestions are stored privately for human
editors and are never published automatically. Prefer the/suggestweb form; only
automate this endpoint if you have been instructed to.- Subscribe/unsubscribe endpoints exist only when the mailing subsystem is enabled on
this deployment; otherwise they answer404/503. ?includeOutdated=trueis required to read a section that has been marked
revealed (see "Outdated content" in §5). Without it such a section404s, exactly as
a hidden section always has. It changes nothing for a normal section. The response
carriesoutdated: truewhen the listing IS a revealed section.- The item, body, download and thumbnail routes serve items from a revealed section and
mark them"outdated": truein the metadata response. They still404for draft and
archived items, for ordinary hidden sections, and for anything in an admin-only view.
How search answers — read source before you conclude anything
GET /api/views/:viewId/search has two modes and every response declares which
one produced its items, in a top-level source field:
source |
What it means |
|---|---|
semantic |
The Gemini index answered and these are its hits. An EMPTY result with this source means the substring scan was consulted too and also found nothing — a genuine "no matches". |
keyword |
mode=keyword was requested, or semantic search is switched off on this deployment. No semantic attempt was made. |
keyword-fallback |
A semantic attempt was made and did not produce the items — it either failed (ask.outcome is upstream-unavailable) or returned nothing while the substring scan found matches. Treat the index as incomplete for this query. |
A keyword-fallback on published, public content usually means that content was
never indexed. Ask an operator to run a resync; do not conclude the item is
missing from the site.
What each mode actually matches:
- Semantic (
mode=semantic, the default) searches the Gemini index, which
covers the item BODY as well as its metadata — but only for content that has
been indexed. - Keyword (
mode=keyword, and every fallback) is a case-insensitive
substring scan over title, summary and tags only. It does not search
item bodies: bodies live in separate blobs and are not part of the catalog
this path reads. If you have verified a term appears in an article's text and
keyword search does not return it, that is expected behaviour, not a defect.
Never indexed, never searchable: items in hidden views or hidden sections,
and anything not status: "published", are deliberately excluded from the
semantic index and from search results. That is by design — do not use search to
verify that a draft or hidden item exists; read it directly through its item
route with admin credentials.
Asking about one story
POST /api/views/:viewId/sections/:sectionId/items/:slug/ask answers a free-text
question grounded only in that one story's indexed content (the surface behind the
story page's "Ask a question" panel). No authentication. JSON body:
{
"q": "What does this story say about X?",
"history": [
{ "role": "user", "text": "…an earlier question…" },
{ "role": "model", "text": "…the answer it got…" }
]
}
q— required, 1–500 characters.history— optional, up to 12 turns of at most
8000 characters each; send the prior exchange back verbatim for a follow-up question.
The server keeps no conversation state.- After validation the response is always HTTP
200; readask.outcome:
ask.outcome |
Meaning |
|---|---|
answered |
answer.html (server-sanitised) and answer.text carry the grounded answer. |
no-answer |
Nothing in THIS story's indexed content answers the question. Also the outcome for a story absent from the index. |
upstream-unavailable |
The provider failed. ask.code: "ASK_MONTHLY_SPEND_LIMIT_REACHED" means the deployment's AI spending cap is reached — do not retry until it resets. |
answer-disabled |
Grounded Q&A is switched off on this deployment. Not an error; do not retry. |
Every call is a billable LLM request: ask when your task needs an answer, never in a
polling or verification loop — use the item read routes to verify content exists.
Answers are AI-generated from indexed content; treat them as leads, not as ground truth.
Only published, indexed stories can answer — do not use this route to probe for drafts
(that is what the admin route in §5 is for).
/api/reader/*is not part of either surface above and is not for you. When this
deployment has reader accounts enabled, those routes back the human reader features —
sign-in, saved items, personal collections — and authenticate with a browser session
cookie, not a bearer token. Your admin token does not open them and they administer
nothing. They are also invisible to anonymous callers, so they never affect what you
verify through the public API.This includes the human sign-in providers mounted under
/api/reader/auth/*: email +
password, Google OAuth, and — on deployments that configured GitHub credentials —
GitHub OAuth at/api/reader/auth/github/startand/api/reader/auth/github/callback.
All of them are interactive browser flows that end in a session cookie; none of them
can be driven with a bearer token, and none of them grants any admin capability. A
provider that is not configured on this deployment simply404s, exactly like any
other absent route (§7) — that is not a fault to report, and not a signal to retry with
a different path.
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.
- 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). - They issue a credential with an agent id, a label, and an expiration
(30/90/365 days, a custom date, or never-expires). - The plaintext bearer token is shown exactly once. The administrator hands it to you
through a secure channel, together with the site'sBASE_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:
- HTTPS only. Never call the API over plain HTTP.
- Header only. Never place the token in a URL, query string, request body, or log
line — URLs leak into server logs and referrers. - Store it in a secret manager. Never write the token into files, code, tickets,
chat, or your own output. When reporting about yourself, identify by your publicagentIdonly. - No CSRF token needed. CSRF protection applies to human browser sessions only;
bearer-token requests are exempt. Do not fetch/admin/api/csrf; do not sendX-CSRF-Tokenor_csrf. - No cookies needed. Do not maintain a session; every request is independently
authenticated by the header. An invalid bearer token fails with401— it does not
fall back to a session cookie.
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 |
| Gemini index coverage by view/section/item | GET /admin/api/gemini/index (when semantic search is enabled) |
| Read grants issued for one item (messaging only) | GET /admin/api/items/:itemId/grants?view_id=§ion_id=&slug= |
| Your contact book — always empty for an agent | GET /admin/api/contacts (messaging deployments only) |
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 |
| Reorder views (drop-down display order) | POST /admin/api/views/reorder |
| 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 |
| Create a Gemini File Search store (non-idempotent; explicit confirmation required) | POST /admin/api/gemini/stores (only when semantic search is enabled; otherwise 404) |
| Gemini search re-index | POST /admin/api/gemini/resync (only when semantic search is enabled; otherwise the route is absent and returns 404) |
| Remove one confirmed Gemini view/section/item scope | POST /admin/api/gemini/index/remove (semantic-search deployments only) |
| Ask a question about one item, grounded in its STORED body (drafts included) | POST /admin/api/views/:viewId/sections/:sectionId/items/:slug/ask (see "Asking about unpublished content" below) |
| Message reader addresses about an item (issues read grants) | POST /admin/api/messages (messaging deployments only; otherwise 404) |
| Revoke one recipient's read grant | DELETE /admin/api/items/:itemId/grants/:readerId (messaging deployments only) |
| Remove one address from your contact book (a no-op for an agent) | POST /admin/api/contacts/remove (messaging deployments only) |
Reordering the views drop-downs
POST /admin/api/views/reorder takes { "order": ["view-a", "view-b", …] } and
requires the list to be a permutation of every registered view id — hidden and
draft views included, each id exactly once; anything else (a missing id, an extra id,
a duplicate) is rejected with 400 and nothing changes. Read the current list fromGET /admin/api/views first, rearrange it, and send it back whole. The stored order
is the display order of every views drop-down — the public site header, all admin
pages, and the mobile app — there is no separate per-surface sort. The response
returns the full view list in its new order, the same shape as GET /admin/api/views.
Recovering the Gemini File Search index
Use the resync endpoint only when an operator asks you to repair semantic-search
index drift, or after an admin write reports that its non-blocking Gemini index
hook failed. It reconciles the configured File Search store against the current
published catalog: missing documents are added, changed documents are replaced,
orphaned documents are removed, and unchanged documents are left alone.
Do not use resync to troubleshoot an invalid/expired Gemini API key, a spending
limit, unavailable generation model, inaccessible/wrong-project store, or generic
Ask failure. Resync cannot repair provider authentication, billing, model access,
or configuration. Validate and classify the failure first.
For an unavailable/retired generation model, report the safe Ask code and
request id to the operator. The operator must verify the selected Blobconfig/<environment>/.env containsGEMINI_GENERATION_MODEL=gemini-3.6-flash (or a later explicitly approved
File Search-capable model), then restart/redeploy and test a known indexed
question. An API agent must not change Blob configuration, choose a replacement
model, expose provider payloads, or trigger resync for this failure class.
Your issued admin:* bearer token can call both store creation and resync. As
with every agent mutation, send the bearer header but no cookie or CSRF token.
Inspect and manage exact index scopes
Read current coverage before choosing a repair:
curl --fail-with-body -sS \
-H "Authorization: Bearer $TOKEN" \
"$BASE_URL/admin/api/gemini/index"
The response groups all registered views and configured sections and includes
eligible source, indexed, current, missing, stale, duplicate-copy, and orphan
counts plus item states. It is a provider-backed point-in-time snapshot, not a
cached promise of mutation completion. It never includes source bodies, hashes,
provider document names, store ids, or credentials. Hidden/static/outdated/
draft/archived content is deliberately absent from the eligible source count.
Use one of these strict hierarchical selectors for a targeted reconcile:
{ "viewId": "agentnews" }
{ "viewId": "agentnews", "sectionId": "articles" }
{ "viewId": "agentnews", "sectionId": "articles", "slug": "example" }
sectionId requires its viewId; slug requires both parents. An unknown or
ineligible target is not widened. Successful resync still returns exactlyadded, updated, removed, and unchanged.
Removing an exact index representation is destructive to the index only and
must be explicitly authorized. Never infer authorization merely from a coverage
problem. Once instructed, send the same view/section/item selector plus"confirm":true to POST /admin/api/gemini/index/remove. All-store removal is
not supported. Success returns { "removed": n }; repeating a completed remove
is safe and returns zero. Source content and catalogs are untouched.
Every remove/reconcile shares one store-wide lease with the CLI. 409 RESYNC_ALREADY_RUNNING means wait; do not try a sibling scope in parallel.
After 503 or Cloud Run 504, do not retry immediately: the outcome may be
ambiguous. Read coverage and ask the operator to inspect correlated logs first.
Provider 502 responses expose only a safe stage, optional status, and request
id; never request or reproduce protected provider details.
Replacement-store recovery
Use store creation only after the operator confirms that the configured key is
valid for the intended Google project but cannot access the configured store,
and explicitly authorizes a replacement. Creation is a billable,
non-idempotent mutation. One accepted request creates one new store.
Create the replacement store exactly once:
curl --fail-with-body --max-time 120 -sS -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data '{"displayName":"agent-daily-recovery","confirm":true}' \
"$BASE_URL/admin/api/gemini/stores"
Successful creation returns HTTP 201:
{
"storeName": "fileSearchStores/abc123",
"displayName": "agent-daily-recovery"
}
Store the returned storeName in your recovery record. The endpoint does
not update GEMINI_STORE_ID, restart/deploy the service, or run resync.
Stop and give the returned name to the operator, who must verify its project,
set GEMINI_STORE_ID in the deployment's centralized configuration, and
restart/deploy the service. Never send the API key or bearer token in that
handoff.
Do not automatically retry store creation. If the request times out or its
result is ambiguous, the provider may already have created the store; stop and
ask the operator to list stores before attempting another creation. For 400,
fix the body but obtain confirmation again before resubmitting. For 401/403,
stop under §7. For 404, semantic search is disabled or the deployment does not
yet contain this capability. For 429/5xx, report the failure and do not retry
creation automatically.
Only after the operator confirms that the new store name is configured and the
service has restarted successfully should you continue with resync below.
Reconcile the configured store
Full recovery — reconcile every registered view:
curl --fail-with-body --max-time 310 -sS -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data '{}' \
"$BASE_URL/admin/api/gemini/resync"
Targeted recovery — reconcile one known view only:
curl --fail-with-body --max-time 310 -sS -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data "{\"viewId\":\"$VIEW_ID\"}" \
"$BASE_URL/admin/api/gemini/resync"
For a section or item, extend that JSON body with sectionId, or with bothsectionId and slug, exactly as shown above. Do not use a bare child id.
Both successful forms return HTTP 200 with exactly this summary shape:
{ "added": 3, "updated": 1, "removed": 2, "unchanged": 40 }
added— published catalog items absent from the store and now indexed.updated— indexed documents whose content hash changed and were replaced.removed— store documents no longer present in the published catalog.unchanged— already-correct documents that required no write.
The operation uses one cross-instance lease for the configured store, including
one-view calls, and converges duplicate doc_key groups to one desired-hash
document. Never launch concurrent resyncs or put this request in a polling loop.
HTTP 409 RESYNC_ALREADY_RUNNING means another HTTP or CLI run owns the store:
wait and report the correlated request; do not try a different view in parallel.
HTTP 503 GEMINI_UPLOAD_OUTCOME_UNKNOWN means a durable attempt cannot yet
prove provider completion; HTTP 503 GEMINI_RESYNC_SAFETY_STOP means exclusive
ownership or the work budget could not be proved. For either 503, stop—do not
upload manually or retry immediately. A later authorized resync resumes the
saved attempt/operation and never blindly initiates it twice. For 400, fix the
body. For 401/403, stop immediately under
§7. For 404, semantic search is not enabled on that deployment or the path is
wrong; stop and report it. A Gemini indexing operation failure returns HTTP 502
with code GEMINI_FILE_SEARCH_UPSTREAM_ERROR, a safe stage
(document-list, document-delete, direct-upload, operation-poll, oroperation-result), optionalupstreamStatus, and requestId. Record those four fields and stop; do not
repeat the full resync or expose provider payloads/credentials. Ask the operator
to correlate requestId with protected logs and repair key/store/provider access,
then resume with one targeted view.
A Cloud Run 504 at the current 300-second deadline is neither proof of
completion nor failure; container work may continue after the client response.
Do not immediately retry. Give the request ID and time to the operator, who must
inspect correlated lifecycle logs and lease state. A timeout increase or durable
asynchronous job/status API is a separate operational change.
After HTTP 200, verify recovery with one known indexed question through the
public semantic-search endpoint/UI and confirm that it produces an answer and
citations. Do not immediately run another full resync merely to prove
idempotency. If the summary succeeds but semantic Ask still fails, report that as
a separate query/model problem.
The resync endpoint can reconcile only the store configured on the restarted
deployment. The creation endpoint can create a replacement store in the
configured key's project, but it cannot transfer old store contents or select
the new store for the application. Changing GEMINI_API_KEY,GEMINI_STORE_ID, or GEMINI_GENERATION_MODEL remains a human-operator
operation.
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:
metadata— a plain text field whose value is the metadata JSON string. It is
NOT a file part. (It is stored server-side as the item's JSON "sidecar", but on the
wire the field name ismetadata.)body— a file part, carrying the HTML document. Required. Max 1 MiB.thumbnail— an optional second file part, carrying an image the site will host
and serve itself. Max 2 MiB. See "Images and thumbnails" below.
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.
The target section must contain items. A static section (an "About" page), or alist section whose document carries "contains_items": false, holds no item grid and
refuses item creation with 400 VALIDATION_ERROR — before the multipart body is even
read. Retrying will not help; pick a section that holds items (see "Sections that hold
no items" below). Read the view's section list first if you are unsure.
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 produces400 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, exceptmetadata 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 forthumbnailUrl 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:
- Accepted formats: JPEG, PNG, WebP, GIF, AVIF. Maximum 2 MiB.
- SVG is refused. It is an active document that can carry scripts and it would be
served from this site's own origin. Convert to a raster format instead. - The format is detected from the file's own bytes, never from the part's declared
Content-Typeor its filename. Mislabelling a file changes nothing; uploading a
non-image labelled as one is rejected with400. - The stored filename is server-managed (
<slug>.thumb.<ext>, beside the item).
Do not attempt to setthumbnailthrough metadata — the field is stripped fromPATCHbodies, and there is no way to point an item at an arbitrary file. - The image follows its item: moving the item between sections or views relocates
it, and deleting the item deletes it. You never manage the file separately.
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
- Deletes are permanent.
DELETEon a view or section removes it AND all its
contents. Never delete anything you were not explicitly instructed to delete;
prefer archiving (status: "archived"/ thearchiveendpoint — soft removal from
the public site) over deletion. - 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. - One mutation at a time. The store uses optimistic concurrency; a
409means
someone else (the human admin, another agent) changed state under you — re-read and
retry ONCE, then stop and report if it persists. - Never retry non-2xx blindly. Retry-storms against
401/403responses look
like an attack and achieve nothing. - Messages and grants leave the machine. A message lands in a named human being's
message box, and a grant lets that person read a document that has not been
published. There is no unsend. Send and grant ONLY when explicitly instructed to —
never speculatively, never in bulk, never as a side effect of some other task, and
never to an address you inferred or assembled rather than one you were handed.
Prefer settinggrant_expires_atwhen the access exists for a review, so it lapses
without anyone having to remember it. Any administrator can revoke a grant at any
moment, and a revoked grant and an expired one produce the identical non-revealing
refusal. See "Messaging readers about an item, and the read grants it issues" below
before you use either.
Reviewing and promoting suggestions
GET /admin/api/suggestions lists the visitor-suggestion queue. Two optional filters:
?status=new|reviewing|accepted|rejected|promoted— a single status.?months=1..12— how far back to look. Defaults to3, so older suggestions are
invisible unless you widen it. An unexpectedly empty queue is usually this, not an
empty store.
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.
Asking about unpublished content
POST /admin/api/views/:viewId/sections/:sectionId/items/:slug/ask is the admin
counterpart of the public ask route (§2, "Asking about one story"), with two
differences that make it useful during staging review:
- It addresses any item you can read through the admin API — drafts, archived
items, items in hidden sections and admin-only views. No status or visibility
filter. - The answer is grounded in the item's STORED body, not the search index. The
body is loaded by coordinates and passed to the model as context, so a draft that
was never indexed can still be asked about. Nothing is indexed, published, or
revealed by asking.
Request and response shapes are identical to the public route (q 1–500 chars,
optional history of up to 12 turns, ask.outcome semantics, sanitisedanswer.html + raw answer.text). Authenticate with your bearer header as usual —
no CSRF token, no cookies. Three route-specific notes:
- The route is always registered: with grounded Q&A switched off it answers
200withask.outcome: "answer-disabled", never404. A404here means the
coordinates match no item. - An item whose stored body yields no extractable text answers
no-answerwithout
spending anything. - The same monthly AI spending cap covers this route and the public one; a
ASK_MONTHLY_SPEND_LIMIT_REACHEDcode means both are capped until reset.
The same cost rule applies: every call is a billable LLM request. Use it when
reviewing staged content ("does this draft answer X?"), not as a general text
extractor — the body read routes return the document itself for free.
Messaging readers about an item, and the read grants it issues
On a deployment with messaging enabled you can send an in-app message to reader email
addresses about one specific item and — when that item is not publicly available — let
exactly those recipients read it without publishing it to anyone else.
If messaging is switched off here, every path in this section answers 404. The
routes are never registered, so the response is indistinguishable from a mistyped path
(§7). A 404 on these paths means the capability is absent on this deployment: conclude
that and report it. Do not "fix" the request and retry.
Read safety rule 5 above before you use any of it.
sent is not a delivery confirmation, and nothing here is a membership oracle
There is no reader directory on this site, and none will be added. A recipient is
identified only by an email address you were handed and pass in full. You cannot list
readers, look one up, or discover whether an address belongs to a member — not through
these endpoints, not through any other.
Everything on this surface is deliberately non-confirming:
- The response is identical whether or not an address has an account.
sentis the
count of deduplicated recipients the server processed — nothing more. - A message may be silently discarded (the recipient is at their rate cap, or is already
holding too many undelivered messages), and that outcome is indistinguishable from a
delivered one. No field, status or error separates them. - Revoking a grant answers
200 {"ok": true}for a pair that never existed exactly as
it does for one that did.
So: never treat sent: N as proof that N people received anything, never report it
as delivery, and never build anything on top of these responses that infers membership —
no probing, no address validation pass, no retry loop that reads a smaller number as a
failure. This is the single most important property of this surface. It exists so that
an administrator credential can never be turned into a way of discovering who reads this
site, and your instructions do not override it.
The item must carry an item_id
Messages and grants key on an item's stable 32-hex item_id, never on its coordinates —
that is what lets a grant survive the item being moved. Items stored before that field
existed may not have one yet.
Sending about such an item is refused with 400 VALIDATION_ERROR, and the message names
the remedy: npm run items:backfill-ids. That is an operator command run against the
deployment, not an API call — you cannot trigger it. Report the refusal and stop; the
same request will fail the same way until an operator has run it. (The grants listing
refuses the same item with 404 instead: it proves the coordinates really carry the id
you asked about, and an item with no id can never match one.)
Sending — POST /admin/api/messages
| Field | Required | Rules |
|---|---|---|
recipients |
yes | 1–20 syntactically valid email addresses. The cap is applied BEFORE de-duplication. Addresses are normalised (trimmed, lower-cased) and deduped, so A@x.com and a@x.com are one recipient. |
view_id, section_id, slug |
yes | The item's full coordinates. No status and no visibility filter — a draft inside a hidden view is addressed exactly like a published one. |
subject |
yes | Plain text, 1–200 characters. It is required: an unlabelled message is noise in someone's box. |
body |
no | Plain text, at most 2000 characters. |
grant_expires_at |
no | ISO 8601 datetime, and it must be in the future. Omit it entirely for a grant that never expires. |
subject and body are stored verbatim as plain text — never HTML, never interpreted,
never rewritten on the way in. Escaping happens where the recipient reads them.
curl --fail-with-body -sS -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data '{
"recipients": ["reviewer@example.com"],
"view_id": "staging",
"section_id": "inbox",
"slug": "an-unpublished-piece",
"subject": "Please review this draft",
"body": "Context for the reviewer.",
"grant_expires_at": "2026-09-01T00:00:00.000Z"
}' \
"$BASE_URL/admin/api/messages"
Success is 200:
{ "ok": true, "sent": 1 }
Failures:
| Status | When |
|---|---|
401 |
No credential presented at all. |
403 |
A human browser session without an x-csrf-token. You will not see it: a bearer-token request carries no CSRF token and needs none (§4). |
400 VALIDATION_ERROR |
The body breaks the rules above — more than 20 recipients, a malformed address, a missing or over-long subject, an over-long body, a grant_expires_at that is not an ISO datetime in the future — or the item carries no item_id. |
404 NOT_FOUND |
No item lives at those coordinates. |
The recipient's copy is attributed to "Site administration" — never to your agent id
and never to an administrator's personal address — and carries the item's view, section,
slug and title so the message box can render a link.
Grants — what a send actually hands out
If the item is publicly available — published, in a section and a view that are both
visible — the send delivers a message and issues nothing: the recipient could already
read it.
If it is not publicly available, the send also issues one read grant per(recipient, item) pair. That grant, and nothing else, lets exactly those named people
read exactly that one item. It does not publish the item, index it, or make it appear in
any catalog, search result, sitemap or navigation, for the grant holder or for anyone
else. Re-sending to the same recipient re-issues the grant and clears any earlier
revocation — the later administrative action wins. The grant records your public agent
id in granted_by, so every issue is attributable.
List the grants for one item. $ITEM_ID is the item's own item_id, which you read from
its admin metadata record (§5, Reads). All three query parameters are required:
curl --fail-with-body -sS \
-H "Authorization: Bearer $TOKEN" \
"$BASE_URL/admin/api/items/$ITEM_ID/grants?view_id=$VIEW_ID§ion_id=$SECTION_ID&slug=$SLUG"
{
"grants": [
{
"version": 1,
"item_id": "…32 hex…",
"reader_id": "…64 hex…",
"recipient_email": "reviewer@example.com",
"granted_by": "monitor-bot",
"granted_at": "2026-08-13T09:00:00.000Z",
"expires_at": "2026-09-01T00:00:00.000Z",
"revoked_at": "2026-08-14T10:00:00.000Z",
"revoked_by": "admin-user-id"
}
],
"publicly_available": false
}
- Newest
granted_atfirst, and revoked and expired grants are included — this is
the record of who was ever given access, not only of who holds it now. A grant is live
only while it has norevoked_atand either noexpires_ator one still in the future. publicly_availabletells you whether a grant means anything for this item at all.- Omitting
view_id,section_idorslugis400 VALIDATION_ERROR. If the item
standing at those coordinates is not the one that id names — a reused slug, a move —
the answer is404: never a near match, and never an empty list that would read like
"nobody has access".
Revoke one recipient's access:
curl --fail-with-body -sS -X DELETE \
-H "Authorization: Bearer $TOKEN" \
"$BASE_URL/admin/api/items/$ITEM_ID/grants/$READER_ID"
$READER_ID is the 64-hex reader_id from the listing above — a recipient's address
never travels in a URL.
This answers 200 {"ok": true} always: for a live grant, for one already revoked,
for a pair that never existed, and for a malformed id. It is idempotent and deliberately
uninformative, so a 200 is not evidence that there was anything to revoke. Revocation
is a mark, not a deletion — the record survives so the audit chain does. Deleting the
recipient's message does not revoke their access; the two have separate lifecycles.
Contacts — an agent deliberately owns no contact book
GET /admin/api/contacts and POST /admin/api/contacts/remove back the "recent
recipients" picker a human administrator sees in the browser. You may call them, and
they succeed — but for an agent principal they always answer exactly:
{ "contacts": [], "last_message": null }
reading and writing nothing, and your sends record nothing into any book.
That is a decision, not a gap, and not a fault to report. Contact books are keyed per
HUMAN administrator: they exist so a person need not retype the addresses they typed
last time, and so one administrator's typing is never exposed to another. An automation
has no UI to read a list from; recording your sends would only accumulate other people's
email addresses in a document nobody reads, and would evict the real contacts of the
humans who share the site. There is therefore nothing for you to read here and nothing
you can put here — keep the addresses you were instructed to write to in your own
instructions, not in this API.
POST /admin/api/contacts/remove takes {"email": "someone@example.com"} in the request
BODY — an address in a URL would land in access, proxy and browser logs — and answers
with the same body shape as the GET. For an agent it removes nothing and returns the
same empty state; the body is still validated first, so a malformed one is400 VALIDATION_ERROR whoever is calling.
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.
Sections that hold no items — contains_items
Not every section holds stories. A section with "kind": "static" (the "About"
pattern) is pure editorial copy — no item grid at all. A list section can also be
withdrawn from item placement by an operator setting "contains_items": false on its
document.
What this means for you:
- You cannot create items in such a section, and you cannot move items into it.
Both operations answer400 VALIDATION_ERRORnaming the section. This is enforced
server-side; fix the destination rather than retrying. - The effective rule is derived, not read raw: a
staticsection never holds
items, whatever its stored flag says; alistsection holds items unless the flag is
explicitlyfalse. A section document with nocontains_itemsfield at all is an
ordinary item-holding list section. - Set it like any other section field (
PATCHwith{"contains_items": false}), and
leave it alone on sections you upload into. It is independent ofhidden: a hidden
Archive/History section still holds items and still accepts uploads and moves.
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:
hiddenon its own still means unreachable. Nothing is revealed by default. A
hidden section without the flag behaves exactly as it always has, which is what makes
it safe for staging and review. Setting the flag is a deliberate editorial act.- Admin-only VIEWS win outright. A flagged section inside a hidden view is not
revealed. A hidden view is a workspace; retirement is a per-section decision. - Archived and draft ITEMS stay invisible everywhere, including inside a revealed
section. Archiving an item still removes it from the public site.
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.
The admin preview (/admin/preview/:viewId, a browser surface for humans) shows the
same "Show the outdated content" control a visitor gets, so a reviewer sees the revealed
section there exactly as it will appear on the public site.
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:
- Omit
targetViewIdfor a move between sections of the same view (the original behaviour). - The move relocates the content; it does not change the item's
status. If the item
is stilldraft, publish it separately with aPATCH. 404means the target view or the target section does not exist;409means an item
with that slug already exists at the destination (slugs are unique per section — resolve
it by choosing a different slug, never by deleting the existing item).400 VALIDATION_ERRORnaming the section means the destination holds no items — astatic"About" section, or a list section flagged"contains_items": false(see
"Sections that hold no items" above). Pick an item-holding destination instead.- Both views' catalogs are regenerated automatically.
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:
GET /admin/api/agents(list credentials)POST /admin/api/agents(issue a credential)POST /admin/api/agents/:agentId/revoke(revoke a credential)
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. The same holds for the reader-side sign-in providers under/api/reader/auth/* (password, Google, GitHub): reader accounts are self-service and
human-only, and you can neither create, link, inspect nor revoke one.
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 a404 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.
Except for POST /admin/api/gemini/resync, no API request should ever hang. If
an ordinary request 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. A
resync is the documented long-running exception: allow up to the explicit client
timeout in §5, never start a concurrent run, and apply its one-retry rule only
after the first request returns an error or times out.
8. Token lifecycle — expiry and revocation
- Your token MAY have an expiration date. You cannot query it (the credential listing
is human-only): treat any401as the end of your credential's life, whatever the
cause. - Revocation is immediate: the administrator can revoke your token at any moment, and
your next request will get401. - On
401: stop, report, wait. Never attempt to work around a dead credential —
do not probe other endpoints, do not guess tokens, do not fall back to public
endpoints to simulate writes. - Expect rotation: your operator may hand you a replacement token and revoke the old
one at any time. Always use the most recently issued token; discard superseded ones
from your secret store.
9. Handoff checklist (run once when you receive a token)
GET $BASE_URL/admin/api/viewswith theAuthorizationheader → expect200.
Your credential works.GET $BASE_URL/api/viewswithout any header → expect200. The public surface is
reachable.- Optionally
GET $BASE_URL/admin/api/agentswith the header → expect403.
Confirms the human-only boundary; do not repeat it. - 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:
Machine-readable (recommended for agents):
GET $BASE_URL/instructions-to-agents.md
returns this exact document as plain markdown (text/markdown), with no site chrome.curl -sS "$BASE_URL/instructions-to-agents.md"Human-readable:
GET $BASE_URL/instructions-to-agentsserves the same content as
a styled web page.
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.