镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

feat(inline-ui): add Inline UI message plumbing - #2121

Open
morgmart wants to merge 3 commits into
mainfrom
morganm/inline-ui-cards
Open

morgmart wants to merge 3 commits into
mainfrom
morganm/inline-ui-cards

Conversation

@morgmart

@morgmart morgmart commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

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

  • Format (src/features/inline-ui/spec.ts): v1 of the signed inline-ui tag, 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 (answers.ts, messages.answer): an answer is an ordinary reply in the question's thread. It tags the asker and carries an inline-ui-answer claim, and its text is the answer itself. The rules for who may answer are: anyone except the author by default; only the people mentioned when for: "mentioned"; only the agent's verified owner for a form. Counting ignores edited, failed, mismatched or duplicate answers.
  • Actions (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 (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.
  • Agent send tool: a new ui argument that validates the object and writes the tag and the plain-text version.
  • Spec: docs/inline-ui.md, linked from docs/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 skipped
  • tsc --noEmit: clean
  • biome and pre-commit hooks: clean
  • Reviewed in three lanes (format, answers and authorization, actions and the channel list). Review fixes are in the last commit.

🤖 Generated with Claude Code

morgmart and others added 3 commits November 1, 2026 20:38
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>
@morgmart
morgmart requested a review from a team as a code owner November 2, 2026 05:18

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [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 ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [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.

kazemek21 added a commit that referenced this pull request Nov 2, 2026
## 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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants