You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
docs(client): Agent Skills — describe the FDv2 wire contract as it ships
agents.md described the earlier streamer draft: `objectVersion` beside
`version`, `inline-resource` plus `category`, `basis` plus `mv`. It now
describes what the channel delivers — the skill's version folded into the
wire key, identification by kind alone, no `mv` — and adds the three
transport decisions that arrived with the loop fixes: the payload identity
guard, the single read timeout, and which failures are recoverable.
README's "Receiving skills from LaunchDarkly" gains `readTimeoutMs` with
its per-mode default, `removeListener` on both stores and the seam, the
`StoreDiagnostics` fields including `payloadsIgnored`, and the 403 wording
that matches what the store now logs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: packages/client/README.md
+8-5Lines changed: 8 additions & 5 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -325,9 +325,10 @@ example above, materializes only what the resolved variation actually asked for.
325
325
|`getSkills(refs)`| Batch form. Accepts `SkillReference` values and bare key strings (string = latest). Results follow input order; missing or unverifiable entries are omitted. |
326
326
|`allSkills()`| Every verified skill the store holds. |
327
327
|`writeSkills(skills, root, options?)`| Materialize to `<root>/<key>/SKILL.md`. Accepts `Skill` / `SkillReference` / key strings, or the literal `'*'`. Returns a `ReconcileReport`. Throws for a caller error — an unusable `root`, a bare string other than `'*'` — as distinct from the per-skill `error` actions in the report. |
328
-
|`InMemorySkillStore`| A `SkillStore` backed by a plain object. `put(raw)`, `getObject(kind, key, version?)`, `allObjects(kind)`, `addListener(kind, fn)`. Holds one object per key, so it answers a version pin with what it has and lets the accessor refuse a mismatch. |
329
-
|`FDv2SkillStore(sdkKey, options?)`| The delivery transport: a `SkillStore` fed by LaunchDarkly over the SDK-facing FDv2 channel. `start()`, `waitForSkills(timeoutMs)`, `close()`, `diagnostics`, `failed`. **Server-side only** — a mobile key or client-side environment ID throws. See [Receiving skills from LaunchDarkly](#receiving-skills-from-launchdarkly). |
330
-
|`watchSkills(skills, root, options?)`|`writeSkills` plus a re-reconcile on every delivery change. Resolves to `{ report, watcher }`; `await watcher.close()` when done. Revocation then takes effect within `debounceMs` of arriving rather than at the next restart. |
328
+
|`InMemorySkillStore`| A `SkillStore` backed by a plain object. `put(raw)`, `getObject(kind, key, version?)`, `allObjects(kind)`, `addListener(kind, fn)`, `removeListener(kind, fn)`. Holds one object per key, so it answers a version pin with what it has and lets the accessor refuse a mismatch. |
329
+
|`FDv2SkillStore(sdkKey, options?)`| The delivery transport: a `SkillStore` fed by LaunchDarkly over the SDK-facing FDv2 channel. `start()`, `waitForSkills(timeoutMs)`, `close()`, `diagnostics`, `failed`, `addListener` / `removeListener`. Options: `mode` (`'stream'` default, or `'poll'`), `baseUri`, `pollIntervalMs`, `readTimeoutMs`, `initialBackoffMs`, `maxBackoffMs`, `maxConsecutiveFailures`. **Server-side only** — a mobile key or client-side environment ID throws. See [Receiving skills from LaunchDarkly](#receiving-skills-from-launchdarkly). |
330
+
|`watchSkills(skills, root, options?)`|`writeSkills` plus a re-reconcile on every delivery change. Resolves to `{ report, watcher }`; `await watcher.close()` when done, which also detaches the watcher from the store. Revocation then takes effect within `debounceMs` of arriving rather than at the next restart. |
331
+
|`StoreDiagnostics`| What the transport has seen: `payloadsTransferred`, `skillObjectsReceived`, `objectsIgnored`, `objectsRevoked`, `payloadsIgnored`, `hashlessObjects`, `connectionFailures`, `lastError`. |
331
332
332
333
#### Receiving skills from LaunchDarkly
333
334
@@ -356,9 +357,11 @@ try {
356
357
357
358
**Streaming is the default, and it is what makes revocation fast.** A `delete-object` reaches a live stream in seconds; with `mode: 'poll'` it arrives within one `pollIntervalMs`. Paired with `watchSkills`, a revoked skill's `SKILL.md` leaves the disk without a restart. During an outage the store keeps serving the last content it received and `writeSkills`' default `onUnavailable: 'keep'` leaves managed files alone — an outage must not read as "everything was revoked".
358
359
360
+
**One network timeout, and its default depends on the mode.**`readTimeoutMs` bounds every step of a request, connecting included. In `mode: 'poll'` it bounds the whole request and defaults to 10 seconds; in `mode: 'stream'` it bounds each wait for the next bytes and defaults to 300 seconds, well beyond LaunchDarkly's heartbeat interval. A stream that goes quiet past it reconnects rather than hanging, and a stream that dies mid-body — a reset, a truncated chunk — reconnects the same way. Every delay between retries, including one the server asks for with `Retry-After`, is capped at `maxBackoffMs`.
361
+
359
362
**The connection also carries your flags.** A client cannot request only the skill payload, so a skills-enabled environment delivers flag and segment objects on the same connection. They are skipped, not evaluated — this store does no evaluation of any kind — and `diagnostics.objectsIgnored` counts them.
360
363
361
-
> **Beta caveats, worth knowing before you deploy.** Payload signing does not exist on this channel yet, so delivery is TLS-only and the content hash establishes self-consistency, not origin authenticity. FDv2 is opt-in per account: without it the endpoints return HTTP 403, which the store reports as a fatal error naming the setting. `ld-relay` does not speak the FDv2 endpoints, so relay-only deployments cannot receive skills.
364
+
> **Beta caveats, worth knowing before you deploy.** Payload signing does not exist on this channel yet, so delivery is TLS-only and the content hash establishes self-consistency, not origin authenticity. The FDv2 protocol is opt-in per account: without it the endpoints return HTTP 403, which the store reports as a fatal error explaining what to do. `ld-relay` does not speak the FDv2 endpoints, so relay-only deployments cannot receive skills.
362
365
363
366
**If every skill comes back empty, check `diagnostics.hashlessObjects`.** Verification requires `contentHash` on the delivered object and withholds anything without one, so a nonzero count there means skills are being withheld rather than that the environment has none. The store logs an error per hashless object and one summary per wholly-hashless payload, both naming the reason. There is deliberately no fallback that skips verification: a hash the SDK computed from the content it was handed would certify the content against itself.
364
367
@@ -532,7 +535,7 @@ All types are re-exported from this package. Handler packages import them from h
532
535
|`SkillReference`| A version-pinned pointer to a skill: `{ key, version }`|
533
536
|`SkillOutcome`| What `getSkillResult()` resolves to: `{ skill, reason, detail }`. `skill` is non-null exactly when `reason` is `'ok'`|
534
537
|`SkillOutcomeReason`| The closed set of retrieval outcomes: `'absent' \| 'integrity_failure' \| 'ok' \| 'store_unavailable' \| 'wrong_version'`|
535
-
|`SkillStore`| The structural seam skill content is retrieved through: `getObject(kind, key, version?)`, `allObjects`, optional `addListener`|
538
+
|`SkillStore`| The structural seam skill content is retrieved through: `getObject(kind, key, version?)`, `allObjects`, optional `addListener`/ `removeListener`|
536
539
|`RawSkillObject`| The wire shape a `SkillStore` serves, before verification. Every field is untrusted. |
537
540
|`ReconcileReport`| The result of `writeSkills()`: `{ actions, ok, errors }`|
538
541
|`ReconcileAction`| One outcome from a reconcile: `{ key, action, version, path, error }`|
|`src/skills-core.ts`| Agent Skills internals shared by the two layers above it: the store and telemetry seams, module state, integrity verification, store resolution |
32
32
|`src/skills.ts`|`skillRefs`, the content accessors, `InMemorySkillStore`, and the documented test-injection hooks |
33
-
|`src/skills-fdv2.ts`| Agent Skills delivery transport — the FDv2 protocol, the `objectVersion`/`version` translation, the held object set, and `FDv2SkillStore`. Sits **below** the store seam; imports `skills-core` only, and nothing imports it |
33
+
|`src/skills-fdv2.ts`| Agent Skills delivery transport — the FDv2 protocol, the wire-key/`version` translation, the held object set, and `FDv2SkillStore`. Sits **below** the store seam; imports `skills-core` only, and nothing imports it |
34
34
|`src/skills-watch.ts`| Agent Skills eager re-reconcile — `watchSkills` / `SkillWatcher`, wiring the store's change listener to `writeSkills`. Sits **above**`skills-fs` and modifies none of it |
35
35
|`src/skills-fs.ts`|`writeSkills` — the manifest format, on-disk filenames, and reconcile semantics |
36
36
|`src/safe-fs.ts`| Symlink-refusing filesystem primitives. Knows nothing about skills; owns the single interceptable rename and unlink call sites |
@@ -42,27 +42,34 @@ The Agent Skills modules are a deliberate split, and the dependencies run **one
42
42
43
43
## Agent Skills — the delivery transport, and the one field that will bite you
44
44
45
-
`FDv2SkillStore` speaks LaunchDarkly's SDK-facing FDv2 channel (`GET /sdk/poll`, `GET /sdk/stream`, server-side SDK key in `Authorization`, `basis`+ `mv` params, `If-None-Match`/304). It lives below the seam and produces raw objects in the shape `SkillStore` documents; **nothing above the seam knows it exists**. The transport design was replaced wholesale late in this feature's life and cost zero changes above this line, which is the strongest evidence the seam is drawn correctly. If a transport change ever seems to require editing an accessor, verification, or `writeSkills`, the adapter boundary is wrong.
45
+
`FDv2SkillStore` speaks LaunchDarkly's SDK-facing FDv2 channel (`GET /sdk/poll`, `GET /sdk/stream`, server-side SDK key in `Authorization`, a `basis`param once a payload has committed, `If-None-Match`/304). It lives below the seam and produces raw objects in the shape `SkillStore` documents; **nothing above the seam knows it exists**. The transport design was replaced wholesale late in this feature's life and cost zero changes above this line, which is the strongest evidence the seam is drawn correctly. If a transport change ever seems to require editing an accessor, verification, or `writeSkills`, the adapter boundary is wrong.
46
46
47
-
**`objectVersion`is the skill's version. `version` is the payload's.**On the wire a skill `put-object` carries both, and they are not interchangeable:
47
+
**The skill's version is in the object's `key`. `version` is the payload's.**Each version of a skill is its own object on the wire, identified as `<key>:<version>`:
`objectVersion` (3) is what a `{key, version}` reference pins and what becomes the seam's `version`. `version` (42) is the version of the *payload* the object arrived in — it moves when anything in the environment moves, including a flag with nothing to do with skills. Reading it as the skill's version fails **silently**: the object verifies, the hash matches, and the caller gets content under a version number that means nothing. Flags and segments carry only `version` and omit both `category` and `objectVersion`, which is exactly why the two fields look interchangeable. `seamObjectFromPut` is the only place the translation happens, and the `version translation` suite asserts it in both directions.
54
+
The `3` after the delimiter is what a `{key, version}` reference pins and what becomes the seam's `version`, under the seam key `pdf-extraction`. `version` (42) is the version of the *payload* the object arrived in — it moves when anything in the environment moves, including a flag with nothing to do with skills. Reading it as the skill's version fails **silently**: the object verifies, the hash matches, and the caller gets content under a version number that means nothing. There is no separate field for the skill's version: the agent-skill payload is a *generic* payload, and generic objects carry only `key`, `kind`, `version` and `object`, exactly like a flag. `splitWireKey` is the only place the wire key is read, `seamObjectFromPut` and `tombstoneFromDelete` both go through it, and the `version translation` suite asserts the translation in both directions. A wire key that will not split cleanly is *held*, not dropped — version-less, or with the offending text as its version — so verification withholds it with `invalid_version` under a key the caller recognises; only a key with nothing before the delimiter is dropped, since there is no identity to hold it under.
56
55
57
-
**Skills are identified by `kind === 'inline-resource' && category === 'skill'`; everything else is ignored, not rejected.** An environment's payload assignment carries the flagging payload alongside the agent-skill payload, so flag and segment objects arrive as a matter of course. Throwing on an unrecognised kind is the unknown-kind reconnect loop this feature must not reproduce — a flag-delivery outage caused by a skills rollout.
56
+
**Skills are identified by `kind === 'skill'`; everything else is ignored, not rejected.** Object kinds on the SDK-facing channel are open strings, and the agent-skill payload is classified `generic`, so a skill arrives under the kind its producer registered — the bare category name — not under a broader wrapper kind with a narrowing field. An environment's payload assignment carries its flag payload alongside its agent-skill payload, so flag and segment objects arrive as a matter of course. Throwing on an unrecognised kind is the unknown-kind reconnect loop this feature must not reproduce — a flag-delivery outage caused by a skills rollout.
57
+
58
+
**No `mv` parameter, deliberately.** It selects the *flag* data model, the connection rejects any value but the flag default, and the generic agent-skill payload is served regardless of it. Sending `mv=1` — the skill payload's own model version — gets the whole connection refused.
58
59
59
60
**Changes commit at `payload-transferred`, not as objects arrive.** A payload version is the unit of consistency: a half-applied full transfer would publish a state the server never described, and would briefly empty the store — which, with pruning on, is the difference between a reconcile and deleting a customer's skill files. An interrupted transfer therefore leaves last known good intact, and listeners fire once per commit.
60
61
62
+
**The first payload intent is read, and is assumed to be the skill payload.** Delivery provides one payload per credential and the protocol requires a client to ignore all but the first payload intent, so `payloads[0]` is both what arrives and what the protocol says to read. The cost of that assumption is that an `xfer-full` for somebody *else's* payload would start an empty pending set, and the next `payload-transferred` would publish it — every skill reported revoked, and with pruning on, a customer's files deleted. `ProtocolReader` therefore learns which payload skills arrive on, from the intent's `id` or from the `(p:<id>:<version>)` selector, and declines to apply a transfer of any other: once at warning level, counted in `diagnostics.payloadsIgnored`, holding last known good. A transfer that names no payload is applied, since one-payload delivery is the common case. The residual is the first transfer of a connection — before a skill has arrived there is nothing to compare against — which is what the separate warning on a multi-payload intent is for.
63
+
61
64
**A hashless object is held, not dropped.** Verification withholds it with `missing_content_hash`; the transport's job is to make that loud (an error per object, a summary per wholly-hashless payload, `diagnostics.hashlessObjects`) rather than to work around it. Dropping it at the transport would report `absent` — indistinguishable from "no such skill" — and would let a prune delete the last known-good copy on disk. Never synthesize a hash from the delivered content: that certifies the content against itself and verifies nothing.
62
65
63
66
**`SkillObjectSet.snapshot` collapses to one object per key, and that is load-bearing here in a way it is not in Python.**`<root>/<key>/SKILL.md` is a single path, so a whole-store consumer must see one object per key or a `'*'` reconcile writes the same path twice and `allSkills` returns two versions of one skill. The Python SDK collapses in `newest_by_key`, above the seam; that helper is not in this SDK yet, so the collapse happens in the adapter. End-to-end behaviour is identical, and `getObject` still resolves a pinned version out of the full set. When `newestByKey` lands in `skills-core.ts`, move it and delete the note on the class.
64
67
65
-
**`close` aborts the signal, it does not just set a flag.** The delivery task spends its life awaiting a stream read, and a flag it never checks would leave a healthy stream running until the process exited. Every backoff timer and the `waitForSkills` timer are `unref`ed for the same reason: a background store must not be why `node` stays up.
68
+
**There is one network timeout, not two.**`readTimeoutMs` is applied through a `ReadDeadline` composed with the store's own abort signal, so connect, headers and each body read share it. Its default is per mode: `DEFAULT_POLL_TIMEOUT_MS` bounds a whole poll request, `DEFAULT_STREAM_READ_TIMEOUT_MS` bounds the gap between reads on a stream, and tripping it on a stream that has gone quiet *reconnects* — the `timeouts` suite measures the bound against a socket that accepts and never answers. Do not add a separate connect timeout; the Python SDK cannot honour one and the two transports are kept option-for-option compatible.
69
+
70
+
**A body read that fails is recoverable; a protocol-reader or dispatch error is not.**`iterSse` wraps the read itself — a reset, a truncated chunk, the read deadline — as `RecoverableTransportError`, because a live stream dies mid-body far more often than it refuses to open, and the delivery loop reads anything else as a bug and stops for the process lifetime. Whatever the consumer's loop body throws while the generator is suspended at a `yield` passes through untouched, so a bug still surfaces as one. The consecutive-failure counter resets at each *committed payload* in `apply`, not when a connection returns: a stream only ever ends by being dropped, so resetting on return would count every healthy, server-recycled connection as a failure and give up on a healthy server. Every retry delay, `Retry-After` included, is clamped to `maxBackoffMs`.
71
+
72
+
**`close` aborts the signal, it does not just set a flag.** The delivery task spends its life awaiting a stream read, and a flag it never checks would leave a healthy stream running until the process exited. The store's signal is the parent of every `ReadDeadline`, so one abort reaches a pending connect and a pending read alike, and a store closing is passed through rather than reported as a failure. Every backoff timer, deadline timer and the `waitForSkills` timer are `unref`ed for the same reason: a background store must not be why `node` stays up.
0 commit comments