How to build an SDK

A standalone SDK is a SDKcraft project with its own sdkcraft.yaml, its own hooks/ tree, and the parts and interfaces that describe what it ships and how it integrates with workshops. Build one by laying out the project, declaring parts and interfaces, authoring the lifecycle hooks, and exercising the result locally before any thought of publishing.

Prerequisites

Before starting, ensure the following are in place:

  • SDKcraft is installed.

  • LXD 6.6 or later is running on the host.

  • Workshop is installed and configured so that you can launch a workshop to try the SDK against.

Start from the template

New SDKs start from canonical/template-sdk, a GitHub-template repository that ships the project skeleton:

  • a sdkcraft.yaml ready to be filled in,

  • a hooks/ tree with stubs for the lifecycle hooks,

  • a VERSION file pinning the upstream release the SDK wraps,

  • a renovate.json that tracks upstream releases on a long-lived version branch and opens PRs to bump VERSION as they ship,

  • CI workflows under .github/workflows/ that build on pull requests and upload to the SDK Store on push to the version branch,

  • a README template aligned to the rest of the project shape.

Use it via GitHub’s “Use this template” button, or git clone if you don’t host on GitHub. The choice that follows is how to fill the template in.

With the sdk-designer skill

template-sdk also ships an agentic skill named sdk-designer. The skill runs an interactive scaffolding conversation: it asks about the software to package, the target platforms, and which interfaces and hooks are needed, then writes the corresponding files into the template.

  1. Aim the agent at the new repository.

  2. Run /sdk-designer and answer the prompts.

  3. Review the generated files and adjust where the skill’s defaults don’t match your case.

By editing the template directly

Without the agentic skill, edit the template files in place. Fill sdkcraft.yaml per Fill in the metadata, replace each hook stub under hooks/ per Author the hooks, update VERSION to the upstream release you intend to ship first, and adjust renovate.json if the upstream project lives somewhere other than the GitHub release page the default config targets.

Fill in the metadata

SDKcraft needs four pieces of metadata to identify and build the SDK: name, version, a one-line summary, and the platforms to build for. Add license to declare the SDK’s licensing terms, and description for a multi-line write-up:

sdkcraft.yaml
name: <NAME>
version: "<VERSION>"
summary: One-line description of the SDK
description: |
  A longer description that explains
  what the SDK packages
  and any noteworthy behavior.
license: MIT
platforms:
  ubuntu@22.04:amd64:
  ubuntu@24.04:amd64:

Use the SDK’s upstream version for version when the SDK wraps a single tool; keep it quoted so that values like 1.0 aren’t parsed as floats.

SDKcraft builds one artifact per entry in platforms. Each entry pairs an Ubuntu base with a CPU architecture from the Debian naming scheme (amd64, arm64, and so on). For SDKs that don’t ship compiled binaries, use all instead of a specific architecture.

Define parts

Parts describe how SDKcraft obtains the SDK’s payload at build time. A small SDK often gets by with a single part; larger SDKs split work along functional boundaries.

For a binary downloaded from a release page, use the dump plugin with a tarball source:

sdkcraft.yaml
parts:
  <NAME>:
    plugin: dump
    source: https://example.com/releases/v${CRAFT_PROJECT_VERSION}/<NAME>-linux-${CRAFT_ARCH_BUILD_FOR}.tar.gz
    source-type: tar

$CRAFT_PROJECT_VERSION and $CRAFT_ARCH_BUILD_FOR expand at build time from the version field and the platform SDKcraft is currently building for.

If the SDK ships supporting files, add them as separate parts with the file source type:

sdkcraft.yaml
parts:
  <NAME>:
    plugin: dump
    source: https://example.com/releases/v${CRAFT_PROJECT_VERSION}/<NAME>-linux-${CRAFT_ARCH_BUILD_FOR}.tar.gz
    source-type: tar
  service-unit:
    plugin: dump
    source: <NAME>.service
    source-type: file

For source-built SDKs, the rust, go, and python plugins take over from dump. The Craft Parts plugin reference lists every plugin and its options.

Declare plugs and slots

Plugs and slots wire the SDK to host resources and to other SDKs in the workshop. A plug requests access to something the workshop provides; a slot offers something the SDK exposes.

The most common patterns are:

  • A mount plug for cache or model directories that should survive workshop refresh.

  • A gpu plug for SDKs that need GPU acceleration.

  • A tunnel slot for services that expose a network endpoint.

For example, an SDK that runs a long-lived HTTP service and caches data under ~/.cache/<NAME>/ declares both a plug and a slot:

sdkcraft.yaml
plugs:
  cache:
    interface: mount
    workshop-target: /home/workshop/.cache/<NAME>

slots:
  api:
    interface: tunnel
    endpoint: 8080

The workshop-target value is the in-workshop path that Workshop backs with persistent host storage; SDKs can’t pick the host path directly, which prevents them from reaching arbitrary host files. Workshop users can override the host side at run time with workshop remount.

Author the hooks

Hooks are the run-time logic of an SDK. Workshop runs them at specific lifecycle stages of the workshop they’re installed in. All hooks are shell scripts in hooks/<HOOK-NAME> and are linted with ShellCheck when the SDK is packed.

SDK is available inside every hook. It points to the SDK’s installation root inside the workshop; use it to reference files the SDK ships, for example "$SDK/bin/<NAME>".

SDK_STATE_DIR is available only inside save-state and restore-state. It points to a temporary directory Workshop creates for one workshop refresh cycle: save-state writes to it before the old workshop is destroyed, and restore-state reads from it once the new workshop is up. The directory is gone as soon as the workshop stops, so don’t use it for long-lived data; back that with a mount plug or store it in the project directory instead.

Workshop recognizes five hook names: setup-base, setup-project, check-health, save-state, and restore-state. A useful SDK rarely needs all five.

setup-base

setup-base runs as root when the SDK is first installed in a workshop and on every workshop refresh. It is the place for system-wide configuration: installing apt packages, wiring PATH, and laying down service unit files.

A minimal setup-base for a single-binary SDK adds the SDK’s bin/ directory to the system PATH:

hooks/setup-base
cat <<EOF > /etc/profile.d/<NAME>.sh
export PATH="$SDK/bin:\$PATH"
EOF

Place system-wide environment variables under /etc/profile.d/ so they apply across shells. Avoid editing /etc/bash.bashrc directly; Workshop may support more than one shell and /etc/profile.d/ is the portable seam.

Inside setup-base, apt is preconfigured to skip recommended and suggested packages and to answer “yes” to confirmation prompts, so apt-get install calls can be terse:

hooks/setup-base
apt-get update
apt-get install build-essential cmake ninja-build

Operations performed in setup-base become part of the workshop’s base snapshot, so subsequent refreshes start from a warmed-up state.

setup-project

setup-project runs as the workshop user after setup-base, once the project directory is mounted and interface plugs and slots are connected. This is the right place for per-user configuration: activating virtual environments, enabling user systemd services, and writing files under /home/workshop/.

A typical setup-project for an SDK that ships a service unit installs and starts the service as a user-level systemd unit:

hooks/setup-project
install -D --mode=644 --target-directory ~/.config/systemd/user \
    "$SDK/<NAME>.service"

systemctl --user daemon-reload
systemctl --user enable --now <NAME>

User-level systemd services are preferred over root-level ones because they cleanly tie their lifetime to the workshop user’s session and don’t require sudo.

Operations in setup-project don’t go into the base snapshot, so use it for anything that depends on project-specific state or that should be re-evaluated on every launch.

check-health

check-health runs as root once every other hook has finished: on workshop launch, after setup-project has run for every SDK in the workshop; on workshop refresh, after restore-state has run for every SDK. Workshop also re-runs check-health on demand when it reassesses the workshop’s state. Use it to verify the SDK is functional and to report status back through workshopctl set-health.

The canonical pattern is to exercise a real entry point and channel any error output back to the user:

hooks/check-health
if ! output=$(sudo -u workshop --login <NAME> --version 2>&1); then
  workshopctl set-health error "$output"
  exit
fi
workshopctl set-health okay

Run the command as sudo -u workshop --login so it picks up the same environment that a workshop user would see interactively; this catches PATH wiring bugs in setup-base that would otherwise stay hidden.

Three health states are meaningful:

  • okay: The SDK is functional.

  • error: Something is wrong. Supply a message that helps a user understand what failed.

  • waiting: The hook should be retried. Workshop retries up to ten times, once per second. If the SDK never reaches okay or error, the health flips to error after those retries are exhausted.

save-state and restore-state

save-state and restore-state are an optional pair that only runs at workshop refresh. save-state runs in the old SDK revision, before Workshop destroys the old workshop. restore-state runs in the new SDK revision, after setup-project has finished for every SDK. Their job is to carry data across the refresh boundary in SDK_STATE_DIR.

Because restore-state runs after setup-project, restored files aren’t yet present while setup-project is still executing; keep any setup that depends on restored state inside restore-state itself, or have check-health retry by reporting waiting until the state shows up.

Use them when the SDK keeps configuration or transient data that doesn’t already live in a mount plug or a project file. Both hooks run as root, so reference the workshop user’s home explicitly rather than relying on ~:

hooks/save-state
if [ -d /home/workshop/.config/<NAME> ]; then
  cp -a /home/workshop/.config/<NAME> "$SDK_STATE_DIR/config"
fi
hooks/restore-state
if [ -d "$SDK_STATE_DIR/config" ]; then
  install -d -o workshop -g workshop /home/workshop/.config/<NAME>
  cp -fa "$SDK_STATE_DIR/config/." /home/workshop/.config/<NAME>/
  chown -R workshop:workshop /home/workshop/.config/<NAME>
fi

Skip these hooks entirely when:

  • The SDK has no state worth preserving, for example a stateless CLI tool.

  • The state already lives in a directory backed by a mount plug, which survives refreshes by definition.

  • The state is regenerated cheaply by setup-base or setup-project.

Warning

The SDK itself is refreshed as part of any workshop refresh. A bug in save-state or restore-state becomes a workshop-wide refresh failure, so test these hooks aggressively before relying on them.

Try the SDK

Once the definition and hooks are in place, build and install the SDK into a workshop with sdkcraft try:

$ sdkcraft try

SDKcraft packs the SDK for each declared platform into files of the form <NAME>_<ARCH>_<BASE>.sdk and copies them into the try area.

Add the SDK to a workshop definition using the try- prefix:

workshop.yaml
name: dev
base: ubuntu@24.04
sdks:
  - name: try-<NAME>

The base must match one of the SDK’s platforms. Then launch the workshop with verbose output and a wait-on-error breakpoint so that any hook failure leaves a usable container behind for inspection:

$ workshop launch --verbose --wait-on-error

Pay particular attention to:

  • Hook output in workshop changes and workshop tasks.

  • The SDK’s status in workshop info; a waiting or error state is the SDK telling you something is wrong.

  • The interaction between this SDK and any other SDKs it’s meant to be installed alongside.

On success, workshop info reports the SDK and a status of okay. On failure, workshop changes and workshop tasks point at the hook that failed; see How to debug issues in workshops for the full troubleshooting flow.

Test the SDK

If the SDK ships a tests/ directory with spread tests, run them against the freshly packed artifacts:

$ sdkcraft test

SDKcraft provisions a clean LXD container for each test, installs the packed SDK into a workshop, and runs the declared scenarios end-to-end.

Tests live under tests/, organised in suites declared by tests/spread.yaml. The starter test at tests/main/launch/ illustrates the layout; add more tests next to the starter, each in its own subdirectory of the same suite:

tests/main/smoke/task.yaml
summary: SDK installs and reports healthy
execute: |
  workshop launch --verbose --wait-on-error
  workshop info | grep -E 'status:\s+okay'

Iterate

Normally, you would use the workshop sketch-sdk command to iterate on an SDK locally. However, even when it doesn’t fit your purpose, the build-try-fix loop is fast:

  1. Edit the definition or a hook.

  2. Run sdkcraft clean && sdkcraft try to rebuild from a clean state.

  3. Run workshop refresh to reapply the SDK in the existing workshop, or workshop launch --verbose --wait-on-error for a fresh start.

sdkcraft clean is optional; omit it when the change is small enough that SDKcraft can incrementally rebuild. For build internals, see the Craft Parts lifecycle documentation.

Next steps

When the SDK behaves correctly under sdkcraft try and its test suite passes, proceed to How to publish an SDK to register the SDK name on the SDK Store and upload a revision.

See also

Explanation:

Reference:

Tutorial: