diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs
index 09267dd..7e48181 100644
--- a/src/ClawdDotNet.App/AppHost.cs
+++ b/src/ClawdDotNet.App/AppHost.cs
@@ -144,6 +144,9 @@ public sealed class AppHost : IAsyncDisposable
/// #197: The external MCP servers, each registered as a tool.
public Core.Mcp.McpConnections? McpServers { get; private set; }
+ /// #726: Workflows agents propose; a human decides and imports by hand.
+ public Core.Automation.AutomationProposals? Automations { get; private set; }
+
/// Modellpreise von OpenRouter - fuer Budget und Kostenprognose (#595). Laedt im Hintergrund.
public ModelPricingCatalog? Pricing { get; private init; }
@@ -745,6 +748,14 @@ public sealed class AppHost : IAsyncDisposable
mcpServers.Start();
host._shutdown.Add(mcpServers.DisposeAsync);
+ // #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"));
+ engine.Automations = automations;
+ host.Automations = automations;
+ automations.Start();
+ host._shutdown.Add(automations.DisposeAsync);
+
// #725: what comes in from outside through a trigger becomes a task.
host.Triggers = new Core.Triggers.InboundTriggers(storage, board, audit, () => instance.Agents);
diff --git a/src/ClawdDotNet.Core/Automation/AutomationProposals.cs b/src/ClawdDotNet.Core/Automation/AutomationProposals.cs
new file mode 100644
index 0000000..f0ecf16
--- /dev/null
+++ b/src/ClawdDotNet.Core/Automation/AutomationProposals.cs
@@ -0,0 +1,393 @@
+using System.Globalization;
+using System.Text;
+using System.Text.Json;
+using ClawdDotNet.Core.Audit;
+using ClawdDotNet.Core.Storage;
+using ClawdDotNet.Core.Tasks;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Core.Automation;
+
+/// 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)
+{
+ public decimal CostPerMonthUsd => Days == 0 ? 0 : Math.Round(CostUsd * 30m / Days, 2);
+ public int RunsPerMonth => Days == 0 ? 0 : (int)Math.Round(Runs * 30.0 / Days);
+}
+
+/// #726: An agent's proposal, as the human decides it.
+public sealed record AutomationProposal(
+ long Id, string AgentId, string Purpose, string Trigger, IReadOnlyList TaskIds, IReadOnlyList Services,
+ string Risks, string TestGuide, string WorkflowJson, string? Justification, string Summary,
+ IReadOnlyList Nodes, IReadOnlyList Warnings, MeasuredSavings Savings,
+ string Status, string? Reason, DateTime CreatedAt, DateTime? DecidedAt, string? TaskId, long? Answers);
+
+/// What an agent hands in.
+public sealed record AutomationRequest(
+ string AgentId, string Purpose, string Trigger, IReadOnlyList TaskIds, IReadOnlyList Services,
+ string Risks, string TestGuide, string WorkflowJson, string? Justification, long? Answers, string? Argument);
+
+public sealed record AutomationOutcome(bool Ok, string Message, long? Id = null);
+
+public interface IAutomationProposals
+{
+ Task> FindAsync(string subject, CancellationToken ct);
+
+ Task ProposeAsync(AutomationRequest request, CancellationToken ct);
+}
+
+///
+/// #726: Agents propose n8n workflows with reasons; a human decides and imports by hand.
+/// Agents never create workflows themselves — a workflow is a program that runs outside every
+/// check here, with all of n8n's credentials. Importing by hand is the point: it makes the
+/// human look at it instead of clicking "looks fine".
+///
+/// Measured, not guessed. The saving is what the replaced tasks' runs cost over the
+/// last days. No flood: at most
+/// open proposals per agent, and a proposal for the same purpose as an open one is refused. A
+/// rejected proposal keeps its reason — a new one for the same purpose must answer it.
+///
+/// Looked for, not only waited for. spots recurring
+/// tasks whose runs keep doing the same thing and puts them to their agent.
+///
+public sealed class AutomationProposals(
+ SqliteStorage storage,
+ IAuditRepository audit,
+ ITaskRepository tasks,
+ TaskboardService? board,
+ ILogger logger,
+ TimeProvider? clock = null) : IAutomationProposals, IAsyncDisposable
+{
+ public const int MaxOpenPerAgent = 3;
+ public const int MeasureDays = 28;
+
+ /// Runs in days from which a recurring task is looked at.
+ public const int CandidateMinRuns = 8;
+ public const int CandidateDays = 14;
+
+ /// Share of runs with the same sequence of calls that makes a task a candidate.
+ public const double CandidateSameShare = 0.75;
+
+ private static readonly JsonSerializerOptions Json = new();
+ private readonly TimeProvider _clock = clock ?? TimeProvider.System;
+ private readonly CancellationTokenSource _cts = new();
+ private Task? _loop;
+
+ private DateTime Now => _clock.GetUtcNow().UtcDateTime;
+
+ public async Task> FindAsync(string subject, CancellationToken ct)
+ => (await ListAsync(null, 500, ct))
+ .Where(p => TaskSimilarity.Compare(p.Purpose, subject) != TaskTitleMatch.None
+ || p.Purpose.Contains(subject.Trim(), StringComparison.OrdinalIgnoreCase))
+ .Take(10)
+ .ToList();
+
+ public async Task ProposeAsync(AutomationRequest r, CancellationToken ct)
+ {
+ 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)
+ return new(false, "'tasks' is required — the tasks it would replace. The saving is measured from their runs.");
+ if (string.IsNullOrWhiteSpace(r.Risks))
+ return new(false, "'risks' is required — what can go wrong, and what the workflow sends outside.");
+ if (string.IsNullOrWhiteSpace(r.TestGuide))
+ return new(false, "'test_guide' is required — how the human checks it after importing.");
+
+ foreach (var id in r.TaskIds)
+ if (await tasks.GetAsync(id, ct) is null)
+ return new(false, $"There is no task '{id}'.");
+
+ var all = await ListAsync(null, 1000, ct);
+ var mine = all.Where(p => p.Status == "proposed" && string.Equals(p.AgentId, r.AgentId, StringComparison.OrdinalIgnoreCase)).ToList();
+ if (mine.Count >= MaxOpenPerAgent)
+ return new(false, $"You have {mine.Count} proposals waiting for a human ({string.Join(", ", mine.Select(m => "#" + m.Id))}). Wait for a decision first.");
+
+ if (all.FirstOrDefault(p => p.Status is "proposed" or "accepted" && Same(p, r)) is { } open)
+ return new(false, $"Proposal #{open.Id} ({open.Status}) is about the same: \"{open.Purpose}\". Do not propose it again.");
+ if (all.FirstOrDefault(p => p.Status == "rejected" && Same(p, r)) is { } rejected
+ && (r.Answers != rejected.Id || string.IsNullOrWhiteSpace(r.Argument)))
+ return new(false, $"Proposal #{rejected.Id} for the same purpose was rejected: \"{rejected.Reason}\". " +
+ $"A new one must answer that: give 'answers' {rejected.Id} and an 'argument' that is not already there.");
+
+ var review = WorkflowReview.Review(r.WorkflowJson, r.Justification);
+ if (!review.Ok)
+ return new(false, "The workflow does not pass the check:\n- " + string.Join("\n- ", review.Problems));
+
+ var savings = await MeasureAsync(r.TaskIds, ct);
+ var id2 = await storage.WriteAsync(async conn =>
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ INSERT INTO AutomationProposals (AgentId, Purpose, Trigger, TaskIds, Services, Risks, TestGuide, WorkflowJson,
+ Justification, Summary, Nodes, Warnings, Savings, Status, CreatedAt, Answers, Argument)
+ VALUES (@agent, @purpose, @trigger, @tasks, @services, @risks, @guide, @workflow,
+ @justification, @summary, @nodes, @warnings, @savings, 'proposed', @at, @answers, @argument)
+ RETURNING Id
+ """;
+ cmd.Parameters.AddWithValue("@agent", r.AgentId);
+ cmd.Parameters.AddWithValue("@purpose", r.Purpose.Trim());
+ cmd.Parameters.AddWithValue("@trigger", r.Trigger.Trim());
+ cmd.Parameters.AddWithValue("@tasks", JsonSerializer.Serialize(r.TaskIds));
+ cmd.Parameters.AddWithValue("@services", JsonSerializer.Serialize(r.Services));
+ cmd.Parameters.AddWithValue("@risks", r.Risks.Trim());
+ cmd.Parameters.AddWithValue("@guide", r.TestGuide.Trim());
+ cmd.Parameters.AddWithValue("@workflow", r.WorkflowJson);
+ cmd.Parameters.AddWithValue("@justification", (object?)r.Justification?.Trim() ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@summary", review.Summary);
+ cmd.Parameters.AddWithValue("@nodes", JsonSerializer.Serialize(review.Nodes, Json));
+ cmd.Parameters.AddWithValue("@warnings", JsonSerializer.Serialize(review.Warnings, Json));
+ cmd.Parameters.AddWithValue("@savings", JsonSerializer.Serialize(savings, Json));
+ cmd.Parameters.AddWithValue("@at", Now.ToString("O"));
+ cmd.Parameters.AddWithValue("@answers", (object?)r.Answers ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@argument", (object?)r.Argument?.Trim() ?? DBNull.Value);
+ return Convert.ToInt64(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
+ }, ct);
+
+ if (board is not null)
+ {
+ var task = await board.CreateAsync(new TaskItem
+ {
+ Title = $"Automation proposal #{id2}: {r.Purpose.Trim()}",
+ Body = $"{r.AgentId} proposes an n8n workflow. Decide it in the approvals view; if you accept, import the JSON " +
+ $"into n8n by hand, check it with the test guide, and release it as an MCP tool.\n\n" +
+ $"Measured over {savings.Days} days: {savings.Runs} run(s), {savings.CostUsd.ToString("0.00", CultureInfo.InvariantCulture)} USD " +
+ $"(about {savings.CostPerMonthUsd.ToString("0.00", CultureInfo.InvariantCulture)} USD a month).\n" +
+ $"What it does: {review.Summary}",
+ Assignee = TaskAssignee.Human,
+ Priority = 2
+ }, ct);
+ await storage.WriteAsync(async conn =>
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "UPDATE AutomationProposals SET TaskId = @task WHERE Id = @id";
+ cmd.Parameters.AddWithValue("@task", task.Id);
+ cmd.Parameters.AddWithValue("@id", id2);
+ return await cmd.ExecuteNonQueryAsync(ct);
+ }, ct);
+ }
+
+ logger.LogInformation("Automation proposal #{Id} by {Agent}: {Purpose}", id2, r.AgentId, r.Purpose);
+ return new(true, $"Proposal #{id2} is with a human. Measured: {savings.RunsPerMonth} run(s) and about " +
+ $"{savings.CostPerMonthUsd.ToString("0.00", CultureInfo.InvariantCulture)} USD a month. " +
+ (review.Warnings.Count > 0 ? "Noted for the human: " + string.Join(" ", review.Warnings) : ""), id2);
+ }
+
+ /// The human's decision. Accepting records it; importing stays with the human, in n8n.
+ public async Task DecideAsync(long id, bool accept, string reason, CancellationToken ct)
+ {
+ if ((await ListAsync("Id = @p", 1, ct, id.ToString(CultureInfo.InvariantCulture))).FirstOrDefault() is not { } proposal)
+ return new(false, $"There is no proposal #{id}.");
+ if (proposal.Status != "proposed")
+ return new(false, $"Proposal #{id} is decided already ({proposal.Status}).");
+ if (!accept && string.IsNullOrWhiteSpace(reason))
+ return new(false, "A rejection needs its reason — it stays with the proposal, so the same one does not come back.");
+
+ await storage.WriteAsync(async conn =>
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "UPDATE AutomationProposals SET Status = @s, Reason = @r, DecidedAt = @at WHERE Id = @id AND Status = 'proposed'";
+ cmd.Parameters.AddWithValue("@s", accept ? "accepted" : "rejected");
+ cmd.Parameters.AddWithValue("@r", (reason ?? "").Trim());
+ cmd.Parameters.AddWithValue("@at", Now.ToString("O"));
+ cmd.Parameters.AddWithValue("@id", id);
+ return await cmd.ExecuteNonQueryAsync(ct);
+ }, ct);
+
+ if (proposal.TaskId is { } taskId && board is not null
+ && await board.GetAsync(taskId, ct) is { Status: not (TaskItemStatus.Done or TaskItemStatus.Canceled) })
+ {
+ await board.AddCommentAsync(taskId, "human", accept ? "Accepted." + (string.IsNullOrWhiteSpace(reason) ? "" : " " + reason.Trim()) : "Rejected: " + reason.Trim(), ct);
+ await board.UpdateAsync(taskId, t => t with { Status = accept ? TaskItemStatus.Done : TaskItemStatus.Canceled }, ct);
+ }
+ return new(true, accept
+ ? $"Proposal #{id} accepted. Import the workflow into n8n by hand, test it, release it as an MCP tool — then switch the replaced tasks off."
+ : $"Proposal #{id} rejected; the reason stays with it.");
+ }
+
+ public Task> ListAsync(string? status, int limit, CancellationToken ct)
+ => ListAsync(status is null ? null : "Status = @p", limit, ct, status);
+
+ // ─── measuring ───
+
+ public async Task MeasureAsync(IReadOnlyList taskIds, CancellationToken ct)
+ {
+ var since = Now.AddDays(-MeasureDays);
+ var runs = 0;
+ decimal cost = 0;
+ long tokens = 0;
+ foreach (var id in taskIds.Distinct(StringComparer.OrdinalIgnoreCase))
+ foreach (var receipt in await audit.ListReceiptsForTaskAsync(id, ct))
+ if (receipt.OccurredAt >= since)
+ {
+ runs++;
+ cost += receipt.CostIsKnown ? receipt.CostUsd : 0;
+ tokens += receipt.PromptTokens + receipt.CompletionTokens;
+ }
+ return new MeasuredSavings(MeasureDays, runs, cost, tokens);
+ }
+
+ // ─── candidates ───
+
+ ///
+ /// Recurring tasks whose runs keep doing the same thing — the same sequence of calls in most
+ /// of them — are put to their agent once a month: maybe a workflow does it without a model.
+ /// Returns the tasks noted in this pass.
+ ///
+ public async Task> FindCandidatesAsync(CancellationToken ct)
+ {
+ if (board is null)
+ return [];
+ var since = Now.AddDays(-CandidateDays);
+ var proposals = await ListAsync(null, 1000, ct);
+ var noted = new List();
+ var perAgent = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var task in await tasks.ListAsync(new TaskQuery(), ct))
+ {
+ if (task.When is not { Kind: TaskWhenKind.Every or TaskWhenKind.Cron } || task.Status is TaskItemStatus.Canceled or TaskItemStatus.Archived)
+ continue;
+ if (TaskAssignee.AgentId(task.Assignee) is not { Length: > 0 } agent)
+ continue;
+ if (proposals.Any(p => p.Status is "proposed" or "accepted" && p.TaskIds.Contains(task.Id, StringComparer.OrdinalIgnoreCase)))
+ continue;
+ if (await NotedSinceAsync(task.Id, Now.AddDays(-30), ct) || perAgent.GetValueOrDefault(agent) >= 2)
+ continue;
+
+ var receipts = (await audit.ListReceiptsForTaskAsync(task.Id, ct)).Where(r => r.OccurredAt >= since).ToList();
+ if (receipts.Count < CandidateMinRuns)
+ continue;
+
+ var signatures = new List();
+ foreach (var receipt in receipts)
+ signatures.Add(Signature(await audit.ListForRunAsync(receipt.RunId, ct)));
+ var common = signatures.GroupBy(s => s).OrderByDescending(g => g.Count()).First();
+ if (common.Key.Length == 0 || (double)common.Count() / signatures.Count < CandidateSameShare)
+ continue;
+
+ var cost = receipts.Where(r => r.CostIsKnown).Sum(r => r.CostUsd);
+ await board.CreateAsync(new TaskItem
+ {
+ Title = $"Automation candidate: {task.Title}",
+ Body = $"Task {task.Id} ran {receipts.Count} times in {CandidateDays} days for {cost.ToString("0.00", CultureInfo.InvariantCulture)} USD, " +
+ $"and {common.Count()} of the runs made the same calls: {common.Key}.\n\n" +
+ "Consider whether an n8n workflow could do it without a model. If yes: Escalation automation_find (is there a proposal already?), " +
+ "then automation_propose with the workflow JSON, the tasks it replaces, the risks and a test guide. " +
+ "If no: close this task and say why — judgement, changing inputs, or decisions a workflow cannot make.",
+ Assignee = "@" + agent,
+ Priority = 2
+ }, ct);
+ await NoteAsync(task.Id, ct);
+ perAgent[agent] = perAgent.GetValueOrDefault(agent) + 1;
+ noted.Add(task.Id);
+ }
+
+ if (noted.Count > 0)
+ logger.LogInformation("Automation candidates put to their agents: {Tasks}", string.Join(", ", noted));
+ return noted;
+ }
+
+ /// The calls of a run in order, repeats folded — "FileRW.read > DirectAPI.get > FileRW.write".
+ internal static string Signature(IReadOnlyList entries)
+ {
+ var steps = new List();
+ foreach (var e in entries.Where(e => e.Status != AuditStatus.Started && e.Tool is not ("Taskboard" or "Escalation")))
+ {
+ var step = Inspection.RunInspector.OperationOf(e.Arguments) is { } op ? $"{e.Tool}.{op}" : e.Tool;
+ if (steps.Count == 0 || steps[^1] != step)
+ steps.Add(step);
+ }
+ return string.Join(" > ", steps);
+ }
+
+ private static bool Same(AutomationProposal existing, AutomationRequest request)
+ => TaskSimilarity.Compare(existing.Purpose, request.Purpose) == TaskTitleMatch.Duplicate
+ || (existing.TaskIds.Count > 0 && existing.TaskIds.OrderBy(t => t).SequenceEqual(request.TaskIds.OrderBy(t => t), StringComparer.OrdinalIgnoreCase));
+
+ // ─── storage ───
+
+ private async Task NotedSinceAsync(string taskId, DateTime since, CancellationToken ct)
+ {
+ await using var conn = await storage.OpenConnectionAsync(ct);
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT COUNT(*) FROM AutomationCandidates WHERE TaskId = @t AND NotedAt >= @since";
+ cmd.Parameters.AddWithValue("@t", taskId);
+ cmd.Parameters.AddWithValue("@since", since.ToString("O"));
+ return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture) > 0;
+ }
+
+ private Task NoteAsync(string taskId, CancellationToken ct)
+ => storage.WriteAsync(async conn =>
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "INSERT INTO AutomationCandidates (TaskId, NotedAt) VALUES (@t, @at)";
+ cmd.Parameters.AddWithValue("@t", taskId);
+ cmd.Parameters.AddWithValue("@at", Now.ToString("O"));
+ return await cmd.ExecuteNonQueryAsync(ct);
+ }, ct);
+
+ private async Task> ListAsync(string? where, int limit, CancellationToken ct, string? value = null)
+ {
+ await using var conn = await storage.OpenConnectionAsync(ct);
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ SELECT Id, AgentId, Purpose, Trigger, TaskIds, Services, Risks, TestGuide, WorkflowJson, Justification, Summary,
+ Nodes, Warnings, Savings, Status, Reason, CreatedAt, DecidedAt, TaskId, Answers
+ FROM AutomationProposals
+ """ + (where is null ? "" : " WHERE " + where) + " ORDER BY Id DESC LIMIT @limit";
+ if (value is not null)
+ cmd.Parameters.AddWithValue("@p", value);
+ cmd.Parameters.AddWithValue("@limit", limit);
+ var result = new List();
+ await using var r = await cmd.ExecuteReaderAsync(ct);
+ while (await r.ReadAsync(ct))
+ result.Add(new AutomationProposal(
+ r.GetInt64(0), r.GetString(1), r.GetString(2), r.GetString(3),
+ JsonSerializer.Deserialize>(r.GetString(4)) ?? [],
+ JsonSerializer.Deserialize>(r.GetString(5)) ?? [],
+ r.GetString(6), r.GetString(7), r.GetString(8), Text(r, 9), r.GetString(10),
+ JsonSerializer.Deserialize>(r.GetString(11), Json) ?? [],
+ JsonSerializer.Deserialize>(r.GetString(12)) ?? [],
+ JsonSerializer.Deserialize(r.GetString(13), Json) ?? new MeasuredSavings(0, 0, 0, 0),
+ r.GetString(14), Text(r, 15), Time(r, 16)!.Value, Time(r, 17), Text(r, 18), r.IsDBNull(19) ? null : r.GetInt64(19)));
+ return result;
+ }
+
+ private static string? Text(SqliteDataReader r, int i) => r.IsDBNull(i) ? null : r.GetString(i);
+
+ private static DateTime? Time(SqliteDataReader r, int i)
+ => r.IsDBNull(i) ? null : DateTime.Parse(r.GetString(i), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
+
+ // ─── loop ───
+
+ public void Start() => _loop ??= RunAsync(_cts.Token);
+
+ private async Task RunAsync(CancellationToken ct)
+ {
+ try { await Task.Delay(TimeSpan.FromMinutes(10), ct); }
+ catch (OperationCanceledException) { return; }
+
+ while (!ct.IsCancellationRequested)
+ {
+ try { await FindCandidatesAsync(ct); }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger.LogWarning(ex, "Looking for automation candidates failed");
+ }
+
+ try { await Task.Delay(TimeSpan.FromHours(24), ct); }
+ catch (OperationCanceledException) { return; }
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _cts.CancelAsync();
+ if (_loop is not null)
+ {
+ try { await _loop; }
+ catch (OperationCanceledException) { }
+ }
+ _cts.Dispose();
+ }
+}
diff --git a/src/ClawdDotNet.Core/Automation/WorkflowReview.cs b/src/ClawdDotNet.Core/Automation/WorkflowReview.cs
new file mode 100644
index 0000000..95379ee
--- /dev/null
+++ b/src/ClawdDotNet.Core/Automation/WorkflowReview.cs
@@ -0,0 +1,181 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using ClawdDotNet.Core.Organisation;
+
+namespace ClawdDotNet.Core.Automation;
+
+/// One node of a workflow as the review reads it: what it does, and where to.
+public sealed record WorkflowNode(string Name, string Type, string Effect, string? Target, bool Dangerous);
+
+public sealed record WorkflowReviewResult(
+ bool Ok,
+ IReadOnlyList Problems,
+ IReadOnlyList Warnings,
+ IReadOnlyList Nodes,
+ IReadOnlyList Targets,
+ string Summary);
+
+///
+/// #726: The free check of a proposed n8n workflow before a human sees it — not whether it is
+/// valid, but what it DOES: every node with its effect, every address outside, everything that
+/// runs code. A workflow is a program that runs outside all our checks, with all of n8n's
+/// credentials; the human must see that before importing it by hand.
+///
+/// Refused: no JSON, no nodes, an embedded secret (credentials are references in n8n,
+/// never values), and nodes that run code or commands without a stated reason.
+///
+public static partial class WorkflowReview
+{
+ public const int MaxBytes = 200 * 1024;
+ public const int MaxNodes = 200;
+
+ [GeneratedRegex(@"\b(sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|xox[bap]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})\b")]
+ private static partial Regex KeyLike();
+
+ public static WorkflowReviewResult Review(string json, string? justification)
+ {
+ var problems = new List();
+ var warnings = new List();
+ var nodes = new List();
+
+ if (string.IsNullOrWhiteSpace(json))
+ return Fail("No workflow was given.");
+ if (System.Text.Encoding.UTF8.GetByteCount(json) > MaxBytes)
+ return Fail($"The workflow is larger than {MaxBytes / 1024} KB.");
+
+ JsonDocument doc;
+ try { doc = JsonDocument.Parse(json); }
+ catch (JsonException ex) { return Fail($"The workflow is no JSON: {ex.Message}"); }
+
+ using (doc)
+ {
+ if (doc.RootElement.ValueKind != JsonValueKind.Object
+ || !doc.RootElement.TryGetProperty("nodes", out var list) || list.ValueKind != JsonValueKind.Array || list.GetArrayLength() == 0)
+ return Fail("The workflow has no nodes — n8n exports it as { \"nodes\": [...], \"connections\": {...} }.");
+ if (list.GetArrayLength() > MaxNodes)
+ return Fail($"The workflow has more than {MaxNodes} nodes.");
+
+ foreach (var node in list.EnumerateArray())
+ {
+ var name = Text(node, "name") ?? "(unnamed)";
+ var type = Text(node, "type") ?? "(no type)";
+ var parameters = node.TryGetProperty("parameters", out var p) && p.ValueKind == JsonValueKind.Object ? p : default;
+ var (effect, target, dangerous) = Effect(type, parameters);
+ nodes.Add(new WorkflowNode(name, type, effect, target, dangerous));
+
+ if (parameters.ValueKind == JsonValueKind.Object)
+ foreach (var path in Secrets(parameters, ""))
+ problems.Add($"Node '{name}' carries a secret in {path} — credentials belong into n8n and are only referenced.");
+ }
+ }
+
+ var risky = nodes.Where(n => n.Dangerous).ToList();
+ if (risky.Count > 0)
+ {
+ var list = string.Join(", ", risky.Select(n => $"'{n.Name}' ({n.Effect})"));
+ if (string.IsNullOrWhiteSpace(justification))
+ problems.Add($"{list}: these run code or commands outside every check. Without a reason ('justification') they are refused.");
+ else
+ warnings.Add($"{list} — the proposer's reason: {justification.Trim()}");
+ }
+ if (nodes.All(n => n.Effect != "starts the workflow"))
+ warnings.Add("No trigger node — the workflow would only run by hand.");
+
+ var targets = nodes.Select(n => n.Target).OfType().Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ return new WorkflowReviewResult(problems.Count == 0, problems, warnings, nodes, targets, Summarize(nodes, targets));
+
+ WorkflowReviewResult Fail(string problem) => new(false, [problem], [], [], [], "");
+ }
+
+ /// What a node does, from its type — the last part of e.g. n8n-nodes-base.httpRequest.
+ internal static (string Effect, string? Target, bool Dangerous) Effect(string type, JsonElement parameters)
+ {
+ var kind = type.Contains('.') ? type[(type.LastIndexOf('.') + 1)..] : type;
+ var k = kind.ToLowerInvariant();
+ if (k.EndsWith("trigger", StringComparison.Ordinal) || k is "webhook" or "cron" or "interval" or "start" or "manualtrigger")
+ return ("starts the workflow", k == "webhook" ? "(webhook in n8n)" : null, false);
+ if (k is "code" or "function" or "functionitem" or "pythonfunction" or "aitransform")
+ return ("runs code", null, true);
+ if (k == "executecommand")
+ return ("runs a command on the n8n host", null, true);
+ if (k == "ssh")
+ return ("runs commands on another machine (SSH)", Host(Param(parameters, "host")), true);
+ if (k == "httprequest")
+ return ("calls an address", Host(Param(parameters, "url")), false);
+ if (k is "emailsend" or "gmail" or "microsoftoutlook" or "sendgrid" or "mailjet" or "mailgun")
+ return ("sends mail", Param(parameters, "toEmail") ?? Param(parameters, "sendTo") ?? "(recipients in the node)", false);
+ if (k is "readwritefile" or "writebinaryfile" or "readbinaryfile" or "readbinaryfiles")
+ return ("reads or writes files on the n8n host", null, false);
+ if (k is "postgres" or "mysql" or "microsoftsql" or "mongodb" or "redis")
+ return ("talks to a database", null, false);
+ if (k is "set" or "if" or "switch" or "merge" or "splitinbatches" or "noop" or "itemlists" or "filter" or "sort" or "limit" or "aggregate" or "datetime" or "stickynote")
+ return ("shapes the data", null, false);
+ return ($"uses {kind}", null, false);
+ }
+
+ private static string? Param(JsonElement parameters, string name)
+ => parameters.ValueKind == JsonValueKind.Object && parameters.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
+ ? v.GetString()
+ : null;
+
+ /// The host of an address; an n8n expression is computed at run time and named as such.
+ private static string? Host(string? address)
+ {
+ if (string.IsNullOrWhiteSpace(address))
+ return null;
+ if (address.TrimStart().StartsWith('=') || address.Contains("{{"))
+ return "(address computed at run time)";
+ return Uri.TryCreate(address.Trim(), UriKind.Absolute, out var uri) ? uri.Host : address.Trim();
+ }
+
+ /// Paths of values that are secrets: by name, or by look (a key or a bearer token).
+ private static IEnumerable Secrets(JsonElement element, string path)
+ {
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Object:
+ foreach (var property in element.EnumerateObject())
+ {
+ var here = path.Length == 0 ? property.Name : $"{path}.{property.Name}";
+ if (property.Value.ValueKind == JsonValueKind.String)
+ {
+ var value = property.Value.GetString() ?? "";
+ var expression = value.TrimStart().StartsWith('=');
+ if (!expression && value.Length > 0
+ && (OrgTemplates.IsSecretName(property.Name) || value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) || KeyLike().IsMatch(value)))
+ yield return here;
+ }
+ else
+ foreach (var inner in Secrets(property.Value, here))
+ yield return inner;
+ }
+ break;
+ case JsonValueKind.Array:
+ var i = 0;
+ foreach (var item in element.EnumerateArray())
+ foreach (var inner in Secrets(item, $"{path}[{i++}]"))
+ yield return inner;
+ break;
+ }
+ }
+
+ private static string Summarize(IReadOnlyList nodes, IReadOnlyList targets)
+ {
+ var parts = new List { $"{nodes.Count} node(s)." };
+ var starts = nodes.Where(n => n.Effect == "starts the workflow").Select(n => n.Name).ToList();
+ if (starts.Count > 0)
+ parts.Add($"Starts with: {string.Join(", ", starts)}.");
+ if (targets.Count > 0)
+ parts.Add($"Reaches outside: {string.Join(", ", targets)}.");
+ var mail = nodes.Where(n => n.Effect == "sends mail").ToList();
+ if (mail.Count > 0)
+ parts.Add($"Sends mail ({string.Join(", ", mail.Select(m => m.Name))}).");
+ var code = nodes.Where(n => n.Dangerous).ToList();
+ if (code.Count > 0)
+ parts.Add($"Runs code or commands: {string.Join(", ", code.Select(c => c.Name))}.");
+ return string.Join(" ", parts);
+ }
+
+ private static string? Text(JsonElement node, string name)
+ => node.ValueKind == JsonValueKind.Object && node.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
+}
diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs
index 1bcbe8b..01e21e5 100644
--- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs
+++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs
@@ -66,6 +66,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// #702: Blind votes on staged calls; set by the host.
public Staging.IVotes? Votes { get; set; }
+ /// #726: Automation proposals; set by the host.
+ public Automation.IAutomationProposals? Automations { get; set; }
+
///
/// #717: Special budgets. A run on a task in a funded tree draws on the pot instead of the
/// daily limits; without this, every run counts against the day.
@@ -1885,6 +1888,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
Doctrines = Doctrines,
SpotChecks = SpotChecks,
Votes = Votes,
+ Automations = Automations,
SpecialBudgets = SpecialBudgets,
Sandbox = Sandbox,
Files = Files,
diff --git a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
index 5cc17ec..29a43b7 100644
--- a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
+++ b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
@@ -395,6 +395,36 @@ public sealed class SqliteStorage
CREATE INDEX IF NOT EXISTS IX_InboundTriggerEvents_Trigger ON InboundTriggerEvents (TriggerId, ReceivedAt);
+ -- #726: workflows agents propose, the human decides; and the tasks already put to their agent.
+ CREATE TABLE IF NOT EXISTS AutomationProposals (
+ Id INTEGER PRIMARY KEY AUTOINCREMENT,
+ AgentId TEXT NOT NULL,
+ Purpose TEXT NOT NULL,
+ Trigger TEXT NOT NULL,
+ TaskIds TEXT NOT NULL,
+ Services TEXT NOT NULL,
+ Risks TEXT NOT NULL,
+ TestGuide TEXT NOT NULL,
+ WorkflowJson TEXT NOT NULL,
+ Justification TEXT NULL,
+ Summary TEXT NOT NULL,
+ Nodes TEXT NOT NULL,
+ Warnings TEXT NOT NULL,
+ Savings TEXT NOT NULL,
+ Status TEXT NOT NULL,
+ Reason TEXT NULL,
+ CreatedAt TEXT NOT NULL,
+ DecidedAt TEXT NULL,
+ TaskId TEXT NULL,
+ Answers INTEGER NULL,
+ Argument TEXT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS AutomationCandidates (
+ TaskId TEXT NOT NULL,
+ NotedAt TEXT NOT NULL
+ );
+
CREATE TABLE IF NOT EXISTS StagedCalls (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
RunId TEXT NOT NULL,
diff --git a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
index e30a526..56fec42 100644
--- a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
+++ b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
@@ -61,6 +61,9 @@ public sealed record AgentToolContext(
/// #702: The ballots of a voter. null outside the engine.
public Staging.IVotes? Votes { get; init; }
+ /// #726: Automation proposals of agents. null outside the engine.
+ public Automation.IAutomationProposals? Automations { get; init; }
+
/// #654: Where code runs — never in the main process. Unavailable under Windows.
public Sandbox.ISandboxRunner? Sandbox { get; init; }
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
index 55a36df..839d0af 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
@@ -698,6 +698,20 @@
"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.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.",
+ "automation.trigger": "Auslöser",
+ "automation.savings": "Gemessen über {days} Tage: {runs} Lauf/Läufe, {cost} USD — etwa {runsMonth} Läufe und {month} USD im Monat.",
+ "automation.does": "Was er tut",
+ "automation.replaces": "Ersetzt Aufgaben",
+ "automation.services": "Braucht",
+ "automation.risks": "Risiken",
+ "automation.testGuide": "Testanleitung",
+ "automation.download": "JSON herunterladen",
+ "automation.reason": "Grund (Pflicht beim Ablehnen)",
+ "automation.accept": "Annehmen",
+ "automation.reject": "Ablehnen",
"staging.voteRunning": "Abstimmung #{id} läuft bis {until}: {yes} ja, {no} nein, {missing} ausstehend. Du kannst vorher entscheiden.",
"staging.voteDone": "Abstimmung #{id}: {outcome} — {yes} ja, {no} nein, {missing} fehlen.",
"staging.voteRule": "Angenommen ab {approve} Ja, abgelehnt bei höchstens {reject}, mindestens {quorum} Stimmen",
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
index 70b71b1..41abf46 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
@@ -698,6 +698,20 @@
"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.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.",
+ "automation.trigger": "trigger",
+ "automation.savings": "Measured over {days} days: {runs} run(s), {cost} USD — about {runsMonth} runs and {month} USD a month.",
+ "automation.does": "What it does",
+ "automation.replaces": "Replaces tasks",
+ "automation.services": "Needs",
+ "automation.risks": "Risks",
+ "automation.testGuide": "Test guide",
+ "automation.download": "Download JSON",
+ "automation.reason": "Reason (required to reject)",
+ "automation.accept": "Accept",
+ "automation.reject": "Reject",
"staging.voteRunning": "Vote #{id} runs until {until}: {yes} yes, {no} no, {missing} outstanding. You can decide it first.",
"staging.voteDone": "Vote #{id}: {outcome} — {yes} yes, {no} no, {missing} missing.",
"staging.voteRule": "Approved from {approve} yes, rejected at {reject} or fewer, at least {quorum} votes",
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js
index d34bdfb..a44b1c7 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js
@@ -77,7 +77,8 @@ export async function rendereStaging() {
const listeBereich = erstelleElement("div", { id: "staging-liste-bereich" });
const dauerBereich = erstelleElement("div", { id: "staging-dauer-bereich" });
const leitungBereich = erstelleElement("div", { id: "staging-leitung-bereich" });
- layout.append(kopfZeile, listeBereich, dauerBereich, leitungBereich);
+ // #726: workflows agents propose — decided here, imported by hand.
+ layout.append(kopfZeile, listeBereich, erstelleAutomationsKarte(), dauerBereich, leitungBereich);
container.append(layout);
await ladeFreigaben();
@@ -420,3 +421,76 @@ function oeffneAblehnenDialog(id) {
modal.oeffnen();
}
+
+/**
+ * #726: Workflows agents propose. The human reads what the workflow does (the review lists every
+ * node and every address outside), decides, and on acceptance imports the JSON into n8n by hand —
+ * deliberately no "apply" button: a workflow runs outside every check here.
+ */
+export function erstelleAutomationsKarte() {
+ const box = erstelleElement("div", { klasse: "automationen" });
+
+ async function lade() {
+ let liste = [];
+ try { liste = (await api("/staging/automations?status=proposed")) || []; } catch { liste = []; }
+ if (!liste.length) {
+ box.replaceChildren(erstelleElement("p", { klasse: "gedaempft text-klein", text: t("automation.none") }));
+ return;
+ }
+ box.replaceChildren(...liste.map(vorschlag));
+ }
+
+ function vorschlag(p) {
+ const grund = erstelleElement("input", { type: "text", placeholder: t("automation.reason") });
+ const entscheiden = async (annehmen) => {
+ try {
+ const r = await api(`/staging/automations/${p.id}/decide`, { method: "POST", body: { accept: annehmen, reason: grund.value } });
+ zeigeMeldung(r?.message ?? t("common.saved"), "erfolg", 8000);
+ await lade();
+ } catch (err) {
+ zeigeMeldung(err.message, "gefahr", 8000);
+ }
+ };
+ const herunterladen = async () => {
+ try {
+ const json = await api(`/staging/automations/${p.id}/workflow`);
+ const blob = new Blob([typeof json === "string" ? json : JSON.stringify(json, null, 2)], { type: "application/json" });
+ const link = erstelleElement("a", { href: URL.createObjectURL(blob) });
+ link.download = `automation-${p.id}.n8n.json`;
+ link.click();
+ setTimeout(() => URL.revokeObjectURL(link.href), 1000);
+ } catch (err) {
+ zeigeMeldung(err.message, "gefahr", 8000);
+ }
+ };
+
+ return erstelleElement("div", { klasse: "automation" },
+ erstelleElement("strong", { text: `#${p.id} ${p.purpose}` }),
+ erstelleElement("div", { klasse: "gedaempft text-klein", text: `${p.agentId} · ${formatiereDatumZeit(p.createdAt)} · ${t("automation.trigger")}: ${p.trigger}` }),
+ erstelleElement("p", { klasse: "text-klein",
+ text: t("automation.savings", { days: p.days, runs: p.runs, cost: p.costUsd.toFixed(2), month: p.costPerMonthUsd.toFixed(2), runsMonth: p.runsPerMonth }) }),
+ erstelleElement("p", { klasse: "text-klein", text: `${t("automation.does")}: ${p.summary}` }),
+ erstelleElement("table", { klasse: "daten-tabelle" },
+ erstelleElement("tbody", {}, ...p.nodes.map((n) => erstelleElement("tr", { klasse: n.dangerous ? "org-ausreisser" : "" },
+ erstelleElement("td", { text: n.name }),
+ erstelleElement("td", { text: n.effect }),
+ erstelleElement("td", { klasse: "gedaempft", text: n.target ?? "" }))))),
+ ...p.warnings.map((w) => erstelleElement("p", { klasse: "hinweis-box warnung text-klein", text: w })),
+ erstelleElement("dl", { klasse: "datenliste" },
+ erstelleElement("dt", { text: t("automation.replaces") }), erstelleElement("dd", { text: p.taskIds.join(", ") }),
+ erstelleElement("dt", { text: t("automation.services") }), erstelleElement("dd", { text: p.services.join(", ") || "—" }),
+ erstelleElement("dt", { text: t("automation.risks") }), erstelleElement("dd", { text: p.risks }),
+ erstelleElement("dt", { text: t("automation.testGuide") }), erstelleElement("dd", { text: p.testGuide })),
+ erstelleElement("div", { klasse: "gruppe-horizontal" },
+ erstelleElement("button", { type: "button", klasse: "klein", text: t("automation.download"), onclick: herunterladen }),
+ grund,
+ erstelleElement("button", { type: "button", klasse: "primaer klein", text: t("automation.accept"), onclick: () => entscheiden(true) }),
+ erstelleElement("button", { type: "button", klasse: "klein", text: t("automation.reject"), onclick: () => entscheiden(false) })));
+ }
+
+ lade();
+ return erstelleKarte(t("automation.title"), [
+ erstelleElement("p", { klasse: "gedaempft text-klein", text: t("automation.hint") }),
+ box
+ ]);
+}
diff --git a/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs b/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs
index 16b3646..4f2fcb1 100644
--- a/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs
+++ b/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs
@@ -41,6 +41,7 @@ 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.
""";
@@ -50,7 +51,7 @@ public sealed class EscalationTool : IAgentTool
"properties": {
"action": {
"type": "string",
- "enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position", "request_budget", "list_approvals", "approve_call", "reject_call", "doctrine_read", "doctrine_set", "spot_checks", "inspect_run", "check_done", "propose_measure", "list_ballots", "cast_vote"]
+ "enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position", "request_budget", "list_approvals", "approve_call", "reject_call", "doctrine_read", "doctrine_set", "spot_checks", "inspect_run", "check_done", "propose_measure", "list_ballots", "cast_vote", "automation_find", "automation_propose"]
},
"amount_usd": { "type": "number", "description": "request_budget: the amount for the task and all its parts, in USD." },
"subject": { "type": "string", "description": "What it is about, in a few words — like a title. escalate, open_conflict, find_decisions." },
@@ -76,7 +77,17 @@ public sealed class EscalationTool : IAgentTool
"measure": { "type": "string", "enum": ["sharpen_task", "change_model", "adjust_rights", "suspend", "security"], "description": "propose_measure: what should follow." },
"vote": { "type": "integer", "description": "cast_vote: the vote number (#…)." },
"choice": { "type": "string", "enum": ["yes", "no"], "description": "cast_vote: your vote." },
- "evidence": { "type": "string", "description": "cast_vote: what you checked — source and time. Required: without it a vote is an opinion." }
+ "evidence": { "type": "string", "description": "cast_vote: what you checked — source and time. Required: without it a vote is an opinion." },
+ "purpose": { "type": "string", "description": "automation_propose: what the workflow is for. automation_find: what to look for." },
+ "trigger": { "type": "string", "description": "automation_propose: what starts it (mail, schedule, webhook …) and how often it happens." },
+ "tasks": { "type": "array", "items": { "type": "string" }, "description": "automation_propose: the task ids it replaces — the saving is measured from their runs." },
+ "services": { "type": "array", "items": { "type": "string" }, "description": "automation_propose: the services and credentials it needs — names only, never values." },
+ "risks": { "type": "string", "description": "automation_propose: what can go wrong, and what it sends outside." },
+ "workflow": { "description": "automation_propose: the n8n workflow as exported JSON ({ nodes, connections }) — object or string. Credentials only as references." },
+ "test_guide": { "type": "string", "description": "automation_propose: how the human checks it after importing." },
+ "justification": { "type": "string", "description": "automation_propose: why a Code, Execute Command or SSH node is needed. Without it such nodes are refused." },
+ "answers": { "type": "integer", "description": "automation_propose: the rejected proposal (#) this one answers." },
+ "argument": { "type": "string", "description": "automation_propose: what is new against that rejection's reason." }
},
"required": ["action"]
}
@@ -108,6 +119,10 @@ public sealed class EscalationTool : IAgentTool
if (Get(input, "action") is "spot_checks" or "inspect_run" or "check_done" or "propose_measure")
return await SpotCheckAsync(input, context, ct);
+ // #726: Proposing a workflow instead of doing routine by model.
+ if (Get(input, "action") is "automation_find" or "automation_propose")
+ return await AutomationAsync(input, context, ct);
+
// #702: A voter's ballot.
if (Get(input, "action") is "list_ballots" or "cast_vote")
return await BallotAsync(input, context, ct);
@@ -184,7 +199,7 @@ public sealed class EscalationTool : IAgentTool
break;
default:
- return ToolResult.Fail("'action' must be one of find_decisions, escalate, list_cases, decide, handle, forward, open_conflict, state_position, request_budget, list_approvals, approve_call, reject_call, doctrine_read, doctrine_set, spot_checks, inspect_run, check_done, propose_measure, list_ballots, cast_vote.");
+ return ToolResult.Fail("'action' must be one of find_decisions, escalate, list_cases, decide, handle, forward, open_conflict, state_position, request_budget, list_approvals, approve_call, reject_call, doctrine_read, doctrine_set, spot_checks, inspect_run, check_done, propose_measure, list_ballots, cast_vote, automation_find, automation_propose.");
}
return outcome.Ok ? ToolResult.Ok(outcome.Message) : ToolResult.Fail(outcome.Message);
@@ -336,6 +351,41 @@ public sealed class EscalationTool : IAgentTool
}
}
+ private static async Task AutomationAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
+ {
+ if (context.Automations is not { } automations)
+ return ToolResult.Fail("Automation proposals are not available in this instance.");
+
+ if (Get(input, "action") == "automation_find")
+ {
+ var subject = Get(input, "purpose") ?? Get(input, "subject") ?? "";
+ if (string.IsNullOrWhiteSpace(subject))
+ return ToolResult.Fail("'purpose' is required — what to look for.");
+ var found = await automations.FindAsync(subject, ct);
+ if (found.Count == 0)
+ return ToolResult.Ok($"No proposal is about \"{subject.Trim()}\".");
+ var sb = new StringBuilder("Proposals about it:\n");
+ foreach (var p in found)
+ sb.AppendLine($"- #{p.Id} by {p.AgentId}: {p.Purpose} — {p.Status}" + (string.IsNullOrWhiteSpace(p.Reason) ? "" : $" (reason: {p.Reason})"));
+ return ToolResult.Ok(sb.ToString().TrimEnd());
+ }
+
+ var workflow = input.TryGetProperty("workflow", out var w)
+ ? w.ValueKind == JsonValueKind.String ? w.GetString() ?? "" : w.GetRawText()
+ : "";
+ long? answers = input.TryGetProperty("answers", out var a) && a.ValueKind == JsonValueKind.Number && a.TryGetInt64(out var n) ? n : null;
+ var outcome = await automations.ProposeAsync(new Core.Automation.AutomationRequest(
+ context.AgentId, Get(input, "purpose") ?? "", Get(input, "trigger") ?? "",
+ List(input, "tasks"), List(input, "services"), Get(input, "risks") ?? "", Get(input, "test_guide") ?? "",
+ workflow, Get(input, "justification"), answers, Get(input, "argument")), ct);
+ return outcome.Ok ? ToolResult.Ok(outcome.Message) : ToolResult.Fail(outcome.Message);
+ }
+
+ private static IReadOnlyList List(JsonElement input, string name)
+ => input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Array
+ ? v.EnumerateArray().Where(e => e.ValueKind == JsonValueKind.String).Select(e => e.GetString()!.Trim()).Where(s => s.Length > 0).ToList()
+ : [];
+
private static async Task BallotAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
if (context.Votes is not { } votes)
diff --git a/src/ClawdDotNet.Web/Contracts/AutomationDtos.cs b/src/ClawdDotNet.Web/Contracts/AutomationDtos.cs
new file mode 100644
index 0000000..82a238f
--- /dev/null
+++ b/src/ClawdDotNet.Web/Contracts/AutomationDtos.cs
@@ -0,0 +1,13 @@
+namespace ClawdDotNet.Web.Contracts;
+
+/// #726: A node of a proposed workflow, as the review read it.
+public sealed record WorkflowNodeDto(string Name, string Type, string Effect, string? Target, bool Dangerous);
+
+/// #726: An agent's workflow proposal with its measured saving and the review.
+public sealed record AutomationProposalDto(
+ long Id, string AgentId, string Purpose, string Trigger, IReadOnlyList TaskIds, IReadOnlyList Services,
+ string Risks, string TestGuide, string? Justification, string Summary, IReadOnlyList Nodes,
+ IReadOnlyList Warnings, int Runs, decimal CostUsd, decimal CostPerMonthUsd, int RunsPerMonth, int Days,
+ string Status, string? Reason, DateTime CreatedAt, DateTime? DecidedAt, long? Answers);
+
+public sealed record AutomationDecisionDto(bool Accept, string? Reason);
diff --git a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
index ad9cd47..95290a9 100644
--- a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
+++ b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
@@ -375,6 +375,31 @@ internal static class ApiEndpoints
result.Kind is Core.Staging.StagingResultKind.Executed, result.Message));
});
+ // #726: Workflows agents propose — the human decides here and imports by hand in n8n.
+ staging.MapGet("/automations", async (AppHost host, CancellationToken ct, string? status) =>
+ {
+ if (host.Automations is not { } automations)
+ return Results.Ok(Array.Empty());
+ return Results.Ok((await automations.ListAsync(status, 50, ct)).Select(p => new AutomationProposalDto(
+ p.Id, p.AgentId, p.Purpose, p.Trigger, p.TaskIds, p.Services, p.Risks, p.TestGuide, p.Justification, p.Summary,
+ p.Nodes.Select(x => new WorkflowNodeDto(x.Name, x.Type, x.Effect, x.Target, x.Dangerous)).ToList(),
+ p.Warnings, p.Savings.Runs, p.Savings.CostUsd, p.Savings.CostPerMonthUsd, p.Savings.RunsPerMonth, p.Savings.Days,
+ p.Status, p.Reason, p.CreatedAt, p.DecidedAt, p.Answers)).ToList());
+ });
+
+ 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")
+ : Results.NotFound(new OutcomeDto(false, $"No proposal #{id}.")));
+
+ staging.MapPost("/automations/{id:long}/decide", async (AppHost host, long id, AutomationDecisionDto input, CancellationToken ct) =>
+ {
+ if (host.Automations is not { } automations)
+ return Results.BadRequest(new OutcomeDto(false, "Automation proposals are not available in this instance."));
+ var outcome = await automations.DecideAsync(id, input.Accept, input.Reason ?? "", ct);
+ return outcome.Ok ? Results.Ok(new OutcomeDto(true, outcome.Message)) : Results.BadRequest(new OutcomeDto(false, outcome.Message));
+ });
+
// #687: How often each lead approved what it was given — a lead that approves everything does nothing.
staging.MapGet("/leads", async (AppHost host, CancellationToken ct, int days = 30) =>
{
diff --git a/tests/ClawdDotNet.Core.Tests/Automation/AutomationProposalsTests.cs b/tests/ClawdDotNet.Core.Tests/Automation/AutomationProposalsTests.cs
new file mode 100644
index 0000000..23902b4
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Automation/AutomationProposalsTests.cs
@@ -0,0 +1,176 @@
+using ClawdDotNet.Core.Audit;
+using ClawdDotNet.Core.Automation;
+using ClawdDotNet.Core.Storage;
+using ClawdDotNet.Core.Tasks;
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+
+namespace ClawdDotNet.Core.Tests.Automation;
+
+///
+/// #726: Agents propose workflows, measured and checked for free; the human decides and imports
+/// by hand. No flood, and a rejected proposal does not come back without a new argument.
+///
+public sealed class AutomationProposalsTests : IDisposable
+{
+ private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "auto-" + Guid.NewGuid().ToString("N")[..8]);
+ private readonly SqliteAuditRepository _audit;
+ private readonly SqliteTaskRepository _tasks;
+ private readonly TaskboardService _board;
+ private readonly AutomationProposals _proposals;
+
+ private const string Workflow = """
+ {"nodes":[
+ {"name":"Every morning","type":"n8n-nodes-base.scheduleTrigger","parameters":{}},
+ {"name":"Get rates","type":"n8n-nodes-base.httpRequest","parameters":{"url":"https://api.rates.example/eur","headers":{"x":"={{ $env.TOKEN }}"}}},
+ {"name":"Mail","type":"n8n-nodes-base.emailSend","parameters":{"toEmail":"desk@example.org"},"credentials":{"smtp":{"id":"1","name":"Office SMTP"}}}
+ ],"connections":{}}
+ """;
+
+ public AutomationProposalsTests()
+ {
+ var storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
+ _audit = new SqliteAuditRepository(storage);
+ _tasks = new SqliteTaskRepository(storage);
+ _board = new TaskboardService(_tasks, Path.Combine(_directory, "tasks"));
+ _proposals = new AutomationProposals(storage, _audit, _tasks, _board, NullLogger.Instance);
+ }
+
+ public void Dispose()
+ {
+ SqliteStorage.ReleasePooledConnections(_directory);
+ try { Directory.Delete(_directory, recursive: true); }
+ catch { /* cleanup is secondary */ }
+ }
+
+ // ─── the review ───
+
+ [Fact]
+ public void The_review_says_what_the_workflow_does_and_where_it_reaches()
+ {
+ var review = WorkflowReview.Review(Workflow, null);
+
+ review.Ok.ShouldBeTrue(string.Join(" ", review.Problems));
+ review.Nodes.Select(n => n.Effect).ShouldBe(["starts the workflow", "calls an address", "sends mail"]);
+ review.Targets.ShouldBe(["api.rates.example", "desk@example.org"]);
+ review.Summary.ShouldContain("Reaches outside: api.rates.example");
+ }
+
+ [Fact]
+ public void Code_needs_a_reason_and_a_secret_is_never_carried()
+ {
+ const string code = """{"nodes":[{"name":"Calc","type":"n8n-nodes-base.code","parameters":{"jsCode":"return 1"}}]}""";
+
+ WorkflowReview.Review(code, null).Problems.ShouldContain(p => p.Contains("Without a reason"));
+ var reasoned = WorkflowReview.Review(code, "Rounds prices the same way the shop does.");
+ reasoned.Ok.ShouldBeTrue();
+ reasoned.Warnings.ShouldContain(w => w.Contains("Rounds prices"));
+
+ WorkflowReview.Review("""{"nodes":[{"name":"Api","type":"n8n-nodes-base.httpRequest","parameters":{"url":"https://x.example","apiKey":"k-123"}}]}""", null)
+ .Problems.ShouldContain(p => p.Contains("parameters") || p.Contains("apiKey"));
+ WorkflowReview.Review("""{"nodes":[{"name":"Api","type":"n8n-nodes-base.httpRequest","parameters":{"headerValue":"Bearer abc"}}]}""", null)
+ .Ok.ShouldBeFalse("a bearer token by its look");
+ WorkflowReview.Review("not json", null).Ok.ShouldBeFalse();
+ WorkflowReview.Review("""{"nodes":[]}""", null).Ok.ShouldBeFalse();
+ }
+
+ // ─── proposing ───
+
+ private async Task RecurringTask(int runs, string agent = "desk", bool same = true)
+ {
+ var task = await _board.CreateAsync(new TaskItem
+ {
+ Title = "Morning exchange rates",
+ Assignee = "@" + agent,
+ When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 7 * * *", TimeZone = "Europe/Berlin" }
+ }, default);
+ for (var i = 0; i < runs; i++)
+ {
+ var run = $"run-{task.Id}-{i}";
+ await _audit.AppendAsync(new AuditEntry { RunId = run, AgentId = agent, Tool = "WebFetch", Arguments = "{\"action\":\"fetch\"}", Status = AuditStatus.Ok, OccurredAt = DateTime.UtcNow.AddDays(-i) }, default);
+ await _audit.AppendAsync(new AuditEntry { RunId = run, AgentId = agent, Tool = same || i % 2 == 0 ? "Mail" : "FileRW", Arguments = "{\"action\":\"send\"}", Status = AuditStatus.Ok, OccurredAt = DateTime.UtcNow.AddDays(-i) }, default);
+ await _audit.RecordReceiptAsync(new RunReceipt { RunId = run, AgentId = agent, TaskId = task.Id, Status = "Completed", CostUsd = 0.05m, CostIsKnown = true, PromptTokens = 1000, OccurredAt = DateTime.UtcNow.AddDays(-i) }, default);
+ }
+ return task.Id;
+ }
+
+ private AutomationRequest Request(string taskId, string purpose = "Send the morning exchange rates", long? answers = null, string? argument = null)
+ => new("desk", purpose, "every morning at 7", [taskId], ["Office SMTP"], "Sends rates to the desk only.", "Run once by hand, check the mail.", Workflow, null, answers, argument);
+
+ [Fact]
+ public async Task A_proposal_carries_its_measured_saving_and_goes_to_a_human()
+ {
+ var taskId = await RecurringTask(10);
+
+ var outcome = await _proposals.ProposeAsync(Request(taskId), default);
+
+ outcome.Ok.ShouldBeTrue(outcome.Message);
+ var proposal = (await _proposals.ListAsync("proposed", 10, default)).Single();
+ proposal.Savings.Runs.ShouldBe(10);
+ proposal.Savings.CostUsd.ShouldBe(0.50m);
+ proposal.Summary.ShouldContain("api.rates.example");
+ (await _board.ListAsync(new TaskQuery { Assignee = TaskAssignee.Human }, default)).ShouldContain(t => t.Title.Contains($"#{proposal.Id}"));
+ }
+
+ [Fact]
+ public async Task The_same_purpose_is_not_proposed_twice_and_a_rejection_must_be_answered()
+ {
+ var taskId = await RecurringTask(3);
+ var first = await _proposals.ProposeAsync(Request(taskId), default);
+
+ (await _proposals.ProposeAsync(Request(taskId, "Send the exchange rates every morning"), default)).Message.ShouldContain($"#{first.Id}");
+
+ (await _proposals.DecideAsync(first.Id!.Value, false, "", default)).Ok.ShouldBeFalse("a rejection needs its reason");
+ (await _proposals.DecideAsync(first.Id!.Value, false, "Rates change format often — the agent copes, a workflow breaks.", default)).Ok.ShouldBeTrue();
+
+ (await _proposals.ProposeAsync(Request(taskId), default)).Message.ShouldContain("must answer");
+ (await _proposals.ProposeAsync(Request(taskId, answers: first.Id, argument: "The rates API now has a versioned, stable format."), default))
+ .Ok.ShouldBeTrue();
+ (await _proposals.FindAsync("exchange rates", default)).Count.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task An_agent_has_at_most_three_open_proposals()
+ {
+ for (var i = 0; i < 3; i++)
+ (await _proposals.ProposeAsync(Request(await RecurringTask(1), $"Purpose number {i} quite different {Guid.NewGuid():N}"), default)).Ok.ShouldBeTrue();
+
+ (await _proposals.ProposeAsync(Request(await RecurringTask(1), "A fourth unrelated thing entirely"), default)).Message.ShouldContain("Wait for a decision");
+ }
+
+ [Fact]
+ public async Task A_proposal_without_the_required_parts_or_a_broken_workflow_is_refused()
+ {
+ var taskId = await RecurringTask(1);
+
+ (await _proposals.ProposeAsync(Request(taskId) with { TaskIds = [] }, default)).Message.ShouldContain("'tasks'");
+ (await _proposals.ProposeAsync(Request("t-nope"), default)).Message.ShouldContain("no task");
+ (await _proposals.ProposeAsync(Request(taskId) with { WorkflowJson = """{"nodes":[{"name":"x","type":"n8n-nodes-base.executeCommand"}]}""" }, default))
+ .Message.ShouldContain("does not pass the check");
+ }
+
+ // ─── candidates ───
+
+ [Fact]
+ public async Task A_recurring_task_that_does_the_same_each_time_is_put_to_its_agent_once()
+ {
+ var same = await RecurringTask(9, "desk");
+ var varied = await RecurringTask(9, "other", same: false);
+
+ var noted = await _proposals.FindCandidatesAsync(default);
+
+ noted.ShouldBe([same], "half the runs of the other task differ");
+ (await _board.ListAsync(new TaskQuery { Assignee = "@desk" }, default)).ShouldContain(t => t.Title.StartsWith("Automation candidate"));
+ (await _proposals.FindCandidatesAsync(default)).ShouldBeEmpty("once a month, not every day");
+ varied.ShouldNotBe(same);
+ }
+
+ [Fact]
+ public void The_signature_folds_repeats_and_leaves_out_bookkeeping()
+ {
+ AuditEntry E(string tool, string op) => new() { Tool = tool, Arguments = $"{{\"action\":\"{op}\"}}", Status = AuditStatus.Ok };
+
+ AutomationProposals.Signature([E("FileRW", "read"), E("FileRW", "read"), E("Taskboard", "complete"), E("Mail", "send")])
+ .ShouldBe("FileRW.read > Mail.send");
+ }
+}