Patterns
Standard patterns for stats, series, data items, and summaries
These patterns cover the standard ways apps produce and store data. Each pattern
includes the naming convention, storage method, and how blocks consume it.
Stat pattern
Stats are point-in-time snapshots — current values that get overwritten on each
sync. Use tools.data.public.stat() with a {app}-{metric} key.
export const backend = {
runsEvery: '15 minutes',
async run({ tools, oauthToken }) {
const res = await fetch('https://api.stripe.com/v1/balance', {
headers: { Authorization: `Bearer ${oauthToken}` },
})
const balance = await res.json()
const available = balance.available[0].amount / 100
await tools.data.public.stat('stripe-mrr', { value: available })
},
}
Blocks query stats by key:
<Currency query="stripe-mrr" label="MRR" />
Common stat keys
stripe-mrr
stripe-customer-count
stripe-active-subscriptions
github-open-issues
github-open-prs
sentry-unresolved-count
posthog-dau
linear-open-issues
cloudflare-requests
railway-status
Series pattern
Series track values over time with automatic aggregation. Use
tools.data.public.series() with the same {app}-{metric} naming convention.
export const backend = {
runsEvery: '1 day',
async run({ tools, oauthToken }) {
const res = await fetch('https://api.stripe.com/v1/charges?limit=100', {
headers: { Authorization: `Bearer ${oauthToken}` },
})
const { data: charges } = await res.json()
const total = charges.reduce((s, c) => s + c.amount, 0) / 100
await tools.data.public.series('stripe-daily-revenue', { value: total })
},
}
Each call creates two rows — a period entry and an aggregate with running
stats. Blocks query the series entries for charts, or the aggregate for
sparklines:
<Chart
query="category:stripe-daily-revenue type:series sort:oldest limit:30"
chartType="line"
label="Daily Revenue"
format="currency"
/>
<Chart />
Common series names
stripe-daily-revenue
stripe-daily-payments
github-issues-activity
github-prs-activity
sentry-error-count
posthog-active-users
cf-daily-requests
cf-daily-bandwidth
railway-deploy-health
Data item pattern
Data items are individual records from external systems. Use
tools.data.public.set() with {app}-{type}:{external-id} keys.
for (const customer of customers) {
await tools.data.public.set(
`stripe-customer:${customer.id}`,
JSON.stringify(customer),
{
title: customer.name || customer.email,
kind: 'customer',
tags: [customer.delinquent ? 'delinquent' : 'active'],
externalId: customer.id,
syncedAt: Date.now(),
},
)
}
Group related items with a shared category for querying:
stripe-customer:cus_abc123 category: "stripe-customers"
stripe-txn:pi_xyz789 category: "recent-transactions"
github-issue:12345 category: "github-issues"
sentry-error:evt_123 category: "recent-errors"
Action return data
Actions return AppResultDataItem[] as their primary structured output. Use
strategy.data() to map raw API responses into the standardized item shape:
async run({ tools, input }) {
const raw = await stripeAPI('/charges', params)
const result = await tools.strategy.data({
data: raw,
task: 'stripe payments',
fields: ['amount', 'customer', 'status'],
})
return { summary: result.summary, data: result.items }
}
The generated mapping produces items with well-defined fields:
{
type: 'payment',
key: 'stripe-payment:ch_abc',
value: { amount: 420, email: 'alice@co.com', status: 'succeeded' },
title: 'alice@co.com - $420',
status: 'succeeded',
url: 'https://dashboard.stripe.com/payments/ch_abc',
}
Returning data does not auto-insert into the data table. It provides a
structured view for the pipeline and blocks. Persistence is explicit via
stat(), series(), or set().
Where data items get used
- Pipeline — downstream steps receive
object.data with typed items
- Blocks —
<List from="/stripe payments" extract="data" /> renders items
from= queries — blocks that call actions live get structured data back
- Search — if the app also persists items via
set(), they are searchable
Summary format
The summary field on action results follows a consistent convention:
Found 25 payments totaling $3,420.
Peak hours: 8-10am (40%). Top region: Northwest (35%).
- Levi's 505 Jeans — 5 sold, ~$125
- Nike Air Max 90 — 4 sold, ~$89
- Patagonia Fleece — 3 sold, ~$145
The format:
- First line: count + brief context
- Optional second line: 1-2 key stats or highlights
- Max 3 preview items as a markdown list, each with 3-4 key fields
This format is a convention, not enforced — but it means downstream steps get a
predictable shape. LLM steps read the summary string for reasoning. App steps
read the structured items and meta for deterministic manipulation.
The strategy.data() result shape
strategy.data() returns a standardized object:
{
summary: string
items: AppResultDataItem[]
count: number
meta?: Record<string, unknown>
}
This shape flows naturally into the action return contract:
const result = await tools.strategy.data({ data: raw, task: 'stripe payments' })
return {
summary: result.summary,
revenue: result.meta?.total,
data: result.items,
}
Full sync
Sync all records from an external API on a schedule:
export const backend = {
runsEvery: '30 minutes',
async run({ tools, oauthToken }) {
const res = await fetch('https://api.stripe.com/v1/customers?limit=100', {
headers: { Authorization: `Bearer ${oauthToken}` },
})
const { data: customers } = await res.json()
for (const customer of customers) {
await tools.data.public.set(
`stripe-customer:${customer.id}`,
JSON.stringify(customer),
{
title: customer.name || customer.email,
kind: 'customer',
tags: [customer.delinquent ? 'delinquent' : 'active'],
externalId: customer.id,
syncedAt: Date.now(),
},
)
}
await tools.data.public.stat('stripe-customer-count', { value: customers.length })
tools.notify(`Synced ${customers.length} customers`)
},
}
Incremental sync
Only fetch new records since the last run using a private cursor:
export const backend = {
runsEvery: '5 minutes',
async run({ tools }) {
const lastSync = await tools.data.private.get('sync-cursor')
const cursor = lastSync?.value
const url = cursor
? `https://api.example.com/events?after=${cursor}`
: 'https://api.example.com/events'
const res = await fetch(url)
const events = await res.json()
for (const event of events) {
await tools.data.public.set(`app-event:${event.id}`, JSON.stringify(event), {
title: event.name,
kind: 'event',
tags: [event.type],
})
}
if (events.length > 0) {
await tools.data.private.set('sync-cursor', events.at(-1).id)
}
},
}
Health monitoring
Track service status and alert on issues:
export const backend = {
runsEvery: '5 minutes',
async run({ tools }) {
const services = ['api', 'database', 'cache']
const results = []
for (const service of services) {
try {
const res = await fetch(`https://status.example.com/${service}`)
const data = await res.json()
results.push({ service, status: data.status, latency: data.latency })
} catch (err) {
results.push({ service, status: 'error', error: err.message })
}
}
const hasErrors = results.some((r) => r.status === 'error')
await tools.data.public.set('health:current', JSON.stringify(results), {
title: hasErrors ? 'Issues Detected' : 'All Systems Operational',
kind: 'health',
status: hasErrors ? 'error' : 'ok',
})
if (hasErrors) {
tools.notify('Service health issues detected', 'error')
}
},
}
Cleanup
Remove stale data periodically:
export const backend = {
runsEvery: '1 day',
async run({ tools }) {
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000
const all = await tools.data.public.getAll()
let deleted = 0
for (const item of all) {
if (item.kind === 'event' && item.createdAt < thirtyDaysAgo) {
await tools.data.public.delete(item.key)
deleted++
}
}
if (deleted > 0) {
tools.notify(`Cleaned up ${deleted} old events`)
}
},
}