Design best practices

When crafting SDKs for Workshop, publishers face design decisions that affect how their SDKs install, integrate, and work inside workshops. Understanding the best practices outlined below helps publishers create more maintainable, reliable, and user-friendly SDKs that better align with Workshop’s architecture and ideology.

This explanation covers key design considerations and provides rationale for common patterns found in a number of SDKs available in the Workshop ecosystem.

System services

System services within SDKs should be designed to integrate smoothly with the workshop’s lifecycle and other SDK components.

Consider the approach used by the ollama SDK: it implements a setup-project hook that configures and starts the systemd service by including a service file:

ollama/sdkcraft.yaml
parts:
  user-service:
    plugin: dump
    source: ollama.service
    source-type: file

The file provides appropriate service configuration:

ollama/ollama.service
[Unit]
Description=Ollama Service
After=network.target

[Service]
ExecStart=/bin/bash -lc "ollama serve"
Restart=always
RestartSec=3

[Install]
WantedBy=default.target

And gets installed during the setup-project phase:

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

systemctl --user daemon-reload
systemctl --user enable --now ollama

This design ensures that the service starts automatically when the workshop is launched, and stops cleanly when the workshop is terminated.

Parts decomposition

The parts mechanism, shared by Workshop with projects such as Snapcraft, enables modularity by separating different aspects of an SDK into discrete, manageable components. Effective decomposition strategies depend on the SDK’s complexity and the independence of its components.

Consider the go SDK, which uses a single part because the Go toolchain can be distributed as a cohesive unit:

go/sdkcraft.yaml
parts:
  go:
    plugin: dump
    source: https://go.dev/dl/go$CRAFT_PROJECT_VERSION.linux-$CRAFT_ARCH_BUILD_FOR.tar.gz
    source-type: tar

In contrast, the ollama SDK is built with multiple parts for the runtime and service configuration, allowing selective updates and reducing build times:

ollama/sdkcraft.yaml
parts:
  ollama:
    plugin: dump
    source: https://github.com/ollama/ollama/releases/download/v0.9.6/ollama-linux-amd64.tgz
    source-type: tar
  user-service:
    plugin: dump
    source: ollama.service
    source-type: file

Parts should be organized around functional boundaries:

Component Type

Description

Runtime components

Core binaries and libraries that change infrequently

Configuration

Settings and templates that may need customization

Data assets

Large files like models or datasets that update independently

Tools

Auxiliary utilities that complement the main functionality

However, parts are not mandatory: the minimal viable option is to forgo them entirely and install everything in the hooks.

Interface layout

Interfaces define how SDKs interact with the host system and other SDKs. The layout of interfaces ultimately impacts an SDK’s usability and security. Publishers should select interfaces based on the resources their SDK requires (via plugs) or exposes (via slots).

In particular, the mount interface plugs are frequently used because they specifically address data persistence and sharing needs. The uv SDK demonstrates this by mounting /home/workshop/.cache/uv to preserve package caches across workshop life-cycle, improving performance for workshop refresh:

uv/sdkcraft.yaml
plugs:
  cache:
    interface: mount
    workshop-target: /home/workshop/.cache/uv

This configuration means that the /home/workshop/.cache/uv/ directory inside the workshop maps to a persistent storage location on the host system. This setup allows the uv SDK to retain its cache between refreshes.

Rather than a plug, a slot provides resources to the workshop; for instance, the ollama SDK uses the tunnel interface slot to expose its server functionality on a specific port, enabling external access to its services:

dotnet10/sdkcraft.yaml
slots:
  ollama-server:
    interface: tunnel
    endpoint: 11434

The most obvious interface choices are as follows:

  • Use mount for persistent data and caches

  • Use gpu when GPU acceleration is required

  • Use tunnel for network services that need to be accessible externally

  • Use ssh for authentication with remote services

For a complete list, see Interfaces; for a discussion of interface capabilities, see Interface concepts.

Environment variables

Environment variables provide a clean way to configure SDK behavior and integrate with workshops. SDKs should use standard POSIX-compatible shell mechanisms to add variables to the workshop.

For system-wide variables that affect all users, SDKs should place configuration files in /etc/profile.d/. The uv SDK demonstrates this approach by setting system-wide PATH modifications in its setup-base hook:

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

For user-specific variables that only make sense for the workshop user, SDKs should modify ~/.profile. Again, the uv SDK illustrates this pattern by setting UV_LINK_MODE=copy in its setup-project hook to address interaction between SDK behavior and workshop architecture:

uv/hooks/setup-project
cat <<EOF >> ~/.profile

# SDK uses 'mount' interface to preserve
# uv cache across refreshes, thus, hardlinking is
# not available on 'uv sync'.
export UV_LINK_MODE=copy
EOF

Publishers should avoid shell-specific configuration files, such as ~/.bash_profile or ~/.bashrc, because Workshop supports multiple shell interpreters and these files may not be sourced consistently across different shell sessions.

Some guidelines for environment variables:

  • Use clear names that indicate origin and purpose; prefix them with the SDK name to avoid conflicts

  • Include comments explaining why specific values are chosen

  • Choose between /etc/profile.d/ for system-wide settings and ~/.profile for user-specific configuration

Parts or hooks?

The decision between shipping prebuilt content and, alternatively, installing it dynamically at runtime through hooks affects SDK size, startup time, and flexibility. Different content types have different optimal strategies.

For instance, Debian packages are best installed in hooks, particularly in setup-base, because they integrate with the system package manager and can leverage apt’s local cache. Installing packages during SDK build would bypass distribution security updates and create larger SDK artifacts.

The ros2 SDK exemplifies this approach:

ros2/hooks/setup-base
apt-get update
apt-get install ros-dev-tools
apt-get install python3-colcon-argcomplete python3-colcon-alias python3-colcon-clean python3-colcon-mixin
# ...

In general, binary artifacts are best shipped as parts when you need to:

  • Pin specific versions regardless of what’s available in package repositories,

  • Distribute custom builds with specialized compilation flags,

  • Provide tools that aren’t available through the system package manager.

This approach ensures consistent environments and avoids eventual dependency conflicts.

The uv SDK shows this approach by shipping prebuilt Rust binaries:

uv/sdkcraft.yaml
parts:
  uv:
    plugin: rust
    source: https://github.com/astral-sh/uv
    source-tag: $CRAFT_PROJECT_VERSION
    source-type: git
    organize:
      uv: bin/uv
      uvx: bin/uvx
    prime:
      - bin/uv
      - bin/uvx

setup-base or setup-project?

The choice between setup-base and setup-project hooks fundamentally affects when and how SDK initialization occurs. This decision impacts performance, caching behavior, and the SDK’s presence in workshop snapshots.

First of all, note that both setup-base and setup-project should configure the workshop for running, but normally don’t directly control service startup or other runtime behavior. For instance, they can configure container shutdown or startup, but they shouldn’t start services directly unless there’s a specific reason to do so.

The setup-base hook runs once when the SDK is installed at launch or refresh time, making it ideal for system-wide configuration that doesn’t change between projects. Operations in setup-base become part of workshop snapshots, improving startup performance at subsequent refreshes.

For instance, the uv SDK uses setup-base for system-wide configuration that includes shell completion, PATH updates, and system package manager integration:

uv/hooks/setup-base
sudo -u workshop mkdir -p /home/workshop/uv-venv

cat <<EOF >> /etc/profile.d/uv.sh
PATH="$SDK/bin:\$PATH"
EOF

"$SDK"/bin/uv generate-shell-completion bash > /etc/bash_completion.d/uv.sh
"$SDK"/bin/uvx --generate-shell-completion bash > /etc/bash_completion.d/uvx.sh

mkdir -p /usr/local/libexec/alternatives

cat << 'EOF' > /usr/local/libexec/alternatives/uv-pip
#!/bin/bash
exec uv pip "$@"
EOF

chmod +x /usr/local/libexec/alternatives/uv-pip
update-alternatives --install /usr/bin/pip pip /usr/local/libexec/alternatives/uv-pip 50

The setup-project hook runs as the workshop user after setup-base, when interfaces are connected and the workshop is fully operational. This makes it suitable for project-specific initialization that might vary depending on the actual project files.

For instance, the comfy SDK uses setup-project to detect the available GPU type, configuring the appropriate PyTorch variant accordingly:

comfy/hooks/setup-project
GPU_TYPE="none"

if command -v lspci >/dev/null 2>&1; then
    if lspci | grep -i 'NVIDIA' >/dev/null 2>&1; then
        GPU_TYPE="nvidia"
    elif lspci | grep -i 'AMD/ATI' >/dev/null 2>&1; then
        GPU_TYPE="amd"
    elif lspci | grep -i 'Intel.*Graphics' >/dev/null 2>&1; then
        GPU_TYPE="intel"
    fi
fi

echo "Detected GPU: $GPU_TYPE"

The need to use setup-project for this purpose arises from the fact that the GPU is accessed via an auto-connected interface, so its availability can only be determined after the workshop has launched and interfaces are connected. However, the choice of packages to install depends on the GPU type, necessitating dynamic configuration at project setup time:

comfy/hooks/setup-project
case "$GPU_TYPE" in
    nvidia)
        pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu129
        ;;
    amd)
        pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.4
        ;;
    *)
        pip install torch torchvision torchaudio
        ;;
esac

In general, you use setup-base for:

  • System package installation

  • Global environment configuration

  • One-time setup operations

  • Content that should be part of snapshots (e.g., infrequently updated or unlikely to change)

Choose setup-project for:

  • Project-specific configuration that depends on the project context

  • Operations requiring auto-connected interfaces

  • Content that shouldn’t be part of snapshots (e.g., frequently updated or extra large)

Health checks

Health check scripts provide essential feedback about SDK operational status and help users diagnose problems quickly. Well-designed health checks go beyond simple binary success or failure, reporting extra details to provide actionable diagnostic information.

The ollama SDK demonstrates comprehensive health checking by testing actual service functionality and channeling its error output:

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

When the workshop is launched with --wait-on-error, the workshop info output will contain these details.

In general, health checks should:

  • Test each of the relevant features, not just the sheer fact of installation

  • Provide specific error codes for different failure modes

  • Include helpful error messages that guide troubleshooting with workshop changes

  • Run quickly to avoid slowing workshop operations down

  • Handle edge cases gracefully

SDK dependencies

The mount interface enables sophisticated collaboration patterns between SDKs within a workshop while avoiding explicit dependency management. Rather than having each SDK prepare and maintain its own resources, SDKs can expose capabilities they provide through slots and consume them through plugs, creating efficient resource utilization patterns.

Consider Python-based SDKs that need to install packages from PyPI. Instead of each SDK maintaining its own virtual environment, one SDK can provide a shared environment that others consume. The uv SDK demonstrates this by exposing a virtual environment slot:

uv/sdkcraft.yaml
slots:
  venv:
    interface: mount
    workshop-source: /home/workshop/uv-venv

Here, workshop-source says that the resource is inside the workshop, rather than on the host.

Other Python-based SDKs can then connect to this shared environment through corresponding plugs. The jupyter SDK shows this pattern:

jupyter/sdkcraft.yaml
plugs:
  venv:
   interface: mount
   workshop-target: $SDK/venv

Workshop users wire the two together through a connections: block in their workshop definition; when no compatible slot is available, the consuming SDK falls back to the host directory that Workshop automatically provides for the plug, so a Python-based SDK still works on its own. For the details of how this works, see How to manage Python environments with the uv SDK.

This pattern extends beyond Python or its virtual environments to encompass various shared resources, including common libraries and runtime environments, shared data directories and caches, and development tools and utilities. It offers several advantages:

  • Eliminates duplication of large tool chains or environments

  • Maintains separation between SDK responsibilities

  • Allows workshop users to mix and match compatible SDKs

  • Avoids the complexity of dependency management with a fallback mechanism

Both examples above assume SDK publishers ship the required plugs and slots. When they don’t, the workshop user can graft the missing elements in the workshop definition, extending an SDK’s capabilities without the publisher’s involvement. This makes plug and slot management a shared effort: SDK authors define the standard capabilities, and users augment them to fit their project’s needs.

See also

Explanation:

Reference: