Files
com.vitorpamplona.amethyst/cli/plans/2026-07-25-buzz-agent-support-channel.md
Vitor PamplonaandClaude Opus 5 8dcc78056d refactor(buzz): move the Backlog and Workflow boards into the channel overflow
The two new boards were added as top-bar icons, which put a Buzz channel back to
four (Canvas, Backlog, Workflow runs, ⋮) and truncated the title row the bar is
there to show — "nosfabrica.commun…" instead of the full relay. That is the
crowding #3729 had just removed; this branch predates it and the merge stacked
them.

Canvas keeps the only icon: it is the channel's shared document, i.e. content.
Backlog and Workflow runs are two more views of the channel, reached
occasionally, so they join Threads and Share in the overflow — and pick up the
`!isDm` gate the icons never had.

Also gives the job board a string resource; the branch's i18n pass left
"Backlog" hardcoded in JobBoardScreen and in the icon's contentDescription.

Adds cli/tests/buzz/agent-exec.sh, which covers what the two loop harnesses
stub out. job-loop.sh proves the scheduler drives *an* --exec program; nothing
committed exercised the real one — the wrapper that turns a job into a PR. With
a stubbed `gh` and agent (no network, credentials, or Claude Code) it asserts
the happy path end to end (task on stdin → agent → commit → push the job branch
→ PR url on stdout) and, as importantly, the paths that must fail: an agent that
changed nothing becomes a job error, an empty task is rejected before the agent
runs, missing scheduler env is a hard error rather than a silent no-op, an agent
that committed for itself is not double-committed, and the default-branch guard
holds — asserting `main` on the remote is left untouched. 19/19.

Verified on emulator-5554: the bar is Canvas + ⋮ again with the full relay name
visible, the menu reads Threads / Backlog / Workflow runs / Share / Members /
Leave, and Backlog still opens from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:37:22 -04:00

18 KiB
Raw Permalink Blame History

Buzz-driven agent support channel for Amethyst

Date: 2026-07-25 Status: prototype landing (CLI) · mobile gaps scoped Owning module: cli/ (with a shared aggregator in commons/)

Goal

Give the Amethyst team a shared feature-request channel where anyone can drive work: the team debates and files requests, an AI coding agent — Claude Code running as this Anthropic account — manages the backlog by itself and works items in parallel, over a self-hosted block/buzz workspace. Every request, upvote, and result is a signed, audited Nostr event the whole room sees. This is not a 1:1 chat with the bot.

Interaction model (decided):

  • Anyone in the channel can drive a work stream — no propose-and-confirm gate; a member's job request is auto-accepted and scheduled (full auto from intake).
  • The bot owns a stack: it orders the backlog by the group's upvotes and runs up to N in parallel, each isolated in its own git worktree/branch.
  • The only human gate is the merge, and it happens on GitHub (branch protection + review) — never inside Amy or the channel. The agent opens PRs; it can never merge or damage main.

Can this live in Amy? Yes — Amy is the scheduler, the coding agent is --exec.

A clean three-way split, no separate project needed for the team-on-a-box case:

  • Amy owns the Buzz side: watch the backlog, order by upvotes, dispatch up to --parallel N, isolate each job in a worktree/branch, report status as job events. Reuses everything already built (relay client, job models, BuzzJobAggregator, the responder, subprocess spawning, the long-running serve pattern). Decision logic lives in commons (pure/testable); git + process I/O lives in the cli command — so Amy stays a thin assembly layer.
  • --exec is the coding agent (Claude Code via buzz-acp / Goose / a script) Amy spawns per job. Not a new project — an existing tool. It runs inside the job's worktree (BUZZ_BRANCH, BUZZ_WORKTREE exported), commits, pushes the branch, opens the PR; its stdout is the result.
  • GitHub owns review + merge, entirely outside the loop.

Graduate to a separate service only if you outgrow one host (hosted, multi-tenant, a web dashboard, a cross-machine worker fleet) — and even then Amy/quartz/commons stay the library underneath.

Why Buzz is the right substrate (and what it is NOT)

Buzz is a self-hosted Nostr relay that acts as a workspace where humans and agents share rooms; Amethyst already models ~78 of its kinds (quartz/.../buzz/) plus client UI (agent console, workspaces, DMs, attestations — shipped in v1.13.0). Upstream, Buzz ships buzz-acp, an ACP harness that already plugs Claude Code (and Goose/Codex) in as the agent runner, and produces code as NIP-34 patches / git diffs / PRs — the same flow this repo's claude/* branches already use.

What already exists in-repo to build on:

Layer Status
Workspace = a relay you own; channels/threads/canvas app + amy buzz post/read
DMs to an agent key (open/hide/add-member/list) app + amy buzz dm …
Agent authorization — NIP-OA owner attestation (virtual membership) AgentAttestationScreen + amy buzz attest
Agent config — personas (30175), managed agents (30177), agent profiles (10100) quartz models + persona editor
Cost/activity telemetry — turn metrics (44200), observer (24200) Agent Console + amy buzz console
Code changes in the room — diff (40008), NIP-34 patches rendered in chat
Human-in-the-loop gate — workflow approval (46010/46030/46031) quartz models only, no UI
Structured jobs — 43001-43006 quartz models + EventFactory dispatch; no client surface (this plan)

The permission reality — the crux

Buzz authorizes by identity, not by capability flags. Its entire vocabulary is coarse: membership + owner/admin/member roles, NIP-OA conditions limited to a single kind and created_at before/after bounds, and per-agent respond_to / channel_add_policy gates. There is no way in Buzz to express "may push but not merge" or "only this repo." So the constraints the task asks for live in three layers, and Buzz is only one:

Requirement Enforced by How
Can't merge/destroy main GitHub branch protection (load-bearing) Protect main (PR + review + green CI, no direct/force push, no branch delete). The agent runner's git credential can only open PRs on feature branches — never merge.
Can't use the agent to code other things Agent runtime + Buzz intake --exec checked out in amethyst only, scoped tools; persona system-prompt scopes the task; --accept-from allowlist = team npubs only.
Only the team can drive it Buzz Team npubs = relay members / the --accept-from allowlist.
Everything accountable Buzz Every request/progress/result is a signed event in the tenant's hash-chained audit log.
Human sign-off before risky actions Buzz workflow gate 46010 pause → 46030/46031 grant/deny by a designated approver key (two-signer; a run can't self-approve).

Honest blast radius: a Buzz-authorized agent key has member-level reach on the relay only. Its reach into code is bounded entirely by the git credential handed to --exec. Keep that credential minimal; branch protection is what actually stops a bad merge.

Architecture (MVP)

  1. The workspace relay. For the agent job channel you have two options:
    • amy serve --buzz --members <npubs> (recommended to start) — a private, agent-authorized workspace on a single JVM process via BuzzMembershipPolicy (quartz): NIP-42 required, only members + NIP-OA-attested agents may read/write. No Rust, no Postgres/Redis/MinIO. The job board + scheduler run on this today. It does NOT emit relay-signed NIP-29 metadata (39000-39003) or run workflows — the job channel doesn't need them.
    • Block's Rust buzz-relay — only if you want the full in-app Buzz workspace/DM UI (relay-signed rosters, relay-assigned DM UUIDs) or server-run workflows. Heavier stack.
  2. One agent identity = its own nostr key, authorized by a NIP-OA attestation the owner issues (amy buzz attest / AgentAttestationScreen). On GitHub it authenticates with a PR-only token; main is branch-protected.
  3. Intake: a team member files a job in the #build channel (or DMs the agent). The responder picks it up, runs a coding agent in an amethyst checkout, streams progress, posts the result, and opens a PR on a claude/* branch. Merge stays human.
  4. Optional approval gate (46010/46030/46031) for irreversible mid-run steps.

CLI prototype (this change)

Thin assembly over quartz job models + a shared aggregator; no protocol logic in cli/.

  • commons/.../model/buzz/BuzzJobs.ktBuzzJobAggregator, a pure, tested (BuzzJobAggregatorTest, 9 cases) folder that correlates 43001-43006 events (by the reply e → request id) into JobView records with a JobState machine (REQUESTED→ACCEPTED→IN_PROGRESS→COMPLETED/FAILED/CANCELLED; newest terminal wins). Shared so a future mobile Jobs board reuses one correlation path.
  • amy buzz job request|list|show|cancel (BuzzJobCommands.kt) — the requester side: file a 43001 (optional --agent, --channel), list/fold jobs (--mine/--assigned), show one job's lifecycle, cancel (43005).
  • amy buzz agent serve RELAY --exec CMD (BuzzAgentCommands.kt) — the backlog scheduler. Watches a channel's REQUESTED jobs, orders them by BuzzJobAggregator.byPriority (upvotes desc, oldest-first tiebreak), and runs up to --parallel N at once — each in its own git worktree + branch (--worktree REPODIR, off --base-ref, named <branch-prefix><jobid>) so concurrent runs never collide (--parallel > 1 requires --worktree; worktree add/remove is mutex-serialized, the agent work runs concurrently). Per job: 43002 accept → 43003 progress → sh -c CMD inside the worktree (task text on stdin; BUZZ_JOB_ID/REQUESTER/CHANNEL/RELAY/ AGENT/UPVOTES/BRANCH/WORKTREE/BASE_REF in env) → 43004 result or 43006 error. Intake gate: --accept-from (explicit npubs) and/or --accept-from-channel (the channel's kind-39002 member roster — "anyone in the channel drives"). --dry-run, --once, --claim-untargeted, --exec-timeout for testing/ops. This is where Claude Code plugs in: --exec runs the agent, which opens the PR and echoes the URL as the result.
  • Upvote priority (BuzzJobs.kt): BuzzJobAggregator folds kind-7 likes (distinct reactors, dislikes excluded) targeting a job into JobView.upvotes; byPriority orders the backlog. The group reprioritizes the stack just by reacting.

Guardrails restated in the command's KDoc: --accept-from / --accept-from-channel is the Buzz-layer intake gate; repo blast radius is the --exec credential (PR-only) + branch protection, not Buzz. Merge is never done here — only on GitHub.

Schema caveat

Kinds 43001-43006 are reserved in Buzz with no upstream builder; the tag layout (e/h/p/status) is Quartz's best-effort model and must be reconciled once Buzz implements the job protocol. The prototype is deliberately isolated so that reconciliation touches only the quartz models + this aggregator.

Mobile app — placement evaluation

The existing agent screens are owner-global concepts entered per-relay, and buried: AgentConsole(relayUrl) (Costs/Personas/Observer, read-only telemetry) is only reachable via a footer in the channel list or a bot-member tap; Costs/Personas/Observer are really the owner's whole fleet, not one relay's. That's a discoverability + scoping smell, but the Console is a coherent owner telemetry surface and should stay that — just get a better entry later.

The shared work surface is a different thing and belongs at the channel level. A Buzz job is h-scoped to a channel, so the backlog is per-channel — exactly like the Canvas (40100) and Forum, which launch from RelayGroupTopBar gated by BuzzRelayDialect.isBuzz. So the Jobs board sits there too (→ Route.BuzzJobBoard(channelId, relayUrl)), NOT inside the owner Console. It lives in that bar's overflow menu rather than as an icon: Canvas is the only affordance holding an icon there, because a fourth and fifth one squeeze the title row until the channel name and relay truncate. Keeping "owner fleet telemetry" and "this channel's shared backlog" as separate surfaces is the right call.

Note the model change also deprioritizes the workflow-approval inbox (46010/46030/46031): with full-auto intake and merge-on-GitHub, the human gate moved to the PR — so the approvals inbox is now P1/optional, not P0. The true P0 is the shared board.

Mobile app gaps (prioritized)

The quartz layer + LocalCache ingest are complete for every kind; the app has zero create/interact surface for the two kinds that define the workflow. Priorities:

P0 — the shared work surface

  • P0-2 Jobs board — LANDED. JobBoardScreen + JobBoardViewModel (per-channel, Route.BuzzJobBoard(channelId, relayUrl), entered from the RelayGroupTopBar overflow menu on Buzz relays). Reads job kinds + kind-7 upvotes scoped to the channel h, folds via BuzzJobAggregator, groups by state (In progress / Queued-by-upvotes / Done / Closed), live via subscribeAsFlow. Three write actions through new Account helpers: file a task (43001, FAB → dialog), upvote (kind-7 + with h), cancel own job (43005). Merge stays on GitHub.
  • P0-1 Approvals inbox — deprioritized to P1 by the full-auto/merge-on-GitHub model (the human gate is now the PR, not a 46010 gate). Still worth it if a workflow-gate flow returns: render 46010, publish 46030/46031, token-hash correlation, push-urgent.
  • P0-3 Agent picker — the board files untargeted jobs (any channel agent claims them), so a picker isn't needed for the shared-channel model; revisit only for directed jobs.

P1 — a credible agent-driving client

  • P1-1 Diff/PR review surface — upgrade read-only 40008 (RenderBuzzDiff) into a full-screen per-file review whose approve action emits 46030/46031. Size M.
  • P1-2 Managed-agent (30177) editor — clone AgentPersonaEditScreen. Size M.
  • P1-3 Persona respond_to/allowlist editing — the safety gate for pointing a persona at a support channel. Size SM.
  • P1-4 Attestation persistenceBuzzHeldAttestations is in-memory; survive restart. Size SM.

P2 — completeness: agent-profile (10100) viewer; a stable "Agents" hub; workflow-run timeline (46020 family, all stored, unrendered); turn-metric → job attribution.

Key files: routes amethyst/.../navigation/routes/Routes.kt; render dispatch .../chats/feed/ChatMessageCompose.kt; renderers .../chats/feed/types/RenderBuzzNotes.kt; ingest model/LocalCache.kt (~L4780-4855); subscription .../relayGroup/datasource/RelayGroupFilterBuilders.kt.

Pivot — jobs → workflows (2026-07-26)

The 43001-43006 job prototype above proved the shape (drive an agent from a shared channel, worktree-isolate, PR-only, merge-on-GitHub), but those kinds are reserved/speculative with no upstream builder. Buzz's real, source-confirmed structured-work primitive is the workflow family — the command kinds are pinned against buzz-relay's Rust command_executor.rs:

  • 30620 workflow definition, 46020 trigger, 46001-46007 run/step lifecycle,
  • 46010 approval-requested gate, 46030 / 46031 grant / deny.

So the driving surface switched to workflows. What that buys over jobs: a first-class human-approval gate (46010 → 46030/46031) baked into the protocol — the exact "anyone in the channel can drive, but a human gates the merge" model the goal asks for — rather than relying on GitHub branch-protection alone.

Divergence (documented): on a real Buzz relay the relay parses the workflow YAML and executes it, signing the lifecycle + approval events. Self-hosted on geode there is no workflow engine, so amy is the runner (amy buzz workflow run) and emits the lifecycle events itself. The command events (30620/46020/46030/46031) stay faithful to Buzz; only the lifecycle content shape is Amethyst's (Buzz leaves it relay-defined).

Correlation: the run id is the trigger's event id and doubles as the approval token, so a grant's d tag equals the run id — no separate token bookkeeping. Two store realities shaped the wire handling, both verified against geode:

  • quartz's SQLiteEventStore routes every #d filter to the addressable d_tag column (NULL for a regular kind like 46030), so decisions are fetched by author — every 46010 gate names its approver in a p tag — and matched to their run by the token the aggregator reads off the event.
  • The runner is restart-safe: runs still at the gate (AWAITING_APPROVAL / APPROVED / DENIED) are rebuilt into the in-flight map from the run id on startup (worktree path + branch are deterministic), so a decision arriving in a later poll — or a fresh --once process — still resolves. The relay is the source of truth, not the in-memory map.

Landed (CLI + commons):

  • commons/.../model/buzz/WorkflowRuns.ktWorkflowRunAggregator folds trigger + lifecycle + grant/deny into per-run state (WorkflowRunAggregatorTest, 8 cases).
  • cli/.../commands/BuzzWorkflowCommands.kttrigger / list / show / approve / deny and the run runner (agent work → 46010 gate → on grant runs --on-approve → 46005 completed; a deny discards the worktree, run is DENIED). Wired into amy buzz workflow.
  • cli/tests/buzz/workflow-loop.sh — end-to-end headless harness (alice triggers, bot runs, carol approves/denies) through embedded geode; 14/14 green, including the deny path and worktree cleanup.
  • cli/tests/buzz/agent-exec.sh — covers the real --exec wrapper the loop harnesses stub out: task → agent → commit → push → PR url, plus the paths that must fail (no diff, empty task, missing scheduler env) and the default-branch guard, asserting main is left unmoved. Stubbed gh + agent, so no network, credentials, or Claude Code; 19/19 green.

Landed (Android app):

  • WorkflowRunBoardScreen + WorkflowRunBoardViewModel (per channel, Route.BuzzWorkflowBoard, entered from the RelayGroupTopBar overflow menu on Buzz relays). Folds the workflow kinds via WorkflowRunAggregator, groups runs by state with "Needs your approval" pinned first, and the named approver grants/denies a paused run inline (46030/46031). Merge stays on GitHub.
  • Account.triggerBuzzWorkflow / approveBuzzWorkflowRun / denyBuzzWorkflowRun (same sign → local-echo → publish-to-group-relay contract as the job helpers).
  • RelayGroupFilterBuilders subscribes the #h-scoped workflow kinds; the board fetches the d-only grant/deny decisions by author (the CLI's approach).
  • NotificationFeedFilter — a 46010 gate addressed to me notifies and is push-eligible (added to NOTIFICATION_KINDS + an acceptableEvent early-return gating on approver() == me).
  • Backbone reused as-is: quartz EventFactory already registers the 46xxx kinds and LocalCache already ingests them (store-only), so no protocol/ingest changes were needed.

So the workflow run board + approval gate is the P0-1 approvals surface the mobile section below anticipated. The jobs board/code stays for now, but the workflow path is the one matching Buzz upstream.

Follow-ups

  1. Reconcile 43001-43006 with Buzz upstream once it defines the job protocol (or retire the job path in favor of workflows).
  2. Wire the P0 mobile screens (approvals inbox + jobs board) on top of BuzzJobAggregator / WorkflowRunAggregator.
  3. Done — a reference --exec wrapper (tools/buzz-agent/agent-exec.sh + README) runs the coding agent in the job worktree, commits, pushes the feature branch, opens a PR with a PR-only token, and prints the URL as the job result — with the branch-protection + token-scope checklist documented. Verified end-to-end against a stubbed gh/agent.
  4. Consider promoting the approval gate (46010) into the responder for irreversible steps.