diff --git a/README.md b/README.md
index a445751..df70929 100644
--- a/README.md
+++ b/README.md
@@ -119,7 +119,7 @@ formo profiles search --order-by net_worth_usd --order-dir desc --size 5
formo profiles search --page 2 --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
+formo profiles search --filters '[{"field":"chains.balance","op":"gt","value":1000,"chain_id":"1"}]' --size 20
```
### Lifecycle tuning (advanced)
@@ -483,29 +483,38 @@ formo events ingest --events '[{"type":"track","event":"First"},{"type":"track",
```json
[
{ "field": "users.net_worth_usd", "op": "gt", "value": 10000 },
- { "field": "chains.1.balance", "op": "gte", "value": 1000 }
+ { "field": "chains.balance", "op": "gte", "value": 1000, "chain_id": "1" }
]
```
-> **The `field` must be a typed path.** A bare name like `net_worth_usd` is
+> **The `field` must be a canonical path.** A bare name like `net_worth_usd` is
> silently ignored by the API (no error, no filtering — the search returns
-> everything). Always prefix the field with its type.
+> everything). Resource identity goes in the named qualifiers below, never in
+> the field path — identifier-in-path fields such as `chains.1.balance` are
+> rejected with a `400`.
| Field | Type | Description |
|---|---|---|
-| `field` | `string` | Typed path (see prefixes below) |
+| `field` | `string` | `users.{attribute}` or one of the four resource paths (see below) |
| `op` | `string` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains` (social fields only), `notEmpty` / `isEmpty` (value-less existence checks). Long-form spellings (`equals`, `greater`, `includes`, …) are retired — the API rejects them with a `400` naming the token |
-| `value` | `any` | Value to compare against |
-| `scope` | `string` | _(token filters only)_ `any` or `protocol` |
-| `appId` | `string` | _(token filters with `scope: protocol`)_ e.g. `aave-v3` |
-
-| Prefix | Examples |
-|---|---|
-| `users.` | `users.net_worth_usd`, `users.volume`, `users.revenue`, `users.points`, `users.device`, `users.location`, `users.lifecycle`, `users.ens`, `users.farcaster` |
-| `chains.` | `chains.balance` (any chain), `chains.1.balance` (Ethereum) |
-| `apps.` | `apps.uniswap-v3.balance` |
-| `tokens.` | `tokens.0xA0b8…48.balance` |
-| `labels.` | `labels.coinbase.verified_account` |
+| `value` | `any` | Value to compare against; must be a number on the `.balance` fields |
+| `chain_id` | `string` | _(optional on any resource filter)_ restrict to one chain; omit to match any |
+| `app_id` | `string` | _(required by `apps.balance`, and by `tokens.balance` with `scope: protocol`)_ e.g. `aave-v3` |
+| `token_address` | `string` | _(required by `tokens.balance`)_ |
+| `tag_id` | `string` | _(required by `labels.value`)_ e.g. `coinbase.verified_account` |
+| `scope` | `string` | _(required by `tokens.balance`)_ `any` or `protocol` |
+
+| Field | Required qualifiers | Example |
+|---|---|---|
+| `users.{attribute}` | none | `{"field":"users.net_worth_usd","op":"gt","value":10000}` |
+| `chains.balance` | none (`chain_id` optional) | `{"field":"chains.balance","op":"gte","value":1000,"chain_id":"1"}` |
+| `apps.balance` | `app_id` | `{"field":"apps.balance","op":"gt","value":500,"app_id":"uniswap-v3"}` |
+| `tokens.balance` | `token_address`, `scope` | `{"field":"tokens.balance","op":"gt","value":0,"token_address":"0xA0b8…48","scope":"any"}` |
+| `labels.value` | `tag_id` | `{"field":"labels.value","op":"eq","value":"true","tag_id":"coinbase.verified_account"}` |
+
+User attributes for `users.{attribute}`: `net_worth_usd`, `volume`, `revenue`,
+`points`, `device`, `location`, `lifecycle`, `ens`, `farcaster`, and the other
+social handles.
Combine multiple filters with `--logic and` (default) or `--logic or`.
diff --git a/SKILLS.md b/SKILLS.md
index 85acf52..90607cc 100644
--- a/SKILLS.md
+++ b/SKILLS.md
@@ -106,7 +106,7 @@ formo profiles search --order-by net_worth_usd --order-dir desc --size 5
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 --filters '[{"field":"chains.1.balance","op":"gt","value":1000}]' --size 20
+formo profiles search --filters '[{"field":"chains.balance","op":"gt","value":1000,"chain_id":"1"}]' --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
@@ -119,21 +119,28 @@ formo profiles search --order-by tx_count --order-dir desc --page 2 --size 20 --
| Property | Type | Description |
|---|---|---|
-| `field` | `string` | **Typed path** — a bare name like `net_worth_usd` is silently ignored by the API |
+| `field` | `string` | **Canonical path** — `users.{attribute}` or a resource path below. A bare name like `net_worth_usd` is silently ignored by the API; identifier-in-path fields like `chains.1.balance` are rejected with a `400` |
| `op` | `string` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains` (social fields only), `notEmpty` / `isEmpty` (value-less existence checks). Long-form spellings (`equals`, `greater`, `includes`, …) are retired — the API rejects them with a `400` naming the token |
-| `value` | `any` | Value to compare against |
-| `scope` | `string` | _(token filters only)_ `any` or `protocol` |
-| `appId` | `string` | _(token filters with `scope: protocol`)_ e.g. `aave-v3` |
+| `value` | `any` | Value to compare against; must be a number on the `.balance` fields |
+| `chain_id` | `string` | _(optional on any resource filter)_ restrict to one chain; omit to match any |
+| `app_id` | `string` | _(required by `apps.balance`, and by `tokens.balance` with `scope: protocol`)_ e.g. `aave-v3` |
+| `token_address` | `string` | _(required by `tokens.balance`)_ |
+| `tag_id` | `string` | _(required by `labels.value`)_ e.g. `coinbase.verified_account` |
+| `scope` | `string` | _(required by `tokens.balance`)_ `any` or `protocol` |
-**Field path prefixes:**
+**Canonical fields — resource identity goes in the qualifiers, never in the path:**
-| Prefix | Examples |
-|---|---|
-| `users.` | `users.net_worth_usd`, `users.volume`, `users.revenue`, `users.points`, `users.device`, `users.location`, `users.lifecycle`, `users.ens`, `users.farcaster` |
-| `chains.` | `chains.balance` (any chain), `chains.1.balance` (Ethereum) |
-| `apps.` | `apps.uniswap-v3.balance` |
-| `tokens.` | `tokens.0xA0b8…48.balance` |
-| `labels.` | `labels.coinbase.verified_account` |
+| Field | Required qualifiers | Example |
+|---|---|---|
+| `users.{attribute}` | none | `{"field":"users.net_worth_usd","op":"gt","value":10000}` |
+| `chains.balance` | none (`chain_id` optional) | `{"field":"chains.balance","op":"gte","value":1000,"chain_id":"1"}` |
+| `apps.balance` | `app_id` | `{"field":"apps.balance","op":"gt","value":500,"app_id":"uniswap-v3"}` |
+| `tokens.balance` | `token_address`, `scope` | `{"field":"tokens.balance","op":"gt","value":0,"token_address":"0xA0b8…48","scope":"any"}` |
+| `labels.value` | `tag_id` | `{"field":"labels.value","op":"eq","value":"true","tag_id":"coinbase.verified_account"}` |
+
+User attributes for `users.{attribute}`: `net_worth_usd`, `volume`, `revenue`,
+`points`, `device`, `location`, `lifecycle`, `ens`, `farcaster`, and the other
+social handles.
Combine multiple filters with `--logic and` (default) or `--logic or`.
diff --git a/skills/formo-analytics/SKILL.md b/skills/formo-analytics/SKILL.md
index bb592e1..1399e3e 100644
--- a/skills/formo-analytics/SKILL.md
+++ b/skills/formo-analytics/SKILL.md
@@ -78,7 +78,7 @@ formo profiles search \
--size 20
```
-Always use typed filter paths such as `users.net_worth_usd`, `chains.1.balance`, `apps.uniswap-v3.balance`, `tokens.
.balance`, or `labels..`. A bare field such as `net_worth_usd` may be ignored.
+Always use canonical filter paths: `users.` (e.g. `users.net_worth_usd`), or one of the four resource fields — `chains.balance`, `apps.balance`, `tokens.balance`, `labels.value` — with the resource identified by a named qualifier (`chain_id`, `app_id`, `token_address`, `tag_id`) alongside `field`/`op`/`value`. A bare field such as `net_worth_usd` may be ignored, and identifier-in-path spellings such as `chains.1.balance` or `labels..` are rejected with a `400`.
Profile updates, label changes, and wallet imports require `profiles:write`. Preserve pagination metadata and continue only while `has_more` is true.
diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts
index 81b014f..7d2346a 100644
--- a/src/commands/profiles.ts
+++ b/src/commands/profiles.ts
@@ -141,13 +141,25 @@ export interface SearchProfilesOptions extends LifecycleThresholdOptions {
logic?: 'and' | 'or'
}
-// Accepted first segments for a FilterCondition `field`, mirroring the API's
-// parseField(). A field whose prefix is not one of these is silently ignored
-// server-side (no error, no filtering — the search returns everything), so we
-// reject it client-side with an actionable message instead.
-const FILTER_FIELD_PREFIXES = new Set([
- 'user',
- 'users',
+// Prefixes that may lead a user-surface `field` (e.g. `users.net_worth_usd`).
+// A bare name like `net_worth_usd` is silently ignored server-side (no error,
+// no filtering — the search returns everything), so we reject it client-side.
+const USER_FIELD_PREFIXES = new Set(['user', 'users'])
+
+// The four canonical resource filter fields. Resource identity lives in named
+// qualifier properties — never in the field path. The retired
+// identifier-in-path spellings (`chains.1.balance`, `apps.uniswap-v3.balance`,
+// `tokens.0x….balance`, `labels.vip`) are rejected by the API with a 400.
+const RESOURCE_FILTER_FIELDS = new Set([
+ 'chains.balance',
+ 'apps.balance',
+ 'tokens.balance',
+ 'labels.value',
+])
+
+// Prefixes owned by the resource fields above. A field that leads with one of
+// these but is not an exact canonical path is a retired dynamic path.
+const RESOURCE_FIELD_PREFIXES = new Set([
'chain',
'chains',
'app',
@@ -158,11 +170,83 @@ const FILTER_FIELD_PREFIXES = new Set([
'labels',
])
+const QUALIFIER_KEYS = [
+ 'chain_id',
+ 'app_id',
+ 'token_address',
+ 'tag_id',
+ 'scope',
+] as const
+
+const FILTER_ENTRY_KEYS = new Set([
+ 'field',
+ 'op',
+ 'value',
+ ...QUALIFIER_KEYS,
+])
+
+/**
+ * Enforce the per-field qualifier rules, mirroring the API's schema. Sending a
+ * qualifier the field does not accept — or omitting a required one — is a 400,
+ * so we fail here with a message that names the offending key.
+ */
+function validateQualifiers(
+ record: Record,
+ field: string,
+): void {
+ const present = (key: string) => record[key] !== undefined
+ const required = (key: string) => {
+ if (!present(key)) {
+ throw new Error(`--filters: "${key}" is required for "${field}"`)
+ }
+ }
+ const forbidden = (keys: readonly string[]) => {
+ for (const key of keys) {
+ if (present(key)) {
+ throw new Error(`--filters: "${key}" is not valid for "${field}"`)
+ }
+ }
+ }
+
+ switch (field) {
+ case 'chains.balance':
+ // chain_id optional — omit it to match any chain.
+ forbidden(['app_id', 'token_address', 'tag_id', 'scope'])
+ break
+ case 'apps.balance':
+ required('app_id')
+ forbidden(['token_address', 'tag_id', 'scope'])
+ break
+ case 'tokens.balance':
+ required('token_address')
+ required('scope')
+ if (record.scope !== 'any' && record.scope !== 'protocol') {
+ throw new Error(`--filters: "scope" must be "any" or "protocol"`)
+ }
+ // app_id identifies the protocol, so it is required by (and only by)
+ // scope: "protocol".
+ if (record.scope === 'protocol') {
+ required('app_id')
+ } else {
+ forbidden(['app_id'])
+ }
+ forbidden(['tag_id'])
+ break
+ case 'labels.value':
+ required('tag_id')
+ forbidden(['app_id', 'token_address', 'scope'])
+ break
+ default:
+ // users.* — a user attribute carries no resource identity.
+ forbidden(QUALIFIER_KEYS)
+ }
+}
+
/**
* 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.
+ * `{ field, op, value }` objects carrying a canonical `field` — either
+ * `users.{attribute}` or one of the four stable resource paths, with resource
+ * identity in named qualifier properties. Exported for unit testing.
*/
export function parseSearchFilters(raw: string): unknown[] {
let parsed: unknown
@@ -183,12 +267,43 @@ export function parseSearchFilters(raw: string): unknown[] {
if (typeof field !== 'string' || field.length === 0) {
throw new Error('--filters: each entry must have a non-empty string "field"')
}
- if (!field.includes('.') || !FILTER_FIELD_PREFIXES.has(field.split('.')[0])) {
- throw new Error(
- `--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)',
- )
+ for (const key of Object.keys(record)) {
+ if (!FILTER_ENTRY_KEYS.has(key)) {
+ // `appId` was the pre-P-2387 spelling; the API now rejects unknown keys.
+ const hint =
+ key === 'appId' ? ' — use the snake_case "app_id" qualifier' : ''
+ throw new Error(`--filters: unknown property "${key}"${hint}`)
+ }
+ }
+ const prefix = field.split('.')[0]
+ if (!RESOURCE_FILTER_FIELDS.has(field)) {
+ if (RESOURCE_FIELD_PREFIXES.has(prefix)) {
+ throw new Error(
+ `--filters: field "${field}" is a retired identifier-in-path spelling. ` +
+ 'Use a stable path — chains.balance, apps.balance, tokens.balance, or labels.value — ' +
+ 'and move the identifier into a qualifier (chain_id, app_id, token_address, tag_id). ' +
+ 'The API rejects the old form with a 400.',
+ )
+ }
+ if (!field.includes('.') || !USER_FIELD_PREFIXES.has(prefix)) {
+ throw new Error(
+ `--filters: field "${field}" must be a canonical path — either ` +
+ 'users.{attribute}, or one of chains.balance, apps.balance, tokens.balance, labels.value ' +
+ '(a bare name is silently ignored by the API and returns the entire unfiltered dataset)',
+ )
+ }
+ }
+ validateQualifiers(record, field)
+ // The balance fields compare numerically; a stringified number is a 400.
+ if (
+ field !== 'labels.value' &&
+ RESOURCE_FILTER_FIELDS.has(field) &&
+ typeof record.value !== 'number'
+ ) {
+ throw new Error(`--filters: "value" must be a number for "${field}"`)
+ }
+ if (field === 'labels.value' && record.value === '') {
+ throw new Error(`--filters: "value" must be non-empty for "labels.value"`)
}
if (!isCanonicalFilterOperator(record.op)) {
throw new Error(
@@ -285,12 +400,15 @@ profiles.command('search', {
'Profile: users.net_worth_usd, users.volume, users.revenue, users.points. ' +
'Engagement: users.device, users.browser, users.os, users.location, users.lifecycle. ' +
'Socials: users.ens, users.farcaster, users.lens, etc. ' +
- 'Chains: chains.balance or chains.{chain_id}.balance. ' +
- 'Apps: apps.{app_id}.balance. Tokens: tokens.{address}.balance ' +
- '(optional "scope":"any"|"protocol" + "appId"). Labels: labels.{tag_id}. ' +
+ 'Resource filters use a stable field plus named qualifiers: ' +
+ 'chains.balance (+ optional "chain_id"); ' +
+ 'apps.balance (+ "app_id", optional "chain_id"); ' +
+ 'tokens.balance (+ "token_address", "scope":"any"|"protocol", "app_id" when scope is "protocol", optional "chain_id"); ' +
+ 'labels.value (+ "tag_id", optional "chain_id"). ' +
'op: eq, neq, gt, gte, lt, lte, in, nin, contains, notEmpty, isEmpty ' +
'(contains = substring, social fields only; notEmpty/isEmpty = value-less existence checks on string fields). ' +
- 'Long-form spellings (equals, notEquals, greater, greaterOrEqual, less, lessOrEqual, notIn, includes) are retired; the API rejects them with a 400 naming the token.',
+ 'Retired and rejected with a 400: identifier-in-path fields (chains.1.balance, apps.uniswap-v3.balance, tokens.0x….balance, labels.vip), ' +
+ 'the "appId" spelling, and long-form operators (equals, notEquals, greater, greaterOrEqual, less, lessOrEqual, notIn, includes).',
),
logic: z
.enum(['and', 'or'])
@@ -326,13 +444,22 @@ profiles.command('search', {
},
{
options: {
- filters: '[{"field":"chains.1.balance","op":"gt","value":1000}]',
+ filters:
+ '[{"field":"chains.balance","op":"gt","value":1000,"chain_id":"1"}]',
size: 20,
},
description: 'Search profiles with > $1k balance on Ethereum (chain 1)',
},
+ {
+ options: {
+ filters:
+ '[{"field":"labels.value","op":"eq","value":"tier-1","tag_id":"vip"}]',
+ size: 20,
+ },
+ description: 'Search profiles carrying the vip label with value tier-1',
+ },
],
- hint: 'Requires profiles:read scope on your API key. Filter "field" must be a typed path (e.g. users.net_worth_usd) — bare names are ignored by the API.',
+ hint: 'Requires profiles:read scope on your API key. Filter "field" must be a canonical path (users.{attribute}, chains.balance, apps.balance, tokens.balance, labels.value) with resource identity in the chain_id/app_id/token_address/tag_id qualifiers — bare names are ignored by the API and identifier-in-path fields are rejected with a 400.',
run({ options }) {
return searchProfilesRun(options)
},
diff --git a/test/commands/bodyBuilders.test.ts b/test/commands/bodyBuilders.test.ts
index 3bc69ce..fd5de18 100644
--- a/test/commands/bodyBuilders.test.ts
+++ b/test/commands/bodyBuilders.test.ts
@@ -393,22 +393,93 @@ describe('commands / body builders', function () {
// ── Profiles search filters ──
describe('parseSearchFilters()', function () {
- it('accepts filters with typed field prefixes', function () {
+ it('accepts filters with canonical field paths', function () {
const filters = parseSearchFilters(
- '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"chains.1.balance","op":"gte","value":1000}]',
+ '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"chains.balance","op":"gte","value":1000,"chain_id":"1"}]',
);
expect(filters).to.have.length(2);
expect((filters[0] as { field: string }).field).to.equal('users.net_worth_usd');
});
- it('accepts apps., tokens., and labels. prefixes', function () {
+ it('accepts apps., tokens., and labels. resource filters with qualifiers', function () {
expect(() =>
parseSearchFilters(
- '[{"field":"apps.uniswap-v3.balance","op":"gt","value":0},{"field":"tokens.0xabc.balance","op":"gt","value":1},{"field":"labels.coinbase.verified_account","op":"eq","value":"true"}]',
+ '[{"field":"apps.balance","op":"gt","value":0,"app_id":"uniswap-v3"},{"field":"tokens.balance","op":"gt","value":1,"token_address":"0xabc","scope":"any"},{"field":"labels.value","op":"eq","value":"true","tag_id":"coinbase.verified_account"}]',
),
).to.not.throw();
});
+ it('accepts a protocol-scoped token filter carrying app_id', function () {
+ expect(() =>
+ parseSearchFilters(
+ '[{"field":"tokens.balance","op":"gt","value":1,"token_address":"0xabc","scope":"protocol","app_id":"aave-v3"}]',
+ ),
+ ).to.not.throw();
+ });
+
+ // The retired dialect: identifiers embedded in the field path. The API
+ // rejects these with a 400, so the CLI must not build such a request.
+ it('rejects retired identifier-in-path fields', function () {
+ for (const field of [
+ 'chains.1.balance',
+ 'apps.uniswap-v3.balance',
+ 'tokens.0xabc.balance',
+ 'labels.coinbase.verified_account',
+ ]) {
+ expect(() =>
+ parseSearchFilters(`[{"field":"${field}","op":"gt","value":1}]`),
+ ).to.throw(/retired identifier-in-path spelling/);
+ }
+ });
+
+ it('rejects the retired appId spelling', function () {
+ expect(() =>
+ parseSearchFilters(
+ '[{"field":"tokens.balance","op":"gt","value":1,"token_address":"0xabc","scope":"protocol","appId":"aave-v3"}]',
+ ),
+ ).to.throw(/unknown property "appId".*app_id/);
+ });
+
+ it('requires the qualifier each resource field identifies by', function () {
+ expect(() =>
+ parseSearchFilters('[{"field":"apps.balance","op":"gt","value":1}]'),
+ ).to.throw(/"app_id" is required/);
+ expect(() =>
+ parseSearchFilters('[{"field":"labels.value","op":"eq","value":"x"}]'),
+ ).to.throw(/"tag_id" is required/);
+ expect(() =>
+ parseSearchFilters(
+ '[{"field":"tokens.balance","op":"gt","value":1,"scope":"any"}]',
+ ),
+ ).to.throw(/"token_address" is required/);
+ expect(() =>
+ parseSearchFilters(
+ '[{"field":"tokens.balance","op":"gt","value":1,"token_address":"0xabc","scope":"protocol"}]',
+ ),
+ ).to.throw(/"app_id" is required/);
+ });
+
+ it('rejects a qualifier the field does not accept', function () {
+ expect(() =>
+ parseSearchFilters(
+ '[{"field":"chains.balance","op":"gt","value":1,"app_id":"uniswap-v3"}]',
+ ),
+ ).to.throw(/"app_id" is not valid/);
+ expect(() =>
+ parseSearchFilters(
+ '[{"field":"users.net_worth_usd","op":"gt","value":1,"chain_id":"1"}]',
+ ),
+ ).to.throw(/"chain_id" is not valid/);
+ });
+
+ it('requires a numeric value on the balance fields', function () {
+ expect(() =>
+ parseSearchFilters(
+ '[{"field":"chains.balance","op":"gt","value":"1000"}]',
+ ),
+ ).to.throw(/"value" must be a number/);
+ });
+
it('rejects retired long-form operator tokens locally', function () {
expect(() =>
parseSearchFilters(
@@ -420,13 +491,13 @@ describe('commands / body builders', function () {
it('rejects a bare (untyped) field — the silent-failure footgun', function () {
expect(() =>
parseSearchFilters('[{"field":"net_worth_usd","op":"gt","value":10000}]'),
- ).to.throw(/must be a typed path/);
+ ).to.throw(/must be a canonical path/);
});
it('rejects a known field name without its prefix', function () {
expect(() =>
parseSearchFilters('[{"field":"tx_count","op":"gt","value":5}]'),
- ).to.throw(/must be a typed path/);
+ ).to.throw(/must be a canonical path/);
});
it('throws on invalid JSON', function () {