From aaccd750f9b1cbb54e86b937b771352a48fbba9b Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sat, 15 Aug 2026 21:51:41 +0200 Subject: [PATCH 1/3] docs(creating-cloud-persona): scope is a boot cost, not just a permission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill's prose already said to prefer concrete subpaths, but its example handed authors `slack: { scope: { channels: '/slack/channels/**' } }` — and the example is what gets copied. Every agent in the watchdog fleet carries that line. It is not merely broad. The mount is traversed when the sandbox starts, so its size is paid on every run, and `/slack/channels/**` measured ~5,950 entries (2,008 files, 3,940 directories) in a real workspace. Runs came up degraded ("scoped initial sync failed; continuing without preloaded reads") and then failed outright with exit 124 once cloud began cancelling non-converging mounts at the hard deadline. Narrowing to the one channel the agent posts to took the same bootstrap to a clean 127s. Rewrites the example to hoist the channel id to a constant and scope that single channel, and adds the two things that were nowhere in the doc: - A write needs the *grant*, not the mirror. That agent kept posting fine in the degraded runs where the sync never completed; mirroring 6,000 entries to send one message was pure cost. - `scope` does NOT interpolate inputs, though trigger `paths` DO — an asymmetry the doc demonstrated on the trigger side without ever saying it does not hold for scope. Hence the constant, plus a test asserting scope and input agree. The two remaining broad examples are left broad, because both are agents that answer mentions anywhere and legitimately need the collection — but each now says why, so neither reads as the default. Also notes that `deploy` warns on these via `lintScopes()`. Co-Authored-By: Claude Opus 5 --- skills/creating-cloud-persona/SKILL.md | 44 ++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/skills/creating-cloud-persona/SKILL.md b/skills/creating-cloud-persona/SKILL.md index 3e4125a..5e23503 100644 --- a/skills/creating-cloud-persona/SKILL.md +++ b/skills/creating-cloud-persona/SKILL.md @@ -247,14 +247,46 @@ a broad `/provider/**` is valid but mounts the whole provider, and a mid-path `*` mounts nothing (see §1): ```ts +const SLACK_CHANNEL_ID = 'C0B9Z4CLG1J'; + integrations: { - // Replies in-thread (writeback to /slack/channels/{id}/messages), so scope channels. - slack: { scope: { channels: '/slack/channels/**' } }, + // Posts findings to ONE channel and never reads Slack, so scope that channel + // — not `/slack/channels/**`. See "scope is a boot cost" below. + slack: { scope: { channel: `/slack/channels/${SLACK_CHANNEL_ID}/**` } }, // Read-only Linear context — scope the concrete subpaths the handler reads. linear: { scope: { projects: '/linear/projects/**', issues: '/linear/issues/**' } } } ``` +**Scope is a boot cost, not just a permission.** The mount is *traversed* when +the sandbox starts, so its size is paid on every run. Collections that grow with +workspace *history* rather than with configuration — `/slack/channels/**`, +`/google-mail/messages/**`, `/google-mail/threads/**` — get big enough to matter: +`/slack/channels/**` measured ~5,950 entries (2,008 files, 3,940 directories) in +one real workspace. That mount could not converge inside its budget, so runs +either came up degraded (`scoped initial sync failed; continuing without +preloaded reads`) or, once cloud began cancelling non-converging mounts at the +hard deadline, failed outright with exit 124. Narrowing to the single channel +took the same agent's bootstrap to a clean 127s. + +Two corollaries worth internalizing: + +- **A write does not need the mirror — only the grant.** The posting agent above + kept posting fine in the degraded runs where the sync never finished. If the + handler only writes to a path, scope that one path; mirroring the collection to + send one message is pure cost. +- **`scope` does NOT interpolate inputs, though trigger `paths` DO.** You can + write `paths: ['/slack/channels/${SLACK_CHANNEL}/**']` in `defineAgent`, but the + same `${…}` in `scope` is not substituted. Hoist the id to a module constant + used by both the scope and the input `default` (as above) and assert in a test + that they agree — otherwise overriding the input at deploy time silently points + the agent at a channel it has no write grant for. + +`deploy` runs `lintScopes()` over these globs and warns (non-fatally) on the +history-sized collections above, on provider-root mirrors, and on globs the mount +would reject outright. Warnings are advice, not a gate: if the agent genuinely +reads the whole collection, keep it. + The full mechanics and the labelled-mirror sub-trap are in the production-correctness checklist below (§1). @@ -281,6 +313,9 @@ integrations: { slack: { optional: true, enabledByInput: 'SLACK_CHANNEL', // set SLACK_CHANNEL → Slack connects + // Broad here only because this agent replies wherever it is mentioned. If + // yours targets known channels, scope them individually (§3) — `scope` is a + // boot cost and does not interpolate `SLACK_CHANNEL`. scope: { channels: '/slack/channels/**' } }, telegram: { @@ -449,7 +484,10 @@ Use this shape unless there is a strong reason not to. > **writes** through needs a non-empty `scope` > (`"slack": { "scope": { "paths": "/slack/channels/**" } }`); github/linear > writes are the exception only because their trigger and writeback paths share -> one bare-id form. `github` is still scoped here so the reviewer's **reads** +> one bare-id form. The scope is broad here because this reviewer answers +> mentions anywhere; an agent that posts to known channels should scope them +> individually, since the whole mirror is traversed at boot (§3). +> `github` is still scoped here so the reviewer's **reads** > (the PR records and `/github/LAYOUT.md` it walks beyond its trigger subtree) > are mounted — an unscoped `"github": {}` mirror is dropped. `scope: {}` is > discarded by persona-kit, and scope values must be strings. Full rules are in From 44fbf9296a2a42b75f5ca1b9599fdd92a9ae9b8d Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sat, 15 Aug 2026 22:00:05 +0200 Subject: [PATCH 2/3] docs: correct two overstatements in the scope section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught both. "A write does not need the mirror — only the grant" is too strong, and §1 of this same skill says why: when the mirror is stuck rather than merely un-preloaded, the writeback cannot be acknowledged either — `ts: ''`, and the run is marked FAILED on the teardown flush. What was actually observed is narrower: the agent kept posting through runs that logged `scoped initial sync failed; continuing without preloaded reads`, where the mount was up and only the preload had been skipped. Reworded to say that, and to point out that the narrow scope fixes both cases because it is cheap enough to converge — which is the actual advice. Also drops the present tense on `lintScopes()`, which is not released yet (AgentWorkforce/workforce#311). Promising a warning that no vendored deploy emits would leave authors trusting a gate that isn't there. Co-Authored-By: Claude Opus 5 --- skills/creating-cloud-persona/SKILL.md | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/skills/creating-cloud-persona/SKILL.md b/skills/creating-cloud-persona/SKILL.md index 5e23503..542663a 100644 --- a/skills/creating-cloud-persona/SKILL.md +++ b/skills/creating-cloud-persona/SKILL.md @@ -271,10 +271,17 @@ took the same agent's bootstrap to a clean 127s. Two corollaries worth internalizing: -- **A write does not need the mirror — only the grant.** The posting agent above - kept posting fine in the degraded runs where the sync never finished. If the - handler only writes to a path, scope that one path; mirroring the collection to - send one message is pure cost. +- **Scope the one path you write to — not the collection around it.** The agent + above kept posting through the runs that logged `scoped initial sync failed; + continuing without preloaded reads`: the mount was up, only the *preload* had + been skipped, and the writeback receipt still came back. Mirroring 6,000 + entries to send one message bought nothing. + + Do not read that as "writes don't need the mount". They do. A mirror that is + genuinely *stuck* — as opposed to merely un-preloaded — cannot acknowledge a + writeback either, which returns `ts: ''` and marks the whole run FAILED on the + teardown flush (see §1). The narrow scope is the fix for both: it is cheap + enough to actually converge. - **`scope` does NOT interpolate inputs, though trigger `paths` DO.** You can write `paths: ['/slack/channels/${SLACK_CHANNEL}/**']` in `defineAgent`, but the same `${…}` in `scope` is not substituted. Hoist the id to a module constant @@ -282,10 +289,11 @@ Two corollaries worth internalizing: that they agree — otherwise overriding the input at deploy time silently points the agent at a channel it has no write grant for. -`deploy` runs `lintScopes()` over these globs and warns (non-fatally) on the -history-sized collections above, on provider-root mirrors, and on globs the mount -would reject outright. Warnings are advice, not a gate: if the agent genuinely -reads the whole collection, keep it. +Once persona-kit ships `lintScopes()` (AgentWorkforce/workforce#311), `deploy` +warns non-fatally on the history-sized collections above, on provider-root +mirrors, and on `/`-leading globs the mount would reject outright. Until then +this is on you to check by eye. Warnings will be advice, not a gate: if the agent +genuinely reads the whole collection, keep it. The full mechanics and the labelled-mirror sub-trap are in the production-correctness checklist below (§1). From 6495c8030c04bb4a481b4f1bf70c46b0671f051a Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sat, 15 Aug 2026 22:23:35 +0200 Subject: [PATCH 3/3] docs: teach the picker gate, not a hard-coded channel id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous revision told authors to hoist the channel id into a constant and use it in `scope`. That works, but it is the worse of the two fixes and this skill should not be steering people to it: pinning an id takes the channel choice away from whoever deploys, and overriding the input then points the agent at a channel it has no write grant for. Cloud already supports the right answer. `persona-deploy.ts` (`pickerTargetPath`) rewrites a picker-gated collection scope down to the single record the deploy input resolves to, for reads and writebacks alike — so `/slack/channels/**` becomes `/slack/channels//**` while the operator keeps choosing the channel. Found while fixing the watchdog fleet: all eight personas already carried the `picker` and none carried `enabledByInput`, so the rewrite never fired and every deploy mirrored the whole channel tree. The failure mode is silent and the four requirements are easy to half-satisfy, so they are now listed explicitly, along with the note that missing any one falls back to mirroring everything. The constant remains documented as the fallback for an agent whose channel really is fixed rather than operator-chosen. Co-Authored-By: Claude Opus 5 --- skills/creating-cloud-persona/SKILL.md | 49 ++++++++++++++++++++------ 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/skills/creating-cloud-persona/SKILL.md b/skills/creating-cloud-persona/SKILL.md index 542663a..4b99b5d 100644 --- a/skills/creating-cloud-persona/SKILL.md +++ b/skills/creating-cloud-persona/SKILL.md @@ -247,14 +247,24 @@ a broad `/provider/**` is valid but mounts the whole provider, and a mid-path `*` mounts nothing (see §1): ```ts -const SLACK_CHANNEL_ID = 'C0B9Z4CLG1J'; - integrations: { - // Posts findings to ONE channel and never reads Slack, so scope that channel - // — not `/slack/channels/**`. See "scope is a boot cost" below. - slack: { scope: { channel: `/slack/channels/${SLACK_CHANNEL_ID}/**` } }, + // Picker-narrowed: cloud rewrites this to /slack/channels//** at + // deploy. Needs `enabledByInput` AND a matching `picker` on that input — see + // "scope is a boot cost" below. Leave the scope string as the bare collection. + slack: { + optional: true, + enabledByInput: 'SLACK_CHANNEL', + scope: { channels: '/slack/channels/**' } + }, // Read-only Linear context — scope the concrete subpaths the handler reads. linear: { scope: { projects: '/linear/projects/**', issues: '/linear/issues/**' } } +}, +inputs: { + SLACK_CHANNEL: { + description: 'Channel the agent posts to.', + env: 'SLACK_CHANNEL', + picker: { provider: 'slack', resource: 'channels' } // ← what enables the rewrite + } } ``` @@ -284,14 +294,33 @@ Two corollaries worth internalizing: enough to actually converge. - **`scope` does NOT interpolate inputs, though trigger `paths` DO.** You can write `paths: ['/slack/channels/${SLACK_CHANNEL}/**']` in `defineAgent`, but the - same `${…}` in `scope` is not substituted. Hoist the id to a module constant - used by both the scope and the input `default` (as above) and assert in a test - that they agree — otherwise overriding the input at deploy time silently points - the agent at a channel it has no write grant for. + same `${…}` in `scope` is not substituted — it is matched literally and mounts + nothing. + + **Do not work around this by hard-coding the id in `scope`.** That pins one + channel and takes the choice away from whoever deploys; override the input and + the agent silently writes to a channel it has no grant for. Use the gate above + instead: cloud's `pickerTargetPath` (in `persona-deploy.ts`) rewrites a + picker-gated collection scope to the single record the input resolves to, for + reads and writebacks alike. Requirements, all four: + + 1. `optional: true` (persona-kit requires it alongside the gate), + 2. `enabledByInput: ''` on the integration, + 3. a `picker` on that input whose `provider` matches the integration and whose + `resource` matches the collection segment, + 4. the scope left as the bare collection (`/slack/channels/**`) — the rewrite + matches that exact path and nothing else. + + Miss any one and it silently falls back to mirroring the whole collection. + A hard-coded constant is the fallback only for an agent whose channel genuinely + is fixed and not operator-chosen; if you do that, drive the input `default` from + the same constant and assert in a test that the two agree. Once persona-kit ships `lintScopes()` (AgentWorkforce/workforce#311), `deploy` warns non-fatally on the history-sized collections above, on provider-root -mirrors, and on `/`-leading globs the mount would reject outright. Until then +mirrors, and on `/`-leading globs the mount would reject outright. A correctly +picker-gated collection is not flagged — the lint stays quiet exactly where cloud +narrows for you. Until then this is on you to check by eye. Warnings will be advice, not a gate: if the agent genuinely reads the whole collection, keep it.