CtrlK
BlogDocsLog inGet started
Tessl Logo

packaging-claw-snaps

Package a "claw"-type local-first AI agent (openclaw, nullclaw, zeroclaw, hermes-agent, picoclaw, odysseus, and future siblings) as a classic-confinement snap, consistently with the existing collection in ~/src/github/ai-labs/snaps. Use when creating a new claw snap, fixing build/release/CI workflows for one, or making the collection more consistent — covers snapcraft.yaml structure, version adoption per runtime, bundling Node/Chromium, self-contained Python venvs on core24, why not to patchelf interpreters under classic confinement, the systemd user-service pattern, and the PR/release/check-releases GitHub workflows.

72

Quality

87%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

SKILL.md
Quality
Evals
Security

Packaging claw snaps

The ~/src/github/ai-labs/snaps collection packages local-first AI agents as classic-confinement snaps (they run arbitrary user code, browse files, exec tools — strict confinement can't express that). openclaw is the reference; copy its structure. For the bundled local-model picker, see the claw-lemonade-tool skill.

Repo layout (standard)

<name>-snap/
  snap/snapcraft.yaml
  snap/local/bin/<name>-launch            # CLI wrapper: sets env, installs service, exec app
  snap/local/bin/<name>-daemon-launch     # gateway daemon wrapper (if the agent has one)
  snap/local/bin/<name>-lemonade(-launch) # lemonade model picker (see claw-lemonade-tool)
  snap/local/bin/setup-providers(.js)     # auto/interactive provider config
  snap/local/share/systemd/user/<name>.service
  snap/local/share/completions/<name>.bash   # optional bash completion (completer:)
  .github/workflows/{pr,release,check-releases}.yaml
  package.json                            # npm-runtime snaps: version placeholder for check-releases
  .gitignore                              # *.snap parts/ stage/ prime/ .snapcraft/
  README.md

snapcraft.yaml essentials

  • base: core24, confinement: classic, grade: stable, adopt-info: <name>.
  • A launcher part (plugin: dump, source: snap/local) with an organize: map listing every script/unit/completion. The app command:s point at bin/<name>-launch.
  • The main part derives the version with craftctl set version=... — method depends on runtime.

Version adoption + upstream fetch, by runtime

  • npm package (openclaw, picoclaw, zeroclaw): plugin: npm, npm-include-node: true, npm-node-version. In override-build: craftctl default, then npm install -g --prefix "$CRAFT_PART_INSTALL" <pkg>, then read the installed package.json version via the bundled node. check-releases.yaml polls registry.npmjs.org/<pkg> for ['dist-tags'].latest. ⚠️ Use JSON.parse(d)['dist-tags'].latestJSON.parse(d).['dist-tags'] is a silent syntax error that breaks the auto-update workflow (was a real bug in openclaw).
  • GitHub release binary (nullclaw): plugin: nil, override-pull curls api.github.com/repos/<o>/<r>/releases/latest for tag_name, downloads the per-arch asset, craftctl set version. Map $CRAFT_ARCH_BUILD_FOR→upstream arch name.
  • PyPI (hermes-agent): plugin: nil + a manual venv (NOT plugin: python — see "Self-contained Python" below); pip install <pkg>; read version from the installed *.dist-info/METADATA. check-releases.yaml polls PyPI.
  • git source app (odysseus): plugin: nil + source: git; build a venv (see "Self-contained Python"), copy app source to $CRAFT_PART_INSTALL/opt/<name>, and have the launcher copy it to a writable $SNAP_USER_COMMON dir on first run / refresh (the app writes next to __file__).

Classic confinement gotchas

  • No AppArmor / user namespaces → bundled Chromium must run with --no-sandbox. Bake it at build time: find the chrome binary, mv it to chrome-real, drop a wrapper exec .../chrome-real --no-sandbox --disable-setuid-sandbox "$@". Set PLAYWRIGHT_BROWSERS_PATH=$SNAP/usr/share/ms-playwright in each app's environment:.
  • Do NOT patchelf the bundled interpreter (node/python) to core24's linker. Classic confinement runs against the host dynamic linker, and a core24-built binary is forward-compatible with a newer host glibc. Patching the interpreter to /snap/core24/current/... instead segfaults silently on hosts whose core24/glibc combination differs (observed on Ubuntu 26.04: ldd and the binary both produce no output, exit 139). openclaw deliberately ships its bundled node un-patched — do the same. (Patching node before npm install also segfaults the build, because /snap/core24/current isn't mounted in the build instance.)
  • Stage the Chromium runtime libs (libnss3, libnspr4, libatk*, libcups2t64, libdrm2, libgbm1, libasound2t64, …) — see openclaw's list.
  • prime: excludes trim bulk: koffi non-linux_x64 prebuilds, tree-sitter foreign prebuilds, the Go stdlib doc/ test/ misc/ trees, etc.

Self-contained Python (the pattern for hermes-agent & odysseus)

A core24 Python app must NOT depend on the host shipping python3.12 — the host may be newer (Ubuntu 26.04 ships 3.14, with no python3.12 at all). The python plugin also fails on core24 ("No suitable Python interpreter found", because python3.12 is filtered as a base-manifest package). So use plugin: nil and build a fully self-contained venv in override-build:

  1. Clean + create the venv with --copies (real interpreter, not a host symlink), idempotently so incremental rebuilds don't recreate over a stale venv:
    rm -rf "$CRAFT_PART_INSTALL"; mkdir -p "$CRAFT_PART_INSTALL"
    python3 -m venv --copies "$CRAFT_PART_INSTALL"
    export PATH="$CRAFT_PART_INSTALL/bin:$PATH"
    pip install --no-cache-dir <your-packages>
  2. Bundle the stdlib (a venv resolves its STDLIB from the base python, not its own lib/ — so just copying files isn't enough on its own, step 3 wires it up):
    PYSTDLIB="$(python3 -c 'import sysconfig; print(sysconfig.get_path("stdlib"))')"
    cp -a "${PYSTDLIB}/." "$CRAFT_PART_INSTALL/lib/python3.12/"
    rm -rf "$CRAFT_PART_INSTALL/lib/python3.12/test"   # large, not needed at runtime
  3. Repoint the venv's home at the runtime /snap/<name>/current so it finds the bundled stdlib (use $CRAFT_PROJECT_NAME, never a hardcoded name — a rename otherwise leaves stale /snap/<old>/... paths):
    sed -i \
      -e "s|^home = .*|home = /snap/${CRAFT_PROJECT_NAME}/current/bin|" \
      -e "s|^executable = .*|executable = /snap/${CRAFT_PROJECT_NAME}/current/bin/python3.12|" \
      "$CRAFT_PART_INSTALL/pyvenv.cfg"
  4. Do NOT patchelf the interpreter (see the gotcha above — it segfaults across distro versions). The un-patched copy uses the host linker and is forward-compatible.
  5. Rewrite venv console-script shebangs from the build path to the runtime path (sed "1s|${CRAFT_PART_INSTALL}|/snap/${CRAFT_PROJECT_NAME}/current|").
  6. Add site-packages via PYTHONPATH in the launcher. Because step 3 makes the venv prefix equal its base prefix, Python stops treating it as a venv and Ubuntu's Debian-patched site.py looks for dist-packages, not the venv's site-packages. So export it explicitly:
    export PYTHONPATH="$SNAP/lib/python3.12/site-packages${PYTHONPATH:+:$PYTHONPATH}"
    With steps 3 + 6 the interpreter resolves both stdlib and site-packages with no host Python at all. Verify by extracting the .snap (unsquashfs) and running its bin/python3 with SNAP=<dir> PYTHONPATH=<dir>/lib/python3.12/site-packages — it should import both an stdlib module (encodings) and an installed package.

systemd user-service pattern (gateway daemon)

The daemon is a second app registered as /snap/bin/<name>.daemon; a user unit ExecStarts it. The CLI launcher installs/refreshes the unit. Use the robust pattern (re-copy when the bundled unit changes, not just on first run) so snap refreshes land:

SYSTEMD_USER_DIR="$HOME/.config/systemd/user"; SERVICE_NAME="<name>"
UNIT_PATH="$SYSTEMD_USER_DIR/${SERVICE_NAME}.service"; mkdir -p "$SYSTEMD_USER_DIR"
if [ ! -f "$UNIT_PATH" ] || ! cmp -s "$SNAP/share/systemd/user/${SERVICE_NAME}.service" "$UNIT_PATH"; then
  cp "$SNAP/share/systemd/user/${SERVICE_NAME}.service" "$UNIT_PATH"
  systemctl --user daemon-reload 2>/dev/null || true
fi
if ! systemctl --user is-enabled "$SERVICE_NAME" >/dev/null 2>&1; then
  systemctl --user enable --now "$SERVICE_NAME" 2>/dev/null || true
fi

Unit: ExecStart=/snap/bin/<name>.daemon …, Restart=always|on-failure, Environment=HOME=%h, WantedBy=default.target. openclaw uses unit name <name>-gateway (with legacy-migration from <name>); the lighter snaps still use bare <name>. Standardizing all on <name>-gateway needs the same migration dance.

Workflows (3 files, keep consistent across repos)

  • pr.yaml — on PR touching snap/**: set up snapd, install snapcraft 8.x/stable, snapcraft pack, snap install --dangerous --classic, then smoke-test each app (<name> --version|--help, <name>.daemon --help, <name>.lemonade </dev/null). Two snapd-setup styles are in use — hand-rolled apt+systemctl (openclaw) vs. snapcore/prepare-env-gh-action (nullclaw); pick one and unify.
  • release.yaml — on GitHub release published: build, snapcraft login --credentials-standard from secrets.SNAPCRAFT_STORE_CREDENTIALS, snapcraft upload --release=candidate <name>_*.snap.
  • check-releases.yaml — cron + manual: compare packaged version to upstream latest; if newer, bump the version placeholder, commit, and create a GitHub release (which triggers release.yaml). Closes the auto-packaging loop.

Verifying without a full build

Snap builds need lxd and take many minutes. Before building you can still: node --check / sh -n every script; python3 -c 'import yaml,...' the YAMLs; run launcher/picker scripts directly with a faked SNAP/HOME and a real lemonade-server. Reserve snapcraft pack for final validation.

picoclaw exception

picoclaw is a CMDOP gRPC client library (strict confinement, no LLM provider, no CLI entry point yet) — it is NOT an LLM agent, so the lemonade tool does not apply to it. Don't force a .lemonade app there.

Repository
kenvandine/skills
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.