This is appkit — the shared half of several app repos, installed once through Homebrew and read by all of them. Working here is not like working in an app repo: a change lands in every app on the next brew upgrade appkit && appkit sync, so the bar is "is this true of all of them", not "does this help the one in front of me".
AGENTS.core.md is the house style that app repos import. It is not for this repo's own rules — it is the payload. Read README.md for the layout and what it deliberately does not do.
Something belongs in appkit when it is true of every app, or would be if the apps agreed. Everything else stays in the app repo, and the boundary is worth defending:
- The harness, not the subject.
commands/capture.shresolves devices, makes workers, boots them, shards the languages and draws the progress line. It has no idea what a scene is — the repo'sscripts/scenes.shsuppliescapture_sceneandappkit.jsonsupplies the list. The moment appkit knows an app's scene names, it has stopped being a kit. - The composer, not the art.
render/compose.swiftlays a capture on a background under a caption, from a storyboard the repo owns. An app whose cards are drawn rather than composed keeps its own renderer and says so inappkit.json. One app on appkit draws a title card that is a hand of playing cards in CoreGraphics; forcing that through a layout spec would be the same mistake as a sharedSpacing.swift. - The shape, not the values.
AGENTS.core.mdsays every app splitsDesignSystem/Tokens/the same way — aScaleladder, thenSpacing/Radiinaming rungs on it, then the brand. The numbers themselves are never shipped from here: three apps had aSpacing.lgmeaning three different values, and one app'sElevationis a deliberately no-op flat ladder. A file would have moved layout in shipped apps; a paragraph cannot.
Every capability a command needs from a device is a platform_* function, and both of platform/{ios,android}.sh define all of them. There is no case $PLATFORM in any command, and writing one is the signal that a capability is missing from the contract rather than that this platform is special.
The contract is small on purpose: resolve, workers, install, prepare, launch, screenshot, kick, retire, locate_app, verify_languages, build, version, set_version, list_devices, use_device, shared_files. A platform that genuinely cannot do one of them defines it as a no-op with a line saying why — platform_shared_files on Android is empty, because appkit has no opinion about formatting Kotlin.
Both adapters must define all of them, and that is a check, not a habit. platform_use_device lived in ios.sh alone while commands/run.sh called it on both, so appkit run --device on Android died with "command not found" — invisible until somebody ran the one command that needed the missing half. The diff that catches it is in CONTRIBUTING.md.
A capability only one platform has is ios_*, not platform_* — ios_project, ios_bundle_id, and the ios_watch_* group. The test is whether an appkit command calls it: nothing in commands/ touches a watch, so there is no platform to branch on and android.sh owes no no-op. Wear OS is a different shape and inventing a stub for it would be a worse lie than its absence. The moment a command needs one, it becomes platform_* and both adapters define it.
Everything an app repo gets is copied by appkit sync and verified by appkit doctor, which the pre-commit hook runs — a copy that drifts fails a commit rather than surviving. A link into the brew prefix would survive neither an upgrade nor another machine, and a repo should still read as itself on a laptop where appkit was never installed.
.gitignore is the one that could not be a link even if we wanted it: git refuses to follow a symlinked one. It fails with unable to access '.gitignore': Too many levels of symbolic links and then applies no rules at all, silently. Verified, not assumed. Its managed block is delimited by markers, and everything below the closing marker is the app's own and is never touched.
The skills are the exception in the other direction: appkit skills links them into ~/.claude/skills, once per machine, because they are the same instructions in every repo and only an agent on this laptop reads them. The link points at brew's opt path, so brew upgrade appkit is the whole of an update.
When editing any of it, edit it here and re-sync the app.
Every script is bash -n clean and the libraries are sourced, never executed. There is no test suite; the check is:
for f in bin/appkit commands/*.sh lib/*.sh platform/*.sh store/*.sh; do
bash -n "$f" || echo "FAIL $f"
done
sh -n githooks/pre-commit
for f in render/*.swift; do swiftc -O -o "/tmp/$(basename "$f" .swift)" "$f" || echo "FAIL $f"; done
appkit help && appkit help upload # both read outside an app repoThen run appkit sync && appkit doctor in a real repo before pushing something several apps will pull.
Never run xcodebuild or gradlew and never launch a simulator to check something — the same rule the app repos carry, for the same reason. commands/run.sh and the platform_build functions exist for the person at the keyboard.
The renderer is the exception worth naming: it is a macOS command-line program, not an app build, and it is meant to be run and looked at. Render a locale into a scratch directory and open the PNGs.
Conventional Commits, and few. A subject line, and a body only when it says something the diff cannot. feat / fix / refactor / docs / ci / chore, ! for a break that makes app repos re-run appkit sync.
Group by what changed for the repos on appkit, not by the order the work happened in — a dozen commits narrating one restructure is a worse record of it than four.
Bash, set -euo pipefail, functions in lib/ and platform/, flow in commands/. Swift 6 under strict concurrency in render/.
Comments earn their place by saying what the code cannot — why position and not .offset, why serial and not parallel. Prose wraps at 80 columns; prettier enforces it through the same config the apps use.