Agent Content API: connect Koda, check it, rotate and revoke¶
Koda is the owner's content agent, run by the Muse service. It drafts and reviews posts for the series by calling the site's Agent Content API with one key. This page is how to make that key, connect Muse, prove it works, and take it away again. The reasons behind the design are in ADR 0037.
Everything here happens on the AI Engine's Koda tab:
https://hybridcloudworks.com/admin/ai-engine?tab=koda. You need the
super_admin role to make, change or revoke the key; an editor can see the
tab but the buttons say they are not theirs.
What the key can do, in plain words: everything the Drafts page can do (list, read, create, edit, send to review, pull back a draft) and read the published articles the public site shows. It cannot publish, unpublish, change a setting, read a secret or touch anything live. Each of those four groups is a switch on the tab, so any of them can be turned off without touching the key.
Until a key exists, every call to the API is refused and nothing is on.
1. Make the key and connect Muse¶
- Open https://hybridcloudworks.com/admin/ai-engine?tab=koda. The first card says No key yet.
- Press Generate key. An amber box appears with the key, beginning
hcwk_. This is the only time it is shown. Press Copy key. - In Muse, add a custom connector. There is no public address for that form: Muse opens it from its own custom-connector flow, which generates a connect link for you (the proposal of 2026-10-31 describes it that way), so start it from inside Muse. The Connect Muse to the site card on the tab lists every value the form asks for; the ones that matter are:
- Provider host:
api-azure.hybridcloudworks.com - Base URL:
https://api-azure.hybridcloudworks.com/api/agent-content/v1 - Authentication: API key. The site reads it from the
Authorizationheader asBearer <key>or as the bare key, or from anX-API-Key(orX-Agent-Key) header, so whichever shape the connector form offers works. It never reads a key from the address. - Paste the key into Muse's secure field. Never paste it into chat, a note or a ticket.
- Back on the tab, press I have pasted it, hide the key. The box closes and the key cannot be shown again.
- Let Muse make its first call,
GET …/agent-content/v1/status. A good answer is200with"agent": "koda"and acapabilitieslist. 401means the key was pasted wrong or has been revoked. Rotate it (part 3) and paste the new one.403means the switch for that call is off on the tab.429means more than 120 calls in a minute; the answer carries aRetry-Afterheader with the seconds to wait.- The
200answer in step 6 is the proof the connection works end-to-end. The card's Last used is a best-effort stamp, written at most once every five minutes, and the tab does not refresh itself: reload the page a few minutes after the call to see it move.
If Muse will not accept api-azure.hybridcloudworks.com as the host and
insists on hybridcloudworks.com, stop here and say so: the API does not
answer on the apex host today, and putting it there is a Cloudflare change
recorded as an open point in the ADR, not a setting on this tab.
2. Check it from your own machine (optional)¶
These prove the same thing without Muse. All are PowerShell; open a new PowerShell window for them and close it when you are done, because a plaintext string in a running shell cannot be reliably wiped. The first line asks for the key and keeps it out of your shell history; it is never printed, and the unmanaged copy it decodes from is zeroed and freed straight away.
$key = Read-Host 'Paste the Koda key' -AsSecureString; $ptr = [Runtime.InteropServices.Marshal]::SecureStringToGlobalAllocUnicode($key); try { $plain = [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeGlobalAllocUnicode($ptr) }
Invoke-RestMethod -Uri 'https://api-azure.hybridcloudworks.com/api/agent-content/v1/status' -Headers @{ Authorization = "Bearer $plain" } | ConvertTo-Json -Depth 4
A good result prints ok: True, agent: koda, the capabilities that are on,
and the limits. Then, with no key at all:
try { Invoke-RestMethod -Uri 'https://api-azure.hybridcloudworks.com/api/agent-content/v1/status' } catch { $_.Exception.Response.StatusCode.value__ }
A good result prints 401. Anything else means the API is not guarding the
route, which would be a defect to raise at once.
When you are done, drop the variables and close the window:
3. Rotate the key¶
Rotate when Muse's handling of the key is in doubt, when someone who could see the screen during step 2 of part 1 should not have, or on the schedule in the credential rotation schedule.
- On the tab, press Rotate key and confirm. A new key is shown once; the old one stops working on its very next request. Copy the new key.
- In Muse, open the connector and replace the key in its secure field.
- Press I have pasted it, hide the key on the tab.
- Have Muse call
statusagain; Last used moves. Until it does, Koda is getting401and is not working.
Rotating keeps the switches as they were and the record of who first made the key.
4. Switch off part of what Koda may do¶
On the What Koda may do card, turn a switch off. The change applies to
Koda's next call: any endpoint in that group answers 403 with a sentence
naming the switch. Turn it back on the same way. Both are written to the
audit log. The first four switches are the agent API's own; the five below
them (Pipeline, Enhanced, Second Brain, Spotlight, Platform) are the admin
sections of part 7, and turning one off closes every admin route under it.
The image upload route is listed under Pipeline and Spotlight both, but which
switch opens it depends on the storage container asked for: article images
(content, blogs, covers) follow Pipeline, certification and speaker-event
images follow Spotlight, and any other container answers 403. There is no
delete switch: the key has no delete route at all.
The Health page shows the same key
on its Content agent (Koda) card: whether a key exists and is active, when
it was last used and rotated, which switches are off, and the refusals of the
last day by reason. That count is of logged refusals only: the content-free
audit rows the API writes when a call presented a value shaped like a site key
and was refused. A call with no key, or with a value of another shape, is
refused without a row (see "Refused calls" below), so a zero on the card does
not mean no call was refused. The card says when it could not look (the key record or the audit
rows could not be read) instead of showing "none" or a zero. The Content
agent probe on its Checks tab gives the same verdict, and the five-minute
pulse records it. The card counts refusals through the two fields the audit
container indexes (action and timestamp, written on every denial row since
this card existed), so refusals logged before that change are not in its
count; the rows themselves are unchanged and still carry kind and at.
5. Revoke the key¶
- Press Revoke key and confirm. Koda's next request is refused with
401. Nothing Koda already wrote is changed; its drafts stay on the Drafts page. - To reconnect later, press Generate key and follow part 1 again.
6. Where to look when something is wrong¶
- The Drafts page (https://hybridcloudworks.com/admin/drafts): drafts
Koda wrote show
koda (agent)as their author. - Refused calls: every call that presented a value shaped like a site
key (
hcwk_followed by 40 to 64 letters, digits,-or_) and was refused is a row in theadmin_audit_logscontainer withkind: "agent-content-denial"(also written asaction, the field the container indexes, with the time as bothatandtimestamp) and areason(no-key,wrong-key,revoked-key,capability-off,rate-limited). A call with no key, or with a value of any other shape in those headers (hcwk_badincluded), is refused without a row, so internet scanners do not fill the log. The key itself is never in a row. Many 401s with no rows means the site never saw a value shaped like a key: the connector sends it somewhere the site does not read (the address, or a header other thanAuthorization,X-API-KeyandX-Agent-Key), or the value in the header is not the whole key (cut short, or wrapped in quotes), or a non-emptyAuthorizationholds something else (a stale token, for example), which is read instead of a goodX-API-Keybeside it. Check the header name and the value before moving or rotating the key. Part 2's command sends the value you paste at its prompt asAuthorization: Bearer, so a200there proves that pasted value is the current key. It does not prove Muse holds the same value or sends it the same way, so compare Muse's header name and value with it next; the command cannot show what Muse sent. - Key events: rows with
actionagent_content_key_generated,agent_content_key_rotated,agent_content_capabilities_changedoragent_content_key_revoked, each with the key's first characters and who did it. - The contract: every route and what it answers is in
.azure/api-surface.json, sectionsagentContentApiandadminAgentContent.
7. What Koda may do in the admin¶
Since 2026-11-01 (ADR 0038) the same
key also works on the admin routes under /api/ that the five section
switches open. Koda calls them exactly as the admin pages do, with the key in
place of a sign-in, and the site treats it as koda (agent) with an editor's
rights (a publisher's in the Doc Vault, and as you in the Second Brain). The
rules that hold on every one of them:
- Nothing can be deleted. A
DELETEis refused everywhere (the agent API's own draft delete is gone too), and the routes that delete or trash byPOSTare not open. - Nothing can be published, scheduled or unpublished. Those routes are not
open, nor are the routes that unpublish as a side effect (request
inspection, reset review state, the Listen & Learn episode review). The
routes that could reach publishing through their body are limited: a status
change may go to
approved,editing,forge_readyorrejected; a job may beforge-article,forge-from-url,batch-inspect,sync-blog-sourceorgenerate-listen-and-learn; a new content item may not be created alreadypublished(or one of the olderpublished_blog,published_news,published_bothspellings) orarchived; a book or chapter may be restored but not archived. - No secret or credential can be read, and no setting can be changed
(settings are read-only, where the editor needs them), and no section the
owner did not name is open: Forge Studio, AI Engine, Image Prompts, the
Amplify section, Platform Settings, admins and the lab agent registry
answer
403. - Every refusal is logged: an
admin_audit_logsrow of kindagent-content-denialwithsurface: "admin", the method, the route as the list spells it (cms/drafts/{id}, never the id itself;unlistedfor a route not on the list) and a reason (route-not-allowed,section-off,role,body-not-allowed,body-unreadable,user-only-route, or the key's ownwrong-key,revoked-key,rate-limited), never the key or the body.
The full list, as the API reports it, is on the tab under What Koda may do
in the admin and in GET …/agent-content/v1/status as adminAccess. In
short, by switch:
| Switch | Pages | Koda may | Koda may not |
|---|---|---|---|
| Pipeline | New Content, Drafts, Review Queue, Editor, Live Pages | List and read content, versions and drafts; create and edit drafts and articles; save the editor; reorder images; move content between review states; generate article drafts, hero and preview images and captions; enqueue the producing jobs and read job state; read the queue, publish board, decision centre and dashboard; read settings, taxonomy and the image gallery; upload images | Publish, schedule, unpublish, set a slug, rehost images, delete anything |
| Enhanced | Listen & Learn, Labs, Frameworks, Coder Corner, Blogs | Read, create and edit Listen & Learn books and chapters, estimate and regenerate, restore an archived book or chapter; read the lab estate, a lab job, the starts and the Coder automation; read, create and edit blog sources (Frameworks and Coder Corner are content, so the Pipeline rows cover them) | Archive or delete books, chapters or versions; publish or withdraw an episode; launch or cancel lab jobs; change lab agents; read the Coder token; delete a blog |
| Second Brain | Second Brain | Read status, notes, history, graph and context; search and ask; create and edit notes and collections; draft from a note, acting as you | Reindex; the desktop sync routes |
| Spotlight | Speaking, Certifications, Ambassador, Doc Vault | Read talks and upsert one; read, create and edit certifications; read, create and edit applications and evidence, import evidence, read sources, files, programs and readiness; in the Doc Vault read, create, upload, edit, untrash and restore a version | Delete a talk, a certification, an application, evidence or a document; create or edit a program |
| Platform | Health, Integrations | Read the health probe results and the ops snapshot; read the integration status and the StreamYard status; run a connection test | Change a probe result or an integration setting; read secrets or credentials; change platform settings |
To take a section away, turn its switch off on the tab; to give Koda a route
that is not listed, or to let it publish, change the table in
functions/src/lib/agent-content/admin-access.js through a pull request.
What the API expects from Koda, in brief¶
- Drafts are addressed by
id, not slug.GET draftsreturns the list with each draft'sidandetag;GET drafts/{id}returns the body. - Every draft answer, in the list and alone, carries two links:
editUrl, the Drafts page with that draft open (https://hybridcloudworks.com/admin/drafts?draft=<id>, for the owner or an admin, signed in), andpreviewUrl, the public preview page with a signed token that expires after 72 hours and shows the draft as the public page would.previewUrlisnullwhile the site's preview secret is not seeded, andnullon every answer to a key whose Read drafts switch is off, because whoever holds the link can read the body. Koda can hand either to the owner without any further call. - Every change (
PUT,send-to-review,back-to-drafts) carries theetagof the version Koda last read. A stale one answers412 CONFLICT: read again, then retry. - A new draft is
POST draftswith{ "fields": { title, subtitle, date, track, part, tags, reading, body, kind, ideaOrigin } }(title and body required), orPOST drafts/from-markdownwith{ "markdown": "<the whole file, front matter included>" }in the blog template's format. The markdown route is the safe way to create and the safe one to retry: a title already in Drafts or In Review answers200withcode: "ALREADY_IMPORTED"and the existingcontentId; a title already published answers200withcode: "PUBLISHED_ELSEWHERE"andexistingId; neither writes a second draft.POST draftsmakes a new id every time, so a lost answer and a retry there would make two drafts. - A key whose Read drafts switch is off still gets its answers, but
wherever one would carry the draft as the page sees it (
draftwithfields), the text is replaced bybodyWithheld: trueand the summary (id, title, stage, etag, allowed actions) stays. The markdown route's answers are the same for every key: a created markdown draft answers201withcontentIdand adraftsummary that includes itsetag(keep it; it is the version token for the next change); the skipped answers,200 ALREADY_IMPORTEDwithcontentIdand200 PUBLISHED_ELSEWHEREwithexistingId, carry no etag. - The body is at most 256 KB. Errors are
{ "ok": false, "code", "error" }. GET publishedandGET published/{slugOrId}answer exactly as the public site's own reads do.