Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,16 +110,16 @@ Search wallet profiles with filters, sorting, and pagination. Returns a `Paginat
| `--order-by` | `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points` |
| `--order-dir` | `asc` or `desc` |
| `--expand` | Comma-separated fields to expand |
| `--conditions` | JSON array of `FilterCondition` objects (see below) |
| `--logic` | Combine conditions with `and` (default) or `or` |
| `--filters` | JSON array of `FilterCondition` objects (see below) |
| `--logic` | Combine filters with `and` (default) or `or` |

```bash
formo profiles search --size 10
formo profiles search --order-by net_worth_usd --order-dir desc --size 5
formo profiles search --page 2 --size 20
formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --size 20
formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]' --logic or --size 20
formo profiles search --conditions '[{"field":"chains.1.balance","op":"gt","value":1000}]' --size 20
formo profiles search --filters '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --size 20
formo profiles search --filters '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]' --logic or --size 20
formo profiles search --filters '[{"field":"chains.1.balance","op":"gt","value":1000}]' --size 20
```

### Lifecycle tuning (advanced)
Expand Down Expand Up @@ -224,7 +224,7 @@ Get a single alert by ID.

```bash
formo alerts create --name "High value tx" --trigger-type event \
--trigger-filters '[{"name":"event","operator":"eq","value":"transaction"}]' \
--trigger-filters '[{"field":"event","op":"eq","value":"transaction"}]' \
--recipient '[{"type":"email","value":["alerts@myapp.com"]}]'
```

Expand Down Expand Up @@ -389,7 +389,11 @@ List all user segments.
| Option | Description |
|---|---|
| `--title` | Segment title |
| `--filter-sets` | JSON array of filter set strings defining the segment |
| `--filters` | JSON array of canonical `{field, op, value}` filter objects |

```bash
formo segments create --title "Whales" --filters '[{"field":"net_worth_usd","op":"gt","value":100000}]'
```

### `segments delete <segmentId>`
Delete a user segment.
Expand Down Expand Up @@ -430,9 +434,10 @@ formo analytics kpis --date-from 2026-04-01 --date-to 2026-04-30 --params '{"gro
formo analytics funnel --date-from 2026-04-01 --date-to 2026-04-30 --params '{"steps":[{"type":"event","event":"page","name":"page::0","filters":[]},{"type":"track","event":"connect","name":"connect::1","filters":[]}],"window_seconds":86400}'
formo analytics top_wallets --date-from 2026-04-01 --date-to 2026-04-30 --params '{"limit":10}'
formo analytics retention --filters '[{"field":"location","op":"eq","value":"US"}]'
formo analytics kpis --filters '[{"field":"page","op":"eq","value":"/pricing"}]' --params '{"page_scope":"page"}'
```

> Requires `query:read` scope. Run `formo analytics <pipe> --help` for the pipe-specific params accepted via `--params`.
`page_scope` accepts `page` (default) or `session` on KPI, revenue, and `top_*` endpoints. It only changes requests with a `page` filter. Requires `query:read` scope. Run `formo analytics <pipe> --help` for pipe-specific params accepted via `--params`.

---

Expand Down Expand Up @@ -478,7 +483,7 @@ formo events ingest --events '[{"type":"track","event":"First"},{"type":"track",

## FilterCondition reference

`profiles search --conditions` accepts a JSON array of filter condition objects:
`profiles search --filters` accepts a JSON array of filter condition objects:

```json
[
Expand Down Expand Up @@ -507,7 +512,7 @@ formo events ingest --events '[{"type":"track","event":"First"},{"type":"track",
| `tokens.` | `tokens.0xA0b8…48.balance` |
| `labels.` | `labels.coinbase.verified_account` |

Combine multiple conditions with `--logic and` (default) or `--logic or`.
Combine multiple filters with `--logic and` (default) or `--logic or`.

---

Expand Down
23 changes: 13 additions & 10 deletions SKILLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ formo profiles search [options]
| `--order-by` | see below | Field to sort by |
| `--order-dir` | `asc`, `desc` | Sort direction |
| `--expand` | `string` | Comma-separated fields to expand |
| `--conditions` | JSON array | Advanced filter conditions (see below) |
| `--logic` | `and`, `or` | Logic operator for combining conditions (default `and`) |
| `--filters` | JSON array | Advanced filter conditions (see below) |
| `--logic` | `and`, `or` | Logic operator for combining filters (default `and`) |

**`--order-by` values:** `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points`

Expand All @@ -103,10 +103,10 @@ formo profiles search --size 10
formo profiles search --order-by net_worth_usd --order-dir desc --size 5

# Profiles with net worth over $10k
formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --size 20
formo profiles search --filters '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --size 20

# Profiles with > $1k balance on Ethereum (chain 1)
formo profiles search --conditions '[{"field":"chains.1.balance","op":"gt","value":1000}]' --size 20
formo profiles search --filters '[{"field":"chains.1.balance","op":"gt","value":1000}]' --size 20

# Second page of 20, sorted by tx count
formo profiles search --order-by tx_count --order-dir desc --page 2 --size 20 --expand labels
Expand Down Expand Up @@ -135,7 +135,7 @@ formo profiles search --order-by tx_count --order-dir desc --page 2 --size 20 --
| `tokens.` | `tokens.0xA0b8…48.balance` |
| `labels.` | `labels.coinbase.verified_account` |

Combine multiple conditions with `--logic and` (default) or `--logic or`.
Combine multiple filters with `--logic and` (default) or `--logic or`.

---

Expand Down Expand Up @@ -222,9 +222,12 @@ formo analytics top_wallets --date-from 2026-04-01 --date-to 2026-04-30 --params

# Retention filtered to US visitors
formo analytics retention --filters '[{"field":"location","op":"eq","value":"US"}]'

# Page-scoped KPIs for the pricing page
formo analytics kpis --filters '[{"field":"page","op":"eq","value":"/pricing"}]' --params '{"page_scope":"page"}'
```

Each pipe accepts pipe-specific params via `--params` (see each command's `--help`): e.g. `funnel` → `steps`, `window_seconds`, `funnel_type`, `group_by`, `limit`, `attribution`; `kpis` → `group_by`, `limit`; `top_*` → `limit`, `offset`.
Each pipe accepts pipe-specific params via `--params` (see each command's `--help`): e.g. `funnel` → `steps`, `window_seconds`, `funnel_type`, `group_by`, `limit`, `attribution`; `kpis` → `group_by`, `limit`; `top_*` → `limit`, `offset`. `page_scope` accepts `page` (default) or `session` on KPI, revenue, and `top_*` endpoints, and only changes requests that include a `page` filter.

---

Expand Down Expand Up @@ -275,7 +278,7 @@ formo alerts create --name "High value tx" --trigger-type event

# Create an alert with filters and recipients
formo alerts create --name "Whale alert" --trigger-type event \
--trigger-filters '[{"name":"revenue","operator":"gt","value":"100000"}]' \
--trigger-filters '[{"field":"revenue","op":"gt","value":"100000"}]' \
--recipient '[{"type":"webhook","value":["https://hooks.example.com/formo"]}]'
```

Expand Down Expand Up @@ -569,19 +572,19 @@ formo segments list
### Create a segment

```bash
formo segments create --title <title> --filter-sets '<json>'
formo segments create --title <title> --filters '<json>'
```

| Option | Description |
|---|---|
| `--title` | Segment title |
| `--filter-sets` | JSON array of filter set strings defining the segment |
| `--filters` | JSON array of canonical `{field, op, value}` filter objects |

> Requires `segments:write` scope.

**Examples:**
```bash
formo segments create --title "Whales" --filter-sets '["net_worth_usd > 100000"]'
formo segments create --title "Whales" --filters '[{"field":"net_worth_usd","op":"gt","value":100000}]'
```

### Delete a segment
Expand Down
2 changes: 1 addition & 1 deletion skills/formo-analytics/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Use a direct lookup for one address or ENS name. Use search for cohorts, orderin
formo profiles get vitalik.eth --expand labels,chains,tokens
formo profiles search --order-by net_worth_usd --order-dir desc --size 10
formo profiles search \
--conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' \
--filters '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' \
--size 20
```

Expand Down
2 changes: 1 addition & 1 deletion src/commands/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const alertBodyOptionsSchema = {
triggerFilters: z
.string()
.optional()
.describe('JSON array of trigger filter objects'),
.describe('JSON array of canonical {"field","op","value"} trigger filters'),
recipient: z
.string()
.optional()
Expand Down
2 changes: 1 addition & 1 deletion src/commands/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ const sharedOptions = z.object({
.optional()
.describe(
'JSON object of pipe-specific params merged into the query, e.g. ' +
'{"limit":10,"group_by":"device"} or funnel ' +
'{"limit":10,"group_by":"device"}, {"page_scope":"session"} for a page filter, or funnel ' +
'{"steps":[{"type":"event","event":"page","name":"page::0","filters":[]}]}. ' +
'May not set date_from/date_to/filters; use the dedicated --date-from/--date-to/--filters flags.',
),
Expand Down
32 changes: 16 additions & 16 deletions src/commands/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export interface SearchProfilesOptions extends LifecycleThresholdOptions {
orderBy?: string
orderDir?: string
expand?: string
conditions?: string
filters?: string
logic?: 'and' | 'or'
}

Expand All @@ -154,32 +154,32 @@ const CONDITION_FIELD_PREFIXES = new Set([
])

/**
* Parse and validate the --conditions JSON. Ensures it is an array of
* Parse and validate the --filters JSON. Ensures it is an array of
* `{ field, op, value }` objects whose `field` is a typed path (e.g.
* `users.net_worth_usd`) — a bare name like `net_worth_usd` is silently
* dropped by the API, so it is rejected here. Exported for unit testing.
*/
export function parseSearchConditions(raw: string): unknown[] {
export function parseSearchFilters(raw: string): unknown[] {
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
throw new Error('--conditions must be a valid JSON array of FilterCondition objects')
throw new Error('--filters must be a valid JSON array of FilterCondition objects')
}
if (!Array.isArray(parsed)) {
throw new Error('--conditions must be a valid JSON array of FilterCondition objects')
throw new Error('--filters must be a valid JSON array of FilterCondition objects')
}
for (const cond of parsed) {
if (!cond || typeof cond !== 'object' || Array.isArray(cond)) {
throw new Error('--conditions: each entry must be an object with field, op, value')
throw new Error('--filters: each entry must be an object with field, op, value')
}
const field = (cond as { field?: unknown }).field
if (typeof field !== 'string' || field.length === 0) {
throw new Error('--conditions: each entry must have a non-empty string "field"')
throw new Error('--filters: each entry must have a non-empty string "field"')
}
if (!field.includes('.') || !CONDITION_FIELD_PREFIXES.has(field.split('.')[0])) {
throw new Error(
`--conditions: field "${field}" must be a typed path — prefix it with ` +
`--filters: field "${field}" must be a typed path — prefix it with ` +
'users., chains., apps., tokens., or labels. ' +
'(a bare name is silently ignored by the API and returns the entire unfiltered dataset)',
)
Expand All @@ -203,15 +203,15 @@ export function searchProfilesRun(options: SearchProfilesOptions) {
addLifecycleThresholdParams(params, options)

let body: object | undefined
if (options.conditions) {
if (options.filters) {
body = {
conditions: parseSearchConditions(options.conditions),
filters: parseSearchFilters(options.filters),
logic: options.logic ?? 'and',
}
}

// INTENTIONAL: the Formo search API is `GET /v0/profiles` with the
// `{ conditions, logic }` filter object in the *request body* (see
// `{ filters, logic }` filter object in the *request body* (see
// docs.formo.so/api/profiles/search — it has a "Request Body (Filters)"
// section under a GET endpoint). This GET-with-body shape is the
// documented, server-supported contract. Do NOT "fix" it to POST — that
Expand Down Expand Up @@ -254,7 +254,7 @@ profiles.command('search', {
.describe('Field to sort by'),
orderDir: z.enum(['asc', 'desc']).optional().describe('Sort direction'),
expand: z.string().optional().describe('Comma-separated fields to expand'),
conditions: z
filters: z
.string()
.optional()
.describe(
Expand All @@ -273,7 +273,7 @@ profiles.command('search', {
logic: z
.enum(['and', 'or'])
.optional()
.describe('Logic operator for combining conditions: "and" (default) or "or"'),
.describe('Logic operator for combining filters: "and" (default) or "or"'),
...lifecycleThresholdOptions,
}),
examples: [
Expand All @@ -288,14 +288,14 @@ profiles.command('search', {
},
{
options: {
conditions: '[{"field":"users.net_worth_usd","op":"gt","value":10000}]',
filters: '[{"field":"users.net_worth_usd","op":"gt","value":10000}]',
size: 20,
},
description: 'Search profiles with net worth > $10k',
},
{
options: {
conditions:
filters:
'[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]',
logic: 'or',
size: 20,
Expand All @@ -304,7 +304,7 @@ profiles.command('search', {
},
{
options: {
conditions: '[{"field":"chains.1.balance","op":"gt","value":1000}]',
filters: '[{"field":"chains.1.balance","op":"gt","value":1000}]',
size: 20,
},
description: 'Search profiles with > $1k balance on Ethereum (chain 1)',
Expand Down
24 changes: 16 additions & 8 deletions src/commands/segments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,24 @@ segments.command('list', {

export interface CreateSegmentOptions {
title: string
filterSets: string
filters: string
}

export function buildCreateSegmentBody(options: CreateSegmentOptions) {
const parsedFilterSets = parseJsonArray(options.filterSets, '--filter-sets')
if (parsedFilterSets.some((item) => typeof item !== 'string')) {
throw new Error('--filter-sets must be a JSON array of strings')
const filters = parseJsonArray(options.filters, '--filters')
for (const filter of filters) {
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
throw new Error('--filters must be a JSON array of {field, op, value} objects')
}
const { field, op } = filter as { field?: unknown; op?: unknown }
if (typeof field !== 'string' || field.length === 0 || typeof op !== 'string' || op.length === 0) {
throw new Error('--filters: each entry requires non-empty string "field" and "op" properties')
}
}

return {
title: options.title,
filterSets: parsedFilterSets,
filters,
}
}

Expand All @@ -60,15 +66,17 @@ segments.command('create', {
description: 'Create a new user segment',
options: z.object({
title: z.string().describe('Segment title'),
filterSets: z
filters: z
.string()
.describe('JSON array of filter set strings defining the segment'),
.describe(
'JSON array of canonical {"field","op","value"} filter objects',
),
}),
examples: [
{
options: {
title: 'Whales',
filterSets: '["net_worth_usd > 100000"]',
filters: '[{"field":"net_worth_usd","op":"gt","value":100000}]',
},
description: 'Create a high-value segment',
},
Expand Down
Loading