Developers

The hairobo API

Injected automatically. You do not include a script. window.hairobo exists before your code runs.

Everything returns a Promise. Calls resolve false rather than throwing when the host declines them: no permission, no dock, rate limited. Write for that.

const spoke = await hairobo.voice.speak('Hello');
if (!spoke) { /* muted, busy, or not permitted: carry on */ }

voice (needs voice)

voice.speak(text) → Promise<bool>

Speaks through the robot in its own voice. Capped at 300 characters, and at most one line every 0.4s; a call inside the gap resolves false and is dropped, not queued. Resolves false while the robot is already talking.

eyes

⚠️ In mode: "session" your page covers the face, so eye movement is invisible while you run. These calls belong in mode: "ambient". Needs eyes.

eyes.look(x, y) → Promise<bool>

Points the gaze. Both are roughly −1…1, x right-positive, y up-positive.

eyes.express(name) → Promise<bool>

One of happy, curious, thinking, sad, excited, calm. Unknown names are ignored rather than erroring.

led (needs led)

led.set(color) → Promise<bool>

Hex string, e.g. '#37D2F0'. The dock's ring.

led.reset() → Promise<bool>

Back to whatever the app would show. Call this when you finish, though teardown does it for you if you forget.

motor (needs motor · needs a dock)

Degrees are relative to where the head is now. The host clamps them, so ask for what you want and let it decide.

motor.pan(degrees) → Promise<bool>

Turns left/right. Clamped to ±20° per call.

Positive degrees turn the same way positive f.x lies. So to follow a face you pass the offset straight through: motor.pan(f.x * step), not -f.x. The negation belongs only to anything that MIRRORS the face, like drawing pupils that look toward the user; the head turns toward it instead, which is the opposite operation. Getting this backwards makes the robot turn away, keep turning until it reaches its pan limit, and then sit there doing nothing.

motor.followFace(on) → Promise<bool>

If you want to follow a face, use this. It hands the job to the robot's own tracking, the same loop behind the built-in follow-me.

await hairobo.motor.followFace(true);    // start
await hairobo.motor.followFace(false);   // stop

You send nothing else. No camera events, no targets, no timers. The app reads its face tracker directly and drives pan and lift itself.

Why this exists, and why followPan is not the same: anything you drive from face events works from data that arrives at about 6 Hz and is already ~160 ms old. The app's own loop is fed at frame rate with no round trip. That gap cannot be tuned away: a plugin reconstructing the servo will always settle less cleanly than the loop that sees the face first-hand.

Needs camera as well as motor (it turns the camera on for you) and stops when your plugin closes. An update that adds a permission does not inherit consent, so the user is asked again.

🔥 If you already hand-rolled a follower, DELETE IT. Every workaround built to fight the old stepping is now actively harmful:

  • your stop band and settle logic
  • sending a centred target once settled
  • scaling speed down near centre
  • adjusting gain when the face crosses the middle
  • the on('face') handler, if it exists only to drive the motor
  • any timer that re-sends targets

Two control loops arguing produce worse motion than either alone. Adding followFace on top of existing tracking code is the most likely way to conclude this API is broken. You send nothing: no events, no targets, no timers.

Use followPan below only when you need the head pointed at something that is not a face: a target of your own choosing.

motor.followPan({targetX, smoothing, maxSpeed}) → Promise<{ok, errorDeg, settled}>

motor.stopFollowPan() → Promise<true>

For following a face, prefer motor.followFace above. Use this for a target only your plugin knows about.

motor.pan sends one pulse and stops, so following with it is visibly stepped however often you call it, and the 0.35 s budget means most of your calls are dropped anyway. followPan hands the robot a target instead and lets it run the servo: smoothing, acceleration, speed limit, deadzone and the stop are all the host's.

hairobo.on('face', (f) => {
  if (f.detected) hairobo.motor.followPan({ targetX: f.x });
  else            hairobo.motor.stopFollowPan();
});

targetX is where the face is, in the same −1…1 space as f.x. Pass it straight through: no negation, exactly as with motor.pan.

smoothing fraction of the remaining error taken per host tick. 0.02–1, default 0.2. Lower is smoother and lazier.
maxSpeed degrees per second, default 12, capped by the host at 30. A ceiling, not the speed it travels at.
deadZone stop below this angular error, in degrees. Default 1.5.
resumeZone do not move again until the error exceeds this for two consecutive ticks. Default 3.5, always greater than deadZone.

Resolves { ok: true, errorDeg, settled }: the current angular error in degrees, and whether the head has stopped. Resolves false with no dock or while the robot is levelling.

⚠️ It used to resolve exactly true. if (r) still works; r === true does not.

What the host is doing on your behalf, since it decides how this feels:

  • Your normalized targetX is converted to a real angle using the camera's own field of view, so the gain does not depend on the lens.
  • The loop runs at 20 Hz (servo rate, not the ~6 Hz camera rate), and the motion already commanded since your last target was measured is subtracted from the error. That dead-time term is why it stops cleanly: without it the loop re-corrects for a move still in flight, overshoots, and hunts.
  • Speed tapers steeply near centre, so the last couple of degrees are a crawl while a large error still runs at full speed.
  • Acceleration is limited, so it eases in and out rather than stepping.
  • The dead zone has hysteresis and a two-tick confirmation, so a settled head needs a real movement to wake it rather than sensor noise.

⚠️ Keep sending targets. Go quiet for more than about half a second and the host stops the motor. That is deliberate: the dock firmware has no watchdog of its own and keeps driving the last command it was given, so a plugin that crashes or stalls mid-follow would otherwise leave the head turning until something physical stopped it. Silence has to mean stop.

You do not need to throttle your calls. A target update is not a movement (the host decides when the motor actually turns), so calling it on every camera event is exactly right.

There is no motor.lift: the third axis is the app's

The dock has three motors: pan (left/right), lift (up/down) and tilt (roll). Only pan and tilt are here, and that is deliberate rather than an oversight. Two reasons, both structural:

Lift down is the one direction that can damage the hardware. It brings the dock's base toward the phone and can knock it off the mount, so every lift-down command passes a safety check that reads live app state (dock geometry, what the robot is currently doing) and refuses when that state says no. A plugin has none of that context, and an API that silently refuses most calls would be worse than no API.

There is no "move N degrees" primitive for lift. Pan and tilt take clamped relative degrees. Lift is pulse-and-hysteresis: short bursts with a rest between them, tuned as a closed loop against live face tracking. Exposing it would mean inventing an open-loop degree model that does not exist today.

It is also the axis the app uses for auto-centring, so a plugin nudging it would be pulling against the app's own correction in a way pan does not.

If you are watching the robot raise and lower itself and wondering which call does that: none of them. That is the app.

motor.tilt(degrees) → Promise<bool>

Rolls the head. Clamped to ±10°, and refused entirely if it would take the phone more than 25° from level. Past that, the dock's base can strike the phone. Also refused if the app cannot currently read its own angle.

motor.center() → Promise<bool>

Return to level. Takes a second or two of visible movement.

At most one motor move every 0.35s. Anything faster resolves false and is dropped, so an animation loop can call freely without building a backlog. The gap is measured between moves that actually happened: a call that turns nothing does not start the clock.

What false means

false is never an error. It means the robot did not turn, and there are four reasons it might not have:

  • there is no dock connected
  • less than 0.35s since the last move
  • the request rounded to less than half a degree (motor.pan(0) and the sub-degree tail of an easing curve both land here)
  • the host refused it: tilt more than 25° from level, or an IMU reading too stale to know which way is up

You cannot tell these apart, deliberately. A plugin that behaves differently depending on why the robot declined is a plugin fighting the host. Treat false as "not this time" and carry on.

Movement reads best with a deadzone. Turning on every small change looks nervous rather than attentive. See follow-me/.

camera (needs camera)

You get positions, never pixels. There is no API that returns a frame, and the image never leaves the phone.

camera.start() → Promise<bool> · camera.stop() → Promise<bool>

Start it, then listen. Stopped automatically when your plugin closes.

Events arrive at roughly 6/second:

hairobo.on('face', (f) => {
  if (!f.detected) return;
  moveEyes(f.x, f.y);      // the TRACKED face
});

hairobo.on('hand', (h) => { /* h.x only */ });

The event carries the whole room as well:

{ detected, x, y, count, faces: [{ x, y, size }, ...] }

x/y are the tracked face: the largest one, or whoever the robot is holding a gaze lock on. faces is every face it can see, up to 8, each with size = apparent width, the only depth cue there is: bigger means nearer.

Order is by x ascending, on the same axis as f.x. That is the axis motor.pan uses, so hairobo.motor.pan(faces[i].x * 20) turns toward face i. Which physical side index 0 sits on is deliberately unspecified: the camera feed is mirrored, so "leftmost" depends on whose left you mean. Drive the motor from the number; don't label a seat in your UI.

⚠️ Positions are not people

There is no face recognition in this API and there is not going to be one. faces[2] is a seat, not a person: two people swapping chairs swap indices, and someone leaving reindexes everyone after them. You can build turn-taking, counting, "is anyone still here", scan-and-pick. You cannot build anything that remembers who someone is, and a plugin that acts as if it can will be wrong in front of a room full of people.

See roll-call/ for a party game built entirely within that limit.

x and y are positions, not corrections: they say where the face is, not which way to move. Anything that mirrors the face (pupils) negates them; anything that turns toward it (motor.pan) does not. See motor.pan above.

data (needs data)

data.weather() → Promise<object>

Current weather from the app. Your only source of outside information. You have no network of your own.

storage (needs NO permission)

Survives between runs. Scoped to your plugin id: you read back only what you wrote, no other plugin can see it, and it is deleted when the user uninstalls you. This is the only thing that persists. The WebView's own localStorage is wiped every run.

Values are strings. set stringifies; get does not parse.

storage.get(key) → Promise<string|null>

An absent key resolves null. That is the normal first-run case, not an error.

storage.set(key, value) → Promise<true>

storage.remove(key) → Promise<bool>

Resolves whether anything was actually there.

storage.keys() → Promise<string[]> · storage.clear() → Promise<true>

storage.usage() → Promise<{used, limit, keys, maxKeys, maxValueBytes}>

Bytes. Show your own usage rather than discovering the cap by hitting it.

if (!hairobo.storage) return;              // older app, see below
const raw  = await hairobo.storage.get('save');
const save = raw ? JSON.parse(raw) : { level: 1, best: 0 };

save.best = Math.max(save.best, score);
try {
  await hairobo.storage.set('save', JSON.stringify(save));
} catch (e) {
  // A cap was hit. Retrying will not help (see below).
}

⚠️ A cap REJECTS the promise. It does not resolve false. Everywhere else in this API false means "dropped, try again": a rate limit that clears by itself. A full store does not empty itself, so reporting it the same way would have you retrying forever. Caps: 200 keys, 128-character keys, 16 KB a value, 256 KB in total.

Character state (mode face, needs NO permission)

Characters receive one event with everything they need to draw. It arrives on every change and once a second, so subscribing late still catches up.

hairobo.on('state', (s) => {
  draw(s.state, s.mood, s.gazeX, s.gazeY);
});
state idle, drowsy, sleeping, listening, thinking, speaking, smile, happy, tracking, error
mood calm, happy, curious, thinking, sad, excited
gazeX, gazeY −1…1, where the eyes would look. Same space as f.x on the face event.
idleSeconds since the person last did anything. The built-in eyes go drowsy at 45 and asleep at 70.
docked the phone is on the robot
dimmed the screen is dimmed for deep idle

Needs "mode": "face" and app build 26. See character.md for the rules a character has to follow.

open (needs open)

open.cab(to) → Promise<bool>

Opens the user's cab app with to as the destination: a saved place name like 'office', or an address. The app resolves the place, finds the pickup point and chooses the app. You never see the address or where the user is. Resolves false if called more than once every few seconds.

There is no "open this link" and there will not be; see permissions.md.

Spoken commands (needs NO permission)

Claim phrases in your manifest with intents, then:

hairobo.on('command', (c) => {     // c.text = what was said, c.phrase = yours
  hairobo.command.done();          // finished; the app stops you anyway at 25s
});

Your plugin runs with no screen, and the app shows your plugin's name on the face while it answers. See commands.md.

Always available

info.get() → Promise<object>

App build, apiVersion, whether a dock is connected, which permissions you were granted, whether the screen is dimmed. Check info.get() before offering robot features, rather than discovering there is no dock by being refused.

log(message)

Goes to the app's debug log. Invisible in release builds: your own debugging, not a user-facing channel.

wait(ms) → Promise

on(event, fn)

version: the API generation this host provides. Currently 5.

2 was the original surface; 3 added storage.* and count/faces on the face event; 4 added motor.followPan; 5 added motor.followFace. Prefer feature-detecting the namespace over comparing the number.

assistant (needs assistant)

assistant.ask({system, messages}) → Promise<string>

Ask Hai Robo's assistant and resolve with its reply.

const reply = await hairobo.assistant.ask({
  system: 'You are a patient English tutor. Correct gently, one point at a time.',
  messages: [{ role: 'user', content: said }]
});
  • messages: {role: 'user'|'assistant', content}, most recent last, and it must contain at least one user turn. Capped at 12 turns, 2000 characters each; system is capped at 1200.
  • One question in flight per plugin. Asking again before the first resolves rejects.
  • Rejects with daily limit reached when the user's own allowance is spent. Do not retry. Tell them.
  • ⚠️ Write system as a literal string in your source. It is reviewed like code, and a prompt assembled at runtime cannot be read by the person deciding whether to publish your plugin. Those submissions are rejected. Dropping the user's words into an otherwise literal prompt is fine; building the instruction itself out of pieces is not. See CONTENT-POLICY.md.

mic (needs microphone)

mic.listen(maxSeconds?) → Promise<string|null>

Listen for one turn and resolve with what the user said.

const said = await hairobo.mic.listen(8);
if (!said) return;                    // silence, or the robot was busy
  • maxSeconds 2–20, default 8.
  • Resolves null on silence. That is normal. Handle it rather than treating it as an error.
  • If the robot is still speaking, this WAITS for it to finish rather than refusing, so speak then listen reads naturally. voice.speak resolves when the line is queued, not when it is spoken. Null after a 15 s wait means the robot never became free.
  • You get the transcript, never audio. The app's own listening indicator is shown, so the user always knows.

The tutor loop

while (running) {
  await hairobo.voice.speak('Your turn.');
  const said = await hairobo.mic.listen(8);
  if (!said) continue;
  const reply = await hairobo.assistant.ask({ system: TUTOR, messages: [{ role: 'user', content: said }] });
  await hairobo.voice.speak(reply);
}

Both need minAppBuild: 21. Declare it, or your plugin will not run and the reason will depend on how old the app is:

  • Builds before 21 drop a permission they do not recognise and install the plugin under-granted. Your calls then fail at runtime with nothing on screen to explain it. Nothing can be done about those builds now. They are already released, which is exactly why minAppBuild exists.
  • Build 21 and later refuse the plugin outright and say which permission they did not understand. Loud, and much easier to diagnose.

The portal blocks a submission that uses assistant or microphone without it, for the same reason.

Feature-detect anything new

On an older app, a newer namespace is undefined, and the property access throws before your .catch can run, leaving your plugin silently stuck:

if (!hairobo.camera) {
  status.textContent = 'Needs a newer Hai Robo';
  return;
}

Either that, or set minAppBuild and refuse to install at all.

⚠️ For the motor-following calls, feature-detect. Do NOT set minAppBuild yet. Neither motor.followFace nor motor.followPan is in a shipping build, and there is currently no build number that means "has them": the released build and the development build carry the same number and different APIs. A plugin declaring one would be offered to users who cannot run it. Detect down the ladder instead:

if (hairobo.motor.followFace)      { /* best: the app's own tracking */ }
else if (hairobo.motor.followPan)  { /* host servo from your targets */ }
else                               { /* discrete motor.pan steps */ }

This note goes away once a build ships with them.

← Previous manifest.json Next → Characters
Preorder Deskmate dock

Reserve your dock.

Drop your details and we'll email you the moment it ships, with early-bird pricing.