Repository navigation
feat(skills): tutorial — voice walkthrough + AG2 Space Getting started page - #5597
Conversation
…d page New `tutorial` skill. Voice mode is the existing walkthrough moved from docs/tutorial-delivery.md. AG2 Space mode creates one "Getting started" HTML page (Room Commons) in the owner's DM after the first-contact greeting, with copyable fresh-install prompts and a pointer to Settings -> Integrations, and summons the owner to it. A per-room marker under <workspace>/state/tutorial/ makes re-link/reinstall refresh the same page instead of adding another. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Companion PR that wires this into first contact (engine CLAUDE.md lives in the desktop repo): the desktop first-contact change. Merge order: this PR first, then #842, then the engine bump. |
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 91%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
|
Owner-approved outline (Chi): eight parts, each one line on what it is and one thing to try (Rooms, Commons, Teamwork, Calls & voice, Connect other agents, Connect your apps, Activity & notices, Mobile), plus an "Import your Claude Code history" part only on a Claude Code core. `--core claude|codex|unknown|auto`; auto reads the engine's resolve_core_runtime. The fresh-install list is folded into the parts. UI labels checked against cinny-webclient main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tial fallbacks The Getting started page (now tour_page.py) gets an inline-SVG hero and one icon per section with AA-checked accents, a step-by-step tour (Back/Next, step dots, Show all, arrow keys), per-section Mark done with a progress bar kept in the page runtime's artifact.state (localStorage, then memory, as guarded fallbacks), reduced-motion support, and no external URL. Content is unchanged. publish/render take --name auto (Matrix profile displayname, else the mxid localpart, else Sutando). room_commons.py is found beside the skill or in the installed skills dir, and its credentials come from the AG2 Space channel env file when the environment has none, through a new shared src/channel_credentials.py that pending-questions now delegates to instead of keeping its own copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Mark done never writes the shared key: the per-viewer key is resolved before the checkboxes are enabled, and without a viewer id progress stays in this browser's localStorage (then memory). - A thenable artifact.state.get is awaited. - Show all: a step dot scrolls its section into view (instant under reduced motion) and moves focus to it. - The Show all toggle keeps one label; aria-pressed carries the state. - Copy fallback names the platform's keys; outcomes are announced in an aria-live status region instead of a static aria-label. - No SVG pairs role="img" with aria-hidden. - A head script sets .js so a scripted page opens on one step without painting all of them; without JS every section shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed publish Copy (approved by Chi): a "Your DM" step first; Connect other agents before Teamwork, with the client's descriptors (Dot by OpenAI, in ChatGPT; Grok Bot in Grok; Muse by Meta); a try-it for Activity & notices (Mention & Keywords); Calls & voice names only the headphone icon in the room header and how an agent joins (it answers in your DM; in a room you pick it under Bring an agent?); the import step starts "If you use Claude Code" and says it reads the local history. Every prompt says where to paste it, a closing card appears when all steps are done, Mark done advances to the next open step, and the step title replaces "Step N of M". Labels reconciled with current screenshots: + at the top of the room list, room menu Invite, plug in the user card. Screenshots: ten privacy-safe crops (assets/, 140 KB) adapted from Zerlinda's Sutando's onboarding page, inlined as data: URIs. The page is now ~218 KB, over one Room Commons write (196,608 bytes), so publish appends in 150,000-byte pieces and rewrites a large change by clearing in pieces; a small change is still one replace. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The assets path walked two parents up, which the workspace-resolution lint refuses; scripts/assets/ sits beside tour_page.py instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nt cap CI on Linux failed with E2BIG: one argv string may not exceed 131,072 bytes there, and pieces were 150,000. Pieces are now 120,000 bytes, and a refresh is one replace only when both texts fit one argument. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Images: Rooms gets its own crop with the box on the + (it reused the Activity bell); the Commons header crop gets the red box on its icon. A test now refuses two steps sharing image bytes. Page: progress and Mark done are hidden without JS instead of stuck disabled; artifact.state.set is awaited inside try; an explicit data-theme (on html, or copied from body) beats prefers-color-scheme; the step change is announced once (heading focus, no aria-live on the title); a repeat copy is re-announced; arrow keys act only inside the tour and not on controls; Show all has a pressed style; a 3/10 position sits beside the step title, hidden from AT. Copy: Commons names PDFs; Calls reads "start the call and pick your agent under 'Bring an agent?'"; Activity names room menu → Notifications; the apps alt is Settings → Apps → Integrations. --audience room says "your agent" throughout, for rooms other than the owner's DM. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"comes back here in your DM" and "Ask ... here" were wrong when the page sits in a room; both now name the DM. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it tracks them The repo ignores assets/ except skills/*/assets/, so the new Rooms crop under scripts/assets/ was never committed and CI could not find it. The assets go back to skills/tutorial/assets/, and the one line that walks up to the skill root carries the lint's line-scoped allow pragma. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ff-coverage gate main() in-process for render, plan and publish (created, unchanged, exit 2, 3 and 4) against a stub room_commons.py, a temp workspace and channel dir, and an env scrubbed of identity and credential variables; plus the https helper (mocked urlopen), detect_core, _workspace, a run timeout, a page-add without an html kind, rewriting an empty page, a marker whose rooms is not a map, and channel_value's environment path. No network. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ost answers Review (Rui's Codex, P2): the page could be created twice when the marker write failed, the process died, or a create's answer was lost. - The page is made with room.commons.pages.create, which takes an operation_id: a retry with the same id returns the same page and writes its body at most once. The marker records `creating` with that id BEFORE the call, `pending` with the kind after it, and `complete` once every piece is written; any rerun resumes from the last entry. - The create's first body carries an ownership marker (<meta name="sutando-tutorial" content="<agent mxid>">). With no marker entry, a listed "Getting started" page is reused only if its text carries this agent's marker; an empty or unrelated same-title page is never adopted or touched, and two marked pages or no agent id stop with exit 4 and the reason. - No more page-remove cleanup or claims about it: a failed append leaves the page pending, and the next run finishes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and a roles line Approved by Chi: no pre-written per-role prompt sets. When the agent knows the owner's work (onboarding roles in the first-contact message, or user_profile.md), it writes the prompts itself and passes them as --prompts <json>, one per slot (dm, commons, teamwork, apps, import, finale). Each is validated (string, one line, 1-200 chars, no link or markup) and escaped on render; a bad entry, or a missing or unreadable file, keeps that slot's generic prompt, and never fails the publish. The slot, not the text, decides the DM/room paste hint. --roles adds a "Picked for: Researcher, Founder" line under the heading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed at 70617b2 from a clean worktree of this head.
Ran: tests/tutorial-getting-started.test.py → 77 tests OK (1 loud skip: PageBehaviourTest when node+jsdom is absent); tests/pending-questions-room-db-serve-process.test.py → 15 OK, so the delegation to the new loader preserves behaviour; scripts/review-checks.sh, lint-sutando-home-path.sh, lint-workspace-resolution.sh, lint-skill.py skills/tutorial → clean.
Verified in code:
src/channel_credentials.pyreads only the one lane env fileresolve_channel_envselects, returns values to its caller and never prints them, short-circuits to{}when the environment already names any of the variables, and does not import or name anyskills/path. The 11 lines removed frompending_questions_room_db.pyare logically identical to the loader. No new env var (AGENT_MXIDalready exists);manifest.jsonhas noconfigblock and the skill reads none.- Chunked publish: the default page is ~214 KB, so the chunk path is the main path. I checked its assumptions against the real client this seat has (
room_commons_client.py:1775):replaceis first-occurrence on the live text with UTF-8 byte offsets handled, and refuses when the text is absent;read --jsonreturns the rawtext;appendon an html kind is verbatim. The test stub implements exactly those semantics, so the "final text equals the unchunked page" test stands on the real contract. One number to fix (nit 1). --name auto: missing display name → localpart → "Sutando"; fetch failures swallowed; never raises. Display name is HTML-escaped (render --name '<b>x</b>&"y"'escapes every character); the 11 images are inline data URIs, no external URL in the output.--audience roomsubstitutes "your agent" for the name and no DM data path exists.- Docs:
tutorial-delivery.mdkeeps the voice procedure and states the page is additive; thesrc-map.mdrow matches the file header;catalog.jsonupdated.
Nits (none blocking):
SKILL.md:114says pieces of "at most 150,000 bytes";getting_started.py:60hasCHUNK_BYTES = 120_000. Keep the code's number: Linux caps a single argv string at 131,072 bytes, so the doc's figure would not even passexecve.- "Never duplicated" rests on the marker file alone (
state/tutorial/getting-started.json). A fresh workspace or a reinstall that dropsstate/yields a second "Getting started" page;pages --jsonalready returns titles, so a title fallback inplan()would close it. PageBehaviourTestskips wherever jsdom is absent; if CI does not install it, the eight JS-behaviour tests never run anywhere. A CI step orNODE_PATHwould make them count.--audience roomis a CLI option but SKILL.md says "run it only in the owner's own DM" — document the flag or drop it.test_the_token_is_never_printedguards onlygetting_started.pyechoing its env (the stub never prints); narrow but not tautological.
Approving. The merge on sonichi/sutando is the owner's.
… audience, redacted errors From Qingyun's review of 70617b2: - SKILL.md stated 150,000-byte pieces; CHUNK_BYTES is 120,000 because Linux caps one argv string at 131,072 bytes. SKILL.md now says so, and a test fails if it names any other piece size. - SKILL.md documents --audience room (only when the owner asks for the page in another room) instead of saying "DM only". - A room_commons.py error is now redacted of every credential-like value in its environment (and each half of a url|secret token) before it becomes the publish error; the token test's stub now leaks its token, so the test checks the redaction rather than our own echo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…load as given cinny-webclient#2231 ends the first-contact greeting with "I picked: <Label1>, <Label2>." using persona labels (multi-word, e.g. "Product Manager"), or slugs when labels are unavailable. --roles "Founder, Sales." showed only "Founder": the slug-only pattern dropped "Sales.". Entries are now split on commas, trimmed of whitespace and one trailing period, and kept when they are 1-40 characters of letters, digits, spaces and & - _ ' . (with at least one letter or digit). A slug-looking entry is title-cased, a label is kept as written; duplicates are dropped case-insensitively and at most six are shown, escaped. SKILL.md says to pass the payload verbatim. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…xisting page Live test at 16fafab: with the marker missing, publish adopted the owned page (unchanged, adopted) and summoned the owner a second time, putting a duplicate card in the DM. A summon now happens only when this page's creation finishes (here, or resumed from our own pending entry) or with --summon; adopting a page found by its ownership marker is not a creation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
qingyun-wu
left a comment
There was a problem hiding this comment.
Re-review at 6949462 (delta 70617b2..HEAD: 5 commits, +642/−115), from a clean worktree.
Ran: tests/tutorial-getting-started.test.py 100 OK (one loud skip, jsdom absent on this host); tests/pending-questions-room-db-serve-process.test.py 15 OK; review-checks.sh PASS; lint-sutando-home-path.sh, lint-skill.py skills/tutorial, check-utc-z-strftime.py clean.
Verified in code
- Create-once: a real
fcntl.flockonstate/tutorial/.getting-started.lockheld across publish and released on exception; thecreatingentry with its op id is written (:404) beforepages.create(:405),pendingafter,completeat :422, so a crash at any point resumes with the same op id and never creates twice, given server-side dedupe by operation_id. I checked that contract against the room-commons client this seat has at main c61464e:mcp_door(args)returns a connect callable (matchesrc.mcp_door(Namespace(...))()),execute(room, action, arguments, operation_id=)is the signature used, and a non-completed outcome raisesOperationPending("may still take effect, so it is never re-run") — the same stance this PR takes.pages --jsondoes carrybyandcopy_of. - Adoption: the
<meta name="sutando-tutorial">marker is read back viaread --jsontext and compared exactly; zero tagged → create, one → adopt (writes the marker, next rununchanged), two or no agent id → exit 4; a same-titled untagged page is never touched. --prompts: known slot, 1–200 chars, no CR/LF/</>, no://orwww.; bad or missing file → generic prompts; prompts are HTML-escaped in text anddata-prompt(probed with<script>and&).--roles: comma split,[\w &'.-]{1,40}, Title-Cased, deduped, ≤6, escaped.- Chunks:
test_skill_md_states_the_real_chunk_sizeties SKILL.md toCHUNK_BYTES;ChunkTestasserts< 131_072;chunks()counts UTF-8 bytes per code point and never splits a character (probed with 4-byte emoji at limits 4/5/6). - No
src/change in the delta; no skill named fromsrc/; no new env var;manifest.jsonhas noconfigblock.
Blocker — getting_started.py:309: create_page wraps the Actions-client exception unredacted. redact() (:200-209) is applied in run() and summon_error, but the PublishError(f"page create {operation_id} did not complete: {exc}") carries str(exc) straight into the {"ok": false, "reason": …} JSON on stdout (main() :518). Probe: an exception text 401 Authorization: Bearer abcdefghijkl at https://relay.x/r reached stdout verbatim. That defeats the "redacted errors" commit on the one call most likely to echo a URL or credential. Fix: redact(str(exc), self.env) there (and on the :312 path's kind!r for symmetry).
Nits
tour_page.py:237-256—role_labels("I picked: researcher, founder")→['Founder']: theI picked:prefix is not stripped, so the first persona is lost when the agent passes the line verbatim, which SKILL.md:51-52 tells it to do. Strip the prefix and add the prefixed form totest_roles_take_the_first_contact_payload_as_given.getting_started.py:276-277— resuming acreatingentry never consults listed tagged pages; if the server's op-id memory has expired, a second page is created and the first orphaned. Checking for a tagged page before re-issuing the create closes it.getting_started.py:200-209— redaction is value-based (env credentials ≥ 8 chars); a server-issued token in stderr is not masked. ABearer \S+pattern alongside would cover it.tour_page.py:222-235— "first person" and "runs on a fresh install" are SKILL.md rules only; bare domains (example.com) passBAD_PROMPT.- Server-side dedupe of
pages.createby operation_id is the one contract I could not exercise from this seat; a live create-retry round trip in the body would close it.
Requesting changes for the blocker; everything else is ready.
… review nits Qingyun's re-review of 6949462 (REQUEST_CHANGES): - Blocker: create_page wrapped the Actions client's exception into the publish reason unredacted, so a bearer or relay URL it echoed reached stdout. It now goes through redact(), as does the bad-kind message, and main() redacts every failure reason it prints. redact() also masks anything shaped like "Bearer <token>" or a url|secret pair, not only values found in the environment (nit 3). - --roles strips a leading "I picked:" so the whole first-contact line can be passed without losing the first persona (nit 1). - Resuming a `creating` entry first looks for a listed page this agent tagged and finishes it, instead of relying only on the server still remembering the operation id (nit 2). - Prompts also refuse bare domains such as example.com (nit 4, the checkable half; "first person" stays a SKILL.md rule). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@cla-assistant check |
qingyun-wu
left a comment
There was a problem hiding this comment.
Re-review at 1a1244a (one commit over 6949462), clean worktree.
Blocker closed. create_page now passes both the Actions-client exception and the bad-kind repr through redact(); main() redacts every printed failure reason as a backstop; and redact() gained a shape rule (SECRET_SHAPE) that masks anything that looks like Bearer … or a url|secret pair, not only values found in the env. Probed: 401 Authorization: Bearer abcdefghijkl at https://relay.x/r → 401 Authorization: Bearer [redacted] at [redacted]; a url|secret relay token → [redacted]; plain text untouched.
Nits taken: role_labels("I picked: researcher, founder") → ['Researcher', 'Founder'] (prefix stripped, trailing period too); a creating entry being resumed now reconciles against an already-listed page tagged by this agent before re-issuing the create, which closes the expired-op-id double-page case.
Ran: tests/tutorial-getting-started.test.py 103 OK (one loud jsdom skip), tests/pending-questions-room-db-serve-process.test.py OK, review-checks.sh PASS.
Approving. Merge on sonichi/sutando is the owner's; the one contract still best proven live is the server's create dedupe by operation_id, which the reconcile step now also covers if it ever lapses.
What changed, and why?
A new
tutorialskill (owner ask, the owner). It has two modes:docs/tutorial-delivery.mdinto the skill without changing it. It readsnotes/first-time-tutorial.mdand delivers one short section at a time. One sentence is new: a section that needs an app the owner hasn't connected gets one line about connecting it, not the full step.scripts/getting_started.py publishcreates one "Getting started" HTML page (Room Commons) in that DM and summons the owner to it. The only variables on the page are the agent's own name (escaped) and the core type, so nothing from the owner's accounts can reach it.Page outline (approved by the owner, revision 2). The page is a short AG2 Space tour. Each part is one line on what it is, plus one thing to try: a Copy-prompt card where a prompt makes sense, otherwise a short how-to line.
the owner's decisions reflected here:
--core claude|codex|unknown|autocontrols it, andpublishdefaults toauto. Auto readssutando_config.resolve_core_runtime(), the same resolver behindscripts/sutando-config.sh core-runtime: it takes the launcher'sSUTANDO_CORE_RUNTIMEstamp, otherwisecore.runtimefrom config. If resolution fails, the core isunknownand the part is omitted.UI labels were checked against the web client
origin/main@2ebcc49f:pages/client/home/Home.tsx,AddAgentMenuItem.tsx). The Rooms category has a "Create room" + button.ag2/agentFlowCopy.ts). App agents: Dot, Grok Bot ("in Grok"), Muse, and Other ("any app with connectors") inag2/agentFlowFaces.tsx. "In ChatGPT, open a chat with Dot" is inag2/appSetupCopy.ts.features/settings/settingsNav.ts).features/room/header/roomHeaderItems.ts,RoomViewHeader.tsx), and the Commons item.RoomViewSwitcher.tsx,room-activity/RoomActivityView.tsx).components/RoomNotificationSwitcher.tsx).Revision 3: interactive and graphic (the owner), plus two fixes from the live run. The coordinator published revision 2 in the owner's DM (
the page). All 9 sections landed, and Copy works in the desktop app.Page (
skills/tutorial/scripts/tour_page.py, pure; the content and wording are unchanged):aria-current, done state), Back/Next, a "Show all" toggle (aria-pressed), and the ←/→ keys. Without scripts every section shows.role=progressbar) reads "3 of 9 done".window.artifact.state(the web clienthtmlRuntime.tsRUNTIME_SHIM; room-commons SKILL.md "Pages that remember"). The key isgs-done-<artifact.me.id>, so it is per viewer, andartifact.state.onsyncs it live. That state is saved with the page, so it survives reloads and a refresh in place. Per room-commons SKILL.md the page has no storage of its own (srcdoc frame,allow-scriptsonly). The fallback order is therefore localStorage, then memory, and every storage access sits in atry(a test checks this structurally).prefers-reduced-motion. Narrow layout below 520px. Dark mode. No external URL, nosrc=, no<img>/<link>, nourl(. The Copy fallback is the same as before.Fixes from the live run (
getting_started.py):ag2.whoamireturneddisplay_name: null.--name autonow resolves the name itself and never fails. It tries, in order:.well-known/matrix/client→/_matrix/client/v3/profile/<mxid>/displayname, https only, with a User-Agent, because the homeserver's edge returns 403 to urllib's default UA;@chi-air-blue.agent:…→ "the owner Air Blue");The mxid comes from
$AGENT_MXID, else the channel env file, else--agent-mxid. I found noroom.members.listor profile action reachable from this script, so it uses the public profile endpoint. SKILL.md step 1 documents the order. On this host it resolves to "the agent's profile name"."room-commons: no access token" until the lane env was sourced. room_commons.py looks for
<workspace>/.claude-sutando/channels/ag2space/relay-client.env. On this install that directory holds only.env, a link to the app's channel env, so its fallback never fires.publishnow passes room_commons.py its own credential variables (read from itsTOKEN_VARS/URL_VARS), taken from the channel env file through the shared resolver (channel_env_resolve→channel_token). It does this only when the environment names none of them, so a token is never mixed with a URL from elsewhere. The policy was already implemented privately inpending_questions_room_db.channel_credentials. It is nowsrc/channel_credentials.py, and pending-questions delegates to it; its 15 tests still pass. Nothing prints the token, and a test checks the failure output.The default
--room-commonsresolved to the clone. It is nowskills/room-commonsbeside this skill, elseclaude_home_path("skills", "room-commons"), the installed skill (a symlink into the engine checkout).Revision 4: review fixes (a reviewer agent, a reviewer agent) (commit
72238b4):state.getis awaited.aria-pressedcarries the state.aria-livestatus region.role="img"witharia-hidden..jsso a scripted page opens on one step with no flash; without scripts every step shows.Revision 5: copy, screenshots and size (all approved by the owner) (commits
8c39f34,0f0f80b):Steps. New order: Your DM, Rooms, Commons, Connect other agents, Teamwork, Calls & voice, Connect your apps, Activity & notices, Mobile, plus the import step on a Claude Code core. That is 10 steps, or 9 without the import.
Paste hints. Every prompt says where to paste it: "Paste in: your DM" or "Paste in: a room". Commons and Teamwork go to a room.
Wording checked against the web client
origin/main@2ebcc49f:agentFlowFaces.tsxandappSetupCopy.ts.RoomNotificationSwitcher.tsx). The bell is the "Activity" button at the top of the room list (HeaderActivityButton.tsx).Icons.Headphone, "Start call"). In your DM the agent answers. In a room you pick it under "Bring an agent?" (callPicker.ts: agents do not join room calls on their own). The user-card microphone is not mentioned or shown, per the owner; a test pins that.Labels reconciled with the screenshots: + at the top of the room list (
Home.tsxaria-label="Add") then Room; the room menu (⋮) then Invite; the plug in the user card to Settings → Integrations (SidebarUserButton.tsx).Tour behaviour. When every step is done, a closing card appears: "You're set — ask anything", with a copy prompt. a teammate done moves to the next open step in step mode, not in Show all. The step's title replaces "Step N of M".
Section.imageslets any step carry screenshots.Screenshots, adapted from a reviewer agent's "Getting started with AG2 Space" page. There are eleven crops in
skills/tutorial/assets/: 140 KB in total, each at most 34 KB. Each is cropped to the control or region so no personal room, name, avatar or account shows. They are inlined asdata:URIs, so the page is still self-contained.Dropped:
The other part of 05 (its mention list), the rest of 01 (its connected accounts) and the room lists in 03 and 04 were cropped away.
Size. The page is now 215,197 bytes with the claude core (215,281 with
--audience room), over Room Commons' 196,608-byte single write.publishappends in pieces of at most 120,000 bytes, split on character boundaries. Each piece is one argv string, so it stays under Linux's 131,072-byte per-argument cap; CI caught 150,000 asE2BIG(fixed ina6d732d). A refresh is onereplacewhen both texts fit one argument. Otherwise it clears the old text in pieces (replace <piece> "") and appends the new, so a viewer may briefly see a partial page during a refresh.Revision 6: second review (a reviewer agent, a reviewer agent) (commits
8698807,452fa11, plus the asset-tracking fix below)..gitignoredropsassets/exceptskills/*/assets/. So the new Rooms crop, kept underscripts/assets/, was never committed, and CI failed withFileNotFoundError. The screenshots now live inskills/tutorial/assets/, and the one line that walks to the skill root carries the lint's line-scopedallow-repo-rootpragma. I ran the test file from a clean worktree at HEAD to prove every asset is tracked. Labels re-verified on the web clientorigin/main@5e6b74b7.room-list-add.jpg, with the red box on the +. It previously reused the Activity bell.await art.state.set(...)sits inside atry.room-collab/htmlhas no theme hook, andRoomHtmlView.css.tskeeps the frame white "whatever the client's theme". So the page followsprefers-color-scheme, and an explicitdata-theme="dark|light"wins: set on<html>, or copied from<body>.aria-live. A repeat copy is re-announced, because the status region is cleared and then set.aria-hiddenbecause the dots already announce position.CallParticipantPicker.tsx).RoomNotificationModeSwitcher"Notifications" item is in the room header's ⋮ menu (RoomViewHeader.tsx) and the room list item's menu (RoomNavItem.tsx).--audience room(render and publish) says "your agent" throughout, for any room that is not the owner's DM. The defaultdmkeeps the agent's name. The DM step now reads correctly in both.Revision 7: create once, even across crashes and lost answers (a reviewer's Codex, P2) (commit
a22b0fe):room.commons.pages.create. I described it live in the owner's DM (a read-onlyroom.actions.describe):idempotency_requirement: required, and its description says "a retry with the same one [operation_id], after a success or a failure, creates the page and writes its body at most once". It takestitle,kind: htmland a firstbodyof up to 196,608 bytes, and it returnskind. It is called through room-commons' own Actions client (room_commons.mcp_door→commons_actions), not hand-written HTTP.creatingplus a fresh operation id before the call. After the call it recordspendingplus the kind. Once every remaining chunk is appended it recordscomplete. A rerun resumes from whatever state it finds:creatingre-issues the create with the same operation id, andpendingrewrites that same page.<meta name="sutando-tutorial" content="<agent mxid>">, so chunk 1 always carries it. If the marker file is lost, a listed "Getting started" page is reused only when its text carries this agent's marker and the listing's writer (by) is this agent or blank.pendingwith an accurate message, and the next run finishes it.pages.createserver that keeps one page per operation id, plus the stubroom_commons.py; no network), all passing:test_ambiguous_create_is_retried_with_the_same_operation_id: the server creates the page and the client loses the answer. The rerun uses the same id, and there is exactly 1 page with the full content.test_create_refused_before_landing_is_retried_with_the_same_operation_idtest_resumed_create_with_changed_content_rewrites_the_first_bodytest_crash_after_create_before_recording_its_kind: thependingwrite fails. The rerun uses the same id, and there is 1 page.test_marker_write_failure_after_the_page_is_wholetest_lost_marker_recovers_the_page_by_its_ownership_markertest_failed_append_stays_pending_and_the_next_run_finishes_ittest_a_create_answer_without_an_html_kind_is_refusedtest_unrelated_same_title_pages_are_never_adopted_or_touched(control: another agent's page and an empty page by this agent, both left alone)test_two_tutorial_pages_stop_with_a_reasontest_unknown_agent_id_with_a_candidate_stopstest_ownership_needs_this_agents_markerRevision 8: prompts tailored to the owner's work (the owner's decision) (commit
f1ac2ba):user_profile.md. SKILL.md step 2 says how: 4–6 prompts, first person, practical, one line, at most 200 characters, runnable on a fresh install (only theappsslot may assume a connected app).--prompts <json>(render and publish) is an object keyed by slot:dm,commons,teamwork,apps,import,finale.://orwww., no<or>. It is escaped on render as before.--rolestakes the payload of the first-contact line that the producer, the web client first-contact change, sends:I picked: <Label1>, <Label2>.. These are persona labels (multi-word ones like "Product Manager" included), or slugs when the client had no labels. The payload is passed verbatim, e.g.--roles "Founder, Product Manager.".& - _ ' ., with at least one letter or digit.product-managerbecomes "Product Manager"); a label is kept as written.16fafabfixed--roles "Founder, Sales.", which had shown only "Founder".I picked:payload as given: "Founder, Sales." gives both entries, "Product Manager" keeps its space, slugsfounder,salesgive "Founder, Sales", and junk, markup and over-long entries are dropped. It is also de-duplicated, capped at 6, and escaped (R&Drenders asR&D);Review nits (a reviewer, approval at
70617b2) (commit afterf1ac2ba):CHUNK_BYTES), because Linux caps one argv string at 131,072 bytes. A test (DocTest) fails if SKILL.md names any other piece size.a22b0fe. A lost marker no longer creates a second page: the run reconciles from the page listing. It deliberately does not trust the title alone (per a reviewer's review); a page is reused only when its text carries this agent's ownership marker.PageBehaviourTestskips there; it runs locally withNODE_PATH. Running it in CI needs a devDependency and a workflow step, which is a repo-wide CI change; I'd rather do that as a follow-up than bundle it into this PR.--audience room: use it only when the owner asks for the page in another room, and summon only whoever they asked for.room_commons.pyerrors are now redacted of every credential-like value in the environment, including each half of aurl|secrettoken. The test's stub now leaks its own token, so the test exercises the redaction rather than our own echo.Re-review fixes (a reviewer, REQUEST_CHANGES at
6949462) (commit1a1244a):create_pageput the Actions client's exception into the publish reason unredacted. It now goes throughredact(), as does the bad-kind message, andmain()redacts every failure reason it prints.redact():Commons.run, which also feedssummon_error;create_page; andmain()'s exit-4 JSON. The remaining messages carry only page kinds, file paths, or our own validation text.Bearer …and aurl|secretrelay token leaves neither in the error or inmain()'s stdout JSON.--rolesstrips a leadingI picked:(any case, optional whitespace), so the whole line can be passed. Tested in both forms.creatingentry first looks for a listed page tagged by this agent and finishes it. A create is re-issued with the same operation id only when no such page is listed, so it no longer relies on the server remembering the id. Tested both ways.redact()also masks anything shaped likeBearer <token>orhttps://…|<secret>, not only values taken from the environment.example.com. "First person" and "runs on a fresh install" stay SKILL.md rules for the agent, because a regex can't check them.PageBehaviourTestin CI by installing jsdom there.Create once.
<workspace>/state/tutorial/getting-started.jsonrecords the page kind for each room, and writes to it are atomic and under a lock. Re-running in the same room rewrites the page if its content changed (including a core change, which adds or drops the import part), or does nothing if it didn't. A re-link or reinstall that reuses the DM therefore refreshes the same page instead of adding a second one. If someone removed the recorded page from the list, it is created again. The owner is summoned only when the page is created, or on a refresh run with--summon.The greeting is never blocked. The skill runs after the reply has been sent. Exit 3 means room-commons is not installed. Exit 4 means a room-commons call failed: the marker is left unchanged, and an empty page this run added is removed again. In both cases the greeting stands and nothing more is said. A summon failure still reports the page (
summon_error).How the Copy prompts work. The HTML surface renders pages in an
allow-scripts-only sandboxed srcdoc frame. It exposeswindow.artifact(state, events, db) and link forwarding, but it has no hook for inserting text into the composer: see the web clienthtmlRuntime.ts,htmlLinks.tsandhtmlSurface.ts. I did not invent one. Each button triesnavigator.clipboard.writeText, then falls back toexecCommand('copy'). If both are refused, it selects the prompt text and the button reads "Press ⌘C".The wiring at first contact goes in a separate PR in the desktop app, which owns
engine/CLAUDE.md/engine/AGENTS.md: (link in a comment below). Until this skill ships in the engine, that bullet's "if the skill is missing" clause applies.Related: #3924 (draft
/wizardtour: a local card plus text, no page) is a separate surface. This PR does not touch it.What should reviewers look at first?
skills/tutorial/scripts/getting_started.py:plan()/publish()(the idempotency marker and the room-commons call sequence).skills/tutorial/SKILL.md: the ordering rule (greeting first, never retracted).tests/tutorial-getting-started.test.py.What user behavior does this prove?
A new owner gets the greeting, and then one Getting started page in their DM: an AG2 Space tour with one thing to try in each part. A re-link does not create a duplicate page.
Feature/documentation consistency
docs/tutorial-delivery.md(canonical for the tutorial) now points to the skill and describes both modes. Itsdocs/catalog.jsonentry has a newcanonical_forandlast_verified: 2026-10-15, and thedocs/README.mdline is updated.skills/room-commonsis shipped by the desktop app as an overlay and is not in this repo, so the skill finds it atskills/room-commons/scripts/room_commons.py, with a--room-commonsoverride.Before/after evidence
Parent commit (skill absent):
HEAD:
Positive controls:
Mutating
plan()to always returncreatemade 3 tests fail.Mutating
sections_for()to show the import part on any non-Codex core made 1 test fail.Dropping the
tryaroundart.state.setfailed the storage-guard test.Adding an
https://icon link failed the no-external-URL test.All four mutations were reverted afterwards.
The page's JS was run in jsdom (from the web client's
node_modules, locally, not in CI) in two modes:I also took headless Chrome screenshots at 360px and 760px widths, light and dark: no horizontal overflow, and the accents are legible.
Rendered parts with
render --name Blue:The argv the script sends was parse-checked against the shipped
room_commons.py(--helpon each form:--json pages,--json page-add,--json --kind html-x read,--kind html-x append|replace|summon,page-remove). All forms parse. The page's inline script passesnode --check.What tests did you run?
The tests cover:
Interactive (rev 3): tour controls and progress markup; 9 a teammate done boxes; an SVG per section plus the hero; no external URLs for any core; dark mode, reduced-motion and narrow-width blocks; every storage access inside a
try; per-viewerartifact.statekey; accent palette at AA in both themes; both page scripts passnode --check(skipped when node is absent).Name (rev 3): explicit name kept;
autouses the profile displayname from the well-known homeserver; then localpart, then Sutando; a non-https homeserver is not followed.Credentials (rev 3): names read from the script; the env file fills them; an env credential wins with nothing mixed in; the token is never printed; the installed room-commons is found.
Rev 4/5:
aria-live; norole=imgwitharia-hidden; the.jsclass set in the head.data:images; page size bound.Each was checked by mutation: auto-advance removed, chunking removed, the shared-key fallback, the dot scroll.
Content: all eight parts in order for every core; the import part present only with
claudeand absent forcodexandunknown; a bad core value is refused; one Copy button per prompt, with the connect prompt kept; dark mode; Codex and Claude never offered in Connect other agents; rendering is deterministic; the name is escaped everywhere; a bad name is refused.Marker: survives a corrupt file, round-trips, and drives
plan.publish, against a stubroom_commons.py:unchangedwith no summon;CLI:
renderfor each core;detect_core()follows theSUTANDO_CORE_RUNTIMEstamp; exit codes 3 and 2.Live path?
Live receipts. The coordinator ran these against real rooms with this skill. They used the branch at
1755749for the create and first refresh, and at452fa11for the latest refresh. They do not exercise the create-once recovery code added ina22b0fe.the owner's DM:publishcreatedthe page; refresh in place updated it.a shared test room:publishcreatedthe page; refresh in place updated it.<img>and all 10h2sections.--name autoresolved "the agent's profile name".--audience roomrendered "Getting started with your agent", with 0 occurrences of the agent's name.Live receipts for the create-once path (head
16fafab, run by the coordinator):a scratch room(the owner is not a member):pages.createwas refused with FORBIDDEN ("the agent's owner may not publish in this room"). The marker keptstate: creatingand operation idthe stored operation id. The rerun re-issued the same operation id and reported the refusal; no page was created.the owner's DM, the marker entry was removed while the owned page (with itssutando-tutorialmeta) still existed.publishadoptedthe page("adopted": true, unchanged). That run also summoned the owner again, a duplicate card, which commit6949462fixes: adopting no longer summons unless--summonis passed.planreturnedcreate.publishthen createdthe pagethroughpages.create, with marker statecomplete. A rerun reported unchanged, with the same kind.sutando-tutorialmeta sits at byte 140; the page shows "Picked for: Founder, Sales", 11<img>and all theh2sections.Still unverified:
Worst-case disruption
The skill is opt-in: nothing runs it until the engine CLAUDE.md change lands. In the worst case the page fails to create and the owner sees only the greeting, which is today's behavior. The page is limited to one per DM, and each run is a constant number of room-commons calls. The shared
channel_credentialsextraction is behaviour-identical for pending-questions (same function body, now delegated), and its tests are unchanged and green.--name automakes at most two 5-second HTTPS GETs before it falls back.🤖 Generated with Claude Code