Guides

Tool Templates

Five ready-to-use tool templates that cover the most common patterns. Each one is a complete, working tool you can create with a single tool call and customize from there.


1. Gmail Notifier

Send an email via the Gmail API using a managed Google OAuth connection. This demonstrates how managed OAuth and code tools work together — Kyew handles the OAuth flow and injects the token into your handler automatically.

Prerequisites

A Google connection with the gmail scope bundle. If you don't have one, create it first:

connection(action="connect",
  provider="google",
  bundles=["gmail"])

Open the returned authorization URL in your browser and grant access. Note the connection_id from the response.

Create the Tool

tool(action="create",
  name="gmail_send",
  description="Send an email via Gmail",
  tool_type="code",
  input_schema={
    type: "object",
    properties: {
      to: { type: "string", description: "Recipient email address" },
      subject: { type: "string", description: "Email subject line" },
      body: { type: "string", description: "Email body (plain text)" }
    },
    required: ["to", "subject", "body"]
  },
  code_config={
    runtime: "javascript",
    allowed_domains: ["gmail.googleapis.com"],
    connection_ids: ["YOUR_CONNECTION_ID"],
    code: "... (see full handler below) ..."
  })

Full Handler Code

export default {
  async fetch(request) {
    const { to, subject, body } = await request.json();
    const authHeader = process.env.KYEW_CONN_GOOGLE_AUTH_HEADER;

    // Build the RFC 2822 message
    const message = [
      `To: ${to}`,
      `Subject: ${subject}`,
      `Content-Type: text/plain; charset=utf-8`,
      "",
      body,
    ].join("\r\n");

    // Base64url encode the message
    const encoded = btoa(unescape(encodeURIComponent(message)))
      .replace(/\+/g, "-")
      .replace(/\//g, "_")
      .replace(/=+$/, "");

    const res = await fetch(
      "https://gmail.googleapis.com/gmail/v1/users/me/messages/send",
      {
        method: "POST",
        headers: {
          Authorization: authHeader,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ raw: encoded }),
      }
    );

    if (!res.ok) {
      const error = await res.text();
      return Response.json({ error, status: res.status }, { status: res.status });
    }

    const result = await res.json();
    return Response.json({
      sent: true,
      message_id: result.id,
      thread_id: result.threadId,
      to,
      subject,
    });
  },
};

Test It

tool(action="test",
  tool_id="YOUR_TOOL_ID",
  test_input={
    to: "[email protected]",
    subject: "Test from Kyew",
    body: "This email was sent by a Kyew code tool."
  })

Customize

  • HTML emails: Change Content-Type to text/html; charset=utf-8 and pass HTML in body.
  • CC/BCC: Add Cc: and Bcc: headers to the RFC 2822 message string.
  • Attachments: Use the Gmail API's multipart upload endpoint instead of the simple send.
  • Reply to a thread: Add threadId to the request body and include an In-Reply-To header in the message.

Connection naming

The environment variable name comes from the connection name. A connection named google produces KYEW_CONN_GOOGLE_AUTH_HEADER. If you named your connection something else, adjust the process.env reference to match.


2. RSS Feed Monitor

Fetch and parse RSS feeds, store seen items in SQLite, and return only new entries. This demonstrates combining HTTP fetching with db.mjs for persistent state across runs.

Prerequisites

None. RSS feeds are public.

Create the Tool

tool(action="create",
  name="rss_monitor",
  description="Fetch RSS feeds and return only new items since last check",
  tool_type="code",
  input_schema={
    type: "object",
    properties: {
      feeds: {
        type: "array",
        items: { type: "string" },
        description: "Array of RSS feed URLs"
      },
      hours_back: {
        type: "number",
        description: "Only return items from the last N hours (default 24)"
      }
    },
    required: ["feeds"]
  },
  code_config={
    runtime: "javascript",
    allowed_domains: ["*"],
    code: "... (see full handler below) ..."
  })

Full Handler Code

import { query, exec } from "./db.mjs";

export default {
  async fetch(request) {
    const { feeds, hours_back = 24 } = await request.json();

    // Create tracking table
    await exec(`CREATE TABLE IF NOT EXISTS rss_seen (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      feed_url TEXT NOT NULL,
      item_hash TEXT NOT NULL UNIQUE,
      title TEXT,
      link TEXT,
      pub_date TEXT,
      seen_at TEXT DEFAULT (datetime('now'))
    )`);

    const cutoff = new Date(Date.now() - hours_back * 3600000).toISOString();
    const results = {};
    let totalNew = 0;

    for (const feedUrl of feeds) {
      try {
        const res = await fetch(feedUrl, {
          headers: { "User-Agent": "Kyew-RSS-Monitor/1.0" },
        });
        const xml = await res.text();

        // Parse items from RSS/Atom
        const items = parseItems(xml);
        const newItems = [];

        for (const item of items) {
          // Create a hash from guid or link to deduplicate
          const hash = simpleHash(item.guid || item.link || item.title);

          // Check if already seen
          const existing = await query(
            "SELECT id FROM rss_seen WHERE item_hash = ?",
            hash
          );

          if (existing.rows.length === 0) {
            // Filter by recency
            if (item.pubDate && new Date(item.pubDate) < new Date(cutoff)) {
              continue;
            }

            await query(
              "INSERT OR IGNORE INTO rss_seen (feed_url, item_hash, title, link, pub_date) VALUES (?, ?, ?, ?, ?)",
              feedUrl, hash, item.title, item.link, item.pubDate
            );
            newItems.push(item);
          }
        }

        results[feedUrl] = {
          new_count: newItems.length,
          items: newItems,
        };
        totalNew += newItems.length;
      } catch (err) {
        results[feedUrl] = { error: err.message };
      }
    }

    return Response.json({
      total_new: totalNew,
      feeds_checked: feeds.length,
      hours_back,
      results,
    });
  },
};

// Simple XML parser for RSS and Atom feeds
function parseItems(xml) {
  const items = [];
  // Match RSS <item> elements
  const rssPattern = /<item>([\s\S]*?)<\/item>/g;
  let match;
  while ((match = rssPattern.exec(xml)) !== null) {
    items.push({
      title: extractTag(match[1], "title"),
      link: extractTag(match[1], "link"),
      guid: extractTag(match[1], "guid"),
      pubDate: extractTag(match[1], "pubDate"),
      description: extractTag(match[1], "description"),
    });
  }
  // Match Atom <entry> elements if no RSS items found
  if (items.length === 0) {
    const atomPattern = /<entry>([\s\S]*?)<\/entry>/g;
    while ((match = atomPattern.exec(xml)) !== null) {
      const linkMatch = match[1].match(/<link[^>]*href="([^"]*)"[^>]*\/?>/) ||
        match[1].match(/<link>([^<]*)<\/link>/);
      items.push({
        title: extractTag(match[1], "title"),
        link: linkMatch ? linkMatch[1] : null,
        guid: extractTag(match[1], "id"),
        pubDate: extractTag(match[1], "updated") || extractTag(match[1], "published"),
        description: extractTag(match[1], "summary"),
      });
    }
  }
  return items;
}

function extractTag(xml, tag) {
  const cdataMatch = xml.match(
    new RegExp(`<${tag}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${tag}>`)
  );
  if (cdataMatch) return cdataMatch[1].trim();
  const match = xml.match(new RegExp(`<${tag}[^>]*>([^<]*)<\\/${tag}>`));
  return match ? match[1].trim() : null;
}

function simpleHash(str) {
  let hash = 0;
  for (let i = 0; i < (str || "").length; i++) {
    const char = str.charCodeAt(i);
    hash = (hash << 5) - hash + char;
    hash |= 0;
  }
  return "rss_" + Math.abs(hash).toString(36);
}

Test It

tool(action="test",
  tool_id="YOUR_TOOL_ID",
  test_input={
    feeds: [
      "https://hnrss.org/frontpage",
      "https://feeds.arstechnica.com/arstechnica/index"
    ],
    hours_back: 12
  })

The first run marks all items as "new." Subsequent runs only return items the tool hasn't seen before.

Customize

  • Keyword filtering: Add a keywords input parameter and filter items by title/description match before returning them.
  • Separate databases: Set db_name: "rss" in code_config to isolate RSS data from other tools.
  • Email digest: Add a _email property to the response with an HTML summary, then schedule the tool to run hourly. See Template 5 for the email pattern.
  • Feed categories: Add a category column to the table and let users tag feeds for grouped output.

Wildcard domains

This template uses allowed_domains: ["*"] since RSS feeds live on many different hosts. If you only monitor a few known feeds, lock it down to those specific domains instead.


3. API Data Lookup

Query any REST API with connection-based auth and format the results. This is a generic pattern you can adapt to any service — change the base URL, headers, and response mapping.

Prerequisites

A connection to the API you want to call. This example uses a generic bearer token connection:

connection(action="create",
  name="myapi",
  provider="custom",
  auth_type="bearer",
  base_url="https://api.example.com")

Then set the token via the secret URL:

connection(action="secret_url", connection_id="YOUR_CONNECTION_ID")

Create the Tool

tool(action="create",
  name="api_lookup",
  description="Query a REST API endpoint and return formatted results",
  tool_type="code",
  input_schema={
    type: "object",
    properties: {
      endpoint: {
        type: "string",
        description: "API path (e.g., /users/123 or /search?q=term)"
      },
      method: {
        type: "string",
        enum: ["GET", "POST", "PUT", "PATCH", "DELETE"],
        description: "HTTP method (default GET)"
      },
      body: {
        type: "object",
        description: "Request body for POST/PUT/PATCH"
      },
      headers: {
        type: "object",
        description: "Additional headers to include"
      }
    },
    required: ["endpoint"]
  },
  code_config={
    runtime: "javascript",
    allowed_domains: ["api.example.com"],
    connection_ids: ["YOUR_CONNECTION_ID"],
    code: "... (see full handler below) ..."
  })

Full Handler Code

export default {
  async fetch(request) {
    const { endpoint, method = "GET", body, headers = {} } = await request.json();

    const baseUrl = process.env.KYEW_CONN_MYAPI_BASE_URL || "https://api.example.com";
    const authHeader = process.env.KYEW_CONN_MYAPI_AUTH_HEADER;

    // Build the full URL
    const url = endpoint.startsWith("http")
      ? endpoint
      : `${baseUrl.replace(/\/$/, "")}${endpoint.startsWith("/") ? "" : "/"}${endpoint}`;

    // Build request options
    const options = {
      method,
      headers: {
        Authorization: authHeader,
        "Content-Type": "application/json",
        Accept: "application/json",
        ...headers,
      },
    };

    if (body && ["POST", "PUT", "PATCH"].includes(method)) {
      options.body = JSON.stringify(body);
    }

    const res = await fetch(url, options);
    const contentType = res.headers.get("content-type") || "";

    let data;
    if (contentType.includes("application/json")) {
      data = await res.json();
    } else {
      data = await res.text();
    }

    if (!res.ok) {
      return Response.json({
        error: true,
        status: res.status,
        status_text: res.statusText,
        data,
      }, { status: res.status });
    }

    // Summarize arrays for readability
    const summary = Array.isArray(data)
      ? { count: data.length, items: data.slice(0, 20) }
      : data;

    return Response.json({
      status: res.status,
      url,
      method,
      data: summary,
    });
  },
};

Test It

tool(action="test",
  tool_id="YOUR_TOOL_ID",
  test_input={
    endpoint: "/users/me",
    method: "GET"
  })

Customize

  • Specific API: Replace the generic endpoint input with named parameters like user_id, query, or date_range and hardcode the URL pattern. This makes the tool easier for the AI to use correctly.
  • Pagination: Add a loop that follows next links or increments page until all results are collected.
  • Response mapping: Instead of returning raw API data, extract and rename the fields you care about so the AI gets a clean, consistent shape.
  • Rate limiting: Add a delay between paginated requests with await new Promise(r => setTimeout(r, 200)).

Adapt this template

This is intentionally generic. For production use, narrow the input_schema to your specific API's parameters and hardcode the URL patterns. A tool that takes user_id is more useful to the AI than one that takes a raw endpoint string.


4. Expense Tracker

A full CRUD application backed by per-user SQLite. Demonstrates structured data management with multiple actions, categories, and CSV export.

Prerequisites

None. Uses the built-in per-user SQLite database.

Create the Tool

tool(action="create",
  name="expenses",
  description="Track expenses with categories, search, and export",
  tool_type="code",
  input_schema={
    type: "object",
    properties: {
      action: {
        type: "string",
        enum: ["create", "list", "get", "update", "delete", "export", "categories"],
        description: "Which operation to perform"
      },
      id: { type: "number", description: "Expense ID (for get, update, delete)" },
      date: { type: "string", description: "Date in YYYY-MM-DD format" },
      vendor: { type: "string", description: "Who was paid" },
      amount: { type: "number", description: "Amount spent" },
      currency: { type: "string", description: "Currency code (default USD)" },
      category: { type: "string", description: "Expense category" },
      notes: { type: "string", description: "Optional notes" },
      start_date: { type: "string", description: "Filter: start date for list/export" },
      end_date: { type: "string", description: "Filter: end date for list/export" },
      limit: { type: "number", description: "Max results for list (default 50)" }
    },
    required: ["action"]
  },
  code_config={
    runtime: "javascript",
    db_name: "expenses",
    code: "... (see full handler below) ..."
  })

Full Handler Code

import { query, exec } from "./db.mjs";

export default {
  async fetch(request) {
    const input = await request.json();
    const { action } = input;

    // Ensure tables exist
    await exec(`CREATE TABLE IF NOT EXISTS expenses (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      date TEXT NOT NULL,
      vendor TEXT NOT NULL,
      amount REAL NOT NULL,
      currency TEXT DEFAULT 'USD',
      category TEXT,
      notes TEXT,
      created_at TEXT DEFAULT (datetime('now'))
    )`);

    switch (action) {
      case "create":
        return handleCreate(input);
      case "list":
        return handleList(input);
      case "get":
        return handleGet(input);
      case "update":
        return handleUpdate(input);
      case "delete":
        return handleDelete(input);
      case "export":
        return handleExport(input);
      case "categories":
        return handleCategories();
      default:
        return Response.json({ error: `Unknown action: ${action}` }, { status: 400 });
    }
  },
};

async function handleCreate({ date, vendor, amount, currency = "USD", category, notes }) {
  if (!date || !vendor || amount === undefined) {
    return Response.json(
      { error: "date, vendor, and amount are required" },
      { status: 400 }
    );
  }
  await query(
    "INSERT INTO expenses (date, vendor, amount, currency, category, notes) VALUES (?, ?, ?, ?, ?, ?)",
    date, vendor, amount, currency, category || null, notes || null
  );
  const result = await query(
    "SELECT * FROM expenses ORDER BY id DESC LIMIT 1"
  );
  return Response.json({ created: result.rows[0] });
}

async function handleList({ start_date, end_date, category, limit = 50 }) {
  let sql = "SELECT * FROM expenses WHERE 1=1";
  const params = [];

  if (start_date) {
    sql += " AND date >= ?";
    params.push(start_date);
  }
  if (end_date) {
    sql += " AND date <= ?";
    params.push(end_date);
  }
  if (category) {
    sql += " AND category = ?";
    params.push(category);
  }

  sql += " ORDER BY date DESC LIMIT ?";
  params.push(limit);

  const result = await query(sql, ...params);

  // Calculate totals
  let totalSql = "SELECT SUM(amount) as total, COUNT(*) as count FROM expenses WHERE 1=1";
  const totalParams = [];
  if (start_date) {
    totalSql += " AND date >= ?";
    totalParams.push(start_date);
  }
  if (end_date) {
    totalSql += " AND date <= ?";
    totalParams.push(end_date);
  }
  if (category) {
    totalSql += " AND category = ?";
    totalParams.push(category);
  }

  const totals = await query(totalSql, ...totalParams);

  return Response.json({
    expenses: result.rows,
    total: totals.rows[0]?.total || 0,
    count: totals.rows[0]?.count || 0,
  });
}

async function handleGet({ id }) {
  if (!id) {
    return Response.json({ error: "id is required" }, { status: 400 });
  }
  const result = await query("SELECT * FROM expenses WHERE id = ?", id);
  if (result.rows.length === 0) {
    return Response.json({ error: "Expense not found" }, { status: 404 });
  }
  return Response.json({ expense: result.rows[0] });
}

async function handleUpdate({ id, date, vendor, amount, currency, category, notes }) {
  if (!id) {
    return Response.json({ error: "id is required" }, { status: 400 });
  }
  const fields = [];
  const params = [];

  if (date !== undefined) { fields.push("date = ?"); params.push(date); }
  if (vendor !== undefined) { fields.push("vendor = ?"); params.push(vendor); }
  if (amount !== undefined) { fields.push("amount = ?"); params.push(amount); }
  if (currency !== undefined) { fields.push("currency = ?"); params.push(currency); }
  if (category !== undefined) { fields.push("category = ?"); params.push(category); }
  if (notes !== undefined) { fields.push("notes = ?"); params.push(notes); }

  if (fields.length === 0) {
    return Response.json({ error: "No fields to update" }, { status: 400 });
  }

  params.push(id);
  await query(`UPDATE expenses SET ${fields.join(", ")} WHERE id = ?`, ...params);

  const result = await query("SELECT * FROM expenses WHERE id = ?", id);
  return Response.json({ updated: result.rows[0] });
}

async function handleDelete({ id }) {
  if (!id) {
    return Response.json({ error: "id is required" }, { status: 400 });
  }
  const existing = await query("SELECT * FROM expenses WHERE id = ?", id);
  if (existing.rows.length === 0) {
    return Response.json({ error: "Expense not found" }, { status: 404 });
  }
  await query("DELETE FROM expenses WHERE id = ?", id);
  return Response.json({ deleted: existing.rows[0] });
}

async function handleExport({ start_date, end_date, category }) {
  let sql = "SELECT * FROM expenses WHERE 1=1";
  const params = [];

  if (start_date) { sql += " AND date >= ?"; params.push(start_date); }
  if (end_date) { sql += " AND date <= ?"; params.push(end_date); }
  if (category) { sql += " AND category = ?"; params.push(category); }

  sql += " ORDER BY date ASC";
  const result = await query(sql, ...params);

  // Build CSV
  const header = "id,date,vendor,amount,currency,category,notes";
  const rows = result.rows.map((r) =>
    [r.id, r.date, `"${(r.vendor || "").replace(/"/g, '""')}"`, r.amount,
     r.currency, r.category || "", `"${(r.notes || "").replace(/"/g, '""')}"`].join(",")
  );
  const csv = [header, ...rows].join("\n");

  // Also compute category breakdown
  const byCategory = {};
  for (const r of result.rows) {
    const cat = r.category || "Uncategorized";
    byCategory[cat] = (byCategory[cat] || 0) + r.amount;
  }

  return Response.json({
    csv,
    row_count: result.rows.length,
    total: result.rows.reduce((sum, r) => sum + r.amount, 0),
    by_category: byCategory,
  });
}

async function handleCategories() {
  const result = await query(
    "SELECT category, COUNT(*) as count, SUM(amount) as total FROM expenses WHERE category IS NOT NULL GROUP BY category ORDER BY total DESC"
  );
  return Response.json({ categories: result.rows });
}

Test It

tool(action="test",
  tool_id="YOUR_TOOL_ID",
  test_input={
    action: "create",
    date: "2026-03-20",
    vendor: "AWS",
    amount: 47.50,
    category: "Infrastructure",
    notes: "March compute charges"
  })

Then list your expenses:

tool(action="test",
  tool_id="YOUR_TOOL_ID",
  test_input={ action: "list", start_date: "2026-03-01" })

Customize

  • Budget alerts: Add a budget action that compares spending against monthly limits per category.
  • Recurring expenses: Add a recurring flag and a scheduled run that auto-creates entries on the 1st of each month.
  • Multi-currency: Store amounts in the original currency and add an exchange rate column. Normalize to a base currency for totals.
  • Receipts: Add a receipt_url column and use Kyew's file upload to attach receipt images.

Named databases

This template uses db_name: "expenses" to keep expense data separate from other tools. Any tool with the same db_name shares the database, so you could build a reporting tool that reads from the same expense data.


5. Scheduled Digest with Email

Aggregate data from SQLite and send a formatted HTML email on a schedule. This template reads from any existing database (like the RSS monitor or expense tracker above) and produces an email summary.

Prerequisites

A data source. This example reads from the rss_seen table created by Template 2, but the pattern works with any SQLite data.

Create the Tool

tool(action="create",
  name="daily_digest",
  description="Build an HTML email digest from stored data",
  tool_type="code",
  input_schema={
    type: "object",
    properties: {
      hours_back: {
        type: "number",
        description: "How many hours of data to include (default 24)"
      },
      title: {
        type: "string",
        description: "Digest title (default 'Daily Digest')"
      }
    }
  },
  code_config={
    runtime: "javascript",
    code: "... (see full handler below) ..."
  })

Full Handler Code

import { query } from "./db.mjs";

export default {
  async fetch(request) {
    const { hours_back = 24, title = "Daily Digest" } = await request.json();

    const since = new Date(Date.now() - hours_back * 3600000).toISOString();
    const now = new Date().toISOString().split("T")[0];

    // Collect data from different sources
    const sections = [];

    // Section 1: New RSS items (from rss_monitor tool)
    try {
      const rssResult = await query(
        "SELECT feed_url, title, link, pub_date FROM rss_seen WHERE seen_at >= ? ORDER BY seen_at DESC",
        since
      );
      if (rssResult.rows.length > 0) {
        const byFeed = {};
        for (const row of rssResult.rows) {
          const feed = row.feed_url || "Unknown";
          if (!byFeed[feed]) byFeed[feed] = [];
          byFeed[feed].push(row);
        }

        let rssHtml = "<h3>New Articles</h3>";
        for (const [feed, items] of Object.entries(byFeed)) {
          const feedName = new URL(feed).hostname;
          rssHtml += `<h4>${feedName} (${items.length})</h4><ul>`;
          for (const item of items.slice(0, 10)) {
            rssHtml += `<li><a href="${item.link}">${item.title || "Untitled"}</a></li>`;
          }
          if (items.length > 10) {
            rssHtml += `<li><em>...and ${items.length - 10} more</em></li>`;
          }
          rssHtml += "</ul>";
        }
        sections.push({ name: "RSS", count: rssResult.rows.length, html: rssHtml });
      }
    } catch (e) {
      // Table doesn't exist yet — skip this section
    }

    // Section 2: Recent expenses (from expenses tool)
    try {
      const expResult = await query(
        "SELECT date, vendor, amount, currency, category FROM expenses WHERE created_at >= ? ORDER BY date DESC",
        since
      );
      if (expResult.rows.length > 0) {
        const total = expResult.rows.reduce((sum, r) => sum + r.amount, 0);
        let expHtml = `<h3>Expenses (${expResult.rows.length} new, $${total.toFixed(2)} total)</h3>`;
        expHtml += `<table style="border-collapse: collapse; width: 100%;">
          <thead><tr style="border-bottom: 2px solid #e5e7eb;">
            <th style="text-align: left; padding: 6px;">Date</th>
            <th style="text-align: left; padding: 6px;">Vendor</th>
            <th style="text-align: right; padding: 6px;">Amount</th>
            <th style="text-align: left; padding: 6px;">Category</th>
          </tr></thead><tbody>`;
        for (const r of expResult.rows) {
          expHtml += `<tr style="border-bottom: 1px solid #f3f4f6;">
            <td style="padding: 6px;">${r.date}</td>
            <td style="padding: 6px;">${r.vendor}</td>
            <td style="padding: 6px; text-align: right;">${r.currency} ${r.amount.toFixed(2)}</td>
            <td style="padding: 6px;">${r.category || "-"}</td>
          </tr>`;
        }
        expHtml += "</tbody></table>";
        sections.push({ name: "Expenses", count: expResult.rows.length, html: expHtml });
      }
    } catch (e) {
      // Table doesn't exist yet — skip this section
    }

    // Build the full email
    const summaryCards = sections.map((s) =>
      `<td style="padding: 12px; background: #f0f9ff; border-radius: 8px; text-align: center;">
        <div style="font-size: 24px; font-weight: bold;">${s.count}</div>
        <div style="color: #666;">${s.name}</div>
      </td>`
    ).join("");

    const emailHtml = `
      <h2>${title}</h2>
      <p style="color: #666;">${now} &mdash; Last ${hours_back} hours</p>
      ${sections.length > 0 ? `
        <table style="border-collapse: collapse; width: 100%;"><tr>${summaryCards}</tr></table>
        <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
        ${sections.map((s) => s.html).join('<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">')}
      ` : "<p>No new activity in the last " + hours_back + " hours.</p>"}
    `;

    return Response.json({
      title,
      period_hours: hours_back,
      sections: sections.map((s) => ({ name: s.name, count: s.count })),
      _email: emailHtml,
    });
  },
};

The _email property

Any property starting with _email in your tool's response becomes the email body when scheduling is enabled. Kyew renders it as HTML, so use tables, inline styles, and links freely. See the Scheduling docs for the full email delivery reference.

Test It

tool(action="test",
  tool_id="YOUR_TOOL_ID",
  test_input={ hours_back: 48, title: "Test Digest" })

If you haven't run the RSS monitor or expense tracker yet, the digest will return an empty state. That's expected — the try/catch blocks handle missing tables gracefully.

Activate and Schedule

Move the tool to active, then set a daily schedule with email delivery:

tool(action="update", tool_id="YOUR_TOOL_ID", status="active")
tool(action="schedule",
  tool_id="YOUR_TOOL_ID",
  schedule={
    enabled: true,
    hours: [8],
    timezone: "America/New_York",
    email_summary: true,
    args: {
      hours_back: 24,
      title: "Daily Digest"
    }
  })

Customize

  • Add more sections: Follow the same pattern — try a query against any table, build HTML if rows exist, push to sections. Each tool that writes to SQLite is automatically available as a digest source.
  • Multiple schedules: Run a brief morning digest at 8am and a full weekly summary on Mondays at 9am by creating two separate tools with different hours_back values.
  • Additional recipients: Add email_recipients: "[email protected]" to the schedule to send to your whole team.
  • Pair with any data tool: The RSS monitor, expense tracker, or any tool that writes to SQLite can feed into this digest. The try/catch pattern means the digest works regardless of which tools you've set up.

Next Steps

  • Code Tools — Full reference for the code tool runtime, sandbox, and security model.
  • Connections — All auth types and environment variable injection details.
  • Use a Database in Code Tools — Deep dive on db.mjs, batch operations, and named databases.
  • Build a Scheduled Report — Step-by-step guide for scheduling and email delivery.
  • Scheduling — All scheduling options including multiple run times and recipients.
Previous
Build a Scheduled Report