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>
18 KiB
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-runningservepattern). Decision logic lives incommons(pure/testable); git + process I/O lives in theclicommand — so Amy stays a thin assembly layer. --execis 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_WORKTREEexported), 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)
- 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 viaBuzzMembershipPolicy(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.
- 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;mainis branch-protected. - Intake: a team member files a job in the
#buildchannel (or DMs the agent). The responder picks it up, runs a coding agent in anamethystcheckout, streams progress, posts the result, and opens a PR on aclaude/*branch. Merge stays human. - 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.kt—BuzzJobAggregator, a pure, tested (BuzzJobAggregatorTest, 9 cases) folder that correlates 43001-43006 events (by the replye→ request id) intoJobViewrecords with aJobStatemachine (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 byBuzzJobAggregator.byPriority(upvotes desc, oldest-first tiebreak), and runs up to--parallel Nat once — each in its owngit worktree+ branch (--worktree REPODIR, off--base-ref, named<branch-prefix><jobid>) so concurrent runs never collide (--parallel > 1requires--worktree; worktree add/remove is mutex-serialized, the agent work runs concurrently). Per job: 43002 accept → 43003 progress →sh -c CMDinside the worktree (task text on stdin;BUZZ_JOB_ID/REQUESTER/CHANNEL/RELAY/ AGENT/UPVOTES/BRANCH/WORKTREE/BASE_REFin 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-timeoutfor testing/ops. This is where Claude Code plugs in:--execruns the agent, which opens the PR and echoes the URL as the result.- Upvote priority (
BuzzJobs.kt):BuzzJobAggregatorfolds kind-7 likes (distinct reactors, dislikes excluded) targeting a job intoJobView.upvotes;byPriorityorders 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 theRelayGroupTopBaroverflow menu on Buzz relays). Reads job kinds + kind-7 upvotes scoped to the channelh, folds viaBuzzJobAggregator, groups by state (In progress / Queued-by-upvotes / Done / Closed), live viasubscribeAsFlow. Three write actions through newAccounthelpers: file a task (43001, FAB → dialog), upvote (kind-7+withh), 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 S–M. - P1-4 Attestation persistence —
BuzzHeldAttestationsis in-memory; survive restart. Size S–M.
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
SQLiteEventStoreroutes every#dfilter to the addressabled_tagcolumn (NULL for a regular kind like 46030), so decisions are fetched by author — every 46010 gate names its approver in aptag — 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
--onceprocess — still resolves. The relay is the source of truth, not the in-memory map.
Landed (CLI + commons):
commons/.../model/buzz/WorkflowRuns.kt—WorkflowRunAggregatorfolds trigger + lifecycle + grant/deny into per-run state (WorkflowRunAggregatorTest, 8 cases).cli/.../commands/BuzzWorkflowCommands.kt—trigger/list/show/approve/denyand therunrunner (agent work → 46010 gate → on grant runs--on-approve→ 46005 completed; a deny discards the worktree, run is DENIED). Wired intoamy 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--execwrapper 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, assertingmainis left unmoved. Stubbedgh+ agent, so no network, credentials, or Claude Code; 19/19 green.
Landed (Android app):
WorkflowRunBoardScreen+WorkflowRunBoardViewModel(per channel,Route.BuzzWorkflowBoard, entered from theRelayGroupTopBaroverflow menu on Buzz relays). Folds the workflow kinds viaWorkflowRunAggregator, 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).RelayGroupFilterBuilderssubscribes the#h-scoped workflow kinds; the board fetches thed-only grant/deny decisions by author (the CLI's approach).NotificationFeedFilter— a 46010 gate addressed to me notifies and is push-eligible (added toNOTIFICATION_KINDS+ anacceptableEventearly-return gating onapprover() == me).- Backbone reused as-is: quartz
EventFactoryalready registers the 46xxx kinds andLocalCachealready 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
- Reconcile 43001-43006 with Buzz upstream once it defines the job protocol (or retire the job path in favor of workflows).
- Wire the P0 mobile screens (approvals inbox + jobs board) on top of
BuzzJobAggregator/WorkflowRunAggregator. - ✅ Done — a reference
--execwrapper (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 stubbedgh/agent. - Consider promoting the approval gate (46010) into the responder for irreversible steps.