diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index d78a0806a4..1d807e1d63 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -957,11 +957,6 @@ available rather than silently discarding it.
- On desktop, Buzz starts every agent with `BUZZ_VERSION` set to the app's
version, and agents usually pass it on to the shell commands they run. A
script or shell config can check for it to tell it is running under Buzz.
-- In communities where your build restricts workflows, workflow messages do
- not automatically start or steer local Claude, Codex, Goose, or Pi agents. To
- have one act on a workflow result, send it a direct request explaining what
- to do with that result. Other communities still let a workflow you or your
- agents created mention your agents.
- **Agents** is where people create and manage agents. For supported types,
open an agent there and use **Identity → Instructions**. Opening its avatar or
name in a conversation shows its profile, where **Settings → Instructions**
@@ -1537,6 +1532,35 @@ matches the displayed participant name. When no participant name is available, B
public-key prefix. A direct message with yourself is labeled **Notes to self**.
These labels only help identify the conversation you select.
+## When your agent ignores a mention
+
+Your agents act only on messages from you, from your other agents, and from
+workflows that you or your agents own and that direct them. When your own
+workflow posts a message that mentions one of your agents on this computer but
+doesn’t direct it, a short note under the message says so. On a relay that uses
+owner-only agent mentions it reads: **“‹Agent› didn’t respond to this workflow
+message. This community limits which workflows can use agents, so other people
+can’t use yours. The workflow’s Runs tab shows why it didn’t respond this
+time.”** Elsewhere it reads: **“‹Agent› didn’t respond because this workflow
+message wasn’t set up to direct agents.”** Only you see it, on this device;
+nothing is posted.
+Other ignored mentions show no note.
+
+On a relay that uses owner-only agent mentions, the desktop workflow editor
+warns when a message step mentions an agent in the channel:
+
+Each warning ends with “This community limits which workflows can use agents,
+so other people can’t use yours.”
+
+- **“Agents you mention respond only when you start this workflow, not when
+ someone else does.”** The workflow starts on a message, reaction or diff.
+- **“Agents won’t respond to workflows started by a webhook.”** It starts on a
+ webhook, whatever its steps.
+- **“Agents won’t respond to this workflow because it has steps other than Send
+ Message and Delay.”** It has a step other than a message or a delay.
+
+A schedule with only message and delay steps shows no warning.
+
## Deleting an agent’s workflow
In **Workflows → Owner → All channel workflows**, open the workflow’s actions
diff --git a/docs/configuration.md b/docs/configuration.md
index 325f05d4cb..944f65e721 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -9,7 +9,6 @@ Build defaults are readable binary data, **never secret storage**.
| Input | Consumer and boundary |
| --- | --- |
| `BUZZ_EXTERNAL_MCP_COMMUNITIES` | Frontend build: space-separated secure community origins allowed to authorize external MCP access. Empty disables access. See below. |
-| `BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES` | Frontend build: space-separated secure community origins where workflow messages cannot start or steer local agents. Empty keeps checked workflow delegation everywhere. See below. |
| `BUZZ_BUILDERLAB_URL` | Shared frontend/native build: the live development broker's Builderlab, and whether a build without an added sign-in server keeps the built-in one; no runtime override. See below. |
| `BUZZ_BUILDERLAB_APPS_DOMAIN` | Shared frontend/native build: exact Apps cookie domain paired with the login origin for native viewer SSO; no runtime override. |
| `BUZZ_BUILDERLAB_APPS_CONTROL_PLANE` | Shared frontend/native build: HTTPS origin of the Apps control plane for the configured login origin, for deployments the built-in public table does not list; no runtime override. |
@@ -48,18 +47,6 @@ repository's workflow sets none. Community names do not grant access. The Agents
opening a connection and on each tool call. Consent disables other communities,
and Settings hides External apps there. Normal agent MCP servers are unchanged.
-## Workflow restriction community flag
-
-`BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES` is a space-separated list of `https://`
-or `wss://` community origins, parsed as the external flag is. The default is
-empty: in every community, a workflow mention can start or steer a local Claude,
-Codex, Goose or Pi agent when its creator is the agent's owner or a sibling the
-owner attested. Each build lists its own restricted community origins. In a listed community no
-workflow-marked message authorizes a local agent request, in bundled or plugin
-harnesses; the message stays visible. While any community is listed, an agent
-whose community cannot be read is restricted too. See
-[workflows](workflows.md).
-
## Packaged community default
Internal packagers can set a public community origin for fresh installs:
diff --git a/docs/unread.md b/docs/unread.md
index 2ee79d07a8..fee7747105 100644
--- a/docs/unread.md
+++ b/docs/unread.md
@@ -379,7 +379,8 @@ channel-mute mention exception. Ordinary unread, DM and participating-thread
rules still apply. No message or read marker is rewritten.
The relay currently emits `buzz:workflow-mention` only for recipients named in
-the stored template as well as the rendered output. If substituted input alone
+the stored template as well as the rendered output, and a relay running
+[owner-only agent mentions](https://github.com/block/buzz/blob/main/docs/operator-workflow-agent-mentions.md) may leave it off, so no agent acts on the message. If substituted input alone
names the owner, its single `p` tag cannot distinguish that mention from owner
attribution and does not create mention attention. Put the owner's explicit
`@Name` in the template when they should be alerted. Distinguishing substituted
diff --git a/docs/workflows.md b/docs/workflows.md
index e6eee5ea2b..a9af115621 100644
--- a/docs/workflows.md
+++ b/docs/workflows.md
@@ -256,21 +256,14 @@ The event must contain a canonical `buzz:workflow-mention` for the receiving
agent; all such tags
must have exactly two fields, a lowercase-hex pubkey, and no duplicate targets.
The relay emits these tags only for targets present in the owner-authored template
-and the rendered output. Legacy `p` tags route mentions but do not authorize them.
+and the rendered output. A relay running
+[owner-only agent mentions](https://github.com/block/buzz/blob/main/docs/operator-workflow-agent-mentions.md) may leave the tag off; the message still posts, but
+no agent acts on it, and the owner's app notes under the message which of their
+agents didn't respond. Legacy `p` tags route mentions but do not authorize them.
Workflows created and maintained under an owned agent's own key remain owned by
that agent and can wake agents with the same proven human owner. This does not
grant an agent permission to edit a workflow owned by a different signing identity.
-Builds can withhold this delegation per community. In communities listed by
-`BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES`, no message carrying a `buzz:workflow` tag
-authorizes a local agent request, whatever its signer, owner or mention tags, in
-bundled or plugin harnesses. Workflow messages there stay visible but do not start
-or steer local Claude, Codex, Goose or Pi agents, including for owner-created
-static templates and scheduled workflows. Each build lists its own restricted
-communities; communities it does not list keep the delegation above. The restriction
-does not prevent workflow output from influencing a later authorized turn. See
-[configuration](configuration.md#workflow-restriction-community-flag).
-
Cached-only startup intentionally shows the actual signer until a live connection
establishes explicit relay authority. Saved operator-contact `relayAuthor` is not
promoted to `self`, and no new trusted storage is introduced. Signer-based search
diff --git a/src/bundled/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts
index 3937b13aec..d5d014ef7d 100644
--- a/src/bundled/claude-code/runtime.test.ts
+++ b/src/bundled/claude-code/runtime.test.ts
@@ -215,7 +215,6 @@ function setup(
status: "ready",
session,
viewer: alice,
- origin: "https://agent.example",
scope: `https://agent.example:${alice}`,
}),
} as unknown as RelayData;
@@ -2247,47 +2246,30 @@ it.each([true, false])(
},
);
-it.each([
- ["https://agent.example", false],
- ["https://other.example", true],
-])(
- "refuses an owner's workflow mention where the build restricts %s",
- async (restricted, runs) => {
- vi.stubEnv("VITE_BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES", restricted);
- const relay = keypair();
- const event = eventDto(
- signed(relay, {
- kind: 9,
- content: "@Claude workflow",
- tags: [
- ["h", channel],
- ["p", self],
- ["buzz:workflow", "true"],
- ["buzz:workflow-owner", alice],
- ["buzz:workflow-mention", self],
- ],
- }),
- );
- const f = setup([event], { workflowAuthority: relay.pubkey });
- try {
- await f.deliver({ type: "mention", event });
- if (runs) {
- await vi.waitFor(() =>
- expect(f.claudes().some((process) => process.prompts.length)).toBe(
- true,
- ),
- );
- } else {
- await flush(10);
- expect(f.claudes()).toHaveLength(0);
- expect(f.workingUntil).not.toHaveBeenCalled();
- }
- } finally {
- f.runtime.dispose();
- vi.unstubAllEnvs();
- }
- },
-);
+it("gives the workflow reason for its owner's workflow mention that doesn't name it", async () => {
+ const relay = keypair();
+ const event = eventDto(
+ signed(relay, {
+ kind: 9,
+ content: "@Agent workflow",
+ tags: [
+ ["h", channel],
+ ["p", self],
+ ["buzz:workflow", "true"],
+ ["buzz:workflow-owner", alice],
+ ],
+ }),
+ );
+ const f = setup([event], { workflowAuthority: relay.pubkey });
+ try {
+ const ignored = vi.fn();
+ await f.deliver({ type: "mention", event }, { ignored });
+ expect(ignored).toHaveBeenCalledExactlyOnceWith("workflow");
+ expect(f.claudes()).toHaveLength(0);
+ } finally {
+ f.runtime.dispose();
+ }
+});
it.each([false, true])(
"handles sibling workflow authorization with cancellation=%s",
diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts
index cc0c42f8ca..f69c5ddfa5 100644
--- a/src/bundled/claude-code/runtime.ts
+++ b/src/bundled/claude-code/runtime.ts
@@ -22,7 +22,10 @@ import {
unsupportedSynced,
unverifiedSynced,
} from "../../features/agents/agent-config";
-import { fromOwner, workflowOf } from "../../features/agents/owner-attestation";
+import {
+ mentionAuthority,
+ workflowOf,
+} from "../../features/agents/owner-attestation";
import type { TranscriptTurn } from "../../features/agents/activity-transcript";
import type { AgentReads } from "../../features/agents/reads";
import { localStore, type AgentStore } from "../../features/agents/store";
@@ -963,16 +966,13 @@ export class ClaudeRuntime {
// for the owner.
const wake = trigger.type !== "mention" ? trigger : undefined;
// Claude runs commands as you; mentions need owner or delegated authority.
- if (
- trigger.type === "mention" &&
- !(await fromOwner(
+ if (trigger.type === "mention") {
+ const authority = await mentionAuthority(
trigger.event,
agent.owner,
workflowOf(this.reads(agent.key), agent.pubkey),
- ))
- ) {
- ignored?.();
- return;
+ );
+ if (authority !== "owner") return ignored?.(authority);
}
signal.throwIfAborted();
const entry = this.entry(agent, settings);
diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts
index fd83bb9951..3ef4421d6d 100644
--- a/src/bundled/codex/runtime.test.ts
+++ b/src/bundled/codex/runtime.test.ts
@@ -2843,7 +2843,8 @@ it("ignores non-owner messages without spawning", async () => {
if ("event" in other.trigger) other.trigger.event.pubkey = "f".repeat(64);
const ignored = vi.fn();
await f.runtime.run({ ...other, ignored });
- expect(ignored).toHaveBeenCalledOnce();
+ // No reason, so no note for its owner.
+ expect(ignored).toHaveBeenCalledExactlyOnceWith(undefined);
expect(f.spawn).not.toHaveBeenCalled();
expect(f.publish).not.toHaveBeenCalled();
});
@@ -2872,6 +2873,28 @@ it("runs a verified relay workflow mention for its owner", async () => {
await f.complete();
expect(f.publish).toHaveBeenCalled();
});
+it("gives the workflow reason for its owner's workflow mention that doesn't name it", async () => {
+ const f = fixture();
+ const relay = keypair();
+ f.snapshot.session.workflowAuthority = relay.pubkey;
+ const ask = f.delivery("@Codex workflow");
+ const event = eventDto(
+ signed(relay, {
+ kind: 9,
+ content: "@Agent workflow",
+ tags: [
+ ["h", "channel"],
+ ["p", pubkey],
+ ["buzz:workflow", "true"],
+ ["buzz:workflow-owner", owner],
+ ],
+ }),
+ );
+ const ignored = vi.fn();
+ await f.runtime.run({ ...ask, trigger: { type: "mention", event }, ignored });
+ expect(ignored).toHaveBeenCalledExactlyOnceWith("workflow");
+ expect(f.spawn).not.toHaveBeenCalled();
+});
it.each([false, true])(
"handles sibling workflow authorization with cancellation=%s",
async (cancelled) => {
diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts
index 98bd30b868..e530be91ad 100644
--- a/src/bundled/codex/runtime.ts
+++ b/src/bundled/codex/runtime.ts
@@ -12,7 +12,10 @@ import { isFinalMessage } from "../../buzz-mcp/session-tools";
import type { WorkOutcome } from "../../features/agents/local-activity";
import type { AgentProtection } from "../../features/agent-protection/service";
import { nativeProtectionBridge } from "../../features/agent-protection/native-bridge";
-import { fromOwner, workflowOf } from "../../features/agents/owner-attestation";
+import {
+ mentionAuthority,
+ workflowOf,
+} from "../../features/agents/owner-attestation";
import {
type ConversationWake,
wakeAnchor,
@@ -1682,16 +1685,13 @@ export class CodexRuntime {
const event =
trigger.type !== "mention" ? wakeAnchor(trigger) : trigger.event;
const reads = this.reads(agent.key);
- if (
- trigger.type === "mention" &&
- !(await fromOwner(
+ if (trigger.type === "mention") {
+ const authority = await mentionAuthority(
trigger.event,
agent.owner,
workflowOf(reads, agent.pubkey),
- ))
- ) {
- delivery.ignored?.();
- return;
+ );
+ if (authority !== "owner") return delivery.ignored?.(authority);
}
delivery.signal.throwIfAborted();
if (!reads) throw new Error("Connect to the agent's community first");
diff --git a/src/bundled/goose/runtime.test.ts b/src/bundled/goose/runtime.test.ts
index 81074f1e9a..dcac6f3f26 100644
--- a/src/bundled/goose/runtime.test.ts
+++ b/src/bundled/goose/runtime.test.ts
@@ -12,6 +12,8 @@ import type { Agent, Delivery } from "../../features/agents/service";
import type { AgentProtection } from "../../features/agent-protection/service";
import type { Host } from "../../features/host/service";
import type { EventData, RelayEvent } from "../../features/relay/events";
+import { eventDto } from "../../features/relay/events";
+import { keypair, signed } from "../../features/relay/testing";
import type { RelayData } from "../../features/relay/service";
import { fakeSpawn, flush, gated } from "./goose-testing";
import { REQUEST_TIMEOUT_MS } from "./goose";
@@ -85,6 +87,8 @@ function setup(
slug?: string,
) => Promise;
thinking?: NonNullable[0]>["thinking"];
+ /** The community's relay workflow key. */
+ workflowAuthority?: string;
/** Wraps the fake's `spawn`, as `gated(…).wrap` does. */
wrap?: (spawn: ReturnType["spawn"]) => Host["spawn"];
}> = {},
@@ -132,6 +136,7 @@ function setup(
dispose: () => {},
};
const session = {
+ workflowAuthority: options.workflowAuthority,
read,
agentMemories: {
open: vi.fn(() => memories),
@@ -1740,6 +1745,29 @@ it.each([
}
});
+it("gives the workflow reason for its owner's workflow mention that doesn't name it", async () => {
+ const relay = keypair();
+ const f = setup({ workflowAuthority: relay.pubkey });
+ const ignored = vi.fn();
+ await f.deliver(
+ eventDto(
+ signed(relay, {
+ kind: 9,
+ content: "@Agent workflow",
+ tags: [
+ ["h", channel],
+ ["p", self],
+ ["buzz:workflow", "true"],
+ ["buzz:workflow-owner", alice],
+ ],
+ }),
+ ),
+ { ignored },
+ );
+ expect(ignored).toHaveBeenCalledExactlyOnceWith("workflow");
+ expect(f.goose()).toBeUndefined();
+});
+
it("takes a wake with no thread only in a Solo chat", async () => {
const ignored = vi.fn();
const stream = setup();
diff --git a/src/bundled/goose/runtime.ts b/src/bundled/goose/runtime.ts
index 9dec34fae8..2964a2ef97 100644
--- a/src/bundled/goose/runtime.ts
+++ b/src/bundled/goose/runtime.ts
@@ -21,7 +21,7 @@ import {
turnNote,
} from "../../buzz-mcp/session-tools";
import {
- fromOwner,
+ mentionAuthority,
type Agent,
type AgentHandle,
type Agents,
@@ -402,23 +402,16 @@ export class GooseRuntime {
// goose runs commands as you; mentions need owner or delegated authority.
if (trigger.type === "mention") {
const snapshot = this.relay(agent.key)?.snapshot();
- if (
- !(await fromOwner(trigger.event, agent.owner, {
- agent: agent.pubkey,
- community: snapshot?.origin,
- authority:
- snapshot?.status === "ready"
- ? snapshot.session.workflowAuthority
- : undefined,
- profiles:
- snapshot?.status === "ready"
- ? snapshot.session.profiles
- : undefined,
- }))
- ) {
- ignored?.();
- return;
- }
+ const authority = await mentionAuthority(trigger.event, agent.owner, {
+ agent: agent.pubkey,
+ authority:
+ snapshot?.status === "ready"
+ ? snapshot.session.workflowAuthority
+ : undefined,
+ profiles:
+ snapshot?.status === "ready" ? snapshot.session.profiles : undefined,
+ });
+ if (authority !== "owner") return ignored?.(authority);
signal.throwIfAborted();
}
const entry = this.entry(agent.key, settings);
diff --git a/src/bundled/pi/runtime.test.ts b/src/bundled/pi/runtime.test.ts
index c6ebd82e6b..070f013811 100644
--- a/src/bundled/pi/runtime.test.ts
+++ b/src/bundled/pi/runtime.test.ts
@@ -14,6 +14,8 @@ import type {
StandaloneTurn,
} from "../../features/agents/service";
import type { EventData, RelayEvent } from "../../features/relay/events";
+import { eventDto } from "../../features/relay/events";
+import { keypair, signed } from "../../features/relay/testing";
import type { RelayData } from "../../features/relay/service";
import { deferred, fakeHost, type Handler } from "./pi-testing";
import { type Config, DEFAULT_CONFIG, PiRuntime } from "./runtime";
@@ -75,6 +77,7 @@ function setup(
/** Channels that are the owner's own Solo chats; the owner is the viewer. */
const solo = new Set();
const session = {
+ workflowAuthority: undefined as string | undefined,
read: vi.fn(async () => [root]),
agentMemories: {
read: vi.fn(async () => ({
@@ -473,6 +476,30 @@ it("shows a conversation only the messages it was not shown, nor its own replies
expect(text).not.toContain("Earlier from Pi");
});
+it("gives the workflow reason for its owner's workflow mention that doesn't name it", async () => {
+ const { host, deliver, session } = setup();
+ const relay = keypair();
+ session.workflowAuthority = relay.pubkey;
+ const turn = deliver({
+ type: "mention",
+ event: eventDto(
+ signed(relay, {
+ kind: 9,
+ content: "@Agent workflow",
+ tags: [
+ ["h", channel],
+ ["p", self],
+ ["buzz:workflow", "true"],
+ ["buzz:workflow-owner", alice],
+ ],
+ }),
+ ),
+ });
+ await turn.run;
+ expect(turn.ignored).toHaveBeenCalledExactlyOnceWith("workflow");
+ expect(host.processes).toEqual([]);
+});
+
it("ignores a mention from someone who is not the owner", async () => {
const { host, deliver } = setup();
const turn = deliver({
diff --git a/src/bundled/pi/runtime.ts b/src/bundled/pi/runtime.ts
index 4e645cc94a..e45111f6ae 100644
--- a/src/bundled/pi/runtime.ts
+++ b/src/bundled/pi/runtime.ts
@@ -29,7 +29,10 @@ import {
} from "../../features/agent-harness/core-memory";
import { readAuthorNames } from "../../features/agent-harness/author-names";
import { readProject } from "../../features/agent-harness/project";
-import { fromOwner, workflowOf } from "../../features/agents/owner-attestation";
+import {
+ mentionAuthority,
+ workflowOf,
+} from "../../features/agents/owner-attestation";
import { type AgentKey, pubkeyOf } from "../../features/agents/membership";
import { sessionPrompts } from "../../features/agents/session-prompts";
import { localStore, type AgentStore } from "../../features/agents/store";
@@ -545,15 +548,14 @@ export class PiRuntime {
// it, and keeps its own destination for a failure recovered after a stop.
const attention = wake?.type === "attention";
// Pi runs commands as you; mentions need owner or delegated authority.
- if (
- trigger.type === "mention" &&
- !(await fromOwner(
+ if (trigger.type === "mention") {
+ const authority = await mentionAuthority(
trigger.event,
agent.owner,
workflowOf(this.reads(agent.key), agent.pubkey),
- ))
- )
- return ignored?.();
+ );
+ if (authority !== "owner") return ignored?.(authority);
+ }
signal.throwIfAborted();
const entry = this.entry(agent, config(raw));
entry.handle = agent;
diff --git a/src/bundled/workflows/WorkflowChannel.tsx b/src/bundled/workflows/WorkflowChannel.tsx
index 977e61cf99..1f844c1797 100644
--- a/src/bundled/workflows/WorkflowChannel.tsx
+++ b/src/bundled/workflows/WorkflowChannel.tsx
@@ -6,6 +6,7 @@ import {
useState,
useSyncExternalStore,
} from "react";
+import type { WorkflowAgentMentions } from "./workflowAgentMentionWarning";
import type {
WorkflowCapability,
WorkflowDefinition,
@@ -65,6 +66,7 @@ export function WorkflowChannel({
onSaveReadback,
onClose,
onDelete,
+ agentMentions,
}: {
capability: WorkflowCapability;
channelId: string;
@@ -77,6 +79,7 @@ export function WorkflowChannel({
) => void;
onClose: () => void;
onDelete: (definition: WorkflowDefinition) => void;
+ agentMentions?: WorkflowAgentMentions | undefined;
}) {
const { snapshot, refresh } = useWorkflowView(
useCallback(
@@ -374,6 +377,7 @@ export function WorkflowChannel({
onChange={(yaml) => setDraft({ ...draft, yaml })}
onSave={save}
onLocalDraftRiskChange={setLocalDraftAtRisk}
+ agentMentions={agentMentions}
readOnly={readonly}
busy={busy}
locked={!!draft.operationId}
diff --git a/src/bundled/workflows/WorkflowEditor.test.tsx b/src/bundled/workflows/WorkflowEditor.test.tsx
index 14e0f69e2d..1841ff8d2b 100644
--- a/src/bundled/workflows/WorkflowEditor.test.tsx
+++ b/src/bundled/workflows/WorkflowEditor.test.tsx
@@ -5,6 +5,7 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, test, vi } from "vitest";
import { WorkflowEditor } from "./WorkflowEditor";
+import { WorkflowMentionMembers } from "./WorkflowMessageText";
import { fixtureYaml } from "./fixtures";
import { parse } from "yaml";
@@ -514,3 +515,41 @@ test("opening an existing workflow focuses its heading without running or saving
expect(run).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
});
+
+test("warns while editing when the relay limits agent mentions to the owner's runs", () => {
+ const yaml = `name: Review
+trigger:
+ on: reaction_added
+steps:
+ - id: say
+ action: send_message
+ text: "@Builder review this"
+`;
+ const editor = (agentMentions?: "any" | "owner") => (
+
+ {}}
+ onSave={() => {}}
+ agentMentions={agentMentions}
+ />
+
+ );
+ const { rerender } = render(editor("owner"));
+ expect(
+ screen.getByText(
+ /Agents you mention respond only when you start this workflow/,
+ ),
+ ).toBeInTheDocument();
+ rerender(editor());
+ expect(screen.queryByText(/Agents you mention/)).not.toBeInTheDocument();
+});
diff --git a/src/bundled/workflows/WorkflowEditor.tsx b/src/bundled/workflows/WorkflowEditor.tsx
index 602abc8e5a..c649759068 100644
--- a/src/bundled/workflows/WorkflowEditor.tsx
+++ b/src/bundled/workflows/WorkflowEditor.tsx
@@ -1,7 +1,14 @@
import { Field } from "../../shared/design-system/ui/Field";
import { Input } from "../../shared/design-system/ui/Input";
import { Textarea } from "../../shared/design-system/ui/Textarea";
-import { useEffect, useId, useRef, useState, type ReactNode } from "react";
+import {
+ useContext,
+ useEffect,
+ useId,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
import { DotsThreeIcon } from "../../shared/design-system/icons";
import { IconButton } from "../../shared/design-system/ui/IconButton";
import { Button } from "../../shared/design-system/ui/Button";
@@ -18,6 +25,11 @@ import { WorkflowForm, type WorkflowSelection } from "./WorkflowForm";
import { conditionRowError } from "./workflowConditionExpression";
import { draftIssue } from "./editor-model";
import { getWorkflowActivationWarning } from "./workflowActivationWarning";
+import {
+ getAgentMentionWarning,
+ type WorkflowAgentMentions,
+} from "./workflowAgentMentionWarning";
+import { WorkflowMentionMembers } from "./WorkflowMessageText";
import {
formStateToYaml,
yamlToFormState,
@@ -50,6 +62,7 @@ export function WorkflowEditor({
children,
create = false,
chooseChannel = false,
+ agentMentions = "any",
}: {
yaml: string;
initialYaml?: string | undefined;
@@ -72,6 +85,8 @@ export function WorkflowEditor({
children?: ReactNode;
create?: boolean;
chooseChannel?: boolean;
+ /** The community's NIP-11 `workflow_agent_mentions`. */
+ agentMentions?: WorkflowAgentMentions | undefined;
}) {
const id = useId();
const heading = useRef(null);
@@ -135,6 +150,8 @@ export function WorkflowEditor({
onChange(next);
};
const warning = getWorkflowActivationWarning(yaml);
+ const members = useContext(WorkflowMentionMembers);
+ const mentionWarning = getAgentMentionWarning(yaml, members, agentMentions);
const submit = () => {
if (readOnly || busy || locked || blocked || error) return;
const wasEnabled =
@@ -406,6 +423,11 @@ export function WorkflowEditor({
{controlReason}
)}
+ {!readOnly && mentionWarning && (
+
+ {mentionWarning}
+
+ )}
{status}
{activating && (
diff --git a/src/bundled/workflows/WorkflowsPage.tsx b/src/bundled/workflows/WorkflowsPage.tsx
index a34932e6e3..011acbfb81 100644
--- a/src/bundled/workflows/WorkflowsPage.tsx
+++ b/src/bundled/workflows/WorkflowsPage.tsx
@@ -5,6 +5,7 @@ import {
useState,
type ReactNode,
} from "react";
+import { useWorkflowAgentMentions } from "./useWorkflowAgentMentions";
import type { RelayData } from "../../features/relay/service";
import type { RelaySession } from "../../features/relay/session";
import type { WorkflowDefinition } from "../../features/workflows/types";
@@ -131,6 +132,7 @@ export function WorkflowCommunity({
}, [open, detailTarget]);
const channel = eligible.find((item) => item.id === selected);
const mentionMembers = useWorkflowMentionMembers(session, channel);
+ const agentMentions = useWorkflowAgentMentions(session.scope);
useEffect(() => {
if (
!selected ||
@@ -340,6 +342,7 @@ export function WorkflowCommunity({
onSaveReadback={setSaveReadback}
onDelete={deleteWorkflow}
viewer={viewer}
+ agentMentions={agentMentions}
onClose={() => openChannel("")}
/>
diff --git a/src/bundled/workflows/useWorkflowAgentMentions.ts b/src/bundled/workflows/useWorkflowAgentMentions.ts
new file mode 100644
index 0000000000..1bfd4fdb56
--- /dev/null
+++ b/src/bundled/workflows/useWorkflowAgentMentions.ts
@@ -0,0 +1,28 @@
+import { useEffect, useState } from "react";
+import { nativeIdentityEnabled } from "../../features/identity/service";
+import { communityFromScope } from "../../features/relay/gifs";
+import { nativeRelayInfo } from "../../features/relay/native";
+import {
+ workflowAgentMentions,
+ type WorkflowAgentMentions,
+} from "./workflowAgentMentionWarning";
+
+/** The active community's NIP-11 mode, read once per community. `any` until
+ * read, when unreadable, and on hosts with no NIP-11 reader. */
+export function useWorkflowAgentMentions(scope: string): WorkflowAgentMentions {
+ const community = communityFromScope(scope);
+ const [mode, setMode] = useState<{
+ community: string;
+ mode: WorkflowAgentMentions;
+ }>();
+ useEffect(() => {
+ if (!community || !nativeIdentityEnabled()) return;
+ const controller = new AbortController();
+ nativeRelayInfo(community, controller.signal).then(
+ (info) => setMode({ community, mode: workflowAgentMentions(info) }),
+ () => {},
+ );
+ return () => controller.abort();
+ }, [community]);
+ return mode?.community === community ? mode.mode : "any";
+}
diff --git a/src/bundled/workflows/workflowAgentMentionWarning.test.ts b/src/bundled/workflows/workflowAgentMentionWarning.test.ts
new file mode 100644
index 0000000000..4664d321df
--- /dev/null
+++ b/src/bundled/workflows/workflowAgentMentionWarning.test.ts
@@ -0,0 +1,148 @@
+import { expect, it } from "vitest";
+import {
+ getAgentMentionWarning,
+ resolveWorkflowMentions,
+ OWNER_RUNS_WARNING,
+ STEPS_WARNING,
+ WEBHOOK_WARNING,
+ workflowAgentMentions,
+} from "./workflowAgentMentionWarning";
+
+const definition = (
+ trigger: string,
+ text = "@Builder review this",
+ extra = "",
+) => `name: Review
+trigger:
+ on: ${trigger}
+steps:
+ - id: say
+ action: send_message
+ text: "${text}"
+${extra}`;
+const member = (pubkey: string, name: string, agent = true) => ({
+ pubkey,
+ name,
+ label: name,
+ agent,
+});
+const builder = member("b", "Builder");
+const warn = (
+ yaml: string,
+ mode = workflowAgentMentions({ workflow_agent_mentions: "owner" }),
+ members = [builder],
+) => getAgentMentionWarning(yaml, members, mode);
+
+it.each(["message_posted", "reaction_added", "diff_posted"])(
+ "warns that only your own runs direct agents on a %s trigger",
+ (trigger) => expect(warn(definition(trigger))).toBe(OWNER_RUNS_WARNING),
+);
+
+const fetchStep = ` - id: fetch
+ action: call_webhook
+ url: https://example.test
+`;
+
+it("warns that agents never respond on a webhook trigger", () => {
+ expect(warn(definition("webhook"))).toBe(WEBHOOK_WARNING);
+});
+
+it("prefers the webhook warning when a webhook trigger has other steps", () => {
+ expect(warn(definition("webhook", undefined, fetchStep))).toBe(
+ WEBHOOK_WARNING,
+ );
+});
+
+it("warns that agents never respond with any step but a message or delay", () => {
+ expect(warn(definition("schedule", undefined, fetchStep))).toBe(
+ STEPS_WARNING,
+ );
+});
+
+it("gives a message trigger with other steps the steps warning", () => {
+ expect(warn(definition("message_posted", undefined, fetchStep))).toBe(
+ STEPS_WARNING,
+ );
+});
+
+it("doesn't warn a schedule with only message and delay steps", () => {
+ const delay = ` - id: wait
+ action: delay
+ duration: 5m
+`;
+ expect(warn(definition("schedule", undefined, delay))).toBeNull();
+});
+
+it("doesn't warn without an agent mention", () => {
+ expect(warn(definition("webhook", "@Someone else"))).toBeNull();
+ expect(getAgentMentionWarning(definition("webhook"), [], "owner")).toBeNull();
+});
+
+it("doesn't warn on a relay that lets any run direct agents, or an older one", () => {
+ expect(warn(definition("webhook"), workflowAgentMentions({}))).toBeNull();
+ expect(
+ warn(
+ definition("webhook"),
+ workflowAgentMentions({ workflow_agent_mentions: "any" }),
+ ),
+ ).toBeNull();
+ expect(workflowAgentMentions({ workflow_agent_mentions: "OWNER" })).toBe(
+ "any",
+ );
+});
+
+it.each([
+ ["@builder review this", true],
+ ["@BUILDER, review this", true],
+ ["(@Builder) review", true],
+ ["@BuilderExtra review", false],
+ ["mail@Builder review", false],
+ ["@Builder_2 review", false],
+])("matches %s as the relay does", (text, warns) => {
+ expect(warn(definition("webhook", text)) !== null).toBe(warns);
+});
+
+it("matches the longest name first, then checks it is an agent", () => {
+ const person = member("p", "Builder Bob", false);
+ expect(
+ warn(definition("webhook", "@Builder Bob review"), undefined, [
+ builder,
+ person,
+ ]),
+ ).toBeNull();
+ expect(
+ resolveWorkflowMentions("@Builder Bob and @Builder", [builder, person]),
+ ).toEqual([person, builder]);
+});
+
+it("names no one for a name two members share", () => {
+ const twin = member("t", "builder", false);
+ expect(warn(definition("webhook"), undefined, [builder, twin])).toBeNull();
+ expect(
+ resolveWorkflowMentions("@Builder", [builder, { ...builder }]),
+ ).toEqual([builder]);
+});
+
+it("folds case per code point, so later mentions keep their place", () => {
+ const dotted = member("i", "İ", false);
+ expect(resolveWorkflowMentions("@İ @Builder", [dotted, builder])).toEqual([
+ dotted,
+ builder,
+ ]);
+});
+
+it("folds names and text as the relay does, Unicode included", () => {
+ const sigma = member("s", "ΟΣ");
+ expect(resolveWorkflowMentions("@ος", [sigma])).toEqual([sigma]);
+ expect(resolveWorkflowMentions("@ΟΣ", [sigma])).toEqual([]);
+ for (const mark of ["\u0345", "\u05B0"])
+ expect(resolveWorkflowMentions(`@Builder${mark}`, [builder])).toEqual([]);
+ expect(resolveWorkflowMentions("\u0085@Builder", [builder])).toEqual([
+ builder,
+ ]);
+ expect(resolveWorkflowMentions("\uFEFF@Builder", [builder])).toEqual([]);
+ const blank = member("w", "\u0085");
+ expect(resolveWorkflowMentions("@\u0085 @Builder", [blank, builder])).toEqual(
+ [builder],
+ );
+});
diff --git a/src/bundled/workflows/workflowAgentMentionWarning.ts b/src/bundled/workflows/workflowAgentMentionWarning.ts
new file mode 100644
index 0000000000..4cfca11236
--- /dev/null
+++ b/src/bundled/workflows/workflowAgentMentionWarning.ts
@@ -0,0 +1,136 @@
+import { parse as yamlParse } from "yaml";
+import type { WorkflowMentionMember } from "./WorkflowMessageText";
+
+/** NIP-11 `workflow_agent_mentions`: `owner` lets only runs the owner or a
+ * timer started, with only message and delay steps, direct agents. */
+export type WorkflowAgentMentions = "any" | "owner";
+
+/** A missing or unknown value is an older relay's: every run may. */
+export function workflowAgentMentions(
+ info: Record,
+): WorkflowAgentMentions {
+ return info.workflow_agent_mentions === "owner" ? "owner" : "any";
+}
+
+const WHY =
+ "This community limits which workflows can use agents, so other people can't use yours.";
+export const OWNER_RUNS_WARNING = `Agents you mention respond only when you start this workflow, not when someone else does. ${WHY}`;
+export const WEBHOOK_WARNING = `Agents won't respond to workflows started by a webhook. ${WHY}`;
+export const STEPS_WARNING = `Agents won't respond to this workflow because it has steps other than Send Message and Delay. ${WHY}`;
+
+const PERSON_TRIGGERS = new Set([
+ "message_posted",
+ "reaction_added",
+ "diff_posted",
+]);
+
+function record(value: unknown): Record | undefined {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : undefined;
+}
+
+const extendsName = (c: string | undefined) =>
+ c !== undefined && /[\p{Alphabetic}\p{N}_]/u.test(c);
+// Rust's `char::to_lowercase`, which folds each code point on its own.
+const fold = (c: string) => [...c.toLowerCase()];
+
+/** The members `text` names, as the relay's `resolve_mention_pubkeys` matches
+ * them: an `@` at the start or after whitespace or `(`, the exact name in any
+ * case, not followed by a letter, digit or `_`; longer names first, each span
+ * once; a name two members share names no one. */
+export function resolveWorkflowMentions<
+ M extends Pick,
+>(text: string, members: readonly M[]): M[] {
+ const byName = new Map();
+ for (const member of members) {
+ if (!member.name.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, ""))
+ continue;
+ const key = member.name.toLowerCase();
+ const known = byName.get(key);
+ byName.set(
+ key,
+ known === undefined || known?.pubkey === member.pubkey ? member : null,
+ );
+ }
+ const chars = [...text];
+ const consumed = chars.map(() => false);
+ const hits: [number, M][] = [];
+ const names = [...members].sort(
+ (a, b) => [...b.name].length - [...a.name].length,
+ );
+ const matchLength = (start: number, name: readonly string[]) => {
+ let at = start;
+ let index = 0;
+ while (index < name.length) {
+ const c = chars[at];
+ if (c === undefined) return undefined;
+ for (const folded of fold(c))
+ if (name[index++] !== folded) return undefined;
+ at++;
+ }
+ return at - start;
+ };
+ for (const { name } of names) {
+ const folded = [...name.toLowerCase()];
+ if (!folded.length) continue;
+ for (let at = 0; at < chars.length; ) {
+ const left =
+ at === 0 ||
+ /\p{White_Space}/u.test(chars[at - 1] ?? "") ||
+ chars[at - 1] === "(";
+ const length =
+ chars[at] === "@" && left && !consumed[at]
+ ? matchLength(at + 1, folded)
+ : undefined;
+ if (length === undefined || extendsName(chars[at + 1 + length])) {
+ at++;
+ continue;
+ }
+ const member = byName.get(name.toLowerCase());
+ if (member) hits.push([at, member]);
+ consumed.fill(true, at, at + 1 + length);
+ at += 1 + length;
+ }
+ }
+ return [...new Set(hits.sort((a, b) => a[0] - b[0]).map(([, m]) => m))];
+}
+
+/** What owner-only agent mentions means for this definition's agent
+ * mentions, as the relay decides it: by trigger and every step's action. */
+export function getAgentMentionWarning(
+ yaml: string,
+ members: readonly WorkflowMentionMember[],
+ mode: WorkflowAgentMentions,
+): string | null {
+ if (mode !== "owner" || !members.some((member) => member.agent)) return null;
+ let definition: Record | undefined;
+ try {
+ definition = record(yamlParse(yaml));
+ } catch {
+ return null;
+ }
+ const steps = Array.isArray(definition?.steps)
+ ? definition.steps.map(record)
+ : [];
+ const mentions = steps.some((step) => {
+ const text = step?.action === "send_message" ? step.text : undefined;
+ return (
+ typeof text === "string" &&
+ resolveWorkflowMentions(text, members).some((member) => member.agent)
+ );
+ });
+ if (!mentions) return null;
+ const trigger = record(definition?.trigger)?.on;
+ // A webhook run never directs agents, whatever its steps.
+ if (trigger === "webhook") return WEBHOOK_WARNING;
+ if (
+ steps.some(
+ (step) => step?.action !== "send_message" && step?.action !== "delay",
+ )
+ )
+ return STEPS_WARNING;
+ return typeof trigger === "string" && PERSON_TRIGGERS.has(trigger)
+ ? OWNER_RUNS_WARNING
+ : null;
+}
diff --git a/src/features/agent-harness/kit.ts b/src/features/agent-harness/kit.ts
index 25f017c416..3bc4f2a9e5 100644
--- a/src/features/agent-harness/kit.ts
+++ b/src/features/agent-harness/kit.ts
@@ -223,17 +223,8 @@ export function harnessKit(sources: Sources): HarnessKit {
const wake = trigger.type !== "mention" ? trigger : undefined;
if (trigger.type === "mention") {
// Only the owner or an attested sibling. Unlike the bundled harnesses,
- // which kept relay workflow mentions for compatibility, no workflow
- // authority is given, so no workflow mention is delegated; the
- // community still applies the build's workflow restriction.
- if (
- !(await fromOwner(trigger.event, agent.owner, {
- agent: agent.pubkey,
- authority: undefined,
- community: sources.relay(agent.key)?.snapshot().origin,
- }))
- )
- return undefined;
+ // which kept relay workflow mentions for compatibility, no workflow check.
+ if (!(await fromOwner(trigger.event, agent.owner))) return undefined;
signal.throwIfAborted();
}
const event =
diff --git a/src/features/agents/activity.ts b/src/features/agents/activity.ts
index d7f0b74c3e..7b17c6ff7e 100644
--- a/src/features/agents/activity.ts
+++ b/src/features/agents/activity.ts
@@ -1,4 +1,8 @@
-import type { LocalAgentSource, LocalWork } from "./local-activity";
+import type {
+ LocalAgentSource,
+ LocalDecline,
+ LocalWork,
+} from "./local-activity";
import { activityPromptThread } from "./activity-transcript";
import type { EventData } from "../relay/events";
import { threadReference } from "../relay/thread-reference";
@@ -61,6 +65,8 @@ type Snapshot = Readonly<{
records: readonly RawRecord[];
turns: readonly ActivityTurn[];
local?: readonly LocalWork[];
+ /** This device's agents' mentions declined for who sent them. */
+ declined?: readonly LocalDecline[];
typing: readonly TypingEntry[];
trimmed: number;
/** Unlike raw-record trimming, losing a turn can lose request completeness. */
@@ -184,6 +190,12 @@ export function createAgentActivity(
(row) => row.scope === local.scope && canAccess(row.channelId),
)
: [];
+ const declined =
+ local && !closed
+ ? (local.source.declined?.() ?? []).filter(
+ (row) => row.scope === local.scope && canAccess(row.channelId),
+ )
+ : [];
const now = Date.now();
const fresh = records.filter(
(record) => now - record.receivedAt < ACTIVITY_HISTORY_AGE_MS,
@@ -259,6 +271,10 @@ export function createAgentActivity(
snapshot.status === status &&
(snapshot.local ?? []).length === localWork.length &&
(snapshot.local ?? []).every((row, index) => row === localWork[index]) &&
+ (snapshot.declined ?? []).length === declined.length &&
+ (snapshot.declined ?? []).every(
+ (row, index) => row === declined[index],
+ ) &&
snapshot.records === records &&
snapshot.trimmed === trimmed &&
JSON.stringify(snapshot.turns) === JSON.stringify(visible) &&
@@ -271,6 +287,7 @@ export function createAgentActivity(
records: Object.freeze(records),
turns: Object.freeze(visible),
local: Object.freeze(localWork),
+ declined: Object.freeze(declined),
typing: Object.freeze(typers),
trimmed,
turnsTrimmed,
diff --git a/src/features/agents/local-activity.ts b/src/features/agents/local-activity.ts
index 8f657c981c..93ce4bf3b4 100644
--- a/src/features/agents/local-activity.ts
+++ b/src/features/agents/local-activity.ts
@@ -1,4 +1,6 @@
import type { AgentSessionAssociation } from "./session-admission";
+import type { MentionRefusal } from "./owner-attestation";
+
export const REPLY_CORRELATION_MS = 120_000;
export type LocalWorkState =
@@ -80,6 +82,14 @@ export type LocalWork = Readonly<{
/** It can be asked again once it fails. */
retryable?: true;
}>;
+/** A mention this agent didn't act on, and why: shown only to its owner here. */
+export type LocalDecline = Readonly<{
+ scope: string;
+ agent: string;
+ channelId: string;
+ messageId: string;
+ refusal: MentionRefusal;
+}>;
const settled = (state: LocalWorkState) =>
state === "done" ||
state === "error" ||
@@ -90,6 +100,8 @@ export const localWorkRoot = (
) => (work.syntheticMessage && !work.thread ? undefined : work.threadRootId);
export type LocalAgentSource = {
work(): readonly LocalWork[];
+ /** Mentions declined for who sent them; notified with `subscribeWork`. */
+ declined?(): readonly LocalDecline[];
subscribeWork(listener: () => void): () => void;
/** Hides one already-seen error on this device; never changes its outcome. */
dismissError?(work: Pick): void;
@@ -105,12 +117,16 @@ export type LocalAgentSource = {
/** App-owned local facts. No relay publication, inference, polling or process control. */
export function createLocalAgentActivity() {
let work: readonly LocalWork[] = [];
+ let declined: readonly LocalDecline[] = [];
const workListeners = new Set<() => void>();
const entries = new Map();
const visible = new Map();
const omissions = new Map();
let revision = 0;
let order = 0;
+ const declines = new Map();
+ const declineKey = (row: Pick) =>
+ `${row.scope}\n${row.agent}\n${row.messageId}`;
/** How to stop each stoppable request's turn, until it settles. */
const stops = new Map Promise>();
/** How to ask each retryable request again, while its row is kept. */
@@ -123,6 +139,7 @@ export function createLocalAgentActivity() {
omissions.delete(id);
}
work = Object.freeze([...entries.values()]);
+ declined = Object.freeze([...declines.values()]);
for (const listener of workListeners) listener();
};
const subscribe = (listeners: Set<() => void>, listener: () => void) => {
@@ -133,6 +150,7 @@ export function createLocalAgentActivity() {
};
const reader: LocalAgentSource = {
work: () => work,
+ declined: () => declined,
subscribeWork: (listener) => subscribe(workListeners, listener),
dismissError({ scope, agent, messageId }) {
for (const [id, row] of entries)
@@ -207,6 +225,7 @@ export function createLocalAgentActivity() {
// still owns the prior attempt's fresh reply IDs, because those relay
// echoes may arrive after the activity row is replaced.
let replaced: LocalWork | undefined;
+ declines.delete(declineKey(input));
for (const [id, row] of entries)
if (
row.scope === input.scope &&
@@ -599,12 +618,25 @@ export function createLocalAgentActivity() {
entries.set(id, Object.freeze({ ...current, stoppable: true }));
publish();
},
- remove: () => {
+ /** With a `refusal`, keeps a note of why the agent didn't act. */
+ remove: (refusal?: MentionRefusal) => {
stops.delete(id);
retries.delete(id);
clearTimeout(replyTimers.get(id));
replyTimers.delete(id);
- if (entries.delete(id)) publish();
+ const row = entries.get(id);
+ if (!entries.delete(id)) return;
+ if (refusal && row) {
+ // Display budget only: the oldest note goes first.
+ const oldest = declines.size >= 512 && declines.keys().next().value;
+ if (oldest) declines.delete(oldest);
+ const { scope, agent, channelId, messageId } = row;
+ declines.set(
+ declineKey(row),
+ Object.freeze({ scope, agent, channelId, messageId, refusal }),
+ );
+ }
+ publish();
},
};
},
@@ -622,6 +654,14 @@ export function createLocalAgentActivity() {
replyTimers.delete(id);
changed = true;
}
+ for (const [key, row] of declines)
+ if (
+ (!scope || row.scope === scope) &&
+ (!agent || row.agent === agent)
+ ) {
+ declines.delete(key);
+ changed = true;
+ }
if (changed) publish();
},
};
diff --git a/src/features/agents/local-decline.test.ts b/src/features/agents/local-decline.test.ts
new file mode 100644
index 0000000000..6d962b4d60
--- /dev/null
+++ b/src/features/agents/local-decline.test.ts
@@ -0,0 +1,60 @@
+import { expect, it } from "vitest";
+import { createAgentActivity } from "./activity";
+import { createLocalAgentActivity } from "./local-activity";
+
+const scope = "https://relay.test:viewer",
+ agent = "a".repeat(64),
+ message = "b".repeat(64);
+const begin = (local: ReturnType) =>
+ local.begin({
+ scope,
+ agent,
+ channelId: "channel",
+ messageId: message,
+ threadRootId: message,
+ });
+const project = (local: ReturnType) =>
+ createAgentActivity(
+ false,
+ () => {},
+ (channel) => channel === "channel",
+ undefined,
+ undefined,
+ { source: local.reader, scope },
+ );
+
+it("keeps why an agent declined a mention, for its owner's view", () => {
+ const local = createLocalAgentActivity();
+ const activity = project(local);
+ try {
+ begin(local).remove("workflow");
+ expect(activity.queries.snapshot().local).toEqual([]);
+ expect(activity.queries.snapshot().declined).toEqual([
+ {
+ scope,
+ agent,
+ channelId: "channel",
+ messageId: message,
+ refusal: "workflow",
+ },
+ ]);
+ // Handling the message again replaces its note.
+ begin(local);
+ expect(activity.queries.snapshot().declined).toEqual([]);
+ } finally {
+ activity.dispose();
+ }
+});
+
+it("shows no note for a rejection without a reason", () => {
+ const local = createLocalAgentActivity();
+ begin(local).remove();
+ expect(local.reader.declined?.()).toEqual([]);
+});
+
+it("forgets notes with the agent's activity", () => {
+ const local = createLocalAgentActivity();
+ begin(local).remove("workflow");
+ local.clear(scope, agent);
+ expect(local.reader.declined?.()).toEqual([]);
+});
diff --git a/src/features/agents/mention-authority.test.ts b/src/features/agents/mention-authority.test.ts
new file mode 100644
index 0000000000..ba4f5e1d12
--- /dev/null
+++ b/src/features/agents/mention-authority.test.ts
@@ -0,0 +1,131 @@
+import { createHash } from "node:crypto";
+import { schnorr } from "@noble/curves/secp256k1.js";
+import { bytesToHex } from "nostr-tools/utils";
+import { expect, it, vi } from "vitest";
+import { eventDto } from "../relay/events";
+import { createProfileDirectory } from "../relay/profile-directory";
+import type { RelayReader } from "../relay/reader";
+import { keypair, signed } from "../relay/testing";
+import { mentionAuthority } from "./owner-attestation";
+
+const relay = keypair(),
+ owner = keypair(),
+ sibling = keypair(),
+ agent = keypair(),
+ stranger = keypair();
+const auth = (key: typeof sibling) => [
+ "auth",
+ owner.pubkey,
+ "",
+ bytesToHex(
+ schnorr.sign(
+ createHash("sha256").update(`nostr:agent-auth:${key.pubkey}:`).digest(),
+ owner.secret,
+ ),
+ ),
+];
+const message = (key: typeof owner, tags: string[][] = []) =>
+ eventDto(signed(key, { kind: 9, content: "@agent go", tags }));
+const workflow = (creator: string, mention = true) =>
+ message(relay, [
+ ["buzz:workflow", "true"],
+ ["buzz:workflow-owner", creator],
+ ...(mention ? [["buzz:workflow-mention", agent.pubkey]] : []),
+ ]);
+const check = (read = vi.fn(async () => [] as never[])) => ({
+ authority: relay.pubkey,
+ agent: agent.pubkey,
+ profiles: createProfileDirectory({ read } as unknown as RelayReader).queries,
+});
+
+it("accepts the owner and an attested sibling", async () => {
+ expect(await mentionAuthority(message(owner), owner.pubkey, check())).toBe(
+ "owner",
+ );
+ expect(
+ await mentionAuthority(
+ message(sibling, [auth(sibling)]),
+ owner.pubkey,
+ check(),
+ ),
+ ).toBe("owner");
+});
+
+it("refuses someone else's message with no reason", async () => {
+ expect(
+ await mentionAuthority(message(stranger), owner.pubkey, check()),
+ ).toBeUndefined();
+ // Without a workflow check, as plugin harnesses call it.
+ expect(
+ await mentionAuthority(workflow(owner.pubkey), owner.pubkey),
+ ).toBeUndefined();
+});
+
+it("accepts the owner's workflow only when it names this agent", async () => {
+ expect(
+ await mentionAuthority(workflow(owner.pubkey), owner.pubkey, check()),
+ ).toBe("owner");
+ expect(
+ await mentionAuthority(
+ workflow(owner.pubkey, false),
+ owner.pubkey,
+ check(),
+ ),
+ ).toBe("workflow");
+});
+
+it("refuses someone else's workflow with no reason", async () => {
+ expect(
+ await mentionAuthority(workflow(stranger.pubkey), owner.pubkey, check()),
+ ).toBeUndefined();
+});
+
+it("gives no reason while the creator's profile can't be read", async () => {
+ const read = vi.fn(async () => {
+ throw new Error("offline");
+ });
+ expect(
+ await mentionAuthority(workflow(sibling.pubkey), owner.pubkey, check(read)),
+ ).toBeUndefined();
+});
+
+it("gives no reason for a workflow whose creator's profile isn't read yet", async () => {
+ const read = vi.fn(async () => [] as never[]);
+ for (const creator of [sibling.pubkey, stranger.pubkey])
+ expect(
+ await mentionAuthority(
+ workflow(creator, false),
+ owner.pubkey,
+ check(read),
+ ),
+ ).toBeUndefined();
+ expect(read).not.toHaveBeenCalled();
+});
+
+it("names a sibling's workflow from its already-read profile", async () => {
+ const profile = eventDto(
+ signed(sibling, {
+ kind: 0,
+ created_at: 100,
+ content: JSON.stringify({ owner: owner.pubkey, is_agent: true }),
+ tags: [auth(sibling)],
+ }),
+ );
+ const read = vi.fn(async () => [profile]);
+ const siblingCheck = check(read as never);
+ expect(
+ await mentionAuthority(
+ workflow(sibling.pubkey),
+ owner.pubkey,
+ siblingCheck,
+ ),
+ ).toBe("owner");
+ expect(
+ await mentionAuthority(
+ workflow(sibling.pubkey, false),
+ owner.pubkey,
+ siblingCheck,
+ ),
+ ).toBe("workflow");
+ expect(read).toHaveBeenCalledOnce();
+});
diff --git a/src/features/agents/owner-attestation.ts b/src/features/agents/owner-attestation.ts
index ca346c5fe0..fac6275266 100644
--- a/src/features/agents/owner-attestation.ts
+++ b/src/features/agents/owner-attestation.ts
@@ -1,9 +1,11 @@
import { schnorr } from "@noble/curves/secp256k1.js";
import { hexToBytes } from "nostr-tools/utils";
-import { communityDestination } from "../communities/destination";
import type { ProfileQueries } from "../relay/profile-directory";
import { hasEventProof, type EventData } from "../relay/events";
-import { workflowMentionOwner } from "../relay/workflow-attribution";
+import {
+ workflowMentionOwner,
+ workflowOwner,
+} from "../relay/workflow-attribution";
import type { AgentReads } from "./reads";
const KEY = /^[0-9a-f]{64}$/;
@@ -90,28 +92,9 @@ function satisfied(
});
}
-/** Whether this build keeps workflow messages from authorizing local agent
- * requests in `community`. The build flag lists exact canonical origins; with
- * any listed, an unknown or unreadable community is restricted too. */
-export function workflowRequestsRestricted(community: string | undefined) {
- const restricted = (
- import.meta.env.VITE_BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES ?? ""
- )
- .split(" ")
- .filter(Boolean);
- if (!restricted.length) return false;
- if (!community) return true;
- try {
- return restricted.includes(communityDestination(community).url);
- } catch {
- return true;
- }
-}
-
-/** How a mention from a relay workflow is checked: the agent's community, the
- * community's workflow key, and its creators' profiles. */
+/** How a mention from a relay workflow is checked: the community's workflow
+ * key, and its creators' profiles. */
export type WorkflowCheck = {
- community?: string | undefined;
authority: string | undefined;
agent: string;
profiles?: Pick | undefined;
@@ -124,7 +107,6 @@ export function workflowOf(
): WorkflowCheck {
return {
agent,
- community: reads?.origin,
authority: reads?.workflowAuthority(),
profiles: reads && {
ensure: async (ids) => {
@@ -135,33 +117,59 @@ export function workflowOf(
};
}
+/** Why a mention can't direct an agent, for a note only its owner sees:
+ * the owner's own workflow posted it without naming this agent as a step's
+ * author-written mention. */
+export type MentionRefusal = "workflow";
+
/** Owner prompts, attested siblings, and verified relay workflow mentions
- * delegated by the owner or a profile-verified sibling to this particular agent.
- * Where the build restricts workflow requests, no workflow-marked message
- * authorizes one: its rendered text is not bound to the owner's template. */
+ * delegated by the owner or a profile-verified sibling to this particular agent. */
export async function fromOwner(
event: EventData,
owner: string,
workflow?: WorkflowCheck,
) {
- const restricted = workflowRequestsRestricted(workflow?.community);
- if (restricted && event.tags.some(([name]) => name === "buzz:workflow"))
- return false;
+ return (await mentionAuthority(event, owner, workflow)) === "owner";
+}
+
+/** `fromOwner`'s verdict with its reason. `undefined` refuses without one:
+ * anyone else's message or workflow, or a creator that can't be read yet. */
+export async function mentionAuthority(
+ event: EventData,
+ owner: string,
+ workflow?: WorkflowCheck,
+): Promise<"owner" | MentionRefusal | undefined> {
if (event.pubkey === owner || (await attestedOwner(event)) === owner)
- return true;
- const creator =
- !restricted &&
- workflow &&
- workflowMentionOwner(event, workflow.authority, workflow.agent);
- if (!creator) return false;
+ return "owner";
+ const creator = workflow && workflowOwner(event, workflow.authority);
+ if (!creator) return undefined;
+ // Only a mention that names this agent reads profiles; the reason for
+ // one that doesn't comes from those already read.
+ const named = !!workflowMentionOwner(
+ event,
+ workflow.authority,
+ workflow.agent,
+ );
+ const owned = await ownedCreator(creator, owner, workflow.profiles, named);
+ if (!owned) return undefined;
+ return named ? "owner" : "workflow";
+}
+
+/** The workflow's creator is the owner or a profile-verified sibling.
+ * Without `read`, only an already-read profile counts. */
+async function ownedCreator(
+ creator: string,
+ owner: string,
+ profiles: WorkflowCheck["profiles"],
+ read: boolean,
+): Promise {
if (creator === owner) return true;
- const profiles = workflow?.profiles;
if (!profiles?.event) return false;
try {
- await profiles.ensure([creator]);
+ if (read) await profiles.ensure([creator]);
const profile = profiles.event(creator);
+ if (!profile) return false;
return (
- !!profile &&
hasEventProof(profile) &&
profile.kind === 0 &&
profile.pubkey === creator &&
diff --git a/src/features/agents/reads.ts b/src/features/agents/reads.ts
index 1f0858e237..955e5f63a9 100644
--- a/src/features/agents/reads.ts
+++ b/src/features/agents/reads.ts
@@ -35,8 +35,6 @@ export type AgentReads = Readonly<{
connection: object;
/** The community these reads are of; it changes when the agent's does. */
scope: string;
- /** That community's normalized origin, when known. */
- origin?: string | undefined;
/** The channel as the community lists it, or undefined when unknown. */
channel(id: string): Promise;
read(
@@ -92,7 +90,6 @@ export function ownerReads(
return Object.freeze({
connection: session,
scope: snapshot.scope ?? "",
- origin: snapshot.origin,
channel: async (id: string) => {
const summary =
session.channels.list().channels.find((item) => item.id === id) ??
diff --git a/src/features/agents/runner.ts b/src/features/agents/runner.ts
index d540208f06..22087cf65d 100644
--- a/src/features/agents/runner.ts
+++ b/src/features/agents/runner.ts
@@ -27,6 +27,7 @@ import type {
Trigger,
} from "./service";
import type { LocalAgentActivity, WorkOutcome } from "./local-activity";
+import type { MentionRefusal } from "./owner-attestation";
import { threadReference } from "../relay/thread-reference";
import {
hasEventProof,
@@ -2784,7 +2785,7 @@ export class AgentRunners {
workingUntil: working.hold,
...(job.work
? {
- ignored: () => job.work?.remove(),
+ ignored: (refusal?: MentionRefusal) => job.work?.remove(refusal),
acknowledged: (input?: Readonly<{ steering?: true }>) => {
job.work?.started();
job.work?.acknowledged(input);
diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts
index 9bd55274a0..a31913caa8 100644
--- a/src/features/agents/service.test.ts
+++ b/src/features/agents/service.test.ts
@@ -2318,6 +2318,32 @@ it("shows no typing for a mention its runtime ignores", async () => {
expect(await published(native, 20002)).toEqual([]);
});
+it("keeps a runtime's refusal reason for its owner's decline note", async () => {
+ const { service, run, emit, activity } = await setup();
+ await service.create({ type: "example/echo", name: "Echo" });
+ run.mockImplementationOnce(async ({ ignored }) => ignored?.("workflow"));
+ emit({
+ events: [
+ event("m", {
+ tags: [
+ ["h", channel],
+ ["p", bot],
+ ],
+ }),
+ ],
+ channelId: channel,
+ });
+ await vi.waitFor(() =>
+ expect(activity.reader.declined?.()).toEqual([
+ expect.objectContaining({
+ agent: bot,
+ channelId: channel,
+ refusal: "workflow",
+ }),
+ ]),
+ );
+});
+
it("wakes on a reply in a thread it started only by a mention or a watch", async () => {
const { service, run, emit } = await setup();
await service.create({ type: "example/echo", name: "Echo" });
diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts
index ecc70d1e36..42e29a6acf 100644
--- a/src/features/agents/service.ts
+++ b/src/features/agents/service.ts
@@ -792,8 +792,9 @@ export type Delivery = Readonly<{
workingUntil(done: PromiseLike): void;
/** Optional presentation evidence supplied by the harness, not inferred by the UI. */
step?(tool: string, target?: string): void;
- /** The plugin declined this request; no work or successful outcome is implied. */
- ignored?(): void;
+ /** The plugin declined this request; no work or successful outcome is implied.
+ * A `refusal` shows its owner, on this device, why the agent didn't act. */
+ ignored?(refusal?: MentionRefusal): void;
/** The harness acknowledged this exact request. Not proof of model consumption. */
acknowledged?(input?: Readonly<{ steering?: true }>): void;
/** A newer acknowledged request owns this turn's activity; hide this message's
@@ -1859,7 +1860,8 @@ export function teamPrompt(
})
.join("\n\n");
}
-export { fromOwner } from "./owner-attestation";
+export { fromOwner, mentionAuthority } from "./owner-attestation";
+import type { MentionRefusal } from "./owner-attestation";
/** What Agents needs of the communities: the viewer, the selected one, and
* a connection to any joined one without selecting it. */
diff --git a/src/features/agents/workflow-authorization.test.ts b/src/features/agents/workflow-authorization.test.ts
index 1c3c630c2b..17c39904d1 100644
--- a/src/features/agents/workflow-authorization.test.ts
+++ b/src/features/agents/workflow-authorization.test.ts
@@ -1,7 +1,7 @@
import { createHash } from "node:crypto";
import { schnorr } from "@noble/curves/secp256k1.js";
import { bytesToHex } from "nostr-tools/utils";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { expect, it, vi } from "vitest";
import { eventDto, type RelayEvent } from "../relay/events";
import { createProfileDirectory } from "../relay/profile-directory";
import type { RelayReader } from "../relay/reader";
@@ -153,81 +153,6 @@ it("recovers from failed or missing reads and respects a replaced profile head",
}
});
-describe("communities the build restricts", () => {
- const restricted = "wss://restricted.example.test";
- const direct = (key = owner, tags: string[][] = []) =>
- eventDto(signed(key, { kind: 9, content: "@target do it", tags }));
- beforeEach(() => {
- vi.stubEnv(
- "VITE_BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES",
- "https://restricted.example.test",
- );
- });
- afterEach(() => {
- vi.unstubAllEnvs();
- });
-
- it("deny every workflow mention there and keep delegation elsewhere", async () => {
- const f = directory();
- try {
- const blocked = { ...f.context, community: restricted };
- expect(await fromOwner(prompt(), owner.pubkey, blocked)).toBe(false);
- expect(await fromOwner(prompt(), creator.pubkey, blocked)).toBe(false);
- expect(f.read).not.toHaveBeenCalled();
- const open = {
- ...f.context,
- community: "wss://team.communities.buzz.xyz",
- };
- expect(await fromOwner(prompt(), owner.pubkey, open)).toBe(true);
- expect(await fromOwner(prompt(), creator.pubkey, open)).toBe(true);
- } finally {
- f.profiles.dispose();
- }
- });
-
- it("deny workflow-marked messages from the owner but keep direct requests", async () => {
- const workflow = [["buzz:workflow", "true"]];
- const blocked = { authority: relay.pubkey, agent: target.pubkey };
- expect(
- await fromOwner(direct(owner, workflow), owner.pubkey, {
- ...blocked,
- community: restricted,
- }),
- ).toBe(false);
- expect(
- await fromOwner(direct(owner, workflow), owner.pubkey, {
- ...blocked,
- community: "https://team.communities.buzz.xyz",
- }),
- ).toBe(true);
- expect(
- await fromOwner(direct(), owner.pubkey, {
- ...blocked,
- community: restricted,
- }),
- ).toBe(true);
- expect(
- await fromOwner(direct(creator, [auth()]), owner.pubkey, {
- ...blocked,
- community: restricted,
- }),
- ).toBe(true);
- });
-
- it("treat an unknown community as restricted while any is listed", async () => {
- const f = directory();
- try {
- expect(await fromOwner(prompt(), creator.pubkey, f.context)).toBe(false);
- expect(await fromOwner(prompt(), creator.pubkey)).toBe(false);
- expect(await fromOwner(direct(), owner.pubkey)).toBe(true);
- vi.stubEnv("VITE_BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES", "");
- expect(await fromOwner(prompt(), creator.pubkey, f.context)).toBe(true);
- } finally {
- f.profiles.dispose();
- }
- });
-});
-
it("denies a profile read invalidated by session disposal", async () => {
let release!: (events: RelayEvent[]) => void;
const pending = new Promise((resolve) => {
diff --git a/src/features/messages/AgentDeclineNotes.test.tsx b/src/features/messages/AgentDeclineNotes.test.tsx
new file mode 100644
index 0000000000..c2519f6893
--- /dev/null
+++ b/src/features/messages/AgentDeclineNotes.test.tsx
@@ -0,0 +1,82 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, expect, it, vi } from "vitest";
+import type { LocalDecline } from "../agents/local-activity";
+import { AgentDeclineNotes } from "./AgentDeclineNotes";
+
+const relay = vi.hoisted(() => ({ mode: "owner" }));
+vi.mock("../../bundled/workflows/useWorkflowAgentMentions", () => ({
+ useWorkflowAgentMentions: () => relay.mode,
+}));
+afterEach(() => {
+ cleanup();
+ relay.mode = "owner";
+});
+const agent = "a".repeat(64),
+ other = "c".repeat(64),
+ message = "b".repeat(64);
+const choices = { identities: [{ pubkey: agent, name: "Builder" }] };
+const session = (declined: LocalDecline[]) =>
+ ({
+ agentActivity: {
+ snapshot: () => ({ declined }),
+ subscribe: () => () => {},
+ },
+ agentChoices: {
+ snapshot: () => choices,
+ subscribe: () => () => {},
+ },
+ }) as never;
+const decline = (refusal: LocalDecline["refusal"], who = agent) => ({
+ scope: "s",
+ agent: who,
+ channelId: "channel",
+ messageId: message,
+ refusal,
+});
+
+it("says which agent didn't act and why", () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByText(
+ "Your agent didn't respond to this workflow message. This community limits which workflows can use agents, so other people can't use yours. The workflow's Runs tab shows why it didn't respond this time.",
+ ),
+ ).toBeInTheDocument();
+});
+
+it("gives no community reason on a relay that lets any workflow direct agents", () => {
+ relay.mode = "any";
+ render(
+ ,
+ );
+ expect(
+ screen.getByText(
+ "Builder didn't respond because this workflow message wasn't set up to direct agents.",
+ ),
+ ).toBeInTheDocument();
+});
+
+it("shows nothing for another message", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+});
diff --git a/src/features/messages/AgentDeclineNotes.tsx b/src/features/messages/AgentDeclineNotes.tsx
new file mode 100644
index 0000000000..991de6f39d
--- /dev/null
+++ b/src/features/messages/AgentDeclineNotes.tsx
@@ -0,0 +1,76 @@
+import { useSyncExternalStore } from "react";
+import { useWorkflowAgentMentions } from "../../bundled/workflows/useWorkflowAgentMentions";
+import type { MentionRefusal } from "../agents/owner-attestation";
+import type { RelaySession } from "../relay/session";
+import styles from "./Messages.module.css";
+
+const noSubscribe = () => () => {};
+
+export function agentDeclineText(name: string, ownerOnly: boolean) {
+ if (!ownerOnly)
+ return `${name} didn't respond because this workflow message wasn't set up to direct agents.`;
+ return `${name} didn't respond to this workflow message. This community limits which workflows can use agents, so other people can't use yours. The workflow's Runs tab shows why it didn't respond this time.`;
+}
+
+/** Why this device's agents didn't act on this message. Local and owner-only:
+ * the decisions are this app's, and nothing is published. */
+export function AgentDeclineNotes({
+ session,
+ scope,
+ channelId,
+ messageId,
+}: {
+ session: Pick;
+ scope: string;
+ channelId: string;
+ messageId: string;
+}) {
+ const read = () =>
+ JSON.stringify(
+ (session.agentActivity?.snapshot().declined ?? [])
+ .filter(
+ (row) => row.channelId === channelId && row.messageId === messageId,
+ )
+ .map((row) => [row.agent, row.refusal]),
+ );
+ const declined = JSON.parse(
+ useSyncExternalStore(
+ session.agentActivity?.subscribe ?? noSubscribe,
+ read,
+ read,
+ ),
+ ) as [string, MentionRefusal][];
+ return declined.length ? (
+ a)} />
+ ) : null;
+}
+
+/** Mounted only when there is a note, so the relay-info read happens only then. */
+function Notes({
+ session,
+ scope,
+ agents,
+}: {
+ session: Pick;
+ scope: string;
+ agents: string[];
+}) {
+ const ownerOnly = useWorkflowAgentMentions(scope) === "owner";
+ // Test and preview sessions may have no choices store.
+ const readChoices = () => session.agentChoices?.snapshot();
+ const choices = useSyncExternalStore(
+ session.agentChoices?.subscribe ?? noSubscribe,
+ readChoices,
+ readChoices,
+ );
+ return agents.map((agent) => {
+ const name =
+ choices?.identities.find((choice) => choice.pubkey === agent)?.name ??
+ "Your agent";
+ return (
+
+ {agentDeclineText(name, ownerOnly)}
+
+ );
+ });
+}
diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx
index 899275d81a..76a78babe7 100644
--- a/src/features/messages/MessageRow.tsx
+++ b/src/features/messages/MessageRow.tsx
@@ -85,6 +85,7 @@ import {
} from "../conversation/ContributionBoundary";
import type { ChannelMessage, Profile } from "../relay/contracts";
import { AttachmentImage } from "./AttachmentImage";
+import { AgentDeclineNotes } from "./AgentDeclineNotes";
import { DeliveryNotice } from "./DeliveryNotice";
import { AttachmentView } from "../conversation/AttachmentView";
import { AudioAttachment } from "./AudioAttachment";
@@ -1621,6 +1622,14 @@ function MessageRowContent({
{!reactionsInBubble && reactionContent}
+ {session && scope && (
+
+ )}
{active && session && scope && extensions?.accessories && (
div:last-child {
flex-shrink: 0;
}
+.agentNote {
+ margin: var(--space-1h) 0 0;
+ color: var(--text-secondary);
+ font-size: var(--text-caption);
+ line-height: var(--text-caption--line-height);
+ letter-spacing: var(--text-caption--letter-spacing);
+}
diff --git a/vite.config.ts b/vite.config.ts
index 050b33e89c..aa0d16b0d6 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -108,13 +108,6 @@ export default defineConfig(async ({ command, mode }) => {
.filter(Boolean)
.map(relayOrigin)
.join(" ");
- const workflowRestrictedCommunities = (
- env.BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES ?? ""
- )
- .split(/\s+/)
- .filter(Boolean)
- .map(relayOrigin)
- .join(" ");
// Public routing configuration only; the viewer pin and credentials stay in Node.
const defaultRelay = env.BUZZ_RELAY_URL?.trim();
const defaultOrigin = defaultRelay ? relayOrigin(defaultRelay) : "";
@@ -211,8 +204,6 @@ export default defineConfig(async ({ command, mode }) => {
"import.meta.env.VITE_BUZZ_EXTERNAL_MCP_COMMUNITIES": JSON.stringify(
externalMcpCommunities,
),
- "import.meta.env.VITE_BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES":
- JSON.stringify(workflowRestrictedCommunities),
"import.meta.env.VITE_BUZZ_BUILDERLAB_URL": JSON.stringify(builderlabUrl),
"import.meta.env.VITE_BUZZ_FEDERATED_IDENTITY": JSON.stringify(
federatedUrl === undefined ? "0" : "1",