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:

// full chart
<Chart query="category:stripe-daily-revenue type:series sort:oldest limit:30" chartType="line" label="Daily Revenue" format="currency" />
// sparkline from aggregate
<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

  1. Pipeline — downstream steps receive object.data with typed items
  2. Blocks<List from="/stripe payments" extract="data" /> renders items
  3. from= queries — blocks that call actions live get structured data back
  4. 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:

  1. First line: count + brief context
  2. Optional second line: 1-2 key stats or highlights
  3. 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 // human-readable summary
items: AppResultDataItem[] // structured data items
count: number // always items.length
meta?: Record<string, unknown> // aggregates, totals, breakdowns
}

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, // still works for pipeline access via #tag.revenue
data: result.items, // structured items for UI and blocks
}

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(),
},
)
}
// persist current count as a stat
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`)
}
},
}