Skip to content

Writing Skills for Sympozium

This guide explains how to create a SkillPack — from simple Markdown instruction bundles to full sidecar containers with auto-provisioned RBAC.


Concepts

A SkillPack is a Kubernetes CRD that bundles one or more skills. When toggled on an Agent, the skills are mounted into every AgentRun pod for that instance.

There are three layers to a skill, each optional beyond the first:

Layer What it does When you need it
Skills (Markdown) Instructions the agent reads at /skills/ Always — this is the core of every SkillPack
Sidecar (Container) Runtime tools injected as a pod sidecar When the skill needs binaries like kubectl, helm, terraform
Native tools (optional) Sidecar operations exposed to the model as typed, function-calling tools When you want reliable invocation (esp. for small/non-Anthropic models) instead of hand-built shell strings
RBAC (Roles) Kubernetes permissions auto-provisioned per run When the sidecar needs to talk to the Kubernetes API
Host access (optional) Explicit host namespace and hostPath mounts When the sidecar must inspect node-local host files/devices
┌─────────────────────────────────────────────────────────┐
│  SkillPack CRD                                          │
│                                                         │
│  spec.skills[]         → ConfigMap → mounted at /skills │
│  spec.sidecar.image    → Container injected into pod    │
│  spec.sidecar.rbac[]   → Role + RoleBinding (per run)   │
│  spec.sidecar.clusterRBAC[] → ClusterRole (per run)     │
└─────────────────────────────────────────────────────────┘

Step 1: Write the Skills (Markdown)

Every skill is a Markdown document that tells the agent how to perform a task. The agent reads these as files at runtime.

apiVersion: sympozium.ai/v1alpha1
kind: SkillPack
metadata:
  name: my-skill
spec:
  category: devops        # grouping in the TUI (kubernetes, security, devops, etc.)
  version: "0.1.0"
  source: custom           # builtin, imported, or custom
  skills:
    - name: deploy-check
      description: Verify a Kubernetes deployment is healthy
      content: |
        # Deployment Health Check

        When asked to check a deployment, run these steps:

        ## 1. Get rollout status
        ```
        kubectl rollout status deployment/<name> -n <namespace>
        ```

        ## 2. Check pod health
        ```
        kubectl get pods -l app=<name> -n <namespace>
        ```

        ## 3. Inspect events
        ```
        kubectl get events -n <namespace> --sort-by=.lastTimestamp | tail -10
        ```

        Report the status as a table with columns: Pod, Status, Restarts, Age.
      requires:
        bins:
          - kubectl        # documents which binaries the skill expects
        tools:
          - bash           # documents which agent tools are needed

Tips for good skill content

  • Be prescriptive — give the agent exact commands to run, not vague instructions.
  • Use Markdown headings — the agent parses structure. ## Steps is better than a wall of text.
  • Include output formats — tell the agent how to present results (tables, summaries, etc.).
  • Specify error handling — what should the agent do if a command fails?
  • List requires — even though it's informational, it documents what the sidecar must provide.

Applying the basic SkillPack

If your skill only needs Markdown (no tools), you're done:

kubectl apply -f config/skills/my-skill.yaml

The SkillPack controller creates a ConfigMap (skillpack-my-skill) containing your skill content. When an agent pod runs, the ConfigMap is projected into /skills/.


Step 2: Build a Sidecar Image (optional)

If your skill references binaries (kubectl, helm, terraform, etc.), you need a sidecar container that provides them. The agent can then exec into the sidecar or use the shared /workspace volume.

Dockerfile

Create a Dockerfile at images/skill-<name>/Dockerfile:

# images/skill-my-tool/Dockerfile

# Multi-stage: grab the binary you need
FROM soldevelo/kubectl:1.36 AS kubectl

# Minimal base image
FROM alpine:3.20

# Install supporting tools
RUN apk add --no-cache \
    bash \
    curl \
    jq \
    && adduser -D -u 1000 agent

# Copy the binary from the builder stage
COPY --from=kubectl /opt/bitnami/kubectl/bin/kubectl /usr/local/bin/kubectl

# Run as non-root (must match the pod's runAsUser: 1000)
USER 1000
WORKDIR /workspace

# Default: sleep forever so the sidecar stays alive for the agent run
CMD ["sleep", "infinity"]

Key requirements

Requirement Why
USER 1000 Agent pods run as UID 1000 with runAsNonRoot: true. Your sidecar must match.
CMD ["sleep", "infinity"] The sidecar runs alongside the agent. It must stay alive for the duration of the run.
Minimal image Keep the image small. Use multi-stage builds to copy only the binaries you need.
No secrets baked in Use env in the sidecar spec or Kubernetes Secrets — never bake credentials into images.

Build and push

docker build -t ghcr.io/yourorg/skill-my-tool:latest images/skill-my-tool/
docker push ghcr.io/yourorg/skill-my-tool:latest

Registering a built-in skill image in CI

If the skill is bundled with Sympozium (i.e. lives under images/ and config/skills/), you must add it to the build pipeline so it is built and pushed automatically:

  1. Makefile — append skill-<name> to the IMAGES variable.
  2. .github/workflows/build.yaml — add skill-<name> to the image matrix.
  3. .github/workflows/release.yaml — add skill-<name> to the image matrix.

For example, to add a new skill-my-tool:

# Makefile
IMAGES = controller apiserver ... skill-k8s-ops skill-my-tool
# .github/workflows/build.yaml & release.yaml
matrix:
  image:
    - ...
    - skill-k8s-ops
    - skill-my-tool

This ensures make docker-build / make docker-push and CI all build the sidecar image alongside the other Sympozium components.


Step 3: Add the Sidecar to the SkillPack

Add a sidecar block to your SkillPack spec:

spec:
  skills:
    - name: deploy-check
      # ... (Markdown content as above)

  sidecar:
    # Required: the container image
    image: ghcr.io/yourorg/skill-my-tool:latest

    # Optional: override the entrypoint (default: ["sleep", "infinity"])
    command: ["sleep", "infinity"]

    # Optional: environment variables
    env:
      - name: KUBECONFIG
        value: /workspace/.kube/config

    # Optional: mount /workspace into the sidecar (default: true)
    mountWorkspace: true

    # Optional: resource requests/limits
    resources:
      cpu: "100m"
      memory: "128Mi"

When the AgentRun controller sees this SkillPack in the run's skills list, it injects the sidecar as an additional container named skill-<skillpack-name>.


Step 3b: Expose Native Tools (optional)

By default your sidecar is reached through execute_command, where the model has to construct the full shell string itself. If your sidecar exposes a CLI with discrete operations, you can instead surface each operation to the model as a typed, function-calling tool — declared right here on the SkillPack, with no extra sidecar code. Smaller and non-Anthropic models are far more reliable filling in structured arguments than quoting a shell command.

Declare tools under spec.sidecar.tools[]:

  sidecar:
    image: ghcr.io/yourorg/service-discovery:latest
    mountWorkspace: true
    tools:
      - name: sd_evaluate_changes          # snake_case, unique across all tools
        description: "Evaluate proposed changes for a service against the catalog."
        exec: ["node", "/app/dist/cli.js"] # operator-declared executable (argv prefix)
        subcommand: evaluate-changes        # appended after exec
        inputMode: stdin                    # "args" (default) or "stdin"
        positionalArgs: ["serviceIdentifier"]
        parameters:                         # JSON Schema handed to the model
          type: object
          properties:
            serviceIdentifier: { type: string }
            catalog: { type: object }
          required: [serviceIdentifier]

The model then calls sd_evaluate_changes({serviceIdentifier: "web", catalog: {...}}) and the runtime runs node /app/dist/cli.js evaluate-changes web with {"catalog": {...}} piped on stdin — against this SkillPack's own sidecar.

What this means for you as a SkillPack author

  • You stay in control of what runs. The exec binary is declared here on the CRD, not chosen by the model. The model only supplies arguments. The controller serializes your tool definitions into a read-only, immutable ConfigMap mounted into the agent, so a running agent can't forge or alter them.
  • It grants no extra authority. Dispatch uses the same gated exec IPC as execute_command, and arguments are passed in argv mode (no shell) — a value like "; rm -rf /" is delivered as one literal argument, never interpreted.
  • Two caveats when authoring. A positional value beginning with - is still handed to your binary, so design your CLI to treat positionals as operands (honor --, or validate values). And native tools need the argv-aware tool-executor.sh — rebuild older sidecar images against the current executor before declaring tools.
  • Admission-validated. The webhook rejects malformed/colliding names, duplicate names across SkillPacks, and positionalArgs that don't map to a required declared parameter — so mistakes surface at kubectl apply, not at runtime.

Keeping tool definitions and the sidecar in sync

Because the project's security model keeps tool definitions operator-authored on the SkillPack (rather than letting the agent auto-register whatever the sidecar advertises), you own the parity between the tools declared here and the operations your sidecar actually implements. This is a deliberate trade-off — it closes the forge-your-own-tools hole at the cost of a place where drift can creep in. Treat the SkillPack as the contract: when you ship a sidecar build that adds, renames, or changes an operation, update sidecar.tools[] in the same change. A good pattern is a CI/CD step at the end of your sidecar build that patches the SkillPack manifest (via kubectl patch or your IaC of choice) so the two never diverge. Automated reconciliation is being explored — see #226.

See Native Sidecar Tools for the full field reference, the trust model, and the matching tool-executor.sh.


Step 4: Define RBAC (optional)

If the sidecar needs to talk to the Kubernetes API (e.g. kubectl get pods), declare RBAC rules. The controller automatically creates and cleans up these resources per AgentRun.

Namespace-scoped RBAC (rbac)

Creates a Role + RoleBinding in the AgentRun's namespace, bound to the sympozium-agent ServiceAccount:

  sidecar:
    image: ghcr.io/yourorg/skill-my-tool:latest
    rbac:
      # Read pods, services, and deployments
      - apiGroups: [""]
        resources: ["pods", "pods/log", "services"]
        verbs: ["get", "list", "watch"]
      - apiGroups: ["apps"]
        resources: ["deployments", "statefulsets"]
        verbs: ["get", "list", "watch", "update", "patch"]

Cluster-scoped RBAC (clusterRBAC)

Creates a ClusterRole + ClusterRoleBinding for resources that span namespaces:

  sidecar:
    clusterRBAC:
      # Read-only access to nodes and namespaces
      - apiGroups: [""]
        resources: ["nodes", "namespaces"]
        verbs: ["get", "list", "watch"]

Security model

Aspect How it works
Scoping Namespace RBAC is scoped to the run's namespace. Cluster RBAC is cluster-wide but typically read-only.
Lifecycle Namespace-scoped Roles and RoleBindings have an ownerReference to the AgentRun — Kubernetes garbage-collects them automatically. Cluster-scoped resources are cleaned up by the controller on AgentRun deletion.
Labelling All RBAC resources are labelled with sympozium.ai/agent-run, sympozium.ai/skill, and sympozium.ai/managed-by: sympozium for auditing.
Least privilege Each SkillPack declares exactly the permissions it needs. There is no shared god-role — each skill gets its own scoped RBAC.
Ephemeral RBAC exists only while the AgentRun exists. When the run finishes (or is deleted), permissions are revoked.

RBAC naming convention

Role/ClusterRole:           sympozium-skill-<skillpack>-<agentrun>
RoleBinding/ClusterRoleBinding: sympozium-skill-<skillpack>-<agentrun>

Step 5: Configure host access (optional)

If your sidecar must inspect host-local files/devices (for example hardware probes), use sidecar.hostAccess.

spec:
  sidecar:
    image: ghcr.io/yourorg/skill-my-tool:latest
    hostAccess:
      enabled: true
      hostPID: true
      runAsRoot: true
      mounts:
        - hostPath: /proc
          mountPath: /host/proc
          readOnly: true
        - hostPath: /sys
          mountPath: /host/sys
          readOnly: true

Host-access behavior

  • enabled gates all host access behavior (default off).
  • hostPID and hostNetwork are pod-level and applied if any enabled sidecar requests them.
  • runAsRoot and privileged are sidecar-level settings.
  • mounts creates hostPath volumes and mounts them only into that sidecar.

Use this sparingly and prefer read-only mounts whenever possible.


Step 6: Toggle the Skill

Via the TUI

  1. Press s on an Agent to drill into the Skills view.
  2. Use Space or Enter to toggle the skill on/off.
  3. The next AgentRun will include the sidecar and RBAC.

Via kubectl

kubectl patch agent <name> --type=merge \
  -p '{"spec":{"skills":[{"skillPackRef":"my-skill"}]}}'

Via the /skills command

/skills <instance-name>

Complete Example: k8s-ops

The built-in k8s-ops skill is the reference implementation. Here's how all three layers come together:

File layout

config/skills/k8s-ops.yaml      # SkillPack CRD (skills + sidecar + RBAC)
images/skill-k8s-ops/Dockerfile  # Sidecar container image

SkillPack YAML (abbreviated)

apiVersion: sympozium.ai/v1alpha1
kind: SkillPack
metadata:
  name: k8s-ops
spec:
  category: kubernetes
  version: "0.1.0"
  source: builtin
  skills:
    - name: cluster-overview
      description: Inspect cluster state and summarise health
      content: |
        # Cluster Overview
        1. `kubectl get nodes -o wide`
        2. `kubectl get pods -A --field-selector=status.phase!=Running`
        ...
      requires:
        bins: [kubectl]
    - name: pod-troubleshoot
      description: Diagnose and fix pod issues
      content: |
        # Pod Troubleshooting
        ...
    - name: resource-management
      description: Scale, update, and manage resources
      content: |
        # Resource Management
        ...
  sidecar:
    image: ghcr.io/sympozium-ai/sympozium/skill-k8s-ops:latest
    command: ["sleep", "infinity"]
    mountWorkspace: true
    resources:
      cpu: "100m"
      memory: "128Mi"
    rbac:
      - apiGroups: [""]
        resources: ["pods", "pods/log", "services", "configmaps", "events"]
        verbs: ["get", "list", "watch"]
      - apiGroups: ["apps"]
        resources: ["deployments", "statefulsets", "replicasets"]
        verbs: ["get", "list", "watch", "update", "patch"]
    clusterRBAC:
      - apiGroups: [""]
        resources: ["nodes", "namespaces"]
        verbs: ["get", "list", "watch"]

What happens at runtime

1. User toggles k8s-ops on instance "alice"
   → Agent.spec.skills = [{skillPackRef: "k8s-ops"}]

2. AgentRun created for instance "alice"
   → Controller resolves SkillPack "k8s-ops"
   → Finds sidecar spec and RBAC rules

3. Controller creates:
   → Role "sympozium-skill-k8s-ops-alice-run-xyz" (namespace)
   → RoleBinding "sympozium-skill-k8s-ops-alice-run-xyz"
   → ClusterRole "sympozium-skill-k8s-ops-alice-run-xyz"
   → ClusterRoleBinding "sympozium-skill-k8s-ops-alice-run-xyz"

4. Job pod created with containers:
   → agent (agent-runner image, reads /skills/)
   → ipc-bridge (NATS forwarder)
   → skill-k8s-ops (kubectl + bash + curl + jq)

5. Agent reads skill Markdown, runs kubectl commands via sidecar.

6. Run completes → Job cleaned up → RBAC garbage-collected.

Troubleshooting

Issue Check
Skill content not appearing kubectl get configmap skillpack-<name> — does it exist?
Sidecar not injected Does the SkillPack have spec.sidecar.image? Is the skill toggled on the instance?
Permission denied in sidecar Check RBAC: kubectl get role,rolebinding -l sympozium.ai/skill=<name>
Sidecar crash Check pod logs: kubectl logs <pod> -c skill-<name>
Image pull error Verify the sidecar image exists and is accessible from the cluster
UID mismatch Sidecar must run as UID 1000 (same as the pod's securityContext.runAsUser)

Quick Reference

# Minimal SkillPack (Markdown only)
apiVersion: sympozium.ai/v1alpha1
kind: SkillPack
metadata:
  name: my-skill
spec:
  category: devops
  skills:
    - name: my-task
      description: Do the thing
      content: |
        # Instructions
        Run `echo hello`.

---

# Full SkillPack (Markdown + Sidecar + RBAC)
apiVersion: sympozium.ai/v1alpha1
kind: SkillPack
metadata:
  name: my-full-skill
spec:
  category: kubernetes
  version: "1.0.0"
  source: custom
  skills:
    - name: my-task
      description: Do the thing
      content: |
        # Instructions
        ...
      requires:
        bins: [kubectl]
  sidecar:
    image: my-registry/my-sidecar:latest
    mountWorkspace: true
    resources:
      cpu: "100m"
      memory: "128Mi"
    tools:                       # optional: typed function-calling tools (see Step 3b)
      - name: my_operation
        description: "Run my operation against the catalog."
        exec: ["my-cli"]
        subcommand: run
        parameters:
          type: object
          properties:
            target: { type: string }
          required: [target]
    rbac:
      - apiGroups: [""]
        resources: ["pods"]
        verbs: ["get", "list"]
    clusterRBAC:
      - apiGroups: [""]
        resources: ["nodes"]
        verbs: ["get", "list", "watch"]