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 (one skill in a web of skills that mention each other by name), written against one person's namespace, their private catalog of those names, and 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 three facts this article
leans on. A skill's prose body is its dependency manifest: the @-mentions
written into the text are the list of tools and skills it needs. Skills and tools share
one flat namespace of slugs: short lowercase handles like fetch-file, all drawn
from a single shared pool of names. And renames cascade: when a skill's slug changes,
every mention of it is rewritten too, so no reference is ever left pointing at nothing.
Everything here is those same
invariants, extended across the gap between two people's devices.
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:
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.
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, meaning only the sender's and recipient's devices can read it, invisible even to the relay, the server that forwards messages between phones. And they should get that privacy without inventing a new wire format that the whole delivery stack would have to learn.
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 (the line where incoming data stops being your own and must be treated as untrusted), 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.
The sender-side question is: given the skill being shared, what else must ride
along? The answer is its transitive @-closure, restricted to skills: everything
the skill @-mentions, plus everything those mention, and so on down.
SkillShare.make computes it with a depth-first walk: it follows each chain of
mentions all the way down before backing up to try the next branch. Starting from the
chosen skill, it parses the body's @-mentions (the same parseMentions scan the
planner uses) and resolves each slug against the sender's registry. Every mention that
resolves to a skill is added to the bundle, and the walk recurses into its body. A
visiting set guards against cycles: a skill that, through some chain of mentions,
ends up mentioning itself just stops the walk at that point. The result is collected in
first-appearance order.
For the built-in @search-info, the walk packs search-info and fetch-file (its
one skill mention) and leaves @check-notebook, @list-devices, @find-file, and
@grep-memory unpacked, because they resolve to tools.
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 true/false switches the recipient gets to set
for themselves, and §6 shows the import choosing conservative defaults for them.
Now the transport problem. The delivery pipeline that carries ordinary Pixie messages already does everything a skill bundle could want. It has sealed-sender encryption: the relay sees an opaque envelope. Not the content, and not even metadata about what kind of content is inside. It has a persistent outbox that holds a copy per recipient device and retries until each device acks (confirms) receipt. It has a sibling self-echo, so the sender's own other devices see the sent bundle too. And it has 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 (the common plain-text data format) wrapped in an object with a
single key: the sentinel pixie_skill_share. A sentinel is a marker string
the transport never looks at; it tells the two apps to read this body as a bundle
rather than as text. 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:
contacts whose public encryption key you actually hold, since sealing a message
requires a key to seal it to. 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.
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:
id must be kebab-case (lowercase
words joined by hyphens, like fetch-file), enforced by the pattern
^[a-z0-9][a-z0-9-]{0,63}$, a regex (a text-matching rule) that admits only
lowercase letters, digits, and hyphens. This single check is load-bearing: every
downstream consumer of a slug (the namespace resolver, the mention parser, the
rename-cascade rewrite 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 (characters that carry special
meaning inside a pattern) do not get sanitized
somewhere downstream; they never enter the system at all.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.
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 does nothing when the plan is expanded (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.
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 (usable if something references it,
but not offered to the planner); the end of this section returns to why that matters.
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 (doing it twice
changes nothing more than doing it once): 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 (takes a new handle) 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 in three places: 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. If the name is taken, it appends a
numeric suffix and tries again 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 before finally updating the item's own id. It is the exact rewrite local renames
use: it matches whole slugs only, ignores case, and guards word boundaries, so renaming
@plan cannot mangle @plan-trip.
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.
Compress the mechanism to its invariants and it reads like a checklist for moving structured data across a trust boundary:
@-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.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.
isWellFormed is deliberately not liberal in what it
accepts.