ClaudeMoneyLower billsAI score · PendingAI-assisted edit · no obvious prompt-injection riskn8n.io

Track AI Agent Costs and Decisions with n8n Ledger

Receive agent events via webhook → calculate token costs → persist ledger → WhatsApp budget alerts and daily digests

Some experienceStepsWorkflowPromptTip
Who
Technicians running multi-agent or Supervisor workflows who need to track per-call LLM/tool costs
You get
A real-time cost ledger aggregated by run, agent, model, and tool; one-time WhatsApp alert on budget overrun; scheduled daily digest
You need
n8n · whatsapp

Constraints

Requires an accessible n8n instance; WhatsApp Business account and credentials; no Anthropic credential needed. Ledger data is stored in n8n workflow static data and only persists while the workflow is active.

What they did

Author oneclick-ai published an n8n template workflow that receives AI agent events (llm_call, tool_call, decision, error) via two webhook endpoints, validates fields and calculates costs using a Code node with model pricing, appends results to a persistent ledger in n8n workflow static data, and sends a one-time WhatsApp alert when a run exceeds budget. A GET endpoint and daily cron trigger (09:00) aggregate the ledger and return a JSON report or send a cost/activity summary via WhatsApp.

How it went

The source reports the template is published in the n8n.io template library (author_report). As of collection, views=2; no measured ROI or production-scale data is provided.

Worth knowing

The ledger relies on n8n workflow static data and only persists while the workflow is active; historical data may be lost on instance restart or re-import. pricingPerMillionUsd uses prefix matching for model names (e.g., claude-sonnet matches claude-sonnet-4-6), so ensure model prefixes align with pricing keys. The source does not claim multi-instance ledger sharing; cross-instance aggregation would require an external database. This site has not tested the workflow (reproduction=not_tested); verify pricing and alert logic with sample events after setup.

n8n workflow template

Structure checked · Not run-tested

Use “Import from File” in n8n, or copy the JSON and paste it into the editor. Connect your own accounts, then try test data. The template is inactive by default.

Setup and test checklist

Credentials to connect

  • After import, connect your own credentials on auth-required nodes; author secrets are not included.

Configuration to replace

  • Source template is n8n public workflow #20257 (~19 nodes). Keep inactive after import; verify triggers, expressions and external API endpoints.
  • Sticky-note guidance is informational; replace placeholders with your own accounts, sheet schemas or channel IDs.

Test input and expected output

  • Manually run the main path once in a test workspace and confirm credentialed nodes authenticate without errors.
  • Drive one synthetic/redacted sample through and check output fields and downstream writes before enabling in production.

Template changes

  • Author credential bindings, webhookId and execution data are removed; the workflow is inactive. Nodes, connections and original prompts are preserved. Not run-tested in n8n.
  • No business-logic edits; verify keys, quotas and privacy compliance before use.

Template retrieved: 2026-10-25

Steps

  1. Import the template

    Open the template page in n8n and import "Track AI agent costs and decisions via webhooks and WhatsApp digests". After import you will see two main branches: Record Events and Report & Daily Digest.

  2. Configure credentials and constants

    1) Link your WhatsApp Business credentials in the WhatsApp Business Cloud nodes, and fill in whatsappBusinessPhoneId (WhatsApp phone number ID) and whatsappRecipientPhone (alert recipient mobile number). 2) Edit the Ledger Config node: set per-run budget (runBudgetUsd, default 0.5), max entries per ledger (maxEntries, default 5000), max runs to retain (maxRuns, default 200), and per-1M-token pricing table (pricingPerMillionUsd, JSON format, with default/claude-haiku/claude-sonnet/claude-opus tiers). 3) Edit the Report Config node: set digest window (digestWindowHours, default 24), recent entry limit (recentLimit, default 20), and WhatsApp destination IDs.

  3. Activate and obtain webhook URLs

    Activate the workflow. Static data only persists across executions when the workflow is active. Copy the full webhook URLs for both paths: - POST /webhook/agent-ledger-event (receive events) - GET /webhook/agent-ledger-report (query reports) Configure the POST URL in your agent or Supervisor workflow to send events after each LLM call, tool call, or decision.

  4. Send agent events to the ledger

    Send events to the POST endpoint. Supports a single object or an events array. Each event must include runId, agentName, and eventType (llm_call/tool_call/decision/error). For llm_call, provide model, inputTokens, and outputTokens; for tool_call, provide toolName and optional toolCostUsd; for decision, provide the decision field. The Validate and Price Events node validates fields, calculates cost from pricingPerMillionUsd (or accepts explicit costUsd), then the Append to Ledger node writes to static data, updates per-run summaries, and triggers a one-time WhatsApp alert if the run exceeds budget.

    Execution prompt · Source text。Validate and Price Events node JS Code field

    const cfg = $input.first().json;
    const body = cfg.body || {};
    
    let pricing;
    try { pricing = JSON.parse(cfg.pricingPerMillionUsd); } catch (e) { pricing = { default: { input: 3, output: 15 } }; }
    
    const TYPES = ['tool_call', 'decision', 'llm_call', 'error'];
    const raw = Array.isArray(body.events) ? body.events : (body.eventType ? [body] : []);
    const errors = [];
    const events = [];
    const num = v => (Number(v) > 0 ? Number(v) : 0);
    const preview = v => v === undefined || v === null ? null : (typeof v === 'string' ? v : JSON.stringify(v)).slice(0, 500);
    
    function rateFor(model) {
      const keys = Object.keys(pricing)
        .filter(k => k !== 'default' && String(model || '').startsWith(k))
        .sort((a, b) => b.length - a.length);
      return pricing[keys[0]] || pricing.default || { input: 0, output: 0 };
    }
    
    if (raw.length === 0) errors.push('Body must contain an events array or a single event with eventType');
    
    raw.forEach((e, i) => {
      const p = 'events[' + i + ']';
      if (!e || typeof e !== 'object') { errors.push(p + ' must be an object'); return; }
      if (!e.runId) errors.push(p + ' is missing runId');
      if (!TYPES.includes(e.eventType)) errors.push(p + ' eventType must be one of ' + TYPES.join(', '));
      if (e.eventType === 'tool_call' && !e.toolName) errors.push(p + ' tool_call requires toolName');
      if (e.eventType === 'decision' && !e.decision) errors.push(p + ' decision requires decision');
    
      const inTok = num(e.inputTokens), outTok = num(e.outputTokens), cacheTok = num(e.cacheReadTokens);
      const r = rateFor(e.model);
      const cacheRate = r.cacheRead !== undefined ? r.cacheRead : (r.input || 0) * 0.1;
      const tokenCost = inTok / 1e6 * (r.input || 0) + outTok / 1e6 * (r.output || 0) + cacheTok / 1e6 * cacheRate;
      const explicit = e.costUsd !== undefined && Number(e.costUsd) >= 0 ? Number(e.costUsd) : null;
      const costUsd = explicit !== null ? explicit : tokenCost + num(e.toolCostUsd);
    
      events.push({
        runId: e.runId,
        taskId: e.taskId || null,
        agentName: e.agentName || 'unknown',
        eventType: e.eventType,
        eventTimestamp: e.timestamp || new Date().toISOString(),
        toolName: e.toolName || null,
        toolInput: preview(e.toolInput),
        toolOutput: preview(e.toolOutput),
        model: e.model || null,
        decision: e.decision || null,
        reasoning: e.reasoning ? String(e.reasoning).slice(0, 1000) : null,
        confidence: typeof e.confidence === 'number' ? e.confidence : null,
        status: e.status || (e.eventType === 'error' ? 'failed' : 'ok'),
        errorMessage: e.errorMessage ? String(e.errorMessage).slice(0, 500) : null,
        inputTokens: inTok,
        outputTokens: outTok,
        cacheReadTokens: cacheTok,
        latencyMs: num(e.latencyMs),
        costUsd: Math.round(costUsd * 1e6) / 1e6,
        metadata: e.metadata && typeof e.metadata === 'object' ? e.metadata : {}
      });
    });
    
    return [{
      json: {
        valid: errors.length === 0,
        validationError: errors.join('; '),
        events,
        runBudgetUsd: Number(cfg.runBudgetUsd) || 0,
        maxEntries: Number(cfg.maxEntries) || 5000,
        maxRuns: Number(cfg.maxRuns) || 200,
        whatsappBusinessPhoneId: cfg.whatsappBusinessPhoneId,
        whatsappRecipientPhone: cfg.whatsappRecipientPhone
      }
    }];

    Execution prompt · Source text。Append to Ledger node JS Code field

    const s = $input.first().json;
    const sd = $getWorkflowStaticData('global');
    if (!sd.ledger) sd.ledger = { seq: 0, entries: [], runs: {} };
    const L = sd.ledger;
    const now = new Date().toISOString();
    const touched = [];
    const alerts = [];
    let seqFrom = null;
    
    for (const ev of s.events) {
      L.seq += 1;
      if (seqFrom === null) seqFrom = L.seq;
      L.entries.push({ seq: L.seq, id: 'led-' + L.seq, recordedAt: now, ...ev });
    
      const r = L.runs[ev.runId] || (L.runs[ev.runId] = {
        runId: ev.runId, taskId: ev.taskId, agents: {}, totalCostUsd: 0,
        inputTokens: 0, outputTokens: 0, llmCalls: 0, toolCalls: 0, decisions: 0, errors: 0,
        firstEventAt: ev.eventTimestamp, lastEventAt: ev.eventTimestamp, budgetAlerted: false
      });
      r.agents[ev.agentName] = (r.agents[ev.agentName] || 0) + 1;
      r.totalCostUsd = Math.round((r.totalCostUsd + ev.costUsd) * 1e6) / 1e6;
      r.inputTokens += ev.inputTokens;
      r.outputTokens += ev.outputTokens;
      if (ev.eventType === 'llm_call') r.llmCalls += 1;
      if (ev.eventType === 'tool_call') r.toolCalls += 1;
      if (ev.eventType === 'decision') r.decisions += 1;
      if (ev.eventType === 'error' || ev.status !== 'ok') r.errors += 1;
      r.lastEventAt = ev.eventTimestamp;
      if (!touched.includes(ev.runId)) touched.push(ev.runId);
    
      if (s.runBudgetUsd > 0 && r.totalCostUsd > s.runBudgetUsd && !r.budgetAlerted) {
        r.budgetAlerted = true;
        alerts.push('Run ' + ev.runId + ' spent $' + r.totalCostUsd.toFixed(4) + ' (budget $' + s.runBudgetUsd.toFixed(2) + ') after ' + (r.llmCalls + r.toolCalls) + ' calls. Last agent: ' + ev.agentName);
      }
    }
    
    // Retention
    if (L.entries.length > s.maxEntries) L.entries = L.entries.slice(-s.maxEntries);
    const ids = Object.keys(L.runs);
    if (ids.length > s.maxRuns) {
      ids.sort((a, b) => String(L.runs[a].lastEventAt).localeCompare(String(L.runs[b].lastEventAt)))
         .slice(0, ids.length - s.maxRuns)
         .forEach(id => { delete L.runs[id]; });
    }
    
    const runs = touched.filter(id => L.runs[id]).map(id => {
      const r = L.runs[id];
      return {
        runId: r.runId, totalCostUsd: r.totalCostUsd, inputTokens: r.inputTokens, outputTokens: r.outputTokens,
        llmCalls: r.llmCalls, toolCalls: r.toolCalls, decisions: r.decisions, errors: r.errors,
        budgetUsd: s.runBudgetUsd || null, budgetExceeded: s.runBudgetUsd > 0 && r.totalCostUsd > s.runBudgetUsd
      };
    });
    
    return [{
      json: {
        budgetAlert: alerts.length > 0,
        whatsappText: '\uD83D\uDEA8 Agent Ledger budget alert\n\n' + alerts.join('\n'),
        whatsappBusinessPhoneId: s.whatsappBusinessPhoneId,
        whatsappRecipientPhone: s.whatsappRecipientPhone,
        webhookResponse: { recorded: s.events.length, seqFrom, seqTo: L.seq, ledgerSize: L.entries.length, runs }
      }
    }];
  5. Query reports and receive daily digests

    Query the ledger via GET /webhook/agent-ledger-report with optional params: ?runId=, ?agent=, ?since=<ISO date>, ?limit=. The Build Ledger Report node aggregates by agent, tool, model, and decision, returning totals, byAgent, byTool, byModel, byDecision, topRuns, and recentEntries. If the request came from the webhook, it returns JSON directly; if triggered by Daily Digest Schedule (default cron 09:00), it generates a WhatsApp text summary and sends it.

    Execution prompt · Source text。Build Ledger Report node JS Code field

    const c = $input.first().json;
    const isWebhook = !!c.headers;
    const q = c.query || {};
    const sd = $getWorkflowStaticData('global');
    const L = sd.ledger || { seq: 0, entries: [], runs: {} };
    const round = n => Math.round(n * 1e6) / 1e6;
    
    let entries = L.entries;
    let windowLabel = 'all recorded history';
    if (!isWebhook) {
      const hours = Number(c.digestWindowHours) || 24;
      const cutoff = Date.now() - hours * 3600000;
      entries = entries.filter(e => new Date(e.recordedAt).getTime() >= cutoff);
      windowLabel = 'last ' + hours + 'h';
    }
    if (q.runId) entries = entries.filter(e => e.runId === q.runId);
    if (q.agent) entries = entries.filter(e => e.agentName === q.agent);
    if (q.since) entries = entries.filter(e => e.recordedAt >= q.since);
    
    const group = keyFn => {
      const m = {};
      for (const e of entries) {
        const k = keyFn(e);
        if (k === null || k === undefined) continue;
        const g = m[k] || (m[k] = { name: k, events: 0, costUsd: 0, inputTokens: 0, outputTokens: 0, errors: 0, totalLatencyMs: 0 });
        g.events += 1;
        g.costUsd += e.costUsd;
        g.inputTokens += e.inputTokens;
        g.outputTokens += e.outputTokens;
        g.totalLatencyMs += e.latencyMs;
        if (e.eventType === 'error' || e.status !== 'ok') g.errors += 1;
      }
      return Object.values(m)
        .map(({ totalLatencyMs, ...g }) => ({ ...g, costUsd: round(g.costUsd), avgLatencyMs: g.events ? Math.round(totalLatencyMs / g.events) : 0 }))
        .sort((a, b) => b.costUsd - a.costUsd);
    };
    
    const byAgent = group(e => e.agentName);
    const byTool = group(e => e.eventType === 'tool_call' ? e.toolName : null);
    const byModel = group(e => e.model);
    const topRuns = group(e => e.runId).slice(0, 5);
    
    const dm = {};
    for (const e of entries.filter(x => x.eventType === 'decision')) {
      const d = dm[e.decision] || (dm[e.decision] = { decision: e.decision, count: 0, confidenceSum: 0, confidenceN: 0 });
      d.count += 1;
      if (typeof e.confidence === 'number') { d.confidenceSum += e.confidence; d.confidenceN += 1; }
    }
    const byDecision = Object.values(dm)
      .map(d => ({ decision: d.decision, count: d.count, avgConfidence: d.confidenceN ? Math.round(d.confidenceSum / d.confidenceN * 100) / 100 : null }))
      .sort((a, b) => b.count - a.count);
    
    const totals = {
      events: entries.length,
      costUsd: round(entries.reduce((a, e) => a + e.costUsd, 0)),
      inputTokens: entries.reduce((a, e) => a + e.inputTokens, 0),
      outputTokens: entries.reduce((a, e) => a + e.outputTokens, 0),
      llmCalls: entries.filter(e => e.eventType === 'llm_call').length,
      toolCalls: entries.filter(e => e.eventType === 'tool_call').length,
      decisions: entries.filter(e => e.eventType === 'decision').length,
      errors: entries.filter(e => e.eventType === 'error' || e.status !== 'ok').length
    };
    totals.errorRate = totals.events ? Math.round(totals.errors / totals.events * 1000) / 1000 : 0;
    
    const limit = Number(q.limit) || Number(c.recentLimit) || 20;
    const recentEntries = entries.slice(-limit).reverse();
    
    const report = {
      generatedAt: new Date().toISOString(),
      window: windowLabel,
      filters: { runId: q.runId || null, agent: q.agent || null, since: q.since || null },
      totals, byAgent, byTool, byModel, byDecision, topRuns, recentEntries,
      runSummaries: q.runId && L.runs[q.runId] ? L.runs[q.runId] : undefined
    };
    
    const top = arr => arr.length ? arr[0].name + ' ($' + arr[0].costUsd.toFixed(4) + ')' : 'n/a';
    const whatsappText = '\uD83D\uDCD2 Agent Ledger digest (' + windowLabel + ')\n'
      + 'Events: ' + totals.events + ' | LLM calls: ' + totals.llmCalls + ' | Tool calls: ' + totals.toolCalls + ' | Decisions: ' + totals.decisions + '\n'
      + 'Total cost: $' + totals.costUsd.toFixed(4) + ' | Errors: ' + totals.errors + '\n'
      + 'Top agent: ' + top(byAgent) + '\n'
      + 'Top tool: ' + top(byTool) + '\n'
      + 'Top model: ' + top(byModel) + '\n'
      + 'Costliest run: ' + top(topRuns);
    
    return [{ json: { isWebhook, report, whatsappText, whatsappBusinessPhoneId: c.whatsappBusinessPhoneId, whatsappRecipientPhone: c.whatsappRecipientPhone } }];

Prompt you can copy

Setup guidance · Editorial suggestion。Give to setup assistant; confirm inputs and permissions first

This block is the prompt. Ads are never placed here.

You are an n8n template setup assistant. The user wants to replicate the "Track AI agent costs and decisions via webhooks and WhatsApp digests" workflow. Assist in this order:
1. Confirm the user has an n8n instance and WhatsApp Business credentials.
2. Guide importing the template and checking Ledger Config / Report Config for budget, pricing, and retention limits.
3. Remind that static data only persists after the workflow is activated.
4. Show where to copy the POST and GET webhook URLs.
5. Provide a sample event JSON for testing.

Paste into a chat assistant for step-by-step setup guidance; node code is provided in steps and does not need additional prompts.

Source

Track AI agent costs and decisions via webhooks and WhatsApp digests · oneclick-ai · n8n.io · 2026-10-01

Comments & feedback

Tried this method? Share results, problems or improvements. Comments appear after moderation. Please avoid sensitive information.

Loading…

    Related