# Robotics: Teleop Operators, Presence and Job Matching

> How an operator publishes Ready/Aux presence, how the ACD matches a robot job to a certified operator by lowest RTT, and what accepting or declining a ring offer does.

The teleop **Ready** screen is the operator's front-of-house surface. It is the
robotics analogue of a contact-centre agent's ready/aux screen: instead of
waiting for a call, the operator waits for a robot job matched to their skill
certifications.

This page documents what that screen publishes, how ClutchCall picks an
operator for a job, and what the ring offer carries into the cockpit.

## The operator model: certifications, levels and presence

The screen loads the signed-in operator with `api.me()`. The operator record it
renders has these fields:

| Field      | Meaning                                                      |
| ---------- | ------------------------------------------------------------ |
| `id`       | Operator id. Stamped on every presence update, accept and decline. |
| `name`     | Display name, broadcast with presence.                        |
| `region`   | Operator's region, broadcast with presence.                   |
| `certs`    | Array of `{ skill, level }` credentials.                      |
| `presence` | `ready` or `aux`.                                             |

Certifications are **per skill, with a level**. The screen renders each one as a
`skill · level` chip and states the matching rule directly to the operator: a
job offer only reaches you if you hold its required cert at level.

Until `api.me()` resolves, the screen renders an empty operator
(`id: ''`, no certs, `presence: 'ready'`). No presence is broadcast while
`ME.id` is empty — the broadcast effect returns early.

## Ready vs Aux and what each broadcasts

Presence is local UI state with two values, toggled by the two buttons in the
screen header:

- **Ready** — the operator is in the dispatchable pool.
- **Aux** — the operator is out of the pool, with a reason selected from a
  dropdown: `Break`, `Training`, `Debrief`, `Meeting`.

Whenever the operator identity or the presence toggle changes, the screen
publishes a presence update on the ring channel:

```ts
ring.setPresence({
  operatorId: ME.id,
  name:       ME.name,
  available:  presence === 'ready',
  region:     ME.region,
  certs:      ME.certs.map((c) => c.skill),
  since:      Date.now(),
});
```

Three details matter when you consume this pool:

- `available` is the only presence signal. Aux is published as
  `available: false`, not as a separate state.
- `certs` is flattened to **skill names only**. The level is not in the presence
  payload, so a dispatcher reading the pool sees which skills an operator holds,
  not at what level.
- The selected Aux reason (`Break`, `Training`, …) is **not** part of the
  payload. It is displayed locally on this screen only.

Aux also changes the screen itself: the Available-jobs card's status text reads
`paused — you are in Aux` instead of `listening`, and every row's **Attach**
button is disabled.

## How a job is matched: certification filter, then lowest RTT

Matching happens off this screen. The Ready screen's job is to advertise the
operator into the pool and to render whatever offer arrives.

The order is:

1. **Certification filter.** Only operators holding the job's required cert are
   candidates. The screen states this to the operator as the reason an offer
   would or would not reach them.
2. **Lowest RTT.** The dispatching ops console reads the presence pool of
   available operators and runs `claim_lowest_rtt` over it, so the candidate with
   the lowest round-trip time to that robot wins.

The resulting offer is delivered to one operator. The offer carries an
`operatorId`, and the ring only belongs to an operator when
`offer.operatorId` equals their own id.

> **NOTE:**
> In the current screen, the offer handler presents **any** offer that arrives on
> the channel rather than comparing `offer.operatorId` to `ME.id` — the demo
> channel is shared between tabs, and per-operator targeting on the client is
> noted in the source as a refinement. Server-side, the ACD has already picked
> the operator before it rings.

## The ring offer: fields, accept and decline

An offer opens a modal over the whole screen. The offer object exposes:

| Field         | Rendered as                                   |
| ------------- | --------------------------------------------- |
| `robotId`     | The headline of the modal.                    |
| `robotModel`  | Subline, with the site.                       |
| `site`        | Subline, with the model.                      |
| `rttMs`       | The "matched RTT" figure, in ms.              |
| `sessionId`   | The "session" figure, monospaced.             |
| `operatorId`  | Which operator the ACD picked.                |

**Accept** acknowledges the offer and moves the operator straight into the
cockpit on the assigned session:

```ts
ring.accept(call.sessionId, ME.id);
nav(`/cockpit?robot=${call.robotId}&session=${call.sessionId}`);
```

**Decline** acknowledges the offer with the same two identifiers and dismisses
the modal. The operator stays on the Ready screen and stays in the pool with
their current presence:

```ts
ring.decline(call.sessionId, ME.id);
```

Both paths clear the modal. The screen implements **no client-side ring
timeout** — an unanswered modal stays up until the operator accepts or
declines, or until the screen unmounts (at which point the ring channel is
closed).

## Where the session id comes from and how it reaches the cockpit

The operator console never mints a session id. The id is created by the match
that produced the offer — a `POST /v1/sessions` match on the engine rings this
console — and arrives as `offer.sessionId`.

From there it travels by URL. Accepting navigates to
`/cockpit?robot=<robotId>&session=<sessionId>`, both URL-encoded, so the cockpit
attaches to the session the platform assigned rather than opening a new one.

The **Attach** button on an Available-jobs row is a different path: it navigates
to `/cockpit?robot=<robot>` with **no** `session` parameter. Only the accepted
ring offer carries an assigned session id into the cockpit.

## The two ring transports

`RingChannel` is created once per screen (held in a ref) and carries both
presence and offers. The screen wires two delivery paths into the same modal:

- **BroadcastChannel.** Works cross-tab with no engine running. This is the path
  used for local and demo flows.
- **Engine realtime.** `ring.connectRealtime(tenant, url)` subscribes to the
  engine's `mod_realtime` channel `private-cti-<orgId>`, so a real match on the
  engine rings this console.

The relay URL comes from `VITE_TELEOP_REALTIME_URL`, falling back to the
platform's default relay host.

The tenant argument is the **org id** from `getOrgId()` — the same id every
other teleop call sends — and not a build-time constant. The BFF's `teleopSign`
signs a `private-cti-<orgId>` subscribe only for an org the caller is a member
of, and a compile-time constant cannot be checked against a membership.

On unmount, the screen calls `ring.close()`.

## The available jobs queue

`api.availableJobs()` backs the queue table. Each row exposes:

| Column        | Field     |
| ------------- | --------- |
| Robot         | `robot`   |
| Site          | `site`    |
| Required cert | `reqCert` |
| Offered RTT   | `rttMs`   |
| Waiting       | `waiting` |

The queue is empty by default. An empty queue renders an explicit message rather
than bare headers, because headers with nothing under them read as "stuck
loading": no robots need an operator right now, and offers appear the moment a
robot you are certified for raises a distress call. While the operator is in
Aux, the message appends that no offers will be made until they go Ready.

## Shift metrics on this screen

The **This shift** card shows four counters, labelled:

| Label            | Refers to                                           |
| ---------------- | --------------------------------------------------- |
| `sessions`       | Teleop sessions this shift.                         |
| `attached`       | Time attached to a robot this shift.                |
| `e-stops`        | Emergency stops this shift.                         |
| `avg divergence` | Average divergence, in degrees.                     |

These four values are **literals in the screen source**, not results of any
procedure the screen calls. `api.me()` and `api.availableJobs()` are the only
data the screen fetches. Treat the tile as a placeholder layout for shift
counters, and do not read the displayed numbers as live operator telemetry.

## Related

- [Authentication](/concepts/authentication) — how the org-scoped token that
  authorises the `private-cti-<orgId>` subscribe is minted
- [Telemetry](/platform/telemetry) — the metric and trace streams the gateway
  emits for sessions
