30 min

Designing a skill API

A skill is a contract offered to a model that cannot see your code, cannot feel your robot, and will take every name you choose literally.

Where you are. You can wrap a learned policy so something other than your own script can call it. This lesson is about the shape of that call: what the planner actually sees, where to cut the robot into pieces, and why the name you choose is part of the interface rather than a comment on it.

Hand the model the real API

Your robot has had an interface since Module 1:

set_joint_targets(q)      # six angles in radians, sent at 50 Hz
get_joint_state()         # six angles back
set_gripper(width)        # metres between the fingers

Give that to a capable model and ask it to pick up the mug.

It answers. Six floats, all in a plausible range, with a confident sentence about reaching toward the mug. The arm moves somewhere. Not to the mug: the model has never seen your link lengths, has no idea where the mug sits in joint space, and could not correct the error if it did, because it replies about once a second and the loop that would do the correcting wants a fresh number every twenty milliseconds.

Nothing failed. The API was real, the call was well formed, the schema validated, and the answer was noise wearing the shape of an answer.

Now delete that interface and offer one function instead: pick(object_id). Same robot, same model, same mug. This time it works often enough to build on.

The model did not get smarter in between. You changed where you cut.

The idea in one paragraph

A skill is the unit a planner composes: one call, one outcome you can name, lasting long enough that a round trip to the model is worth paying for. Designing a skill API is mostly deciding where to cut the robot. Cut too fine and you have asked the planner to do control, which it cannot do and could not afford at the rate control happens. Cut too coarse and the planner has nothing left to decide, because your one skill is the whole task and you have written a script with an expensive front end. Everything else follows from three properties: arguments typed tightly enough that guessing is impossible, names written for the model that reads them, and outputs that are literally the inputs of the next call.

Four things cross the boundary

the boundary Inside the skill the policy checkpoint controller gains and limits the IK solver hand-eye calibration retry, timeout, logging none of it is visible name how the planner finds it description prompt text, sent every call inputSchema no units, no frames in it outputSchema optional, and worth writing The planner sees these four things and nothing else
What the planner can see of a robot: only a name, a description, an input schema and an output schema cross the boundary, while the policy checkpoint, controller, kinematics and calibration stay hidden behind it

Wider than the screen; scroll it sideways.

Whatever transport you use, and lesson 7 uses MCP, the planner sees four things: a name, a description, an input schema, and if you bothered, an output schema. That is the entire surface. The checkpoint you fine-tuned, the controller gains, the IK solver, the hand-eye calibration, the fact that pick is a diffusion policy while home is a hardcoded joint trajectory: none of it is visible, and none of it should be.

The description is not documentation. It is prompt text that ships on every request, and it is the only place you can say things the schema cannot express: when to use this rather than the neighbouring skill, what it will refuse to do, roughly how long it takes.

The grain rule

The clock decides the grain, and it decides it before taste gets a vote.

A planner call costs somewhere in the region of 0.3 to 3 seconds depending on how much you let it think and how many images you send. That is an engineering estimate rather than a published figure; the frontier APIs expose a thinking-level dial and recommend the middle setting, and none of them publish per-call latency. Take the middle of that range as one round trip and the rule falls out:

tskill>tround tript_{\text{skill}} > t_{\text{round trip}}

In words: a skill should take longer to execute than it takes to decide on. If it does not, the robot spends most of the task standing still, waiting for the model to say the next obvious thing.

Skills shorter than one decision 3 s moving, 9 s waiting Skills longer than one decision 10 s moving, 2 s waiting 0 2 4 6 8 10 12 s planner call, about 1 s robot moving
Two plans of the same task laid out on a time axis: the first alternates one-second planner calls with sub-second skills so the robot is idle more than half the time, the second merges the small skills so each planner call is hidden behind a longer motion

Wider than the screen; scroll it sideways.

That gives you a tier table. Each tier runs at its own rate, and moving work across a tier boundary is the main lever you have.

TierOne call isRateWho decides
Controllera joint or torque command200-1000 Hzyour PID loop, Module 1
Policyan action chunk10-200 Hzthe learned network, Modules 3-5
Skilla nameable behaviour, seconds long0.2-2 Hzthis module
Taskthe whole goaloncethe human

Typed: leave nothing to be guessed

JSON Schema has types. It has no units, no coordinate frames, and no idea that 0.3 might mean metres, centimetres or a fraction of the gripper’s range. The model will guess, confidently, and the guess will validate.

So put the physics in the names.

{
  "name": "place_on",
  "description": "Place the currently held object onto a named surface. Requires an object in the gripper. Takes 3-5 s.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "surface": {"type": "string", "enum": ["desk", "shelf", "tray"]},
      "offset_x_m": {"type": "number", "minimum": -0.15, "maximum": 0.15},
      "offset_y_m": {"type": "number", "minimum": -0.15, "maximum": 0.15},
      "frame":      {"type": "string", "enum": ["surface"], "default": "surface"}
    },
    "required": ["surface"]
  }
}

"name": "place_on" "surface": enum ["desk", "shelf", "tray"] "offset_x_m": number minimum -0.15, maximum 0.15 "frame": enum ["surface"] a closed set of names the unit lives in the name off-desk fails validation the frame is stated, not guessed
An annotated skill declaration showing four defences against a guessed argument: a closed set of surface names, the unit carried in the parameter name, bounds that turn an off-desk value into a validation error, and an explicitly stated coordinate frame

Wider than the screen; scroll it sideways.

Four defences are doing work there. _m puts the unit where it cannot be missed. frame is an enum with one legal value today, which costs nothing and stops the silent reinterpretation the day you add a second. minimum and maximum turn “somewhere off the desk” into a validation error instead of a motion. And surface is an enum rather than free text, because a free string is an invitation.

Named: the name is retrieval

The model chooses a skill mostly from its name, then confirms with the description. Names are therefore semantic, not cosmetic.

Name for the effect, not the implementation. run_act_policy_ckpt_7 tells the planner nothing it can reason with; pick tells it exactly when to reach for this. Keep verb-noun ordering consistent across the whole library, because inconsistency reads as meaning: if you have pick and place_on and then gripper_release, the planner will assume that third one is a different kind of thing.

And keep the library small. A dozen skills the planner uses well beats forty it confuses.

Composable: outputs are inputs

The point of a skill library is that a plan is a chain. That only works if the value that comes out of one call is the value that goes into the next.

look() returns a list of detections, each with an object_id. pick(object_id) takes one of those ids. place_on(surface) takes a name from a closed enum. Write a five-step plan by hand and check every argument: if any of them had to be invented rather than read off an earlier result, you have found the place your system will hallucinate, and the fix is the API rather than the prompt.

Three shapes you will meet

Interface styleWhat the planner emitsWhere it lives
Description plus JSON Schemaa tool call, one at a time, usually blockingshipped products, including MCP and the Gemini robotics API
Generated code over primitivesPython that loops and branches over your motion librarythe Code as Policies lineage, mostly research
Symbolic operators with preconditions and effectsa goal, solved by a classical plannerresearch only; nothing shipped

The third row is worth knowing about precisely because nobody ships it. Preconditions and effects as machine-checkable statements are a research idea with real appeal and no product behind it. What you actually get is row one, which means the discipline of row three is something you impose on yourself, inside a schema that will not enforce it. That is the next lesson.

Check yourself

1. Why is exposing set_joint_targets to a planner useless rather than merely inefficient?

Two independent reasons, and either alone is fatal. The planner has no body model, so it cannot map “the mug” to six joint angles even in principle. And joint targets are the input to a loop that runs at 50 Hz or faster, while the planner answers roughly once a second, so it cannot close that loop even with perfect numbers. The interface is real and well typed, which is exactly why the failure is silent.

2. You are deciding whether open_gripper should be a skill. What is the test?

Not “is it a distinct behaviour” but “will the planner ever want to choose differently here.” Opening the gripper before a grasp is unconditional, so exposing it buys no decisions and costs a full planner round trip of dead time plus one more sequencing mistake the model can make. Fold it into pick. Anything the planner would decide the same way every time belongs inside a skill.

3. Your place_on takes a parameter offset_x. A colleague says the type is number, so it is typed. What is still missing?

The unit, the frame and the bounds. JSON Schema knows it is a number and nothing else, so the model will pick a value using whatever convention it inferred from the description, and the value will validate whether it means metres, centimetres or a fraction of the table. Rename it offset_x_m, add an explicit frame enum even if there is only one legal value today, and give it a minimum and maximum so an off-table value fails as validation rather than as motion.

4. Why should object identifiers be minted by perception rather than written by the model?

Because a model asked to name an object will produce a name whether or not the object is there, and nothing downstream can tell the difference between a correct name and an invented one. An identifier that only exists because a detector produced it means a call referring to it is at worst stale, not fictional. It also turns “the mug is gone” into a lookup failure you can report precisely instead of a motion toward nothing.

5. A colleague proposes forty skills so the planner can handle anything. What goes wrong?

Three things. Selection accuracy falls, because forty names include near-synonyms the model cannot reliably distinguish. The description text of all forty ships on every request, so each call gets slower and more expensive. And more skills usually means smaller skills, which pushes you under the round-trip rule and fills the task with dead time. A dozen well-cut skills used correctly beats forty used approximately.

Do this

About thirty minutes. Pure standard library.

1. Declare four skills. In skills.py, write TOOLS as a list of dicts for look, pick, place_on and home, each with name, description and inputSchema. Follow the typing rules: units in parameter names, frames as explicit enums, bounds on every number, identifiers only ever taken from a prior result. Print it with json.dumps(TOOLS, indent=2).

2. Run the grain rule. Estimate wall-clock duration for each skill, then measure the cost of getting it wrong:

PLANNER_S = 1.0
DURATIONS = {"look": 0.4, "open_gripper": 0.3, "pick": 4.0,
             "place_on": 3.5, "home": 2.0}

plan = ["look", "open_gripper", "pick", "place_on", "home"]
motion = sum(DURATIONS[s] for s in plan)
thinking = PLANNER_S * len(plan)
print(f"{motion:.1f}s moving, {thinking:.1f}s waiting, "
      f"idle {thinking / (motion + thinking):.0%}")

Then fold open_gripper into pick, drop it from the plan, add 0.3 s to pick, and run it again. You should get 33% idle before and 28% after. That is the grain rule in one number: modest for a single badly cut skill, and compounding with every extra one, until you reach the figure above.

3. Break your own schema. For each parameter, write the most plausible wrong value a model could supply that would still pass validation. If you can find one, the schema is not tight enough yet. Fix it, and note which fix was a rename rather than a constraint.

4. Chain-check a plan. Write out five steps for “clear the desk” using only your four skills, with concrete arguments. Mark every argument that was produced by an earlier step. Anything unmarked is a hallucination site. Change the API until nothing is unmarked, then keep the plan: lesson 5 makes each of those steps report honestly, and lesson 6 attacks the assumption that look told you the truth.

What you can now do

You can decide where to cut a robot into skills using the round-trip rule instead of intuition, and say why a joint-level interface is unusable by a planner in principle rather than in practice. You can write a skill declaration whose arguments cannot be guessed wrong, because units live in names, frames are explicit, numbers are bounded and identifiers come from perception. And you can look at a proposed skill library and tell whether it composes, by checking that every argument of every step was produced by a step before it.

What you can now do

You can cut a robot into skills at the right grain, type the arguments so units and frames cannot be guessed wrong, and name them so the planner reaches for the right one.