@awesomate/hosting-mcp 0.20.7 → 0.21.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
@@ -40196,6 +40196,49 @@ async function knowledgeSources(config3, args) {
40196
40196
  const page = await hubGet(config3, `/api/knowledge/sources${qs ? `?${qs}` : ""}`);
40197
40197
  return describeSourcePage(page);
40198
40198
  }
40199
+ if (args.action === "search") {
40200
+ const q = new URLSearchParams();
40201
+ if (args.q) q.set("q", args.q);
40202
+ if (args.kind) q.set("kind", args.kind);
40203
+ if (args.visibility) q.set("visibility", args.visibility);
40204
+ for (const t of args.tags ?? []) q.append("tag", t);
40205
+ for (const c of args.collectionIds ?? []) q.append("collection", c);
40206
+ if (args.cursor) q.set("cursor", args.cursor);
40207
+ if (args.limit !== void 0) q.set("limit", String(args.limit));
40208
+ const qs = q.toString();
40209
+ const page = await hubGet(config3, `/api/knowledge/library${qs ? `?${qs}` : ""}`);
40210
+ return { ...describeSourcePage(page), note_visibility: "Each source carries visibility (private|internal|public \u2014 who may retrieve it; enforced) and collections. Nothing marked public = a public chatbot answers nothing." };
40211
+ }
40212
+ if (args.action === "tag") {
40213
+ if (!args.sourceId || !Array.isArray(args.tags)) return { error: "invalid_request", note: "tag needs sourceId and tags (the full replacement list of free-form tags; collections are unaffected)" };
40214
+ return await hubPatch(config3, `/api/knowledge/sources/${encodeURIComponent(args.sourceId)}`, { tags: args.tags });
40215
+ }
40216
+ if (args.action === "set_visibility") {
40217
+ const ids = args.sourceIds?.length ? args.sourceIds : args.sourceId ? [args.sourceId] : [];
40218
+ if (!ids.length || !args.visibility) return { error: "invalid_request", note: "set_visibility needs sourceId (or sourceIds) and visibility" };
40219
+ if (args.visibility === "public" && !args.confirm) {
40220
+ return {
40221
+ error: "confirmation_required",
40222
+ note: `Making ${ids.length} source(s) PUBLIC means anyone with a public key or a public chatbot can retrieve them, and once fetched or crawled that cannot be taken back. Explain this to the user, then call again with confirm set to their account slug (awesomate_whoami \u2192 account) only after they have agreed in so many words.`
40223
+ };
40224
+ }
40225
+ return await hubPost(config3, "/api/knowledge/sources/batch", {
40226
+ source_ids: ids,
40227
+ visibility: args.visibility,
40228
+ ...args.confirm ? { confirm: args.confirm } : {}
40229
+ });
40230
+ }
40231
+ if (args.action === "move") {
40232
+ const ids = args.sourceIds?.length ? args.sourceIds : args.sourceId ? [args.sourceId] : [];
40233
+ if (!ids.length || !args.collectionIds?.length && !args.removeCollectionIds?.length) {
40234
+ return { error: "invalid_request", note: "move needs sourceId (or sourceIds) and collectionIds (add to) and/or removeCollectionIds" };
40235
+ }
40236
+ return await hubPost(config3, "/api/knowledge/sources/batch", {
40237
+ source_ids: ids,
40238
+ ...args.collectionIds?.length ? { add_collection_ids: args.collectionIds } : {},
40239
+ ...args.removeCollectionIds?.length ? { remove_collection_ids: args.removeCollectionIds } : {}
40240
+ });
40241
+ }
40199
40242
  if (args.action === "summary") {
40200
40243
  try {
40201
40244
  const s = await hubGet(config3, "/api/knowledge/sources/summary");
@@ -40232,7 +40275,12 @@ async function knowledgeSources(config3, args) {
40232
40275
  note: "add needs url or sitemap. For a local FILE on this machine, use awesomate_knowledge_upload \u2014 it reads the file off disk and streams it, so the bytes never pass through the conversation."
40233
40276
  };
40234
40277
  }
40235
- const body = args.url ? { url: args.url } : { sitemap: args.sitemap, ...args.since ? { since: args.since } : {} };
40278
+ const body = {
40279
+ ...args.url ? { url: args.url } : { sitemap: args.sitemap, ...args.since ? { since: args.since } : {} },
40280
+ ...args.visibility ? { visibility: args.visibility } : {},
40281
+ ...args.tags?.length ? { tags: args.tags } : {},
40282
+ ...args.collectionIds?.length ? { collection_ids: args.collectionIds } : {}
40283
+ };
40236
40284
  return await hubPost(config3, "/api/knowledge/sources", body);
40237
40285
  } catch (err) {
40238
40286
  return mapKnowledgeError(err, config3.apiBase);
@@ -40572,11 +40620,12 @@ async function knowledgeAgents(config3, args) {
40572
40620
  if (args.goal) {
40573
40621
  const draft = await hubPost(config3, "/api/knowledge/agents/draft", { goal: args.goal });
40574
40622
  if (!draft.proposal) return { error: "draft_failed", detail: draft };
40575
- const created = await hubPost(config3, "/api/knowledge/agents", draft.proposal);
40576
- return { ...created, test_questions: draft.test_questions ?? [], status_note: "Created as a private DRAFT: test it, then publish when the user approves." };
40623
+ const body = { ...draft.proposal, ...args.audience ? { audience: args.audience } : {} };
40624
+ const created = await hubPost(config3, "/api/knowledge/agents", body);
40625
+ return { ...created, 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.` };
40577
40626
  }
40578
40627
  if (!args.name) return { error: "name (or goal) is required for create" };
40579
- return await hubPost(config3, "/api/knowledge/agents", { name: args.name });
40628
+ return await hubPost(config3, "/api/knowledge/agents", { name: args.name, ...args.audience ? { audience: args.audience } : {} });
40580
40629
  }
40581
40630
  if (args.action === "publish") {
40582
40631
  if (!args.agentId) return { error: "agentId is required for publish" };
@@ -40592,6 +40641,48 @@ async function knowledgeAgents(config3, args) {
40592
40641
  return mapAgentsError(err, config3.apiBase);
40593
40642
  }
40594
40643
  }
40644
+ async function knowledgeCollections(config3, args) {
40645
+ try {
40646
+ if (args.action === "list") {
40647
+ const r = await hubGet(config3, "/api/knowledge/collections");
40648
+ return { ...r, note: 'A collection is a named set of sources; scope an agent to one with scope.tags ["collection:<collection_id>"]. default_visibility only pre-fills new sources \u2014 each source keeps its own visibility.' };
40649
+ }
40650
+ if (args.action === "create") {
40651
+ if (!args.slug || !args.name) return { error: "invalid_request", note: "create needs slug (lower-case, hyphens) and name" };
40652
+ return await hubPost(config3, "/api/knowledge/collections", {
40653
+ slug: args.slug,
40654
+ name: args.name,
40655
+ ...args.description ? { description: args.description } : {},
40656
+ ...args.defaultVisibility ? { default_visibility: args.defaultVisibility } : {}
40657
+ });
40658
+ }
40659
+ if (!args.collectionId) return { error: "invalid_request", note: `${args.action} needs collectionId (from list)` };
40660
+ if (args.action === "get") {
40661
+ const r = await hubGet(config3, "/api/knowledge/collections");
40662
+ const c = r.collections?.find((x) => x.collection_id === args.collectionId);
40663
+ return c ? { collection: c } : { error: "not_found", note: "No collection with that id in this workspace" };
40664
+ }
40665
+ if (args.action === "update") {
40666
+ const body = {
40667
+ ...args.slug ? { slug: args.slug } : {},
40668
+ ...args.name ? { name: args.name } : {},
40669
+ ...args.description !== void 0 ? { description: args.description } : {},
40670
+ ...args.defaultVisibility ? { default_visibility: args.defaultVisibility } : {}
40671
+ };
40672
+ if (!Object.keys(body).length) return { error: "invalid_request", note: "update needs at least one of slug, name, description, defaultVisibility" };
40673
+ return await hubPatch(config3, `/api/knowledge/collections/${encodeURIComponent(args.collectionId)}`, body);
40674
+ }
40675
+ if (args.action === "delete") {
40676
+ return await hubDelete(config3, `/api/knowledge/collections/${encodeURIComponent(args.collectionId)}`);
40677
+ }
40678
+ if (!args.sourceIds?.length) return { error: "invalid_request", note: `${args.action} needs sourceIds` };
40679
+ return await hubPost(config3, `/api/knowledge/collections/${encodeURIComponent(args.collectionId)}/sources`, {
40680
+ [args.action]: args.sourceIds
40681
+ });
40682
+ } catch (err) {
40683
+ return mapKnowledgeError(err, config3.apiBase);
40684
+ }
40685
+ }
40595
40686
 
40596
40687
  // src/index.ts
40597
40688
  var config2 = null;
@@ -41545,9 +41636,17 @@ server.registerTool(
41545
41636
  server.registerTool(
41546
41637
  "awesomate_knowledge_sources",
41547
41638
  {
41548
- description: "The knowledge base's content sources. action 'list' \u2014 ONE page of sources, most recently ingested first (default 50, max 200 via limit): read `page.has_more`/`next_cursor` and pass cursor to continue \u2014 a page is never the whole library. 'summary' \u2014 exact whole-library counts {total, by_kind, chunks, indexed_chunks, failed_sources, failed_jobs_7d}: use THIS to say what the knowledge base contains, and if failed_jobs_7d > 0 say so \u2014 those ingests are missing from every other count. 'jobs' \u2014 ingest job statuses (optional status filter: queued|running|succeeded|failed). 'add' \u2014 ingest a public page {url} or a whole site {sitemap, since?}; ALWAYS get explicit approval first (ingest costs money and counts against quota), for a local FILE on the user's machine use awesomate_knowledge_upload instead (it streams the file from disk; this tool takes URLs only). A pack_required response means the allowance is exhausted: NOTHING was purchased \u2014 present the pack price (1 credit = $100) and let the user buy from the hub if they want it. 'remove' {sourceId} \u2014 deletes the source AND its indexed content; explicit approval required.",
41639
+ description: "The knowledge base's content sources \u2014 the LIBRARY. action 'search' {q?, kind?, visibility?, tags?, collectionIds?, cursor?, limit?} \u2014 find sources by title/tag substring and metadata filters (instant, free; for CONTENT search use awesomate_knowledge_search); each row carries visibility (private|internal|public \u2014 who may retrieve it, ENFORCED), tags and collections. 'tag' {sourceId, tags} \u2014 REPLACE a source's free-form tags. 'set_visibility' {sourceId|sourceIds, visibility, confirm?} \u2014 change who may retrieve the source(s); 'public' is irreversible once fetched, so it needs the user's explicit agreement and their account slug as confirm. 'move' {sourceId|sourceIds, collectionIds?, removeCollectionIds?} \u2014 add to / remove from collections. 'list' \u2014 ONE page of sources, most recently ingested first (default 50, max 200 via limit): read `page.has_more`/`next_cursor` and pass cursor to continue \u2014 a page is never the whole library. 'summary' \u2014 exact whole-library counts {total, by_kind, chunks, indexed_chunks, failed_sources, failed_jobs_7d}: use THIS to say what the knowledge base contains, and if failed_jobs_7d > 0 say so \u2014 those ingests are missing from every other count. 'jobs' \u2014 ingest job statuses (optional status filter: queued|running|succeeded|failed). 'add' \u2014 ingest a public page {url} or a whole site {sitemap, since?}, optionally with visibility (default private), tags and collectionIds; ALWAYS get explicit approval first (ingest costs money and counts against quota), for a local FILE on the user's machine use awesomate_knowledge_upload instead (it streams the file from disk; this tool takes URLs only). A pack_required response means the allowance is exhausted: NOTHING was purchased \u2014 present the pack price (1 credit = $100) and let the user buy from the hub if they want it. 'remove' {sourceId} \u2014 deletes the source AND its indexed content; explicit approval required.",
41549
41640
  inputSchema: {
41550
- action: external_exports.enum(["list", "summary", "add", "remove", "jobs"]),
41641
+ action: external_exports.enum(["list", "summary", "add", "remove", "jobs", "search", "tag", "set_visibility", "move"]),
41642
+ q: external_exports.string().max(100).optional().describe("search: title/tag substring"),
41643
+ kind: external_exports.string().max(32).optional().describe("search: source kind filter (web, document, video, audio, image, book, dataset)"),
41644
+ visibility: external_exports.enum(["private", "internal", "public"]).optional().describe("add: the audience the new source is cleared for (default private); set_visibility: the level to apply; search: filter"),
41645
+ tags: external_exports.array(external_exports.string().max(64)).max(64).optional().describe("add: free-form tags; tag: the REPLACEMENT list; search: filter (AND)"),
41646
+ collectionIds: external_exports.array(external_exports.string().max(64)).max(16).optional().describe("add: put the source in these collections; move: add to; search: filter"),
41647
+ removeCollectionIds: external_exports.array(external_exports.string().max(64)).max(16).optional().describe("move: remove from these collections"),
41648
+ sourceIds: external_exports.array(external_exports.string().max(300)).max(200).optional().describe("set_visibility/move: several sources at once"),
41649
+ confirm: external_exports.string().max(64).optional().describe("set_visibility to 'public' only: the account slug, after the user agreed in so many words"),
41551
41650
  url: external_exports.string().url().optional().describe("add: one public page / blog post / YouTube link"),
41552
41651
  sitemap: external_exports.string().url().optional().describe("add: sitemap.xml URL \u2014 ingests every listed page"),
41553
41652
  since: external_exports.string().optional().describe("add+sitemap only: skip entries with lastmod older than this ISO date"),
@@ -41557,10 +41656,10 @@ server.registerTool(
41557
41656
  limit: external_exports.number().int().min(1).max(200).optional().describe("list only: page size (default 50, max 200)")
41558
41657
  }
41559
41658
  },
41560
- async ({ action, url, sitemap, since, sourceId, status, cursor, limit }) => {
41659
+ async ({ action, url, sitemap, since, sourceId, status, cursor, limit, q, kind, visibility, tags, collectionIds, removeCollectionIds, sourceIds, confirm }) => {
41561
41660
  try {
41562
41661
  return knowledgeResult(
41563
- await knowledgeSources(requireConfig(), { action, url, sitemap, since, sourceId, status, cursor, limit })
41662
+ await knowledgeSources(requireConfig(), { action, url, sitemap, since, sourceId, status, cursor, limit, q, kind, visibility, tags, collectionIds, removeCollectionIds, sourceIds, confirm })
41564
41663
  );
41565
41664
  } catch (err) {
41566
41665
  return knowledgeError(err);
@@ -41658,6 +41757,7 @@ server.registerTool(
41658
41757
  agentId: external_exports.string().max(64).optional().describe("get/publish/test: agent_id from list"),
41659
41758
  name: external_exports.string().max(80).optional().describe("create: blank draft with this name"),
41660
41759
  goal: external_exports.string().max(2e3).optional().describe("create: describe the agent and AI drafts it from the account content"),
41760
+ audience: external_exports.enum(["private", "internal", "public"]).optional().describe("create: who the agent serves \u2014 '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."),
41661
41761
  message: external_exports.string().max(4e3).optional().describe("test: the question to ask the draft"),
41662
41762
  sessionId: external_exports.string().max(128).optional().describe("test: keep follow-ups in one thread")
41663
41763
  }
@@ -41670,6 +41770,28 @@ server.registerTool(
41670
41770
  }
41671
41771
  }
41672
41772
  );
41773
+ server.registerTool(
41774
+ "awesomate_knowledge_collections",
41775
+ {
41776
+ description: "Collections: named sets of knowledge sources with a default audience \u2014 the unit a chatbot is scoped to (agent scope.tags ['collection:<collection_id>']). action 'list' \u2014 every collection with source counts by visibility. 'create' {slug, name, description?, defaultVisibility?} \u2014 defaultVisibility only pre-fills NEW sources' visibility; each source keeps its own. 'get' {collectionId}. 'update' {collectionId, slug?|name?|description?|defaultVisibility?}. 'delete' {collectionId} \u2014 removes the collection; the sources survive (explicit approval first). 'add'/'remove' {collectionId, sourceIds} \u2014 membership. A source may be in several collections. A PUBLIC chatbot over a collection whose sources are all private answers nothing \u2014 check visibility (awesomate_knowledge_sources search) before building on one.",
41777
+ inputSchema: {
41778
+ action: external_exports.enum(["list", "create", "get", "update", "delete", "add", "remove"]),
41779
+ collectionId: external_exports.string().max(64).optional().describe("get/update/delete/add/remove: collection_id from list"),
41780
+ slug: external_exports.string().max(64).optional().describe("create/update: lower-case letters, digits, hyphens"),
41781
+ name: external_exports.string().max(120).optional().describe("create/update"),
41782
+ description: external_exports.string().max(2e3).optional().describe("create/update"),
41783
+ defaultVisibility: external_exports.enum(["private", "internal", "public"]).optional().describe("create/update: pre-fills new sources only"),
41784
+ sourceIds: external_exports.array(external_exports.string().max(300)).max(200).optional().describe("add/remove")
41785
+ }
41786
+ },
41787
+ async (args) => {
41788
+ try {
41789
+ return knowledgeResult(await knowledgeCollections(requireConfig(), args));
41790
+ } catch (err) {
41791
+ return knowledgeError(err);
41792
+ }
41793
+ }
41794
+ );
41673
41795
  server.registerTool(
41674
41796
  "awesomate_knowledge_people",
41675
41797
  {
@@ -41976,10 +42098,13 @@ server.registerTool(
41976
42098
  description: "Ingest ONE file from the user's own computer into their Knowledge Base (Pro+). 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), business data (csv, tsv, xls, xlsx, json), audio and video (transcribed), and images. 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. 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.",
41977
42099
  inputSchema: {
41978
42100
  path: external_exports.string().min(1).max(4096).describe("Path to the file on the user's machine (~ is expanded)"),
41979
- title: external_exports.string().max(300).optional().describe("Display title; defaults to the filename")
42101
+ title: external_exports.string().max(300).optional().describe("Display title; defaults to the filename"),
42102
+ 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."),
42103
+ tags: external_exports.array(external_exports.string().max(64)).max(64).optional().describe("Free-form tags for the new source"),
42104
+ collectionIds: external_exports.array(external_exports.string().max(64)).max(16).optional().describe("Put the new source in these collections (awesomate_knowledge_collections list)")
41980
42105
  }
41981
42106
  },
41982
- async ({ path: rawPath, title }) => {
42107
+ async ({ path: rawPath, title, visibility, tags, collectionIds }) => {
41983
42108
  try {
41984
42109
  const { statSync, existsSync: existsSync3 } = await import("node:fs");
41985
42110
  const { resolve: resolve2, basename, extname } = await import("node:path");
@@ -42019,7 +42144,12 @@ server.registerTool(
42019
42144
  }
42020
42145
  const contentType = KB_UPLOAD_TYPES[ext] ?? "application/octet-stream";
42021
42146
  const filename = title?.trim() ? `${title.trim()}${ext}` : basename(abs);
42022
- const result = await hubUploadFile(requireConfig(), "/api/knowledge/sources", {
42147
+ const uploadQuery = new URLSearchParams();
42148
+ if (visibility) uploadQuery.set("visibility", visibility);
42149
+ for (const t of tags ?? []) uploadQuery.append("tags", t);
42150
+ for (const c of collectionIds ?? []) uploadQuery.append("collection_ids", c);
42151
+ const uploadQs = uploadQuery.toString();
42152
+ const result = await hubUploadFile(requireConfig(), `/api/knowledge/sources${uploadQs ? `?${uploadQs}` : ""}`, {
42023
42153
  localPath: abs,
42024
42154
  filename,
42025
42155
  contentType
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awesomate/hosting-mcp",
3
- "version": "0.20.7",
3
+ "version": "0.21.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",
@@ -58,11 +58,12 @@ platform's answer beats anything you remember.
58
58
  |---|---|
59
59
  | Tenant state, plan gate, consent, quota, packs (CALL FIRST) | `awesomate_knowledge_status` |
60
60
  | Enable the knowledge base (idempotent; 202 = keep polling status) | `awesomate_knowledge_provision` |
61
- | Sources list / add url·sitemap / remove / ingest jobs | `awesomate_knowledge_sources` |
61
+ | The LIBRARY: search by title/tag/collection/visibility, list, add url·sitemap (with visibility, tags, collections), tag, set visibility (public needs the slug typed), move between collections, remove, ingest jobs | `awesomate_knowledge_sources` |
62
+ | Collections — named sets of sources a chatbot is scoped to: list / create / update / delete / add / remove members | `awesomate_knowledge_collections` |
62
63
  | Instant search with facet counts (find WHAT is in there: moments, pages, datasets; free) | `awesomate_knowledge_search` |
63
64
  | Ask a question → verified answer + numbered sources (accepts the same facet `filters` to ask within a slice) | `awesomate_knowledge_ask` |
64
65
  | Agent persona / fallback message / model tier — get & set (the single workspace default) | `awesomate_knowledge_agent` |
65
- | Agent BUILDER: list / get / create (AI-drafted from their content, saved as a private draft) / test the draft / publish (explicit approval first) | `awesomate_knowledge_agents` |
66
+ | Agent BUILDER: list / get / create (AI-drafted from their content, saved as a private draft; pass `audience:'public'` for anything customers will talk to — it then answers from sources marked public ONLY) / test the draft / publish (explicit approval first) | `awesomate_knowledge_agents` |
66
67
  | People, places, topics: list / name / hide / merge / alias suggestions / re-run resolution | `awesomate_knowledge_people` |
67
68
  | Business-data warehouse: `metrics` / `datasets` / `query` / `imports` — answer quantitative questions from their own imported business data (query is free-form but read-only) | `awesomate_knowledge_data` |
68
69
  | Refresh these skills from the latest package | `awesomate_skill_update` |
@@ -83,10 +83,20 @@ before a customer sees it. Show the citations.
83
83
 
84
84
  ## 5. Draft the agent
85
85
 
86
- `awesomate_knowledge_agents {action:'create', goal}` — describe what the agent
87
- is for and the platform drafts instructions, scope, tone and test questions
88
- from the account's own content. It saves as a **private draft**; nothing is
89
- live and nothing is lost.
86
+ `awesomate_knowledge_agents {action:'create', goal, audience}` — describe what
87
+ the agent is for and the platform drafts instructions, scope, tone and test
88
+ questions from the account's own content. It saves as a **private draft**;
89
+ nothing is live and nothing is lost.
90
+
91
+ **`audience` is the retrieval ceiling, and it is enforced.** `public` for
92
+ anything customers or a website will talk to: the agent then answers from
93
+ sources marked public ONLY, whatever key runs it. `internal` for the team
94
+ (internal + public sources). `private` (the default) for the owner's own
95
+ tools. A source's own visibility is set at upload (`visibility` on
96
+ `awesomate_knowledge_upload` / `sources add`) or in the hub Library; a public
97
+ agent over a library with nothing marked public answers nothing — say so
98
+ before building, and get the owner to mark the customer-facing sources public
99
+ first. Widening an agent later is a publish, so choose now.
90
100
 
91
101
  Then `{action:'test', agentId, message}` — free and unmetered, and the right
92
102
  way to check behaviour. Test the awkward cases: something out of scope, a