@awesomate/hosting-mcp 0.92.2 → 0.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -40747,6 +40747,21 @@ function installedSkillVersion(name) {
40747
40747
  return null;
40748
40748
  }
40749
40749
  }
40750
+ function compareVersions(a, b) {
40751
+ const pa = a.split(/[.-]/).map((x) => Number.parseInt(x, 10) || 0);
40752
+ const pb = b.split(/[.-]/).map((x) => Number.parseInt(x, 10) || 0);
40753
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
40754
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
40755
+ if (d !== 0) return d < 0 ? -1 : 1;
40756
+ }
40757
+ return 0;
40758
+ }
40759
+ function oldestInstalledVersion(perSkill) {
40760
+ return perSkill.reduce(
40761
+ (min, s) => s.installed !== null && (min === null || compareVersions(s.installed, min) < 0) ? s.installed : min,
40762
+ null
40763
+ );
40764
+ }
40750
40765
  function skillVersions(sourceRoot = DEFAULT_SOURCE_ROOT) {
40751
40766
  return bundledSkillNames(sourceRoot).map((name) => ({ name, installed: installedSkillVersion(name) }));
40752
40767
  }
@@ -40803,8 +40818,9 @@ function installSkills(version2, sourceRoot = DEFAULT_SOURCE_ROOT) {
40803
40818
  }
40804
40819
 
40805
40820
  // src/knowledge.ts
40806
- function scrubKeyMaterial(text) {
40807
- return text.replace(/amk_[a-z]_[A-Za-z0-9]{6,}/g, "[redacted:platform-key]").replace(/amt_[a-z]+_[A-Za-z0-9]{6,}/g, "[redacted:hub-token]");
40821
+ import { createHash } from "node:crypto";
40822
+ function scrubKeyMaterial(text, opts = {}) {
40823
+ return text.replace(/amk_([a-z])_[A-Za-z0-9]{6,}/g, (m, cls) => opts.keepWebsiteKeys && cls === "p" ? m : "[redacted:platform-key]").replace(/amt_[a-z]+_[A-Za-z0-9]{6,}/g, "[redacted:hub-token]");
40808
40824
  }
40809
40825
  var KNOWLEDGE_GATE_COPY = {
40810
40826
  displayName: "Knowledge Base",
@@ -40890,13 +40906,33 @@ function storageFullPayload(body) {
40890
40906
  note: "The Knowledge Base storage is full and the source was NOT added. Relay the message exactly as it is; offer no purchase, because none is open to this account or this person right now. Removing sources they no longer need frees space."
40891
40907
  };
40892
40908
  }
40909
+ async function deleteOrNotFound(config3, path) {
40910
+ try {
40911
+ return await hubDelete(config3, path) ?? { deleted: true };
40912
+ } catch (err) {
40913
+ if (err instanceof HubApiError && err.status === 404 && bodyOf(err)?.error === "not_found") {
40914
+ return {
40915
+ deleted: false,
40916
+ not_found: true,
40917
+ note: "Nothing with that id was there. It may already be deleted (an earlier attempt can go through even when its answer never arrives), or the id is wrong. List again to check before telling the user either way."
40918
+ };
40919
+ }
40920
+ throw err;
40921
+ }
40922
+ }
40893
40923
  function bodyOf(err) {
40894
40924
  return err.body && typeof err.body === "object" ? err.body : null;
40895
40925
  }
40896
40926
  function mapKnowledgeError(err, apiBase) {
40897
40927
  if (!(err instanceof HubApiError)) throw err;
40898
40928
  const body = bodyOf(err);
40899
- if (err.status === 404) return notAvailablePayload(apiBase);
40929
+ if (err.status === 404) {
40930
+ if (typeof body?.error !== "string" || !/^[a-z0-9_]+$/.test(body.error)) return notAvailablePayload(apiBase);
40931
+ if (body.error === "not_found") {
40932
+ return { error: "not_found", note: typeof body.message === "string" ? body.message : "There is no such item on this account. List again for the current ids." };
40933
+ }
40934
+ throw err;
40935
+ }
40900
40936
  if ((err.status === 403 || err.status === 402) && body?.upgrade_required) return upsellPayload(apiBase, body.plan, body);
40901
40937
  if (err.status === 403 && body?.consent_required) {
40902
40938
  return consentPayload(typeof body.error === "string" ? body.error : 'Knowledge is not using your content: "Use your content for Knowledge" is off', apiBase);
@@ -41147,10 +41183,7 @@ async function knowledgeSources(config3, args) {
41147
41183
  }
41148
41184
  if (args.action === "remove") {
41149
41185
  if (!args.sourceId) throw new Error("sourceId is required for remove");
41150
- return await hubDelete(
41151
- config3,
41152
- `/api/knowledge/sources/${encodeURIComponent(args.sourceId)}`
41153
- );
41186
+ return deleteOrNotFound(config3, `/api/knowledge/sources/${encodeURIComponent(args.sourceId)}`);
41154
41187
  }
41155
41188
  if (!args.url && !args.sitemap) {
41156
41189
  return {
@@ -41194,15 +41227,18 @@ function renderAnswer(meta, streamedAnswer, streamError = null) {
41194
41227
  const session = typeof meta.session === "string" && meta.session ? { session: meta.session } : {};
41195
41228
  const fallback = typeof meta.answer_plain === "string" && meta.answer_plain || typeof meta.no_answer_message === "string" && meta.no_answer_message || null;
41196
41229
  if (status === "error") {
41230
+ const errorCode = typeof meta.error_code === "string" ? meta.error_code : null;
41231
+ const ownKey = errorCode === "byok_key_invalid" || errorCode === "byok_no_credit";
41197
41232
  return {
41198
41233
  status: "error",
41199
41234
  answer: null,
41200
41235
  platform_error: true,
41201
41236
  not_in_verified_content: false,
41237
+ ...errorCode ? { error_code: errorCode } : {},
41202
41238
  detail: streamError ?? (streamedAnswer || null),
41203
41239
  configured_fallback: fallback,
41204
41240
  ...session,
41205
- note: "The knowledge platform failed to answer: a service problem, NOT a content gap. Do not tell the user their content lacks this. Say the service hit a problem, retry once, and if it persists raise it via awesomate_support. configured_fallback is only what an end customer would have seen meanwhile."
41241
+ note: ownKey ? `The agent runs on this account's own OpenRouter key, and OpenRouter ${errorCode === "byok_no_credit" ? "says it is out of credit" : "refused it"}. This is NOT a content gap. Tell the user to top up or replace the key at Settings, Integrations (hub.awesomate.ai/settings?tab=integrations), or to clear the agent's model so it runs on Awesomate's.` : "The knowledge platform failed to answer: a service problem, NOT a content gap. Do not tell the user their content lacks this. Say the service hit a problem, retry once, and if it persists raise it via awesomate_support. configured_fallback is only what an end customer would have seen meanwhile."
41206
41242
  };
41207
41243
  }
41208
41244
  if (status !== "ok") {
@@ -41636,6 +41672,17 @@ function mapAgentsError(err, apiBase) {
41636
41672
  }
41637
41673
  if (err instanceof HubApiError && err.status === 403) {
41638
41674
  const body = bodyOf(err);
41675
+ if (body && body.error === "upgrade_required" && body.capability === "public_widget") {
41676
+ const requiredPlan = typeof body.requiredPlan === "string" ? body.requiredPlan : "Support Plus";
41677
+ return {
41678
+ upgrade_required: true,
41679
+ capability: "public_widget",
41680
+ requiredPlan,
41681
+ ...typeof body.message === "string" ? { message: body.message } : {},
41682
+ billingUrl: `${apiBase}/billing?upgrade=${encodeURIComponent(requiredPlan)}`,
41683
+ note: "Nothing was made. Relay the message as it is and let the user decide; never retry."
41684
+ };
41685
+ }
41639
41686
  if (body && body.error === "upgrade_required" && body.capability === "knowledge_questions") {
41640
41687
  const requiredPlan = typeof body.requiredPlan === "string" ? body.requiredPlan : "Support Plus";
41641
41688
  return {
@@ -41650,6 +41697,107 @@ function mapAgentsError(err, apiBase) {
41650
41697
  }
41651
41698
  return mapKnowledgeError(err, apiBase);
41652
41699
  }
41700
+ var WEBSITE_KEY_MAX_ORIGINS = 16;
41701
+ function websiteOrigins(input) {
41702
+ const raw = (input ?? []).map((s) => s.trim()).filter(Boolean);
41703
+ if (!raw.length) return { ok: false, error: "website_key needs origins: the website addresses the chat will run on, like https://example.com.au" };
41704
+ const out = [];
41705
+ for (const r of raw) {
41706
+ if (r.includes("*")) return { ok: false, error: `"${r}": a wildcard is not allowed, because the key would then answer on any website.` };
41707
+ let u;
41708
+ try {
41709
+ u = new URL(r.includes("://") ? r : `https://${r}`);
41710
+ } catch {
41711
+ return { ok: false, error: `"${r}" is not a website address.` };
41712
+ }
41713
+ if (u.protocol !== "https:") return { ok: false, error: `"${r}" must be an https:// address.` };
41714
+ if (u.username || u.password) return { ok: false, error: `"${r}" must not include a username or password.` };
41715
+ if (u.pathname !== "/" || u.search || u.hash) return { ok: false, error: `"${r}" should be just the site address, without a path, for example ${u.origin}` };
41716
+ if (!out.includes(u.origin)) out.push(u.origin);
41717
+ }
41718
+ if (out.length > WEBSITE_KEY_MAX_ORIGINS) return { ok: false, error: `At most ${WEBSITE_KEY_MAX_ORIGINS} websites per key.` };
41719
+ return { ok: true, origins: out };
41720
+ }
41721
+ var htmlAttr = (v) => v.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
41722
+ function websiteSnippet(widgetUrl, key, agentId, title) {
41723
+ const attrs = [["data-key", key], ["data-agent", agentId]];
41724
+ if (title?.trim()) attrs.push(["data-title", title.trim()]);
41725
+ const lines = attrs.map(([k, v], i) => ` ${k}="${htmlAttr(v)}"${i === attrs.length - 1 ? "></script>" : ""}`);
41726
+ return [`<script src="${htmlAttr(widgetUrl)}"`, ...lines].join("\n");
41727
+ }
41728
+ function websiteKeyRequestId(agentId, origins, label, cap, liveKeyIds) {
41729
+ const h = createHash("sha256").update(`${agentId}|${[...origins].sort().join(",")}|${label}|${cap ?? "default"}|${[...liveKeyIds].sort().join(",")}`).digest("hex");
41730
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`;
41731
+ }
41732
+ function liveWebsiteKeys(keys, agentId) {
41733
+ const now = Date.now();
41734
+ return keys.filter(
41735
+ (k) => k.class === "p" && (!agentId || k.agent_id === agentId) && !k.revoked_at && !(typeof k.expires_at === "string" && new Date(k.expires_at).getTime() <= now)
41736
+ );
41737
+ }
41738
+ function websiteAgentRefusal(agent) {
41739
+ const name = typeof agent.name === "string" && agent.name ? `"${agent.name}"` : "This agent";
41740
+ if (agent.status !== "published") return `${name} is not published yet, so a website chat would have nothing to answer with. Test it, then publish it (with the user's yes) first.`;
41741
+ if (agent.audience !== "public") return `${name} serves a ${typeof agent.audience === "string" ? agent.audience : "private"} audience, and a website chat answers the public. Change its audience to public and publish again (with the user's yes) first.`;
41742
+ if (agent.suspended_at) return `${name} is paused, so a website chat would not answer. Resume it (with the user's yes) first.`;
41743
+ return null;
41744
+ }
41745
+ var WEBSITE_KEY_NOTE = "A website key is meant to sit in page source: it only answers from the websites listed, only as this agent, and only from sources marked public. It carries its own monthly answer cap (by default half the account's answers), so one busy site cannot use up every answer.";
41746
+ async function websiteKeyAction(config3, args) {
41747
+ const base = "/api/knowledge/agents";
41748
+ if (args.action === "website_keys") {
41749
+ const r = await hubGet(config3, `${base}/keys`);
41750
+ return { keys: liveWebsiteKeys(r.keys ?? [], args.agentId), note: `${WEBSITE_KEY_NOTE} The key itself is shown only once, when it is made; it cannot be read back.` };
41751
+ }
41752
+ if (args.action === "revoke_website_key") {
41753
+ if (!args.keyId) return { error: "invalid_request", note: "revoke_website_key needs keyId (from website_keys)." };
41754
+ const r = await hubGet(config3, `${base}/keys`);
41755
+ if (!liveWebsiteKeys(r.keys ?? []).some((k) => k.key_id === args.keyId)) {
41756
+ return { error: "not_found", note: "There is no working website key with that id. Read website_keys for the ids; other kinds of key are managed in the hub only." };
41757
+ }
41758
+ if (args.confirm !== true) {
41759
+ return { error: "confirmation_required", note: "Revoking this key stops the chat on every website using it at once. Tell the owner, and call again with confirm:true only after they say yes." };
41760
+ }
41761
+ return deleteOrNotFound(config3, `${base}/keys/${encodeURIComponent(args.keyId)}`);
41762
+ }
41763
+ if (!args.agentId) return { error: "invalid_request", note: "website_key needs agentId (from list) and origins." };
41764
+ const origins = websiteOrigins(args.origins);
41765
+ if (!origins.ok) return { error: "invalid_request", note: `${origins.error} Nothing was made.` };
41766
+ const agent = agentOf(await hubGet(config3, `${base}/${encodeURIComponent(args.agentId)}`));
41767
+ const notReady = websiteAgentRefusal(agent);
41768
+ if (notReady) return { error: "agent_not_ready", note: `${notReady} Nothing was made.` };
41769
+ if (args.confirm !== true) {
41770
+ return {
41771
+ error: "confirmation_required",
41772
+ note: `Making a website key puts this agent on ${origins.origins.join(", ")}, where anyone visiting can ask it questions, and those answers count against the account's monthly answers. Tell the owner that, and call again with confirm:true only after they say yes.`
41773
+ };
41774
+ }
41775
+ const status = await hubGet(config3, "/api/knowledge/status");
41776
+ const widgetUrl = typeof status.widget_url === "string" && status.widget_url ? status.widget_url : null;
41777
+ if (!widgetUrl) return { error: "widget_unavailable", note: "This hub has no website chat loader configured, so a key would have nowhere to run. Nothing was made; tell the user to contact Awesomate support." };
41778
+ const listed = await hubGet(config3, `${base}/keys`);
41779
+ const liveIds = liveWebsiteKeys(listed.keys ?? [], args.agentId).map((k) => String(k.key_id));
41780
+ const label = (args.label?.trim() || `website: ${origins.origins.map((o) => o.replace(/^https:\/\//, "")).join(", ")}`).slice(0, 120);
41781
+ const minted = await hubPost(config3, `${base}/keys`, {
41782
+ class: "p",
41783
+ agent_id: args.agentId,
41784
+ allowed_origins: origins.origins,
41785
+ label,
41786
+ request_id: websiteKeyRequestId(args.agentId, origins.origins, label, args.monthlyAnswerCap, liveIds),
41787
+ ...args.monthlyAnswerCap !== void 0 ? { monthly_answer_cap: args.monthlyAnswerCap } : {}
41788
+ });
41789
+ const key = typeof minted.key === "string" ? minted.key : "";
41790
+ const title = args.title?.trim() || (typeof agent.name === "string" ? agent.name : "");
41791
+ const cap = typeof minted.monthly_answer_cap === "number" ? minted.monthly_answer_cap : null;
41792
+ return {
41793
+ key_id: minted.key_id ?? null,
41794
+ key,
41795
+ allowed_origins: origins.origins,
41796
+ monthly_answer_cap: cap,
41797
+ snippet: key ? websiteSnippet(widgetUrl, key, args.agentId, title) : null,
41798
+ note: `${WEBSITE_KEY_NOTE} The key is shown ONCE: hand the snippet straight to the user to paste before </body> on those sites (or add it to the site for them), and do not repeat the key anywhere else.` + (cap !== null ? ` Tell the user this key has its own limit of ${cap} answers a month. A key's limit cannot be changed once it is made: to change it, make a new key with monthlyAnswerCap, swap the snippet on the site, then revoke this one.` : "")
41799
+ };
41800
+ }
41653
41801
  var VISITOR_TEST_MAX_CHARS = 2e3;
41654
41802
  var POLICY_TAGS_MAX = 50;
41655
41803
  function policyScope(args) {
@@ -41668,6 +41816,9 @@ var POLICY_NOTE = "An access policy narrows what a key or a signed-in person may
41668
41816
  async function knowledgeAgents(config3, args) {
41669
41817
  try {
41670
41818
  const base = "/api/knowledge/agents";
41819
+ if (args.action === "website_key" || args.action === "website_keys" || args.action === "revoke_website_key") {
41820
+ return await websiteKeyAction(config3, args);
41821
+ }
41671
41822
  if (args.action === "logs") {
41672
41823
  const q2 = new URLSearchParams();
41673
41824
  if (args.agentId) q2.set("agent_id", args.agentId);
@@ -41745,19 +41896,20 @@ async function knowledgeAgents(config3, args) {
41745
41896
  const current = scope && typeof scope === "object" && !Array.isArray(scope) ? scope : {};
41746
41897
  return touchesScope(scopeArgs) ? { scope: agentPatchBody(scopeArgs, current).scope } : scope !== void 0 ? { scope } : {};
41747
41898
  };
41899
+ const callerFields = agentPatchBody({ ...args, collectionIds: void 0, kinds: void 0, sourceIds: void 0 });
41748
41900
  let created;
41749
41901
  let sentScope;
41750
41902
  let extra = {};
41751
41903
  if (args.goal) {
41752
41904
  const draft = await hubPost(config3, "/api/knowledge/agents/draft", { goal: args.goal });
41753
41905
  if (!draft.proposal) return { error: "draft_failed", detail: draft };
41754
- const body = { ...draft.proposal, ...withScope(draft.proposal.scope), ...args.audience ? { audience: args.audience } : {} };
41906
+ const body = { ...draft.proposal, ...callerFields, ...withScope(draft.proposal.scope) };
41755
41907
  sentScope = body.scope;
41756
41908
  created = await hubPost(config3, "/api/knowledge/agents", body);
41757
41909
  extra = { test_questions: draft.test_questions ?? [], status_note: `Created as a private DRAFT serving audience '${args.audience ?? "private"}': test it, then publish when the user approves.` };
41758
41910
  } else {
41759
41911
  if (!args.name) return { error: "name (or goal) is required for create" };
41760
- const body = { name: args.name, ...args.audience ? { audience: args.audience } : {}, ...withScope(void 0) };
41912
+ const body = { ...callerFields, name: args.name, ...withScope(void 0) };
41761
41913
  sentScope = body.scope;
41762
41914
  created = await hubPost(config3, "/api/knowledge/agents", body);
41763
41915
  }
@@ -41856,7 +42008,7 @@ async function knowledgeCollections(config3, args) {
41856
42008
  return await hubPatch(config3, `/api/knowledge/collections/${encodeURIComponent(args.collectionId)}`, body);
41857
42009
  }
41858
42010
  if (args.action === "delete") {
41859
- return await hubDelete(config3, `/api/knowledge/collections/${encodeURIComponent(args.collectionId)}`);
42011
+ return deleteOrNotFound(config3, `/api/knowledge/collections/${encodeURIComponent(args.collectionId)}`);
41860
42012
  }
41861
42013
  if (!args.sourceIds?.length) return { error: "invalid_request", note: `${args.action} needs sourceIds` };
41862
42014
  return await hubPost(config3, `/api/knowledge/collections/${encodeURIComponent(args.collectionId)}/sources`, {
@@ -42135,10 +42287,7 @@ function changelogSince(installed) {
42135
42287
  function skillUpdateInfo() {
42136
42288
  const perSkill = skillVersions();
42137
42289
  const staleSkills = perSkill.filter((s) => s.installed !== SERVER_VERSION).map((s) => s.name);
42138
- const oldest = perSkill.reduce(
42139
- (min, s) => s.installed !== null && (min === null || s.installed < min) ? s.installed : min,
42140
- null
42141
- );
42290
+ const oldest = oldestInstalledVersion(perSkill);
42142
42291
  const updateAvailable = perSkill.length === 0 || staleSkills.length > 0;
42143
42292
  return {
42144
42293
  serverVersion: SERVER_VERSION,
@@ -43426,13 +43575,13 @@ server.registerTool(
43426
43575
  }
43427
43576
  }
43428
43577
  );
43429
- function knowledgeResult(data) {
43578
+ function knowledgeResult(data, opts = {}) {
43430
43579
  return {
43431
43580
  content: [
43432
43581
  {
43433
43582
  type: "text",
43434
43583
  text: `${accountStamp()}
43435
- ${scrubKeyMaterial(JSON.stringify(data, null, 2))}`
43584
+ ${scrubKeyMaterial(JSON.stringify(data, null, 2), opts)}`
43436
43585
  }
43437
43586
  ]
43438
43587
  };
@@ -43564,7 +43713,7 @@ server.registerTool(
43564
43713
  author: external_exports.array(external_exports.string().max(200)).max(10).optional(),
43565
43714
  year: external_exports.array(external_exports.string().regex(/^\d{4}$/)).max(10).optional(),
43566
43715
  doc: external_exports.array(external_exports.string().max(200)).max(50).optional().describe("doc_ids from search hits")
43567
- }).optional().describe("Ask within a slice of the library: EXACTLY the awesomate_knowledge_search input shapes, reusable verbatim; filters only ever narrow")
43716
+ }).optional().describe("Ask within a slice of the library: EXACTLY the awesomate_knowledge_search input shapes, reusable verbatim; filters only ever narrow. A filtered question is answered by the workspace's 'knowledge' agent (the default agent takes no filters), so its tone can differ from an unfiltered one")
43568
43717
  }
43569
43718
  },
43570
43719
  async ({ question, session, filters }) => {
@@ -43607,7 +43756,7 @@ server.registerTool(
43607
43756
  "awesomate_knowledge_agents",
43608
43757
  {
43609
43758
  annotations: annotate("awesomate_knowledge_agents", MUTATING),
43610
- description: "The agent builder (multi-agent; the older awesomate_knowledge_agent tool is the single workspace default). action 'list': every agent with status (draft/published vN/suspended). 'get' {agentId}: full config incl. system message and scope. 'create' {goal}: AI drafts the whole setup (instructions, scope, tone, test questions) from the account's own content and saves it as a PRIVATE DRAFT (never live, nothing lost); or {name} for a blank draft. The drafted scope is often too wide: it can come back limited to no collection (so it answers from every business's and every audience's content) or limited to kinds that leave out PDFs (stored as 'book'), FAQs ('faq') or images. Pass collectionIds (and kinds if needed) WITH create to scope it in one step; when the account has collections and the new agent reads none of them, or its kinds skip content the library holds, the result carries a warning listing the collections: fix it with update before testing or publishing. 'update' {agentId, ...fields}: edit the DRAFT config: name, description, systemMessage, noAnswerMessage, grounding, audience, collectionIds (scope to collections from awesomate_knowledge_collections), kinds, sourceIds, maxSources, temperature. Nothing changes for callers until 'publish'. 'test' {agentId, message, sessionId?}: chat with the DRAFT config: free, unmetered, the right way to check wording and scope before going live. WARNING: the draft test ignores the agent's audience, so it is NOT what the public sees for anything touching internal or private sources (seen live: a public-audience draft quoted an internal staff handbook's door code and Wi-Fi password, which the published agent correctly refused a visitor). 'visitor_test' {agentId, message (max 2000 chars), sessionId?}: ask the PUBLISHED agent through the real public visitor path, the only way to check what a website visitor actually gets; run it after publishing whenever the agent is public-facing or its scope includes internal/private sources. It needs the plan the public widget ships with (upgrade_required below it: relay, never retry), and a question it cannot answer lands in the owner's Questions inbox like a real visitor's, so say so before using it. 'publish' {agentId}: makes the draft LIVE immediately for every key bound to the agent: get the user's explicit approval first, and read the version back. 'suspend' / 'resume' {agentId}: stop the agent answering on every key bound to it, or start it again (its last published version); both change what live callers get at once, so get the owner's explicit approval first. 'delete' {agentId, confirm:true}: removes the agent for good and every key bound to it stops answering; tell the owner that and pass confirm:true only after they say yes. 'logs' {agentId?, since?, until?, endpoint?, status?, limit?, cursor?}: the request log (each call to the agents: when, which endpoint, its status), newest first. 'answer_rates' {days? 1-92, default 30}: how each agent is answering, in bands, counts only. 'where_used': each agent with its audience, scope and status, to see which agent a website or automation is using. 'ai_key_status': whether the account has its own AI key saved (names and dates, never a key); saving or removing one is the owner's, in the hub at Settings, Integrations. 'models': the models the account's own AI key can use. Access policies (customer groups): 'policies' lists them; 'policy_get' {policyId}; 'policy_create' {name, description?, collectionIds?|kinds?|sourceIds?|tags?}: a policy named segment:all or segment:<group> is what an agent's customer groups use, so a person in that group answers only from what it allows; 'policy_update' {policyId, name?|description?|scope fields} (a scope REPLACES the stored one); 'policy_delete', 'policy_suspend', 'policy_resume' {policyId}. Every policy change applies to live callers at once: show the owner what changes and get a yes first. API keys are shown once in the hub (Knowledge, Agents) and never pass through this tool.",
43759
+ description: "The agent builder (multi-agent; the older awesomate_knowledge_agent tool is the single workspace default). action 'list': every agent with status (draft/published vN/suspended). 'get' {agentId}: full config incl. system message and scope. 'create' {goal}: AI drafts the whole setup (instructions, scope, tone, test questions) from the account's own content and saves it as a PRIVATE DRAFT (never live, nothing lost); or {name} for a blank draft. The drafted scope is often too wide: it can come back limited to no collection (so it answers from every business's and every audience's content) or limited to kinds that leave out PDFs (stored as 'book'), FAQs ('faq') or images. Pass collectionIds (and kinds if needed) WITH create to scope it in one step; when the account has collections and the new agent reads none of them, or its kinds skip content the library holds, the result carries a warning listing the collections: fix it with update before testing or publishing. 'update' {agentId, ...fields}: edit the DRAFT config: name, description, systemMessage, noAnswerMessage, grounding, audience, collectionIds (scope to collections from awesomate_knowledge_collections), kinds, sourceIds, maxSources, temperature. Nothing changes for callers until 'publish'. 'test' {agentId, message, sessionId?}: chat with the DRAFT config: free, unmetered, the right way to check wording and scope before going live. WARNING: the draft test ignores the agent's audience, so it is NOT what the public sees for anything touching internal or private sources (seen live: a public-audience draft quoted an internal staff handbook's door code and Wi-Fi password, which the published agent correctly refused a visitor). 'visitor_test' {agentId, message (max 2000 chars), sessionId?}: ask the PUBLISHED agent through the real public visitor path, the only way to check what a website visitor actually gets; run it after publishing whenever the agent is public-facing or its scope includes internal/private sources. It needs the plan the public widget ships with (upgrade_required below it: relay, never retry), and a question it cannot answer lands in the owner's Questions inbox like a real visitor's, so say so before using it. 'publish' {agentId}: makes the draft LIVE immediately for every key bound to the agent: get the user's explicit approval first, and read the version back. 'suspend' / 'resume' {agentId}: stop the agent answering on every key bound to it, or start it again (its last published version); both change what live callers get at once, so get the owner's explicit approval first. 'delete' {agentId, confirm:true}: removes the agent for good and every key bound to it stops answering; tell the owner that and pass confirm:true only after they say yes. 'logs' {agentId?, since?, until?, endpoint?, status?, limit?, cursor?}: the request log (each call to the agents: when, which endpoint, its status), newest first. 'answer_rates' {days? 1-92, default 30}: how each agent is answering, in bands, counts only. 'where_used': each agent with its audience, scope and status, to see which agent a website or automation is using. 'ai_key_status': whether the account has its own AI key saved (names and dates, never a key); saving or removing one is the owner's, in the hub at Settings, Integrations. 'models': the models the account's own AI key can use. Access policies (customer groups): 'policies' lists them; 'policy_get' {policyId}; 'policy_create' {name, description?, collectionIds?|kinds?|sourceIds?|tags?}: a policy named segment:all or segment:<group> is what an agent's customer groups use, so a person in that group answers only from what it allows; 'policy_update' {policyId, name?|description?|scope fields} (a scope REPLACES the stored one); 'policy_delete', 'policy_suspend', 'policy_resume' {policyId}. Every policy change applies to live callers at once: show the owner what changes and get a yes first. Website chat (owner only; Support Plus and above, upgrade_required below it: relay it, never retry): 'website_keys' {agentId?}: the working website keys (no key text: a key is shown once, when made). 'website_key' {agentId, origins, title?, label?, monthlyAnswerCap?, confirm:true}: makes a website key for a PUBLISHED, public-audience agent (refused otherwise, with what to do first) and returns it ONCE with the ready <script> snippet to paste before </body>; anyone on those sites can then ask the agent, and answers count against the account's monthly answers. Each key has its own monthly answer limit (by default half the account's at the time it is made), returned as monthly_answer_cap: tell the user the number. A key's limit cannot be changed later; a different limit is a new key, then revoke the old one. Ask the owner which sites, say what it means, and pass confirm:true only after they say yes. 'revoke_website_key' {keyId, confirm:true}: stops the chat on every site using that key at once; ask first. Other keys (for automations and servers) are made in the hub (Knowledge, Agents) and never pass through this tool.",
43611
43760
  inputSchema: {
43612
43761
  action: external_exports.enum([
43613
43762
  "list",
@@ -43631,9 +43780,17 @@ server.registerTool(
43631
43780
  "policy_update",
43632
43781
  "policy_delete",
43633
43782
  "policy_suspend",
43634
- "policy_resume"
43783
+ "policy_resume",
43784
+ "website_key",
43785
+ "website_keys",
43786
+ "revoke_website_key"
43635
43787
  ]),
43636
- confirm: external_exports.boolean().optional().describe("delete: true only after the owner said yes to deleting this agent"),
43788
+ confirm: external_exports.boolean().optional().describe("delete, website_key, revoke_website_key: true only after the owner said yes"),
43789
+ origins: external_exports.array(external_exports.string().max(200)).max(16).optional().describe("website_key: the websites the chat runs on, https://host only (no path, no wildcard), e.g. https://example.com.au and https://www.example.com.au"),
43790
+ title: external_exports.string().max(60).optional().describe("website_key: the chat window's title in the snippet (data-title); defaults to the agent's name"),
43791
+ label: external_exports.string().max(120).optional().describe("website_key: a name for the key in key lists"),
43792
+ monthlyAnswerCap: external_exports.number().int().min(1).optional().describe("website_key: this key's own monthly answer cap; leave it out for half the account's monthly answers"),
43793
+ keyId: external_exports.string().max(128).optional().describe("revoke_website_key: key_id from website_keys"),
43637
43794
  since: external_exports.string().max(40).optional().describe("logs: ISO time to read from"),
43638
43795
  until: external_exports.string().max(40).optional().describe("logs: ISO time to read to"),
43639
43796
  endpoint: external_exports.string().max(200).optional().describe("logs: only calls to this endpoint, e.g. /v1/answer"),
@@ -43647,22 +43804,22 @@ server.registerTool(
43647
43804
  name: external_exports.string().max(120).optional().describe("create: blank draft with this name (max 80); update: rename; policy_create/update: the policy name, e.g. segment:members"),
43648
43805
  goal: external_exports.string().max(2e3).optional().describe("create: describe the agent and AI drafts it from the account content"),
43649
43806
  audience: external_exports.enum(["private", "internal", "public"]).optional().describe("create/update: who the agent serves: 'public' for anything customers or a website will talk to (it then answers from sources marked public ONLY), 'internal' for the team, 'private' (default) for the owner's own tools. Widening later is a publish, so choose now."),
43650
- description: external_exports.string().max(300).optional().describe("update: one-line description shown in the hub; policy_create/update: what the policy is for"),
43651
- systemMessage: external_exports.string().max(8e3).optional().describe("update: the agent instructions (system message)"),
43652
- noAnswerMessage: external_exports.string().max(500).optional().describe("update: what the agent says when the grounding gate declines to answer"),
43653
- grounding: external_exports.enum(["strict", "grounded_chat"]).optional().describe("update: 'strict' refuses anything not in the retrieved content (use it for anything customer-facing); 'grounded_chat' may add general knowledge around the cited content"),
43807
+ description: external_exports.string().max(300).optional().describe("create/update: one-line description shown in the hub; policy_create/update: what the policy is for"),
43808
+ systemMessage: external_exports.string().max(8e3).optional().describe("create/update: the agent instructions (system message); with goal, it replaces the drafted ones"),
43809
+ noAnswerMessage: external_exports.string().max(500).optional().describe("create/update: what the agent says when the grounding gate declines to answer"),
43810
+ grounding: external_exports.enum(["strict", "grounded_chat"]).optional().describe("create/update: 'strict' refuses anything not in the retrieved content (use it for anything customer-facing); 'grounded_chat' may add general knowledge around the cited content"),
43654
43811
  collectionIds: external_exports.array(external_exports.string().max(64)).max(16).optional().describe("create/update: scope the agent to these collections (collection_id from awesomate_knowledge_collections list); replaces the collection scope only, other scope fields and free-form tags are kept. policy_create/update: the collections the policy allows"),
43655
43812
  kinds: external_exports.array(external_exports.string().max(40)).max(16).optional().describe("create/update: restrict retrieval to source kinds (web, document, video, audio, image, book, faq, dataset; PDFs are stored as 'book'); omit for all kinds. policy_create/update: the kinds the policy allows"),
43656
43813
  sourceIds: external_exports.array(external_exports.string().max(200)).max(500).optional().describe("create/update, policy_create/update: pin the scope to specific sources"),
43657
- maxSources: external_exports.number().int().min(1).max(10).optional().describe("update: how many sources one answer may cite (1-10)"),
43658
- temperature: external_exports.number().min(0).max(1).optional().describe("update: 0 is the most literal"),
43814
+ maxSources: external_exports.number().int().min(1).max(10).optional().describe("create/update: how many sources one answer may cite (1-10)"),
43815
+ temperature: external_exports.number().min(0).max(1).optional().describe("create/update: 0 is the most literal"),
43659
43816
  message: external_exports.string().max(4e3).optional().describe("test: the question to ask the draft; visitor_test: the question a visitor asks the published agent (max 2000 characters)"),
43660
43817
  sessionId: external_exports.string().max(128).optional().describe("test/visitor_test: keep follow-ups in one thread")
43661
43818
  }
43662
43819
  },
43663
43820
  async (args) => {
43664
43821
  try {
43665
- return knowledgeResult(await knowledgeAgents(requireConfig(), args));
43822
+ return knowledgeResult(await knowledgeAgents(requireConfig(), args), { keepWebsiteKeys: args.action === "website_key" });
43666
43823
  } catch (err) {
43667
43824
  return knowledgeError(err);
43668
43825
  }
@@ -43805,7 +43962,7 @@ server.registerTool(
43805
43962
  "awesomate_knowledge_data",
43806
43963
  {
43807
43964
  annotations: annotate("awesomate_knowledge_data", MUTATING),
43808
- description: "The knowledge platform's business-data warehouse (every plan, within the plan's row and dataset limits). action 'metrics': headline numbers for the data tab. 'datasets': the datasets imported and their columns: read this FIRST, measures/dimensions must name real columns. 'imports': import job statuses. 'query' {dataset, measures:[{column, agg}], plus optional dimensions/filters passed through}: answer QUANTITATIVE questions from the user's own imported business data (revenue by month, top customers); read-only, results come back as rows to present honestly. 'dataset' {datasetId}: one dataset with its columns and how each is read. 'dataset_update' {datasetId, name?, description?, columns?:[{column_id, name?, semantic_type?, role?, unit?, currency?, canonical_metric_key?, pii?}]}: rename it or settle how its columns are read (show the owner the change first). 'import' {importId}: one import, with any questions it is waiting on. 'import_action' {importId, importAction: approve|reject|withdraw|reimport|answer, answers?}: approve lets a waiting import in, reject turns one away, withdraw takes an import that is already in back out again (confirm:true, after the owner says yes), reimport runs it again, answer {answers: {question key: answer}} replies to its questions. Every import_action changes the owner's numbers: say what it does and get a yes first. New data is imported in the hub UI, not here.",
43965
+ description: "The knowledge platform's business-data warehouse (every plan, within the plan's row and dataset limits). action 'metrics': headline numbers for the data tab. 'datasets': the datasets imported (names and ids, no columns): read this FIRST, then 'dataset' {datasetId} for a dataset's columns, because measures/dimensions must name real columns. 'imports': import job statuses. 'query' {dataset, measures:[{column, agg}], plus optional dimensions/filters passed through}: answer QUANTITATIVE questions from the user's own imported business data (revenue by month, top customers); read-only, results come back as rows to present honestly. 'dataset' {datasetId}: one dataset with its columns and how each is read. 'dataset_update' {datasetId, name?, description?, columns?:[{column_id, name?, semantic_type?, role?, unit?, currency?, canonical_metric_key?, pii?}]}: rename it or settle how its columns are read (show the owner the change first). 'import' {importId}: one import, with any questions it is waiting on. 'import_action' {importId, importAction: approve|reject|withdraw|reimport|answer, answers?}: approve lets a waiting import in, reject turns one away, withdraw takes an import that is already in back out again (confirm:true, after the owner says yes), reimport runs it again, answer {answers: {question key: answer}} replies to its questions. Every import_action changes the owner's numbers: say what it does and get a yes first. New data is imported in the hub UI, not here.",
43809
43966
  inputSchema: {
43810
43967
  action: external_exports.enum(["metrics", "datasets", "imports", "query", "dataset", "dataset_update", "import", "import_action"]),
43811
43968
  datasetId: external_exports.string().max(200).optional().describe("dataset, dataset_update: the dataset id from datasets"),
@@ -45298,19 +45455,25 @@ server.registerTool(
45298
45455
  "awesomate_knowledge_upload",
45299
45456
  {
45300
45457
  annotations: annotate("awesomate_knowledge_upload", MUTATING),
45301
- description: "Ingest ONE file from the user's own computer into their Knowledge Base. Pass a LOCAL PATH \u2014 this server runs on their machine and streams the file to the hub itself, so the file contents never pass through the conversation. Handles documents (pdf, md, txt), spreadsheets (csv, tsv, xls, xlsx, xlsm, json; these NEED kind, see below), audio and video (transcribed, Pro and above), and images (OCR, Pro and above). Word/PowerPoint/RTF/EPUB files are NOT parseable yet \u2014 the tool refuses them with the workaround (export to PDF, or save as .md/.txt). Max 100 MB per file; bigger media goes through the hub's Knowledge \u2192 Sources page. SPREADSHEETS: pass kind, or the tool refuses before uploading. kind 'sheet' makes each row a searchable, citable record: price lists, fee tables, timetables, FAQs, policies, product lists, anything a customer asks about. kind 'data-import' builds a reportable dataset for totals and trends: job or invoice history, sales by month, read with awesomate_knowledge_data. If it is not obvious which the user wants, ask them; never guess. The same goes for a spreadsheet in the File Manager (fromFilesPath): pass kind with it. The source's title comes from the filename, slugified (spaces, punctuation and non-ASCII letters become _), even when title is passed; fix it afterwards with awesomate_knowledge_sources {action:'rename'}. INGESTING COSTS MONEY and counts against the monthly allowance, so ALWAYS get explicit approval for the specific file(s) first and say what it will consume. For several files, call once per file and report progress \u2014 do not loop silently. Returns a job with its live status; poll awesomate_knowledge_sources {action:'jobs'} until it succeeds (a duplicate upload replays the earlier job \u2014 a response already reading succeeded needs no polling), then probe the content with awesomate_knowledge_ask before building anything on it. A pack_required response means the allowance is exhausted: nothing was ingested and nothing was purchased.",
45458
+ description: "Ingest ONE file from the user's own computer into their Knowledge Base. Pass a LOCAL PATH: this server runs on their machine and streams the file to the hub itself, so the file contents never pass through the conversation. Handles documents (pdf, md, txt), spreadsheets (csv, tsv, xls, xlsx, xlsm, json; these NEED kind, see below), audio and video (transcribed, Pro and above), and images (OCR, Pro and above). Word/PowerPoint/RTF/EPUB files are NOT parseable yet: the tool refuses them with the workaround (export to PDF, or save as .md/.txt). Max 100 MB per file; bigger media goes through the hub's Knowledge \u2192 Sources page. SPREADSHEETS: pass kind, or the tool refuses before uploading. kind 'sheet' makes each row a searchable, citable record: price lists, fee tables, timetables, FAQs, policies, product lists, anything a customer asks about. kind 'data-import' builds a reportable dataset for totals and trends: job or invoice history, sales by month, read with awesomate_knowledge_data. If it is not obvious which the user wants, ask them; never guess. The same goes for a spreadsheet in the File Manager (fromFilesPath): pass kind with it. The source's title comes from the filename, slugified (spaces, punctuation and non-ASCII letters become _), even when title is passed; fix it afterwards with awesomate_knowledge_sources {action:'rename'}. visibility 'public' publishes the file, so it needs confirm: the account slug, passed only after the user agreed in so many words (the same rule as awesomate_knowledge_sources add). INGESTING COSTS MONEY and counts against the monthly allowance, so ALWAYS get explicit approval for the specific file(s) first and say what it will consume. For several files, call once per file and report progress; do not loop silently. Returns a job with its live status; poll awesomate_knowledge_sources {action:'jobs'} until it succeeds (a duplicate upload replays the earlier job, and a response already reading succeeded needs no polling), then probe the content with awesomate_knowledge_ask before building anything on it. A pack_required response means the allowance is exhausted: nothing was ingested and nothing was purchased.",
45302
45459
  inputSchema: {
45303
45460
  path: external_exports.string().min(1).max(4096).optional().describe("Path to the file on the user's machine (~ is expanded). Omit when using fromFilesPath."),
45304
45461
  fromFilesPath: external_exports.string().max(1024).optional().describe("Instead of a local file: a path in the account's File Manager (their n8n file system), relative to files/, e.g. 'public/reports/weekly.md'. The hub streams it from their storage \u2014 nothing leaves this machine."),
45305
45462
  title: external_exports.string().max(300).optional().describe("Used as the uploaded file's name (local uploads only). The platform slugifies it (spaces, punctuation and non-ASCII letters become _), so for a readable title rename the source after the job succeeds"),
45306
45463
  kind: external_exports.enum(["sheet", "data-import"]).optional().describe("Spreadsheets only (csv, tsv, xls, xlsx, xlsm, json), and required for them: 'sheet' = each row a searchable, citable record (price lists, timetables, fee tables, FAQs); 'data-import' = a reportable dataset for totals and trends (job or invoice history). Ask the user if unclear; never guess"),
45307
- visibility: external_exports.enum(["private", "internal", "public"]).optional().describe("Who may retrieve this source (default private). public = customers and public chatbots; irreversible once fetched \u2014 ask first."),
45464
+ visibility: external_exports.enum(["private", "internal", "public"]).optional().describe("Who may retrieve this source (default private). public = customers and public chatbots; irreversible once fetched, so it also needs confirm."),
45465
+ confirm: external_exports.string().max(64).optional().describe("visibility 'public' only: the account slug, after the user agreed in so many words"),
45308
45466
  tags: external_exports.array(external_exports.string().max(64)).max(64).optional().describe("Free-form tags for the new source"),
45309
45467
  collectionIds: external_exports.array(external_exports.string().max(64)).max(16).optional().describe("Put the new source in these collections (awesomate_knowledge_collections list)")
45310
45468
  }
45311
45469
  },
45312
- async ({ path: rawPath, fromFilesPath, title, kind, visibility, tags, collectionIds }) => {
45470
+ async ({ path: rawPath, fromFilesPath, title, kind, visibility, confirm, tags, collectionIds }) => {
45313
45471
  try {
45472
+ if (visibility === "public" && !confirm) {
45473
+ return errorResult(
45474
+ new Error("Adding a file as public cannot be undone once it has been fetched or crawled. Ask the user in plain words, then call again with confirm: the account slug. Nothing was uploaded.")
45475
+ );
45476
+ }
45314
45477
  if (fromFilesPath) {
45315
45478
  const dot = fromFilesPath.lastIndexOf(".");
45316
45479
  const filesExt = dot > fromFilesPath.lastIndexOf("/") ? fromFilesPath.slice(dot).toLowerCase() : "";
@@ -45325,6 +45488,7 @@ server.registerTool(
45325
45488
  path: fromFilesPath,
45326
45489
  ...kind ? { kind } : {},
45327
45490
  ...visibility ? { visibility } : {},
45491
+ ...visibility === "public" && confirm ? { confirm } : {},
45328
45492
  ...tags?.length ? { tags } : {},
45329
45493
  ...collectionIds?.length ? { collection_ids: collectionIds } : {}
45330
45494
  });
@@ -45379,6 +45543,7 @@ server.registerTool(
45379
45543
  const filename = title?.trim() ? `${title.trim()}${ext}` : basename(abs);
45380
45544
  const uploadQuery = new URLSearchParams();
45381
45545
  if (visibility) uploadQuery.set("visibility", visibility);
45546
+ if (visibility === "public" && confirm) uploadQuery.set("confirm", confirm);
45382
45547
  if (kind) uploadQuery.set("kind", kind);
45383
45548
  for (const t of tags ?? []) uploadQuery.append("tags", t);
45384
45549
  for (const c of collectionIds ?? []) uploadQuery.append("collection_ids", c);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awesomate/hosting-mcp",
3
- "version": "0.92.2",
3
+ "version": "0.93.0",
4
4
  "description": "Awesomate MCP server — lets Claude manage your Awesomate WordPress hosting, plan, limits, n8n automations, and build Node/static apps + databases",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -1,5 +1,16 @@
1
1
  {
2
2
  "versions": [
3
+ {
4
+ "version": "0.93.0",
5
+ "highlights": [
6
+ "Claude can put a published agent on your website: it makes a website key for the sites you name, after your yes, and hands you the code to paste. Each website key has its own monthly answer limit, half your account's by default, and Claude tells you the number",
7
+ "Uploading a file as public now needs the same typed confirmation as adding a public page",
8
+ "A delete that finds nothing says the item may already be gone or the id may be wrong, and a delete that timed out says it may have gone through instead of saying nothing changed",
9
+ "Asking within filters works with the workspace's default agent, and a new agent drafted from a goal keeps the name you gave it",
10
+ "Skill update checks compare version numbers properly (0.10 is newer than 0.9)",
11
+ "When a Knowledge agent fails because the account's own OpenRouter key is refused or out of credit, Claude says so and where to fix it, instead of a generic service problem"
12
+ ]
13
+ },
3
14
  {
4
15
  "version": "0.92.2",
5
16
  "highlights": [
@@ -164,6 +164,7 @@ confirm:true}`, both after the owner's yes.
164
164
  | Agent builder (what `awesomate_knowledge_agents` calls) | `GET\|POST /api/knowledge/agents` · `GET\|PATCH\|DELETE /api/knowledge/agents/:agent_id` · `POST /api/knowledge/agents/:agent_id/{publish,suspend,resume}` · `GET /api/knowledge/agents/{logs,answer-rates,where-used}` · `GET\|POST /api/knowledge/agents/policies` · `GET\|PATCH\|DELETE /api/knowledge/agents/policies/:policy_id` · `POST /api/knowledge/agents/policies/:policy_id/{suspend,resume}` |
165
165
  | **Test a DRAFT agent** (`action:'test'`) | `POST /api/knowledge/agents/:agent_id/chat` `{message, session_id?}`. There is **no** `/test` endpoint — guessing one returns 404. It ignores the agent's audience. |
166
166
  | **Ask the PUBLISHED agent as a visitor** (`action:'visitor_test'`) | `POST /api/knowledge/agents/:agent_id/visitor-chat` `{message (max 2000), session_id?}`. 403 `upgrade_required` below the public-widget plan; an unanswered question lands in the Questions inbox. |
167
+ | **Website chat key** (`action:'website_key'`, `website_keys`, `revoke_website_key`; owner only, Support Plus and above) | `POST /api/knowledge/agents/keys` `{class:'p', agent_id, allowed_origins (https only, no path or wildcard), label?, request_id?, monthly_answer_cap?}` returns the key ONCE with its `monthly_answer_cap`; with no cap the hub sets half the account's monthly answers at that moment, and a key's cap cannot be changed later (a new limit is a new key). The tool refuses an agent that is not published, not public-audience, or paused. List with `GET /api/knowledge/agents/keys` (class `p`), revoke with `DELETE /api/knowledge/agents/keys/:key_id`. Every one only after the owner's yes. |
167
168
  | Entity layer probe (`available` false = not enabled yet, stop) | `GET /api/knowledge/entities` |
168
169
  | People list / detail | `GET /api/knowledge/people?status=named\|unknown\|hidden\|all` · `GET /api/knowledge/people/:id` — returns `{people, next_cursor}` and **no `counts` key**. |
169
170
  | Entity COUNTS + availability | `GET /api/knowledge/entities` → `{available, counts: {...}}` — counts are nested under `counts`. Reading `people.counts?.x ?? 0` prints a confident row of zeros next to data that plainly exists. |