From 2eeccf8aa9a651e09c6d77351bb112f213b9f3fb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 18:22:56 +0000 Subject: [PATCH 1/4] docs(service-discoverability): add the agent traversal order and label pitfall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited the skill against the current upstream guide (docs.internetcomputer.org/guides/frontends/service-discoverability/). The five layers, field rules, serving rules, deployment checklist and acceptance tests were already covered — and in two places the skill already corrects the guide (the `-e ic` vs `--network ic` pairing for a raw canister ID, and the claim that certified-assets needs a `/.well-known/*` SPA exemption). The gaps were: - No statement of the order in which an agent actually consumes the layers. Added "How an Agent Traverses This": the five-step walk from a bare URL to a correctly-encoded call, framed so each step shows which published fields have to be good for the next one to be cheap. - New pitfall 10: a manifest of bare `id` values is schema-valid but useless, since the agent then has to fetch `candid:service` for every canister and guess from method names — the exact work the manifest exists to avoid. - Checklist now carries the guide's routing line, scoped to the hosts where it is not automatic (legacy asset canister, non-IC hosts). - Concrete forward-compat examples on the unknown-fields rule, and a link to the Candid interface guide. Evals: two cases added for the new content, both run with baseline — role labels 4/4 with skill vs 2/4 without, traversal order 5/5 vs 2/5. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QvQiPZ2t6HuCDM8ie7MerC --- evaluations/service-discoverability.json | 26 +++++++++++++++++++++--- skills/service-discoverability/SKILL.md | 20 ++++++++++++++++-- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/evaluations/service-discoverability.json b/evaluations/service-discoverability.json index dac0a0d..2a33afa 100644 --- a/evaluations/service-discoverability.json +++ b/evaluations/service-discoverability.json @@ -1,7 +1,6 @@ { "skill": "service-discoverability", "description": "Evaluation cases for the service-discoverability skill. Tests whether agents make a canister app discoverable to an AI agent: generate the /.well-known/ic-architecture manifest at deploy time (presync, not hard-coded IDs), serve it correctly as JSON, expose candid:service and a discoverable getApiDoc, understand the OQL data surface, and publish/read ii-derivation-origin without confusing it with ii-alternative-origins.", - "output_evals": [ { "name": "Generate ic-architecture at deploy time (not hard-coded IDs)", @@ -69,9 +68,29 @@ "Uses `icp canister metadata candid:service --network ic` (a raw canister ID has no project environment, so --network targets mainnet directly rather than -e)", "Uses icp-cli, NOT dfx" ] + }, + { + "name": "Adversarial: manifest entries need role labels, not just IDs", + "prompt": "My /.well-known/ic-architecture manifest lists all four of my canisters with just their \"id\" fields, since id is the only required field. Is that enough for an AI agent handed my app's URL? Just the key point.", + "expected_behaviors": [ + "Says bare IDs are schema-valid but defeat the manifest's purpose: the agent cannot tell which canister is the backend", + "Explains the agent would fall back to fetching candid:service for every canister and guessing from method names", + "Recommends adding a role for every entry, plus a description where the purpose is not obvious from the name", + "Does NOT claim the manifest is fine as-is because id is the only required field" + ] + }, + { + "name": "Discovery traversal order from a bare URL", + "prompt": "An AI agent is handed only https://myapp.com and nothing else. What sequence of requests gets it to a correctly-encoded call against my backend canister? Just the ordered steps, no config.", + "expected_behaviors": [ + "Step 1: GET /.well-known/ic-architecture at that origin to enumerate canister IDs and pick the backend by its role/description", + "Step 2: fetch candid:service metadata on the chosen canister ID for exact method signatures and types", + "Step 3: call the getApiDoc query method for behavior the Candid types cannot express (units, auth, polling, irreversibility)", + "Mentions the derivation origin (/.well-known/ii-derivation-origin, defaulting to the visible origin when absent) for acting as the signed-in user", + "Does NOT require a human to supply a canister ID out of band" + ] } ], - "trigger_evals": { "description": "Queries to test whether the skill activates correctly.", "should_trigger": [ @@ -82,7 +101,8 @@ "How do agents discover which canisters make up my app?", "Expose a getApiDoc method so agents understand my canister's behavior", "How do I publish my app's ii-derivation-origin so agents use the right principal?", - "Serve a service discovery manifest for my IC app" + "Serve a service discovery manifest for my IC app", + "What sequence of requests does an agent make to discover my IC app from just its URL?" ], "should_not_trigger": [ "Sign an AI agent in to oisy.com and act as me", diff --git a/skills/service-discoverability/SKILL.md b/skills/service-discoverability/SKILL.md index 2c75fd5..a9ffb1e 100644 --- a/skills/service-discoverability/SKILL.md +++ b/skills/service-discoverability/SKILL.md @@ -60,7 +60,7 @@ Field rules: - `version` — the manifest schema version. - `id` — **required**, a canister principal. - `name`, `role` — human-readable labels; `description` is optional. These fields are untrusted, so a consumer sanitizes them before use. -- Unknown fields must be ignored, so the format can grow without breaking older readers. +- Unknown fields must be ignored, so the format can grow (e.g. per-canister network hints, or an api-doc pointer) without breaking older readers. ### Generate it at deploy time (the `presync` pattern) @@ -217,10 +217,23 @@ https://hcv4s-uaaaa-aaabq-qaaba-cai.icp.net - Generate it at deploy time with the same `presync` pattern when the origin is a per-network canister URL; for a custom domain it is a one-line static file (e.g. `public/.well-known/ii-derivation-origin`). - **Do not confuse it with `ii-alternative-origins`.** A custom origin is enabled by two coupled files: the app pins `derivationOrigin` in its II configuration, and the derivation origin itself publishes `/.well-known/ii-alternative-origins` listing the origins allowed to derive against it. That list answers "who may point here," not "where does this app point" — there is no reverse lookup from an app URL to its derivation origin, and reading it backwards silently produces the wrong principal. Note that on the default `*.icp0.io` / `ic0.app` canister origins you do **not** set a custom `derivationOrigin` at all (the `internet-identity` skill's Mistake #8 explains why adding it there breaks auth); a custom `derivationOrigin` goes hand in hand with a custom domain — see the `custom-domains` skill. +## How an Agent Traverses This + +The layers are published independently but consumed in one order. Knowing that order is what tells you which fields have to be good: each step exists to let the agent skip work at the next. + +1. **`GET /.well-known/ic-architecture`** — one request against the URL the user gave. The agent now holds every canister ID and, from `role`/`description`, knows which one to talk to. This step has to be self-sufficient: if the labels do not identify the backend, the agent falls back to fetching `candid:service` for every canister and guessing from method names. +2. **`candid:service` on the chosen canister** — exact signatures and types, so calls encode and decode correctly. It also reveals `getApiDoc` and, when present, `schema`/`execute`, which is why those names must appear in the interface rather than in a side channel. +3. **`getApiDoc()`** — one query call returning the behavior the types cannot carry (units, auth, polling, irreversibility). It is the only defense against an agent encoding a correctly-typed call that means the wrong thing. +4. **`schema()` once, then `execute(...)` per question** — for data-rich canisters. Server-side filtering and aggregation keep rows out of the agent's context; without them the agent pulls whole tables and pages through them. +5. **Derivation origin** — read `/.well-known/ii-derivation-origin`, or fall back to the visible origin when it is absent, then derive the user's delegation for that origin. Calls now carry the user's own principal, so your existing access control applies unchanged. + +Steps 1-3 take an agent from a bare URL to a correctly-encoded, correctly-understood call in three round trips. Every layer left unpublished pushes it back onto guessing. + ## Deployment Checklist - [ ] **Composition:** the deploy pipeline emits `/.well-known/ic-architecture` (real JSON, extensionless path) with real per-environment canister IDs. - [ ] **Content type:** a `_headers` rule serves the manifest as `application/json`. +- [ ] **Routing (non-static-site hosts only):** `/.well-known/*` is exempt from the SPA catch-all rewrite. Automatic on the static-site (certified-assets) canister, where a real file wins over the rewrite; needed on the legacy `@dfinity/asset-canister` or any non-IC host. - [ ] **Interface:** `candid:service` metadata is present (not stripped). - [ ] **Behavior:** the backend exposes `getApiDoc` / `get_api_doc` returning markdown. - [ ] **Data (if applicable):** data-rich canisters expose OQL `schema` + `execute`. @@ -266,7 +279,9 @@ Locally, the static-site recipe serves the same paths — e.g. `curl http://fron 9. **Naming the behavior method undiscoverably.** The name must appear in `candid:service`, so use `getApiDoc` / `get_api_doc`. A method reachable only via an out-of-band hint defeats zero-knowledge discovery. -10. **Stripping `candid:service`.** Some minified/size-optimized builds drop wasm metadata. Keep it — it is what makes the interface fetchable. Verify with `icp canister metadata candid:service --network ic`. +10. **Listing canisters without labels that identify them.** `id` is the only required field, so a manifest of bare IDs is valid — and useless: an agent cannot tell the backend from the frontend without fetching `candid:service` for every entry and inferring from method names, which is the exact work the manifest exists to avoid. Give every entry a `role`, and a `description` wherever the purpose is not obvious from the name. + +11. **Stripping `candid:service`.** Some minified/size-optimized builds drop wasm metadata. Keep it — it is what makes the interface fetchable. Verify with `icp canister metadata candid:service --network ic`. ## Additional References @@ -277,4 +292,5 @@ Locally, the static-site recipe serves the same paths — e.g. `curl http://fron - Load `icp-cli` for `icp.yaml` / `canister.yaml`, environments, and the recipe system. - Load `canister-security` for access control on the methods agents call. - Authoritative human guide: [Service discoverability](https://docs.internetcomputer.org/guides/frontends/service-discoverability/). +- Candid interface reference for the typed interface agents read: [Candid interface](https://docs.internetcomputer.org/guides/canister-calls/candid/). - Community example of the Layer 1 generation (a personal repo — illustrative, not a stable dependency): [`raymondk/demo-ic-architecture`](https://github.com/raymondk/demo-ic-architecture/tree/main/frontend/ic-architecture). From c600013ba3a0cd97090cf9f75e8ae1198ba544f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 18:29:23 +0000 Subject: [PATCH 2/4] docs(service-discoverability): cut duplication the traversal section added CI flagged the body crossing the 5,000-token spec recommendation (4,753 -> 5,361). Rather than trim substance, cut the duplication the new section actually introduced: - The traversal restated each layer's rationale, which the layer sections and pitfall 10 already own. Reduced to the ordered requests plus the round-trip cost argument. - Layer 5's ii-alternative-origins bullet re-explained the inverse relation at length; pitfall 8 now owns that, and the bullet keeps only the derivationOrigin coupling and the default-origin caveat. Down to 5,206 tokens. Still over the recommendation, and left there: 11 of the other 28 skills exceed it too (writing-motoko 11k, internet-identity 8.8k), so cutting load-bearing content to reach a soft threshold would be the wrong trade. Re-running the touched cases caught two regressions from the first pass, both fixed here and re-verified: - Compressing traversal step 5 dropped "act as the signed-in user" and the derivation-origin behavior stopped surfacing (5/5 -> 4/5, now 5/5). - Compressing the Layer 5 bullet dropped "silently"; pitfall 8 said "yields the wrong principal" without it, and the silent-failure warning stopped surfacing (4/4 -> 3/4, now 4/4 with the wording restored to the pitfall). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QvQiPZ2t6HuCDM8ie7MerC --- skills/service-discoverability/SKILL.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/skills/service-discoverability/SKILL.md b/skills/service-discoverability/SKILL.md index a9ffb1e..79b38f4 100644 --- a/skills/service-discoverability/SKILL.md +++ b/skills/service-discoverability/SKILL.md @@ -215,19 +215,19 @@ https://hcv4s-uaaaa-aaabq-qaaba-cai.icp.net - If the derivation origin is just the app's own visible origin, you may omit the file; its absence means "derive for the visible / requested origin itself." - Generate it at deploy time with the same `presync` pattern when the origin is a per-network canister URL; for a custom domain it is a one-line static file (e.g. `public/.well-known/ii-derivation-origin`). -- **Do not confuse it with `ii-alternative-origins`.** A custom origin is enabled by two coupled files: the app pins `derivationOrigin` in its II configuration, and the derivation origin itself publishes `/.well-known/ii-alternative-origins` listing the origins allowed to derive against it. That list answers "who may point here," not "where does this app point" — there is no reverse lookup from an app URL to its derivation origin, and reading it backwards silently produces the wrong principal. Note that on the default `*.icp0.io` / `ic0.app` canister origins you do **not** set a custom `derivationOrigin` at all (the `internet-identity` skill's Mistake #8 explains why adding it there breaks auth); a custom `derivationOrigin` goes hand in hand with a custom domain — see the `custom-domains` skill. +- **Do not confuse it with `ii-alternative-origins`.** A custom origin is enabled by two coupled files: the app pins `derivationOrigin` in its II configuration, and the derivation origin itself publishes `/.well-known/ii-alternative-origins` listing the origins allowed to derive against it. That list is the inverse relation — "who may point here," not "where does this app point" (Pitfall 8). On the default `*.icp0.io` / `ic0.app` canister origins, do **not** set a custom `derivationOrigin` at all (the `internet-identity` skill's Mistake #8 explains why it breaks auth); a custom one goes hand in hand with a custom domain — see the `custom-domains` skill. ## How an Agent Traverses This -The layers are published independently but consumed in one order. Knowing that order is what tells you which fields have to be good: each step exists to let the agent skip work at the next. +Published independently, the layers are consumed in one order — each step exists to let the agent skip work at the next: -1. **`GET /.well-known/ic-architecture`** — one request against the URL the user gave. The agent now holds every canister ID and, from `role`/`description`, knows which one to talk to. This step has to be self-sufficient: if the labels do not identify the backend, the agent falls back to fetching `candid:service` for every canister and guessing from method names. -2. **`candid:service` on the chosen canister** — exact signatures and types, so calls encode and decode correctly. It also reveals `getApiDoc` and, when present, `schema`/`execute`, which is why those names must appear in the interface rather than in a side channel. -3. **`getApiDoc()`** — one query call returning the behavior the types cannot carry (units, auth, polling, irreversibility). It is the only defense against an agent encoding a correctly-typed call that means the wrong thing. -4. **`schema()` once, then `execute(...)` per question** — for data-rich canisters. Server-side filtering and aggregation keep rows out of the agent's context; without them the agent pulls whole tables and pages through them. -5. **Derivation origin** — read `/.well-known/ii-derivation-origin`, or fall back to the visible origin when it is absent, then derive the user's delegation for that origin. Calls now carry the user's own principal, so your existing access control applies unchanged. +1. `GET /.well-known/ic-architecture` → every canister ID, and which one to call (from `role`/`description`). +2. `candid:service` on that canister → exact signatures and types, plus the names `getApiDoc` / `schema` / `execute`, which is why those must live in the interface and not a side channel. +3. `getApiDoc()` → the semantics the types cannot carry. +4. `schema()` once, then `execute(...)` per question, filtered and aggregated server-side. +5. `/.well-known/ii-derivation-origin`, or the visible origin when absent → derive the user's delegation and act as the signed-in user, so existing access control applies unchanged. -Steps 1-3 take an agent from a bare URL to a correctly-encoded, correctly-understood call in three round trips. Every layer left unpublished pushes it back onto guessing. +Steps 1-3 take an agent from a bare URL to a correctly-encoded, correctly-understood call in three round trips. Each layer left unpublished replaces one of them with guessing — unlabeled manifest entries alone cost a `candid:service` fetch per canister (Pitfall 10). ## Deployment Checklist @@ -275,7 +275,7 @@ Locally, the static-site recipe serves the same paths — e.g. `curl http://fron 7. **A missing `tmpl..json`, or templates in the wrong directory.** `$ICP_CLI_ENVIRONMENT` selects the template by name; if the file for the current environment is absent — or lives at the repo root while `presync` runs from the canister directory — `envsubst` reads nothing and writes an empty manifest. Keep one template per environment, under the frontend canister directory. -8. **Reading `ii-alternative-origins` to find the derivation origin.** It is the inverse relation (who may derive against this origin), not a pointer to it. There is no reverse lookup; using it backwards yields the wrong principal. Publish and read `ii-derivation-origin` for the forward fact. +8. **Reading `ii-alternative-origins` to find the derivation origin.** It is the inverse relation (who may derive against this origin), not a pointer to it. There is no reverse lookup; using it backwards **silently** yields the wrong principal — a plausible wrong answer, not an error. Publish and read `ii-derivation-origin` for the forward fact. 9. **Naming the behavior method undiscoverably.** The name must appear in `candid:service`, so use `getApiDoc` / `get_api_doc`. A method reachable only via an out-of-band hint defeats zero-knowledge discovery. From 42ac85a8fc063a36d892866edbe5d8d111411852 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 19:55:06 +0000 Subject: [PATCH 3/4] docs(service-discoverability): correct the legacy .well-known claim and soften pitfall 10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review on #369. All four inline findings plus the suppressed one checked out against the repo, so all five are applied. The suppressed finding was the significant one, and it exposed a pre-existing error the new checklist line had propagated: the legacy @dfinity/asset-canister does NOT need a /.well-known/* SPA exemption. Its enable_aliasing serves index.html only when no file matches (static-site references/legacy-asset-canister.md:69), exactly like certified-assets, so a real file wins there too. Its actual requirement is an .ic-assets.json5 un-ignore rule so the hidden directory ships at all (custom-domains SKILL.md:75, static-site pitfall 12) — an upload problem, not a routing one. Corrected in all three places that carried it: the "Serve it correctly" bullet, pitfall 4, and the checklist line (now "Reachability", and no longer prescribing one universal remedy; non-IC hosts are told to check their own routing precedence). Also from the review: - The traversal ran execute(...) before obtaining the delegation, which fails against a data surface that requires a signed principal. Identity now precedes the data step, with a note that step 3 is where the agent learns which methods are gated, so only unauthenticated reads may run ahead of it. - The traversal and pitfall 10 named role/description as the identifying labels, omitting name — which the manifest examples themselves use to identify the backend. Now "whatever identifies it". - Pitfall 10 called a bare-ID manifest "useless". It is not: it still enumerates the canisters without out-of-band discovery. Reworded to the real cost — a candid:service fetch per entry to recover the routing. Evals: cases 8 and 9 encoded the same overstatements and were rewritten to check identifying labels and the extra-fetch cost rather than mandate role; 9 gained an expectation that identity is resolved before any gated call. Case 4 asserted the disproved legacy claim and was corrected. All re-run: 4 -> 4/4, 8 -> 4/4, 9 -> 6/6. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QvQiPZ2t6HuCDM8ie7MerC --- evaluations/service-discoverability.json | 15 ++++++++------- skills/service-discoverability/SKILL.md | 20 +++++++++++--------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/evaluations/service-discoverability.json b/evaluations/service-discoverability.json index 2a33afa..dd367fa 100644 --- a/evaluations/service-discoverability.json +++ b/evaluations/service-discoverability.json @@ -37,7 +37,7 @@ "expected_behaviors": [ "Explains a real uploaded file beats the /* SPA rewrite on certified-assets, so the manifest is served directly (the rewrite only catches paths with no matching file)", "Notes .well-known/ is uploaded automatically by the static-site recipe, with no .ic-assets.json5 or un-ignore rule needed", - "Notes an explicit /.well-known/* SPA exemption is only needed on the legacy @dfinity/asset-canister or a non-IC host", + "Does NOT claim the legacy @dfinity/asset-canister needs a /.well-known/* SPA exemption; its enable_aliasing likewise only serves index.html when no file matches, and its actual requirement is an .ic-assets.json5 un-ignore rule so the hidden directory is uploaded", "Does NOT claim you must add .ic-assets.json5 on the static-site canister" ] }, @@ -70,23 +70,24 @@ ] }, { - "name": "Adversarial: manifest entries need role labels, not just IDs", + "name": "Adversarial: manifest entries need identifying labels, not just IDs", "prompt": "My /.well-known/ic-architecture manifest lists all four of my canisters with just their \"id\" fields, since id is the only required field. Is that enough for an AI agent handed my app's URL? Just the key point.", "expected_behaviors": [ - "Says bare IDs are schema-valid but defeat the manifest's purpose: the agent cannot tell which canister is the backend", - "Explains the agent would fall back to fetching candid:service for every canister and guessing from method names", - "Recommends adding a role for every entry, plus a description where the purpose is not obvious from the name", - "Does NOT claim the manifest is fine as-is because id is the only required field" + "Says a bare-ID manifest is schema-valid and still useful for enumerating the canisters without out-of-band discovery", + "Identifies the real cost: with nothing to tell the canisters apart, the agent pays a candid:service fetch per entry and infers roles from method names", + "Recommends labelling each entry with whatever identifies it (name, role, and description where the name is not self-explanatory)", + "Does NOT insist that `role` specifically is required, since an identifying `name` can distinguish the backend" ] }, { "name": "Discovery traversal order from a bare URL", "prompt": "An AI agent is handed only https://myapp.com and nothing else. What sequence of requests gets it to a correctly-encoded call against my backend canister? Just the ordered steps, no config.", "expected_behaviors": [ - "Step 1: GET /.well-known/ic-architecture at that origin to enumerate canister IDs and pick the backend by its role/description", + "Step 1: GET /.well-known/ic-architecture at that origin to enumerate canister IDs and pick the backend by its identifying labels", "Step 2: fetch candid:service metadata on the chosen canister ID for exact method signatures and types", "Step 3: call the getApiDoc query method for behavior the Candid types cannot express (units, auth, polling, irreversibility)", "Mentions the derivation origin (/.well-known/ii-derivation-origin, defaulting to the visible origin when absent) for acting as the signed-in user", + "Resolves the user's delegation BEFORE any call that getApiDoc says needs a signed principal, rather than leaving identity until after the data calls", "Does NOT require a human to supply a canister ID out of band" ] } diff --git a/skills/service-discoverability/SKILL.md b/skills/service-discoverability/SKILL.md index 79b38f4..25054a4 100644 --- a/skills/service-discoverability/SKILL.md +++ b/skills/service-discoverability/SKILL.md @@ -121,7 +121,7 @@ with the template using `${FRONTEND_ID}` / `${BACKEND_ID}` instead. `icp caniste ### Serve it correctly - **`.well-known/` is uploaded automatically** by the static-site recipe (it traverses `.well-known/` even though it skips other dotfiles). A file at `dist/.well-known/ic-architecture` is served at `/.well-known/ic-architecture` with **no extra config** — no `.ic-assets.json5`, no SPA-exemption rule. -- **A real file beats the SPA fallback.** With the static-site `/* /index.html 200` rewrite, the manifest is a real file, so it is served directly; the rewrite only catches paths with no matching file. (This is the certified-assets behavior. On the *legacy* `@dfinity/asset-canister` — or any non-IC host — you must explicitly exempt `/.well-known/*` from the SPA catch-all, or it returns `index.html`.) +- **A real file beats the SPA fallback.** With the static-site `/* /index.html 200` rewrite, the manifest is a real file, so it is served directly; the rewrite only catches paths with no matching file. (The *legacy* `@dfinity/asset-canister` resolves the same way — its `enable_aliasing` also only serves `index.html` when no file matches — but there the manifest has to be uploaded in the first place: `.ic-assets.json5` needs `{ "match": ".well-known", "ignore": false }`, or the hidden directory never ships. On a non-IC host, check that host's own routing precedence; where rewrites shadow real files, exempt `/.well-known/*`.) - **Set the content type.** Extensionless files do not get `application/json` automatically. Add a `_headers` file (at the root of `dir`, e.g. via `public/_headers`) so the manifest is served as JSON: ```text @@ -221,19 +221,21 @@ https://hcv4s-uaaaa-aaabq-qaaba-cai.icp.net Published independently, the layers are consumed in one order — each step exists to let the agent skip work at the next: -1. `GET /.well-known/ic-architecture` → every canister ID, and which one to call (from `role`/`description`). +1. `GET /.well-known/ic-architecture` → every canister ID, and which one to call, from whichever labels identify it (`name`, `role`, `description`). 2. `candid:service` on that canister → exact signatures and types, plus the names `getApiDoc` / `schema` / `execute`, which is why those must live in the interface and not a side channel. -3. `getApiDoc()` → the semantics the types cannot carry. -4. `schema()` once, then `execute(...)` per question, filtered and aggregated server-side. -5. `/.well-known/ii-derivation-origin`, or the visible origin when absent → derive the user's delegation and act as the signed-in user, so existing access control applies unchanged. +3. `getApiDoc()` → the semantics the types cannot carry, including **which calls need a signed principal**. +4. `/.well-known/ii-derivation-origin`, or the visible origin when absent → derive the user's delegation and act as the signed-in user, so existing access control applies unchanged. +5. `schema()` once, then `execute(...)` per question, filtered and aggregated server-side. -Steps 1-3 take an agent from a bare URL to a correctly-encoded, correctly-understood call in three round trips. Each layer left unpublished replaces one of them with guessing — unlabeled manifest entries alone cost a `candid:service` fetch per canister (Pitfall 10). +The identity step is numbered 5 as a layer but happens **before the first protected call**: step 3 is where the agent learns which methods require a signed principal, so anything gated — a protected `execute`, or any update call — has to wait for the delegation. Only unauthenticated reads can run ahead of it. + +Steps 1-3 take an agent from a bare URL to a correctly-encoded, correctly-understood call in three round trips. Each layer left unpublished replaces one of them with guessing — an unlabeled manifest alone costs a `candid:service` fetch per canister (Pitfall 10). ## Deployment Checklist - [ ] **Composition:** the deploy pipeline emits `/.well-known/ic-architecture` (real JSON, extensionless path) with real per-environment canister IDs. - [ ] **Content type:** a `_headers` rule serves the manifest as `application/json`. -- [ ] **Routing (non-static-site hosts only):** `/.well-known/*` is exempt from the SPA catch-all rewrite. Automatic on the static-site (certified-assets) canister, where a real file wins over the rewrite; needed on the legacy `@dfinity/asset-canister` or any non-IC host. +- [ ] **Reachability (non-static-site hosts only):** `/.well-known/ic-architecture` actually resolves. Automatic on the static-site canister; on the legacy `@dfinity/asset-canister` the directory needs an `.ic-assets.json5` un-ignore rule to be uploaded at all; on a non-IC host, exempt `/.well-known/*` from the SPA catch-all if that host's rewrites shadow real files. - [ ] **Interface:** `candid:service` metadata is present (not stripped). - [ ] **Behavior:** the backend exposes `getApiDoc` / `get_api_doc` returning markdown. - [ ] **Data (if applicable):** data-rich canisters expose OQL `schema` + `execute`. @@ -267,7 +269,7 @@ Locally, the static-site recipe serves the same paths — e.g. `curl http://fron 3. **Assuming the manifest is served, without setting its content type.** The extensionless file is served, but not as `application/json` unless a `_headers` rule says so. Add the `Content-Type: application/json` block above. -4. **Expecting `.ic-assets.json5` to matter.** That is the *legacy* asset canister's config; the static-site (certified-assets) canister ignores it. `.well-known/` is uploaded automatically and needs no un-ignore rule. Only the legacy canister — or a non-IC host — needs an explicit `/.well-known/*` SPA exemption. +4. **Expecting `.ic-assets.json5` to matter.** That is the *legacy* asset canister's config; the static-site (certified-assets) canister ignores it. `.well-known/` is uploaded automatically and needs no un-ignore rule. The legacy canister is the reverse: it needs `{ "match": ".well-known", "ignore": false }` or the directory is never deployed — an upload problem, not a routing one. 5. **Writing the file with a `.json` extension.** The path is exactly `/.well-known/ic-architecture` (and `/.well-known/ii-derivation-origin`) — no extension, matching the IC `.well-known` convention (`ic-domains`, `ii-alternative-origins`). @@ -279,7 +281,7 @@ Locally, the static-site recipe serves the same paths — e.g. `curl http://fron 9. **Naming the behavior method undiscoverably.** The name must appear in `candid:service`, so use `getApiDoc` / `get_api_doc`. A method reachable only via an out-of-band hint defeats zero-knowledge discovery. -10. **Listing canisters without labels that identify them.** `id` is the only required field, so a manifest of bare IDs is valid — and useless: an agent cannot tell the backend from the frontend without fetching `candid:service` for every entry and inferring from method names, which is the exact work the manifest exists to avoid. Give every entry a `role`, and a `description` wherever the purpose is not obvious from the name. +10. **Listing canisters without labels that identify them.** `id` is the only required field, so a manifest of bare IDs is valid, and it still earns its keep: the agent gets the app's canisters without discovering them out of band. What it loses is the routing — with nothing to tell the backend from the frontend, the agent pays a `candid:service` fetch per entry and infers roles from method names. Label every entry with whatever identifies it (`name` is often enough; add `role`, and `description` where the purpose is not obvious from the name). 11. **Stripping `candid:service`.** Some minified/size-optimized builds drop wasm metadata. Keep it — it is what makes the interface fetchable. Verify with `icp canister metadata candid:service --network ic`. From 06e74e4e2e7908de85bda7c87d3419822b9bbbd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:57:21 +0000 Subject: [PATCH 4/4] docs(service-discoverability): de-flake eval 8 and tighten the fallback attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both follow-ups from the review on #369, neither blocking; both verified before applying. Eval case 8's first expectation was positively phrased ("Says a bare-ID manifest is ... still useful for enumerating"), but the prompt ends with "Just the key point", so the answer leads with "No, add labels" and compresses the affirmative nuance out — the reviewer scored 3/4 on two independent runs where the local run had scored 4/4. Reworded to the negative form, which checks the thing that actually matters (the skill must not teach that a bare-ID manifest is useless) and does not require the answer to spend words affirming it. Kept the prompt scoped rather than dropping "Just the key point", since CONTRIBUTING wants prompts tight enough to stay inside the eval timeout. Now 4/4 on two runs. The legacy fallback sentence attributed serving index.html-on-no-match to enable_aliasing. Strictly that flag is the per-path /x -> /x.html aliasing rule; the no-match fallback is the separate built-in. The conclusion (a real file wins, so no SPA exemption is needed) is unaffected, so this just names the behavior without naming the wrong mechanism — which also avoids contradicting static-site's legacy reference, where the same shorthand lives. Evals: 8 -> 4/4 twice. Case 4 covers the edited sentence and was re-run three times: 4/4, 4/4, and one 3/4 on an unrelated expectation (.well-known auto-upload), which is pre-existing variance in that case rather than a regression from this change. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QvQiPZ2t6HuCDM8ie7MerC --- evaluations/service-discoverability.json | 2 +- skills/service-discoverability/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/evaluations/service-discoverability.json b/evaluations/service-discoverability.json index dd367fa..601c212 100644 --- a/evaluations/service-discoverability.json +++ b/evaluations/service-discoverability.json @@ -73,7 +73,7 @@ "name": "Adversarial: manifest entries need identifying labels, not just IDs", "prompt": "My /.well-known/ic-architecture manifest lists all four of my canisters with just their \"id\" fields, since id is the only required field. Is that enough for an AI agent handed my app's URL? Just the key point.", "expected_behaviors": [ - "Says a bare-ID manifest is schema-valid and still useful for enumerating the canisters without out-of-band discovery", + "Does NOT dismiss a bare-ID manifest as invalid or useless — acknowledges it is schema-valid and still enumerates the canisters without out-of-band discovery", "Identifies the real cost: with nothing to tell the canisters apart, the agent pays a candid:service fetch per entry and infers roles from method names", "Recommends labelling each entry with whatever identifies it (name, role, and description where the name is not self-explanatory)", "Does NOT insist that `role` specifically is required, since an identifying `name` can distinguish the backend" diff --git a/skills/service-discoverability/SKILL.md b/skills/service-discoverability/SKILL.md index 25054a4..f763b5d 100644 --- a/skills/service-discoverability/SKILL.md +++ b/skills/service-discoverability/SKILL.md @@ -121,7 +121,7 @@ with the template using `${FRONTEND_ID}` / `${BACKEND_ID}` instead. `icp caniste ### Serve it correctly - **`.well-known/` is uploaded automatically** by the static-site recipe (it traverses `.well-known/` even though it skips other dotfiles). A file at `dist/.well-known/ic-architecture` is served at `/.well-known/ic-architecture` with **no extra config** — no `.ic-assets.json5`, no SPA-exemption rule. -- **A real file beats the SPA fallback.** With the static-site `/* /index.html 200` rewrite, the manifest is a real file, so it is served directly; the rewrite only catches paths with no matching file. (The *legacy* `@dfinity/asset-canister` resolves the same way — its `enable_aliasing` also only serves `index.html` when no file matches — but there the manifest has to be uploaded in the first place: `.ic-assets.json5` needs `{ "match": ".well-known", "ignore": false }`, or the hidden directory never ships. On a non-IC host, check that host's own routing precedence; where rewrites shadow real files, exempt `/.well-known/*`.) +- **A real file beats the SPA fallback.** With the static-site `/* /index.html 200` rewrite, the manifest is a real file, so it is served directly; the rewrite only catches paths with no matching file. (The *legacy* `@dfinity/asset-canister` resolves the same way — its `index.html` fallback likewise fires only when no file matches — but there the manifest has to be uploaded in the first place: `.ic-assets.json5` needs `{ "match": ".well-known", "ignore": false }`, or the hidden directory never ships. On a non-IC host, check that host's own routing precedence; where rewrites shadow real files, exempt `/.well-known/*`.) - **Set the content type.** Extensionless files do not get `application/json` automatically. Add a `_headers` file (at the root of `dir`, e.g. via `public/_headers`) so the manifest is served as JSON: ```text