diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs
index 7e48181..91ecff2 100644
--- a/src/ClawdDotNet.App/AppHost.cs
+++ b/src/ClawdDotNet.App/AppHost.cs
@@ -750,7 +750,10 @@ public sealed class AppHost : IAsyncDisposable
// #726: agents propose workflows; recurring tasks that do the same each time are put to them.
var automations = new Core.Automation.AutomationProposals(storage, audit, taskRepository, board,
- loggerFactory.CreateLogger("ClawdDotNet.Core.Automation"));
+ loggerFactory.CreateLogger("ClawdDotNet.Core.Automation"), () => instance.Automation);
+ // The escalation tool only mentions proposals to agents of an instance that works with n8n.
+ if (tools.Get(Core.Cases.CaseDesk.ToolName) is Tools.Taskboard.EscalationTool escalation)
+ escalation.AutomationEnabled = () => instance.Automation.Enabled;
engine.Automations = automations;
host.Automations = automations;
automations.Start();
diff --git a/src/ClawdDotNet.Core/Automation/AutomationProposals.cs b/src/ClawdDotNet.Core/Automation/AutomationProposals.cs
index f0ecf16..24961e6 100644
--- a/src/ClawdDotNet.Core/Automation/AutomationProposals.cs
+++ b/src/ClawdDotNet.Core/Automation/AutomationProposals.cs
@@ -9,6 +9,16 @@ using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Automation;
+///
+/// #726: Whether the instance works with n8n. Off by default: an instance without n8n has no
+/// use for workflow proposals, and every proposal and candidate costs runs and attention.
+///
+public sealed class AutomationSettings
+{
+ [System.Text.Json.Serialization.JsonPropertyName("enabled")]
+ public bool Enabled { get; set; }
+}
+
/// What the replaced tasks cost over the measured days — taken from the receipts, not estimated.
public sealed record MeasuredSavings(int Days, int Runs, decimal CostUsd, long Tokens)
{
@@ -32,6 +42,9 @@ public sealed record AutomationOutcome(bool Ok, string Message, long? Id = null)
public interface IAutomationProposals
{
+ /// Whether the instance works with n8n — without it, nothing is proposed.
+ bool Enabled { get; }
+
Task> FindAsync(string subject, CancellationToken ct);
Task ProposeAsync(AutomationRequest request, CancellationToken ct);
@@ -57,8 +70,13 @@ public sealed class AutomationProposals(
ITaskRepository tasks,
TaskboardService? board,
ILogger logger,
+ Func? settings = null,
TimeProvider? clock = null) : IAutomationProposals, IAsyncDisposable
{
+ public bool Enabled => settings?.Invoke().Enabled ?? true;
+
+ public const string DisabledMessage = "This instance does not work with n8n — workflow proposals are switched off.";
+
public const int MaxOpenPerAgent = 3;
public const int MeasureDays = 28;
@@ -85,6 +103,8 @@ public sealed class AutomationProposals(
public async Task ProposeAsync(AutomationRequest r, CancellationToken ct)
{
+ if (!Enabled)
+ return new(false, DisabledMessage);
if (string.IsNullOrWhiteSpace(r.Purpose) || string.IsNullOrWhiteSpace(r.Trigger))
return new(false, "'purpose' and 'trigger' are required — what it is for, and what starts it.");
if (r.TaskIds.Count == 0)
@@ -236,7 +256,7 @@ public sealed class AutomationProposals(
///
public async Task> FindCandidatesAsync(CancellationToken ct)
{
- if (board is null)
+ if (board is null || !Enabled)
return [];
var since = Now.AddDays(-CandidateDays);
var proposals = await ListAsync(null, 1000, ct);
diff --git a/src/ClawdDotNet.Core/Config/InstanceConfig.cs b/src/ClawdDotNet.Core/Config/InstanceConfig.cs
index dcac7f7..3691162 100644
--- a/src/ClawdDotNet.Core/Config/InstanceConfig.cs
+++ b/src/ClawdDotNet.Core/Config/InstanceConfig.cs
@@ -159,6 +159,10 @@ public sealed class InstanceConfig
[JsonPropertyName("fileHistory")]
public Files.FileHistorySettings FileHistory { get; set; } = new();
+ /// #726: Whether this instance works with n8n — only then do agents propose workflows. Off by default.
+ [JsonPropertyName("automation")]
+ public Automation.AutomationSettings Automation { get; set; } = new();
+
/// #197: External MCP servers whose tools the agents may use — set up by a human only.
[JsonPropertyName("mcpServers")]
public List McpServers { get; set; } = new();
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
index 839d0af..944853a 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
@@ -698,6 +698,9 @@
"staging.rejected": "Aufruf wurde abgelehnt.",
"staging.rejectFailed": "Ablehnen fehlgeschlagen: {error}",
"staging.withLead": "Die Leitung {lead} darf dies bis {until} entscheiden, im Rahmen dessen, was ihre Einheit erlaubt. Du kannst vorher entscheiden; danach liegt es allein bei dir.",
+ "automation.disabled": "Diese Instanz arbeitet nicht mit n8n, deshalb schlagen Agenten keine Workflows vor. Schalte die n8n-Anbindung in den Einstellungen (Externe MCP-Server) ein, wenn du n8n betreibst.",
+ "mcpServers.n8n": "Diese Instanz arbeitet mit n8n",
+ "mcpServers.n8nHint": "Nur dann schlagen Agenten Workflows für wiederkehrende Routine vor, und nur dann werden ihnen solche Aufgaben vorgelegt. Aus: keine Vorschläge, keine Kandidaten.",
"automation.title": "Automatisierungsvorschläge",
"automation.hint": "Agenten schlagen n8n-Workflows für Routine vor, die sie immer wieder gleich erledigen. Lies, was der Workflow tut — jeden Knoten und jede Adresse, die er erreicht. Nimmst du an, importierst du das JSON von Hand in n8n, testest es und gibst es als MCP-Werkzeug frei; einen Knopf, der das für dich tut, gibt es mit Absicht nicht. Eine Ablehnung behält ihren Grund.",
"automation.none": "Kein Vorschlag wartet auf dich.",
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
index 41abf46..fdb4a5b 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
@@ -698,6 +698,9 @@
"staging.rejected": "Call was rejected.",
"staging.rejectFailed": "Rejection failed: {error}",
"staging.withLead": "Its lead {lead} may decide this until {until}, within the limits its unit grants. You can decide it first; after that it is yours alone.",
+ "automation.disabled": "This instance does not work with n8n, so agents propose no workflows. Switch the n8n connection on in the settings (external MCP servers) if you run n8n.",
+ "mcpServers.n8n": "This instance works with n8n",
+ "mcpServers.n8nHint": "Only then do agents propose workflows for recurring routine, and only then are such tasks put to them. Off: no proposals, no candidates.",
"automation.title": "Automation proposals",
"automation.hint": "Agents propose n8n workflows for routine they do the same way again and again. Read what the workflow does — every node and every address it reaches. If you accept, import the JSON into n8n by hand, test it, and release it as an MCP tool; there is deliberately no button that does it for you. A rejection keeps its reason.",
"automation.none": "No proposal waits for you.",
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/mcp-servers.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/mcp-servers.js
index e5114a4..6588c09 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/views/mcp-servers.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/mcp-servers.js
@@ -86,8 +86,23 @@ export function erstelleMcpServerKarte() {
lade();
+ // #726: whether the instance works with n8n — only then do agents propose workflows.
+ const n8n = erstelleElement("input", { type: "checkbox" });
+ api("/staging/automations/settings").then((s) => { n8n.checked = !!s?.enabled; }).catch(() => {});
+ n8n.addEventListener("change", async () => {
+ try {
+ const r = await api("/staging/automations/settings", { method: "PUT", body: { enabled: n8n.checked } });
+ zeigeMeldung(r?.message ?? t("common.saved"), "erfolg", 6000);
+ } catch (err) {
+ n8n.checked = !n8n.checked;
+ zeigeMeldung(err.message, "gefahr", 8000);
+ }
+ });
+
return erstelleKarte(t("mcpServers.title"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("mcpServers.hint") }),
+ erstelleElement("label", { klasse: "gruppe-horizontal" }, n8n, erstelleElement("span", { text: t("mcpServers.n8n") })),
+ erstelleElement("p", { klasse: "gedaempft text-klein", text: t("mcpServers.n8nHint") }),
liste,
erstelleElement("h4", { text: t("mcpServers.add") }),
erstelleElement("div", { klasse: "formular-raster" },
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js
index a44b1c7..00d0c59 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js
@@ -431,6 +431,13 @@ export function erstelleAutomationsKarte() {
const box = erstelleElement("div", { klasse: "automationen" });
async function lade() {
+ // Without n8n there are no proposals — the card says so instead of standing empty.
+ let einstellung = null;
+ try { einstellung = await api("/staging/automations/settings"); } catch { einstellung = null; }
+ if (!einstellung?.enabled) {
+ box.replaceChildren(erstelleElement("p", { klasse: "gedaempft text-klein", text: t("automation.disabled") }));
+ return;
+ }
let liste = [];
try { liste = (await api("/staging/automations?status=proposed")) || []; } catch { liste = []; }
if (!liste.length) {
diff --git a/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs b/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs
index 4f2fcb1..4d5eca5 100644
--- a/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs
+++ b/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs
@@ -24,7 +24,20 @@ public sealed class EscalationTool : IAgentTool
{
public string Name => CaseDesk.ToolName;
- public string Description => """
+ ///
+ /// #726: Whether the instance works with n8n. Only then are agents told about workflow
+ /// proposals — an instance without n8n has no use for them. Set by the host.
+ ///
+ public Func? AutomationEnabled { get; set; }
+
+ private const string AutomationHint =
+ "\nRoutine you do the same way again and again may not need a model: automation_find (is there a proposal for it, and why was one rejected?), " +
+ "then automation_propose (purpose, trigger, tasks it replaces, services, risks, workflow as n8n JSON, test_guide; justification if it runs code). " +
+ "A human decides and imports it — you never create workflows yourself.";
+
+ public string Description => AutomationEnabled?.Invoke() == true ? BaseDescription + AutomationHint : BaseDescription;
+
+ private const string BaseDescription = """
Escalate what you cannot decide, and decide what is escalated to you.
- find_decisions: decisions that bind you and answer a question. escalate shows them anyway.
- escalate: you cannot get on and it is not yours to decide. Goes to your lead (or a human). Name the task you are stuck on; it waits for the answer.
@@ -41,7 +54,6 @@ public sealed class EscalationTool : IAgentTool
spot_checks lists the runs picked for you, with what the audit log shows; inspect_run (run) shows one run in full;
check_done (check, verdict ok|finding, note) closes a check. propose_measure (agent, measure, reasoning, check) puts a measure to a human:
sharpen_task, change_model, adjust_rights, suspend — or security, if you suspect the agent is steered from outside.
- Routine you do the same way again and again may not need a model: automation_find (is there a proposal for it, and why was one rejected?), then automation_propose (purpose, trigger, tasks it replaces, services, risks, workflow as n8n JSON, test_guide; justification if it runs code). A human decides and imports it — you never create workflows yourself.
As a voter: list_ballots shows the calls you are asked to vote on; cast_vote (vote, choice yes|no, reasoning, evidence) — check against your own data, with source and time. You never see the other votes.
""";
@@ -355,6 +367,8 @@ public sealed class EscalationTool : IAgentTool
{
if (context.Automations is not { } automations)
return ToolResult.Fail("Automation proposals are not available in this instance.");
+ if (!automations.Enabled)
+ return ToolResult.Fail(Core.Automation.AutomationProposals.DisabledMessage);
if (Get(input, "action") == "automation_find")
{
diff --git a/src/ClawdDotNet.Web/Contracts/AutomationDtos.cs b/src/ClawdDotNet.Web/Contracts/AutomationDtos.cs
index 82a238f..d9f1e29 100644
--- a/src/ClawdDotNet.Web/Contracts/AutomationDtos.cs
+++ b/src/ClawdDotNet.Web/Contracts/AutomationDtos.cs
@@ -11,3 +11,6 @@ public sealed record AutomationProposalDto(
string Status, string? Reason, DateTime CreatedAt, DateTime? DecidedAt, long? Answers);
public sealed record AutomationDecisionDto(bool Accept, string? Reason);
+
+/// #726: Whether the instance works with n8n.
+public sealed record AutomationSettingsDto(bool Enabled);
diff --git a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
index 95290a9..917b257 100644
--- a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
+++ b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
@@ -387,6 +387,18 @@ internal static class ApiEndpoints
p.Status, p.Reason, p.CreatedAt, p.DecidedAt, p.Answers)).ToList());
});
+ // #726: whether this instance works with n8n — only then do agents propose workflows.
+ staging.MapGet("/automations/settings", (AppHost host) => Results.Ok(new AutomationSettingsDto(host.Instance.Automation.Enabled)));
+
+ staging.MapPut("/automations/settings", (AppHost host, AutomationSettingsDto input) =>
+ {
+ host.Instance.Automation.Enabled = input.Enabled;
+ host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings));
+ return Results.Ok(new OutcomeDto(true, input.Enabled
+ ? "n8n connection on: agents may propose workflows, and recurring routine is put to them."
+ : "n8n connection off: no workflow proposals, no candidates."));
+ });
+
staging.MapGet("/automations/{id:long}/workflow", async (AppHost host, long id, CancellationToken ct) =>
host.Automations is { } automations && (await automations.ListAsync(null, 1000, ct)).FirstOrDefault(p => p.Id == id) is { } proposal
? Results.Text(proposal.WorkflowJson, "application/json")
diff --git a/tests/ClawdDotNet.Core.Tests/Automation/AutomationProposalsTests.cs b/tests/ClawdDotNet.Core.Tests/Automation/AutomationProposalsTests.cs
index 23902b4..3b2950f 100644
--- a/tests/ClawdDotNet.Core.Tests/Automation/AutomationProposalsTests.cs
+++ b/tests/ClawdDotNet.Core.Tests/Automation/AutomationProposalsTests.cs
@@ -165,6 +165,22 @@ public sealed class AutomationProposalsTests : IDisposable
varied.ShouldNotBe(same);
}
+ [Fact]
+ public async Task Without_n8n_nothing_is_proposed_and_no_candidate_is_put_to_anyone()
+ {
+ var settings = new AutomationSettings { Enabled = false };
+ var storage = new SqliteStorage(Path.Combine(_directory, "off.db"));
+ var off = new AutomationProposals(storage, _audit, _tasks, _board, NullLogger.Instance, () => settings);
+ var taskId = await RecurringTask(9);
+
+ off.Enabled.ShouldBeFalse();
+ (await off.ProposeAsync(Request(taskId), default)).Message.ShouldContain("does not work with n8n");
+ (await off.FindCandidatesAsync(default)).ShouldBeEmpty();
+
+ settings.Enabled = true;
+ (await off.ProposeAsync(Request(taskId), default)).Ok.ShouldBeTrue();
+ }
+
[Fact]
public void The_signature_folds_repeats_and_leaves_out_bookkeeping()
{
diff --git a/tests/ClawdDotNet.Tools.Tests/Taskboard/AutomationHintTests.cs b/tests/ClawdDotNet.Tools.Tests/Taskboard/AutomationHintTests.cs
new file mode 100644
index 0000000..f207999
--- /dev/null
+++ b/tests/ClawdDotNet.Tools.Tests/Taskboard/AutomationHintTests.cs
@@ -0,0 +1,20 @@
+using ClawdDotNet.Tools.Taskboard;
+using Shouldly;
+
+namespace ClawdDotNet.Tools.Tests.Taskboard;
+
+/// #726: Agents hear of workflow proposals only in an instance that works with n8n.
+public sealed class AutomationHintTests
+{
+ [Fact]
+ public void The_hint_follows_the_n8n_switch()
+ {
+ var enabled = false;
+ var tool = new EscalationTool { AutomationEnabled = () => enabled };
+
+ tool.Description.ShouldNotContain("automation_propose");
+ enabled = true;
+ tool.Description.ShouldContain("automation_propose");
+ new EscalationTool().Description.ShouldNotContain("automation_propose", customMessage: "without the host saying so, nothing is proposed");
+ }
+}