Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5f0ce4a
feat(client): Agent Skills — the FDv2 delivery transport
XieX Sep 4, 2026
a1f6e6a
fix(client): Agent Skills — read the skill's version off the wire key
XieX Sep 11, 2026
fffd40d
feat(client): Agent Skills — name the payload a transfer completed
XieX Sep 11, 2026
9abdf98
fix(client): Agent Skills — keep FDv2 delivery alive through the fail…
XieX Sep 11, 2026
8a2be9e
feat(client): Agent Skills — removeListener, and a no-store message t…
XieX Sep 11, 2026
d0ccb0f
docs(client): Agent Skills — describe the FDv2 wire contract as it ships
XieX Sep 11, 2026
66a6369
fix(client): four delivery defects from the Bugbot review
XieX Sep 15, 2026
a333b1d
docs(client): Agent Skills — sweep delivery-transport comments for a …
XieX Sep 15, 2026
e8a8816
docs(client): Agent Skills — finish the public-SDK comment sweep
XieX Sep 15, 2026
d7cddcb
fix(client): Agent Skills — do not expire an unchanging environment
XieX Sep 15, 2026
850d895
fix(client): Agent Skills — resolve waitForSkills false when delivery…
XieX Sep 15, 2026
399c92d
fix(client): Agent Skills — clear the SSE data buffer on every block
XieX Sep 15, 2026
27be083
fix(client): Agent Skills — bound the retry loop a goodbye cannot escape
XieX Sep 15, 2026
1ed139b
fix(client): Agent Skills — report revocations and hashless payloads …
XieX Sep 15, 2026
224d267
fix(client): Agent Skills — tighten the store's listener and wait lif…
XieX Sep 15, 2026
8a6387c
fix(client): Agent Skills — stream from stream.launchdarkly.com by de…
XieX Sep 15, 2026
07d51ff
docs(client): Agent Skills — bring the transport contract notes up to…
XieX Sep 15, 2026
91f9a0b
Merge branch 'xie/skills-08-review-closeout' into xie/skills-fdv2-tra…
XieX Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 47 additions & 3 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ if (!result.enabled) {

Agent Skills are versioned `SKILL.md` documents managed in LaunchDarkly, attachable to AI Config variations by reference. This package surfaces which skills a config references, retrieves their content, and materializes them onto disk where agent runtimes (the Claude Agent SDK and friends) can discover them.

**Content delivery is not wired up in this release.** Everything below runs against the `SkillStore` seam; the shipped default is *absent*, so the accessors throw an actionable error until a store is configured. `InMemorySkillStore` is provided for local development, tests, and bring-your-own-content. The real transport ships in a follow-up.
**Everything below runs against the `SkillStore` seam**, and the shipped default is *absent*, so the accessors throw an actionable error until a store is configured. `InMemorySkillStore` is provided for local development, tests, and bring-your-own-content; `FDv2SkillStore` is the delivery transport that receives content from LaunchDarkly — see [Receiving skills from LaunchDarkly](#receiving-skills-from-launchdarkly).

```ts
import { createHash } from 'node:crypto';
Expand Down Expand Up @@ -325,7 +325,10 @@ example above, materializes only what the resolved variation actually asked for.
| `getSkills(refs)` | Batch form. Accepts `SkillReference` values and bare key strings (string = latest). Results follow input order; missing or unverifiable entries are omitted. |
| `allSkills()` | Every verified skill the store holds. |
| `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. |
| `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. |
| `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. `addListener` throws for any kind but `'skill'`. |
| `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`. `close()` is final — `start()` throws afterwards — and `addListener` throws for any kind but `'skill'`. Options: `mode` (`'stream'` default, or `'poll'`), `baseUri`, `streamUri`, `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). |
| `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. |
| `StoreDiagnostics` | What the transport has seen: `payloadsTransferred`, `skillObjectsReceived`, `objectsIgnored`, `objectsRevoked` (each `delete-object`, plus each key a full transfer dropped altogether — a version bump is a move, not a revocation), `payloadsIgnored`, `hashlessObjects`, `connectionFailures`, `lastError`. |
| `createSkill(init)` / `createSkillReference(init)` | Build frozen `Skill` / `SkillReference` values. Use `createSkill` to hand `writeSkills` content you already have. |
| `createSkillOutcome(init)` | Build a frozen `SkillOutcome`. Exported for tests and for wrapping your own retrieval in the same shape. |
| `SKILL_OBJECT_KIND` | `'skill'` — the delivery object kind. |
Expand All @@ -349,6 +352,47 @@ So on those two platforms, write permission on the managed root **and on its anc

**No LaunchDarkly telemetry is emitted for skills.** Signals go through an internal no-op emitter; `client.track()` is never called and no LD context is involved.

#### Receiving skills from LaunchDarkly

`InMemorySkillStore` is for tests and bring-your-own-content. In production, skill content arrives through `FDv2SkillStore`, which speaks LaunchDarkly's SDK-facing FDv2 delivery channel — the same `GET /sdk/poll` and `GET /sdk/stream` endpoints the base SDK's FDv2 data source uses, authenticated with the environment's server-side SDK key. Polling goes to `sdk.launchdarkly.com` and streaming to `stream.launchdarkly.com` by default; set `baseUri` alone to point both at one host, such as a relay or private instance, or `streamUri` as well when the two differ.

```ts
import { FDv2SkillStore, initClient, watchSkills } from '@launchdarkly/ai-server';

const store = new FDv2SkillStore(process.env.LD_SDK_KEY!).start();
await store.waitForSkills(10_000);
await initClient({ skillStore: store });

// Materialize now, and re-materialize whenever delivery changes.
const { report, watcher } = await watchSkills('*', '.claude/skills');
try {
// ...
} finally {
await watcher.close();
await store.close();
}
```

**`waitForSkills` orders boot against the first payload.** It resolves `true` once a payload has been committed, or once a `304` confirms the payload already held is the current one. It resolves `false` if the wait times out, the store is closed, or delivery stops for good — a fatal error such as an unauthorized key resolves it right away rather than at the timeout, so a boot gated on the return value does not proceed on a dead store. Neither a closed store nor one that has given up waits at all, whether the wait was already pending when it happened or started afterwards. Read `failed` to tell a store that gave up from one that timed out; a store you closed yourself leaves `failed` as `null`, because closing is not a failure.

**`close()` is final.** It stops delivery and does not undo it: `start()` throws on a closed store rather than opening a second connection, since a store that looks live and is not is worse than one that plainly refuses. Retrieval needs no restart — a closed store keeps answering from the content it received — so construct a new store if you need delivery again.

**`addListener` observes skill changes only.** The store notifies changes to `'skill'` objects; flag and segment objects on the same connection are skipped, never dispatched. A listener registered under any other kind therefore throws rather than being accepted and silently never firing. `removeListener` tolerates any kind, so a consumer that detaches on close can do so unconditionally. `watchSkills` is the intended consumer of both.

**Nothing above the store changes.** The accessors, integrity verification, and `writeSkills` are transport-agnostic: they see raw objects through the `SkillStore` seam and cannot tell which store produced them. Everything documented above about verification and reconcile semantics applies unchanged.

**Server-side only.** Skills are for server-side agent runtimes and skill content is customer-confidential. A mobile key (`mob-…`) or a client-side environment ID throws from the constructor.

**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".

**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`.

**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.

> **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.

**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 a summary whenever the store it holds becomes wholly hashless or the withheld objects change — both naming the reason, and neither repeated for a payload re-delivered unchanged. 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.

#### Observability: integrity failures are logged for your SIEM

A skill that fails integrity verification is **withheld** — the accessor returns `null`, `writeSkills` reports an `error` action, and no unverified byte reaches your agent. Every failure additionally writes one line to `console.error` in a fixed, machine-parseable shape, so that withholding is *detectable* and not merely correct:
Expand Down Expand Up @@ -511,7 +555,7 @@ All types are re-exported from this package. Handler packages import them from h
| `SkillReference` | A version-pinned pointer to a skill: `{ key, version }` |
| `SkillOutcome` | What `getSkillResult()` resolves to: `{ skill, reason, detail }`. `skill` is non-null exactly when `reason` is `'ok'` |
| `SkillOutcomeReason` | The closed set of retrieval outcomes: `'absent' \| 'integrity_failure' \| 'ok' \| 'store_unavailable' \| 'wrong_version'` |
| `SkillStore` | The structural seam skill content is retrieved through: `getObject(kind, key, version?)`, `allObjects`, optional `addListener` |
| `SkillStore` | The structural seam skill content is retrieved through: `getObject(kind, key, version?)`, `allObjects`, optional `addListener` / `removeListener` |
| `RawSkillObject` | The wire shape a `SkillStore` serves, before verification. Every field is untrusted. |
| `ReconcileReport` | The result of `writeSkills()`: `{ actions, ok, errors }` |
| `ReconcileAction` | One outcome from a reconcile: `{ key, action, version, path, error }` |
Expand Down
Loading
Loading