Repository navigation
Conversation
Agents can attach Inline UI to a message: a signed `inline-ui` tag
({v: 1, type, ...}) for choice, action, list, form or checklist, with
the message text ending in a plain-text version derived from that value.
An edit, an unknown type or version, or text that disagrees with the tag
leaves the message as plain text. The format is runtime-neutral so other
agent hosts can emit it.
Answers are ordinary messages carrying an `inline-ui-answer` tag that
names the answered message and the value picked, with the answer itself
as text, so they are signed, identical on every device and readable by
agents without Inline UI. Lists name channels by id and resolve live
through the existing permission-aware channel lookup. A join is answered
only after the relay confirms it. The agent `send` tool takes `ui`.
No visual change: messages keep showing their plain text.
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Carl <c217fe6b9d958f41c3a5e030dccc7f626775a923089cb6491305eade75ea1f1b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Carl <c217fe6b9d958f41c3a5e030dccc7f626775a923089cb6491305eade75ea1f1b@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Labels now allow emoji joiners and variation selectors, and reject line and paragraph separators, bidi controls and buzz:// links, so two Inline UIs can't share one plain-text version. own: false reads as absent, as the send schema allows. Answering a form loads the agent's profile first so its verified owner is known. Co-authored-by: Carl <c217fe6b9d958f41c3a5e030dccc7f626775a923089cb6491305eade75ea1f1b@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
kalvinnchau
left a comment
There was a problem hiding this comment.
🤖 Three answer-contract issues found; details inline. The answer APIs currently have no renderer callers, so these are API defects rather than current card-click regressions.
| const channelId = original?.tags.find(([name]) => name === "h")?.[1]; | ||
| const asked = | ||
| original?.kind === 9 && | ||
| inlineUiMessage(original.content, original.tags); |
There was a problem hiding this comment.
🤖 [P2] Reject answers to edited questions
After an author edit, the folded question has no inlineUi, but this reparses the immutable original event. In Chromium and WebKit, calling the real session's messages.answer(id, "0") after the edit still signed and published an answer and asker mention; counting correctly returned zero. An unchanged-text edit also reproduces it. Supply current question eligibility to the writer and recheck before enqueueing after asynchronous profile work. Add a regression asserting no publication after an author edit. This is currently an API defect: no card renderer calls this method yet.
| // verified owner is known before deciding. | ||
| if (asked.ui.type === "form") await profiles?.ensure([authorId]); | ||
| const owner = (agent: string) => profiles?.owner?.(agent); | ||
| if (!canAnswer({ authorId, mentions, inlineUi: asked }, viewer, owner)) |
There was a problem hiding this comment.
🤖 [P2] Await verified ownership before deciding form authorization
On a cold agent profile, profiles.ensure() returns after accept() schedules attestedOwner(), without awaiting its WebCrypto verification (profile-directory.ts:79–105,239–243). owner() can therefore still be undefined here, rejecting the legitimate owner with “This isn’t yours to answer”; a later retry can succeed. This is source-traced, not runtime-reproduced, and fails closed. Provide an explicit awaitable verified-owner contract and test delayed verification using the real directory; the synchronous owner-installing ensure stub hides the ordering.
| row.delivery !== "seen") || | ||
| row.channelId !== message.channelId || | ||
| row.threadRootId !== message.threadRootId || | ||
| row.createdAt < message.createdAt || |
There was a problem hiding this comment.
🤖 [P3] Preserve valid answers across clock skew
A question signed at now+300s and an answer signed at now pass through the outbox/fold, but this timestamp comparison drops the answer. A focused probe reproduced this; outbox.ts:774 permits up to 15 minutes of skew. The signed answer already identifies its question by event hash. Remove the cross-host timestamp ordering requirement and align its documentation/test, or explicitly normalize the answer timestamp. Add a skew regression. Counting currently has no renderer caller.
## TL;DR An MCP caller outside Buzz, such as the standalone stdio server, can now start a Solo chat with `channel_create` by passing `solo: true` and optional `members`. The tool sends the same create the app sends when it starts a Solo chat: a private stream labeled `buzz.solo`, with the members as `p` tags so the relay adds them in the create. The create is signed by the caller's own key. The tool then reads back the label and the roster, and `channel_get` shows `solo`. ## What changes - `channel_create` takes `solo` and `members` (pubkeys or npubs, at most 32, the relay's limit). A Solo chat must be a private stream without `about` or `template`, so only the named members join it. `members` is refused without `solo`, so ordinary creates are unchanged. - The kind:9007 matches `channelWrites.create`: `buzz.solo` and `buzz.session.named` label tags (the caller chose the name), then a `p` tag per member. That relies on the relay adding initial members in the create ([block/buzz#8421](block/buzz#8421)). A member whose policy refuses third-party adds keeps the whole create out, so a refusal creates nothing. - The tool reports each member the way the roster shows it. A relay that drops the label is reported as having made an ordinary private channel, and a create the relay never shows still names the channel ID. - `channel_get` lists `solo` among a Solo chat's flags. No message is sent, so no agent turn starts, and the chat is not filed in a Solo workspace. The signer and the relay's membership and authorization checks are unchanged. ## Verification - Vitest in `src/buzz-mcp/tools.test.ts`: the exact Solo create tags plus member, label and roster readback; an ordinary private create stays unlabeled; argument refusals happen before publishing; a refused member creates nothing; a dropped label and a create that never shows are reported. - Real transport: from a clean subprocess, a JSON-RPC client drove the built standalone server (`node dist-buzz-mcp/buzz-mcp.mjs`) over stdio. It ran against an isolated local relay built from block/buzz main (`scripts/start-isolated-test-relay.sh`), with throwaway keys for owner, member and outsider. The owner's `channel_create` with `solo` and one member returned the new stable ID, and its `channel_get` read back `(stream, solo, private)`. The member's own server read the same chat as `you: member`. The outsider's server could not see it, and its attempt to add itself was refused. In the relay's database the chat has labels `buzz.solo` and `buzz.session.named`, `created_by` is the caller's key, and the member row was written in the same transaction as the channel. An ordinary private create made in the same run has no labels. Not covered: opening the chat in the desktop app. The tool's own check uses `soloChannelMetadata`, the same function the app uses to put a channel under Solo. Nearby open PRs: #1624 adds an `auto-join` flag on the line next to the new `solo` flag in `channel_get`. Merging both is a one-line conflict. #2121 also edits `src/buzz-mcp/tools.ts` and its tests, but not `channel_create`. #2082 and #2112 change other Solo and channel operations, and this PR does not touch them. ## Initiative context [Nexus Buzz plugins status](https://g2.sqprod.co/apps/kylez-nexus-buzz-plugins-status) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Kyle Zemek <kylez@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source review via Wes’s account, incorporating independent Rocket, Mantis and Groot lanes.
No new findings. Two existing P2 API defects remain source-confirmed and should be fixed: answering edited questions and checking ownership before cold-profile verification completes. Please apply the repairs and regressions described in those existing threads; this review does not duplicate their inline comments. The existing clock-skew feedback also remains relevant; timestamp rejection is still part of the current implementation and documented contract.
Reviewed head 977aad4d9f1636d941f257b40811848e878f5e13 against base 67e4049171ae615c29da5f83153daf42079436e2 (merge base ac8495a8bd9585f0ddd1c99312bf30af068af962). Inspected the public description and commit text; no attached images. Source-only: no tests, builds, app launches, CI assessment or runtime reproduction performed. The new action/list/answer helpers have no production card callers yet; native interaction, performance, focus, keyboard and retry UX remain unverified. This COMMENT is not an approval.
Summary
This adds the plumbing that lets an agent attach interactive Inline UI to a chat message: a choice, an action, a channel list, a form or a checklist. The agent picks a type and supplies the words and channel references. The app decides how each type looks and what it does.
Nothing visible changes yet. The app doesn't draw the cards, so every Inline UI message still shows its plain-text version. The card designs are in progress in Figma and will follow in a separate PR.
What's included
src/features/inline-ui/spec.ts): v1 of the signedinline-uitag, its validation, and a plain-text version derived from it. A message counts as Inline UI only while its text still ends with that exact plain text. Any edit turns it back into plain text.answers.ts,messages.answer): an answer is an ordinary reply in the question's thread. It tags the asker and carries aninline-ui-answerclaim, and its text is the answer itself. The rules for who may answer are: anyone except the author by default; only the people mentioned whenfor: "mentioned"; only the agent's verified owner for a form. Counting ignores edited, failed, mismatched or duplicate answers.actions.ts): the app joins first and answers only after the relay confirms the join. A failed join posts nothing. Opening a channel only navigates.channel-list.ts): live, paged rows resolved with the viewer's own permissions. It reuses the channel-link lookup. DM, Solo and hidden channels never appear as joinable rows.sendtool: a newuiargument that validates the object and writes the tag and the plain-text version.docs/inline-ui.md, linked fromdocs/agents.md, so other runtimes, including hosted Builderlab agents, can emit the same format.Screenshots
None. This PR has no visual surface; Inline UI messages render as plain text until the cards PR lands.
Testing
vitest run src/features/inline-ui src/features/relay src/buzz-mcp: 2751 passed, 4 skippedtsc --noEmit: clean🤖 Generated with Claude Code