# Browser meeting bot provider

This pack gives the AgentOS meeting-participant channel (`@framers/agentos/io/channels/meetings`) a provider for Google Meet and Microsoft Teams. The provider hands each run to the scheduler on a worker machine over HTTP and authenticates the reports that scheduler forwards by the run's worker token. It never drives a browser itself.

## Installation

```bash
npm install @framers/agentos-ext-meeting-browser
```

Peer dependency: `@framers/agentos` (`>=0.15.9`), including the published meeting entry and `DispatchRefusedError(reason, message)` contract.

## Usage

```ts
import { EXTENSION_KIND_MEETING_PROVIDER } from '@framers/agentos';
import type { IMeetingBotProvider } from '@framers/agentos/io/channels/meetings';
import { createExtensionPack } from '@framers/agentos-ext-meeting-browser';

// The client credential comes from MEETING_SCHEDULER_TOKEN when no option or secret gives it.
const pack = createExtensionPack({
  options: { schedulerUrl: 'https://scheduler.example.com', workerId: 'worker-1' },
});
const descriptor = pack.descriptors.find((d) => d.kind === EXTENSION_KIND_MEETING_PROVIDER);
manager.registerProvider(descriptor!.payload as IMeetingBotProvider); // the host's MeetingManager
```

## Configuration

`createExtensionPack` throws when `schedulerUrl`, `workerId` or the client credential is missing. The credential resolves from `schedulerToken`, then the secret `meetings.schedulerToken`, then the environment variable that `schedulerTokenEnv` names.

| Option | Required | Default | What it sets |
| --- | --- | --- | --- |
| `schedulerUrl` | Yes | None, throws if absent | The scheduler's base url on the worker |
| `workerId` | Yes | None, throws if absent | The worker's id, kept on each run's dispatch record |
| `schedulerToken` | Yes, here or as the secret or the environment variable | The secret `meetings.schedulerToken`, then the environment | The client credential the scheduler accepts |
| `schedulerTokenEnv` | No | `MEETING_SCHEDULER_TOKEN` | The environment variable read for the credential |
| `botIdentity` | No | `bot` | The identity the bot publishes under in the run's LiveKit room |
| `platforms` | No | `['google_meet', 'microsoft_teams']` | The platforms this worker serves |
| `identityFor` | No | None: no identity is sent, and the scheduler takes its configured default | An async function of the run that answers the Google Meet identity the run joins with; asked at each dispatch of a Google Meet run, never for another platform. An answer outside `IDENTITY_NAME`'s form is refused `DispatchRefusedError('permission_denied', 'no_identity')` before any request |
| `fetchImpl` | No | The global `fetch` | The `fetch` the provider calls the scheduler with |
| `priority` | No | `50` | The descriptor's priority |

The pack returns one descriptor of kind `meeting-provider`, `browserMeetingProvider`, whose payload is a `BrowserMeetingProvider` named `browser`.

## What the provider does

- **Join links.** `checkJoinUrl` accepts `https://meet.google.com/xxx-xxxx-xxx` links and Microsoft Teams join links on `teams.microsoft.com` (`/l/meetup-join/...`) and `teams.live.com` (`/meet/...`). It refuses every other host, an address literal included, any scheme but `https`, a link that carries credentials, and a platform the worker does not serve.
- **Dispatch.** `dispatch` posts the run to the scheduler's `POST /runs` with the run's link, bot name, notice, stop phrase and limits, the room's url and publish token, the per-run worker token, the report url and, for a Google Meet run whose `identityFor` answers one, the identity it joins with. An answer outside `IDENTITY_NAME`'s folder-name form throws `DispatchRefusedError('permission_denied', 'no_identity')` before any request, since the scheduler's schema answers it `400`, an ordinary error the manager would retry. A scheduler older than the `identity` field drops it (its schema keeps the fields it knows) and starts the run on the first free signed-in folder of its pool, so upgrade the workers' scheduler before a provider whose `identityFor` answers a name. A `201` dispatches the run. A `409` with `error: 'no slot'` (every slot leased), `'no identity'` (the default identity held by a live run) or `'identity held'` (the named identity held by a live run) throws `NoCapacityError` carrying the word, which the manager puts on the run's `bot:left`. Exact `no signed-in identity` (the default identity not signed in, not provisioned or unreadable), `identity signed out`, `identity missing` and `paused` errors throw `DispatchRefusedError('permission_denied', 'no_identity')` for the first three and `DispatchRefusedError('permission_denied', 'paused')`. A scheduler older than the `no signed-in identity` word answers `no identity` whenever no identity is free, so it gets the capacity refusal. The manager ends these typed refusals unbilled without retrying them. An unread or unknown 409 body throws an ordinary error with a fixed status-only message and follows the manager's existing transient-fault policy. No response text or parser error is attached to any refusal.
- **Commands.** `command` posts `startCapture`, `suspendCapture`, `postChat`, `leave` or `kill` to `POST /runs/:id/command`. A `404` means the scheduler no longer holds the run, and is not an error.
- **Usage.** `usage` reads `GET /runs` and answers each run asked for (every run the scheduler lists when none is named) with `alive` (the scheduler lists it in a state other than `exited`) and the seconds the scheduler counted.
- **Deletion.** `deleteRun` calls `DELETE /runs/:id` and answers `{ deleted: false }`: the provider keeps no copy of a run. A `404` means the scheduler no longer holds the run. Any other refusal throws, so the purge that called it tries again.
- **Reports.** The scheduler posts each report to the run's report url as a `ReportPost` under `Authorization: Bearer <worker token>`. `verifyEvent` accepts a report whose bearer token hashes to the run's `dispatch.workerTokenHash`. `parseEvent` returns one event whose id is `browser:<run>:<seq>:<event>`, so a report delivered twice keeps one id.

Every request to the scheduler carries `Authorization: Bearer <schedulerToken>` and follows no redirect.

The provider declares itself a participant that posts and reads the chat, publishes the call's audio into the run's LiveKit room, records and transcribes nothing, keeps nothing, and leaves on the stop phrase in the chat: `{ joinsAsParticipant: true, postsChat: true, readsChat: true, audioPath: 'livekit_room', recordsAudio: false, transcribes: false, keeps: 'nothing', stopPath: 'chat' }`.

## The protocol

`src/protocol.ts` holds the zod schemas of the messages between the scheduler and a bot container (`JobMessage`, `CommandMessage`, `AckMessage`, `ReportMessage` and their union `ChannelMessage`) and of the scheduler's HTTP API (`StartRunRequest`, `ReportPost`, `RunStatus`, `Capacity`), with `reportEventId` and `IDENTITY_NAME`, the pattern of a Google Meet identity's name (lower-case letters, digits and hyphens, 1 to 32). The package root exports them all.

## The scheduler

The scheduler runs on each worker machine beside Docker. It leases a slot per run, starts one bot container per run under a fixed policy, holds each container's command channel, relays the provider's commands down it, and forwards the container's reports to the run's report url. The meeting manager's job store stays the source of truth: the scheduler holds only the runs it was given.

```bash
MEETING_WORKER_ID=worker-1 MEETING_SLOTS=2 MEETING_CLIENT_TOKEN_FILE=/srv/meeting/secrets/client \
MEETING_BOT_IMAGE=ghcr.io/framerslab/agentos-meeting-bot:0.1.0 MEETING_LISTEN=127.0.0.1:8788,10.90.0.1:8788 \
MEETING_CHANNEL_URL=ws://10.90.0.1:8788/channel MEETING_STATE_ROOT=/srv/meeting/secure/state \
MEETING_PROFILES_ROOT=/srv/meeting/secure/profiles MEETING_DEFAULT_IDENTITY=notes1 \
MEETING_SECCOMP_PROFILE=/srv/meeting/seccomp.json agentos-meeting-scheduler
```

| Variable | Required | What it sets |
| --- | --- | --- |
| `MEETING_WORKER_ID` | Yes | The worker's id: on every forwarded report, in `GET /capacity`, and on its containers' labels |
| `MEETING_SLOTS` | Yes | How many runs the worker holds at once |
| `MEETING_CLIENT_TOKEN_FILE` | Yes | A file holding the client credential (16 characters or more) the provider presents; the value itself never sits in the environment |
| `MEETING_BOT_IMAGE` | Yes | The bot image, by tag or digest |
| `MEETING_LISTEN` | Yes | The addresses it listens on, `host:port` separated by commas (`[::1]:8788` for IPv6) |
| `MEETING_CHANNEL_URL` | No | The channel url the containers reach; default `ws://<first address>/channel` |
| `MEETING_STATE_ROOT` | No | The folder of the runs' state files, on an encrypted volume; without it a restart takes no run back |
| `MEETING_PROFILES_ROOT` | No | The identity pool's folder; without it a Google Meet run naming no identity joins with a fresh browser profile, and one naming an identity is refused with `identity missing` |
| `MEETING_DEFAULT_IDENTITY` | With the pool | The identity a Google Meet run naming none takes, and that folder alone (for example `notes1`); the scheduler refuses to start with a pool and none |
| `MEETING_SECCOMP_PROFILE` | No | A seccomp profile file; the scheduler sends its JSON with each container's security options |
| `MEETING_LOG_DRIVER` | No | `journald` sets `HostConfig.LogConfig` to `{ Type: 'journald', Config: { tag: '{{.Name}}' } }` for each new bot container. Unset or blank omits the option and inherits the daemon's existing driver. Other explicit values fail startup with `invalid_MEETING_LOG_DRIVER`, without printing the value. |
| `MEETING_RESOLVE_ALLOW` | No | Test hosts, separated by commas, whose links it takes over `http` at any address; empty in production |

It logs JSON lines through AgentOS's `createLogger`. The lines carry run ids, states, reasons and counts, never a meeting link, a name, a chat line or a token. `SIGTERM` stops it and leaves the containers running for the next start to take back.

TLS belongs to the reverse proxy in front of the scheduler. Every route but `GET /health` takes the client credential as a bearer token:

| Route | Answer |
| --- | --- |
| `GET /health` | `200` with the worker id |
| `GET /capacity` | The `Capacity`: the slots, the busy count and the pause flag |
| `GET /runs` | A `RunStatus` per run it holds; an exited run stays listed for an hour after its exit, its seconds stopped at the exit |
| `POST /runs` | `201` with the run's `RunStatus`, also for the same run sent again with its token; `400` for a refused link or body; `409` with `paused` when paused, `no slot` when the slots are full, `no identity` when a Google Meet run naming no identity finds the default identity held by a live run, `no signed-in identity` when that default is not signed in, not provisioned or the pool cannot be read, and for a run naming an identity `identity held`, `identity signed out` or `identity missing`, the last too while no pool is configured; `413` for a body over 64 KiB; `503` when another start of the same run got there first |
| `POST /runs/:id/command` | The container's acknowledgement (`200`, or `500` when it refused or did not answer within 10 seconds); `kill` stops the container itself and answers `200`; a `leave` before the bot has opened its channel stops its container, and one while a reconnecting bot's channel is down answers `202` and goes down the next channel; `409` for any other command before the bot has opened its channel; `404` for a run it does not hold, and for any command but `kill` once the run has ended |
| `DELETE /runs/:id` | Stops and removes the container and forgets the run; nothing is reported for it afterwards |

**The join link.** A link must be a Google Meet or Microsoft Teams join link (the provider's `classifyJoinUrl`), and every address its host resolves to must pass AgentOS's `isPublicNetworkAddress` (from `@framers/agentos/io/media/images`, the rule AgentOS's own guarded fetches apply): loopback, private, link-local, carrier-grade NAT, documentation, benchmarking, multicast, reserved and unallocated ranges are refused, and so is an IPv6 address that carries a refused IPv4 one (IPv4-mapped, NAT64, 6to4), whatever its spelling. A host named in `MEETING_RESOLVE_ALLOW` is taken over `http` or `https` at any address, names its platform in its `platform` query parameter, and its job carries `adapter: 'fixture'`, so the bot drives the fixture page's adapter.

**The container.** Nothing customer-supplied reaches Docker. A container gets the image and limits of the scheduler's configuration, the bots' network (`meeting-bots`), no capabilities, a read-only root with `tmpfs` scratch folders, `no-new-privileges` and the seccomp profile, and only two environment variables: the channel url and the run id. The run's token goes to the container's stdin as one line, and stdin is then closed, so `docker inspect` never shows it. The job, the meeting link among it, travels over the command channel.

An explicit journal driver changes newly created containers only. Existing containers keep the driver they were created with. The scheduler does not change the Docker daemon, enable journal persistence, set a retention period, remove older log files or upgrade an already running container. Those are the operator's host procedures. The journal's own retention bounds determine how long original lines remain after a bot container is removed; the configuration alone is no retention receipt. See [Docker's journald driver](https://docs.docker.com/engine/logging/drivers/journald/).

**The command channel.** The bot opens `<channel url>` with `Authorization: Bearer <its run's token>`. A token no live run holds gets `401`, and a second channel for one run is closed with `4409`. The job is the channel's first message on every channel the bot opens, so a bot that reconnects after a restart picks up where it was; a leave taken while it was away goes down its next channel. Each run's reports go to its report url one at a time, in the order they came, under `Authorization: Bearer <run token>`. A post is tried three times with a 10-second timeout each, and a `4xx` answer other than `429` is not tried again.

An invalid JSON message or a message that fails the channel schema produces `channel_message_unread` at warn through the scheduler's host logger, with only `{ runId, kind }`: the leased run's id, as the scheduler's other lines carry it, and the kind. Kind is ack, report, job, command or other; an arbitrary discriminator never becomes a logged word. The allowance is one per leased run across channel reconnects, stored as the optional boolean `channelUnread` in its existing state file. Adoption preserves it and deletion removes it with that file. Older files without the boolean remain readable. A replacement dispatch starts a new lease and allowance. No rejected payload, schema error, original discriminator, message length or token is persisted or logged. A crash after the allowance is persisted but before output can lose the warning; a failed state write followed by a restart can repeat it. No durable diagnostic queue is added.

The unread callback is optional on the internal channel peer and cannot fail valid traffic if the host logger throws. Valid reports and acknowledgements retain their existing handling. Schema-valid messages in the wrong direction keep the existing silent-drop behavior. This diagnostic adds no product report or billing event. The default scheduler logger remains silent when no host logger is supplied.

**The identity pool.** Google Meet joins as a signed-in identity: each identity is a folder under `MEETING_PROFILES_ROOT` holding its browser profile, mounted read-write at `/home/bot/profile`. A run names its identity on the start request (`identity`) and takes that folder when it holds a `provisioned` file, holds no `needs_reauth` file and no live run holds it: one run per identity. A run naming none takes the identity `MEETING_DEFAULT_IDENTITY` names and that folder alone, whatever the other folders hold: the default is a name read from configuration, never the first free folder, so a run from a product that names no identity joins as the address its host invited. A bot that finds its identity signed out reports the error `needs_reauth`, and the scheduler writes `needs_reauth` in that folder, so the identity leaves the pool until it is signed in again. The refusals: for a run naming none, `no identity` while the default is held and `no signed-in identity` while it is marked, not provisioned or the pool cannot be read; for a run naming one, `identity held`, `identity signed out` and `identity missing` (`identity missing` too on a scheduler with no `MEETING_PROFILES_ROOT`, which has no folder of any name). A folder's files answer before its runs: one marked `needs_reauth` or holding no `provisioned` file is refused in those words even while a live run holds it. Microsoft Teams joins as a guest with no profile.

**Upgrading a worker with a pool.** A scheduler with `MEETING_PROFILES_ROOT` set and no `MEETING_DEFAULT_IDENTITY` refuses to start, so set the variable in the worker's unit in the same step that installs this version.

**Restarts.** With `MEETING_STATE_ROOT`, each run's material (its job, its token, its report url, its start time, its report sequence) is written to `<run id>.json` (mode `0600`) as it changes, and deleted with the run. At start the scheduler takes back every run whose state file and container it finds and watches the container's exit again. A container of this worker with no state file is stopped and removed. A state file whose container is gone is reported `crashed`.

**Ends and kills.** A container's exit is reported `done` (exit code 0) or `crashed`, unless the bot reported its own end first. A run told to leave that has not exited 30 seconds later, a bot that has not opened its channel two minutes after its start, and a run a minute past its whole length are killed, and the exit reports each as `crashed`. A dispatch sent again with the run's token answers the run it started; one with a new token replaces the earlier container, and nothing is reported for that container.

## The bot image

`image/` builds the container the scheduler starts for each run, from this folder (`docker build -f image/Dockerfile .`), since the bot shares `src/protocol.ts` with the scheduler: Debian 12 with Node 24, Chromium through Playwright 1.63.0, PulseAudio, Xvfb, `@livekit/rtc-node`, and the bot program in `image/src/`. One container is one run.

- **Start.** `entrypoint.sh` starts PulseAudio on `image/pulse/default.pa` alone: a null sink `meeting`, the browser's output, whose monitor the bot publishes, and a null sink `silence`, whose monitor is the browser's microphone. The root file system is read-only, so PulseAudio's runtime and state folders and its cookie are made under the `/run/pulse` tmpfs as the bot user, and Chromium's config folder (`XDG_CONFIG_HOME`), where its crash handler keeps its database and without which the browser does not start, is `/tmp/.config` in the `/tmp` tmpfs. Then Xvfb on display `:99`, then `bot.js`, which reads the run's token from stdin and opens the command channel at `MEETING_CHANNEL_URL` with it.
- **The run.** The job is the channel's first message. The bot joins the run's LiveKit room without publishing, launches Chromium on the identity's profile at `/home/bot/profile` when the run has one (a Google Meet identity from the pool) or on a fresh profile, and drives the platform's adapter: it reports `joining`, `waiting_room` and `in_call`, posts the notice, reads it back from the chat panel and reports `notice_posted`, then reads the panel every second. Each step of the posting is bounded and named, so a night that fails there reports the step rather than a bare timeout: `notice: chat_button_missing` (no Chat key was shown and the keyboard shortcut opened no panel), `chat_button_blocked` (the key would not take the click, a dialog over it, and the shortcut opened no panel), `chat_input_missing` (the panel opened without a composer-shaped control in any frame), `chat_input_disabled` (the composer is drawn disabled, read-only or aria-disabled, which the adapter reads as the meeting's chat being off for the bot: the outline's reading, not Google's statement), `chat_input_blocked` (a composer that took text would not take the fill), `chat_enter_blocked` (it would not take the Enter) or `chat_echo_missing` (the message was typed and sent and never read back). The message is read back as a new message among those the chat observer reads, so a line the panel already held, the bot's own notice after a rejoin among them, never stands for the one being sent. The `left` report of a notice that failed carries the step's code alone as its `message`, `notice: <code>`, so the product keeps the end and its detail from one event; the outline stays on the error report before it. A failed notice step's report carries, after its code, an outline of the chat panel (`image/src/adapters/chat-outline.ts`): for the main frame and each frame the page embeds, the frame's host and its counts of drawn controls by role (textbox, combobox, button, menuitem), of editables, of list items and of elements carrying a message id, the accessible names of the composer-shaped controls (and of the buttons in an embedded frame, whose controls are the chat's own; a menu item is counted and never named, since no lookup targets one and a menu may list people), each cut to forty characters of letters, digits and a few marks and at most twelve a frame after the filter below, the Chat key's state, the side panel's buttons by the allow-listed words of their names, the composer-shaped controls by their fixed facts and the allow-listed words of their names, and the lookups the step tried, for example `notice: chat_input_missing; panel: frames=2; f1 meet.google.com textbox=0 combobox=1 button=23 menuitem=0 editable=0 listitem=0 messageid=0 key=pressed:true,expanded:none pbtn=3,on:3,send:1(1); f2 chat.google.com textbox=1 combobox=0 button=6 menuitem=2 editable=1 listitem=3 messageid=3 cands=div,textbox,ce:1,dis:0,ro:0,ad:0,drawn:1,box:1,vis:visible,ah:0,name:aria,lbl:history,chat:0,land:none,words:history-on names=History is on|Send message; tried=composer:role,textarea,editable`. `key=` is the toolbar's Chat key (the first drawn button named "Chat with everyone") by its `aria-pressed` and `aria-expanded` values (`true`, `false`, `mixed`, `other`, or `none` when absent), the sign of an open panel where the client marks one. `cands=` lists up to four composer-shaped controls (a textbox by role, a textarea, a text-like input, an editable), the drawn ones first and `more:<n>` for the rest, each by fixed facts alone: its tag and role, then `ce` (editable), `dis` (the disabled attribute), `ro` (readonly), `ad` (aria-disabled on it or above it), `drawn` (it has a client rect), `box` (a width and a height, which Playwright's visibility read needs), `vis` (its computed visibility), `ah` (aria-hidden on it or above it, which Playwright's role lookup skips), `name` (how it is named: `labelledby`, `aria`, `label`, `title`, `placeholder` or `none`), `lbl` (whether its name begins as the lookups expect: `send`, `history`, else `other` or `none`), `chat` (an element above it is labelled for the chat), `land` (the nearest landmark above it) and `words` (the words of a fixed allow-list, `LABEL_WORDS` in `chat-outline.ts`, that its name holds, joined by `-` in the name's order, each once, a plural read as its word and an apostrophe dropped, or `none`: words for a chat stopped for the participant such as `off`, `turned`, `disabled`, `host`, `only`, `cant`; for a step a guest takes first such as `join`, `accept`, `invite`, `request`, `continue`; for the composer's own wordings such as `send`, `message`, `history`, `read`, `view`; for an outside guest such as `guest`, `external`, `signed`; for whether the chat is available such as `available`, `unavailable`, `isnt`, `wont`, `open`, `unable`; for a restriction and who sets it such as `restricted`, `locked`, `blocked`, `organizer`, `admin`, `policy`, `setting`, `domain`; for the meeting and its people such as `call`, `meeting`, `during`, `participant`, `attendee`, `member`; and for a continuation in Google Chat such as `google`, `space`, `group`, `conversation`, `new`, `here` and `continuous` (Google calls that chat "continuous meeting chat"); no word names a person). `pbtn=` is written when the frame draws a side panel (a complementary landmark: an `aside`, or the role written on a div): the drawn buttons under it (whatever landmark lies between, a header, a form or a region of the panel's own), `on:` how many of them are enabled (no `disabled` attribute and no `aria-disabled="true"` on them or above them), and, for each allow-listed word a button's name holds, `<word>:<drawn>(<enabled>)` in the list's order; a button's name is read for its words alone, and a button inside a message, or carrying a message's id (the Pin key beside one), counts for nothing, as it names nothing. A control outside any message whose name quotes a message's text is read for its listed words as any other control is; the words and the tallies never write the name itself. The facts tell apart a composer the adapter's reads pass over (boxless, hidden, under an aria-hidden element, named another way), a disabled one, a panel that never opened on its key, and, for a composer the client draws disabled under a label the filter below drops, whether the label and the panel's buttons speak of a stopped chat, of a step to take first (a `join` or an `accept` on an enabled button, say), of a view-only chat or of a guest's standing; a name is read for its kind, its start and its words and never written. The outline is fitted to its length (1,900 characters): when the frames' lines would pass it, every frame's counts, key and panel totals are written first, then each frame's first candidate, then the panels' tallies by word in rounds, then each frame's second candidate, and so on while they fit, then each frame's names while they fit, no token cut; when the counts alone pass it, the frames at the end are dropped whole; the lookups tried always stay at its end. It never holds an address or a link, nor a message's text or a participant's name that the frames draw: a control inside a message element names nothing; a name is tested whole, before any cut, and is dropped when it holds the whole of a tile's, a sender's or a message's text (a message's text read from the element marked as its text, from the element carrying its id, which is all a Google Chat conversation marks, from an item of a list, and from each element of the message that holds no other, so its sender's name and each of its lines count on their own), when such a text holds the whole of it, when it shares a run of three words with one, or when it holds a word of a participant's name, however the name spells them, each text compared whole, read both as the page gives it and with each element in it a word apart (a sender's name beside a message's first line, two lines split by a line break, a tile's name beside an icon's word), with a mark between two words read both as nothing and as a space (`alpha/beta`), a modifier letter written as an apostrophe (`Alʼs`) read as the apostrophe is, and a text or a word of fewer than three characters matched only as a word of the name, except in a script whose words run together (Chinese, Japanese, Korean, Thai, Lao, Khmer, Burmese), where it is matched anywhere in the name and each letter also counts as a word of a run; a name longer than two hundred characters is dropped without its comparison; a name that holds anything shaped like an address or a link (an `@` address, a scheme such as `https://`, `mailto:` or `tel:`, a protocol-relative `//`, a bare host such as `docs.example.com` with or without a path after it, or an IPv4 or an IPv6 address), read as written, with its compatibility forms decomposed and every invisible or combining mark dropped (a format character such as a zero-width space or a soft hyphen, a default-ignorable code point such as a combining grapheme joiner, a combining mark such as a dot above: none sets a scheme's colon or a host's dot apart) and ideographic full stops read as dots, or as it would be shown, is dropped whole, never cleaned and kept; the names are withheld altogether (`names=withheld`) when any frame went unread or any frame's reading stopped at its element limit, since the texts they must not quote may then be unread, and when those texts come to more than twenty thousand terms, which bounds the filter's work; and a frame is named by its host alone. A text the frames do not draw as a message, a sender or a tile (a message outside the part of a long conversation the panel shows, a participant with no tile) is unknown to the filter. The frames are read at once, all of them within three seconds, and a frame that does not answer in time is written as `unread` and holds up no other. A `monitor_heartbeat` goes every ten seconds, and only while the panel was read through in the last ten seconds.
- **The audio.** The call's audio is published into the room only on `startCapture`: a 48 kHz mono microphone track read from the `meeting` monitor with `parec`, with nothing written to disk. Thirty seconds without a good read of the chat suspend the track inside the container, whatever the manager's clock says, and `startCapture` is refused while the read is that stale, before the notice's chat has been read, or while the bot is leaving. Starts and suspends run one at a time, in the order they come: a `startCapture` that arrives while another is in flight publishes nothing more, so two commands close together publish one track and run one `parec`, and a suspend that arrives while a start is in flight waits for it, then stops the track and the capture that start began.
- **The chat and the stop.** Each message the bot did not post itself is reported as `chat_message`. A message that is the stop phrase is also reported as `stop_command`, and the bot leaves on it at once with "Leaving now; everything I took is being deleted.". The message is compared with the phrase as AgentOS's meeting manager compares it (`image/src/stop-phrase.ts`): case ignored, each run of whitespace read as one space and the whitespace around it dropped, and `.`, `!` or `?` allowed after it. A message that holds the phrase among other words is reported as `chat_message` alone, another bot's notice in the same meeting among them. A report carries a message's first 4,000 characters, and the bot decides the stop on that text, which is the text the manager reads (`image/src/chat-report.ts`). A message the adapter read from its whole element, because the client drew no body element for it, holds the sender's name and the time on lines of their own, and a line of it that is the phrase, other than the line of the sender's name, is a stop too; another bot's notice is one line that holds the phrase among other words, and stays a chat message. A job whose stop phrase is blank once its whitespace is folded is refused (`src/protocol.ts`), so no run starts with a stop phrase no message could match. The bot's own posts, the notice that names the stop phrase among them, are never reported: the bot knows them by the texts it posted, never by a sender's name, so a participant whose name is You is heard like any other. A message is remembered by the client's id for it, or, when its element carries none, by the element and its text, so the same text sent twice is reported twice.
- **The limits.** The bot leaves when the waiting room's limit passes (`not_admitted`), when the client's participant count shows nobody else for the alone limit (`alone`), and at the run's whole length (`cap`); the manager's clock is the backstop. A count the client does not show is unknown, never zero. The bot reports `participant_joined` and `participant_left` when the count changes, and `usage`, with the seconds since its start, as soon as its channel opens and once a minute after until it exits, whatever it is doing: AgentOS's meeting manager, given a usage silence (`usageSilenceMs`), ends a run whose worker sends no usage report for that long as `crashed` and kills its bot, counting from the dispatch, and a bot that is joining or waiting for its host to admit it reports nothing else.
- **Leaving.** On `leave` the bot suspends the track, posts its farewell, waiting ten seconds at most for it, so a farewell the chat is slow to take never keeps the bot past the thirty seconds a bot told to leave has before it is killed, presses Leave, reports `left` with the reason (and, for a notice that failed, `notice: <code>` as its message) and `done` with the seconds since its start, and exits with code 0. On `kill` it exits at once. A browser or a page that dies under the bot, a step that fails or a signed-out identity ends the run at once: the bot reports `left` (`crashed`) and `crashed` with its exit code, and exits with that code.
- **The network.** Chromium runs with service workers blocked, since a service worker's requests pass Playwright's routing by, and a route on every request: `https` only, no address literal as a host, the page's top-level document on the platform's join hosts alone, and every other request, a frame's document included, on the platform's hosts. Blocked requests are counted by host and reported once at the end, as the error `network_blocked: <host>x<count>,...`. WebSocket and media connections are the worker's firewall's.
- **Errors.** An error report carries a phase and a content-free code (`join: join_control_missing`, `browser: TimeoutError`), never a library's message, which can quote the meeting's link or a page's text. A Microsoft Teams guest types its name in the form the client's name field takes, as the client read a guest's name on 31 October 2026: letters, marks, numbers, spaces and `-` `'` `’` `.` `_` `@`, 50 characters at most. Each other character is typed as a space, the spaces folded, and a name past 50 characters is cut at its last space within them, so `Harbor notes (SaysHere)` joins as `Harbor notes SaysHere`. A mark goes with the character it sits on, kept on a letter or a number and dropped with a character the field refuses, and an emoji's variation selector or keycap is never kept, so `Notes ❤️ bot` joins as `Notes bot`. A name past 50 characters that closes on a label in parentheses keeps the label and cuts the words before it, so `Harbor and Sons Maritime Logistics International notes (SaysHere)` joins as `Harbor and Sons Maritime Logistics SaysHere` and the label that says whose bot it is still shows in the call, and a label that leaves no room for the words before it is typed alone; the bot's own messages are known by the name the field holds. A name with nothing the field takes, or one the field still marks refused, ends the join at once with `join_name_refused`, rather than Join now pressed behind a field that keeps it disabled. A Microsoft Teams join that cannot go on names its step and what held it (`join_page_timeout`, `join_continue_covered`, `join_name_readonly`, `join_name_refused`, `join_now_covered`, `join_now_disabled` and the like), read from Playwright's own words in its call log, and the report carries the page's outline after the code: the page's host and its path's first part, whether the device dialog's key was pressed, what covered the control by the client's own name for it, and the hosts of the page's frames (`join: join_now_covered; page: teams.microsoft.com light-meetings, dialog pressed, cover fui-DialogSurface__backdrop, frames login.microsoftonline.com`), never a query, a path past its first part or a word the page shows. A signed-in Google Meet identity that Google signed out or challenges reports `needs_reauth` before it asks to join, and the scheduler takes it out of the pool.
- **The sandbox.** Chromium runs without its own sandbox, which needs user namespaces that the container's dropped capabilities and seccomp profile refuse; the container is the boundary. `image/seccomp.json` is Docker's default seccomp profile, unchanged (`seccomp/default.json` of moby/profiles at `6fe7deb1`); the scheduler sends it with each container when `MEETING_SECCOMP_PROFILE` names it.
- **Logs.** The bot writes JSON lines through pino, each with the run's id: events, phases, reasons, codes and counts, never a link, a name or a chat line.

**The adapters.** `image/src/adapters/google-meet.ts` and `microsoft-teams.ts` each hold a selector table and a `selectorsVersion`, the date its selectors were last read against the live client, and the hosts the client may load. Every selector is one Playwright engine: CSS, a comma list included, one `text=` selector, or one `role=` selector, an element by its role and its accessible name, which survives a suffix on a label and a wrapper the label sits on. Their checks read what is visible, a read waits two seconds at most for its element, and the chat is read only while its panel is open. The Meet chat is read as open-source Meet clients read it in 2026, after the nightly check's first Meet run (24 October 2026) found no textarea to type the notice into: the toolbar's Chat key is a button named "Chat with everyone", and the composer is a `<textarea>` labelled "Send a message" in a meeting whose chat is Meet's own, or Google Chat's editable in a meeting whose chat is a Google Chat conversation (Workspace's Chat in Meet: the chat of a meeting scheduled in Calendar, for the host's own domain), a `contenteditable` div with the textbox role labelled "History is on", which one reading of April 2026 found under a wrapper that carries the "Send a message" label instead. A Google Chat conversation's panel draws that editable, its Send key and its messages inside a frame the panel embeds, on chat.google.com, where a page-level locator never looks (Playwright's `page.locator` is the main frame's): the nightly check's Meet run of 25 October 2026 opened the panel on its key and found no composer by any lookup (`chat_input_missing`). The adapter looks for the composer in every frame of the page, the main frame first and the frames read anew on each poll since the chat's frame attaches after the panel opens, by role and name first, then as the textarea, then as an editable by its shape, in any language, and, while the Chat key says its panel is open, as a textarea, a textbox or an editable inside the side panel (an `aside`, or the complementary role), whatever its label, which is how the nightly check's Meet run of 27 October 2026 found the composer: drawn disabled and aria-disabled under another label, which the outline reads as the meeting's chat being off for the bot, a signed-in guest from another organisation (an inference from the outline, not something a Google page states); a composer the client draws disabled, read-only or aria-disabled is named `chat_input_disabled` at once, by Playwright's read of its state (`isEditable`, whose actionability page, https://playwright.dev/docs/actionability, names the `disabled` attribute, `aria-disabled="true"` on an element above, `readonly` and `aria-readonly`; Playwright 1.63.0, which the image pins, reads `aria-disabled="true"` on the composer itself as well, which the lifecycle test pins), before any text is typed, with the panel's outline and `tried=state:<lookup>`. The key's word is its `aria-pressed` or `aria-expanded` (that run read `expanded:true` once the panel had opened): open when either reads true, closed when either reads false, and a key that marks neither is trusted once the panel opened on it in the page; so a field of another side panel never stands for the composer while the chat panel is closed, a composer found by the panel lookup alone is read again on every later post (the farewell, a message the manager commands) and in the observer's reads, and a key that says its panel is open is never pressed, since the key toggles the panel and a press would close it. Each lookup reads its first visible match, never its first match, since the client keeps screens it is not showing in the page; it closes a tip laid over the call ("Got it", "Dismiss", "Not now", "No thanks") before it presses the Chat key, opens the panel with Meet's shortcut (Ctrl+Alt+c) when the key is missing or will not take the click, types with Playwright's `fill`, sends with Enter and presses the Send key in the composer's frame once when Enter left the text there, and reads its own message back as a new message among those its observer reads in that frame: an element carrying `data-message-id` (in a Google Chat conversation the message's name, `spaces/…/messages/…`; the Pin key beside the message carries the same id and is never read as one) or an element with its sender's name. The observer reads the panel's messages from the frame the composer is in, so a stop phrase typed in a Google Chat conversation is heard. Every step waits ten seconds at most (five after the shortcut) and names itself when it fails, with the panel's outline beside the name. The Teams adapter reads the client Microsoft serves an anonymous guest as it drew its pages on 30 October 2026 (`teams.microsoft.com/light-meetings`, after the launcher's "Continue on this browser"), after the nightly check's Teams runs of 29 and 30 October 2026 ended `join_control_missing` with requests to `teams.public.onecdn.static.microsoft` blocked: the launcher and that client load their scripts from Microsoft's static CDN, so the hosts the client may load include the ones Microsoft's list of Microsoft 365 URLs and IP address ranges (https://learn.microsoft.com/en-us/microsoft-365/enterprise/urls-and-ip-address-ranges) names as required, `*.static.microsoft` and `*.usercontent.microsoft` (ID 193) and `*.lync.com` among the Teams hosts (ID 12), and Microsoft's sign-in CDN, `*.msauth.net`, `*.msauthimages.net` and `*.msftauthimages.net` beside `*.msftauth.net` (ID 59), from which the sign-in page the launcher opens in a frame, looking for a signed-in user, loads its scripts: the nightly check's Teams runs of 31 October 2026 on the worker ended `join: TimeoutError` with `aadcdn.msauth.net` blocked, while a run of the same image elsewhere joined. The name field is read by its `data-tid`, since its id is generated, and Join now is pressed once the name is in it. The client the bot's browser was served on 31 October 2026 finds no microphone or camera there and lays a dialog over the pre-join screen ("Are you sure you don't want audio or video?") whose backdrop takes every click until its one key, Continue without audio or video, is pressed: the nightly check's Teams run of 30 October 2026, on the image that loads Microsoft's CDN hosts, ended `join: TimeoutError` pressing Join now under it. The adapter waits three seconds at most for that key once the name is in the field and presses it, then presses Join now, each press a short one for thirty seconds and fifteen presses at most, each press, the dialog key's included, bounded by the time left, so a dialog laid over the key after that first look is closed before the next press and a dialog whose key takes no press never holds the join past the thirty seconds, and a press of the dialog's key that finds the dialog gone leaves the next round to Join now; with the key pressed, the client keeps Computer audio chosen, joins with no microphone and plays the call into the browser's output, the call's audio. The lobby is the screen under `[data-tid="calling-lobby-screen"]` or the lobby's words ("Someone will let you in when the meeting starts." before the meeting has started). The call draws no calling stage: its Leave key, `button#hangup-button` named by its title alone, is drawn in the call and not in the lobby, so it stands for the call and is the key the adapter presses to leave. The waits for the lobby and the call read the first shown match. The People key is the roster key (`button#roster-button`, or a key named People and nothing else), read at its first shown match, since a client may keep a hidden copy of it in the page and draws keys that name people without being it (Invite people). It reads "2 People" with one other person in the call and "People" while the bot is alone, so a key that reads "People" alone counts no one besides the bot, and other words with no number, or a key not read, are no count. The composer is CKEditor's editable, which that client draws as `[data-tid="ckeditor"]` itself and other builds inside it, and the adapter types into either; a message's element (`[data-tid="chat-pane-message"]`, the message's id in its own id) holds the text in `div#content-<id>`, and the author's name and the time sit beside it in the message's item (`[data-tid="chat-pane-item"]`), from which the adapter reads the author. The client draws the bot's own message at once, reading "Sending..." where its time goes, until the chat service takes it and gives it its time. It draws a message "Failed to send." for a moment even when the service took it, and gives it its time about half a second later, as it drew every message a guest sent on 31 October 2026 while the meeting's other side read each one once; a message the service refused keeps reading "Failed to send." and reached nobody. So the adapter counts a message posted only once its item shows its time (`time[id^="timestamp-"]`), watches a message drawn failed for its time through the pause before the next send (five seconds, then ten, and five after the last), and sends it again only when it still reads failed; one that reads "Sending..." again at the pause's end, as the client draws a message it sends once more on its own, is never sent again and is named `chat_send_unanswered`, since the service may still take it. The bot's own messages are the items under the name it joined with (`[data-tid="message-author-name"]`), so a participant's message with the same words never stands for the bot's, and the message a send drew is the next of them, since the client gives a message a new id when the service takes it. A refused message is sent again after five seconds, then after ten, and one the service refused at all three sends is named `chat_send_failed`; a message with no answer within fifteen seconds is not sent again, since the service may still take it, and is named `chat_send_unanswered`. Either ends the notice step as `notice_failed`, so the bot never listens behind a notice nobody saw. A message whose element holds no body element, in either client, is read from the whole element and marked so (`bodyRead: false`). `fixture.ts` runs the adapter of the platform the link's `platform` parameter names against the test fixture page, whose markup carries that platform's selectors; the bot takes it only for a job marked `adapter: 'fixture'`, and its network policy admits the fixture's own host alone, over plain `http` too.

## Tests

`test/provider.spec.ts` runs the provider against a stubbed `fetch`: the declaration, the join-link checks, dispatch and the `409`, report verification and parsing, commands, deletion and its refusals, and usage. `test/protocol.spec.ts` parses each channel message, refuses an unknown report event and a stop phrase that is blank once its whitespace is folded, and checks the event ids. `test/scheduler.spec.ts` runs the scheduler on an ephemeral port with a fake Docker (`test/fakes/docker.ts`), a fake product that records every report (`test/fakes/product.ts`) and real `ws` sockets: the credential, the slots and the container policy, the join-link checks, the channel's job, commands and reports, a second channel, exits, the bot's own end, restarts and stray containers, the kills, capacity and pausing, the fixture host, a dispatch sent again, deletion, a leave taken while the bot was away, report order and retries, the identity pool, refused bodies, start failures and the listen addresses. `image/src/__tests__/adapters.test.ts` checks the bot's adapters with no browser: every selector in the Google Meet and Microsoft Teams tables is one Playwright engine, the Meet chat's key and composer are named by role and accessible name with the earlier markup kept as fallbacks, the panel's composer is read by its shape inside the side panel alone and a composer drawn disabled is named `chat_input_disabled` by its state before any text is typed, and a fill that failed on one that took text `chat_input_blocked`, a second post through that composer presses the Chat key once whichever state the key marks (`aria-pressed`, `aria-expanded` or neither), a key that says its panel is open is never pressed, another panel's field is never taken while the key says the panel closed, each lookup reads its first visible match past a hidden one, the network policy's rule on each platform's hosts, schemes and address literals, the fixture adapter's platform, code and hosts, each adapter's reading of a message with and without its body element, through a fake page, the Teams adapter's press of the device dialog's key before Join now and its press of Join now when the dialog closed before its key was pressed, a guest's name typed in the form the name field takes (parentheses, punctuation and an emoji as spaces, a decomposed accent composed, a name past 50 characters cut at a space, and one that closes on a label in parentheses cut before the label, which it keeps) with the bot's own messages counted under that name, and a name the field refuses named `join_name_refused` at once, its join's named steps and the page's outline (a Join now under a dialog it does not know, the launcher's key under a cover, a name field that takes no text, a link that never loads), Microsoft's sign-in CDN among a Teams page's hosts, and its count of a message as posted once it shows its time, sent again when the chat service refused it and named `chat_send_failed` after three refused sends, the bot's own message alone past a participant's with the same words, and a message the service answered only after the wait sent once and named `chat_send_unanswered`, through a fake Teams page that keeps a clock, the Meet observer's reading of the panel through Google Chat's editable alone, its reading of the messages from the frame the composer is in, its refusal of a panel that shows no composer, its reading of the panel through the composer under another label while the Chat key says the panel is open and its refusal of another panel's field when the key says it is closed or there is no key, and the Meet adapter's posting of a message through a fake call: the panel opened on its key and the notice typed into each composer shape, in the page or in a frame the panel embeds, and read back as a new message from the composer's frame (one the panel already held stands for nothing), a tip closed first, the shortcut when the key is under a dialog or missing, the Send key in the composer's frame when Enter left the text, with the composer's text read within the step's wait, the step named when the composer, the key, the click or the echo is missing or the composer refuses the text or the Enter, and the panel's outline carried by the failed step's error (each frame by its host, the counts, the names, the lookups tried, in the record line's alphabet). The outline's own tests read a fake tree that draws the tiles, a sender, a message's text and a Pin key inside the message, and check that the outline counts them and quotes none of them, names no button of the main frame and no menu item anywhere, drops a name that quotes a participant in another spelling or by a word of the name or that holds an address or a link, keeps a safe name after a dozen that quoted a participant, withholds every name when the main frame went unread or a reading was cut short, stops at its element limit, names a frame by its host alone, reads the frames at once so that a frame that never answers holds up none after it, writes that frame as unread, writes each composer-shaped control's allow-listed label words in the name's order (a plural as its word, an apostrophe dropped, `news` never as `new`) or `none`, writes the side panel's buttons by those words with how many are drawn and how many of them enabled while naming no button, writes no panel token for a frame that draws no side panel, and writes whole the line of a page shaped as the nightly run of 28 October, whose disabled composer says the chat isn't available and whose disabled panel button opens Google Chat. `image/src/__tests__/audio.test.ts` runs the audio publisher against fakes of `@livekit/rtc-node` and `parec` whose publish waits until the test answers it: two starts at once publish one track and start one capture, and a suspend that comes while a start waits stops the track and the capture that start began. `image/src/__tests__/stop-phrase.test.ts` checks which chat messages are the stop phrase: the phrase whatever its case, the whitespace around and inside it, and its closing punctuation is; another bot's notice, its short notice to a late arrival and the phrase among other words are not. `image/src/__tests__/chat-report.test.ts` checks the bot's report of a message: its text and sender cut to the protocol's ceilings, the stop decided on the text as reported, and a line that is the phrase taken as a stop in a message read from its whole element only. CI runs all seven with the pack's tests.

The diagnostic cases also cover every exact refusal body and malformed or unknown bodies, both output streams, fixed error metadata, a real HTTP/WebSocket channel's invalid JSON and schema failures, the warning's run id, one allowance across reconnect and persisted adoption, a new run and replacement lease, valid reports and command acknowledgements, and a throwing diagnostic logger. Driver tests assert that an unset policy omits LogConfig, an explicit journal selection changes only that option, and unsupported values produce a fixed error. The real executable entry is imported with filesystem, Docker and server leaves substituted to prove environment-to-policy wiring. CI typechecks these fixtures separately, checks the built scheduler policy and public refusal dependency, and retains the existing package and browser lifecycle suites. Host journal persistence and post-removal retention need separate deployment evidence. `test/dispatch-refusals.spec.ts`, `test/channel-diagnostics.spec.ts`, `test/log-driver.spec.ts` and `test/scheduler-entry.spec.ts` hold these cases, and CI runs them with the seven above. The refusal and driver files also start a scheduler on an ephemeral port with the fake Docker: the provider reads the scheduler's own answers when it is paused, when its slots are full, when a live run holds its one signed-in identity, when its identity pool cannot be read, and when the pool is empty or holds only signed-out or unprovisioned folders, a run started under the journal policy gets that `LogConfig` and one started without it gets none, and an unsupported setting's error message is the fixed word alone.

`test/meet-identity.spec.ts` holds the Google Meet identity's cases, with a scheduler on an ephemeral port, the fake Docker, a fake product and identity folders made for each case. The start request takes an `identity` that matches `IDENTITY_NAME` and refuses another form. A run naming an identity takes that folder, also while another run holds a different one, and is refused `identity held` while a live run holds it, `identity signed out` while it is marked `needs_reauth`, and `identity missing` for a folder the pool lacks, for a pool that cannot be read and on a scheduler with no pool, where a run naming none still starts as a guest. A profiles root that is not there, is a file or cannot be read logs `the identity pool cannot be read` beside its refusal, which a readable pool that lacks the named folder never logs: the scheduler stats the root and each marker, since `fs.existsSync` answers false for a read that fails (https://nodejs.org/api/fs.html#fsexistssyncpath). A run naming none takes the configured default alone, by its name though a free folder sorts before it: `no identity` while a live run holds it and `no signed-in identity` while it is marked, not provisioned or unreadable, whatever the other folders hold. A scheduler with a pool and no default refuses to start, and a Microsoft Teams run gets no identity and no profile. The provider asks `identityFor` for a Google Meet run alone and sends its answer, maps each refusal word, and, dispatching through a real scheduler, gets the named folder mounted, a second run naming it refused as `NoCapacityError` carrying `identity held`, and a name the pool lacks refused `no_identity`; an `identityFor` answer outside the folder-name form is refused `no_identity` before any request. CI runs it with the files above.

**The lifecycle test.** `image/fixture/` is a meeting page that carries the Google Meet adapter's selectors: a pre-join screen (the name field, the camera and microphone toggles, Ask to join), a waiting screen, the call's tiles, the participant count, the chat panel and the Leave key, and an `<audio>` element that plays a known recording into the page's output, which is the call's audio. Its Meet chat is drawn as open-source Meet clients read it: the messages are the items of a list labelled "In-call messages", each with `data-message-id`, and the composer is the textarea labelled "Send a message" (the default), Google Chat's editable, a textbox div editable by a bare `contenteditable` attribute and labelled "History is on" (`chat=history`), that editable under a wrapper that carries the "Send a message to everyone" label with no role or label of its own (`chat=nested`), or a frame the panel embeds (`chat=frame`, the fixture's `chat-frame.html` at `/chat-frame`) that holds that editable, its Send key and the messages, with none of them in the page, as a Google Chat conversation's panel embeds its frame from chat.google.com; with `chat=none` the panel holds the messages and no composer at all, and with `chat=off` it holds the textarea drawn disabled and aria-disabled under another label, in a side panel that is an `aside`, as Meet drew it on 27 October 2026 for a participant the outline reads as having its chat off. With `chat=aria-disabled` it holds that textarea under the same label marked `aria-disabled="true"` on itself alone, with no `disabled` attribute, so the page still takes text into it, and with `chat=relabelled` that textarea enabled under a label of its own, which no lookup by name reads either. With `chat=unfillable` it holds a div with the textbox role labelled "Send a message" and no `contenteditable`, which Playwright reads as editable, by its role with no `aria-readonly`, and whose fill throws, since it is neither a field nor an editable. With `platform=microsoft_teams` in its link it carries the Microsoft Teams adapter's selectors instead, drawn as the client Microsoft served an anonymous guest on 30 October 2026: the name field known by its `data-tid` with a generated id and Join now disabled until a name is typed, the field holding 50 characters and, as the client read a guest's name on 31 October 2026, refusing a name with any character outside letters, numbers, spaces and `-` `'` `’` `.` `_` `@` (marked `aria-invalid`, the client's words for its rule in an alert, Join now disabled), the page reporting the name Join now took, the lobby under `[data-tid="calling-lobby-screen"]` with the lobby's words, a call with no calling stage whose Leave key is `button#hangup-button` named by its title, a People key that reads "People" while the bot is alone and "2 People" with one other person, after a hidden copy of it and an Invite people key, and a message as an item (`[data-tid="chat-pane-item"]`) that holds the author's name and the time beside the message's element (`[data-tid="chat-pane-message"]`), whose text is in `div#content-<id>`; the composer is CKEditor's editable inside `[data-tid="ckeditor"]`, or that element itself with `composer=self`, the shape that client draws. As the client drew it for the bot's browser on 31 October 2026, the Teams page lays the device dialog over the pre-join screen from the start, its backdrop taking every click until Continue without audio or video is pressed, and draws the bot's message as "Sending..." until it answers: the first `sendFails` messages the bot sends (1 when the link names none) read "Failed to send.", and the rest are given their time and only then kept as posted; with `answers`, a comma list of `take`, `refuse`, `hold` and `flicker`, the page answers each of the bot's messages in turn as the list says, a held message never answered, a flickering one kept as posted at once and drawn "Failed to send." with its Retry key from 100 ms after the send, for a second, before its time, and takes every message past the list's end; with `dialog=unknown`, the dialog over the pre-join screen is one the adapter does not know, its words and its key another dialog's, and it covers Join now until the run ends. Its server, `serve.mjs` on port 8787, queues scripted events from `POST /control` (`admit`, `late_joiner`, `chat` with a sender and a text, `noId` for a Meet message drawn without `data-message-id` and `noBody` for a Teams message drawn without its body element, `hide_chat`, `show_chat`, `leave_all`) for the page to poll, and keeps what the page did on `GET /state`: its knocks, the texts the bot posted, its presses of Leave. The recording is spoken from `recording.txt` by espeak-ng when the fixture's image is built: about twenty seconds of 16 kHz mono speech. `image/test/compose.yml` runs LiveKit 1.13.7 in development mode (its documented key pair, `devkey` and `secret`), the fixture, a fake product that records every report with a hash of the token it came under, and the scheduler on this pack's build, which starts real bot containers from `agentos-meeting-bot:ci` on the `meeting-bots` network under its fixed container policy, the seccomp profile included. `image/test/lifecycle.mjs` sends fourteen runs through the scheduler's API and listens to each run's room with `@livekit/rtc-node`:

- The first run joins and waits in the waiting room for 70 seconds, as a host slow to admit a bot keeps it, reporting `usage` at least twice from its start to its admission and never 65 seconds apart, then is admitted, posts the notice, reads it back and reports a heartbeat within 12 seconds of it. No audio frame reaches the room before `startCapture`.
- After `startCapture` the recording is heard in the room: above 0.01 RMS in at least three of five seconds, the first frame after `notice_posted`.
- With the chat panel hidden the heartbeats stop, the bot suspends its own capture within 45 seconds and the audio stops. With the panel shown the heartbeats come back and the capture starts again.
- A late joiner is reported as `participant_joined`, and the short notice is posted.
- Another bot's notice in the chat, which names the stop phrase, is reported as `chat_message`, and no `stop_command` or `left` follows in five seconds. A participant's stop phrase is reported as `chat_message` and `stop_command`. The bot posts "Leaving now; everything I took is being deleted.", presses Leave, and reports `left` (`stopped_by_participant`) and `done`. No audio reaches the room after `left`, and the container is removed.
- A second run, admitted and confirmed but never sent `startCapture`, publishes no track for 15 seconds, then leaves on the `leave` command with its reason.
- A third run, sent two `startCapture` commands at once, publishes one track. The same short message sent twice by messages without a `data-message-id` gives two `chat_message` reports, and the stop phrase from a participant whose name is You is obeyed.
- A Microsoft Teams run with the chat's editable inside `[data-tid="ckeditor"]`, and another with that element itself as the editable, each join through the name field by its `data-tid`, the bot's name `Notetaker (test)` typed as `Notetaker test`, the form the field takes, and past the device dialog's key, wait in the lobby, count the one other person on the People key, past a hidden copy of it and an Invite people key, once admitted to the call its Leave key stands for, post the notice and read it back (the first run's notice and farewell each drawn failed for a moment before their time, and each posted once; the second run's notice sent again after the page refused the first send), report the same short message sent twice as two `chat_message` reports, report each message as its body with its author read from the message's item, and leave on the stop phrase by `button#hangup-button`, which the second run sends in a message drawn without its body element; the second run's farewell is refused twice and then never answered, and that bot reports `left` within twenty seconds of the stop, its farewell never posted.
- A Google Meet run whose chat panel holds Google Chat's editable, a textbox named "History is on", and another whose panel holds that editable under a wrapper carrying the "Send a message" label, neither with a textarea, each post the notice and read it back, report a participant's message as typed, and leave on the stop phrase with the farewell posted through the same composer. Before the adapter read these shapes, each ended as the nightly check's Meet run of 24 October 2026 did: `notice_failed`, thirty seconds after `in_call`.
- A Google Meet run whose chat's composer, Send key and messages are inside a frame the panel embeds posts the notice through the frame's editable and reads it back from the frame, reports a participant's message drawn in the frame as typed, and leaves on the stop phrase typed there. Before the adapter looked in the page's frames, it ended as the nightly check's Meet run of 25 October 2026 did: `notice_failed` with `notice: chat_input_missing`, ten seconds after `in_call`.
- A Google Meet run whose panel holds no composer at all reports `notice: chat_input_missing` with the panel's outline after it (the fixture's host, the counts of controls by role and of the message drawn before the panel opened, the Chat key pressed, the pre-join name field as the one composer-shaped control, undrawn under its hidden section, and the lookups tried), in the record line's alphabet and quoting neither that message, its sender nor a tile's name, and leaves with `notice_failed` and `notice: chat_input_missing` as the `left` report's message.
- A Google Meet run whose panel holds the composer drawn disabled and aria-disabled under another label, in a side panel that is an `aside`, reports `notice: chat_input_disabled` at once, with the panel's outline after it (the Chat key pressed, the disabled textarea as the drawn candidate in the complementary landmark, the pre-join name field undrawn after it, and `tried=state:panel`), posts nothing, quotes no tile's name, and leaves with `notice_failed` and `notice: chat_input_disabled` as the `left` report's message. Before the adapter read the panel's composer by its shape and its state, it ended as the nightly check's Meet run of 27 October 2026 did: `notice: chat_input_missing`, ten seconds after `in_call`.
- A Google Meet run whose panel holds the composer enabled under another label, which the adapter finds by its shape and its place in the panel alone, posts the notice and reads it back, reports a participant's message as typed, and leaves on the stop phrase with the farewell posted through the same composer, the panel left open: the Chat key, which toggles the panel and marks its state in `aria-pressed`, is pressed once. Before the adapter read the key's state, the farewell pressed the key again, which closed the panel, and failed as `chat_input_missing`.
- A Google Meet run whose panel holds the textarea of the disabled run above, under its label, marked `aria-disabled="true"` on itself alone, with no `disabled` attribute, reports `notice: chat_input_disabled` at once, its outline reading the composer as `dis:0,ad:1` with `tried=state:panel`, posts nothing though the page would take the text, and leaves with `notice_failed` and `notice: chat_input_disabled` as the `left` report's message. Playwright's actionability page names `aria-disabled` only on an element above the target; this run pins that Playwright 1.63.0 reads it on the composer itself too.
- A Microsoft Teams run whose pre-join screen lies under a dialog the adapter does not know reports `join: join_now_covered` with the page's outline (the fixture's host, the device dialog's key not pressed, what covered Join now, no frame), in the record line's alphabet and quoting nothing the dialog says, and ends `crashed`.
- A Google Meet run whose panel holds a composer that says it takes text and takes no fill reports `notice: chat_input_blocked`, never `chat_input_disabled`, with the panel's outline after it and `tried=fill:role`, posts nothing, and leaves with `notice_failed` and `notice: chat_input_blocked` as the `left` report's message.
- `docker inspect` of each live container shows neither its meeting link nor a token, and shows no bind mount, the browser profile in a tmpfs, a read-only root, no capabilities and the seccomp profile.
- Every report reaches the product under its run's token.

Each step prints one line. The first failed check of the first two runs and of the last two checks prints the reports received so far, by sequence number, event and time, and exits 1. The twelve runs between them go on past a failed check where their run allows, and the test then fails at its end naming every check of theirs that failed. From this folder, once the pack is built:

```bash
docker build -f image/Dockerfile -t agentos-meeting-bot:ci .
openssl rand -hex 24 > image/test/client-token.txt
docker compose -f image/test/compose.yml up -d --build
npm install --prefix image/test --no-audit --no-fund
node image/test/lifecycle.mjs
docker compose -f image/test/compose.yml down -v
```

## The image, the nightly check and the measurement in CI

**The bot image** ([`meeting-bot-image.yml`](https://github.com/framerslab/agentos-extensions/blob/master/.github/workflows/meeting-bot-image.yml), "Meeting bot image") runs on every pull request that touches this folder. It builds the pack, checks that `image/seccomp.json` is byte for byte `seccomp/default.json` of moby/profiles at `6fe7deb1` and prints its sha256, builds the bot image, starts its Chromium under the scheduler's container policy with the image's own entrypoint and `image/test/browser-smoke.mjs` in the bot program's place (it prints Playwright's own error when the browser does not start, which the bot's content-free log does not), and runs the lifecycle test above against the compose stack. A push to `master` that touches this folder also pushes the image to GHCR as `ghcr.io/framerslab/agentos-meeting-bot:sha-<commit>` and prints the pushed digest. The push runs in a job of its own, the only one in the workflow that can write packages: it loads the image the test ran against, while the build and the test, which install dependencies and run their scripts, hold a read-only token. A released version's image is tagged `:<version>` by the release workflow (`release.yml`) once that version is published: it copies the `:sha-` image of the newest commit to `master` that changed this folder at or before the release, with no rebuild, and never moves a version's tag already on GHCR. The image's dependencies are installed from `image/package-lock.json`; a branch without one gets one resolved by the workflow and offered as the artifact `image-lock`, to commit.

**The nightly real-join check** ([`nightly-real-join.yml`](https://github.com/framerslab/agentos-extensions/blob/master/.github/workflows/nightly-real-join.yml), daily at 03:17 UTC and by hand) joins meetings it makes in the team's own tenants, so no one else's meeting is touched. Each platform is a job of its own, `meet` for Google Meet and `teams` for Microsoft Teams. `image/test/nightly/create-meetings.mjs` makes a Google Calendar event with a Meet on a host user's calendar with the bot's identity invited (a service account with domain-wide delegation for the Calendar events scope; `--meetings <n>` makes `n` of them, one a run) and a Microsoft Teams meeting for an organiser user that lets everyone past its lobby, since no person attends it (an app registration with the application permission `OnlineMeetings.ReadWrite.All` and an application access policy granted for that user). `image/test/nightly/real-join.mjs` refuses a Meet whose organiser is not the host user and a Teams link outside the team's tenant, sends the bot to each through the worker's scheduler, sends `startCapture` after the notice and a heartbeat and `leave` after a usage report, and writes per run to `image/test/nightly/outcome.json` (each platform's runs as `platforms.<platform>.runs`, each with its `runId` and its `identity`) whether the bot was admitted, posted its notice, was heard (its capture started and a usage report followed) and left, with the seconds from its joining report to each. Both scripts take `--platform google_meet` or `--platform microsoft_teams`, and each job runs them for its own platform alone; with neither they do both. `real-join.mjs --identity <name>`, repeatable, sends one Google Meet run a named identity, at once, the i-th into the i-th link of `MEET_URLS`, each naming its identity on the start request (an event whose Meet never came keeps its place in `meet_urls` as an empty entry, and its identity alone is answered as a meeting not made); with none, one run naming no identity, which the scheduler gives its configured default. By hand the workflow takes three inputs: `identities`, comma-separated folder names the Meet job sends one run each for, at once, each naming its identity on the start request and each into a meeting of its own, the job making one meeting a name (empty, one run naming none into one meeting, as the schedule sends it); `copy_from`, a folder on the worker copied into the last named identity before the joins, with its `provisioned` marker and no `needs_reauth` marker (empty, nothing is copied); and `delete_after`, a folder the copy step made, deleted by name in the job's last step (empty, nothing is deleted). The two steps reach the worker as `bots` through the measurement's SSH key, inside its window. `bots` reads and writes a folder's markers alone: the profile in it belongs to the bot image's user (uid 10001), which Chromium keeps 0700 and 0600, so each step copies or deletes as root in a throwaway container of the newest bot image the worker holds (`--network none`, a read-only root, the pool mounted at `/profiles`, every capability dropped but those `cp -a` needs to keep each file's owner, mode and times, or `DAC_OVERRIDE` alone for the delete), and refuses before any container when the folder to copy into is taken, the source holds no `provisioned` marker or the worker holds no bot image. A profile a live run holds is mid-write and carries Chromium's process-singleton files in its folder (`SingletonLock`, a symlink to a name that is not there, `SingletonCookie` and `SingletonSocket`; https://chromium.googlesource.com/chromium/src/+/main/chrome/browser/process_singleton_posix.cc), so the copy step first reads the scheduler's `GET /capacity` and refuses while it counts any live run, since it counts runs and names no run's identity, refuses on the worker when the source holds `SingletonLock`, and leaves the three files out of the copy. A copy that fails part way, or whose source holds `SingletonLock` once it is done (a browser opened it while the copy ran), is removed as root, so no half copy refuses the next dispatch; a copy that completes carries a `copied_from` marker naming its source. The delete step deletes only a folder that carries `copied_from`, which no signed-in identity carries (the scheduler's configured default among them, whatever its name), and refuses one holding `SingletonLock`, both before any container. These checks read the folders' files: the scheduler offers no hold on an identity that a workflow can take, so a run that opens and closes the source within the copy's own seconds is not seen. On a dispatch naming identities, a step just before the delete prints each named folder and its `provisioned` and `needs_reauth` markers as present or absent, reading nothing inside a file, so the run's own log holds the markers of a folder its last step deletes. A job fails when a run of its platform did not reach `in_call` or `notice_posted`; three failed nights in a row for one platform are the signal to pause it with the meeting manager's `pausePlatform` until a check passes. Each job removes its meetings at its end, and keeps its `outcome.json` and `reports.json` (every report of the run as the sink served it: sequence, event, time, and of its data a reason, seconds, a participant count or an error's code, never a link, a name or what anyone said) as the artifact `nightly-meet-reports` or `nightly-teams-reports` for seven days, whatever its end: the record a failed night is read from. A check that fails on its own after its run started, on a command the scheduler no longer answers, keeps the reports it had read, records the failure among its errors and fails its job. The scheduler posts the check's reports to a report sink beside it on the worker, `http://127.0.0.1:8789/report`, which serves a run's reports as a JSON array on `GET <scheduler url>/sink/<run id>` behind the scheduler's client credential. The jobs read these repository secrets by name. A `secrets` job runs first and reads which are set: a platform whose secrets are not all set is skipped, its job shown as skipped and the run's summary naming the missing ones, while the other platform's job runs.

| Secret | What it is | Read by |
|---|---|---|
| `MEETING_SCHEDULER_URL`, `MEETING_SCHEDULER_TOKEN` | the worker's scheduler and its client credential | `meet` (its copy step too, for `/capacity`), `teams` |
| `MEET_CALENDAR_SA_JSON`, `MEET_HOST_USER`, `MEET_BOT_IDENTITY` | the service account's key, the user whose calendar hosts the Meet, the bot identity's address | `meet` |
| `TEAMS_GRAPH_TENANT_ID`, `TEAMS_GRAPH_CLIENT_ID`, `TEAMS_GRAPH_CLIENT_SECRET`, `TEAMS_ORGANIZER_USER_ID` | the app registration and the user the Teams meeting is made for | `teams` |
| `NIGHTLY_LIVEKIT_URL`, `NIGHTLY_LIVEKIT_API_KEY`, `NIGHTLY_LIVEKIT_API_SECRET` | the LiveKit server the bots publish into, as the worker reaches it, and its key pair | `meet`, `teams` |
| `WORKER_HOST`, `WORKER_SSH_KEY` | the worker and the measurement's SSH key for its `bots` user | `meet`'s copy, marker and delete steps alone, on a dispatch naming their folders; the `secrets` job does not gate on them |

**The measurement** ([`measure-meeting-bots.yml`](https://github.com/framerslab/agentos-extensions/blob/master/.github/workflows/measure-meeting-bots.yml), "Measure meeting bots", by hand with `concurrency`, `minutes` and the transcriber's `price_per_min`) measures a worker's capacity on the worker itself. Over SSH (the secrets `WORKER_HOST` and `WORKER_SSH_KEY`) it copies the fixture, the seccomp profile and `image/test/measure/` to `/srv/meeting/measure` and runs `measure.sh` as the worker's `bots` user. That starts a stack of its own on the bots' network: LiveKit 1.13.7 with a key pair made for the run, one fixture per bot, and a scheduler of the newest published version of this pack with those fixtures as its test hosts, which starts the bots from the published image of that version. `measure-listen.mjs` sends each bot to its fixture, admits it, sends `startCapture` after the notice and a heartbeat, and listens to each run's room with AgentOS's `startListenSession` on `LiveKitStreamTransport` at 16 kHz with `DeepgramStreamingSTT` (its key read from `/srv/meeting/secrets/deepgram` on the worker). After the given minutes it writes `measurement.json`, the workflow's artifact `measurement`: each bot's memory and CPU (median and peak over the window, from `docker stats` every 15 seconds), the LiveKit server's, the harness's and the scheduler's load (also per concurrent run), the seconds sent to the transcriber and their cost at the given price, the word error rate of each run's transcript against `recording.txt` (a token-level edit distance against the looping recording), and the slots the worker carries with the bots at their peak under 70 percent of its CPU and memory. While it runs, the bots reach the stack's containers on the bridge: the fixtures on 8787, LiveKit on 7880, 7881 and 7882/udp, and the scheduler's channel on 8798. Neither runs on a pull request or a push: the nightly check runs on its schedule or by hand, the measurement by hand only.

## License

Apache-2.0
