Authoring a skill

The anatomy of a skill: the files on disk, how its parameters come from the function signature, and how it behaves in sim and on hardware.

A skill is one robot action: pick up a pipette, dispense into a well, read a sensor. Skills are Python. They live in your project under skills/<skill_id>/. Workflows chain them together.

This page covers the anatomy and how the platform turns your function signature into parameters. For the geometry patterns that make skills reusable across objects, see Skill authoring patterns. For the screen you edit and run a single skill on, see The Skills Editor.

What a skill looks like on disk

The canonical layout is three files in a folder named for the skill:

skills/my_skill/
├── metadata.yaml      # Description, tags, conditions
├── modules.py         # Shared imports and helpers (optional)
└── robotic_code.py    # The function that does the thing

Only metadata.yaml and robotic_code.py are required. The catalog discovers skills by scanning for metadata.yaml, and the loader imports skills/<skill_id>/robotic_code.py. A skill with no modules.py loads fine.

In practice modules.py is close to standard. It is the conventional place to put the platform import and any helpers shared inside a skill. See Sharing code between skills below.

Creating a new skill

From anywhere inside your working tree:

zeon new skill my_skill

The CLI walks up from your current directory looking for a .zeon/ folder to find the project root. It checks for collisions across every file it is about to write before writing any of them, so a name clash aborts without leaving a half-created skill. It then prints the created paths and points you at skills/my_skill/robotic_code.py.

You can also create one from The Skills Editor. Names are normalised when a skill is created that way: the backend may lower-case the name and substitute underscores, and the canonical name that comes back is the one you get. The scaffolded function is already named to match it. Leave it that way.

⚠️

Use underscores, not hyphens. The name validator accepts [a-z_] followed by [a-z0-9_-], 1 to 64 characters, so my-skill is accepted at creation time. But skill_id is separately validated as ^[a-z0-9_]+$ when the catalog loads the skill, and a hyphenated id fails that check. A skill created with a hyphen in its name will scaffold successfully and then fail to load.

Anatomy: robotic_code.py

A fresh skill's robotic_code.py:

from execution.execution_functions import *


def my_skill():
    """Skill implementation for my_skill."""
    # TODO: Implement skill logic here.
    return {"success": True}

Two rules:

  1. The function name must equal the skill_id, which is to say the folder name. The catalog parses the file and looks for a def whose name matches the skill id exactly. Nothing else in the file is treated as the entry point. If no such function is found, the skill's parameters cannot be derived.
  2. Return a dict containing success. Any other keys you return are carried into the execution result and are visible to the workflow.

from execution.execution_functions import * brings in the runtime API: arm motion (move_arm, move_arm_js, move_relative), gripper control, world and anchor queries (get_object_pose, load_object_anchor, get_world_state), the pipette verbs, perception, operator prompts, logging via print_log, and SkillObject itself. Every function, with its parameters, units, and return shape, is documented in the Skill runtime API.

⚠️

Older skills sometimes import execution_functions through a legacy parent package that no longer exists. If a skill's modules.py has a longer import path that fails to resolve, replace it with execution.execution_functions. That is what zeon new skill scaffolds today.

The star import does not give you the standard library

import * brings in only what the package exports. time is not among those names, so calling time.sleep(...) without importing time raises NameError. Import it explicitly:

import time

from execution.execution_functions import move_arm, print_log


def my_skill():
    move_arm(arm="left_arm", position=[0.4, 0.1, 0.3], orientation=[0, -1.57, 0], speed=100)
    time.sleep(0.5)
    print_log("my_skill complete")
    return {"success": True}

If an operator might want to pause mid-wait, pause_aware_sleep(seconds) is exported as a drop-in for time.sleep that polls the pause context while it waits.

Parameters

Give the function arguments and they become the skill's parameters. The Python signature is the single source of truth for parameter structure: name, type, whether it is required, and its default. The catalog reads the signature statically with ast, so building the catalog never has to import your skill or its hardware dependencies.

A parameter is required if it has no default, and optional if it does.

def my_skill(duration: float = 30, message: str = "hello"):
    """Wait, then log a message."""

Annotations map to parameter types:

AnnotationParameter type
SkillObjectobject
strstring
boolboolean
intint
floatfloat
list, List, tuple, Tuple, Sequencearray
dict, Dict, Mappingobject

Optional[X] and X | None are unwrapped to X. An unannotated parameter is typed from its default value, falling back to string when there is no default.

The type decides the control you get when you fill the skill in. See The Skills Editor for the control each type renders as.

SkillObject: receiving a world object

Most real skills act on something in the world, and the way a skill receives one is a parameter annotated SkillObject. It is the dominant parameter type in practice.

SkillObject is a frozen dataclass with two fields:

FieldWhat it is
idThe world object's UUID, the handle you pass to world functions.
poseA 12-element array: [centroid_x, centroid_y, centroid_z, roll, pitch, yaw, extent_x, extent_y, extent_z, origin_x, origin_y, origin_z].

At execution time the loader resolves the workflow's object binding to a real world instance and constructs the SkillObject for you. You never build one yourself. If a required object parameter is missing from the execution context, or the referenced object is not in the loaded world, the skill fails with an explicit error rather than silently continuing.

Use object.id to address the world instance:

from protocol_schema import SkillObject

from .modules import load_object_anchor, move_arm, print_log


def dispense_to_anchor(object: SkillObject, anchor: str, volume: float = 5, speed: float = 5):
    """Left arm dispenses to a named anchor on an object.

    Args:
        object: Object to dispense into.
        anchor: Anchor name from the object's yaml file (e.g. 'A1', 'H12').
        volume: Volume to dispense in µL.
        speed: Pipette speed.
    """
    object_id = object.id
    anchor = load_object_anchor(object_id, anchor)
    ...

Note the shape. The skill takes the object generically, takes the anchor name as a string, and resolves the geometry at runtime. That is what makes one dispense skill work against a PCR plate and a cold block alike. Skill authoring patterns covers this in depth.

SkillObject is exported from execution.execution_functions, so a star import already has it. Skills that import explicitly take it from protocol_schema.

Where parameter descriptions come from

Structure comes from the signature. Description text is the one thing metadata.yaml can override, and it resolves in this order:

  1. parameter_descriptions[<name>] in metadata.yaml
  2. The function docstring's Google-style Args: entry
  3. The parameter name, as a last-resort placeholder

The docstring is the common path: an Args: block and no YAML at all. Reach for parameter_descriptions when you want to change the text a user sees without touching the docstring:

parameter_descriptions:
  volume: "Volume to dispense, in microlitres (1 to 200)."
⚠️

metadata.yaml cannot define parameter structure, choices, ranges, or units. Adding a parameters: block will not give a modern skill new inputs. A skill parameter carries only name, type, description, required, default, and, for nested and array types, object_schema and array_item_type. There are no choices, min, max, or units fields. Validate ranges inside your function. A parameters: list in metadata.yaml is read only as a fallback when the signature cannot be parsed at all. Change the signature, not the YAML.

Anatomy: metadata.yaml

A fresh skill's metadata.yaml:

# Skill: my_skill
skill_id: my_skill
version: "1.0.0"
description: ""

parameters: []

preconditions: {}
postconditions: {}

tags: []
FieldWhat it's for
skill_idUnique id. Must match the folder name. A mismatch is logged as a warning and the YAML value wins, which then breaks the file lookup.
versionSemantic version, X.Y.Z.
descriptionOne-line summary of the skill.
parametersFallback only. Derived from the function signature in normal operation.
parameter_descriptionsMap of parameter name to description text. Overrides the docstring.
preconditionsKey/value state requirements. See below.
postconditionsKey/value state guarantees written on success. See below.
safety_rulesList of human-readable safety constraints. Stored and displayed as documentation, not machine-enforced.
tagsFree-form labels for grouping and filtering.
high_riskBoolean, defaults to false. Intended to trigger automatic execution checkpoints.
world_state_primeOptional initial scene state (object_positions), used by the world-restore path to reset a scene for skill testing.

Full field reference: skills/<id>/metadata.yaml.

Preconditions and postconditions

These are not world queries. They are flat key/value pairs against the workflow's execution state, the shared bag of values that flows from node to node during a run. Values are compared as case-insensitive strings.

A typical chain has one skill guarantee a postcondition, say plate_sealed: true, that a later skill requires as a precondition.

preconditions:
  epipette_connected: true
  tip_attached: true
postconditions:
  tip_ejected: true

The two halves behave differently today:

  • Postconditions are applied. When a skill returns successfully, its postcondition keys are written into execution state, so later nodes can require them. When it fails, those keys are left at their prior value (or false).
  • Preconditions are declared and parsed but not currently enforced on skill nodes. The validator exists and works, but the check is short-circuited by a hard-coded bypass in the executor, pending a UI control to toggle it. On cleanup nodes a failed precondition is logged as a warning and execution continues, by design, because cleanup is best-effort.

Write them anyway. They are the documented contract between your skills, they drive the state keys the executor declares, and the postcondition half is live. Do not rely on a precondition to stop a bad run right now.

Sharing code between skills

Two mechanisms, both used heavily in real projects.

modules.py sits beside robotic_code.py and is imported relatively. At its simplest it is one line that re-exports the platform surface:

from execution.execution_functions import *

Then robotic_code.py does from .modules import move_arm, print_log. This is the pattern the bundled example skills use. zeon new skill does not create modules.py, and the skill it scaffolds imports execution.execution_functions directly, so add modules.py yourself when you want the seam. Anything else you put in modules.py, such as pose constants, transform maths, or tool offsets, comes along with it.

Calling another skill is a plain import of its function:

from aspirate_volume.robotic_code import aspirate_volume
from dispense_to_anchor.robotic_code import dispense_to_anchor

The loader puts the skills/ directory on sys.path, so each skill folder is importable as a top-level package. This is common: composite transfer skills are built by calling the aspirate and dispense skills in sequence rather than duplicating their motion code.

Before every load the loader drops any cached module whose file lives under the skills tree and calls importlib.invalidate_caches(). An edit to a helper or a sub-skill is picked up on the next run, including a file created seconds earlier in the same process, without restarting anything.

📘

Because each skill folder is a top-level package, skill ids share one namespace with anything else importable. Keep ids specific (pick_tip, not pick).

Behaving differently in sim and on hardware

is_sim_mode() returns true when the runtime is in simulation. Use it to skip real I/O that has no simulated counterpart, such as a network call to a sensor, and return a plausible value instead:

import requests

from .modules import is_sim_mode, print_log


def read_sensor():
    if is_sim_mode():
        print_log("Sim mode: skipping HTTP read")
        return {"success": True, "value": SIM_VALUE, "detected": True}

    # Real hardware: poll the sensor over HTTP.
    status = requests.get(f"{SENSOR_HOST}/api/status", timeout=TIMEOUT_S).json()
    ...

Arm motion, gripper control, and world queries work the same in both modes, which is the point of the abstraction. Branch only around hardware the simulator does not model.

⚠️

The pipette verbs and the Slack helpers are not part of that abstraction. They have no simulated counterpart, so they attempt the real thing whichever mode you are in: the pipette calls try to reach the device over Bluetooth and can block for several minutes before giving up, and the Slack helpers post real messages from a simulated run. Guard both behind is_sim_mode() in any skill you expect to run in the simulator.

See Running a workflow on real hardware.

Trying one skill on its own

You do not have to build a workflow to run a skill. Load a world, give the skill its inputs, and run that one skill against it. That is the fastest loop for getting a single action to behave. The Skills Editor is the screen for it and describes every control.

Two things about that loop are properties of the skill, not the screen:

  • Parameters are read from the file on disk, not from an unsaved buffer. Save your code before you expect a signature change to show up as new inputs.
  • Object parameters are resolved against the world that is currently loaded. What the skill receives is that world's object id, so a skill tested against one world needs the equivalent object present in any other world you run it in.

When your parameters do not appear

If a skill reports no parameters and you expected some, either the function genuinely takes no arguments or the signature could not be read. Check three things in robotic_code.py:

  1. The function name matches the skill id exactly.
  2. The file parses. A syntax error anywhere in robotic_code.py blocks parameter extraction for the whole file.
  3. The function is at the top level of the module, not nested inside another function or a class.

Getting your skill into the cloud

Files written by zeon new skill are local until you sync. Review them, then push:

zeon status
zeon diff
zeon sync -m "Add my_skill"

zeon sync pulls the cloud's latest, merges it into your work, and pushes back. See zeon sync and How sync works.


Did this page help you?