Full Nori A3 docs coming soon. What works today.
Skip to content

The handshake

Shortly after the control channel opens, the daemon sends its ack — a self-description of the robot you just connected to. Read it at use-time with robotInfo() (null until it arrives) or subscribe with the onReady option:

ts
const teleop = new RemoteTeleop({
  /* ...options as usual... */
  onReady: (info) => {
    if (!info.accepted) { console.error("robot refused session:", info.error); return; }
    console.log("protocol v" + info.protocolVersion, "units:", info.normMode);
    console.log("joints:", info.descriptor?.joints);
    console.log("cameras:", info.descriptor?.cameras);   // same roles as the CameraLayout tiles
    console.log("gripper range:", info.descriptor?.ranges?.["right_arm_gripper.pos"]);
  },
});

What's in a RobotInfo

FieldMeaning
acceptedfalse = the daemon refused the session (error says why). The connection stays up so you can see logs/telemetry, but control frames are ignored.
protocolVersionThe daemon's nori-protocol major. Compared against this SDK's NORI_PROTOCOL_VERSION; a difference sets versionMismatch.
normModeUnits of every .pos value in state/action: "range_m100_100" (normalized) or "degrees".
watchdogProfile{ t_warn_ms, t_stop_ms } — control-frame silence beyond these slows, then stops, the robot. Disclosure, not negotiation: the daemon picks it from the measured link; you can't change it.
descriptorWhat the robot is: joints (every drivable <motor>.pos key), base, aux (e.g. lifts), cameras (roles, matching the composite layout tiles), and ranges — the authoritative [min, max] per key. Out-of-range values are clamped robot-side, never rejected, so use ranges to scale your inputs, not to pre-validate. Two optional maps ride along on newer robots: jog_scale (nominal full-deflection jog scale per namespace; its task table names yaw as the canonical angular-z verb, shoulder_pan as a deprecated alias) and ranges_si (calibrated SI bounds per normalized key; may be inverted — interpolate as written, never sort).
initialStateThe joint pose at session start.
versionMismatchAdvisory. Mixed daemon versions exist across the fleet, so the SDK warns and proceeds — unknown frame types are ignored by both sides, so a mismatch means vocabulary gaps, never unsafe behavior.
modelAdvisory label ("L2", "A3") for logs and dataset provenance. Don't branch on it — branch on descriptor and capabilities — with one sanctioned exception the SDK already handles internally: the frozen L2 fleet's base-angular wire sign (Driving → Base). Deployed L2 daemons predate this field and never send it, so absence is not evidence of a non-L2 robot.
capabilitiesOptional verbs this robot honours beyond the core ("task_jog", "pose_targets", "record", …). Three-valued: check with supportsCapability(info, cap)true/false when declared, undefined when the ack predates the field, which means unknown, probe or assume legacy, never no. An explicit [] means none.

Old daemons may send a bare ack — every field except accepted is optional, so null-check what you read. The ack is re-sent on every daemon (re)connect, so a robot restart mid-session refreshes robotInfo(). The raw parse is exported as parseAck(frame) if you need it standalone.

One rejection worth knowing by name

accepted: false with error: "unauthorized" is the robot's internal agent token — its own bridge authenticating to its daemon — being missing or stale. That's a robot-side provisioning problem.

It is not about room access. Room access is gated by Supabase RLS when you join the private channel: if your account isn't the robot's paired account you'd never join, let alone reach an offer. (There is no room token — the legacy HMAC token is retired.)

If you see unauthorized, nothing on your end fixes it. Report it to us.

Apache-2.0