Data Programs
A data program is a named, stored, agent-authored JS script that fetches,
joins, filters, or aggregates provider or app data, and is rendered by a
dashboard panel. It is the generic answer to "I can query this once in chat —
now make it a panel other people can see, that refreshes on its own."
There is no new credential machinery here. A data program is exactly a
run-code script (same providerFetch / providerFetchAll /
providerSearchAll / appAction / workspace* globals you already use for
ad-hoc analysis) plus two things: a stored, named identity, and a small
emit(rows, schema) contract so the result can be cached and charted. Read
provider-api first if you have not already — data programs assume you know
provider-api-catalog / provider-api-docs / provider-api-request.
When to reach for this vs. a canned action
- The common case (won/lost by pipeline, a standard date-range rollup) already
has a first-class action (
hubspot-deals, etc.) — use that.
- A one-time chat question — just query it with
run-code or
provider-api-request and answer directly. Don't save a program for
something you'll never look at again.
- A cohort needs an arbitrary property filter, an IN-search over custom
values, or a join against a second provider, and the user (or a
dashboard) will want to see it again with fresh data — that's a data
program.
Authoring workflow
- Prototype in chat first. Write the fetch/join/aggregate logic as a
normal
run-code script and confirm it returns the rows you expect.
- Wrap it to call
emit(rows, schema) exactly once at the end instead of
returning/printing the result (see contract below).
- Save it with
save-data-program:save-data-program(
name: "risk-meeting-cohort", // stable slug, unique per your account
title: "Risk Meeting — HubSpot cohort",
description: "...",
code: "<the run-code script text>",
defaultParams: { riskStatuses: ["Churn Risk"] }, // optional
refreshMode: "ttl", // "ttl" | "manual"
refreshTtlMs: 900000, // optional, floor 60000
background: false, // true for slow multi-minute joins
)
This dry-runs the code with defaultParams before persisting and
rejects the save with a structured error if it fails — a broken program is
never stored. On success it returns
{ programId, rowCount, columns, sampleRows }: use that as your proof the
program actually produces the rows you expect before telling the user it's
ready.
- Bind a dashboard panel to it. Panels reference programs through the
"program" source; the panel's sql field carries a JSON descriptor
instead of a query string:{ "programId": "dp_...", "params": { "riskStatuses": ["Churn Risk"] } }
The panel renders through the exact same chart/table components as every
other panel source — it only ever receives { rows, schema }.
- Iterate without re-saving with
preview-data-program(code, params?) —
same dry-run path, no persisted row. Use this while tuning a script before
calling save-data-program again.
- Refresh on demand with
run-data-program(programId, params?, forceRefresh?, includeRows?) — this is what the agent calls to get a
compact proof-of-result (rowCount, columns, sampleRows) without
waiting for a panel view. Pass includeRows: true only when you need the
full (capped) row set back in context.
The sandbox surface
Inside a data program you get exactly the run-code globals, plus one
addition:
providerFetch(provider, path, init?), providerFetchAll(...),
providerSearchAll(...) — the same generic provider-access helpers used for
ad-hoc analysis. See provider-api for the pagination/search option shapes.
appAction(name, params), webFetch, workspace* Resources helpers — same
as run-code.
params — a frozen global object: the params passed into this run
(defaultParams merged by the caller, or whatever was passed to
run-data-program / the panel descriptor). Read it, never mutate it.
emit(rows, schema?) — call this exactly once, at the end, with your
result. A second call throws. schema is optional —
{ name: string, type: string }[] — inferred automatically from the first
50 rows when omitted (a column is "json" if rows disagree on primitive
type, otherwise "number" / "string" / "boolean"). console.log remains
completely free for debugging; it never interferes with emit() parsing and
is captured separately as a truncated log tail on the run record.
Caps enforced on every run (not configurable per-program):
| Limit |
Value |
| Max emitted rows |
10,000 (rows beyond this are dropped, truncated: true) |
| Max emitted result size |
4 MiB (rows dropped from the end to fit; a single row over 4 MiB is a hard result_too_large failure — nothing to safely keep) |
| Max active programs per app |
200 (archive unused ones to free room) |
| Minimum refresh TTL |
60,000 ms |
| Run rows kept per (program, params) |
5 most recent (older ones are pruned automatically) |
Truncation is always honest: a partial result comes back with
truncated: true, never a silent drop.
Caching and refresh model
Every (programId, paramsHash) pair (params are canonicalized and hashed, so
equivalent param objects share a cache entry) has its own run history. What
happens on each call to runDataProgram / a panel view:
| Situation |
Behavior |
Fresh cache hit (younger than refreshTtlMs, or refreshMode: "manual" with any prior success) |
Returns cached rows instantly, cacheHit: true |
| Cache is older than the TTL |
Re-executes synchronously (panel views get a 25s budget; agent/manual calls get 120s), replacing the cache on success |
refreshMode: "manual" |
Never auto-refreshes; only forceRefresh: true (or manual_refresh from the UI) re-runs it |
| No cache yet |
Executes synchronously like a normal first fetch |
background: true program, no fresh cache |
Serves the last good run immediately with stale: true and enqueues a durable background execution (10-minute budget); the next call finalizes it if it has completed |
| Background program, execution still running, no prior success |
background_pending failure — the panel shows an explicit "running in background" error card, never a blank chart |
Execution fails (timeout, sandbox error, bad emit() shape) |
Failure result with a structured error code; if a previous successful run exists, it is attached as lastGoodRun so the panel can stale-serve instead of going blank |
Program was archived (delete-data-program) |
Explicit archived failure — panels show "This data program was archived", never a silent blank |
Use background: true only for programs that routinely take longer than the
foreground budget (multi-provider joins over large cohorts, deep pagination).
Everything else should stay foreground — it is simpler to reason about and
serves fresh data faster.
Error codes
| Code |
Meaning |
What to do |
program_not_found |
No program with that id |
Check the id via list-data-programs |
access_denied |
Caller can't see this program |
Don't leak existence; ask the owner to share it |
archived |
Program was soft-deleted |
Repoint the panel or unarchive by resaving under the same name |
timeout |
Didn't finish within the run's budget |
Reduce scope, page in fewer items, or mark background: true |
emit_missing |
Script never called emit(...) |
Add the emit(rows, schema?) call at the end |
emit_shape_invalid |
emit() args weren't (array-of-plain-objects, optional-schema-array) |
Fix the shape; check for a stray second emit() call |
result_too_large |
A single row (or the whole result) exceeds the byte cap |
Trim large string fields before emitting |
sandbox_error |
Uncaught exception in the script |
Read the thrown message; guard optional provider fields |
run_code_unavailable |
The run-code module isn't available in this build |
Not something a program author can fix |
background_pending |
Background program has no result yet |
Wait and retry, or check lastGoodRun if present |
Every failure is a structured { code, message }, never a bare string — treat
code as the thing to branch on, message as the thing to show a human.
Security notes
- Credentials are always the viewer's, never the author's. When a shared
program's panel is viewed,
providerFetch inside it resolves auth using the
viewing user's own configured provider credentials — not the program
author's. A teammate missing a HubSpot key sees a HubSpot auth error on that
panel, not someone else's data.
- Only share programs you trust. Because execution uses the viewer's
credentials, only bind to or view a shared data program if you trust its
code — it runs with your own access, exactly like opening someone else's
extension.
- Data programs are org-internal only, never public. Unlike dashboards,
a data program can never be shared publicly — sharing is restricted to
members of your org. This is a hard invariant, not a default.
- Raw provider tokens never reach the model or the browser. The only thing
that ever leaves the sandbox is the
{ rows, schema } your emit() call
produces — the same trust boundary as every other dashboard panel source.
- The actions that execute or mutate stored code (
save-data-program,
preview-data-program, run-data-program, delete-data-program) are not
callable from the sandboxed extensions/iframe bridge — only from the agent
or the server-rendered panel path.
Worked example: Risk Meeting (HubSpot x Pylon)
This is the reference example installed by the ensure-risk-meeting-dashboard
action (templates/analytics/actions/ensure-risk-meeting-dashboard.ts), which
idempotently upserts both programs below plus a two-panel "Risk Meeting"
dashboard. It demonstrates the three capabilities a hardcoded action can't
anticipate for every customer: an arbitrary HubSpot property IN-search, a
batched multi-hop association join, and a join against a second, unrelated
provider.
risk-meeting-cohort.js — HubSpot deals in a configurable risk_status
cohort, resolved to their company domain, joined against Pylon account
sentiment by domain:
// params: { riskStatuses?: string[], enterpriseOnly?: boolean }
const DEFAULT_RISK_STATUSES = [
"On the Radar",
"Churn Risk",
"Confirmed Churn",
"No Save Attempted",
];
const riskStatuses =
Array.isArray(params.riskStatuses) && params.riskStatuses.length > 0
? params.riskStatuses
: DEFAULT_RISK_STATUSES;
const HIGH_RISK_SENTIMENTS = ["frustrated", "high_risk_detractor"];
function chunk(items, size) {
const out = [];
for (let i = 0; i < items.length; i += size)
out.push(items.slice(i, i + size));
return out;
}
async function main() {
// 1. Arbitrary property IN-search + open-close-date filter — not
// expressible through the canned hubspot-deals filters.
const dealSearch = await providerFetchAll(
"hubspot",
"/crm/v3/objects/deals/search",
{
method: "POST",
body: {
filterGroups: [
{
filters: [
{
propertyName: "risk_status",
operator: "IN",
values: riskStatuses,
},
{
propertyName: "closedate",
operator: "GT",
value: String(Date.now()),
},
],
},
],
properties: [
"dealname",
"risk_status",
"risk_summary",
"risk_category",
"hs_next_step",
"churn_notes",
"total_contract_value",
"customer_success_owner",
"dealstage",
"closedate",
],
limit: 100,
},
itemsPath: "results",
pagination: {
cursorBodyPath: "after",
nextCursorPath: "paging.next.after",
maxPages: 20,
},
},
);
const deals = dealSearch.items || [];
const dealIds = deals.map((d) => d && d.id).filter(Boolean);
// 2. Batched deal -> company association (100 per call). The raw
// association rows never leave this program.
const companyByDeal = {};
for (const batch of chunk(dealIds, 100)) {
if (batch.length === 0) continue;
const assoc = await providerFetch(
"hubspot",
"/crm/v3/associations/deals/companies/batch/read",
{ method: "POST", body: { inputs: batch.map((id) => ({ id })) } },
);
for (const r of (assoc && assoc.results) || []) {
const dealId = r && r.from && r.from.id;
const companyId =
r && Array.isArray(r.to) && r.to.length > 0 ? r.to[0].id : null;
if (dealId && companyId) companyByDeal[dealId] = companyId;
}
}
// 3. Batched company -> domain lookup.
const companyIds = Array.from(new Set(Object.values(companyByDeal))).filter(
Boolean,
);
const domainByCompany = {};
for (const batch of chunk(companyIds, 100)) {
if (batch.length === 0) continue;
const companies = await providerFetch(
"hubspot",
"/crm/v3/objects/companies/batch/read",
{
method: "POST",
body: { inputs: batch.map((id) => ({ id })), properties: ["domain"] },
},
);
for (const r of (companies && companies.results) || []) {
const domain =
r && r.properties && r.properties.domain
? String(r.properties.domain).toLowerCase()
: null;
if (r && r.id && domain) domainByCompany[r.id] = domain;
}
}
// 4. A second, unrelated provider — zero bespoke glue action required.
const pylonAccounts = await providerFetchAll("pylon", "/accounts", {
itemsPath: "data",
pagination: {
cursorParam: "cursor",
nextCursorPath: "pagination.cursor",
maxPages: 20,
},
});
const sentimentByDomain = {};
for (const account of pylonAccounts.items || []) {
if (!account) continue;
const domain =
typeof account.domain === "string" ? account.domain.toLowerCase() : null;
if (!domain) continue;
if (
params.enterpriseOnly &&
account.account_profile !== "Enterprise Active Customer"
)
continue;
if (account.general_sentiment)
sentimentByDomain[domain] = account.general_sentiment;
}
// 5. Join + emit. csm_name is computed HERE — never baked into a generic
// hubspot-deals action.
const rows = deals.map((deal) => {
const props = (deal && deal.properties) || {};
const dealId = deal && deal.id;
const companyId = dealId ? companyByDeal[dealId] : null;
const domain = companyId ? domainByCompany[companyId] : null;
const sentiment = domain ? sentimentByDomain[domain] || null : null;
return {
deal_id: dealId || null,
deal_name: props.dealname || null,
risk_status: props.risk_status || null,
risk_summary: props.risk_summary || null,
risk_category: props.risk_category || null,
next_step: props.hs_next_step || null,
churn_notes: props.churn_notes || null,
arr: Number(props.total_contract_value || 0),
csm_name: props.customer_success_owner || null,
dealstage: props.dealstage || null,
closedate: props.closedate || null,
domain: domain || null,
pylon_sentiment: sentiment,
cross_source_flag: Boolean(
domain && HIGH_RISK_SENTIMENTS.includes(sentiment),
),
};
});
emit(rows, [
{ name: "deal_id", type: "string" },
{ name: "deal_name", type: "string" },
{ name: "risk_status", type: "string" },
{ name: "risk_summary", type: "string" },
{ name: "risk_category", type: "string" },
{ name: "next_step", type: "string" },
{ name: "churn_notes", type: "string" },
{ name: "arr", type: "number" },
{ name: "csm_name", type: "string" },
{ name: "dealstage", type: "string" },
{ name: "closedate", type: "string" },
{ name: "domain", type: "string" },
{ name: "pylon_sentiment", type: "string" },
{ name: "cross_source_flag", type: "boolean" },
]);
}
await main();
risk-meeting-pylon-early-warning.js — the complementary "support signal
outrunning CRM signal" view: enterprise Pylon accounts already flagged at-risk
by sentiment that have not yet shown up in the HubSpot cohort above (a
same-shape HubSpot domain lookup, minus the Pylon domain set — see
templates/analytics/seeds/data-programs/risk-meeting-pylon-early-warning.js
for the full script). This is the third validation capability: excluding one
provider's cohort from another's, entirely inside one program.
Both programs are installed and kept up to date by
ensure-risk-meeting-dashboard — an idempotent action that upserts each
program by its stable name and a two-panel "Risk Meeting" dashboard whose
panels use source: "program". Installing the dashboard requires no
HubSpot/Pylon credentials up front; provider auth resolves per-viewer only
when a panel is actually rendered or run-data-program is called.
Related skills
provider-api — the catalog/docs/request/staging actions a data program's
providerFetch* calls are built on.
dashboard-management — creating and laying out the dashboard a program's
panel lives on.
cross-source-analysis — identity-stitching discipline (stable id + email,
de-duplication, provenance) for joins like the one above.
security — credential handling and access-scoping invariants this
primitive relies on.
1---2name: data-programs-23description: Save a run-code fetch/join/aggregate script as a stored, refreshable data source that dashboard panels bind to and render. Use when an ad-hoc provider-api-request or run-code analysis should become a live dashboard panel other users see, or when a cohort/join needs an arbitrary property filter, an IN-search, or a cross-provider stitch a canned action can't express.4---56# Data Programs78A **data program** is a named, stored, agent-authored JS script that fetches,9joins, filters, or aggregates provider or app data, and is rendered by a10dashboard panel. It is the generic answer to "I can query this once in chat —11now make it a panel other people can see, that refreshes on its own."1213There is no new credential machinery here. A data program is exactly a14`run-code` script (same `providerFetch` / `providerFetchAll` /15`providerSearchAll` / `appAction` / `workspace*` globals you already use for16ad-hoc analysis) plus two things: a stored, named identity, and a small17`emit(rows, schema)` contract so the result can be cached and charted. Read18`provider-api` first if you have not already — data programs assume you know19`provider-api-catalog` / `provider-api-docs` / `provider-api-request`.2021## When to reach for this vs. a canned action2223- The common case (won/lost by pipeline, a standard date-range rollup) already24 has a first-class action (`hubspot-deals`, etc.) — use that.25- A one-time chat question — just query it with `run-code` or26 `provider-api-request` and answer directly. Don't save a program for27 something you'll never look at again.28- A cohort needs an arbitrary property filter, an IN-search over custom29 values, or a join against a second provider, **and** the user (or a30 dashboard) will want to see it again with fresh data — that's a data31 program.3233## Authoring workflow34351. **Prototype in chat first.** Write the fetch/join/aggregate logic as a36 normal `run-code` script and confirm it returns the rows you expect.372. **Wrap it to call `emit(rows, schema)` exactly once** at the end instead of38 returning/printing the result (see contract below).393. **Save it** with `save-data-program`:40 ```41 save-data-program(42 name: "risk-meeting-cohort", // stable slug, unique per your account43 title: "Risk Meeting — HubSpot cohort",44 description: "...",45 code: "<the run-code script text>",46 defaultParams: { riskStatuses: ["Churn Risk"] }, // optional47 refreshMode: "ttl", // "ttl" | "manual"48 refreshTtlMs: 900000, // optional, floor 6000049 background: false, // true for slow multi-minute joins50 )51 ```52 This **dry-runs the code with `defaultParams` before persisting** and53 rejects the save with a structured error if it fails — a broken program is54 never stored. On success it returns55 `{ programId, rowCount, columns, sampleRows }`: use that as your proof the56 program actually produces the rows you expect before telling the user it's57 ready.584. **Bind a dashboard panel to it.** Panels reference programs through the59 `"program"` source; the panel's `sql` field carries a JSON descriptor60 instead of a query string:61 ```json62 { "programId": "dp_...", "params": { "riskStatuses": ["Churn Risk"] } }63 ```64 The panel renders through the exact same chart/table components as every65 other panel source — it only ever receives `{ rows, schema }`.665. **Iterate without re-saving** with `preview-data-program(code, params?)` —67 same dry-run path, no persisted row. Use this while tuning a script before68 calling `save-data-program` again.696. **Refresh on demand** with `run-data-program(programId, params?,70forceRefresh?, includeRows?)` — this is what the agent calls to get a71 compact proof-of-result (`rowCount`, `columns`, `sampleRows`) without72 waiting for a panel view. Pass `includeRows: true` only when you need the73 full (capped) row set back in context.7475## The sandbox surface7677Inside a data program you get exactly the `run-code` globals, plus one78addition:7980- `providerFetch(provider, path, init?)`, `providerFetchAll(...)`,81 `providerSearchAll(...)` — the same generic provider-access helpers used for82 ad-hoc analysis. See `provider-api` for the pagination/search option shapes.83- `appAction(name, params)`, `webFetch`, `workspace*` Resources helpers — same84 as `run-code`.85- `params` — a **frozen** global object: the params passed into this run86 (`defaultParams` merged by the caller, or whatever was passed to87 `run-data-program` / the panel descriptor). Read it, never mutate it.88- `emit(rows, schema?)` — call this **exactly once**, at the end, with your89 result. A second call throws. `schema` is optional —90 `{ name: string, type: string }[]` — inferred automatically from the first91 50 rows when omitted (a column is `"json"` if rows disagree on primitive92 type, otherwise `"number"` / `"string"` / `"boolean"`). `console.log` remains93 completely free for debugging; it never interferes with `emit()` parsing and94 is captured separately as a truncated log tail on the run record.9596Caps enforced on every run (not configurable per-program):9798| Limit | Value |99| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |100| Max emitted rows | 10,000 (rows beyond this are dropped, `truncated: true`) |101| Max emitted result size | 4 MiB (rows dropped from the end to fit; a single row over 4 MiB is a hard `result_too_large` failure — nothing to safely keep) |102| Max active programs per app | 200 (archive unused ones to free room) |103| Minimum refresh TTL | 60,000 ms |104| Run rows kept per (program, params) | 5 most recent (older ones are pruned automatically) |105106Truncation is always honest: a partial result comes back with107`truncated: true`, never a silent drop.108109## Caching and refresh model110111Every `(programId, paramsHash)` pair (params are canonicalized and hashed, so112equivalent param objects share a cache entry) has its own run history. What113happens on each call to `runDataProgram` / a panel view:114115| Situation | Behavior |116| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |117| Fresh cache hit (younger than `refreshTtlMs`, or `refreshMode: "manual"` with any prior success) | Returns cached rows instantly, `cacheHit: true` |118| Cache is older than the TTL | Re-executes synchronously (panel views get a 25s budget; agent/manual calls get 120s), replacing the cache on success |119| `refreshMode: "manual"` | Never auto-refreshes; only `forceRefresh: true` (or `manual_refresh` from the UI) re-runs it |120| No cache yet | Executes synchronously like a normal first fetch |121| `background: true` program, no fresh cache | Serves the last good run immediately with `stale: true` and enqueues a durable background execution (10-minute budget); the _next_ call finalizes it if it has completed |122| Background program, execution still running, no prior success | `background_pending` failure — the panel shows an explicit "running in background" error card, never a blank chart |123| Execution fails (timeout, sandbox error, bad `emit()` shape) | Failure result with a structured error code; if a previous successful run exists, it is attached as `lastGoodRun` so the panel can stale-serve instead of going blank |124| Program was archived (`delete-data-program`) | Explicit `archived` failure — panels show "This data program was archived", never a silent blank |125126Use `background: true` only for programs that routinely take longer than the127foreground budget (multi-provider joins over large cohorts, deep pagination).128Everything else should stay foreground — it is simpler to reason about and129serves fresh data faster.130131## Error codes132133| Code | Meaning | What to do |134| ---------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------- |135| `program_not_found` | No program with that id | Check the id via `list-data-programs` |136| `access_denied` | Caller can't see this program | Don't leak existence; ask the owner to share it |137| `archived` | Program was soft-deleted | Repoint the panel or unarchive by resaving under the same name |138| `timeout` | Didn't finish within the run's budget | Reduce scope, page in fewer items, or mark `background: true` |139| `emit_missing` | Script never called `emit(...)` | Add the `emit(rows, schema?)` call at the end |140| `emit_shape_invalid` | `emit()` args weren't `(array-of-plain-objects, optional-schema-array)` | Fix the shape; check for a stray second `emit()` call |141| `result_too_large` | A single row (or the whole result) exceeds the byte cap | Trim large string fields before emitting |142| `sandbox_error` | Uncaught exception in the script | Read the thrown message; guard optional provider fields |143| `run_code_unavailable` | The run-code module isn't available in this build | Not something a program author can fix |144| `background_pending` | Background program has no result yet | Wait and retry, or check `lastGoodRun` if present |145146Every failure is a structured `{ code, message }`, never a bare string — treat147`code` as the thing to branch on, `message` as the thing to show a human.148149## Security notes150151- **Credentials are always the viewer's, never the author's.** When a shared152 program's panel is viewed, `providerFetch` inside it resolves auth using the153 _viewing_ user's own configured provider credentials — not the program154 author's. A teammate missing a HubSpot key sees a HubSpot auth error on that155 panel, not someone else's data.156- **Only share programs you trust.** Because execution uses the viewer's157 credentials, only bind to or view a shared data program if you trust its158 code — it runs with your own access, exactly like opening someone else's159 extension.160- **Data programs are org-internal only, never public.** Unlike dashboards,161 a data program can never be shared publicly — sharing is restricted to162 members of your org. This is a hard invariant, not a default.163- Raw provider tokens never reach the model or the browser. The only thing164 that ever leaves the sandbox is the `{ rows, schema }` your `emit()` call165 produces — the same trust boundary as every other dashboard panel source.166- The actions that execute or mutate stored code (`save-data-program`,167 `preview-data-program`, `run-data-program`, `delete-data-program`) are not168 callable from the sandboxed extensions/iframe bridge — only from the agent169 or the server-rendered panel path.170171## Worked example: Risk Meeting (HubSpot x Pylon)172173This is the reference example installed by the `ensure-risk-meeting-dashboard`174action (`templates/analytics/actions/ensure-risk-meeting-dashboard.ts`), which175idempotently upserts both programs below plus a two-panel "Risk Meeting"176dashboard. It demonstrates the three capabilities a hardcoded action can't177anticipate for every customer: an arbitrary HubSpot property IN-search, a178batched multi-hop association join, and a join against a second, unrelated179provider.180181**`risk-meeting-cohort.js`** — HubSpot deals in a configurable `risk_status`182cohort, resolved to their company domain, joined against Pylon account183sentiment by domain:184185```js186// params: { riskStatuses?: string[], enterpriseOnly?: boolean }187const DEFAULT_RISK_STATUSES = [188 "On the Radar",189 "Churn Risk",190 "Confirmed Churn",191 "No Save Attempted",192];193const riskStatuses =194 Array.isArray(params.riskStatuses) && params.riskStatuses.length > 0195 ? params.riskStatuses196 : DEFAULT_RISK_STATUSES;197const HIGH_RISK_SENTIMENTS = ["frustrated", "high_risk_detractor"];198199function chunk(items, size) {200 const out = [];201 for (let i = 0; i < items.length; i += size)202 out.push(items.slice(i, i + size));203 return out;204}205206async function main() {207 // 1. Arbitrary property IN-search + open-close-date filter — not208 // expressible through the canned hubspot-deals filters.209 const dealSearch = await providerFetchAll(210 "hubspot",211 "/crm/v3/objects/deals/search",212 {213 method: "POST",214 body: {215 filterGroups: [216 {217 filters: [218 {219 propertyName: "risk_status",220 operator: "IN",221 values: riskStatuses,222 },223 {224 propertyName: "closedate",225 operator: "GT",226 value: String(Date.now()),227 },228 ],229 },230 ],231 properties: [232 "dealname",233 "risk_status",234 "risk_summary",235 "risk_category",236 "hs_next_step",237 "churn_notes",238 "total_contract_value",239 "customer_success_owner",240 "dealstage",241 "closedate",242 ],243 limit: 100,244 },245 itemsPath: "results",246 pagination: {247 cursorBodyPath: "after",248 nextCursorPath: "paging.next.after",249 maxPages: 20,250 },251 },252 );253 const deals = dealSearch.items || [];254 const dealIds = deals.map((d) => d && d.id).filter(Boolean);255256 // 2. Batched deal -> company association (100 per call). The raw257 // association rows never leave this program.258 const companyByDeal = {};259 for (const batch of chunk(dealIds, 100)) {260 if (batch.length === 0) continue;261 const assoc = await providerFetch(262 "hubspot",263 "/crm/v3/associations/deals/companies/batch/read",264 { method: "POST", body: { inputs: batch.map((id) => ({ id })) } },265 );266 for (const r of (assoc && assoc.results) || []) {267 const dealId = r && r.from && r.from.id;268 const companyId =269 r && Array.isArray(r.to) && r.to.length > 0 ? r.to[0].id : null;270 if (dealId && companyId) companyByDeal[dealId] = companyId;271 }272 }273274 // 3. Batched company -> domain lookup.275 const companyIds = Array.from(new Set(Object.values(companyByDeal))).filter(276 Boolean,277 );278 const domainByCompany = {};279 for (const batch of chunk(companyIds, 100)) {280 if (batch.length === 0) continue;281 const companies = await providerFetch(282 "hubspot",283 "/crm/v3/objects/companies/batch/read",284 {285 method: "POST",286 body: { inputs: batch.map((id) => ({ id })), properties: ["domain"] },287 },288 );289 for (const r of (companies && companies.results) || []) {290 const domain =291 r && r.properties && r.properties.domain292 ? String(r.properties.domain).toLowerCase()293 : null;294 if (r && r.id && domain) domainByCompany[r.id] = domain;295 }296 }297298 // 4. A second, unrelated provider — zero bespoke glue action required.299 const pylonAccounts = await providerFetchAll("pylon", "/accounts", {300 itemsPath: "data",301 pagination: {302 cursorParam: "cursor",303 nextCursorPath: "pagination.cursor",304 maxPages: 20,305 },306 });307 const sentimentByDomain = {};308 for (const account of pylonAccounts.items || []) {309 if (!account) continue;310 const domain =311 typeof account.domain === "string" ? account.domain.toLowerCase() : null;312 if (!domain) continue;313 if (314 params.enterpriseOnly &&315 account.account_profile !== "Enterprise Active Customer"316 )317 continue;318 if (account.general_sentiment)319 sentimentByDomain[domain] = account.general_sentiment;320 }321322 // 5. Join + emit. csm_name is computed HERE — never baked into a generic323 // hubspot-deals action.324 const rows = deals.map((deal) => {325 const props = (deal && deal.properties) || {};326 const dealId = deal && deal.id;327 const companyId = dealId ? companyByDeal[dealId] : null;328 const domain = companyId ? domainByCompany[companyId] : null;329 const sentiment = domain ? sentimentByDomain[domain] || null : null;330 return {331 deal_id: dealId || null,332 deal_name: props.dealname || null,333 risk_status: props.risk_status || null,334 risk_summary: props.risk_summary || null,335 risk_category: props.risk_category || null,336 next_step: props.hs_next_step || null,337 churn_notes: props.churn_notes || null,338 arr: Number(props.total_contract_value || 0),339 csm_name: props.customer_success_owner || null,340 dealstage: props.dealstage || null,341 closedate: props.closedate || null,342 domain: domain || null,343 pylon_sentiment: sentiment,344 cross_source_flag: Boolean(345 domain && HIGH_RISK_SENTIMENTS.includes(sentiment),346 ),347 };348 });349350 emit(rows, [351 { name: "deal_id", type: "string" },352 { name: "deal_name", type: "string" },353 { name: "risk_status", type: "string" },354 { name: "risk_summary", type: "string" },355 { name: "risk_category", type: "string" },356 { name: "next_step", type: "string" },357 { name: "churn_notes", type: "string" },358 { name: "arr", type: "number" },359 { name: "csm_name", type: "string" },360 { name: "dealstage", type: "string" },361 { name: "closedate", type: "string" },362 { name: "domain", type: "string" },363 { name: "pylon_sentiment", type: "string" },364 { name: "cross_source_flag", type: "boolean" },365 ]);366}367368await main();369```370371**`risk-meeting-pylon-early-warning.js`** — the complementary "support signal372outrunning CRM signal" view: enterprise Pylon accounts already flagged at-risk373by sentiment that have **not yet** shown up in the HubSpot cohort above (a374same-shape HubSpot domain lookup, minus the Pylon domain set — see375`templates/analytics/seeds/data-programs/risk-meeting-pylon-early-warning.js`376for the full script). This is the third validation capability: excluding one377provider's cohort from another's, entirely inside one program.378379Both programs are installed and kept up to date by380`ensure-risk-meeting-dashboard` — an idempotent action that upserts each381program by its stable `name` and a two-panel "Risk Meeting" dashboard whose382panels use `source: "program"`. Installing the dashboard requires no383HubSpot/Pylon credentials up front; provider auth resolves per-viewer only384when a panel is actually rendered or `run-data-program` is called.385386## Related skills387388- `provider-api` — the catalog/docs/request/staging actions a data program's389 `providerFetch*` calls are built on.390- `dashboard-management` — creating and laying out the dashboard a program's391 panel lives on.392- `cross-source-analysis` — identity-stitching discipline (stable id + email,393 de-duplication, provenance) for joins like the one above.394- `security` — credential handling and access-scoping invariants this395 primitive relies on.