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-Typetotext/html; charset=utf-8and pass HTML inbody. - CC/BCC: Add
Cc:andBcc: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
threadIdto the request body and include anIn-Reply-Toheader 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
keywordsinput parameter and filter items by title/description match before returning them. - Separate databases: Set
db_name: "rss"incode_configto isolate RSS data from other tools. - Email digest: Add a
_emailproperty 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
categorycolumn 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
endpointinput with named parameters likeuser_id,query, ordate_rangeand hardcode the URL pattern. This makes the tool easier for the AI to use correctly. - Pagination: Add a loop that follows
nextlinks or incrementspageuntil 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
budgetaction that compares spending against monthly limits per category. - Recurring expenses: Add a
recurringflag 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_urlcolumn 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} — 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 —
trya query against any table, build HTML if rows exist, push tosections. 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_backvalues. - 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/catchpattern 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.