Skip to content

~3 min read

The five verbs·

Everything an agent can say to me. Five Strands @tools, one return shape, one guard behind them all.

from strands import Agent
from fomo.tools import calibrate, photo, move, pose, motion

fomo = Agent(tools=[calibrate, photo, move, pose, motion])
fomo("look 40 degrees left, tilt up a little, take a photo, then nod")

Every tool returns {"status": "success"|"error", "content": [{"text": "..."}], ...extras} and never raises into the agent. When the dashboard is running it owns my serial port and the tools talk to it over HTTP; otherwise they open the bus themselves. fomo mcp serves the same five over MCP.

calibrate·

calibrate(mode="report", i_know=False, hold_seconds=0, extremes=None, write_eeprom=False)

mode what I do
report print ~/.fomo/calib.json. Nothing moves.
manual drop torque, sample the bus for hold_seconds (≥ 20) while you move me by hand; the fold becomes home, the extremes my window. Needs i_know.
reset move calib.json aside. Poses survive — they are relative to home.

extremes={"1": [66.4, 251.0], ...} types the windows instead of sampling. write_eeprom=True burns MIN/MAX into servos 1–4 (LOCK 0 → write → LOCK 1). No automatic stop search: on me it only ever found the fold, so I trust the hand. See the install page.

photo·

photo(source="auto", quality=60, inline=False)

One JPEG. Sources tried in order — lan (the Nicla's /stream, first frame out of the MJPEG), usb (Nicla raw REPL _snap(q)), dash (/api/camera/snapshot.jpg), local (this machine's webcam via imagesnap or OpenCV). Saved to ~/.fomo/photos/YYYYmmdd-HHMMSS.jpg. The result names the source — I never pretend a webcam frame came from my head, and if every source fails I say so, source by source. inline=True adds the JPEG as an image block so the agent can see what I saw.

move·

move(actions, i_know=False)

actions is a list applied together as one ramped, guarded command:

[{"joint": "elbow", "by": -15}, {"look": {"pan": 30, "tilt": -10}}]
action meaning
{"joint": j, "to": deg} absolute servo degrees (tick 2048 = 180.0)
{"joint": j, "by": deg} relative to the present reading
{"joint": j, "rel": deg} relative to my calibrated home
{"look": {"pan", "tilt"}} the head, about home
{"home": true} fold everything
{"stop": true} abort in flight; torque held unless folded
{"torque": false} release — refused while I'm upright (rule 7)
{"read": true} present degrees plus what the head feels — ToF distance, roll/pitch, bench or head — no motion

Joints by name or id: base 1 · shoulder_lift 2 · elbow 3 · wrist_flex 4 · pan 5 · tilt 6. Any angle inside a joint's window is one command; the guard cuts it into ≤ 10° legs and ramps them at speed °/s (default 30, ceiling 90), validated whole before the first write. Only a hard limit refuses — the window, the head window, the gate, a weak rail (rule 8).

pose·

pose(action="list", name=None, description="", i_know=False, seconds=0, nudge=None)

list · show · save · goto · nudge · delete. A pose is a place, stored relative to home, so recalibrating shifts it with the fold instead of breaking it. 5 built-ins ship with me — Poses has the table and the twin.

motion·

motion(action="list", name=None, i_know=False, speed=None, stay=None, frames=None, description="")

list · show · run · save · delete. A motion is a sentence — keyframes played on the real arm, starting from wherever I am and ending folded unless it says stay. 9 built-ins: approve deny happy frustrated curious wave look_around up sleep. "Show me you're happy" in the panel ends up in motion(action="run", name="happy"); speed overrides every frame's ramp, stay=True leaves me in the last frame. Every frame is checked against the hard limits before the first write; a stall mid-motion holds me instead of forcing it. Motions shows all nine in the twin.