Personal kits are cloud-hosted, owner-private compositions of immutable foundation releases (for example AgentKit skills) plus the user's own overlays. SkillX stores immutable revisions, verifies every composition against the pinned release bytes, computes foundation rebases deterministically, and records conflicts for the user to resolve. SkillX never merges content on its own judgement, and no revision becomes effective without an explicit user approval.
- Mount:
/api/v1/kits/@{handle}/{name}[/...]. Source:apps/web/app/routes/api.v1.kits.tsandapps/web/app/lib/personal-kits/. - Feature flag:
SKILLX_PERSONAL_KITS_API("1"or"true", trimmed and in any case, enables it, as forSKILLX_RELEASES_API; any other value or no value is off). The production deployment sets it to"1"inapps/web/wrangler.jsonc, so it is on at skillx.sh. While it is off, every path returns the JSON 404kit.api_disabled, before any authentication or lookup, so a client can say "this deployment does not offer personal kits" instead of "no such kit". Reading base releases also needs the release stack (SKILLX_RELEASES_API, D1, R2). Enabling and rolling back are in Enablement and rollback. - Wire formats:
packages/contracts/src/personal-kit/. The golden vectorpackages/contracts/src/fixtures/personal-kit-golden.jsonmust re-derive byte for byte on both the SkillX (TypeScript) and AgentKit (Go) sides.
- Callers authenticate with a session, an unscoped API key, or a scoped
key acting as its owner: reads need
library:readorkits:write:@<handle>, writeskits:write:@<handle>, for the handle of the kit. A scoped key with no kit scope (such asreleases:publish:@halone) gets 401invalid_api_key; one scoped to another handle gets 403auth.scope_missingbefore any lookup. The read-onlyPOSTs (preview-update,proposals/validate) count as writes and needkits:write:@<handle>. The admin secret is not an identity here: a write that presents a valid one gets 404 (an invalid one gets 401invalid_admin_secret), and a read with only the admin secret is anonymous (401). A write in a web session (noAuthorizationheader) must come from the site's own origin; otherwise it gets 403cross_originbefore the session is read. - A kit is visible only to its owner. Every other caller gets the JSON 404, never 403, so a kit's existence is not revealed.
- Every write (create, store, rebase, resolve, activate, reject) requires
that the caller still owns the kit's publisher
@handleand that it is active. Otherwise the call returns 403kit.publisher_not_owned. - Transferring a kit to another owner is not supported.
- One request may spend at most 800 base reads (release lookups, manifest and
blob reads), cached and shared across all phases of the request. Past that
it fails with 422
kit.request_too_large; split the kit or the update. - Reads use the release read limiter. Writes use the write limiter, which fails closed.
- Request bodies are capped at 1 MiB + 64 KiB. Documents are capped at 1 MiB and 128 skills.
- Every response is JSON with
Cache-Control: no-store. Errors use{ "error": { "code", "message" } }.
{ "schema": "agentkit.skill-overlay/v1", "baseTreeDigest": "sha256:…", "ops": [
{ "op": "patch", "path": "SKILL.md", "expectSha256": "<hex>", "patch": "@@ -5,5 +5,5 @@\n …" },
{ "op": "add", "path": "notes.md", "contentBase64": "…", "mode": "100644" },
{ "op": "replace", "path": "x.md", "expectSha256": "<hex>", "contentBase64": "…" },
{ "op": "delete", "path": "old.md", "expectSha256": "<hex>" } ] }opsare sorted by the raw UTF-8 bytes of their path, with one op per path. Unknown fields are refused.baseTreeDigestmust equal the pinned basereleaseDigest, which is theagentkit.skill-tree/v1tree digest.patchapplies to text files only and must be an exact unified diff.- A
replacewithoutmodekeeps the mode of the base file.
{ "schema": "skillx.personal-kit/v1", "kit": "@alpha/my-kit", "localId": "local/my-kit",
"visibility": "private", "parent": { "kit": "@alpha/base-kit", "revisionDigest": "sha256:…" },
"skills": [ { "identity": "@alpha/demo", "mode": "override",
"base": { "package": "@alpha/demo", "version": "1.0.0", "releaseDigest": "sha256:…" },
"overlay": { … }, "compositionDigest": "sha256:…", "alias": "demo", "exposure": "…" } ],
"exclude": [], "profile": "…", "runtimes": { "claude": ">=1.0.0" } }localIdis the offline idlocal/<name>. On publish it maps to@<handle>/<name>. A kit has alocalIdonly when its name is an AgentKit kit ID (^[a-z0-9]+(-[a-z0-9]+)*$, at most 64 bytes); otherwise 422kit.invalid.excludeis stored sorted and without duplicates, so equivalent documents have one revision digest.parentnames a single kit revision. An array is refused withkit.multi_parent.- Lineage is resolved child first, up to depth 4. Cycles are refused with
kit.cycle. skillsare sorted by identity, and each identity appears once.modetakes one of four values:extendallows add ops only.overridekeeps the base identity and can change anything.forkrequires a new identity under the kit handle and its ownalias.createis a brand-new skill with no base:baseisnull, the identity is under the kit handle, and the overlay adds every file (SKILL.mdincluded) onto the empty tree, sobaseTreeDigestissha256:b1bc397a21f055678d40748596d8541925bbc1d566c4ab456f1af35630145e93and onlyaddops are allowed. The files are user content that no release scan has seen, so the server scans every file in full on each revision write, as it scans the lines a proposal adds: a secret, an absolute local path, binary or byte-order-mark content is refused with 422kit.content_refusedand nothing is stored; any otherdangerfinding stores the revisionquarantined. A created skill pins no paid base, so it never makes a kit a refused public derivative, but new kits still start private. Created content is checked only by that automatic scan, not by the release pipeline and its signed manifest, so a public kit that holds one serves content no release review has seen; readers should trust it as they trust the kit owner. It is skipped by rebase, and the SkillX CLI does not install a kit that holds one (AgentKit materializes it from the document). The golden vector ispackages/contracts/src/fixtures/personal-kit-create-golden.json.
- Shared skills, those owned by AgentKit
kits/coreor shipped by more than one official kit, can be forked but never overlaid; an in-place overlay is refused with 422overlay.shared_alias(AgentKit code). The guard fails closed: every skill whose name or directory alias starts withak-orak:(any case) is always shared, whether or notSKILLX_SHARED_SKILL_ALIASESis set. That variable (comma or whitespace separated identities) adds further shared skills to this default; entries that are not identities (bare aliases such asplan, mixed case) are ignored. While it holds no valid identity, every@agentkit/*identity is shared too, because kit releases carry noak-prefix (@agentkit/plan); the 422 detail then says the operator has not configured the variable. The guard runs on every revision write (manual, rebase, conflict resolve, proposal), not on activate, rollback or lock, so a stored revision that already overlays such a skill stays usable but cannot be rebased until the overlay becomes a fork. Once the variable is set, other@agentkit/*skills may be overlaid. AgentKit kit composition owns the canonical list: the non-ak-skills thatkits/coreor more than one official kit ships. Operators set the variable from that list; SkillX never derives it. - A fork alias that starts with
ak-orak:is refused with 422identity.reserved_alias(AgentKit code). - Kit names, identities and fork identities pass the full identity rules (pattern, 64-byte segments, portable segment rules).
- A parent kit owned by someone else is usable only through a revision that
was authored
publicand activated by its owner. Drafts, proposals, rejected or quarantined revisions, and revisions authored while that kit was private are refused withkit.base_unavailable. compositionDigestis the tree digest of the base (the empty tree forcreate) with the overlay applied. The server recomputes it from the pinned release bytes and refuses a mismatch with 409kit.composition_digest_mismatch.basemay pin any release of the package the owner can read, not only the newest one. A yanked pin still resolves and adds a warning; an unknown version, or a digest that is not that version's, iskit.base_unavailable.- The revision digest is the SHA-256 of the canonical JSON bytes of the validated document.
"composition": {
"extends": [ { "kit": "engineer", "version": "3.1.0" },
{ "skillx": "@alpha/base", "version": "1.0.0", "releaseDigest": "sha256:…" } ],
"include": [ "@alpha/demo" ],
"resolutions": [ { "identity": "@alpha/demo", "path": "SKILL.md", "category": "conflict.same_hunk",
"baseFrom": "sha256:…", "baseTo": "absent", "baseTreeFrom": "sha256:…", "baseTreeTo": "sha256:…",
"choice": "keep-old", "overlayDigestAfter": "sha256:…", "kit": "engineer",
"fromVersion": "3.0.0", "toVersion": "3.1.0", "planToken": "…", "approvedBy": "user" } ]
}It carries the AgentKit manifest's extends and include and the user's
resolution records (agentkit.skill-overlay-resolution/v1 records), so a kit
synced from SkillX can be rebased or edited from that copy.
- It is optional. A document without it is unchanged and keeps its revision
digest; with it, the composition is part of the canonical JSON and so of
the digest (vector:
packages/contracts/src/fixtures/personal-kit-composition-golden.json). - Validation is strict (
kit.invalidat the offending path) and nothing is normalized or reordered:extendshas 1 to 16 unique entries, eachkit(an AgentKit kit ID) orskillx(an identity, withreleaseDigest), with an exactversion;includehas at most 256 unique identities;resolutionshas at most 1,000 records, unique by identity, path and category, withchoiceone ofkeep-old,use-upstream,edit-overlayorfork(a fork record namesfork: {identity, alias}with the record's identity) andapprovedBy: "user". - SkillX stores it verbatim and never resolves
extendsor applies a record: the entries' pins, overlays and composition digests stay the only truth. - A server-built revision (rebase, conflict resolution) drops it, because it moves the pins the records describe; the client attaches fresh records in its next revision. A session-derived proposal keeps the from revision's composition (it never moves a pin).
The lock is derived on read from immutable documents, so equal revisions always yield equal lock bytes. It contains:
resolver,kit,localId,revisionId,revisionNumberandrevisionDigest;profileandruntimes;- one entry per exposed skill:
identity,alias,mode,exposure,package,version,releaseDigest(all threenullfor acreateentry),overlayDigests(one per lineage layer),compositionDigest, andowner: "personal:@h/k".
The lock owner key stays personal:@<handle>/<name> on SkillX. AgentKit keys
its local kits personal:<name> (local/<name>) and maps that to the SkillX
key when it publishes. The lock never contains machine paths or secrets.
GET /lock returns full documents only for kits the caller owns and, with a
scoped key, may read; a foreign ancestor, and an owned ancestor under a handle
the scoped key holds neither library:read nor kits:write:@<handle> for,
appears as {kit, revisionDigest, foreign: true}.
Every kit has a generation. GET returns it, with an ETag of "g<n>".
- Every write carries the expected generation, as
If-Match: "g<n>"(the ETag; a bare<n>is also accepted, and so is the weak formW/"g<n>"that Cloudflare substitutes when it compresses theGET) or asexpectedGenerationin the body (kept for compatibility). Both may be sent when they agree; a disagreement, a list or*is 400invalid_request. A stale generation writes nothing and fails with 412kit.generation_conflictwhen it came withIf-Match, or 409kit.generation_conflictwhen it came in the body. A first write (a kit that does not exist yet) expects generation 0.proposals/validatetakesIf-Matchtoo and reportsgenerationConflictinstead of failing. - The kit row, the revision or conflict, and the audit row commit in one D1 batch under the generation guard.
- Creating writes (
revisions,rebase,conflicts/:id/resolve) also carry anidempotencyKey([A-Za-z0-9._:-]{8,128}).- Replaying the same key with the same document returns the stored revision
with 200 and
replayed: true. - Reusing the key with a different document fails with 409
kit.idempotency_conflict. - The key is looked up before the generation, so a retry after a lost answer
replays (200,
replayed: true) instead of failing as stale. A rebase key replays its stored revision or its recorded conflict only for the same request (fromRevisionId,updatesandincludeForks); other updates under the same key fail with 409kit.idempotency_conflict.
- Replaying the same key with the same document returns the stored revision
with 200 and
- Storing a document equal to a live revision fails with 409
kit.revision_exists. A rejected document may be stored again as a new revision.
Method and path (/api/v1/kits/@h/n + …) |
Purpose |
|---|---|
GET |
Kit summary: kit, localId, visibility, generation, latestRevisionNumber, effectiveRevision, openConflicts. |
GET /revisions, GET /revisions/:id |
Revision list, or one revision with its document. |
POST /revisions {expectedGeneration, idempotencyKey, document} |
Store a draft revision with origin manual. 201 {revision, generation, replayed, warnings}. The first write creates the kit, always private: only an activation sets the kit's visibility, so an API key never creates a public kit. |
POST /revisions/:id/reject {expectedGeneration} |
Reject a draft. Rejecting the effective revision fails with 409 kit.revision_effective. A session-derived proposal is refused with 403 kit.interactive_approval_required; it is rejected only through POST /revisions/:id/review in a web session. |
GET /diff?from=&to= |
Read-only structural diff: changed document fields, and added, removed and changed entries with their overlay paths. |
POST /preview-update {updates:[{package,version}], fromRevisionId?, includeForks?} |
Rebase report only. Writes nothing. |
POST /rebase {expectedGeneration, idempotencyKey, updates, fromRevisionId?, includeForks?} |
Clean: 201, a draft revision with origin rebase (never activated implicitly). Otherwise: 200 {status:"needs-review", conflictId, report}. A newer conflict supersedes older open ones. |
GET /conflicts, GET /conflicts/:id |
Conflict records: the report, the resolution and reevalRequired. |
POST /conflicts/:id/resolve {expectedGeneration, idempotencyKey, approvedBy:"user", decisions, agentSuggestion?} |
The user's decision becomes a draft revision with origin resolution. |
POST /activate, POST /rollback {expectedGeneration, revisionId, approval:{revisionDigest}} |
Make a revision effective. rollback re-activates an earlier effective revision. |
GET /lock[?revision=<id>] |
The lock and lineage documents of the effective (or named) revision. |
POST /delete, POST /restore {} |
Soft-delete the kit, or restore it within 30 days (owner session or unscoped key, or the admin; scoped keys 401). A deleted kit answers 404 here, in the library and in MCP get_kit, and a new revision under its name is 409 kit.deleted. Both advance generation. An admin delete is containment: only the admin can restore it (the owner gets 403 restore.admin_deleted). See api-reference.md. |
The default sources are the effective revision for fromRevisionId and for
the lock.
Rebase is a deterministic three-way merge. For each entry whose base package
appears in updates, it takes:
- B0, the pinned base;
- U0, the user's overlay;
- B1, the new release resolved by version (a yanked version is never a target).
Forks are skipped unless includeForks: true is set.
The report (skillx.kit-rebase-report/v1) lists, per entry, from, to,
status (clean | needs-review | unchanged | skipped) and its findings.
Each finding is a {category, path, detail} record using the AgentKit
category strings:
clean.untouched,clean.disjoint,clean.identical(both sides made the same change)conflict.same_hunk,conflict.renamed_or_deleted,conflict.added_bothconflict.dependency_removed,conflict.dependency_missingconflict.script_changed,conflict.mode_changedconflict.capability_escalation,conflict.capability_downgradeconflict.exposure_changed,conflict.license_changedconflict.entitlement,conflict.ambiguous_provider,conflict.revoked_base
The server adds the last three itself. Deleting a path that upstream changed
is conflict.renamed_or_deleted. The line diff is bounded at 1,000 edits
(AgentKit MaxEditDistance); a larger diff is recorded as
conflict.same_hunk with detail diff_limit. An entry with no overlay still
runs the semantic checks against upstream, so an added script, a dropped
allowed-tools restriction or another capability change blocks for review.
Any conflict.* finding fails closed:
nothing becomes effective, and the old effective revision keeps working.
Reports and conflict records hold categories, paths and digests, never file content. The stored conflict keeps the user's clean candidate overlays so the resolution can reuse them.
There is one decision per needs-review entry. approvedBy must be
"user", otherwise the call fails with 409 kit.approval_required.
keep-old: the entry stays on its current base, with its overlay unchanged.use-upstream: the entry moves to the new base with no overlay. It requiresdiscardUserDelta: true, otherwise 409kit.discard_unconfirmed.edit-overlay: the entry moves to the new base with the suppliedoverlayandcompositionDigest. The server verifies both against the bytes.fork: the entry follows upstream unmodified, and the user's composition moves toforkIdentitywith its ownalias.
An optional agentSuggestion (16 KiB at most) is stored in the resolution
record (agentkit.skill-overlay-resolution/v1) with
agentSuggestionApplied: false. It is never applied. Any choice other than
keep-old sets reevalRequired. A conflict that is no longer open fails
with 409 kit.conflict_closed.
When every decision is keep-old, the result equals the source revision.
The conflict then closes onto that revision (200), and no new revision is
written.
Activation requires approval.revisionDigest to equal the exact revision
digest, otherwise 409 kit.approval_required. Activating (or rolling back
to) a revision whose document is public needs an interactive web session,
whatever its origin: an API key gets 403 kit.interactive_approval_required,
so a kit becomes public only by a signed-in owner. Before moving
effective_revision_id, it re-checks every pinned base of the lineage:
- A base that is quarantined, revoked, no longer entitled or removed blocks
activation with 409
kit.locked_incompatible, listing each pin inerror.reasons. - A yanked base still works (pins keep working) and is reported in
warnings.
GET /lock runs the same check, so a second machine either restores the
identical lock or gets the explicit reason. A proposed revision must be
reviewed before activation (kit.review_required). A rejected or quarantined
revision cannot be activated (kit.revision_unusable). A quarantined
revision, effective or anywhere in the parent chain, makes GET /lock fail
with kit.locked_incompatible (reason the revision is quarantined, or the
quarantined parent) until the owner activates or rolls back to another
revision. A parent revision that is no longer available (missing, rejected,
or a foreign kit that is no longer public) fails the lock and activation the
same way, with the parent named in error.reasons; storing a new revision on
such a parent stays 422 kit.base_unavailable.
AgentKit can turn improvements learned in local sessions into a proposal. The server accepts only the reviewed overlay, never raw session data, and the proposal is always private. The owner reviews it, then activates it as usual. Nothing activates implicitly.
- Private kits only: a proposal for a public kit fails with 409
proposal.private_kit_required(fork it to a private kit first). So does activating a gated revision (below) while the kit is public, or when its own document ispublic: the check covers the visibility the kit would have after activation, not only its current one. The kit's visibility is never flipped by a proposal. - A human approves: review (approve or reject) and activation of a
session-derived revision need an interactive web session. An API key, such
as the one AgentKit submits with, gets 403
kit.interactive_approval_required. The proofs recordcredential. - The gate is inherited: a revision is gated when it, or any ancestor in
its revision chain, is session-derived, whatever the origins in between
(manual, rebase, resolution). The same holds for the revision a document's
parentnames in another kit, and for that revision's own chain, at every level of the lineage. A parent that cannot be found or read counts as gated, or fails the activation withkit.locked_incompatible. A gated revision activates (or is rolled back to) only from a web session, only on a private kit, and only as a private document. - What the gate follows: the gate follows revision history, not
content. A manual revision's parent is the kit's effective revision, so
once a session-derived revision has been effective, the revisions built on
it stay gated. A session-derived revision that never became effective
gates nothing but its own rebases and resolutions and the kits whose document
parentnames it. Rolling back to an earlier private revision with no session-derived ancestor (allowed with an API key) ends the gate for the revisions built after it. Content copied into a revision by hand carries no history, so it is not gated; making it public still needs a web session. - No chaining on unreviewed proposals: a from-revision that is
proposed(or any session-derived revision that is notreviewed) fails with 409kit.review_required, and an unreviewed proposal is never usable as a parent kit revision. The same rule applies to the source ofpreview-updateandrebase(and to the from revision when a conflict is resolved): an unreviewed proposal fails with 409kit.review_required, a rejected or quarantined revision with 409kit.revision_unusable. - Rejection only through review: the generic
POST /revisions/:id/rejectrefuses a proposal with 403kit.interactive_approval_required; usePOST /revisions/:id/reviewwithdecision: "reject"in a web session.
| Endpoint | Purpose |
|---|---|
POST /proposals/validate {expectedGeneration?, proposal} |
Every check below, including the server-side composition. Writes nothing. Returns state, revisionDigest, compositionDigest, requiredPermissions, declaredPermissions, findings, changedPaths, generation and generationConflict. |
POST /proposals {expectedGeneration, idempotencyKey, proposal} |
Stores a session-derived revision (201; an idempotent replay is 200), parented to fromRevisionId, in state proposed, or quarantined when the scan finds danger. Its review metadata is written in the same batch. |
GET /revisions/:id/proposal |
What the owner reviews: declared and required permissions, the evidence, finding codes and changed paths. |
POST /revisions/:id/review {expectedGeneration, decision, approval?} |
reject is final. approve needs approval: {revisionDigest, permissions, visibility: "private", consent: true}, where permissions are exactly the declared ones; otherwise 409 kit.approval_required. The revision becomes reviewed, with the proof in approval_json (kept on activation under review). Only a proposed revision can be reviewed. |
POST /revisions/:id/quarantine {reason?} |
Operators only (X-Admin-Secret); owners get 403 kit.admin_only. Quarantines any revision. |
{
"schema": "skillx.kit-proposal/v1",
"fromRevisionId": "pkr_…",
"skill": { "identity": "@alpha/demo", "base": { "package": "@alpha/demo", "version": "1.0.0", "releaseDigest": "sha256:…" },
"overlay": { "schema": "agentkit.skill-overlay/v1", "baseTreeDigest": "sha256:…", "ops": [] }, "compositionDigest": "sha256:…" },
"declaredPermissions": ["allowed-tools:Bash"],
"evidence": { "summary": "redacted, at most 2 KiB", "signals": { "sessions": 3 } },
"visibility": "private"
}The server copies the from revision's document. It replaces that one skill's
overlay and compositionDigest, sets the visibility to private, and
verifies the result like any revision.
-
Base and skill: the skill must be in the from revision, and
basemust equal its pin exactly. Otherwise the proposal fails withproposal.unknown_skillorproposal.base_mismatch. A rebase moves a base; a proposal does not. -
Raw session data: a key that names a transcript, session, prompt, messages, conversation, environment, secret or token, working directory, history, tool output or logs, at any depth, fails with 422
proposal.forbidden_field. The message names the key path, never its value. A path isproposal, then.keyfor a key of[A-Za-z0-9_$-],["key"](a JSON string) for any other key, so.and[in a key are never ambiguous,[index]for an array item,#jsonfor a JSON string read as nested keys, and[…]for a key over the length limit, which is never echoed. Keys are compared in this order: refused when longer than 64 code points; percent-decoded up to 3 rounds (refused when an escape remains); refused unless every character is printable ASCII or its fullwidth form (U+FF01 to U+FF5E); NFKC (Unicode 15.0, pinned in the policy; only the fullwidth forms change, so the result does not depend on the runtime's Unicode version); refused unless printable ASCII;A-Zlowercased; everything buta-z0-9removed (Session_Log,api-key,%70assword, fullwidth letters). A string that holds a JSON object or array, also inside JSON string layers (outsidecontentBase64andpatch), is checked as nested keys. A key with a numeric value (an evidence signal) is allowed. Unknown fields are refused. An unknown top-level body field is 400invalid_request. The key lists and the content rules are the shared policyskillx.kit-proposal-policy/v1inpackages/contracts/src/fixtures/kit-proposal-policy-v1.json; AgentKit applies the same file before upload, and both sides test againstkit-proposal-policy-vectors-v1.jsonnext to it. -
Visibility: anything but
"private"fails with 422proposal.private_only. -
Evidence: optional.
summaryis at most 2048 UTF-8 bytes (proposal.evidence_too_large).signalsholds at most 16 short lowercase names mapped to finite numbers. -
Content scan: covers what the new overlay introduces against the from composition (the lines a changed file gains, a new file whole), plus the evidence summary as
evidence/summary.md. Lines kept from the base are not scanned, so a finding already in the base never refuses a proposal.- Fails with 422
proposal.content_refused, nothing stored: a secret, binary content, text with a byte order mark (UTF-8/16/32), or an absolute local path. - Secrets: vendor token formats; a
key: value,key = valueor"key": "value"assignment, also with:=or=>, in brackets (cfg["password"] = ...), as a flag (--password=...) or inside another value (Server=db;Password=...,?user=a&password=...), in YAML, JSON,.envor code, whose key holds a stem followed by nothing or by at most two qualifiers ofcontent.secretKeys.qualifiersor a version number (secret_key_base,api_key_prod,private_key_id; notsecretName,passwordlessortokenizer): a password stem (secret-password:password,dbPassword,DB_PASSWD, a value of 4 or more characters) or a secret stem (secret-assignment:clientSecret,apiKey,accessToken,awsCredentials, a value of 8 or more characters), in any case and separator style, compared through the same key normalization as the envelope (the stems arecontent.secretKeysin the policy); aKEY=valueline whose key holds a stem anywhere (secret-dotenv); a YAMLpassword:key whose new value sits on the following indented lines or in a|/>block scalar, also as a list item- password:, a quoted key"password":, or after up to two anchors or tags (&pw,!secret,&a !s), even when the key line is kept from the base; Bearer tokens, URLs withuser:pass@, JWTs and private key blocks. A YAML anchor or tag before an inline value is skipped (up to two). Placeholders (<...>,%VAR%,{...},***,${...}, a whole$VAR) and numbers are not secrets; any other value, also one that starts with$, is. - Paths: every absolute local path, matched case-insensitively, after NFC
normalization, with Unicode slash forms (
/,∕,⁄,\) read as separators and invisible format characters (zero-width spaces and joiners) removed, and also JSON-escaped (\/,\\) or percent-encoded (%2F,%5C,%3A,%2E, decoded up to three%25layers; a text still holding an encoded separator or dot after that is refused): any drive-letter path, UNC shares (\\host\shareand//host/share),file:andsmb:URLs, editor links to a file (vscode://file/...), a user home in ansshorsftpURL, a bare/home,/Users,/root,/mnt,/media,/Volumes,/var/folders,/tmp,/privateor/run/user, a path glued to a flag (-I/opt/x,--include=/opt/x), and any other POSIX path of two or more segments in any script (such as/opt/...,/srv/...,/var/www/...,/usr/src/...,/proc/self/root/...,/Usuários/...). Only the policy'slocalPathSystemAllowlistis allowed: wholesubtreesof binary and library directories (/usr/bin/...,/usr/lib/...,/usr/local/bin/...), andexactwell-known directories and files, matched as a whole (/etc/hosts,/proc/cpuinfo,/dev/null,/usr/share/zoneinfo; not/usr/share/doc/xor/usr/local/opt/x). A..segment refuses the path. URLs of network schemes (https://host/path,s3://, ...) are not local paths; a path glued after a URL's,,;,|,&,(,),[,],{or}is still read.error.findingslistsdanger:<rule>:<path>codes, never the matched text. Every detector runs in time linear in its input, so a hostile request body cannot exhaust the Worker CPU. - Any other
dangerfinding, such as prompt injection or ajavascript:URL, stores the proposal asquarantined. A quarantined proposal cannot be reviewed or activated.
- Fails with 422
-
Permissions: the server derives the permissions a change needs from the two compositions:
allowed-tools:<tool>for a newly allowed tool, orallowed-tools:*when the restriction is dropped;hooksfor a new or changedhooksvalue;script:<path>for a changed declared script or executable;executable:<path>for a newly executable file.
The declared permissions must equal the required ones. A missing one fails with 422
proposal.undeclared_permission(error.required,error.missing), an unneeded one with 422proposal.excess_permission(error.required,error.extra). At most 64 permissions can be declared. -
Nesting: a request body nested deeper than 32 levels is 400
invalid_request.
- Immutability: revision content columns cannot be updated, and revisions
are never deleted (D1 triggers). The audit log (
personal_kit_audit) is append-only. - Privacy: kits are private by default. A
publickit is refused with 422kit.public_derivative_forbiddenwhen any base, or the parent kit, is private or paid. Overlays are stored only for their owner. - Entitlement: bases load through the pinned-read access rules for the owner. The owner must be able to read the release (public, a private package of a publisher the same account owns, or entitled), its publisher must be active, and no release of its tree may be quarantined. Every blob is checked against the manifest SHA-256.
- Pins: a pin is the exact package, version and tree digest. Another package that ships the same tree never answers for it.
- No AI auto-accept: only the deterministic engine and the user's explicit decisions write revisions. Nothing activates implicitly.
The migration is strictly additive. It creates four tables:
personal_kitspersonal_kit_revisionspersonal_kit_conflictspersonal_kit_audit
Their indexes enforce uniqueness of the kit name and, per kit, of the revision
number, the revision idempotency key and the rebase-conflict idempotency key.
Foreign keys are ON DELETE RESTRICT. Four triggers guard immutability
and the append-only audit. The rollback SQL is recorded in the migration
header.
Migration 0016_personal_kit_proposals is also strictly additive. It creates
personal_kit_proposals, keyed by revision_id, which holds:
- the skill identity;
- the declared and required permissions;
- the optional evidence;
- the scan finding codes and changed paths.
It also adds an index on kit_id, two triggers that make the rows immutable
and undeletable, and two guard triggers on personal_kit_revisions:
rejected and quarantined are final (a rejected revision may still be
quarantined), reviewed moves only to quarantined, nothing returns to
draft or proposed, and a session-derived revision becomes reviewed only
with the owner's consent proof in approval_json, which is never cleared. It never holds transcripts, prompts, environment
or file content. Its rollback SQL is in its header.
The flag is the only switch; enabling it runs no migration (0015 and 0016
are applied with the rest) and touches no existing data. Production already
runs with "1" in apps/web/wrangler.jsonc; the steps below apply to any
other deployment and to re-enabling after a rollback.
- Turn it on for one environment (
SKILLX_PERSONAL_KITS_API="1") and deploy that environment. - Run the enablement smoke against it with a dedicated smoke owner account
whose publisher holds a published base skill, and a second account:
SKILLX_SMOKE_OWNER_KEY=... SKILLX_SMOKE_OTHER_KEY=... node scripts/smoke-personal-kits.mjs --base <url> --allow-remote --handle <owner handle> --base-skill @pub/slug@version. A remote run refuses to start withoutSKILLX_SMOKE_OTHER_KEYor with a base URL that is nothttps. The smoke checks 401 for anonymous, idempotent replay, 412 on a staleIf-Match, activation and the lock read-back, then that the second account gets 404 on reading the kit, storing a revision and activating it, gets 403kit.publisher_not_ownedcreating a kit under the owner's handle, and leaves the owner's kit unchanged. It never follows a redirect (a 3xx fails the check) and prints a JSON receipt with no keys. Exit 2 means the deployment still answerskit.api_disabled. - Clean up: the API only soft-deletes a kit, and a deleted name is never
reused (revisions and audit rows are immutable, and kits are
ON DELETE RESTRICT), so the smoke reuses one private kit,@handle/agentkit-smoke(--kit-nameto choose another), and each run only appends and activates one revision there; the receipt names the kit and the run. Keep that kit on the dedicated smoke account; do not point the smoke at a real user's handle. The second account's attempt to create@handle/smoke-squat-<random>leaves no kit behind: it is refused with 403kit.publisher_not_ownedbefore anything is written. - Roll back by setting the flag to
"0"and redeploying. Every kit path then answers 404kit.api_disabledagain; stored revisions, locks and audit rows stay in D1 untouched and come back unchanged when the flag returns. Clients keep their local kits: AgentKit mapskit.api_disabledto "unsupported" (exit 2) and never treats it as a deleted kit.
apps/web/tests/personal-kits/kit-enablement-smoke.test.ts runs the same
smoke against the real route modules over the in-memory test D1/R2; it proves
the smoke and the flag-off answer, not a deployment.
| Code | Status |
|---|---|
kit.generation_conflict, kit.idempotency_conflict, kit.revision_exists, kit.composition_digest_mismatch, overlay.conflict, kit.approval_required, kit.review_required, kit.revision_unusable, kit.revision_effective, kit.discard_unconfirmed, kit.conflict_closed, kit.locked_incompatible, kit.not_a_proposal, proposal.private_kit_required |
409 |
kit.generation_conflict when the stale generation came with If-Match |
412 |
proposal.invalid, proposal.forbidden_field, proposal.private_only, proposal.evidence_too_large, proposal.unknown_skill, proposal.base_mismatch, proposal.content_refused (findings), proposal.undeclared_permission (required, missing), proposal.excess_permission (required, extra) |
422 |
kit.admin_only, kit.interactive_approval_required, restore.admin_deleted, cross_origin |
403 |
kit.invalid, overlay.invalid, schema.unsupported_version, kit.duplicate_identity, kit.mode_violation, kit.alias_collision, overlay.shared_alias, identity.reserved_alias, kit.request_too_large, kit.cycle, kit.multi_parent, kit.public_derivative_forbidden, kit.base_unavailable (409 when resolving a conflict) |
422 |
kit.publisher_not_owned |
403 |
invalid_request (malformed body, ids, updates or If-Match) |
400 |
kit.api_disabled (flag off, on every path) |
404 |
not_found (not the owner, unknown id) |
404 |