Skip to content

feat(agents): agents never DM, except with their owner; they answer other DMs in Solo chats - #3261

Draft
joahg wants to merge 6 commits into
block:mainfrom
joahg:joah/agents-solo-not-dm
Draft

joahg wants to merge 6 commits into
block:mainfrom
joahg:joah/agents-solo-not-dm

Conversation

@joahg

@joahg joahg commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

Agents can no longer DM people or each other. The one exception is a DM whose only members are the agent and its owner, such as the pinned Bestie conversation. For any other private conversation, an agent starts a Solo chat, and that Solo chat always includes its owner. When anyone else DMs an agent, the agent answers by starting a Solo chat for that request.

Changes

  • No DM tool. dm is removed from the Buzz MCP tools.
  • No posts in DMs, except one with only the owner. send and reply_in_subthread refuse a channel whose metadata type is dm, unless its roster (39002) holds only the agent and its owner. The error tells the agent to start a Solo chat instead.
  • Plugin agents can't open DMs. Kind 41010 is gone from crates/agent-controller app_agent KINDS, so the app won't sign a DM open with a plugin agent's key. Bestie's DM is opened by the owner's app, so it doesn't need this.
  • Owner is always in an agent's Solo chat. channel_create with solo: true adds the agent's owner (client.owner) to the members. If the host doesn't know the owner, it refuses. The node client now exposes owner from its NIP-OA auth tag. The owner counts toward the relay's 32-member limit at create.
  • DM turns route to Solo chats. prompt.md (shared posting conventions) drops "In a DM, send replies at the top level." It now says to answer a DM from anyone but the owner by starting a Solo chat with the sender (the owner is added), and to keep using that Solo chat for follow-ups on the same request. The new DM_PROMPT replaces the "pass reply" routing line in those DM turns for Claude Code, Goose, Pi, the harness kit (which share turnPrompt) and Codex. ownerDm in features/agents/reads.ts identifies owner-only DMs from the channel's members. Every one of those callers passes it, so owner-only DM turns keep the ordinary DM routing.

Not covered here

  • Relay enforcement. A harness agent that holds its own key could still sign a 41010 or a kind 9 into a DM directly. Only the relay can refuse those, and the relay isn't in this repo.
  • Harness failure notices. The harness's own reply() posts, like the failed-turn notice that tags the owner, still go to the conversation, and that includes a DM.
  • Remote/hosted agents. Agents on other hosts (e.g. Builderlab) have their own tool sets and need the same change there.

Testing

  • vitest run src/buzz-mcp/tools.test.ts: pass. Tests cover:
    • no dm tool
    • send and reply_in_subthread refused in a DM that has another person in it, allowed in a DM with only the owner, and refused there when the host doesn't know the owner
    • posting in a stream still works
    • the owner added to a Solo create (deduped, counted toward 32)
    • no Solo chat without an owner
  • src/features/agent-harness/kit.test.ts: an owner-only DM turn gets no DM_PROMPT. A DM with a third member, or with unknown members, gets it.
  • src/features/agents/reads.test.ts: ownerDm is true only for a DM whose members are just the agent and its owner. It's false for unknown members, an absent owner, or any third member.
  • The Claude Code prompt tests cover the DM turn instruction and the owner-DM case, which keeps the reply routing. The Codex prompt tests cover the DM turn instruction.
  • cargo test -p buzz-agent-controller: pass, including 41010 now refused.
  • tsc --noEmit, biome and rustfmt hooks: clean.
  • Vitest over src/buzz-mcp, src/bundled/{claude-code,codex}, src/features/agents and src/features/agent-harness: every failure is in a file this PR doesn't touch, and each one either passes when rerun alone or also fails on unmodified main (codex/tabs effort refresh).

🤖 Generated with Claude Code

joahg and others added 2 commits November 10, 2026 22:27
…ir owner

Agents lose the `dm` tool, and `send` and `reply_in_subthread` refuse a DM
channel. Plugin agents' keys no longer sign DM opens (41010). A Solo chat an
agent starts always adds its owner. The shared posting conventions and the
Claude Code, Goose, Pi and Codex DM turn context tell an agent to answer a DM
by starting a Solo chat for the request, with the sender and its owner.

AGNTOPS-780

Co-authored-by: Hertz <c6741012416ef5c49a865db79682113385c5fb998e8c890234fb442b59e22748@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Joah Gerstenberg <[email protected]>
A DM whose only members are the agent and its owner, such as Bestie's
pinned chat, stays open to the agent: send accepts it, and the Claude Code
and Codex turns keep the ordinary DM routing there instead of sending the
answer to a Solo chat. Every other DM stays closed.

AGNTOPS-780

Co-authored-by: Hertz <c6741012416ef5c49a865db79682113385c5fb998e8c890234fb442b59e22748@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Joah Gerstenberg <[email protected]>
@joahg joahg changed the title feat(agents): agents never DM; they answer DMs in Solo chats with their owner feat(agents): agents never DM, except with their owner; they answer other DMs in Solo chats Nov 11, 2026
@joahg
joahg marked this pull request as ready for review November 11, 2026 21:40
@joahg
joahg requested a review from a team as a code owner November 11, 2026 21:40

@kazemek21 kazemek21 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.

Kyle reviewed the PR and the Nexus review-tracker notes. Approved with the non-blocking suggestion below.

Posted by Codex (gpt-6.1-sol) on behalf of kylez

: thread
? "IMPORTANT: `send` replies in this thread by default, so the conversation stays threaded. If the human explicitly asks for a channel-root, top-level, or broadcast post, pass `channel`. If the requested destination is ambiguous, ask before sending."
: "IMPORTANT: This is a new top-level message. `send` replies in its thread by default — the triggering message is the thread root. Do NOT reply into any other (older) thread. If the human explicitly asks for a channel-root, top-level, or broadcast post, pass `channel`.";
: channel.dm && !channel.ownerDm

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] Preserve the owner-DM exception in every shared-prompt caller

Goose, Pi and the harness kit still construct channel with only dm, so ownerDm is undefined here even in the owner’s own DM. Those turns now explicitly tell the agent to start a Solo chat instead of answering in Bestie. Could these callers pass the owner-only status as the Claude runtime does?

Posted by Codex (gpt-6.1-sol) on behalf of kylez

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 Good catch, fixed in 245e265. Goose, Pi and the harness kit now pass ownerDm the same way the Claude Code runtime does: the kit stores it at admission from the channel's members, and Goose and Pi compute it from the channel summary. A new kit test checks that an owner-only DM keeps the normal DM routing and that a DM with a third member, or with unknown members, gets the Solo chat instruction.

joahg and others added 3 commits November 11, 2026 17:58
… turns

Goose, Pi and the harness kit built the turn's channel with only `dm`, so
in a DM with only the owner they still told the agent to start a Solo chat.
Each now passes `ownerDm` the way the Claude Code runtime does.

AGNTOPS-780

Co-authored-by: Hertz <c6741012416ef5c49a865db79682113385c5fb998e8c890234fb442b59e22748@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Joah Gerstenberg <[email protected]>
* origin/main: (38 commits)
  Fix reminder counts with compact dock corner tabs (block#3126)
  Add conditional undo for explicit reads (block#3291)
  fix(composer): edit channel links as atomic chips (block#3312)
  fix(agents): show remote instruction limit (block#3325)
  NEXUS-4033: Offer session skills in the core task composer (block#2790)
  feat(github): name pasted repository and commit links (block#3268)
  Let plugins give standing instructions to every agent turn (block#3285)
  fix(relay): read a channel's creation once, not per metadata version (block#3244)
  fix(messages): remove unconfirmed sends from this device (block#3317)
  fix: expose proven non-delivery for artifacts (block#3300)
  feat(plugins): let plugins mark a person's name in @mention suggestions (block#3260)
  fix(mcp): strip metadata from images agents attach (block#3311)
  fix(identity): name a linked key that isn't here, and keep Restore after a failed backup check (block#3238)
  feat(changesets): show the files a local agent changed on its reply (block#3214)
  fix(identity): show a backed-up key as backed up, and drop dead retries (block#3223)
  fix(browser): open Builderlab account connections in the system browser (block#3287)
  Expose exact captured-message reads (block#3264)
  docs(agents): keep internal discussion out of public repo content (block#3258)
  fix(identity): clear a copied private key, name restore failures, fix storage copy (block#3236)
  fix(channels): list workspaces that load after `#` opens (block#3277)
  ...
AGNTOPS-780

Co-authored-by: Hertz <c6741012416ef5c49a865db79682113385c5fb998e8c890234fb442b59e22748@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Joah Gerstenberg <[email protected]>
joahg added a commit to block/buzz that referenced this pull request Nov 12, 2026
## Summary

Agents stop writing in direct messages, except a DM with only their
owner. This is the relay-side backstop for block/buzz-app#3261, which
takes away the app agents' DM tools. An agent holding its own key could
still sign DM events directly, and only the relay can stop that.

A key counts as an agent when it has a stored owner
(`users.agent_owner_pubkey`, written when the agent authenticates with a
NIP-OA owner attestation). For those keys:

- **Writes into a DM channel** are refused with `restricted: agents
cannot post in direct messages; start a Solo chat that includes your
owner instead`. The check sits in `check_channel_membership` for
`ChannelWrite::Post`, so it covers stream messages, reactions,
deletions, typing and other channel ephemerals, artifact moves and edit
validation. Non-DM channels skip it at no extra cost.
- **Workflow `send_message`** (which posts as the workflow owner and
checks `may_post` directly) applies the same rule before persisting, so
an agent can't route a DM post through a workflow.
- **Put-user (9000) and edit-metadata (9002)** skip the generic gate, so
they get their own check in a DM: an agent never adds DM members with
9000 (an addition to an owner-only DM always widens it), and edits topic
or purpose only in its owner-only DM (the edit posts a system message
with the text).
- **Huddle admission** checks `may_post` directly, so it applies the
same rule to the huddle channel and its lifecycle parent.
- **DM open (41010) and DM add-member (41011)** are refused unless the
resulting participant set is exactly the agent and its owner.
- **Exemption:** a DM whose live members are exactly the agent and its
owner stays open (for example, a personal assistant's home chat).
- Lookup failures fail closed. Confirmed agent owners are cached per
community (`agent_owner_cache`, 5 min TTL). The link is first-write-wins
and never changes once set. A missing link is never cached, because
another relay process may write it later. `link_agent_owner` primes the
cache.

Humans are unaffected: they can still open DMs that include an agent and
post in them. The agent answers elsewhere.

Not covered:
- A key that never presented an owner attestation has no stored owner
and looks human.
- The huddle join commit re-check (4e) doesn't repeat the DM rule; the
pre-join admission check enforces it.
- Agents also can't delete their own earlier messages in a non-owner DM
via kind 5, since deletions pass the same `Post` gate.

### Related issue

Companion to block/buzz-app#3261. AGNTOPS-780.

### Testing

New Postgres ingest tests (`handlers::ingest::postgres_tests::agent_*`),
run locally:
- An agent is refused in a 1:1 DM with someone else and in a group DM
with its owner; the humans in both still post.
- An agent posts in a DM with only its owner.
- An agent can open a DM only with its owner; a human can still open a
DM with an agent.
- An agent can't add members to its owner DM, through 41011 or 9000.
- An agent can't set the topic in a DM with a stranger.
- An agent is refused admission to a huddle in a DM with a stranger and
admitted in its owner-only DM; a human still joins.
- An agent's workflow `send_message` is refused in a DM with a stranger
and allowed in its owner-only DM.
- An owner linked after a lookup miss is seen on the next lookup (no
negative caching).

`cargo clippy -p buzz-relay --all-targets -D warnings` is clean. The
other ingest and command-executor tests pass locally (214), except two
that fail only because of my local database: one needs the desired-state
FTS schema, and the other fails creating its restricted role in setup.
Neither reaches DM code. I pushed without the local pre-push `just
test-unit` lane because a `buzz-cli` unit test hung there for about 30
minutes; CI runs that lane.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Joah Gerstenberg <[email protected]>
Co-authored-by: Hertz <c6741012416ef5c49a865db79682113385c5fb998e8c890234fb442b59e22748@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <[email protected]>
@joahg
joahg marked this pull request as draft November 12, 2026 14:36
* origin/main: (133 commits)
  Expose ordinary chats without workspace headings (block#3530)
  [2/2] feat(agents): offer Automatic (Recommended) in Create agent and Identity (block#3345)
  fix(solo): preserve and resume unsent chat drafts (block#3525)
  fix(builderlab): give refused app sign-ins and Identity links a route to Organization sign-in (block#3078)
  fix(agents): say why Import an agent can't import here, and link Manage plugins (block#3073)
  [1/2] feat(agents): add a recommended agent setup (core) (block#3344)
  test(browser): survive the live-stream restart a page-clock jump causes (block#3442)
  chore(renovate): hold rusqlite below 0.41 for mesh-llm (block#3438)
  test(browser): serve the diffs and CLI reload fixtures outside the navigation budget (block#3445)
  test(browser): play the draft clip at rate 0 instead of looping it before the Play click (block#3446)
  fix(status): reopen a status whose preset deadlines coincide as Today (block#3449)
  fix(messages): tolerate a cancelled reaction-name chunk load (block#3450)
  fix(pairing): tolerate /proc/net/tcp rows printed twice or missed (block#3451)
  test(browser): wait on the signal each test asserts in local-agent-activity and message-navigation (block#3444)
  Expose native channel headings to Solo sources (block#3523)
  test(browser-host): run the 100,000-channel broker scale check alone in the measurements lane (block#3447)
  fix(solo): keep every contributed destination action (block#3516)
  fix(identity): report an issuer that keeps rate-limiting an agent as a limit (block#2365)
  fix(identity): let a stuck at-launch sign-in copy its link (block#2453)
  fix(profiles): open avatar previews on hover in compact density (block#3510)
  ...

Signed-off-by: Joah Gerstenberg <[email protected]>

# Conflicts:
#	src/bundled/codex/prompt.test.ts
#	src/bundled/goose/runtime.ts

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.

2 participants