All articles

Agent

Mechanism: Skill Bundles & Sharing

13 min read

A skill you have refined — the phrasing that finally makes the planner check the calendar before answering, the search procedure that never skips a source — is a procedure worth giving away. But a skill is not a file you can just attach. It is a node in a reference graph, written against one person's namespace, about to cross a trust boundary into someone else's. This article is the machinery that makes "Share with a contact" safe on both ends: what travels, how it travels, and how it lands without breaking anything it touches.

This is a mechanism deep-dive under Skills, Tools, and the @-Namespace, which you should read first — it establishes that a skill's prose body is its dependency manifest (the @-mentions are the toolset), that skills and tools share one flat slug namespace, and that renames cascade so no reference ever dangles. Everything here is those same invariants, extended across the gap between two people's devices.


1. Three problems hiding in one button

The feature is a single affordance: long-press a skill in Settings → Agent → Skills and choose Share with a contact. Pick a person; they get a message; if they like what they see, they import it. Simple.

But walk through what could go wrong, and the button decomposes into three distinct problems:

  1. What travels? The parent article's Search Info skill says "THEN use @fetch-file…" — and Fetch File is itself a skill. Ship Search Info's text alone and the recipient receives prose full of references to things they do not have. The bundle has to carry the skill and its dependencies — but not all of them, as we will see.

  2. How does it travel? Skills are personal instructions to a personal agent. They deserve exactly the privacy of a private message — end-to-end encrypted, invisible to the relay — and they should get it without inventing a new wire format that the whole delivery stack would have to learn.

  3. How does it land? The recipient's device is about to accept structured data authored by someone else into the subsystem that steers their agent. That is a trust boundary, and it needs the full treatment: validation before parsing trusts anything, human review before anything is stored, and a merge that cannot clobber or corrupt what the recipient already has.

One Swift type, SkillShare, answers all three. The rest of this article is a tour through it, in wire order: build the bundle, ship it, receive it.


2. The closure: pack skills, not tools

The sender-side question is: given the skill being shared, what else must ride along? The answer is its transitive @-closure — restricted to skills.

SkillShare.make computes it with a depth-first walk. Starting from the chosen skill, it parses the body's @-mentions (the same parseMentions scan the planner uses), resolves each slug against the sender's registry, and for every mention that resolves to a skill, adds it to the bundle and recurses into its body. A visiting set guards against cycles — a skill that transitively mentions itself just stops the walk there — and the result is collected in first-appearance order:

share @search-info
   │
   ▼ walk its mentions
@check-notebook ──▶ tool          → NOT packed (resolves on the recipient)
@fetch-file ──────▶ skill         → packed, recurse:
     @list-devices ──▶ tool       → NOT packed
     @find-file ─────▶ tool       → NOT packed
@grep-memory ─────▶ tool          → NOT packed

bundle = [ search-info,  fetch-file ]
           (the skill)   (its closure)

The asymmetry is the design decision worth staring at: tool mentions are left in the prose but never packed. Tools are code — compiled capabilities like find_file and queryCalendar that ship inside the app (see Mechanism: CallTool — One Tool to Bind Them for how they are invoked). Code is never put on the wire; only prose is. A tool mention like @find-file travels as exactly that — four words of text — and resolves, on arrival, against the recipient's own tool registry. Both sides run the same app, so the same slugs name the same capabilities, and the recipient's copy is the only one their agent would ever be allowed to execute anyway.

This has a pleasant security consequence: a skill bundle is inert. It contains instructions, not capabilities. Importing one cannot grant the sender's agent anything, and cannot smuggle executable behavior onto the recipient's device — the worst a hostile bundle can do is contain unhelpful prose, and (as §5 and §6 show) even that gets read by a human before it is stored and cannot enter the planner's menu on its own.

Each packed skill is flattened to a wire Item carrying exactly four fields — id (the slug), name, shortDescription, detailedBody. That is the authored content and nothing else. The local bookkeeping from the parent article — the enabled and advertised booleans — deliberately does not travel: those are the recipient's decisions to make, and §6 shows the import choosing conservative defaults for them.


3. The wire: a sentinel riding a normal message

Now the transport problem. The delivery pipeline that carries ordinary Pixie messages already does everything a skill bundle could want: sealed-sender encryption (the relay sees an opaque envelope — not the content, and not even metadata about what kind of content), a persistent outbox that holds and retries a copy per recipient device until each device acks, a sibling self-echo so the sender's own other devices see the sent bundle, and dedup so a retried delivery never lands twice.

Rebuilding any of that for a new payload type would be madness. So skill sharing does not add a payload type. Every payload on the wire carries a TypeTag — a label that tells the receiving pipeline what kind of thing it is decrypting — and a skill bundle rides the most ordinary tag there is: .message, the tag of a plain chat message. The bundle is JSON, wrapped in a one-key object whose key is the sentinel pixie_skill_share, and that JSON string is the message body:

{ "pixie_skill_share": {
    "skill":   { "id": "search-info", "name": "Search Info",
                 "shortDescription": "…", "detailedBody": "…" },
    "closure": [ { "id": "fetch-file", "name": "Fetch File", … } ]
} }

This is a house pattern, not a one-off — message edits and deletes ride the same way under a pixie_message_op sentinel, and Homebase control traffic under pixie_homebase_op. The send path is literally the normal one: the share sheet (SkillSharePickerView) lists your contacts — filtered to the reachable ones, meaning contacts whose public key you actually hold, since sealing requires a key to seal to — and tapping a name calls ComposeService.sendMessage(body:toRecipient:) with the encoded body, the same entry point every chat bubble goes through. No new wire payload, no server-side change, and every delivery guarantee inherited for free. The picker's footer says the honest version to the user: "The skill and every skill it references travel end-to-end encrypted as a message. They'll review it before anything is added on their side."

On the receiving side, the conversation view has to recognize the sentinel to avoid rendering a wall of JSON. Message rendering tries a cheap prefilter first — does the body even contain the string pixie_skill_share? — and only then attempts the full JSON decode. A body that decodes (and survives the validation in §4) renders as a card: the skill's name, its one-liner, a "+ 2 linked skills" count, and — on the recipient's side only — a "Tap to review & import" prompt. The sender sees the same card labeled "You shared a skill," and tapping their own copy does nothing; there is nothing to import from yourself.


4. The trust boundary: hard validation, before anything else

Everything up to here ran on the sender's device, over the sender's own data. The moment decode runs on a received body, the rules change: this is foreign input, and the code treats it accordingly. Decoding gates on isWellFormed, a hard validation pass that a bundle must survive before any other code — including the review sheet — ever touches it:

  • Bounded size. At most 12 items per bundle (the shared skill plus its closure), and per item: a non-empty name of at most 80 characters, a short description of at most 400, a detailed body of at most 8,000. A bundle cannot be a memory bomb or an unscrollable wall.
  • Kebab-safe slugs. Every item's id must match ^[a-z0-9][a-z0-9-]{0,63}$. This single regex is load-bearing: every downstream consumer of a slug — the namespace resolver, the mention parser, the rename-cascade regex that will interpolate the slug into a pattern in §6 — now only ever handles lowercase alphanumerics and hyphens. Ids with spaces, uppercase, path separators, or regex metacharacters do not get sanitized somewhere downstream; they never enter the system at all.
  • Internally consistent. No two items in one bundle may claim the same slug.

The failure mode is deliberately quiet. decode returns nil for anything malformed, and the conversation view simply falls through to rendering the body as an ordinary chat message. A hostile or corrupted bundle does not crash, does not half-import, does not even get a card — it degrades to some strange-looking text in a chat bubble, which is exactly what it is.


5. Review before import: consent is reading

A well-formed bundle still imports nothing. Tapping the card opens SkillShareReviewView, and its design rule is stated in the code's own comment: nothing imports until the user has SEEN what the skill does.

The sheet renders every item in the bundle — the shared skill and each closure member — under a header of its handle and name (@search-info — Search Info), with its one-liner and then its full detailed body, monospaced and selectable. Not a summary, not a preview truncated with an ellipsis: the entire prose the recipient's agent would one day follow, laid out for human reading. Recall from the parent article that a skill body is simultaneously instructions to a model and a dependency manifest; reviewing the text is reviewing the behavior, because with skills there is nothing else to review.

The sheet also runs one honest diagnostic before the user decides: unresolvedMentions. It walks every @-mention across the whole bundle and checks each against three populations — the bundle's own items (intra-bundle references resolve to each other), the recipient's local skills, and the recipient's tool registry. Anything that resolves to none of the three is flagged at the top in orange: "References not available here: @some-slug. Those steps will be skipped."

Note what the flag does not do: it does not block the import. A mention that resolves to nothing simply no-ops at plan-expansion time — the parent article's namespace machinery already drops unknown slugs safely — so the right policy is graceful degradation with disclosure, not refusal. This matters for the boring real case: version skew. A friend on a newer app version shares a skill mentioning a tool your version does not ship yet; you can still import it today, read exactly which step will be skipped, and the skill quietly heals when you update.

Below the flags sit two buttons: Cancel, and Import.


6. Landing: dedupe, re-slug, cascade

Import is a merge — SkillShare.merge(into:) — and it is governed by one absolute rule stated up front: local skills are never touched. The recipient's existing skills do not get renamed, edited, overwritten, or disabled, no matter what arrives. All accommodation is made by the incoming side.

Each incoming item is first stamped into a real AgentSkill with conservative local state: enabled: true, advertised: false. Then the merge walks the incoming list, and for each item asks: does a local skill already hold this slug?

No collision — the skill is appended as-is. The common case.

Collision, identical content — if the local skill under that slug has the same name and the same detailed body, the incoming copy is dropped and recorded as skippedIdentical. This is what makes import idempotent: your friend shares the same skill twice, or you re-import after an app reinstall, and the second pass is a clean no-op — the summary sheet says "Nothing new — you already had all of this." It also collapses the built-ins gracefully: a bundle whose closure includes Fetch File lands on a device that already ships Fetch File, and the duplicate simply evaporates.

Collision, different content — the interesting case. Two people independently named skills @meeting-prep, with different procedures inside. The local one keeps its name (rule one); the incoming one re-slugs, and it does so through the very same allocator the parent article introduced for local renames: SkillRegistry.makeSlug kebab-cases the incoming skill's display name and probes for availability — against local skills, against the recipient's tool slugs, and against the rest of the incoming bundle, so a re-slug cannot create a fresh collision with a bundle-mate — appending a numeric suffix until it finds a free address (meeting-prep-2).

And then the crucial step: the rename cascades across the incoming bundle. Suppose the shared skill's body says "use @meeting-prep for the agenda," and @meeting-prep was the closure member that just became @meeting-prep-2. Without a cascade, the imported skill would reference the recipient's unrelated @meeting-prep — a silent semantic hijack, arguably worse than a dangling reference. So the merge runs SkillRegistry.applyRename(from:to:in:) over the incoming items' bodies — the exact whole-slug, case-insensitive, boundary-guarded rewrite that local renames use (renaming @plan cannot mangle @plan-trip) — before finally updating the item's own id:

incoming bundle          local store        after merge
────────────────         ───────────        ─────────────────────────────
@daily-brief             @meeting-prep      @daily-brief
  "…use @meeting-prep…"  (different body)     "…use @meeting-prep-2…"
@meeting-prep       ──collision──▶          @meeting-prep-2

Scoping the cascade to the incoming bundle only is rule one again, from the other direction: the bundle's internal links are repaired within the bundle, and the recipient's existing prose is never rewritten on account of someone else's naming choices.

The whole outcome is returned as an ImportOutcome — what imported, what was skipped as identical, and every from → to rename — and the review sheet flips to a summary rendering exactly that, including a plain-language line per rename: "@meeting-prep was taken — imported as @meeting-prep-2." Only then does the store persist: the existing list plus the surviving incoming skills, appended.

One last, quietly important default. Remember the arrival stamp: enabled: true, advertised: false. In the parent article's terms, imported skills exist — they validate as references, they expand if some skill mentions them — but they do not appear in the planner's menu, and per that article's rule ("selecting at least one advertised skill is what turns planning on"), importing a bundle can never switch the planning machinery on by itself. The planner menu is the user's alignment surface: the set of one-liners that steer what their agent chooses to do. Nothing crosses from someone else's authorship into your agent's decision criteria on its own; the user promotes an imported skill explicitly, under Settings → Agent → Skills, as the summary sheet's footer tells them. Review gates what is stored; promotion gates what gets agency. Two separate consents, because they are two separate risks.


7. What this buys

Compress the mechanism to its invariants and it reads like a checklist for moving structured data across a trust boundary:

  • Nothing dangles. The transitive closure at send time and the rename cascade at import time are two halves of one guarantee: every intra-bundle @-reference resolves after landing, under whatever slugs the merge settled on. The only permitted unresolved references — tools or skills the recipient genuinely lacks — are disclosed in orange before consent, and no-op safely after.
  • Nothing local changes. All collisions resolve against the incoming side. Import is append-only with dedupe; the recipient's namespace, prose, and settings are exactly as they were, plus some new entries.
  • Nothing runs without eyes on it. Hard validation before parsing is trusted, full-body human review before storage, and unadvertised arrival before agency. A bundle is inert prose until a person has read it and separately chosen to put it on the menu.

And the transport got all of its guarantees — encryption, retry, multi-device delivery, dedup — by the cheapest possible move: refusing to be special. A skill bundle is just a message that both ends happen to recognize.

The parent article closed on the idea that this layer's real design work is deciding how capability is named, offered, and withheld. Sharing is that idea stretched between two people: your friend's hard-won procedure arrives named (re-slugged into your namespace without stepping on it), offered (a card, a review sheet, an Import button), and withheld (off the menu) — until you, reading it, decide otherwise.


References

  • Transitive closure — the graph notion behind "the skill plus everything reachable from it."
  • Idempotence — why re-importing the same bundle must be a no-op.
  • Signal, "Sealed sender" — the origin of the delivery property skill bundles inherit from the messaging pipeline.
  • Robustness principle — and its modern inversion; isWellFormed is deliberately not liberal in what it accepts.

Next: Mechanism: Inference over the Relay.