Writing Data

Store and manage data from app backends

App backends write data using tools.data. There are two storage scopes — public for data shared across the server, and private for data only the app install can see.

Operations

set(key, value, meta?)

Store or update a key-value pair. The value is always a string; use JSON.stringify for complex data.

await tools.data.public.set(`customer:${customer.id}`, JSON.stringify(customer), {
title: customer.name,
kind: 'customer',
tags: ['active'],
status: customer.active ? 'active' : 'inactive',
})

get(key)

Retrieve a single entry by key. Returns null if not found.

const item = await tools.data.public.get('weather')

getAll()

List all entries for the current scope.

const all = await tools.data.public.getAll()

delete(key)

Remove an entry.

await tools.data.public.delete('old-data')

stat(key, value, meta?)

Store a point-in-time metric. Calling stat with the same key overwrites the previous value — use it for current counts, gauges, and snapshots that reflect the latest state.

await tools.data.public.stat('stripe-mrr', { value: 48200 })

Stats use the {app}-{metric} naming convention:

stripe-mrr stripe-customer-count stripe-active-subscriptions github-open-issues github-open-prs sentry-unresolved-count posthog-dau linear-open-issues

Under the hood, stat creates a row with type: "stat" and the value as a JSON object. Blocks query it directly:

<Currency query="stripe-mrr" label="MRR" />

For multi-field stats, pass an object with all the metrics:

await tools.data.public.stat('github-stats', {
openIssues: 42,
openPRs: 7,
contributors: 128,
})
// blocks extract individual fields
<Number query="github-stats" extract="openIssues" label="Open Issues" />
<Number query="github-stats" extract="openPRs" label="Open PRs" />

series(name, value, options?)

Store a time-series data point with automatic aggregation. Each call creates a period-keyed entry and updates running aggregates (sum, count, min, max, avg) plus a rolling points array (up to 30 values) on the aggregate row.

await tools.data.public.series('stripe-daily-revenue', { value: 1420 })

Series use the same {app}-{metric} naming convention as stats:

stripe-daily-revenue stripe-daily-payments github-issues-activity github-prs-activity sentry-error-count posthog-active-users cf-daily-requests cf-daily-bandwidth

How series storage works

Each series() call creates two rows:

  1. Aggregate row: key=name, type="series-agg" — running sum, count, min, max, avg, and a points array
  2. Period row: key=name:suffix, type="series", category=name — the individual data point
await tools.data.public.series('stripe-daily-revenue', { value: 1420 })
// creates:
// key="stripe-daily-revenue", type="series-agg", value={sum, avg, points:[...]}
// key="stripe-daily-revenue:2026-03-07", type="series", category="stripe-daily-revenue"

The period option controls bucketing: 'hourly', 'daily' (default), 'weekly', or 'monthly'. Within each period, updating the same series replaces the value and recalculates aggregates.

Querying series in blocks

// full chart from individual series entries
<Chart query="category:stripe-daily-revenue type:series sort:oldest limit:30" chartType="line" label="Daily Revenue" format="currency" />
// sparkline from the aggregate row (single row, no query needed)
<Chart />

Multi-field series

For metrics with multiple values per point:

await tools.data.public.series('github-issues-activity', {
opened: 5,
closed: 3,
})
// blocks extract multiple fields
<Chart query="category:github-issues-activity type:series sort:oldest limit:30" extract={["opened", "closed"]} chartType="bar" label="Issues Activity" />

Metadata

The optional meta parameter on set accepts:

FieldTypeDescription
titlestringHuman-readable title
kindstringData kind for filtering (e.g., customer)
tagsstring[]Tags for grouping
statusstringCurrent status
weightnumberPriority or importance score
formatstringValue format: text, json, or markdown
urlstringLink to external resource
extraobjectArbitrary additional metadata
aiVisibilitystringcontext, searchable (default), or hidden
scopestringserver (default), channel, or user
startsAtnumberEvent start time
endsAtnumberEvent end time
externalIdstringID in the external system
syncedAtnumberLast sync timestamp

Public vs private

Public data is visible to all server members. Use it for synced records, shared dashboards, and anything that should trigger flows.

await tools.data.public.set('latest-payment', paymentJson, {
title: `$${amount} from ${customer}`,
kind: 'payment',
})

Private data is scoped to the app install. Use it for sync cursors, OAuth tokens, and internal state.

await tools.data.private.set('last-sync-cursor', lastId)
const cursor = await tools.data.private.get('last-sync-cursor')

AI visibility

Control how the AI assistant sees your data:

  • searchable (default) — discoverable when the assistant searches
  • context — automatically loaded into every AI conversation
  • hidden — invisible to the assistant entirely
await tools.data.public.set(key, content, {
kind: 'memory',
tags: ['preference'],
aiVisibility: 'context',
scope: 'user',
})

Key naming conventions

Use the {app}-{metric} convention for stat and series keys. For individual records, use {app}-{type}:{external-id}:

// stat and series keys
'stripe-mrr'
'stripe-daily-revenue'
'github-open-issues'
// individual record keys
`stripe-customer:${customer.id}``github-issue:${issue.number}``sentry-error:${event.id}`

This convention ensures blocks can query known keys without configuration and keeps keys predictable across apps.

Triggering flows

When public data is created, it can trigger event-based flows. Flows configured with on app.data run automatically:

on stripe.data => check if amount > 1000 => /gmail alert @cathy