oficina / meet Public
Slice 2 copy list
Copy review waived for this round per suite discussion 5; catalogued for the later pass.
This covers every user-facing string introduced on branch slice/2-room (77227de..6354af6): the holding screen's scripted half, the room and its client, the removal and takeover confirm pages, and the one AGENTS.md prose change a person could trace back to a screen. Sources: git diff 77227de..6354af6 -- internal/calls/templates internal/calls/static internal/calls/handlers_room.go internal/calls/handlers_join.go AGENTS.md, task-6/7/8-report.md's own "new/changed user-facing strings" lists, and the reviewers' copy notes recorded in .superpowers/sdd/2026-09-10-meet-slice-2-room/progress.md. No string below has been through copy-review; none was changed to write this file.
A note on the JSON refusal bodies: stream.go's 410 body carries only a machine reason code (buffer, replaced, standing, expired, removed, ended) in body["reason"] — nothing in that JSON is prose a person reads. room.js's terminal(reason) looks the code up in its own TERMINAL table to get the sentence a person actually sees, so the sentences in the Terminal sentences table below are the complete set of what those refusal bodies can cause to appear.
Holding page (/m/{sqid}/join)
templates/join.html
| String | Where | Note | | --- | --- | --- | | This meeting is already open in another tab. | join.html, content block, OpenElsewhere notice | Reviewer note (progress.md, Task 6 review): untrue during the resume window — a session whose stream has merely lapsed, not collided, still sees this. Suggested replacement, not applied to code: "You are already in this meeting from this browser." | | Use this tab instead | join.html, content block, link inside the OpenElsewhere notice, to /m/{sqid}/takeover | Same notice as above; this is the link text, not a separate sentence. | | The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it. | join.html, content block, <noscript> | | | Your camera preview | join.html, content block, aria-label on #preview | Added in Task 7's fix round (task-7-report.md item 11). | | Camera | join.html, content block, field-select Label for #pick-camera | Same word is the fixed aria-label/button text below, on #toggle-camera. | | Microphone | join.html, content block, field-select Label for #pick-mic | Same word is the fixed aria-label/button text below, on #toggle-mute. | | Speaker | join.html, content block, field-select Label for #pick-speaker (row hidden unless setSinkId is supported and a speaker is found) | | | Microphone | join.html, content block, #toggle-mute — aria-label and visible button text | Task 7 fix: the accessible name is now fixed; only aria-pressed and the state word move. Replaces earlier "Mute"/"Unmute" wording (task-7-report.md item 10). | | Camera | join.html, content block, #toggle-camera — aria-label and visible button text | Same fix; replaces earlier "Camera off"/"Camera on". | | Join the call | join.html, content block, #join button | |
static/devices.js
| String | Where | Note | | --- | --- | --- | | This browser cannot reach a camera or microphone here. You can still join with neither. | devices.js, top level (no navigator.mediaDevices.getUserMedia) | "You can still join with neither" recurs in all four device-error/no-support sentences below — the room is built to allow joining with no local media, and every sentence says so the same way. | | Asking for your camera and microphone… | devices.js, startPreview() | Task 7 fix: now said before the getUserMedia call, not only after it fails (task-7-report.md item 9). | | Meet needs permission to use your camera and microphone. You can still join with neither. | devices.js, errorSentence() (NotAllowedError) | | | Your camera or microphone is already in use by another app. You can still join with neither. | devices.js, errorSentence() (NotReadableError) | New in Task 7's fix round; previously fell through to the generic sentence (task-7-report.md item 8). | | Meet could not reach a camera or microphone. You can still join with neither. | devices.js, errorSentence() (default — anything but NotAllowedError/NotReadableError) | Task 7 fix replaces an earlier "No camera or microphone was found…" (task-7-report.md item 8). | | Muted / On | devices.js, applyToggles(), #toggle-mute-state | State word, never colour alone; moves with prefs.muted. | | Off / On | devices.js, applyToggles(), #toggle-camera-state | State word; moves with prefs.cameraOff. | | Camera <n> | devices.js, labelFor() | Fallback option label in #pick-camera when the browser gives no device.label (before permission is granted). | | Microphone <n> | devices.js, labelFor() | Same fallback, for #pick-mic. |
Room page (/m/{sqid})
templates/room.html — an entirely new file this branch. Several of its lines are the holding screen's own wording, reused: noted per row.
| String | Where | Note | | --- | --- | --- | | Starts {{.Starts}}. | room.html, content block (meeting has a start time) | Mirrors the holding screen's identical line (already catalogued for slice 1). | | You are {{.Name}}, joining as a {{.Standing}}. | room.html, content block | Task 6 changed this from an initial "in the call as a <kind>." to match the holding screen's wording exactly — true before any participation exists, since this renders server-side ahead of anything room.js does (task-6-report.md item 14). | | This team's Meet has no relay yet. Calls usually connect only between people on the same network. | room.html, content block, floor notice (no relay configured) | Mirrors the holding screen's identical notice. | | The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it. | room.html, content block, <noscript> | Same sentence as the holding screen's <noscript>, with a link appended (next row) the holding screen's version doesn't have. | | Back to the holding screen | room.html, content block, link inside the <noscript>, to /m/{sqid}/join | Same wording room.js's own wayOut() uses for its link of the same name (see Links, below) — the scriptless and scripted paths land on the same words. | | Microphone | room.html, content block, #mute — aria-label and visible button text | Same fixed-name pattern as the holding screen's toggle. | | Camera | room.html, content block, #camera — aria-label and visible button text | Same fixed-name pattern as the holding screen's toggle. | | Leave | room.html, content block, #leave button (rst-btn="danger") | | | Waiting to join | room.html, content block, #queue heading (h2) | Shown only when the admission queue is non-empty. | | Meetings | room.html, content block, back-nav Label (members only) | Same established back-nav label used across the app (already catalogued for slice 1); reused here, not new wording. | | End the meeting | room.html, content block, member-only link to /meetings/{sqid}/end | Same established link text (already catalogued for slice 1); target="_blank" rel="noopener" is new here — the room holds the call in this tab, so ending opens in a new one. |
static/room.js
Status sentences (renderStatus(), via say())
| String | Where | Note | | --- | --- | --- | | The room is full. | room.js, renderStatus() | Shown while state.full is set, cleared by the next roster. | | Waiting for someone to let you in. | room.js, renderStatus() | Shown to a guest that has knocked (joined, not yet admitted). A member in the same window is "connecting", never "waiting" — a member is admitted by its own join. | | Connecting to the call. | room.js, renderStatus() | Default state between hello and admission, for a member or a guest that hasn't joined yet. Task 8 review flagged the risk of this sentence showing forever behind an unnamed refusal; the stall counter (MAX_STALLS/STALL_WINDOW) and the terminal sentences below are the fix. | | You are the only one here. | room.js, renderStatus() | Shown once admitted with no other peers in the mesh. |
Terminal sentences (TERMINAL, said by terminal())
| String | Where | Note | | --- | --- | --- | | This meeting is open in another tab. | room.js, TERMINAL.replaced | Carries two links: the takeover, which is its real next step, and "Back to the holding screen" behind it (task-10-report.md, part C). The browser gate locates the takeover by href rather than reading #status a as a single element. | | You no longer have access to this meeting. | room.js, TERMINAL.standing | | | Your invitation to this meeting has expired. | room.js, TERMINAL.expired | | | Someone removed you from this meeting. | room.js, TERMINAL.removed | | | This meeting has ended. | room.js, TERMINAL.ended | Also the fallback for any reason code TERMINAL doesn't recognise. | | You cannot join this meeting. | room.js, TERMINAL.gone | Task 8 review: "the fresh-404 sentence must be neutral" — replaces an earlier "This meeting is no longer open." (progress.md Task 8 note; task-8-report.md item 1 of "New user-facing strings"). | | You have been signed out. | room.js, TERMINAL.signedout | | | This browser could not open the call’s connection. | room.js, TERMINAL.accept (406) | Written as ’ in the source. TERMINAL.badlast carried the same sentence for a 400 and was removed in the fix wave: neither probe form sends last, so the 400 it was written for could not reach it (review W-8). | | Meet cannot reach the call. The room may be full, or you may have been signed out. | room.js, TERMINAL.unreachable | Reached after MAX_STALLS rounds with no hello inside STALL_WINDOW — a guess covering the two causes the client can't tell apart. | | This browser cannot make calls. | room.js, TERMINAL.nowebrtc | |
Links (wayOut())
| String | Where | Note | | --- | --- | --- | | Sign in again | room.js, wayOut(), link to /signin | Shown only for signedout, and it is the only link on that screen — no "Back to the holding screen" is appended, because that would just bounce the person straight back to sign-in (task-8-report.md, Deviations item 1). | | Use this tab instead | room.js, wayOut(), link to /m/{sqid}/takeover | Shown only for replaced. Same wording as the takeover confirm page's own submit button (see below). | | Try again | room.js, wayOut(), link to /m/{sqid} | Shown only for unreachable, first, with "Back to the holding screen" behind it. The room, not the holding screen: the guess this sentence makes is "the room may be full", and the answer to that is to try the room again. | | Back to the holding screen | room.js, wayOut(), link to /m/{sqid}/join | Shown for every terminal reason except signedout — the rule is that no terminal screen is a dead end, and replaced is no longer the exception (task-10-report.md, part C). signedout still is: the holding screen would only bounce that person straight back to sign-in. |
Tile captions
| String | Where | Note | | --- | --- | --- | | <name> (you) | room.js, tile() | The self tile's caption; <name> is the server-rendered data-self-name, never typed by this browser. | | Someone | room.js, renderTiles() | Fallback caption for a peer whose name hasn't arrived from the server yet. | | Could not connect. | room.js, tile() | Shown on a tile whose pair has failed; never colour alone — this sentence plus .meet-tile-note's weight are the two cues. | | Retry | room.js, tile() | Button shown beside "Could not connect.", restarts ICE (or requests a fresh generation if the browser has no restartIce). | | Play | room.js, tile() / startPlaying() | Shown only if the browser refuses autoplay on a remote track; new in Task 8's fix round (task-8-report.md item 7 / "New user-facing strings"). | | Remove | room.js, removeLink() | A member's removal control on another peer's tile; opens /m/{sqid}/remove/{peer} in a new tab (§3.5 destructive-action rule). |
The queue
| String | Where | Note | | --- | --- | --- | | Someone | room.js, renderQueue() | Fallback name for a waiting guest whose name hasn't been sent. Same fallback word as the tile caption above, independent occurrence. | | Admit | room.js, renderQueue() | Button beside each waiting guest's name; posts admit for that peer. |
Removal and takeover confirm pages
handlers_room.go, rendered through templates/confirm.html
| String | Where | Note | | --- | --- | --- | | Remove <name> from the call? | handlers_room.go, confirmRemove() — Title | <name> comes from hub.PeerNameFor, the same authority Remove itself checks — this page can never name a peer it couldn't actually remove. Earlier draft named the meeting instead of the person (task-6-report.md, Concerns); fixed in Task 6's fix round. Applies equally to a waiting guest in the admission queue — there is no separate wording for that case (progress.md, Task 6 review note). | | They will be disconnected and can rejoin only if they are still allowed in. | handlers_room.go, confirmRemove() — Body | One sentence for both actor kinds rather than branching copy: true for a guest, who has only the link, and for a member, who can also come back from the meeting list (code comment, handlers_room.go; noted there for this catalogue). | | Remove | handlers_room.go, confirmRemove() — Label (submit button, via confirm-form) | | | Cancel | templates/confirm.html, content block, confirm-form CancelLabel | Pre-existing text (already catalogued for slice 1); confirmRemove's and confirmTakeover's CancelHref now both point at /m/{sqid}/join, not back into the room — a second /m/{sqid} would open a second stream and trip the takeover 409 for someone who only pressed Cancel (code comment, handlers_room.go; issue 11 half-closed per progress.md, Task 6 review). | | <meeting title> | handlers_room.go, confirmRemove() and confirmTakeover() — BackLabel, via a.store.title(m) | Back-nav label on both confirm pages, replacing the generic "Meetings" default; confirm.html's {{or .BackLabel "Meetings"}} is what makes this possible (task-6-report.md item 9). | | This meeting is open in another tab | handlers_room.go, confirmTakeover() — Title | | | Use this tab instead? The other tab will leave the call. | handlers_room.go, confirmTakeover() — Body | | | Use this tab | handlers_room.go, confirmTakeover() — Label (submit button) | Distinct from room.js's "Use this tab instead" link text (wayOut()) and the holding screen's identical link text — three slightly different wordings for the same move, not yet reconciled. |
The AGENTS.md exception line
| String | Where | Note | | --- | --- | --- | | Screens work with JavaScript disabled, with one exception: browser media participation (the holding screen's camera preview and join button, and the call itself in the room) needs script; both pages render their title, standing, floor notice and navigation without it. Destructive actions get their own confirm-page URL. | AGENTS.md, "Rules the family holds hard" | Replaces the prior unqualified "Screens work with JavaScript disabled; destructive actions get their own confirm-page URL." — the exception is scoped to exactly the two pages above. | | make browser is the second gate, for any change to a page or to the client: a real call in real Chromium. It needs the Claude Code bash sandbox off — the sandbox blocks UDP, so Chromium gathers no ICE candidates and every call fails with frames stuck at 0, which reads exactly like a client bug and is not one. | AGENTS.md, "Rules the family holds hard", new bullet | Developer-facing (this doc, and whoever else reads AGENTS.md), not end-user copy, but a string introduced on this branch a person reads. | | Accessible by construction, progressively enhanced, and never dependent on JavaScript to be usable, with the one exception named above: browser media participation, on the holding screen and in the room. | AGENTS.md, "UX conventions" intro line | Same exception restated where the UX-conventions section makes the blanket claim; kept consistent with the Rules-section wording rather than contradicting it (task-6-report.md item 10). |
The Codex round 1 fix wave
No new user-facing string. The wave (3c8c6ac.., recorded in docs/superpowers/reviews/2026-09-10-slice-2-record.md §2a) changed what the server and the client do and nothing a person reads: the refusals it adds are the existing 404 body and the existing TERMINAL sentences, reached by more paths than before. Two of those sentences are now reachable for a new reason, and the wording is unchanged:
| String | Where | Note | | --- | --- | --- | | You no longer have access to this meeting. | room.js, TERMINAL.standing | Already catalogued. Now also reached without waiting for the tick: a removed member's next relay, reset or resume is refused, and the probe that follows a signal 404 reads the standing tombstone (spec §15.12). | | This meeting is already open in another tab. | join.html, OpenElsewhere notice | Already catalogued, with the standing note that it is untrue during the resume window. One of the two cases behind that note is gone: a participation past its window no longer counts as open elsewhere (S2-2). The suggested rewording still stands for the rest. |
One developer-facing string is new, in a code comment and a Makefile comment rather than on a screen, and is listed only so this file stays a complete record of the branch:
| String | Where | Note | | --- | --- | --- | | meet: reset | room.js, requestReset() — console.error prefix | Diagnostic, in the same shape as the file's other two (meet: pageshow, meet: start). Never rendered. |
The Codex round 2 fix wave
No new user-facing string. The round-2 wave (de597c6.., recorded in the review record's §2b) changed when the client tears a call down and what a probe is allowed to write. Nothing new is rendered, and no existing sentence changed. One row is worth restating because the wave narrowed what can reach it:
| String | Where | Note | | --- | --- | --- | | This meeting is open in another tab. | room.js, TERMINAL.replaced | Already catalogued. Two ways of reaching it wrongly are now closed: a transient probe answer no longer sends the client round the fresh-entry ladder into its own participation (S2-12), and neither does a 404 about a peer that merely left (S2-3, round 1). It is now shown only when this session really does hold a peer somewhere else. |
Slice 3
The relay slice (docs/superpowers/plans/2026-09-10-meet-slice-3-relay.md). Copy review is waived this round per suite discussion 5; every new string is catalogued here instead. Four new strings are user-facing, all in room.js; two strings already in this file are listed beside them, because slice 3 changes when one of them is shown and adds a second path to the other.
| String | Where | Note | | --- | --- | --- | | Meet could not reach the relay. Calls may not connect until it can. | room.js, iceWarning() — #ice-warning, a role="status" paragraph the script creates once beside #status | Spec §5.3 step 1. Shown from the first failed /ice fetch until one succeeds; the client retries every minute behind it. Its own live region rather than #status, so it cannot take the call's own sentence with it. Says what happened and what it may cost, and does not diagnose the network — §5.5's rule. Shown only where a relay is configured (data-relay): on the floor there is no relay this instance could reach, and the sentence would be untrue. A 429 is not a failure and never shows it: the server named a retry time and the client waits it out inside the same budget. | | The relay credential expired for this connection. | room.js, tileNote() — a tile's .meet-tile-note | Spec §5.3 step 3. Shown per connection, not per call: the must-succeed-by is the old credential's expiry, and the browser cannot see which credential an allocation is using, so this is the honest signal and not a diagnosis. The existing "Retry" beside it runs a fresh fetch and then another ICE restart. | | Could not renew this connection through the relay. | room.js, tileNote() — a tile's .meet-tile-note | Spec §5.3 step 2's other ending, and the reason it is not the sentence above. Three ICE restarts with a good new credential in hand, none of them proven within thirty seconds: the credential is fine and the path is not, so "expired" would be a sentence about the wrong thing. Same Retry beside it. Codex round 1 (S3-4) tightened which of the two a person sees: with a replacement credential in hand this is now the only possible sentence, where the old deadline could previously rename it "expired" after a single attempt. | | Could not connect, even through the relay. | room.js, tileNote() | Spec §5.5, in the spec's own words. Replaces "Could not connect." on a failed tile when #room carries data-relay="1" and this connection was actually given the list — a connection still running on the empty list never went near a relay, and the sentence would be a claim about something it never tried. The README's TCP-443-only note (slice 6) is where the possible causes are named; the tile does not diagnose. | | Retry | room.js, tile() | Already catalogued for the failed tile. Now also the control on an expired tile, where it means "fetch a new credential and restart", not "restart". One label, because from a person's side it is one thing: try this connection again. | | Could not connect. | room.js, tileNote() | Already catalogued. Unchanged, and still what a failed tile says on an instance with no relay (§5.5's floor). |
Developer-facing, in console.error prefixes rather than on a screen, listed only so this file stays a complete record of the branch. All three print an error object and never a response body: the /ice answer carries a credential, and nothing about the servers reaches the console.
| String | Where | Note | | --- | --- | --- | | meet: ice | room.js, the one-minute retry's rejection handler | Same shape as meet: reset, meet: pageshow, meet: start. Never rendered. | | meet: renew | room.js, the renewal's rejection handlers | As above. | | meet: roster | room.js, onRoster() — the roster path is asynchronous now that §5.2's fetch is in front of the first connection | As above. |
Slice 4
The chat and screen-share slice (docs/superpowers/plans/2026-09-10-meet-slice-4-chat.md). Copy review is waived this round per suite discussion 5; every new string is catalogued here instead.
The chat panel
internal/calls/templates/partials/chat.html and internal/calls/static/chat.js — the suite's first chat panel (spec §6, §10). These five strings belong to the panel and not to Meet: they ship with the partial and the script, and a second app that adopts those two files adopts these words with them. That is the reason to look hard at them now — a sentence changed after a second app has taken it is a sentence changed in two products.
| String | Where | Note | | --- | --- | --- | | Chat | chat.html, the panel's <h2 class="chat-title"> and its aria-label | The panel's name, in the heading and again on the section so the region is announced by name. One word, and the same word twice on purpose: a label that disagrees with the visible heading is worse than either alone. | | Message | chat.html, the <label for="chat-text"> | The control's label. Not "Your message" and not a placeholder: a label that only exists until you type is not a label. | | Send | chat.html, the submit button | The verb, not "Submit". | | Could not send. Try again. | chat.js, #chat-status — a role="status" paragraph inside the form | Shown whenever the host's send answers anything but exactly true, throwing included: the message was not accepted, so nobody will ever see it and nothing was echoed into the log. The draft stays in the box, which is what "Try again" means here — press Send again, not retype it. Editing the draft clears the sentence, because it was about text that no longer exists. Says what happened and does not diagnose the network (§5.5's rule, applied to chat). | | Message is too long. | chat.js, #chat-status | Shown while the textarea holds more than 2048 bytes of the text as the wire carries it (§4.4) — its JSON-escaped UTF-8 form, which is what the server measures too, so a quote, a backslash and a newline each cost two — and the Send button is disabled behind it. The control's own maxlength="2048" counts characters, so for anything outside ASCII, and for anything JSON escapes, the browser will happily allow a message the server would refuse with a 413; this sentence is what stands in that gap. (The string is unchanged; the measure behind it was corrected in the Codex fix wave, W4-1 — before that the panel counted raw UTF-8 and the server counted raw JSON, and a draft with two dozen quotes in it fell between them.) Present tense because it describes the box as it is right now, and it clears itself when the draft comes back under the limit. |
Two strings on a chat line are generated, not authored, and are listed only so this file stays a complete record: the visible clock (HH:MM, the reader's own local time) and the title on the same <time> element, which is Date.prototype.toLocaleString() — the whole date and time in the reader's own locale and wording, per AGENTS.md's dates convention. Neither is text anyone here wrote, and neither is translatable by this app.
Neither sentence is carried by colour: .chat-status paints in --rst-text, the ordinary text colour, and the weight it adds is a second cue on top of the words rather than the signal itself (AGENTS.md, "State — never colour alone").
The room: chat and screen share
internal/calls/static/room.js, internal/calls/templates/room.html — the room's own half of the slice. These strings are Meet's, not the panel's: they name a control in this room and a thing that happened in this call.
| String | Where | Note | | --- | --- | --- | | Share screen | room.html, #share in #controls | The verb and its object, because "Share" alone in a call could be a link, a file or a screen. The name never changes under the reader: the state moves in aria-pressed and in the aria-hidden span beside it, so the button is announced as the same control whether it is on or off — the rule the Microphone and Camera toggles already follow. The two device toggles carry the name again in aria-label; this one does not need to, because its visible text is the name and the only part that changes is hidden from the reader. | | On | room.js, renderShare() — #share-state | The word in the button while this tab is sharing. Same word as the other two toggles use for the same idea, on purpose. Never the whole signal: aria-pressed and calls.css's inset outline say it twice more, and neither is colour. | | Off | room.html and room.js, renderShare() — #share-state | The resting state, rendered by the server so a screen shows the truth before the script runs, and restored by renderShare() when sharing stops. | | <name>’s screen | room.js, shareCaption() — the <figcaption> of a share tile | The caption on the second tile a sharing peer gets. The name is the roster's, written with textContent like every other name in the room, and the apostrophe is the typographic one (U+2019) the rest of this app uses. A screen is captioned by whose it is, because in a grid of faces that is the only thing that tells two screens apart. Not "Screen" and not "Shared screen": both are true of every tile in the row. | | Screen sharing was not allowed. | room.js, startSharing() — #status, the room's own role="status" line | Shown when getDisplayMedia refuses: the person dismissed the browser's picker, or a policy forbade the capture. Past tense and passive on purpose — the browser refused, and this file cannot see which of the two it was, so it does not guess. It does not say "try again", because the button is still there and still says what it does. Cleared by the next thing the room has to say, like every other sentence on that line. |
Developer-facing, in a console.error prefix rather than on a screen:
| String | Where | Note | | --- | --- | --- | | meet: share | room.js, the share button's rejection handler | Same shape as meet: start, meet: roster, meet: renew. Never rendered, and it prints an error object — never a capture, a track id or a peer's name. | | meet: chat | room.js, runBroadcast() — the broadcast lane's rejection handler | As above. Nothing awaits the pump, so a rejection escaping it would go where neither a person nor a gate could see it. It prints an error object and never a message: §4.4 says nothing of an accepted chat reaches a log line, and that includes this one. |
Slice 5
The drive surface's Task 2, Settings: mint an agent's token once, show it, and revoke it (.superpowers/sdd/2026-09-11-meet-slice-5-drive/task-2-brief.md). Every string below is new; none replaces or renames a string already in this file.
| String | Where | Note | | --- | --- | --- | | Settings | templates/layout.html, the account corner beside sign-out; templates/settings.html's <title> and page-header | The screen's name, in the nav link and again as the page's own title — the same doubling link_once.html's "Your meeting link" and index's "Meetings" already use between a page's <title> and its heading. Shown in the shell only when Viewer.Member is true (render.go's page, now a method so it can ask a.isMember): a guest signed into a room they were let into never sees it, because they have no agents to manage. | | Agents | settings.html, the list section's <h2> | The section's name, plural because the screen is the list of them. Not repeated as a page title — "Settings" already owns that. | | An agent acts on your behalf with exactly your reach, as <name>~<your address>. | agent_once.html, the sentence under the minted agent's name | Spec §8.1's principal, spelled out in one sentence rather than left as a bare name~email string: says what an agent is (a delegate with the operator's own reach, no more and no less) at the one moment a person is looking straight at the credential that makes it true. ~ between the two halves is deliberate and unexplained here — it is the same separator principal() (agents.go) writes into a meeting's creator field, so a person who later sees scheduler~paul@rslw.com on a meetings list recognises the shape from this sentence. | | Name | settings.html, the create form's field label (ui/field) | The one field the create form has. Not "Agent name": the field envelope is already inside a "Create an agent" section: heading, then label, would be typed as "Create an agent" / "Agent name" / an agent name — this is title.html's "Title" pattern (a form under its own heading names its field plainly, not by repeating the heading's noun). | | Create an agent | settings.html, the creation section's <h2> and its submit button | One string in two places on purpose, the way chat.html's "Chat" names both a heading and an aria-label for the same reason: a button whose action reads differently from the section it sits in is a small tax on every reader, paid for nothing. Says what happens, not "New" or "Add" (page-header.html's own rule for ActionLabel, applied here to a plain form button instead). | | This is the only time this token is shown. Copy it now. | agent_once.html, the paragraph above the readonly token field | link_once.html's "This is the only time this link is shown. Copy it now and send it to the people you are meeting." shortened to fit a token instead of a link: nobody is sent a token the way a meeting link is sent to attendees, so the sentence stops after "Copy it now." rather than inventing an audience for it. | | Revoke | settings.html, the link on each live agent's row; handlers_settings.go's confirm page, the destructive button (ui/confirm-form's Label) | The verb, matching "Replace the link" / "End" on the meetings list: an action link named for what it does, and the same word again as the confirm page's own submit — one control, described consistently on the way to it and on it. | | Revoke <name>? Anything using its token stops working at once. | handlers_settings.go's confirmRevokeAgent, split as confirm.html's Title ("Revoke <name>?") and Body ("Anything using its token stops working at once.") | Same two-part shape as "End the meeting?" / "'<title>' will close for everyone and its link will stop working." (handlers_meetings.go's confirmEnd): the question names the thing by its own identity (the agent's name, not "this agent"), the body says the one consequence that matters — every request the token would have authorized now fails, immediately, not at some later sweep. "At once" carries that timing; nothing here promises what already-open work using the token will see, because nothing in this slice tracks that. | | Revoked | settings.html, a revoked row (ui/status-pill, default neutral tone) | The one-word state for a row that used to show its principal. A pill, not a link, because there is no action left to take from here — spec's "state is never colour alone" (AGENTS.md): the tone is neutral and the word is what actually carries the meaning. | | No agents yet. | settings.html, the empty list (ui/empty-state) | Deliberately terser than "No meetings yet. Make one above and share its link." (index.html's own empty state): an agent is a less familiar idea than a meeting, and this screen's create form sits directly under the heading with no other section between — the empty state does not need to teach what the form immediately beneath it already explains at a glance. | | Give the agent a name of 1 to 64 letters, digits, - or . | `handlerssettings.go's createAgentHandler, beside the Name field on errAgentName | Spells out the rule agentNameRe (agents.go) enforces, the same register as "Keep the title under 200 characters." — the limit stated as a fact about the field, not a complaint about what was typed. The submitted name is never echoed back inside this sentence (only into the field's own Value, ui/field's ordinary redisplay), so a name containing something worth not repeating verbatim in a message never has to be. | | You already have 20 live agents. Revoke one before creating another. | handlerssettings.go's createAgentHandler, beside the Name field on errAgentLimit | The refusal for the twenty-live-agent ceiling the Task 1 review asked for. States the number and the way out, the same shape as the sign-in form's "Too many sign-in attempts just now. Wait a few minutes and try again." As of fix round 1, store.createAgent itself counts live agents (inside the same transaction as the insert) and returns errAgentLimit at twenty; the handler only renders the sentence — see agents.go and the task's report. | | You already have a live agent with that name. | handlerssettings.go's createAgentHandler, beside the Name field on errAgentDuplicate | As of fix round 1, store.createAgent checks for a live duplicate name itself (a plain count query in the same transaction, no unique index — a revoked name is free to reuse at once) and returns errAgentDuplicate; the handler only renders the sentence. See agents.go` and the task's report. |
Two sentinel errors, errAgentLimit and errAgentDuplicate, live in agents.go beside errAgentName as of fix round 1 — see the task's report for the move from handlers_settings.go, where they were declared as scaffolding for this task's duration only.
Task 4, the badge on the list (.superpowers/sdd/2026-09-11-meet-slice-5-drive/task-4-brief.md): index.html names which meetings an agent made, right beside every other meeting a member made by hand.
| String | Where | Note | | --- | --- | --- | | Agent | index.html, {{template "badge" dict "Label" "Agent"}} beside an agent-made meeting's title, both the Upcoming and Ended lists | The one word, in ui/badge (AGENTS.md's vendored component — not hand-rolled) beside the lucide bot icon (internal/calls/icons): the icon alone would fail AGENTS.md's "state is never colour alone" rule on its own account (a shape is not a colour, but it is still not text), so the badge's own text label is what actually says what the icon means, exactly as ui/badge's doc comment expects it to be used. Shown only when the row's ByAgent is true (store.meetingViewOf) — a member's own meeting carries neither the icon nor the word. | | by <principal> | index.html, the <p> directly under an agent-made meeting's title | Spec §8.1's "robot badge and operator name on every write", the second half of it: the principal a person already met on Settings' agent_once.html ("An agent acts on your behalf with exactly your reach, as <name>~<your address>.") and would recognise here without translation — claude~paul@example.com is the same shape, resolved by store.creatorLabel through the same principal() (agents.go). Lowercase "by" because it reads as a caption under a title, not a sentence of its own; present only when the row is an agent's. |
meetingView.CreatedByPrincipal also resolves a member-made meeting's creator to their display name (store.creatorLabel's person:<id> branch), but nothing on index.html shows that value: a member does not need to be told they made their own meeting, and spec §8.1's "on every write" is about the drive surface's writes, not every write. The field exists on every row; the template reads it only when ByAgent is true.