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_skillThe 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, somy-skillis accepted at creation time. Butskill_idis 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
robotic_code.pyA 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:
- The function name must equal the
skill_id, which is to say the folder name. The catalog parses the file and looks for adefwhose 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. - 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_functionsthrough a legacy parent package that no longer exists. If a skill'smodules.pyhas a longer import path that fails to resolve, replace it withexecution.execution_functions. That is whatzeon new skillscaffolds 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:
| Annotation | Parameter type |
|---|---|
SkillObject | object |
str | string |
bool | boolean |
int | int |
float | float |
list, List, tuple, Tuple, Sequence | array |
dict, Dict, Mapping | object |
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
SkillObject: receiving a world objectMost 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:
| Field | What it is |
|---|---|
id | The world object's UUID, the handle you pass to world functions. |
pose | A 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:
parameter_descriptions[<name>]inmetadata.yaml- The function docstring's Google-style
Args:entry - 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.yamlcannot define parameter structure, choices, ranges, or units. Adding aparameters:block will not give a modern skill new inputs. A skill parameter carries onlyname,type,description,required,default, and, for nested and array types,object_schemaandarray_item_type. There are nochoices,min,max, orunitsfields. Validate ranges inside your function. Aparameters:list inmetadata.yamlis read only as a fallback when the signature cannot be parsed at all. Change the signature, not the YAML.
Anatomy: metadata.yaml
metadata.yamlA fresh skill's metadata.yaml:
# Skill: my_skill
skill_id: my_skill
version: "1.0.0"
description: ""
parameters: []
preconditions: {}
postconditions: {}
tags: []| Field | What it's for |
|---|---|
skill_id | Unique id. Must match the folder name. A mismatch is logged as a warning and the YAML value wins, which then breaks the file lookup. |
version | Semantic version, X.Y.Z. |
description | One-line summary of the skill. |
parameters | Fallback only. Derived from the function signature in normal operation. |
parameter_descriptions | Map of parameter name to description text. Overrides the docstring. |
preconditions | Key/value state requirements. See below. |
postconditions | Key/value state guarantees written on success. See below. |
safety_rules | List of human-readable safety constraints. Stored and displayed as documentation, not machine-enforced. |
tags | Free-form labels for grouping and filtering. |
high_risk | Boolean, defaults to false. Intended to trigger automatic execution checkpoints. |
world_state_prime | Optional 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: trueThe 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_anchorThe 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, notpick).
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:
- The function name matches the skill id exactly.
- The file parses. A syntax error anywhere in
robotic_code.pyblocks parameter extraction for the whole file. - 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.
Anchor-driven geometry, articulated objects, and the move_relative gotcha.
Every field a skill's metadata file accepts, and what each one is used for.
Wire your skill into a multi-step protocol.
The screen where you edit a skill, fill in its inputs, and run it on its own.
Updated 1 day ago