Every parameter below is generated from the tool catalog the server advertises over MCP. Every returned field is generated from a contract traced to the upstream response struct or the connector line that derives it, and the same contract is what CI validates real handler output against.
Rates and shares are decimals between 0 and 1 (for example, 0.27 = 27%), except where a field explicitly states percentage points.
Reference format 3 · 35 tools · catalog sha256 7c6870e8b9c66a4d366fc4c0abc53504721dd7c85cf3de9e3c5a202ef50d05f0
Adds one subscriber to one static segment, idempotently.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
email | string | no | — | 1–320 characters | — |
id | string | no | — | 1–320 characters | — |
segment_id | string | yes | — | 1–320 characters | — |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. A four-key receipt, relayed verbatim. The four simple write tools do NOT share a receipt shape: this one and `remove_subscriber_from_segment` carry a `segment`, `unsubscribe_subscriber` carries a status instead, and `update_subscriber` has no boolean at all.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
added | boolean | always present | — | — | True when this call created the membership, false when the subscriber was already in the segment. BOTH are successes — the guarded write is what decides, so `false` means "already there", never "failed". |
subscriber | object | always present | — | — | Just the two identifying keys — this receipt carries no status, no names and no memberships. |
subscriber.id | string | always present | — | — | The subscriber id. |
subscriber.email | string | always present | — | — | Their email address. |
segment | object | always present | — | — | The segment written to, so the name can be confirmed against the id that was sent. |
segment.id | string | always present | — | — | The segment id. |
segment.name | string | always present | — | — | The segment’s name. |
message | string | always present | — | — | A sentence stating the outcome — `Added <email> to segment "<name>".`, or `<email> is already in segment "<name>".` when `added` is false. Composed UPSTREAM, not by the connector. |
structuredContent: Identical to the text payload — the same object is sent as `structuredContent`, so the key paths above are the paths a client reads on both channels.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response.
No date inputs: the result is not scoped by a date window.
isError result) — Both `email` and `id`, or neither, or an `email` that is not a valid address. Rejected in-handler with no upstream call.isError result) — An unknown or not-owned `segment_id` (404), a segment that is dynamic or pre-built rather than static (400), or an unknown subscriber (404). Segment validation runs BEFORE the subscriber lookup and before any write. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Queues a bulk job that adds every subscriber matching a filter to one static segment.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
confirmation_token | string | yes | — | 1–200 characters | From preview_bulk_change({ action: "add_to_segment" }) for this exact filter and segment. Single-use; expires 120 seconds after it is issued. |
filter | object | yes | — | — | Required — a filter with 1–10 conditions; an unfiltered audience or count is not allowed. |
filter.match | string | no | "all" | one of: all | any | — |
filter.conditions | array of object | yes | — | 1–10 items | An array of 1–10 condition objects. Always pass an array, even when there is only one condition. |
filter.conditions[].field | string | yes | — | one of: createdAt | lastActiveAt | lastUnsubscribedAt | openRate | clickRate | openedEmails_l7d | openedEmails_l30d | openedEmails_l90d | openedEmails_l180d | openedEmails_l365d | clickedEmails_l7d | clickedEmails_l30d | clickedEmails_l90d | clickedEmails_l180d | clickedEmails_l365d | deliveredEmails_l7d | deliveredEmails_l30d | deliveredEmails_l90d | deliveredEmails_l180d | deliveredEmails_l365d | openedEmails | clickedEmails | deliveredEmails | status | sourceType | segmentIds | The subscriber attribute or engagement metric to compare. |
filter.conditions[].operator | string | yes | — | one of: gt | ge | lt | le | eq | ne | contains | Comparison operator. Valid choices depend on field: dates and rates use gt, ge, lt, or le only (never eq or ne); counts use gt, ge, lt, le, or eq; status and sourceType use eq or ne only. |
filter.conditions[].value | string | number | yes | — | — | Comparison value. Use a string for dates, status, and sourceType; use a number for rates and engagement counts. Dates must be an absolute RFC-3339 timestamp (e.g. "2026-05-08T00:00:00Z") or a relative "-N days|weeks|months" (e.g. "-90 days") — a bare "2026-05-08" is rejected. Rates are decimals between 0 and 1 (e.g. 0.27 = 27%). Engagement counts are non-negative integers. |
signup_form | string | no | — | 1–200 characters | A signup-form name, not an id. |
segment_id | string | yes | — | matching ^[0-9a-f]{24}$ | The segment id (24-character hex), as returned by list_segments. |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. A receipt for ENQUEUED work, or the statement that nothing was queued. It deliberately carries no job id and no `started` boolean: the id was a task-queue handle reaching users as a "Bulk action ID" line, and `started: true` read as completion to a model, which then reported the change as done before the job had run.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
status | string, one of: processing | waiting | nothing_to_do | always present | — | mapped from the upstream receipt status, or set to `nothing_to_do` on the empty-cohort path. | Where the work stands, in customer terms rather than job terms. `processing` began immediately; `waiting` is behind another bulk action (one runs at a time, up to three queued); `nothing_to_do` means nothing matched at write time so NOTHING was queued. None is a completion — `processing` and `waiting` describe background work with no field here reporting what changed, and `nothing_to_do` is the one status that is terminal. |
subscribers_matched | integer | always present | whole count | the connector counts it; never an upstream field. | How many subscribers matched when the work was queued — counted fresh immediately before the enqueue, NOT read back out of the token record: the job enumerates the filter when it runs, so the fresh number names the group being acted on, and `previewed_before` carries the token-bound figure whenever the two differ. It is NOT a count of what changed: nothing has completed yet, and a subscriber already added to the segment is skipped. It is `0` exactly when `status` is `nothing_to_do`. |
previewed_before | integer | absent when the fresh count equals the one the token bound — the ordinary case, on `processing` and `waiting`. It is ALWAYS present on `nothing_to_do`, where the approved figure is the only record of what the group was. | whole count | read from the stored token record, on any path where it differs from the fresh count. | The cohort size a person approved earlier, present whenever it differs from what was there at write time. On `processing` and `waiting` it says the group moved after approval and by how much, in the direction the `message` names; growth past the allowed headroom never reaches here, having been refused. Alongside `subscribers_matched: 0` it is what distinguishes "the group emptied after you approved it" from "nothing matched in the first place", and on that path the confirmation token has been spent, so a fresh preview is needed. |
filter | object | always present | — | echoed from the parsed input. | The STRUCTURED filter echoed back — not the compiled expression sent upstream — so the same selection can be reused or reversed. With `segment_id` it is the full record of who was added to which segment. |
filter.match | string, one of: all | any | always present | — | — | Whether every condition or any condition had to match. Carries the schema default `all` when the caller omitted it, and is emitted before `conditions`. |
filter.conditions | array of object | always present | — | — | The 1–10 conditions, echoed in the order they were supplied. |
filter.conditions[].field | string | always present | — | — | The subscriber attribute or engagement metric compared. |
filter.conditions[].operator | string, one of: gt | ge | lt | le | eq | ne | contains | always present | — | — | The comparison operator, echoed as supplied. The legal set narrows by field: `gt`/`ge`/`lt`/`le` for dates and rates, plus `eq` for counts; `eq`/`ne` for `status` and `sourceType`; `contains` for `segmentIds` membership. Note `ge`/`le`, not `gte`/`lte`. |
filter.conditions[].value | string or number | always present | — | — | The compared value: a string for dates, status and sourceType; a number for rates and engagement counts. |
segment_id | string | always present | — | echoed from the parsed input. | The segment subscribers were added to, echoed back. Always the id, never a name: names are resolved before this point, and a rule-computed segment is rejected rather than echoed. |
signup_form | string | absent when the caller supplied no `signup_form` — the key is spread conditionally | — | echoed from the parsed input. | The signup-form name echoed back when one scoped the cohort. It travels with the filter upstream, because the count was of `(filter) and sourceId in [form]`; sending the bare filter would have acted on a wider group than the number reported. |
message | string | always present | — | composed in the connector; no upstream origin. | A plain-language sentence describing what is happening, composed here so a reader is not left to infer it from `status`. Its wording differs across all three statuses, and on the `nothing_to_do` path it additionally states that the confirmation token is spent, which is true of both halves because both redeem one. |
structuredContent: An object with the single key `result`, carrying the same payload one level deeper — so every field above is at `result.<path>` when a client reads `structuredContent` instead of the text.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response.
No date inputs: the result is not scoped by a date window.
isError result) — A guard refused the call and nothing was enqueued: a filter that will not compile, a segment that is rule-computed rather than static, or a `confirmation_token` that is missing, expired, already consumed, bound to a different action, selector or destination segment, whose cohort has grown beyond the drift bound. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.isError result) — The enqueue itself was refused — most often because another account limit was hit. The upstream 4xx message is relayed verbatim. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Applies a previously previewed bulk archive to the subscribers a filter matches, authorized by the token `preview_bulk_change` issued.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
confirmation_token | string | yes | — | 1–200 characters | From preview_bulk_change({ action: "archive" }). Single-use; expires 120 seconds after it is issued. |
filter | object | yes | — | — | Required — a filter with 1–10 conditions; an unfiltered audience or count is not allowed. |
filter.match | string | no | "all" | one of: all | any | — |
filter.conditions | array of object | yes | — | 1–10 items | An array of 1–10 condition objects. Always pass an array, even when there is only one condition. |
filter.conditions[].field | string | yes | — | one of: createdAt | lastActiveAt | lastUnsubscribedAt | openRate | clickRate | openedEmails_l7d | openedEmails_l30d | openedEmails_l90d | openedEmails_l180d | openedEmails_l365d | clickedEmails_l7d | clickedEmails_l30d | clickedEmails_l90d | clickedEmails_l180d | clickedEmails_l365d | deliveredEmails_l7d | deliveredEmails_l30d | deliveredEmails_l90d | deliveredEmails_l180d | deliveredEmails_l365d | openedEmails | clickedEmails | deliveredEmails | status | sourceType | segmentIds | The subscriber attribute or engagement metric to compare. |
filter.conditions[].operator | string | yes | — | one of: gt | ge | lt | le | eq | ne | contains | Comparison operator. Valid choices depend on field: dates and rates use gt, ge, lt, or le only (never eq or ne); counts use gt, ge, lt, le, or eq; status and sourceType use eq or ne only. |
filter.conditions[].value | string | number | yes | — | — | Comparison value. Use a string for dates, status, and sourceType; use a number for rates and engagement counts. Dates must be an absolute RFC-3339 timestamp (e.g. "2026-05-08T00:00:00Z") or a relative "-N days|weeks|months" (e.g. "-90 days") — a bare "2026-05-08" is rejected. Rates are decimals between 0 and 1 (e.g. 0.27 = 27%). Engagement counts are non-negative integers. |
signup_form | string | no | — | 1–200 characters | A signup-form name, not an id. |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. A receipt for ENQUEUED work, never a report of what changed. It runs asynchronously in chunks, so nothing here counts how many subscribers were actually archived — `subscribers_matched` is the cohort size counted at queue time and neither `processing` nor `waiting` ever reaches a terminal state. This shape deliberately carries no job id and no `started` boolean: the id was a task-queue handle surfacing to users as a "Bulk action ID" line, and `started: true` read as completion to a model, which then reported the change as done before the job had run. Five keys, four when no signup form was used, six when the count moved after approval.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
status | string, one of: processing | waiting | nothing_to_do | always present | — | mapped from the upstream `started`/`queued` receipt status, or set to `nothing_to_do` on the empty-cohort path. | Where the work stands, in customer terms rather than job terms. `processing` means it started immediately; `waiting` means it is behind another bulk action (one runs at a time, up to three queued); `nothing_to_do` means the cohort had emptied by write time, so NOTHING was queued. Only the last is terminal — the other two describe background work, and this response is a receipt, not a completion. There is no way to read a bulk action back, so nothing here can be polled. |
subscribers_matched | integer | always present | whole count | the fresh drift-check count, not anything the enqueue response returns. | How many subscribers matched when the work was queued — counted fresh immediately before the enqueue, NOT read back out of the token record: the job enumerates the filter when it runs, so the fresh number names the group being acted on. It is NOT a count of what has changed: nothing has completed yet, and a subscriber already in the target state, or inside its own 45-day window, is skipped. It is `0` exactly when `status` is `nothing_to_do`. |
previewed_before | integer | absent when the fresh count equals the one the token bound — the ordinary case, on every status | whole count | read from the stored token record, on any path where it differs from the fresh count. | The cohort size a person approved earlier, present only when it differs from what was there at write time. On `processing` and `waiting` it says the group moved after approval and by how much, in the direction the `message` names; growth past the allowed headroom never reaches here, having been refused. Alongside `subscribers_matched: 0` it says the group emptied after approval — and on that path the confirmation token has been spent, so a fresh preview is needed. It is the figure `expectedTotalItems` still carries upstream. |
message | string | always present | — | composed in the connector; no upstream origin. | A plain-language sentence describing what is happening, composed here so a reader is not left to infer it from `status`. Its wording differs across all three statuses, and when the count moved after approval it names the earlier figure and the direction it moved in. |
filter | object | always present | — | echoed from the parsed input. | The STRUCTURED filter echoed back — not the compiled expression sent upstream — so the same cohort can be re-previewed or unarchived again. |
filter.match | string, one of: all | any | always present | — | — | Whether every condition or any condition had to match. Carries the schema default `all` when the caller omitted it, and is emitted before `conditions`. |
filter.conditions | array of object | always present | — | — | The 1–10 conditions, echoed in the order they were supplied. |
filter.conditions[].field | string | always present | — | — | The subscriber attribute or engagement metric compared. |
filter.conditions[].operator | string, one of: gt | ge | lt | le | eq | ne | contains | always present | — | — | The comparison operator, echoed as supplied. The legal set narrows by field: `gt`/`ge`/`lt`/`le` for dates and rates, plus `eq` for counts; `eq`/`ne` for `status` and `sourceType`; `contains` for `segmentIds` membership. Note `ge`/`le`, not `gte`/`lte`. |
filter.conditions[].value | string or number | always present | — | — | The compared value: a string for dates, status and sourceType; a number for rates and engagement counts. |
signup_form | string | absent when the caller supplied no `signup_form` — the key is spread conditionally | — | echoed from the parsed input. | The signup-form name echoed back when one scoped the cohort. It travels with the filter upstream, because the previewed count was of `(filter) and sourceId in [form]`; sending the bare filter would act on a wider group than the token was issued for. |
structuredContent: An object with the single key `result`, carrying the same payload one level deeper — so every field above is at `result.<path>` when a client reads `structuredContent` instead of the text.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response. There is no per-subscriber result to page: the job is chunked upstream and its outcome is not readable through the connector. Every failure message says explicitly that nothing was archived, because the worst outcome is a partial or completed archive being reported that never happened.
No date inputs: the result is not scoped by a date window. Date-valued filter conditions take an absolute RFC-3339 timestamp or a relative `"-N days|weeks|months"` string, and must be byte-identical to the previewed filter.
isError result) — A filter that fails the schema, or a missing `confirmation_token` — which the schema requires, since an empty filter would mean every subscriber upstream. No upstream call.isError result) — The token is older than its 120-second life, or never existed. Preview again for a fresh one. Nothing was archived.isError result) — The token was already spent, so the change may already have been started by that earlier call. Reported distinctly from "expired" on purpose — the record is flagged rather than deleted so a replay reads "already used". Nothing was archived by THIS call.isError result) — The token was minted for a different account. The account identity comes from the verified request, never from caller input, which is what makes a leaked token useless elsewhere.isError result) — An `unarchive` token spent here. Preview the change actually wanted.isError result) — The filter, or the presence or spelling of `signup_form`, differs from what was previewed. Pass the previewed filter unchanged, or preview again.isError result) — The fresh count exceeds the previewed one by more than the allowed headroom (the greater of 10 subscribers or 2%, capped at 500). A cohort that SHRANK always passes, because the write then touches fewer people than were approved. The message names both numbers.isError result) — The token could not be verified. Fails CLOSED — in contrast to the per-user rate limiter, which fails open by design.isError result) — The upstream refused, most often because another bulk operation is already in progress (one running plus three queued is the ceiling). Also an empty filter, a signup form that matches no form or several, or an expected item count of zero. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.isError result) — The count taken before enqueuing failed, which aborts BEFORE any job is created.Queues a bulk job that removes every subscriber matching a filter from one static segment.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
confirmation_token | string | yes | — | 1–200 characters | From preview_bulk_change({ action: "remove_from_segment" }) for this exact filter and segment. Single-use; expires 120 seconds after it is issued. |
filter | object | yes | — | — | Required — a filter with 1–10 conditions; an unfiltered audience or count is not allowed. |
filter.match | string | no | "all" | one of: all | any | — |
filter.conditions | array of object | yes | — | 1–10 items | An array of 1–10 condition objects. Always pass an array, even when there is only one condition. |
filter.conditions[].field | string | yes | — | one of: createdAt | lastActiveAt | lastUnsubscribedAt | openRate | clickRate | openedEmails_l7d | openedEmails_l30d | openedEmails_l90d | openedEmails_l180d | openedEmails_l365d | clickedEmails_l7d | clickedEmails_l30d | clickedEmails_l90d | clickedEmails_l180d | clickedEmails_l365d | deliveredEmails_l7d | deliveredEmails_l30d | deliveredEmails_l90d | deliveredEmails_l180d | deliveredEmails_l365d | openedEmails | clickedEmails | deliveredEmails | status | sourceType | segmentIds | The subscriber attribute or engagement metric to compare. |
filter.conditions[].operator | string | yes | — | one of: gt | ge | lt | le | eq | ne | contains | Comparison operator. Valid choices depend on field: dates and rates use gt, ge, lt, or le only (never eq or ne); counts use gt, ge, lt, le, or eq; status and sourceType use eq or ne only. |
filter.conditions[].value | string | number | yes | — | — | Comparison value. Use a string for dates, status, and sourceType; use a number for rates and engagement counts. Dates must be an absolute RFC-3339 timestamp (e.g. "2026-05-08T00:00:00Z") or a relative "-N days|weeks|months" (e.g. "-90 days") — a bare "2026-05-08" is rejected. Rates are decimals between 0 and 1 (e.g. 0.27 = 27%). Engagement counts are non-negative integers. |
signup_form | string | no | — | 1–200 characters | A signup-form name, not an id. |
segment_id | string | yes | — | matching ^[0-9a-f]{24}$ | The segment id (24-character hex), as returned by list_segments. |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. A receipt for ENQUEUED work, or the statement that nothing was queued. It deliberately carries no job id and no `started` boolean: the id was a task-queue handle reaching users as a "Bulk action ID" line, and `started: true` read as completion to a model, which then reported the change as done before the job had run.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
status | string, one of: processing | waiting | nothing_to_do | always present | — | mapped from the upstream receipt status, or set to `nothing_to_do` on the empty-cohort path. | Where the work stands, in customer terms rather than job terms. `processing` began immediately; `waiting` is behind another bulk action (one runs at a time, up to three queued); `nothing_to_do` means nothing matched at write time so NOTHING was queued. None is a completion — `processing` and `waiting` describe background work with no field here reporting what changed, and `nothing_to_do` is the one status that is terminal. |
subscribers_matched | integer | always present | whole count | the connector counts it; never an upstream field. | How many subscribers matched when the work was queued — counted fresh immediately before the enqueue, NOT read back out of the token record: the job enumerates the filter when it runs, so the fresh number names the group being acted on, and `previewed_before` carries the token-bound figure whenever the two differ. It is NOT a count of what changed: nothing has completed yet, and a subscriber already removed from the segment is skipped. It is `0` exactly when `status` is `nothing_to_do`. |
previewed_before | integer | absent when the fresh count equals the one the token bound — the ordinary case, on `processing` and `waiting`. It is ALWAYS present on `nothing_to_do`, where the approved figure is the only record of what the group was. | whole count | read from the stored token record, on any path where it differs from the fresh count. | The cohort size a person approved earlier, present whenever it differs from what was there at write time. On `processing` and `waiting` it says the group moved after approval and by how much, in the direction the `message` names; growth past the allowed headroom never reaches here, having been refused. Alongside `subscribers_matched: 0` it is what distinguishes "the group emptied after you approved it" from "nothing matched in the first place", and on that path the confirmation token has been spent, so a fresh preview is needed. |
filter | object | always present | — | echoed from the parsed input. | The STRUCTURED filter echoed back — not the compiled expression sent upstream — so the same selection can be reused or reversed. With `segment_id` it is the full record of who was removed from which segment. |
filter.match | string, one of: all | any | always present | — | — | Whether every condition or any condition had to match. Carries the schema default `all` when the caller omitted it, and is emitted before `conditions`. |
filter.conditions | array of object | always present | — | — | The 1–10 conditions, echoed in the order they were supplied. |
filter.conditions[].field | string | always present | — | — | The subscriber attribute or engagement metric compared. |
filter.conditions[].operator | string, one of: gt | ge | lt | le | eq | ne | contains | always present | — | — | The comparison operator, echoed as supplied. The legal set narrows by field: `gt`/`ge`/`lt`/`le` for dates and rates, plus `eq` for counts; `eq`/`ne` for `status` and `sourceType`; `contains` for `segmentIds` membership. Note `ge`/`le`, not `gte`/`lte`. |
filter.conditions[].value | string or number | always present | — | — | The compared value: a string for dates, status and sourceType; a number for rates and engagement counts. |
segment_id | string | always present | — | echoed from the parsed input. | The segment subscribers were removed from, echoed back. Always the id, never a name: names are resolved before this point, and a rule-computed segment is rejected rather than echoed. |
signup_form | string | absent when the caller supplied no `signup_form` — the key is spread conditionally | — | echoed from the parsed input. | The signup-form name echoed back when one scoped the cohort. It travels with the filter upstream, because the count was of `(filter) and sourceId in [form]`; sending the bare filter would have acted on a wider group than the number reported. |
message | string | always present | — | composed in the connector; no upstream origin. | A plain-language sentence describing what is happening, composed here so a reader is not left to infer it from `status`. Its wording differs across all three statuses, and on the `nothing_to_do` path it additionally states that the confirmation token is spent, which is true of both halves because both redeem one. |
structuredContent: An object with the single key `result`, carrying the same payload one level deeper — so every field above is at `result.<path>` when a client reads `structuredContent` instead of the text.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response.
No date inputs: the result is not scoped by a date window.
isError result) — A guard refused the call and nothing was enqueued: a filter that will not compile, a segment that is rule-computed rather than static, or a `confirmation_token` that is missing, expired, already consumed, bound to a different action, selector or whose cohort has grown beyond the drift bound. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.isError result) — The enqueue itself was refused — most often because another account limit was hit. The upstream 4xx message is relayed verbatim. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Applies a previously previewed bulk unarchive to the subscribers a filter matches, authorized by the token `preview_bulk_change` issued.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
confirmation_token | string | yes | — | 1–200 characters | From preview_bulk_change({ action: "unarchive" }). Single-use; expires 120 seconds after it is issued. |
filter | object | yes | — | — | Required — a filter with 1–10 conditions; an unfiltered audience or count is not allowed. |
filter.match | string | no | "all" | one of: all | any | — |
filter.conditions | array of object | yes | — | 1–10 items | An array of 1–10 condition objects. Always pass an array, even when there is only one condition. |
filter.conditions[].field | string | yes | — | one of: createdAt | lastActiveAt | lastUnsubscribedAt | openRate | clickRate | openedEmails_l7d | openedEmails_l30d | openedEmails_l90d | openedEmails_l180d | openedEmails_l365d | clickedEmails_l7d | clickedEmails_l30d | clickedEmails_l90d | clickedEmails_l180d | clickedEmails_l365d | deliveredEmails_l7d | deliveredEmails_l30d | deliveredEmails_l90d | deliveredEmails_l180d | deliveredEmails_l365d | openedEmails | clickedEmails | deliveredEmails | status | sourceType | segmentIds | The subscriber attribute or engagement metric to compare. |
filter.conditions[].operator | string | yes | — | one of: gt | ge | lt | le | eq | ne | contains | Comparison operator. Valid choices depend on field: dates and rates use gt, ge, lt, or le only (never eq or ne); counts use gt, ge, lt, le, or eq; status and sourceType use eq or ne only. |
filter.conditions[].value | string | number | yes | — | — | Comparison value. Use a string for dates, status, and sourceType; use a number for rates and engagement counts. Dates must be an absolute RFC-3339 timestamp (e.g. "2026-05-08T00:00:00Z") or a relative "-N days|weeks|months" (e.g. "-90 days") — a bare "2026-05-08" is rejected. Rates are decimals between 0 and 1 (e.g. 0.27 = 27%). Engagement counts are non-negative integers. |
signup_form | string | no | — | 1–200 characters | A signup-form name, not an id. |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. A receipt for ENQUEUED work, never a report of what changed. It runs asynchronously in chunks, so nothing here counts how many subscribers were actually unarchived — `subscribers_matched` is the cohort size counted at queue time and neither `processing` nor `waiting` ever reaches a terminal state. This shape deliberately carries no job id and no `started` boolean: the id was a task-queue handle surfacing to users as a "Bulk action ID" line, and `started: true` read as completion to a model, which then reported the change as done before the job had run. Five keys, four when no signup form was used, six when the count moved after approval.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
status | string, one of: processing | waiting | nothing_to_do | always present | — | mapped from the upstream `started`/`queued` receipt status, or set to `nothing_to_do` on the empty-cohort path. | Where the work stands, in customer terms rather than job terms. `processing` means it started immediately; `waiting` means it is behind another bulk action (one runs at a time, up to three queued); `nothing_to_do` means the cohort had emptied by write time, so NOTHING was queued. Only the last is terminal — the other two describe background work, and this response is a receipt, not a completion. There is no way to read a bulk action back, so nothing here can be polled. |
subscribers_matched | integer | always present | whole count | the fresh drift-check count, not anything the enqueue response returns. | How many subscribers matched when the work was queued — counted fresh immediately before the enqueue, NOT read back out of the token record: the job enumerates the filter when it runs, so the fresh number names the group being acted on. It is NOT a count of what has changed: nothing has completed yet, and a subscriber already in the target state, or inside its own 45-day window, is skipped. It is `0` exactly when `status` is `nothing_to_do`. |
previewed_before | integer | absent when the fresh count equals the one the token bound — the ordinary case, on every status | whole count | read from the stored token record, on any path where it differs from the fresh count. | The cohort size a person approved earlier, present only when it differs from what was there at write time. On `processing` and `waiting` it says the group moved after approval and by how much, in the direction the `message` names; growth past the allowed headroom never reaches here, having been refused. Alongside `subscribers_matched: 0` it says the group emptied after approval — and on that path the confirmation token has been spent, so a fresh preview is needed. It is the figure `expectedTotalItems` still carries upstream. |
message | string | always present | — | composed in the connector; no upstream origin. | A plain-language sentence describing what is happening, composed here so a reader is not left to infer it from `status`. Its wording differs across all three statuses, and when the count moved after approval it names the earlier figure and the direction it moved in. |
filter | object | always present | — | echoed from the parsed input. | The STRUCTURED filter echoed back — not the compiled expression sent upstream — so the same cohort can be re-previewed or archived again. |
filter.match | string, one of: all | any | always present | — | — | Whether every condition or any condition had to match. Carries the schema default `all` when the caller omitted it, and is emitted before `conditions`. |
filter.conditions | array of object | always present | — | — | The 1–10 conditions, echoed in the order they were supplied. |
filter.conditions[].field | string | always present | — | — | The subscriber attribute or engagement metric compared. |
filter.conditions[].operator | string, one of: gt | ge | lt | le | eq | ne | contains | always present | — | — | The comparison operator, echoed as supplied. The legal set narrows by field: `gt`/`ge`/`lt`/`le` for dates and rates, plus `eq` for counts; `eq`/`ne` for `status` and `sourceType`; `contains` for `segmentIds` membership. Note `ge`/`le`, not `gte`/`lte`. |
filter.conditions[].value | string or number | always present | — | — | The compared value: a string for dates, status and sourceType; a number for rates and engagement counts. |
signup_form | string | absent when the caller supplied no `signup_form` — the key is spread conditionally | — | echoed from the parsed input. | The signup-form name echoed back when one scoped the cohort. It travels with the filter upstream, because the previewed count was of `(filter) and sourceId in [form]`; sending the bare filter would act on a wider group than the token was issued for. |
structuredContent: An object with the single key `result`, carrying the same payload one level deeper — so every field above is at `result.<path>` when a client reads `structuredContent` instead of the text.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response. There is no per-subscriber result to page: the job is chunked upstream and its outcome is not readable through the connector. Every failure message says explicitly that nothing was unarchived, because the worst outcome is a partial or completed unarchive being reported that never happened.
No date inputs: the result is not scoped by a date window. Date-valued filter conditions take an absolute RFC-3339 timestamp or a relative `"-N days|weeks|months"` string, and must be byte-identical to the previewed filter.
isError result) — A filter that fails the schema, or a missing `confirmation_token` — which the schema requires, since an empty filter would mean every subscriber upstream. No upstream call.isError result) — The token is older than its 120-second life, or never existed. Preview again for a fresh one. Nothing was unarchived.isError result) — The token was already spent, so the change may already have been started by that earlier call. Reported distinctly from "expired" on purpose — the record is flagged rather than deleted so a replay reads "already used". Nothing was unarchived by THIS call.isError result) — The token was minted for a different account. The account identity comes from the verified request, never from caller input, which is what makes a leaked token useless elsewhere.isError result) — An `archive` token spent here. Preview the change actually wanted.isError result) — The filter, or the presence or spelling of `signup_form`, differs from what was previewed. Pass the previewed filter unchanged, or preview again.isError result) — The fresh count exceeds the previewed one by more than the allowed headroom (the greater of 10 subscribers or 2%, capped at 500). A cohort that SHRANK always passes, because the write then touches fewer people than were approved. The message names both numbers.isError result) — The token could not be verified. Fails CLOSED — in contrast to the per-user rate limiter, which fails open by design.isError result) — The upstream refused, most often because another bulk operation is already in progress (one running plus three queued is the ceiling). Also an empty filter, a signup form that matches no form or several, or an expected item count of zero. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.isError result) — The count taken before enqueuing failed, which aborts BEFORE any job is created.Creates a static segment from a structured filter, fills it once from that filter, and reports how many subscribers were queued into it.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
name | string | yes | — | 1–100 characters | The new segment name, passed directly. A collision with an existing segment name comes back as an error. |
filter | object | yes | — | — | Required — a filter with 1–10 conditions; an unfiltered audience or count is not allowed. |
filter.match | string | no | "all" | one of: all | any | — |
filter.conditions | array of object | yes | — | 1–10 items | An array of 1–10 condition objects. Always pass an array, even when there is only one condition. |
filter.conditions[].field | string | yes | — | one of: createdAt | lastActiveAt | lastUnsubscribedAt | openRate | clickRate | openedEmails_l7d | openedEmails_l30d | openedEmails_l90d | openedEmails_l180d | openedEmails_l365d | clickedEmails_l7d | clickedEmails_l30d | clickedEmails_l90d | clickedEmails_l180d | clickedEmails_l365d | deliveredEmails_l7d | deliveredEmails_l30d | deliveredEmails_l90d | deliveredEmails_l180d | deliveredEmails_l365d | openedEmails | clickedEmails | deliveredEmails | status | sourceType | segmentIds | The subscriber attribute or engagement metric to compare. |
filter.conditions[].operator | string | yes | — | one of: gt | ge | lt | le | eq | ne | contains | Comparison operator. Valid choices depend on field: dates and rates use gt, ge, lt, or le only (never eq or ne); counts use gt, ge, lt, le, or eq; status and sourceType use eq or ne only. |
filter.conditions[].value | string | number | yes | — | — | Comparison value. Use a string for dates, status, and sourceType; use a number for rates and engagement counts. Dates must be an absolute RFC-3339 timestamp (e.g. "2026-05-08T00:00:00Z") or a relative "-N days|weeks|months" (e.g. "-90 days") — a bare "2026-05-08" is rejected. Rates are decimals between 0 and 1 (e.g. 0.27 = 27%). Engagement counts are non-negative integers. |
signup_form | string | no | — | 1–200 characters | A signup-form name, not an id. |
confirmation_token | string | no | — | 1–200 characters | Required. From preview_segment_count for this exact filter and signup form. Single-use; expires 120 seconds after it is issued. |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. Exactly two top-level keys. Unlike the bulk tools, this one echoes back neither `filter` nor `signup_form`, and it MINTS no token — but it REDEEMS one: the create is refused outright without a `confirmation_token` from `preview_segment_count` for the same cohort. The bulk-action receipt for the fill is deliberately not surfaced: it is a task-queue handle with no use to the caller.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
segment | object | always present | — | — | Exactly three keys. The contract for this object is CLOSED on the connector side, so a new nested backend key is dropped rather than relayed until it is reviewed and described. |
segment.id | string | always present | — | — | The new segment’s id — usable immediately with `inspect_segment`, and the id the one-time fill was queued against. |
segment.name | string | always present | — | — | The new segment’s name, as stored. |
segment.matchedCount | integer | always present | whole count | — | How many subscribers matched the filter, measured immediately BEFORE the create by the same count query `preview_segment_count` runs, and then queued into the segment — the same frame `bulk_add_subscribers_to_segment` reports as `subscribers_matched`. It is what was QUEUED, not settled membership: the fill is chunked and asynchronous and has not finished when the call returns, so a read moments later can show fewer. `0` is a valid success — the segment is created empty and no fill is queued. |
message | string | always present | — | — | A sentence confirming the create and its size — `Created segment "X" with N matching subscribers.` — followed by what the fill did and the standing snapshot caveat: `Membership is a one-time snapshot taken at creation and does not change as subscribers start or stop matching.` Composed by the CONNECTOR, which appends its own sentences to the upstream create message, because the fill is enqueued MCP-side and only the connector knows whether it was queued. Two branches, both success: a queued fill and a zero cohort created empty. |
structuredContent: Identical to the text payload — the same object is sent as `structuredContent`, so the key paths above are the paths a client reads on both channels.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response.
No date inputs: the result is not scoped by a date window. Date-valued filter conditions take an absolute RFC-3339 timestamp or a relative `"-N days|weeks|months"` string; a bare date is rejected.
isError result) — A filter that fails the schema, including a field/operator pairing the enforced filter rejects. No upstream call.isError result) — The upstream 400 relayed verbatim: `A segment named "X" already exists. Choose a different name.` A repeat name is a rejection, not an idempotent no-op. It lands after the token was redeemed, so the message adds that nothing was created and that the token is used up — a retry under a different name needs a fresh one.isError result) — A rule that will not compile (400), or a `signup_form` matching no form (404) or several (400). Nothing is persisted on this path — a `signup_form` that vanished between the count and the create 404s at the RE-COUNT, before the segment is written at all. The token is spent either way, since the redeem precedes both, so the message says so and a retry needs a fresh one. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.isError result) — The `confirmation_token` was missing, expired, already spent, issued for another account, another action or another selector. NOTHING is created and NOTHING is queued — the token is redeemed and the cohort re-counted before the segment is written, which is what makes that true — and each refusal names its own correction. An absent token is its own refusal and names `preview_segment_count` as the tool that issues one, because a client holding a `tools/list` cached from before the token was introduced has no token to reason about. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.isError result) — The group grew past the drift headroom between the count that minted the token and the re-count immediately before the write. NOTHING is created and NOTHING is queued, and the message names both numbers and the headroom. One-sided: a group that SHRANK is not refused, and one that emptied entirely lands on `created with a zero cohort` instead. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.isError result) — The ONE partial-success path: the segment was created, then the `addToSegments` enqueue failed, so the segment exists and is empty. The message names the segment and its id and points at `bulk_add_subscribers_to_segment` as the recovery — the segment is not rolled back, because a named empty segment is one call from correct while a rollback loses the chosen name. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Creates one subscriber, or returns the existing one unchanged when the email is already on the list.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
email | string | yes | — | 1–320 characters | — |
first_name | string | no | — | 0–100 characters | — |
last_name | string | no | — | 0–100 characters | — |
segment_ids | array of string | no | — | 0–20 items; each item 1–320 characters | — |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. A find-or-create receipt. Any extra top-level key the upstream might add is dropped by an explicit destructure, so this is the whole payload.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
created | boolean | always present | — | — | True when this call created the subscriber, false when one already existed and was returned unchanged. This flag is authoritative — the connector cannot see the HTTP status, so it is the only signal that distinguishes the two outcomes, and BOTH are successes. |
subscriber | object | always present | — | — | The create-path profile, which is NARROWER than `get_subscriber`’s: the upstream projection omits `createdAt`, `source`, `lastOpenedAt`, `lastClickedAt` and `customFields`. Call `get_subscriber` when those are needed. |
subscriber.id | string | always present | — | — | The subscriber id. |
subscriber.email | string | always present | — | — | Their email address. |
subscriber.firstName | string | always present | — | — | First name, `""` when unset. |
subscriber.lastName | string | always present | — | — | Last name, `""` when unset. |
subscriber.status | string, one of: active | unconfirmed | unsubscribed | bounced | complained | cleaned | archived | always present | — | — | Subscription state after the call, from the same roster the read tools return; `active` is the backend’s `confirmed`. |
subscriber.segments | array of object | always present | — | — | Segment memberships after the call, `[]` when none — never null. |
subscriber.segments[].name | string | always present | — | — | The segment’s name. |
subscriber.segments[].type | string, one of: static | dynamic | always present | — | — | Only `static` or `dynamic` on the subscriber routes — a `pre_built` segment is reported as `dynamic` here, unlike `list_segments` and `inspect_segment`, which return the raw type. |
note | string | conditional — `created` is false AND the call also supplied a name or `segment_ids` — because neither is applied to an existing subscriber. Absent otherwise, never null | — | composed in fd-mcp-server, with no upstream origin. | A warning that the requested names or segments were NOT applied, naming `update_subscriber` and `add_subscriber_to_segment` as the tools that would apply them. Its presence means part of the request was silently not performed. |
structuredContent: Identical to the text payload — the same object is sent as `structuredContent`, so the key paths above are the paths a client reads on both channels.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response.
No date inputs: the result is not scoped by a date window.
isError result) — An `email` that is not a valid address (checked in-handler before any upstream call), or any other schema failure.isError result) — An unknown or not-owned `segment_ids` entry, or one naming a dynamic or pre-built segment — with ONE MESSAGE PER BAD ID, all accumulated, and the check running BEFORE the find-or-create decision, so a bad segment id is rejected even when the email already exists. Also a soft-deleted email, or a validator bound. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Queues a CSV export of a subscriber group and reports how many it covers; the file itself is emailed to the account, never returned here.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
confirmation_token | string | yes | — | 1–200 characters | From preview_bulk_change({ action: "export" }) for this exact group. Single-use; expires 120 seconds after it is issued. |
filter | object | no | — | — | An ad-hoc group to export. Pass this or `segment_id`, never both and never neither. |
filter.match | string | no | "all" | one of: all | any | — |
filter.conditions | array of object | yes | — | 1–10 items | An array of 1–10 condition objects. Always pass an array, even when there is only one condition. |
filter.conditions[].field | string | yes | — | one of: createdAt | lastActiveAt | lastUnsubscribedAt | openRate | clickRate | openedEmails_l7d | openedEmails_l30d | openedEmails_l90d | openedEmails_l180d | openedEmails_l365d | clickedEmails_l7d | clickedEmails_l30d | clickedEmails_l90d | clickedEmails_l180d | clickedEmails_l365d | deliveredEmails_l7d | deliveredEmails_l30d | deliveredEmails_l90d | deliveredEmails_l180d | deliveredEmails_l365d | openedEmails | clickedEmails | deliveredEmails | status | sourceType | segmentIds | The subscriber attribute or engagement metric to compare. |
filter.conditions[].operator | string | yes | — | one of: gt | ge | lt | le | eq | ne | contains | Comparison operator. Valid choices depend on field: dates and rates use gt, ge, lt, or le only (never eq or ne); counts use gt, ge, lt, le, or eq; status and sourceType use eq or ne only. |
filter.conditions[].value | string | number | yes | — | — | Comparison value. Use a string for dates, status, and sourceType; use a number for rates and engagement counts. Dates must be an absolute RFC-3339 timestamp (e.g. "2026-05-08T00:00:00Z") or a relative "-N days|weeks|months" (e.g. "-90 days") — a bare "2026-05-08" is rejected. Rates are decimals between 0 and 1 (e.g. 0.27 = 27%). Engagement counts are non-negative integers. |
segment_id | string | no | — | 1–64 characters | The id of one segment, as returned by `list_segments`. Pass this or `filter`, never both and never neither. |
signup_form | string | no | — | 1–200 characters | A signup-form name, not an id. Valid only alongside `filter`. |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. Three always-present keys plus the echoed group. The upstream job id is deliberately NOT relayed: no tool consumes it, and an operational id does not belong in a tool result.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
started | boolean | always present | — | asserted by the connector, not relayed. | Always `true` on the success path — the export job was ACCEPTED, not finished. There is no false value: a refusal is an `isError` result instead. |
matched | integer | always present | whole count | — | How many subscribers the export covers, counted live immediately BEFORE the job was queued, over the same resolved filter string the job is given. It is never 0 on this path: a zero cohort is refused instead, because the upstream exporter returns early without publishing a completion event and no email would ever arrive. |
previewed_before | integer | conditional — the count the job was given differs from the one the confirmation token was issued for. Absent in the ordinary case where the two agree, and never null | whole count | read from the redeemed token record, never an upstream value. | The group size a person approved at preview time, present only when the group moved between then and the enqueue. Only a SHRINK reaches here: growth past the drift headroom is refused rather than exported, and a shrink to nothing is refused too. It exists so a file covering fewer people than were approved is disclosed rather than silently substituted. |
message | string | always present | — | composed from `matched`. | A sentence stating the count, that the file is being emailed to the address on the account, and that the download link stops working 24 hours after it is sent. Composed by the connector, because the response carries no file and no link — without it a reader has nowhere to look for the export. |
filter | object | conditional — the caller selected the group with `filter`. Absent when `segment_id` was used, never null | — | — | The filter the export was scoped to, echoed as the structured input so the same group can be re-exported without rebuilding it. |
filter.match | string, one of: all | any | always present | — | — | Whether every condition or any condition had to match. Carries the schema default `all` when the caller omitted it, and is emitted before `conditions`. |
filter.conditions | array of object | always present | — | — | The 1–10 conditions, echoed in the order they were supplied. |
filter.conditions[].field | string | always present | — | — | The subscriber attribute or engagement metric compared. |
filter.conditions[].operator | string, one of: gt | ge | lt | le | eq | ne | contains | always present | — | — | The comparison operator, echoed as supplied. The legal set narrows by field: `gt`/`ge`/`lt`/`le` for dates and rates, plus `eq` for counts; `eq`/`ne` for `status` and `sourceType`; `contains` for `segmentIds` membership. Note `ge`/`le`, not `gte`/`lte`. |
filter.conditions[].value | string or number | always present | — | — | The compared value: a string for dates, status and sourceType; a number for rates and engagement counts. |
signup_form | string | conditional — the caller supplied a `signup_form`. Absent otherwise, never null | — | — | The signup-form NAME the group was narrowed by. It is resolved to a source id upstream and ANDed onto the filter before the cohort is counted, so the number in `matched` and the rows in the file are scoped by it too. |
segment_id | string | conditional — the caller selected the group with `segment_id`. Absent when `filter` was used, never null | — | — | The segment the export was scoped to, echoed back. Exactly one of `filter` or `segment_id` is ever present, mirroring the input rule. |
structuredContent: An object with the single key `result`, carrying the same payload one level deeper — so every field above is at `result.<path>` when a client reads `structuredContent` instead of the text.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response. The export itself is not capped either: the job pages, writes and merges the whole group however large, so nothing is truncated.
No date inputs: the result is not scoped by a date window. Date-valued filter conditions take an absolute RFC-3339 timestamp or a relative `"-N days|weeks|months"` string; a bare date is rejected.
isError result) — Neither `filter` nor `segment_id`, both together, or a `signup_form` alongside `segment_id`. Refused by the connector with no upstream call — an unscoped export would hand over the entire account.isError result) — A filter that fails the schema, including a field/operator pairing the enforced filter rejects. No upstream call.isError result) — The `confirmation_token` was missing, expired, already spent, issued for another account, another action or another selector, or the group has grown past the drift headroom since it was previewed. NOTHING is enqueued and no file is produced, and each refusal names its own correction. A replay lands here, which is the whole point: an export is a whole-account enumeration into a CSV carrying every subscriber's email and last-active IP. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail, terminated with a full stop if it did not end with one; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.isError result) — The group emptied between the preview and the redeem — allowed past the one-sided drift bound, then refused here. Nothing is queued and no email is sent, and the message says the token is spent, because it was claimed before this check.isError result) — A `segment_id` that names no segment on this account (404), or a rule-based segment with no rule stored. It is the ONE refusal involving an upstream call that leaves the confirmation token untouched: the segment lookup runs BEFORE the redeem, so the token is still live for the rest of its 120 seconds and the SAME one works on a retry naming the `segment_id` that was previewed — which is why this refusal must not be read as "preview again". A retry naming some OTHER segment is refused as a selector mismatch instead, because the token is bound to what the preview counted. The message is composed connector-side and the upstream body is deliberately never relayed: its not-found text carries raw pgx/scany internals.isError result) — A `signup_form` matching no form (404) or several (400), a filter that parses but has no SQL form (400) — including a segment-selected export whose stored rule parses without one, since the segment is resolved to that rule before the count — or an export already in flight for the account — that last one refused against a per-account lease released by EXPIRY alone, so the window clears on a clock rather than when the running export finishes. Every refusal here comes from one of the two post-redeem upstream calls, the drift count and the enqueue, so each relays the upstream sentence — verbatim but for a full stop when it ends without one — with a further sentence APPENDED stating the token is gone: the correction is a fresh preview, never a retry of the same token. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail, terminated with a full stop if it did not end with one; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Returns a static routing guide naming the connector’s tools by capability area, so a plan can be formed without an exploratory data call.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: string (markdown), always present. A markdown routing guide listing the connector’s tools under capability headings (email performance, subscribers, workflows and automations, forms, checkout and revenue, account). It carries no account data of any kind, so it answers "which tool" without spending a data call.
structuredContent: An object with the single key `guide`, carrying the same payload one level deeper — so every field above is at `guide.<path>` when a client reads `structuredContent` instead of the text.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response.
No date inputs: the result is not scoped by a date window.
Returns the connected Flodesk account’s brand identity — the name, website, logo, address, palette, fonts and social links an LLM needs to produce on-brand output.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. The account’s brand identity. Deliberately excluded from the underlying record: the terms-and-conditions and privacy-policy HTML blobs, the double-optin ids, the viral-footer and free-month account flags, and the record’s own ids and timestamps.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
name | string | absent when The account has not set it. Upstream marks every field `omitempty`, so an unset field is absent rather than empty — nothing here is ever `""`, `{}` or `null`. | — | — | The business/brand name as typed in Brand settings. Not the account owner’s personal name — that is `fullName` on `get_me`. |
website | string | absent when The account has not set it. Upstream marks every field `omitempty`, so an unset field is absent rather than empty — nothing here is ever `""`, `{}` or `null`. | — | — | The brand’s website URL. |
logo | string | absent when The account has not set it. Upstream marks every field `omitempty`, so an unset field is absent rather than empty — nothing here is ever `""`, `{}` or `null`. | — | — | Direct image URL for the brand logo. Stored and returned verbatim, so it may carry crop/resize query parameters that render the logo as the user cropped it; the connector neither parses nor rewrites them. |
address | object | absent when Every part of the address is unset. | — | — | The mailing address Flodesk renders in email footers. Parts are individually optional — a brand may set a country but no street. |
address.street | string | absent when The account has not set it. Upstream marks every field `omitempty`, so an unset field is absent rather than empty — nothing here is ever `""`, `{}` or `null`. | — | — | Street line of the mailing address; may itself carry a suite or unit. |
address.city | string | absent when The account has not set it. Upstream marks every field `omitempty`, so an unset field is absent rather than empty — nothing here is ever `""`, `{}` or `null`. | — | — | City or town of the mailing address. |
address.state | string | absent when The account has not set it. Upstream marks every field `omitempty`, so an unset field is absent rather than empty — nothing here is ever `""`, `{}` or `null`. | — | — | State, province or region, as the account typed it. Not a code — free text. |
address.country | string | absent when The account has not set it. Upstream marks every field `omitempty`, so an unset field is absent rather than empty — nothing here is ever `""`, `{}` or `null`. | — | — | Country name as the account typed it. Not an ISO code — `get_me` carries the account country code. |
address.postalCode | string | absent when The account has not set it. Upstream marks every field `omitempty`, so an unset field is absent rather than empty — nothing here is ever `""`, `{}` or `null`. | — | — | Postal or ZIP code of the mailing address. |
colors | object with dynamic keys | absent when The account has set no colors, or has cleared every slot it set. | — | — | Brand palette, hex values. Sparse — only the slots the user filled appear. The keys are positional slots (`color1`, `color2`, …) and carry NO semantic role: there is no primary/secondary/accent meaning to read into them. The upstream column is an open `map[string]string` whose key validator constrains nothing, so treat the slot set as open rather than a fixed range — read the keys present rather than probing for a slot number. A slot cleared in the app is stored as an empty string upstream and filtered out, so a present key is always a real color. |
colors.<key> | string | one entry per key present in the payload | — | — | A palette slot, e.g. `color1`, `color2` — an open set, not a fixed range. |
socialLinks | object with dynamic keys | absent when The account has set no social links, or has cleared every one it set. | — | — | Profile URLs keyed by network. Sparse — only the networks the user set appear. The upstream column is an open `map[string]string` with no network enumeration behind it, so read the keys present rather than probing for a known set; `twitter` is the key for X. A link cleared in the app is filtered out the same way colors are. |
socialLinks.<key> | string | one entry per key present in the payload | — | — | A social network name, e.g. `instagram`, `tiktok`, `substack`. |
fontFamilies | array of string | absent when The account has uploaded no custom fonts — the common case. | — | — | Font family names uploaded to this account, sorted. Absent does NOT mean the brand has no typography: it means no CUSTOM fonts were uploaded, and the account uses Flodesk’s built-in fonts. Weights, styles and font-file URLs are deliberately not carried. Capped at the 200 families the upstream page size allows. |
message | string | conditional — Emitted by fd-mcp-server, and only when the account has configured nothing at all. It is then the ONLY key: a bare `{}` reads to a model as a failed call, so the empty case is narrated instead. | — | composed in the connector when the upstream body is empty; never an upstream field. | Says no brand settings are configured yet and where to set them. Never present alongside brand data. |
structuredContent: An object with the single key `brand`, carrying the same payload one level deeper — so every field above is at `brand.<path>` when a client reads `structuredContent` instead of the text.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response.
No date inputs: the result is not scoped by a date window.
isError result) — The brand or font read failed. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Returns Flodesk Checkout revenue and conversion — the account summary, the per-checkout breakdown, and the split by payment type.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
currency | string | no | — | matching ^[A-Z]{3}$ | ISO 4217 3-letter uppercase code (e.g. USD). Omit to use the account default; the response `currencies` field lists the currencies available on the account. |
order_by | string | no | — | one of: name | status | totalOrders | totalVisitors | totalSales | convRate | Sort key for the per-checkout breakdown. Defaults to totalOrders when omitted. |
sort | string | no | — | one of: asc | desc | — |
page | integer | no | — | 1 to ∞ | — |
per_page | integer | no | — | 1 to 20 | — |
is_subscription | boolean | no | — | — | — |
from | string | no | — | 1–64 characters | — |
to | string | no | — | 1–64 characters | — |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. Exactly four top-level keys — `overall`, `funnels`, `revenueMix`, `currencies` — joining three upstream responses. The only value transformation is the money conversion; nothing is renamed or dropped.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
overall | object | always present | — | `overall` is a key name fd-mcp-server gives the first upstream response. | The account-wide checkout summary, always complete and never paged. |
overall.totalVisitors | integer | always present | whole count | — | Visitors across checkouts. |
overall.totalOrders | integer | always present | whole count | — | Orders placed. |
overall.totalSubscriptionOrders | integer | always present | whole count | — | Orders that were subscriptions. |
overall.totalSales | number | always present | the currency’s major unit (e.g. dollars, not cents) — converted here from the upstream minor-unit integer | applied at — upstream sends minor units (cents); the connector divides by 100 except for zero-decimal currencies (jpy, krw, vnd and the rest of that roster), using the nearest enclosing `currency` value. | Gross sales. The money key is `totalSales` — there is NO `totalRevenue` anywhere in this payload. |
overall.totalSubscriptionRevenue | number | always present | the currency’s major unit (e.g. dollars, not cents) — converted here from the upstream minor-unit integer | applied at — upstream sends minor units (cents); the connector divides by 100 except for zero-decimal currencies (jpy, krw, vnd and the rest of that roster), using the nearest enclosing `currency` value. | The subscription share of gross sales. |
overall.totalPublishes | integer | always present | whole count | — | Published checkouts. |
overall.totalCustomers | integer | always present | whole count | — | Distinct customers. |
overall.convRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Orders divided by visitors. The rate key is `convRate` — there is NO `conversionRate`. |
overall.currency | string | always present | lowercase ISO 4217 code | — | The SINGLE currency every money figure above is denominated in: the `currency` input when given, otherwise the account’s first available currency. `""` when the account has none. |
overall.currencies | array of string | always present; null when the account has no currencies at all | ISO 4217 codes | — | The account’s roster of available currencies — the values the `currency` input accepts. Distinct from `currency` (singular), which is the one resolved for this response. Declared upstream as a string array with NO `omitempty`, so the key is always present and a nil slice marshals to `null`. |
funnels | object | always present | — | `funnels` is a key name fd-mcp-server gives the second upstream response. | The per-checkout breakdown, kept in its own upstream envelope — so the rows are at `funnels.data[]`, NOT at `funnels[]`. |
funnels.data | array of object | always present | — | — | The ranked page of checkouts, `[]` when empty — never null. |
funnels.data[].id | string | always present | — | — | The checkout id. |
funnels.data[].name | string | always present | — | — | The checkout’s name. |
funnels.data[].status | string | always present | — | — | The checkout’s publication status as stored upstream. |
funnels.data[].totalVisitors | integer | always present | whole count | — | Visitors to this checkout. |
funnels.data[].totalOrders | integer | always present | whole count | — | Orders through this checkout. |
funnels.data[].totalSales | number | always present | the currency’s major unit (e.g. dollars, not cents) — converted here from the upstream minor-unit integer | applied at — upstream sends minor units (cents); the connector divides by 100 except for zero-decimal currencies (jpy, krw, vnd and the rest of that roster), using the nearest enclosing `currency` value. This row converts using the ROW’s own `currency`, not the top-level one. | Gross sales for this checkout. |
funnels.data[].totalCustomers | integer | always present | whole count | — | Distinct customers for this checkout. |
funnels.data[].totalActiveSubscriptions | integer | always present | whole count | — | Active subscriptions from this checkout. Note the name: `totalActiveSubscriptions` here, `totalActives` on the workflow and segment rankings. |
funnels.data[].convRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | This checkout’s orders divided by its visitors. |
funnels.data[].currency | string | always present | lowercase ISO 4217 code | — | This row’s currency. It is load-bearing: the row’s money figure is converted with it. |
revenueMix | object | always present | — | `revenueMix` is a key name fd-mcp-server gives the third upstream response. | The revenue split by payment type. |
revenueMix.totalSales | number | always present | the currency’s major unit (e.g. dollars, not cents) — converted here from the upstream minor-unit integer | applied at — upstream sends minor units (cents); the connector divides by 100 except for zero-decimal currencies (jpy, krw, vnd and the rest of that roster), using the nearest enclosing `currency` value. | Gross sales across the three payment types. |
revenueMix.totalOrders | integer | always present | whole count | — | Orders across the three payment types. |
revenueMix.currency | string | always present | lowercase ISO 4217 code | — | The currency this breakdown is denominated in. |
revenueMix.items | array of object | always present | — | — | Always EXACTLY three rows in a fixed order — one-time, payment plan, subscription — with zeros for a type that had no orders. Never a short array. |
revenueMix.items[].type | string, one of: one_time | payment_plan | subscription | always present | — | — | The payment type this row aggregates. |
revenueMix.items[].name | string | always present | — | — | The display label — `One-time payments`, `Payment plans` or `Subscriptions`. Always set, even for a type with no orders. |
revenueMix.items[].totalOrders | integer | always present | whole count | — | Orders of this payment type. |
revenueMix.items[].totalSales | number | always present | the currency’s major unit (e.g. dollars, not cents) — converted here from the upstream minor-unit integer | applied at — upstream sends minor units (cents); the connector divides by 100 except for zero-decimal currencies (jpy, krw, vnd and the rest of that roster), using the nearest enclosing `currency` value. These rows carry no `currency` of their own, so they inherit `revenueMix.currency`. | Gross sales of this payment type. |
revenueMix.items[].percent | number | always present | PERCENTAGE POINTS from 0 to 100 with two decimals (42.31 = 42.31%) — deliberately unlike `convRate`, which is a 0–1 decimal. The two scales coexist in this one payload. | — | This type’s share of `revenueMix.totalSales`. The last row absorbs the rounding remainder so the three sum to 100. |
currencies | array of string | always present; null when the account has no currencies at all | ISO 4217 codes | duplicated in fd-mcp-server from `overall.currencies`. | A copy of `overall.currencies`, lifted to the top level for convenience. Same values, same nullness — `null`, never absent, because the upstream field it is copied from is always present. |
structuredContent: An object with the single key `checkout`, carrying the same payload one level deeper — so every field above is at `checkout.<path>` when a client reads `structuredContent` instead of the text.
Paged by `page` (1-based) and `per_page` (1–20; no advertised default — the backend uses 10 and silently resets anything outside 1–20 to 10). The upstream writes `X-Total`, `X-Total-Pages`, `X-Page` and `X-Per-Page` as HTTP response headers and the connector’s client returns only the parsed body, so the response carries NO `total`, `page`, `perPage` or `hasMore` field. A short or empty `data` array is the only end-of-collection signal, and an empty page cannot be told apart from a page past the end. Only `funnels.data` is paged; `overall` and `revenueMix` are always complete.
Accepts `from` and `to` as either a bare date (`2026-09-01`) or a full RFC-3339 timestamp. `from` is coerced to start-of-day and `to` to end-of-day, in the window the backend compares; an omitted `to` defaults to today. `from` must be on or before `to`, and the resolved span must not exceed 365 days — both are client-side validation errors, not upstream ones. Omitting `from` selects the lifetime path and sends no window upstream.
isError result) — A legacy camelCase parameter (`orderBy`, `perPage`, `isSubscription`), a `currency` that is not three uppercase letters, `page` below 1, `per_page` outside 1–20, `from` after `to`, or a span over 365 days. Rejected client-side with no upstream call.isError result) — Any of the three calls failed; they run under one Promise.all, so one failure fails the whole tool call. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Returns what one email actually said — its copy as ordered blocks, with link destinations and image urls — plus the template it was built on.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
email_id | string | yes | — | 1–64 characters | — |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. One email’s content and the design it was built on. The rendered HTML is never returned; the copy is extracted from the editor’s stored blocks.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
id | string | always present | — | — | The email’s id, echoing the one asked for. |
name | string | always present | — | — | The internal name of the campaign, which is what the account owner sees in their list — not the subject line. |
status | string | always present | — | — | The campaign’s stored status. A sent campaign reads `done`; drafts read `draft` and scheduled sends `scheduled`. There is no `sent` value. |
subject | string | always present | — | — | The subject line as the recipient received it — editor markup stripped and entities decoded by the same helper `rank_emails` uses, so the two tools cannot disagree. `""` on a draft whose subject was never set. |
previewText | string | always present | — | — | The preview/preheader text, `""` when unset. |
sendTime | string (rfc3339) | always present; null when the campaign is a draft that was never scheduled | — | — | When the campaign was sent, or is scheduled to send. The key is ALWAYS present and carries JSON `null` for a draft with neither — deliberately not the zero time, which is what the underlying value would otherwise marshal to. |
blocks | array of object | always present | — | — | The email’s content in the order it appears, so the first entry is the opening hook. Blocks that are pure layout or chrome — spacers, dividers, the logo, social icons, the address block and the footer — never appear, because they carry no copy. `[]` for a campaign whose body is empty or unreadable, which is a normal read and not an error. |
blocks[].type | string | always present | — | — | The kind of block the copy came from, as the email editor stored it — for example `art`, `text`, `button`, `image`. It is the editor’s own vocabulary, not a fixed enum, so a block kind added to the editor appears here unchanged. |
blocks[].text | string | absent when the block carries no copy — an image block is the usual case | — | — | The block’s copy with formatting removed, entities decoded and whitespace collapsed. Paragraphs within one block are separated by newlines. Merge tags such as `{{first_name}}` are preserved verbatim, because they are part of what the author wrote. |
blocks[].href | string | absent when the block links nowhere | — | — | Where the block points — the destination of a button or a linked element, so a call to action is legible as more than its label. |
blocks[].linkType | string | absent when the block links nowhere | — | — | What kind of destination `href` is, as stored by the editor — for example an external url, an uploaded file, or a checkout. |
blocks[].imageUrl | string | absent when the block is not an image | — | — | The image’s source url. An image block usually carries no `text` — alt text or a caption surfaces as text when the block has one, but words set inside the picture itself are not stored as text anywhere. So an email whose blocks are mostly images is one whose copy is largely unreadable, not one with little to say. |
totalBlocks | integer | always present | — | — | How many content blocks the email has — layout-only blocks are never counted. It exceeds the length of `blocks` whenever the budget dropped whole blocks, but not when a single oversized block was returned as a truncated prefix; `blocksTruncated` covers both. |
blocksTruncated | boolean | always present | — | — | Whether `blocks` is a prefix of the email rather than the whole of it. The cut normally lands on a block boundary, so copy is not severed mid-sentence — except for a single block longer than the whole budget, which is returned as a truncated prefix of its own text rather than dropped (dropping it would read as an empty email). This flag, not a `totalBlocks` that exceeds the returned count, is the reliable signal that a read is partial. |
template | object | always present | — | — | The design the email was built on. |
template.source | string | always present | — | — | Where the design came from — a gallery template, a private gallery template, a template the user saved, a scratch build, Canva, or Studio. Empty for a campaign created by duplicating another one, which records no source of its own. |
template.id | string | absent when the campaign referenced no template — built from scratch, or duplicated from another campaign | — | — | The stable identifier of the template the email was built on. This is what groups campaigns by design; `rank_emails` returns the same identifier on every ranked row, so grouping many emails by template needs no call to this tool. |
template.name | string | absent when the referenced template record carries no name — a template the user saved themselves has none, and neither does a gallery template that has since been deleted | — | — | The template’s display name. Absence is a fact about the stored record, never a failed lookup: the identifier is still returned and is sufficient to group by design. |
structuredContent: An object with the single key `results`, carrying the same payload one level deeper — so every field above is at `results.<path>` when a client reads `structuredContent` instead of the text.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response.
No date inputs: the result is not scoped by a date window.
isError result) — No email with that id belongs to the caller — an unknown id and another account’s id are indistinguishable by design. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Returns one sent email’s performance by id, including per-variant results and the winner when that email ran a subject-line split test.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
email_id | string | yes | — | 1–100 characters | — |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. One sent email’s blended performance, with its subject-line A/B test attached when it ran one.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
id | string | always present | — | — | The email id. A resend resolves to its PARENT’s id upstream, so the value can differ from the `email_id` that was asked for. When it does, the response MIXES two campaigns: the counts and rates are the resend’s own, while this id, `subject` and `abTest` are the original’s — and `name` ends in ` - Resend`. Re-reading with this id returns the original’s own figures. |
name | string | always present | — | — | The internal email name; a resend reads `"<name> - Resend"`. |
sentAt | string (rfc3339) | always present | — | — | When the email was sent. |
subject | string | always present | — | — | The subject line as a recipient saw it, `""` when it could not be resolved — parsed and rendered, so neither editor markup nor personalization-token template source reaches the caller. The same helper serves `rank_emails` and the per-variant subjects, so those reads cannot disagree about one email. On a FINALIZED split test this is the winning variant’s subject: the campaign subject is overwritten when a winner is picked (variant A’s on a `tie`), and left alone only when the winner is `none`. The sample group saw two different subjects; `variants[]` carries both. |
totalSends | integer | always present | whole count | — | Individual sends across every group that has sent. Before the remainder ships — a running or cancelled test — this tracks the two variants combined; once it ships it exceeds them. Both this and the per-variant `sent` values are counted the same way over the same event log — identical DISTINCT-subscriber expressions — so they are not different grains and nothing is rounded. What differs is the store: this figure is read from a periodically-refreshed rollup while the per-variant figures are read live off the event log (and frozen once a test completes). The rollup is recomputed by a batch job with no bounded cadence, so it can trail them — occasionally by a long way. A figure BELOW the two variants’ combined `sent` proves the rollup is behind; one at or above it does not prove it is current, since once the remainder ships this one exceeds them by design. The email’s report page in Flodesk counts live, so it can show a higher figure than this one. A resend does not raise that page’s send count — it reaches only the original’s unopened recipients, who are already counted — so for this figure the gap is lag alone, even for a resent email. |
totalUniqueOpens | integer | always present | whole count | — | Distinct subscribers who opened, blended across the whole send. For a resent email the report page in Flodesk also counts the resend’s openers, none of whom had opened the original, so it stays above this figure even after the rollup catches up; `rank_emails` reports the resend as its own ` - Resend` row. |
openRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Unique opens divided by deliveries across every group that has sent. The blend spans THREE groups — variant A, variant B, and the remainder — so it is not bounded by the two variant rates and may sit above, below, or between them. A blend below both variant rates and one above both are each reachable, and both are exercised by the committed cases. It is also read from the periodically-refreshed rollup while the per-variant rates are read live, so the two can disagree on freshness alone — most visibly during a running test. Nothing bounds the rollup’s age: a batch backlog can leave it behind the live figures beside it — occasionally by a long way. |
totalUniqueClicks | integer | always present | whole count | — | Distinct subscribers who clicked, blended across the whole send. For a resent email the report page in Flodesk also counts the resend’s clickers, so it stays above this figure even after the rollup catches up. |
clickRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Blended unique clicks divided by deliveries. |
unsubRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Unsubscribes divided by deliveries, blended across the whole send. |
abTest | object | always present; null when the email ran no subject-line split test — the case for most campaigns | — | — | The split test attached to this email. `null` is the explicit "there was no test" signal, and no variant data of any kind appears alongside it. The winner is always chosen by unique open rate subject to a minimum-opens floor. Test duration is a stage-level constant rather than per-test configuration, so it is not a field here — read the actual window from `startedAt` / `endsAt`. |
abTest.status | string, one of: draft | scheduled | running | finalizing | completed | cancelled | always present | — | — | Where the test is in its lifecycle, and the ONLY way to tell live figures from frozen ones: while `running` the per-variant numbers keep moving as recipients open, and at `completed` they are frozen to the snapshot taken when the test ended. In practice only `running`, `finalizing`, `completed` and `cancelled` are reachable here — a `draft` or `scheduled` test belongs to an email that has not sent, which 404s before the test is read. |
abTest.winner | string, one of: A | B | tie | none | absent when the test has not finalized — `omitempty` upstream, so a running or finalizing test carries no key at all. That ABSENCE is a distinct third state: `none` means finalized with no variant clearing the minimum-opens floor, `tie` means finalized with equal open rates, and an absent key means not decided yet | — | — | The winning variant. `none` and `tie` are real recorded outcomes, not errors and not placeholders. A cancelled test is finished yet never picks one, so absence covers both "not decided yet" and "cancelled". |
abTest.startedAt | string (rfc3339) | absent when the test never started | — | — | When the test window opened. |
abTest.endsAt | string (rfc3339) | absent when the test never started | — | — | When the test window closes. The duration is a stage-level constant rather than a per-test setting — 24 hours on production, shorter on staging — so read the window from these timestamps instead of assuming a length. |
abTest.finalizedAt | string (rfc3339) | absent when the test has not completed | — | — | When the winner was recorded and the per-variant figures were frozen. |
abTest.cancelledAt | string (rfc3339) | absent when the test was not cancelled | — | — | When an in-flight test was terminated. |
abTest.sampleSize | integer | always present | whole count | — | The planned test audience TOTAL across both arms — not per variant — fixed when the test began and split between them, variant A taking the odd recipient when it does not divide evenly. |
abTest.remainderSize | integer | always present | whole count | — | The held-back audience that ships after the test concludes, carrying the winning subject — or, when the winner is `none`, split across the two variant subjects (A taking the odd recipient) rather than falling back to the original. Usually `0` when the audience was small enough to split with nothing held back, though it is derived as recorded-recipients minus sample, so an audience that grew after the test began can report a non-zero size on a test that held nothing back. Once it ships it is why the top-level counts exceed the per-variant ones; before that they do not. |
abTest.sentCount | integer | always present | whole count | — | How many recipients have been sent to so far, across the sample and any remainder already shipped. Stays LIVE even after the test completes, because the remainder keeps sending. |
abTest.pendingCount | integer | always present | whole count | — | How many recipients are still waiting to be sent to, on a running test. On a CANCELLED test nothing further ships, so this is usually `0` — cancelling rewrites the campaign total down to the locked sample. It stays POSITIVE when the cancel landed before that sample finished sending, or when assigned recipients were suppressed at send: those people were dropped, not queued. |
abTest.variants | array of object | always present | — | — | One entry per variant, A then B. Always two entries for a real test — never empty, and never a single entry. |
abTest.variants[].name | string, one of: A | B | always present | — | — | The variant label. Two variants is the only shape the product supports today. |
abTest.variants[].subject | string | always present | — | — | The subject line this variant tested, as a recipient saw it: stored as editor HTML and relayed only after the same parse-and-render pair the send pipeline uses, so markup is stripped, entities are decoded, and personalization tokens resolve to their authored default rather than arriving as `{{ subscriber.firstName | … }}` template source. The campaign-level `subject` goes through the identical helper, so the two never disagree in form. An unset one stays `""` rather than falling back to the campaign subject — in practice only variant B, since A is seeded from the campaign subject when the test is created. |
abTest.variants[].sent | integer | always present | whole count | — | Sends for this variant’s share of the sample only. |
abTest.variants[].uniqueOpens | integer | always present | whole count | — | Distinct subscribers who opened this variant. |
abTest.variants[].uniqueClicks | integer | always present | whole count | — | Distinct subscribers who clicked this variant. |
abTest.variants[].openRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | This variant’s unique opens divided by its DELIVERIES (not its sends); `0` when nothing was delivered. The delivered count is not a returned field, so this rate cannot be re-derived from `sent`. This is the metric the winner is chosen on. |
abTest.variants[].clickRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | This variant’s unique clicks divided by its deliveries; `0` when nothing was delivered. |
abTest.variants[].isWinner | boolean | always present | — | — | Whether this variant won. False on BOTH entries when the winner is `none` or `tie`, and on both while the test is still undecided. |
structuredContent: An object with the single key `stats`, carrying the same payload one level deeper — so every field above is at `stats.<path>` when a client reads `structuredContent` instead of the text.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response. It is a single-email read; one email plus at most two variants is far inside the response cap.
No date inputs: the result is not scoped by a date window. A single campaign has a single send, so there is no window to scope — for a date-scoped view across emails use `rank_emails` or `get_email_trends`.
isError result) — The id names nothing the analytics view has — unknown, another account’s, a draft or scheduled email, an A/B VARIANT CHILD id (only the parent has a row in the collapsed view), or an email sent so recently it has not been aggregated yet. That last case also hides it from `rank_emails`, which reads the same view, so retrying there will not find it either. An `isError` result of kind `not_found` reading `No sent email found with id "<id>". Draft and scheduled emails have no stats. Use \`rank_emails\` to find a sent email's id.` The upstream body is never relayed. Never zero-filled stats, which would read as "sent, and got no opens".isError result) — A missing, empty or over-100-character `email_id`, rejected before any upstream call.isError result) — Any other upstream failure. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Returns account-wide email performance for a window or for all time, as counts of emails and sends plus four rates.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
from | string | no | — | 1–64 characters | — |
to | string | no | — | 1–64 characters | — |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. Exactly six fields, always all six. Opens and clicks appear only as rates: there is no unique-open or unique-click COUNT on this tool — use `rank_emails` for per-email counts. Both the windowed and lifetime query paths fill the same six fields, so the shape does not change with the window.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
totalEmails | integer | always present | whole count | — | How many emails were sent in the window (or over the account’s lifetime). |
totalSends | integer | always present | whole count | — | How many individual sends those emails produced. |
deliveryRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Deliveries divided by sends. |
openRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Unique opens divided by DELIVERIES, not by sends. The underlying unique-open count is not returned. |
clickRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Unique clicks divided by deliveries. The underlying unique-click count is not returned. |
unsubRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Unsubscribes divided by deliveries. |
structuredContent: An object with the single key `totals`, carrying the same payload one level deeper — so every field above is at `totals.<path>` when a client reads `structuredContent` instead of the text.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response.
Accepts `from` and `to` as either a bare date (`2026-09-01`) or a full RFC-3339 timestamp. `from` is coerced to start-of-day and `to` to end-of-day, in the window the backend compares; an omitted `to` defaults to today. `from` must be on or before `to`, and the resolved span must not exceed 365 days — both are client-side validation errors, not upstream ones. Omitting `from` selects the lifetime path and sends no window upstream.
isError result) — `from` after `to`, or a resolved span over 365 days. Rejected client-side with no upstream call.isError result) — The analytics read failed. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Returns email metrics bucketed over time — for the whole account or for one segment — optionally with the per-email ranking for one of those metrics.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
from | string | yes | — | 1–64 characters | — |
to | string | no | — | 1–64 characters | — |
interval | string | yes | — | one of: day | week | month | — |
metric | string | no | — | one of: totalEmails | totalSends | totalUniqueOpens | totalUniqueClicks | openRate | clickRate | unsubRate | — |
rank_by_metric | boolean | no | — | — | — |
per_page | integer | no | — | 1 to 20 | — |
segment_id | string | no | — | matching ^[0-9a-f]{24}$ | Optional. One segment id (24-character hex), as returned by `list_segments`, and not a built-in system segment. Scopes the series to campaigns whose audience included that segment. |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. A time series plus, on request, a per-email ranking and the segment it was scoped to. The series values are neither renamed nor recomputed by the connector; the `rankings` and `segment` keys are both the connector’s.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
labels | array of string (rfc3339) | always present; null when an UNSCOPED window contained no campaign events at all. A `segment_id`-scoped request returns a POPULATED array even when nothing matched, because the scoped fork zero-fills its bins rather than returning early | — | — | Bucket-start timestamps in ascending order — full RFC-3339 timestamps, not bare dates. Every series’ `data` array is positionally aligned to this array. |
datasets | object | always present | — | — | An OBJECT keyed by metric name, not an array. All seven metric keys are returned on every call whatever `metric` was passed — `metric` only selects which series `rankings` ranks by — and the whole object is `{}` when the window contained no events. |
datasets.totalEmails | array of object | absent when an UNSCOPED window contained no campaign events, in which case `datasets` is `{}` and every metric key is missing together. A `segment_id`-scoped request never reaches this state: its fork zero-fills the bins on purpose, so an empty scoped window still returns all seven keys, each with all-zero data | — | — | The emails sent in the bucket series. Present on EVERY call — the `metric` input does not filter `datasets`. |
datasets.totalEmails[].id | literal "all" | always present | — | — | Constant `"all"` — the series is not broken down by any grouping. |
datasets.totalEmails[].group | literal "All" | always present | — | — | Constant `"All"`. |
datasets.totalEmails[].data | array of number | always present | whole counts | — | One value per bucket in `labels`, index-aligned to it: emails sent in the bucket. |
datasets.totalSends | array of object | absent when an UNSCOPED window contained no campaign events, in which case `datasets` is `{}` and every metric key is missing together. A `segment_id`-scoped request never reaches this state: its fork zero-fills the bins on purpose, so an empty scoped window still returns all seven keys, each with all-zero data | — | — | The individual sends in the bucket series. Present on EVERY call — the `metric` input does not filter `datasets`. |
datasets.totalSends[].id | literal "all" | always present | — | — | Constant `"all"` — the series is not broken down by any grouping. |
datasets.totalSends[].group | literal "All" | always present | — | — | Constant `"All"`. |
datasets.totalSends[].data | array of number | always present | whole counts | — | One value per bucket in `labels`, index-aligned to it: individual sends in the bucket. |
datasets.totalUniqueOpens | array of object | absent when an UNSCOPED window contained no campaign events, in which case `datasets` is `{}` and every metric key is missing together. A `segment_id`-scoped request never reaches this state: its fork zero-fills the bins on purpose, so an empty scoped window still returns all seven keys, each with all-zero data | — | — | The distinct subscribers who opened in the bucket series. Present on EVERY call — the `metric` input does not filter `datasets`. |
datasets.totalUniqueOpens[].id | literal "all" | always present | — | — | Constant `"all"` — the series is not broken down by any grouping. |
datasets.totalUniqueOpens[].group | literal "All" | always present | — | — | Constant `"All"`. |
datasets.totalUniqueOpens[].data | array of number | always present | whole counts | — | One value per bucket in `labels`, index-aligned to it: distinct subscribers who opened in the bucket. |
datasets.totalUniqueClicks | array of object | absent when an UNSCOPED window contained no campaign events, in which case `datasets` is `{}` and every metric key is missing together. A `segment_id`-scoped request never reaches this state: its fork zero-fills the bins on purpose, so an empty scoped window still returns all seven keys, each with all-zero data | — | — | The distinct subscribers who clicked in the bucket series. Present on EVERY call — the `metric` input does not filter `datasets`. |
datasets.totalUniqueClicks[].id | literal "all" | always present | — | — | Constant `"all"` — the series is not broken down by any grouping. |
datasets.totalUniqueClicks[].group | literal "All" | always present | — | — | Constant `"All"`. |
datasets.totalUniqueClicks[].data | array of number | always present | whole counts | — | One value per bucket in `labels`, index-aligned to it: distinct subscribers who clicked in the bucket. |
datasets.openRate | array of object | absent when an UNSCOPED window contained no campaign events, in which case `datasets` is `{}` and every metric key is missing together. A `segment_id`-scoped request never reaches this state: its fork zero-fills the bins on purpose, so an empty scoped window still returns all seven keys, each with all-zero data | — | — | The unique opens divided by deliveries in the bucket series. Present on EVERY call — the `metric` input does not filter `datasets`. |
datasets.openRate[].id | literal "all" | always present | — | — | Constant `"all"` — the series is not broken down by any grouping. |
datasets.openRate[].group | literal "All" | always present | — | — | Constant `"All"`. |
datasets.openRate[].data | array of number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | One value per bucket in `labels`, index-aligned to it: unique opens divided by deliveries in the bucket. |
datasets.clickRate | array of object | absent when an UNSCOPED window contained no campaign events, in which case `datasets` is `{}` and every metric key is missing together. A `segment_id`-scoped request never reaches this state: its fork zero-fills the bins on purpose, so an empty scoped window still returns all seven keys, each with all-zero data | — | — | The unique clicks divided by deliveries in the bucket series. Present on EVERY call — the `metric` input does not filter `datasets`. |
datasets.clickRate[].id | literal "all" | always present | — | — | Constant `"all"` — the series is not broken down by any grouping. |
datasets.clickRate[].group | literal "All" | always present | — | — | Constant `"All"`. |
datasets.clickRate[].data | array of number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | One value per bucket in `labels`, index-aligned to it: unique clicks divided by deliveries in the bucket. |
datasets.unsubRate | array of object | absent when an UNSCOPED window contained no campaign events, in which case `datasets` is `{}` and every metric key is missing together. A `segment_id`-scoped request never reaches this state: its fork zero-fills the bins on purpose, so an empty scoped window still returns all seven keys, each with all-zero data | — | — | The unsubscribes divided by deliveries in the bucket series. Present on EVERY call — the `metric` input does not filter `datasets`. |
datasets.unsubRate[].id | literal "all" | always present | — | — | Constant `"all"` — the series is not broken down by any grouping. |
datasets.unsubRate[].group | literal "All" | always present | — | — | Constant `"All"`. |
datasets.unsubRate[].data | array of number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | One value per bucket in `labels`, index-aligned to it: unsubscribes divided by deliveries in the bucket. |
rankings | object with dynamic keys | conditional — `rank_by_metric: true` AND `metric` set. Otherwise the key is absent entirely; `rank_by_metric` without `metric` is a client-side validation error, so the flag can never be set without one | — | the key name `rankings` is invented by fd-mcp-server; its value is the entire upstream campaign-trend list body, nested unchanged. | The per-email ranking for the requested metric, as an object keyed by that metric name whose value is the row array — NOT a bare list. Every row carries the same nine fields for every metric, counts and rates together, so which fields come back never depends on which metric was asked for. Rows carry no recipient scope; `rank_emails` reports that per campaign. |
rankings.<key> | array of object | one entry per key present in the payload | — | — | the single requested metric name. Any of the seven fills — the four counts and the three rates alike. `rankings: {}` means only that no campaign qualified in the range, never that the metric cannot be answered |
rankings.<key>[].id | string | always present | — | — | The email id. |
rankings.<key>[].name | string | always present | — | — | The internal email name. |
rankings.<key>[].totalSends | integer | always present | whole count | — | Individual sends for this email. |
rankings.<key>[].totalUniqueOpens | integer | always present | whole count | — | Distinct subscribers who opened it. |
rankings.<key>[].totalUniqueClicks | integer | always present | whole count | — | Distinct subscribers who clicked it. |
rankings.<key>[].openRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Unique opens divided by deliveries for this email. Present on EVERY ranked row whatever `metric` was ranked by — the row shape does not vary with the request — so a count ranking carries it too. |
rankings.<key>[].clickRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Unique clicks divided by deliveries for this email. Present on every ranked row, as `openRate` is. |
rankings.<key>[].unsubRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Unsubscribes divided by deliveries for this email. Present on every ranked row, as `openRate` is. |
rankings.<key>[].sentAt | string (rfc3339) | always present | — | — | When the email was sent. |
segment | object | conditional — `segment_id` was supplied AND names a segment this account can see. Omitting it leaves the key absent entirely; an id the account cannot see is an `isError` result rather than this key carrying null | — | the key and all three fields are assembled by fd-mcp-server from a separate segment-detail read; no trend endpoint returns any of this. | Which segment the series was scoped to, and how big it is right now. Nested rather than flattened so the one non-windowed number in the payload is visibly not part of the time series. |
segment.id | string | always present | — | — | The segment id, echoed from the segment-detail body rather than from the request — so its casing is whatever the upstream stores, which is lower-case. (The connector separately lower-cases the `segment_id` INPUT before the lookup, since that lookup is an exact compare; that is a different step and not the reason this value reads as it does.) |
segment.name | string | always present | — | — | The segment’s name. |
segment.currentActiveSubscribers | integer | always present; null when there is no usable stored membership tally. This is the NORMAL answer for a custom rule-based `dynamic` segment — its membership is evaluated live and never materialized, so the count is always null however many subscribers the rule matches — and it also covers a `pre_built` segment before its first materialization | whole count, never a rate | renamed from upstream `totalActiveSubscribers` on a SECOND read of the segment-detail endpoint, issued alongside the trend read. | Members with `active` status, as of the last time the stored membership aggregate was tallied. It is never recomputed for this call, so a subscriber added or removed during the conversation may not be reflected. The `from`/`to` window does not bound it and never has — a historical active count is not derivable, so this is deliberately a CURRENT figure sitting beside windowed series, and the key is named for that rather than for being live. `null` means the size is unknown, `0` means the tally really is zero. It is the same number, with the same caveat, that `inspect_segment` and `list_segments` report for the same segment. |
structuredContent: An object with the single key `trends`, carrying the same payload one level deeper — so every field above is at `trends.<path>` when a client reads `structuredContent` instead of the text.
The time series itself is not paged and carries no pagination field. When `rankings` is requested it is fetched at page 1 with `per_page` (1–20; the upstream re-clamps anything outside that to 10) and likewise carries no `total`, `page` or `hasMore` — the upstream writes those to HTTP headers the connector’s client discards. There is no cursor and no way to reach a second page of rankings.
`from` is REQUIRED and `interval` (`day`, `week` or `month`) selects the bucket size. Each accepts a bare date or a full RFC-3339 timestamp; `from` is coerced to start-of-day, `to` to end-of-day, and an omitted `to` defaults to today. `from` must be on or before `to`. The window is capped by bucket count, not by the 365-day cap the other windowed tools apply: at most 58 days with `day`, 59 weeks with `week`, and 59 months with `month` (inclusive, on UTC calendar dates), so an UNSCOPED multi-year window is accepted at `month`. A request carrying `segment_id` is a different matter: the segment-scoped read validates the window upstream and rejects a span over 365 days, so the same range can succeed account-wide and fail once a segment is named. The window bounds the series and the rankings only: `segment.currentActiveSubscribers` is a stored tally unaffected by `from`/`to`.
isError result) — A missing `from` or `interval`, `from` after `to` (or after today when `to` is omitted), a window wider than the interval allows (58 days for `day`, 59 weeks for `week`, 59 months for `month`), `rank_by_metric` without `metric`, `per_page` outside 1–20, or a `segment_id` that is not a 24-character hex id or is one of the two built-in system segment ids. Rejected client-side with no upstream call; for a too-wide window the span message names the limit and a coarser interval or a shorter range.isError result) — The `segment_id` named a segment this account cannot see — unknown, deleted, or another account’s. An `isError` result of kind `not_found`, reading `No segment found with id "<id>". Use list_segments to see your segments and their ids.` Deliberately NOT an empty series, which would read as "no activity in that segment". The upstream not-found body is never relayed, because it carries raw datastore text.isError result) — Either call failed, including the upstream 365-day rejection of a segment-scoped window. The bucket-count rules behind the upstream "Invalid time range" rejection are enforced client-side (see validation error), so that rejection should not reach this variant. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Returns the connected Flodesk account’s own user record — who the connector is acting as.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. The seven-key account record. Every key is unconditionally present because the handler writes a literal map rather than a struct with `omitempty`, so nothing here can be absent or null. The endpoint deliberately carries NO plan, billing or feature-access field, so a plan tier cannot be read through the connector.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
id | string | always present | — | — | The Flodesk user id of the account. |
email | string | always present | — | — | The account owner’s login email address. |
fullName | string | always present | — | — | The owner’s name as a single string, `""` when unset. There is no `firstName` or `lastName` on this record — those belong to subscriber shapes. |
timezone | string | always present | — | — | The account’s configured timezone. |
countryCode | string | always present | — | — | The account’s country code. |
emailVerified | boolean | always present | — | — | Whether the owner’s own address is verified. A boolean, not a timestamp. |
createdAt | string (rfc3339) | always present | — | — | When the account was created; the zero time `"0001-01-01T00:00:00Z"` if it was never set. |
structuredContent: An object with the single key `user`, carrying the same payload one level deeper — so every field above is at `user.<path>` when a client reads `structuredContent` instead of the text.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response.
No date inputs: the result is not scoped by a date window.
isError result) — The account read failed. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Answers how many subscribers two or more static segments share, alongside each segment’s own size — the aggregate intersection, without moving a single subscriber record through the model.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
segment_ids | array of string | yes | — | 2–10 items; each item 1–64 characters; matching ^[A-Za-z0-9_-]+$ | Two to ten segment ids, as returned by `list_segments`. No duplicates. Static segments only. |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. Cross-segment membership: the shared-subscriber count plus each named segment’s own size. The only read in the catalog that relates segments to each other rather than reporting on them side by side.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
overlapCount | integer | always present | whole count of subscribers, never a rate | — | How many active subscribers belong to EVERY segment named in the request — the N-way intersection, not a pairwise or any-of figure. `0` is a real answer meaning the segments share nobody, and is distinguishable from the error cases, which return no counts at all. Because it is counted over the same predicate as each `totalActives`, it can never exceed the smallest of them. |
segments | array of object | always present | — | — | One row per id in the request, 2–10 of them. Every named segment gets a row: an empty segment appears with `totalActives: 0` rather than being dropped. Aggregate only by construction — the connector projects these four keys explicitly, so no subscriber id, email or other per-person field can reach the model through this tool. |
segments[].id | string | always present | — | — | The segment id, echoed from the request — the same value `list_segments` returns and `inspect_segment` takes as `segment_id`. |
segments[].name | string | always present | — | — | The segment’s name, resolved server-side, so the overlap can be reported without a second `list_segments` call. |
segments[].segmentType | string | always present | — | — | The RAW segment type, exactly as stored — never `mapSegmentType`’d, so a `pre_built` segment would stay `pre_built` rather than collapsing into `dynamic`. In practice only `static` is ever observed here: a non-static id is rejected upstream with a 4xx before any counting happens, so it never reaches a row. It is a plain string rather than a closed enum so a segment type the backend adds later surfaces as-is. |
segments[].totalActives | integer | always present | whole count of subscribers, never a rate | — | That segment's OWN current size — the denominator for reading `overlapCount` as a share. Counted live on the read replica at request time, over the same `deleted IS FALSE AND status = 'confirmed'` predicate as `overlapCount` — not read from the stored aggregate that `list_segments` and `inspect_segment` report. Two consequences worth planning around: the number can differ slightly from what those tools say about the same segment, and it is never `null`, because there is no aggregate that might be missing. |
structuredContent: An object with the single key `overlap`, carrying the same payload one level deeper — so every field above is at `overlap.<path>` when a client reads `structuredContent` instead of the text.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response.
No date inputs: the result is not scoped by a date window.
isError result) — A dynamic or pre-built segment id is among the inputs. Upstream refuses with a 4xx naming the offending segment and its type — accumulated, so naming two offenders names both — and the connector relays that detail verbatim. No count is computed, and no segment is reported as `0`. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.isError result) — An id that does not exist, was deleted, or belongs to another account. Upstream 404s naming every id it could not resolve, relayed verbatim; the id is never silently dropped from the intersection. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.isError result) — The advertised schema rejects the input: fewer than 2 or more than 10 ids, a duplicate id (case-folded, so the same id in two cases is still a duplicate), a blank id, one over 64 characters, or one carrying anything outside `[A-Za-z0-9_-]`. The charset bound is load-bearing rather than cosmetic: the ids are joined with commas onto a single query param, so an id containing a comma would otherwise split into two upstream ids and silently widen the intersection. No upstream call is made.isError result) — The handler has no try/catch: an upstream 5xx or timeout, and any upstream shape change that breaks the body parse, is converted centrally. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Groups email engagement by weekday, part of day, or device class, to show when and where an audience engages.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
group_by | string | yes | — | one of: dayOfWeek | timeOfDay | deviceType | — |
period | string | no | — | one of: last7Days | last30Days | last90Days | — |
from | string | no | — | 1–64 characters | — |
to | string | no | — | 1–64 characters | — |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. Exactly ONE top-level key, whichever `group_by` asked for; the other two are omitted. The label key is `groupName` (not `group`) and the share key is `percentage` (not `share`), and the three groupings do not share a field set.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
dayOfWeek | array of object | conditional — `group_by: "dayOfWeek"`. The other two top-level keys are then ABSENT entirely (Go `omitempty`), not null and not `[]` | — | — | Always the full seven-row roster, Monday through Sunday, zero-filled where there is no data. This is the only grouping that carries rate fields and the only one without `percentage`. |
dayOfWeek[].groupName | string, one of: Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday | always present | — | — | The weekday this row aggregates. |
dayOfWeek[].totalSends | integer | always present | whole count | — | Sends made on that weekday. |
dayOfWeek[].openRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Unique opens divided by deliveries for that weekday. |
dayOfWeek[].clickRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Unique clicks divided by deliveries for that weekday. |
timeOfDay | array of object | conditional — `group_by: "timeOfDay"`; absent otherwise | — | — | Always the full four-row roster, zero-filled where there is no data. Carries no rate fields. |
timeOfDay[].groupName | string, one of: Morning | Afternoon | Evening | Night | always present | — | — | The part of day this row aggregates. |
timeOfDay[].totalUniqueOpens | integer | always present | whole count | — | Distinct subscribers who opened during that part of day. |
timeOfDay[].percentage | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | This row’s share of all unique opens in the window — a bare part/whole ratio with no ×100. |
deviceType | array of object | conditional — `group_by: "deviceType"`; absent otherwise | — | — | Always the full two-row roster, zero-filled where there is no data. Carries no rate fields. |
deviceType[].groupName | string, one of: Desktop | Mobile | always present | — | — | The device class this row aggregates. |
deviceType[].totalUniqueClicks | integer | always present | whole count | — | Distinct subscribers who clicked from that device class. |
deviceType[].percentage | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | This row’s share of all unique clicks in the window — a bare part/whole ratio with no ×100. |
structuredContent: An object with the single key `insights`, carrying the same payload one level deeper — so every field above is at `insights.<path>` when a client reads `structuredContent` instead of the text.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response.
Accepts `period` (`last7Days`, `last30Days` or `last90Days`) XOR `from`/`to` — supplying both is a client-side validation error rather than one silently winning. Accepts `from` and `to` as either a bare date (`2026-09-01`) or a full RFC-3339 timestamp. `from` is coerced to start-of-day and `to` to end-of-day, in the window the backend compares; an omitted `to` defaults to today. `from` must be on or before `to`, and the resolved span must not exceed 365 days — both are client-side validation errors, not upstream ones. Omitting `from` selects the lifetime path and sends no window upstream.
isError result) — `period` supplied together with `from`/`to`, `from` after `to`, a span over 365 days, or a missing/unknown `group_by`. Rejected client-side with no upstream call.isError result) — The analytics read failed. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Returns one subscriber’s profile by email or id, including status, source, segment memberships and last engagement.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
email | string | no | — | 1–320 characters | — |
id | string | no | — | 1–320 characters | — |
include_custom_fields | boolean | no | — | — | — |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. One subscriber profile, relayed verbatim: nothing on this tool is renamed, dropped or computed by the connector.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
id | string | always present | — | — | The subscriber id. |
email | string | always present | — | — | The subscriber’s email address. |
firstName | string | always present | — | — | First name, `""` when unset. The key is always present. |
lastName | string | always present | — | — | Last name, `""` when unset. The key is always present. |
status | string, one of: active | unconfirmed | unsubscribed | bounced | complained | cleaned | archived | always present | — | — | Subscription state. `active` is the backend’s `confirmed`, rewritten upstream; every other value passes through verbatim. |
createdAt | string (rfc3339) | always present | — | — | When the subscriber was added. |
source | string | always present | — | — | A plain-language label for how the subscriber joined — `Signup form`, `Added manually`, `Imported from a file`, `Integration` or `Checkout`, and the raw backend value for a source type Flodesk has not labelled yet. NOTE the divergence: here it is the bare label, whereas `list_subscribers` appends `": <form name>"` for form rows. |
segments | array of object | always present | — | — | Segment memberships, `[]` when none — never null. A segment deleted between the two reads is silently skipped rather than failing the profile. There is no segment id here, so reaching `inspect_segment` needs a `list_segments` name lookup. |
segments[].name | string | always present | — | — | The segment’s name. |
segments[].type | string, one of: static | dynamic | always present | — | — | Only `static` or `dynamic` on the subscriber routes — a `pre_built` segment is reported as `dynamic` here, unlike `list_segments` and `inspect_segment`, which return the raw type. |
lastOpenedAt | string (rfc3339) | always present; null when the subscriber has never opened an email | — | — | When they last opened an email, from a separate single-row activity query. The key is ALWAYS present and carries JSON `null` for "never" — deliberately not the epoch, and deliberately not absent (contrast `list_email_recipients`, where the same-named key is omitted). |
lastClickedAt | string (rfc3339) | always present; null when the subscriber has never clicked a link | — | — | When they last clicked a link, from a separate single-row activity query. Always present; `null` means never. |
customFields | object with dynamic keys | conditional — `include_custom_fields: true` was passed. With the flag off or absent the key is omitted entirely; with it on and no values stored, it is `{}` rather than null | — | — | Stored custom-field values as a dynamic-keyed object. Values are whatever was stored — the upstream type is arbitrary JSON, not necessarily a string. |
customFields.<key> | any JSON value (string, number, boolean, null, array or object) | one entry per key present in the payload | — | — | the account’s custom-field machine keys — the same keys `update_subscriber` sets values by |
structuredContent: An object with the single key `subscriber`, carrying the same payload one level deeper — so every field above is at `subscriber.<path>` when a client reads `structuredContent` instead of the text.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response.
No date inputs: the result is not scoped by a date window.
isError result) — No subscriber matches the email or id. An `isError` result of kind `not_found` reading `No subscriber found for "<lookup>".` — the upstream 404 body is re-wrapped upstream so raw datastore text never reaches the model. There is no empty success shape.isError result) — Both `email` and `id`, or neither, or an `email` that is not a valid address. Rejected in-handler with no upstream call.isError result) — Any other upstream failure. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Returns the current subscriber-list snapshot and, when a window is given, how much it changed against the preceding window of the same length.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
period | string | no | — | one of: last7Days | last30Days | — |
from | string | no | — | 1–64 characters | — |
to | string | no | — | 1–64 characters | — |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. Two upstream responses joined under two connector-chosen keys. Neither response is renamed, projected or recomputed.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
overall | object | always present | — | `overall` is a key name fd-mcp-server gives the first upstream response. | Exactly three keys, and NEVER window-scoped: the endpoint behind them takes no query parameters at all, so these stay current/trailing-fixed even when `period` or `from`/`to` is supplied. There is no `totalUnsub` here — unsubscribes appear only inside `change`. |
overall.totalActives | integer | always present | whole count | — | Subscribers with `active` status right now. |
overall.totalNew30d | integer | always present | whole count | — | Subscribers added in the trailing 30 days. |
overall.totalNew7d | integer | always present | whole count | — | Subscribers added in the trailing 7 days. |
change | object | conditional — the caller supplied `period` OR `from`; with neither, the second upstream call is skipped and the key is absent entirely | — | `change` is a key name fd-mcp-server gives the second upstream response. | The windowed comparison. Its three keys are `totalActives`, `totalNew` and `totalUnsub` — not `added`/`removed` — and each is an OBJECT, not a plain integer. |
change.totalActives | object | always present | — | — | Active subscribers, with its change against the preceding window. An OBJECT, not a plain integer. |
change.totalActives.value | number | always present | a raw count, float64-encoded — not a rate | — | Active subscribers in the window. |
change.totalActives.change | number | absent when the prior equal-length window had none — a pointer with `omitempty`, so the key is ABSENT, never null and never 0 | a fractional delta, so 0.25 means +25% — not a count and not percentage points | — | The relative change versus the immediately preceding window of the same length: `(current − previous) / previous`. |
change.totalNew | object | always present | — | — | Subscribers added, with its change against the preceding window. An OBJECT, not a plain integer. |
change.totalNew.value | number | always present | a raw count, float64-encoded — not a rate | — | Subscribers added in the window. |
change.totalNew.change | number | absent when the prior equal-length window had none — a pointer with `omitempty`, so the key is ABSENT, never null and never 0 | a fractional delta, so 0.25 means +25% — not a count and not percentage points | — | The relative change versus the immediately preceding window of the same length: `(current − previous) / previous`. |
change.totalUnsub | object | always present | — | — | Subscribers who unsubscribed, with its change against the preceding window. An OBJECT, not a plain integer. |
change.totalUnsub.value | number | always present | a raw count, float64-encoded — not a rate | — | Subscribers who unsubscribed in the window. |
change.totalUnsub.change | number | absent when the prior equal-length window had none — a pointer with `omitempty`, so the key is ABSENT, never null and never 0 | a fractional delta, so 0.25 means +25% — not a count and not percentage points | — | The relative change versus the immediately preceding window of the same length: `(current − previous) / previous`. |
structuredContent: An object with the single key `summary`, carrying the same payload one level deeper — so every field above is at `summary.<path>` when a client reads `structuredContent` instead of the text.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response. Neither upstream endpoint is paginated.
Accepts `period` (`last7Days` or `last30Days`) XOR `from`/`to` — supplying both is a client-side validation error. Accepts `from` and `to` as either a bare date (`2026-09-01`) or a full RFC-3339 timestamp. `from` is coerced to start-of-day and `to` to end-of-day, in the window the backend compares; an omitted `to` defaults to today. `from` must be on or before `to`, and the resolved span must not exceed 365 days — both are client-side validation errors, not upstream ones. Omitting `from` selects the lifetime path and sends no window upstream. Whichever is used scopes ONLY the `change` half.
isError result) — `period` together with `from`/`to`, `from` after `to`, or a span over 365 days. Rejected client-side with no upstream call.isError result) — Either call failed; they run under one Promise.all, so one failure fails the tool call. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Returns subscriber growth bucketed over time — additions, unsubscribes, the active total, and the net of the first two.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
from | string | yes | — | 1–64 characters | — |
to | string | no | — | 1–64 characters | — |
interval | string | yes | — | one of: day | week | month | — |
metric | string | no | — | one of: totalActives | totalNew | totalUnsub | netGrowth | — |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. A bucketed subscriber-growth series. Upstream dataset arrays are copied under their own keys; the only added value is `netGrowth`.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
labels | array of string (rfc3339) | always present | — | — | Bucket-start timestamps in ascending order, one per bucket of the requested `interval`. Never null — the handler substitutes `[]` for an upstream null — and every series’ `data` array is positionally aligned to it. |
datasets | object | always present | — | — | An OBJECT keyed by metric name, not an array. Never null: `{}` at minimum. Its key set is decided by the `metric` input and by whether upstream returned plots — omitting `metric` yields the three upstream series plus `netGrowth`. |
datasets.totalActives | array of object | conditional — `metric` was omitted or set to `totalActives`, AND the metric produced at least one plot upstream — the map key is created only by appending a plot, so a metric with no rows carries no key at all | — | — | The totalActives series. Requesting a single `metric` suppresses the other keys entirely, and requesting `netGrowth` suppresses this one even though it is fetched to compute it. |
datasets.totalActives[].id | literal "all" | always present | — | — | Constant `"all"` — the series is not broken down by any grouping. |
datasets.totalActives[].group | literal "All" | always present | — | — | Constant `"All"`. |
datasets.totalActives[].data | array of number | always present | whole counts — this tool returns no rates, percentages or money | — | One value per bucket in `labels`, index-aligned to it: the point-in-time active-subscriber count at that bucket. |
datasets.totalNew | array of object | conditional — `metric` was omitted or set to `totalNew`, AND the metric produced at least one plot upstream — the map key is created only by appending a plot, so a metric with no rows carries no key at all | — | — | The totalNew series. Requesting a single `metric` suppresses the other keys entirely, and requesting `netGrowth` suppresses this one even though it is fetched to compute it. |
datasets.totalNew[].id | literal "all" | always present | — | — | Constant `"all"` — the series is not broken down by any grouping. |
datasets.totalNew[].group | literal "All" | always present | — | — | Constant `"All"`. |
datasets.totalNew[].data | array of number | always present | whole counts — this tool returns no rates, percentages or money | — | One value per bucket in `labels`, index-aligned to it: subscribers added during that bucket. |
datasets.totalUnsub | array of object | conditional — `metric` was omitted or set to `totalUnsub`, AND the metric produced at least one plot upstream — the map key is created only by appending a plot, so a metric with no rows carries no key at all | — | — | The totalUnsub series. Requesting a single `metric` suppresses the other keys entirely, and requesting `netGrowth` suppresses this one even though it is fetched to compute it. |
datasets.totalUnsub[].id | literal "all" | always present | — | — | Constant `"all"` — the series is not broken down by any grouping. |
datasets.totalUnsub[].group | literal "All" | always present | — | — | Constant `"All"`. |
datasets.totalUnsub[].data | array of number | always present | whole counts — this tool returns no rates, percentages or money | — | One value per bucket in `labels`, index-aligned to it: subscribers who unsubscribed during that bucket. |
datasets.netGrowth | array of object | conditional — `metric` was omitted or set to `netGrowth`; in that case it is ALWAYS present, even when neither input series came back | — | computed in fd-mcp-server as Σ totalNew groups − Σ totalUnsub groups per bucket. It is not an upstream metric: the backend’s roster has no such entry and would reject it. | Net list growth per bucket. The key name is `totalUnsub`, not `totalUnsubscribed`, on the series it subtracts. |
datasets.netGrowth[].id | literal "netGrowth" | always present | — | — | Constant `"netGrowth"` — the connector labels its own series. |
datasets.netGrowth[].group | literal "netGrowth" | always present | — | — | Constant `"netGrowth"` — the same value as `id` on this series. |
datasets.netGrowth[].data | array of number | always present | whole counts (a net figure, so it can be negative) | — | One value per bucket, `totalNew − totalUnsub`. Always the same length as `labels`, zero-filled where an input series had no bucket, so it is never null and never NaN. |
structuredContent: An object with the single key `trends`, carrying the same payload one level deeper — so every field above is at `trends.<path>` when a client reads `structuredContent` instead of the text.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response. The upstream endpoint is not paginated; the series length is set by the window and `interval`.
`from` is REQUIRED and `interval` (`day`, `week` or `month`) selects the bucket size. Each date accepts a bare date or a full RFC-3339 timestamp; `from` is coerced to start-of-day, `to` to end-of-day, and an omitted `to` defaults to today. `from` must be on or before `to`. The window is capped by bucket count, not by the 365-day cap the list tools use: at most 58 days with `day`, 59 weeks with `week`, and 59 months with `month` (inclusive, on UTC calendar dates), so a multi-year window is accepted at `month`.
isError result) — A missing `from` or `interval`, `from` after `to` (or after today when `to` is omitted), a window wider than the interval allows (58 days for `day`, 59 weeks for `week`, 59 months for `month`), or a `metric` outside the four-value enum. Rejected client-side with no upstream call; the span message names the limit and a coarser interval or a shorter range.isError result) — The trend read failed. The window rules behind the upstream "Invalid time range" rejection are enforced client-side (see validation error), so that rejection should not reach this variant. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Returns one segment’s type, membership rule and stored size — what the segment IS, rather than who is in it.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
segment_id | string | yes | — | 1–64 characters | The id of one segment, as returned by `list_segments`. |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. One segment’s definition and size, in the emitted key order `id`, `name`, `type`, optional `filterExpression`, `totalSubscribers`, `activeSubscribers`. The payload is scoped to definition plus size: `color` and `createdAt` are deliberately not relayed, and an explicit projection keeps any future upstream field out until it is reviewed.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
id | string | always present | — | — | The segment id. |
name | string | always present | — | — | The segment’s name. |
type | string, one of: static | dynamic | pre_built | always present | — | renamed from upstream `segmentType`. | The RAW segment type — `pre_built` is never collapsed into `dynamic` here. A type Flodesk adds later surfaces as-is rather than failing the read. |
filterExpression | string (filter-expression) | absent when the segment is `static` — upstream guarantees a nil rule there, so the key is dropped rather than emitted as null | — | — | The membership rule verbatim in Flodesk’s expression syntax, byte for byte as stored: not parsed, normalized or translated into the structured `filter` those other tools take. It deliberately does not reuse the name `filter` for that reason. Present for `dynamic` and `pre_built`, never null. |
totalSubscribers | integer | always present; null when there is no usable stored aggregate — a custom `dynamic` segment (always, however many subscribers its rule matches), a `pre_built` one before its first materialization, or a `static` one whose aggregate row was invalidated | whole count, never a rate | — | All members, as of the last stored aggregate — never recomputed for the call. `null` means the size is unknown, `0` means the tally really is zero. This is the count `list_segments` drops; `inspect_segment` keeps both. |
activeSubscribers | integer | always present; null when there is no usable stored aggregate — a custom `dynamic` segment (always, however many subscribers its rule matches), a `pre_built` one before its first materialization, or a `static` one whose aggregate row was invalidated | whole count, never a rate | renamed from upstream `totalActiveSubscribers`. | Members with `active` status, as of the same stored aggregate. Named to match `list_segments`, and backed by the same aggregate query, so the two segment reads cannot disagree about a segment. |
structuredContent: An object with the single key `segment`, carrying the same payload one level deeper — so every field above is at `segment.<path>` when a client reads `structuredContent` instead of the text.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response. It is a single-resource read; use `list_subscribers` to page the members themselves.
No date inputs: the result is not scoped by a date window. The counts come from stored membership, so there is no window to request and no live recount — `preview_segment_count` is the live-count path.
isError result) — An unknown, deleted, or other account’s id, or an id the route pattern rejects outright. An `isError` result of kind `not_found`, reading `No segment found with id "<id>". Use list_segments to see your segments and their ids.` The upstream body is never relayed here, because it carries raw datastore text.isError result) — A missing, empty or over-64-character `segment_id`, rejected in-handler with one fixed message and no upstream call.isError result) — Any other upstream failure, or an upstream shape change that breaks the response parse. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Lists the individual subscribers who opened or clicked one email, optionally narrowed to one clicked link.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
email_id | string | yes | — | 1–200 characters | Id of an email, from `rank_emails`. |
engagement | string | yes | — | one of: opened | clicked | — |
link | string | no | — | 1–2048 characters | A url; valid only with engagement: 'clicked'. |
limit | integer | no | — | 1 to 100 | Default 25 when omitted. |
page | integer | no | — | 1 to 1000000000 | 1-based; default 1 when omitted. |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. The upstream envelope relayed verbatim. Three facets share it — `opened`, `clicked` without a `link`, and `clicked` with a `link` — and they differ in which keys are present, never in what a key means.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
total | integer | always present | whole count | — | How many recipients match this facet in total, independent of `page` and `limit` and identical on every page. On an empty page beyond the first the count is recovered by re-issuing the same query for one row, so it stays true rather than collapsing to 0. |
page | integer | always present | — | — | The page actually served, after the server floors an invalid or non-positive page to 1 and clamps above 1,000,000,000. |
hasMore | boolean | always present | — | — | True exactly when `total > page × limit`, computed on this endpoint rather than read off a pagination struct. False on the last full page and on any page past the end. This is the stop condition. |
recipients | array of object | always present | — | — | The page of recipients, `[]` when none — never null. Which engagement keys a row carries is decided by the facet, so the row shape differs between `opened` and `clicked`. |
recipients[].id | string | always present | — | — | The subscriber id. |
recipients[].email | string | always present | — | — | Their email address. |
recipients[].firstName | string | always present | — | — | First name, `""` when unset OR when the subscriber row no longer exists (deleted since the send). |
recipients[].lastName | string | always present | — | — | Last name, `""` when unset or unresolvable. |
recipients[].status | string, one of: active | unconfirmed | unsubscribed | bounced | complained | cleaned | archived | | always present | — | — | Current subscription state, or `""` when the subscriber row no longer exists (deleted since the send). The lookup runs without a status filter, so recipients who later unsubscribed still resolve with their real status. |
recipients[].lastOpenedAt | string (rfc3339) | absent when `engagement` is `clicked` (the clicked facets never set it), or the subscriber has no open recorded — the key carries `omitempty`, so it is ABSENT, never null | — | — | The subscriber’s most recent open of this email. The key name is `lastOpenedAt`, and it appears only on the `opened` facet. |
recipients[].opens | integer | absent when `engagement` is `clicked`, or the count is 0 — `omitempty` on a non-pointer int drops a zero, so a missing key means zero, not unknown | whole count | — | How many times this subscriber opened the email. The key name is `opens`. |
recipients[].lastClickedAt | string (rfc3339) | absent when `engagement` is `opened` (that facet never sets it), or the subscriber has no click recorded — the key carries `omitempty`, so it is ABSENT, never null | — | — | The subscriber’s most recent click. The key name is `lastClickedAt`, and it appears only on the two `clicked` facets. |
recipients[].clicks | integer | absent when `engagement` is `opened`, or the count is 0 — a missing key means zero, not unknown | whole count | — | How many times this subscriber clicked. The key name is `clicks`. |
links | array of object | conditional — `engagement: "clicked"` with NO `link`, and the email has at least one clicked link. Absent on the `opened` facet, absent on a `link` drill-in, and absent when the email has no clicked links (an empty slice plus `omitempty`) | — | — | Up to 50 clicked links for the WHOLE email, ordered by url ascending and then truncated — NOT by click count, so when `totalLinks` exceeds this length the omitted links may include the most-clicked ones. It describes the email, not the page, and repeats unchanged on every recipient page. |
links[].url | string | always present | — | — | The NORMALIZED link url (dynamic tracking params collapsed to a placeholder) — the same normalization the `link` drill-in matches against, so a url copied from here works verbatim as the `link` input. |
links[].uniqueClicks | integer | always present | whole count | — | Distinct subscribers who clicked this link. |
links[].totalClicks | integer | always present | whole count | — | All clicks on this link, repeats included. |
totalLinks | integer | absent when it would be 0 — which includes the `opened` facet and the `link` drill-in, neither of which sets it at all | whole count | — | The true count of distinct clicked links, independent of the 50-entry cap, so a caller can tell when `links` is only a top slice. |
link | string | conditional — the `link` drill-in facet only — no other facet emits this key | — | — | The requested link url, echoed back in its normalized form. |
structuredContent: An object with the single key `result`, carrying the same payload one level deeper — so every field above is at `result.<path>` when a client reads `structuredContent` instead of the text.
Paged by `page` (1-based, maximum 1,000,000,000) and `limit` (1–100; the backend defaults to 25 and clamps above 100). `total`, `page` and `hasMore` are BODY fields, so page until `hasMore` is false. `links` and `totalLinks` do NOT page with the rows: they are fetched at page 1 with a 50-row cap and describe the whole email, so they repeat identically on every page.
No date inputs: the result is not scoped by a date window.
isError result) — A missing `email_id`, a missing or unknown `engagement`, a `link` passed with `engagement: "opened"`, or `limit`/`page` out of bounds. Rejected client-side with no upstream call.isError result) — The recipients read failed; the whole 4xx band is relayed verbatim. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Lists the account’s segments with their ids, types and active-member counts, one page at a time — the whole roster is reachable, and this is the way to turn a segment name into an id.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
page | integer | no | — | 1 to 1000000000 | 1-based; default 1 when omitted. |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. One page of the account’s segment roster, including segments `rank_segments` cannot see: this route reads mcp-api’s own segment list rather than the analytics view that inner-joins stored aggregates, so a never-materialized dynamic segment appears here with a `null` count instead of vanishing.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
segments | array of object | always present | — | renamed from the upstream `data` envelope. | The segments on this page, newest-created first, `[]` on an account with none and on any page past the end. Upstream’s `totalSubscribers` is deliberately dropped from each row, as is any future upstream field — `inspect_segment` is where both counts are returned. |
segments[].id | string | always present | — | — | The segment id — the value `inspect_segment` takes as `segment_id`. |
segments[].name | string | always present | — | — | The segment’s name. |
segments[].type | string, one of: static | dynamic | pre_built | always present | — | renamed from upstream `segmentType`. | The RAW segment type: `pre_built` stays `pre_built` here, unlike `get_subscriber`’s memberships, which collapse it into `dynamic`. |
segments[].activeSubscribers | integer | always present; null when there is no usable stored aggregate — every custom `dynamic` segment, a `pre_built` one awaiting its first materialization, or a `static` one whose aggregate row was invalidated | whole count, never a rate | renamed from upstream `totalActives`, relayed with no `?? 0`. | Members with `active` status, as of the last stored aggregate. `null` means the size is NOT COMPUTED and `0` means the tally really is zero — the value is relayed uncoerced precisely so those two cannot be confused. |
total | integer | conditional — the upstream sent it — which it always does today (a non-pointer int), though the handler spreads it conditionally | whole count | — | How many segments the account has in total, independent of how many rows came back and identical on every page. Still correct on a page past the end. |
page | integer | always present | — | relayed from upstream `page`, falling back to the requested page only while an older mcp-api omits the field. | The 1-based page actually served, echoing the `page` input (1 when it was omitted). The server floors an invalid or non-positive page to 1 and clamps above 1,000,000,000; the advertised maximum equals that cap so the echo matches the request. |
hasMore | boolean | always present | — | relayed from upstream `hasMore`, falling back to `total > offset + rows` only while an older mcp-api omits the field. | True exactly when segments beyond this page exist — so it is false on the last page and on any page past the end. This is the stop condition for walking the roster; do not infer one from the row count. |
structuredContent: Identical to the text payload — the same object is sent as `structuredContent`, so the key paths above are the paths a client reads on both channels.
Paged by `page` alone (1-based, maximum 1,000,000,000, default 1). Page SIZE is not an input: every call requests the server’s cap of 100, so the offset is `(page - 1) * 100`. `total`, `page` and `hasMore` are BODY fields, so the stop condition is explicit: page until `hasMore` is false. Ordering is fixed newest-created-first upstream and is not a caller input, which is what keeps a just-created segment on page 1. Paging reads the live roster, so a segment created or deleted between two page requests can move an entry across a page boundary.
No date inputs: the result is not scoped by a date window.
isError result) — A `page` that fails the schema — non-integer, below 1, or above 1,000,000,000. No upstream call is made.isError result) — The handler has no try/catch: any upstream failure, and any upstream shape change that breaks the envelope parse, is converted centrally. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Lists the subscribers matching a structured filter, one page at a time, with the true total for the whole match.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
filter | object | no | — | — | Required unless `emails` or `ids` is given — a filter with 1–10 conditions; an unfiltered audience is not allowed. |
filter.match | string | no | "all" | one of: all | any | — |
filter.conditions | array of object | yes | — | 1–10 items | An array of 1–10 condition objects. Always pass an array, even when there is only one condition. |
filter.conditions[].field | string | yes | — | one of: createdAt | lastActiveAt | lastUnsubscribedAt | openRate | clickRate | openedEmails_l7d | openedEmails_l30d | openedEmails_l90d | openedEmails_l180d | openedEmails_l365d | clickedEmails_l7d | clickedEmails_l30d | clickedEmails_l90d | clickedEmails_l180d | clickedEmails_l365d | deliveredEmails_l7d | deliveredEmails_l30d | deliveredEmails_l90d | deliveredEmails_l180d | deliveredEmails_l365d | openedEmails | clickedEmails | deliveredEmails | status | sourceType | segmentIds | The subscriber attribute or engagement metric to compare. |
filter.conditions[].operator | string | yes | — | one of: gt | ge | lt | le | eq | ne | contains | Comparison operator. Valid choices depend on field: dates and rates use gt, ge, lt, or le only (never eq or ne); counts use gt, ge, lt, le, or eq; status and sourceType use eq or ne only. |
filter.conditions[].value | string | number | yes | — | — | Comparison value. Use a string for dates, status, and sourceType; use a number for rates and engagement counts. Dates must be an absolute RFC-3339 timestamp (e.g. "2026-05-08T00:00:00Z") or a relative "-N days|weeks|months" (e.g. "-90 days") — a bare "2026-05-08" is rejected. Rates are decimals between 0 and 1 (e.g. 0.27 = 27%). Engagement counts are non-negative integers. |
emails | array of string | no | — | 1–100 items; each item 1–320 characters | 1–100 subscriber email addresses to return, matched case-insensitively; duplicates collapse. Not combinable with `ids`. |
ids | array of string | no | — | 1–100 items; each item 1–320 characters; matching ^[A-Za-z0-9-]+$ | 1–100 subscriber ids (the `id` a row or `get_subscriber` returns) to return; duplicates collapse. Not combinable with `emails`. |
signup_form | string | no | — | 1–200 characters | A signup-form name, not an id. |
limit | integer | no | — | 1 to 100 | Default 25 when omitted. |
page | integer | no | — | 1 to 1000000000 | 1-based; default 1 when omitted. |
sort | string | no | — | one of: lastActiveAt | createdAt | openedEmails_l30d | openedEmails_l90d | openedEmails_l365d | openedEmails | clickedEmails_l30d | clickedEmails_l90d | clickedEmails_l365d | clickedEmails | — |
direction | string | no | — | one of: asc | desc | Omitted sorts descending (backend default 'desc'). |
include_segments | boolean | no | — | — | Omitted or false leaves `segments` off every row. |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. The upstream list envelope, relayed verbatim — nothing renamed, dropped or computed by the connector.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
total | integer | always present | whole count | — | How many subscribers match the filter (and, when given, are in the `emails` / `ids` lookup set) in total — its own count over the same filter with no offset or limit term, so it is INDEPENDENT of `page` and `limit`, identical on every page, and still correct on a page past the end. |
page | integer | always present | — | — | The page actually served. The server floors an invalid or non-positive page to 1 and clamps above 1,000,000,000; the advertised maximum equals that cap so the echo matches the request. |
hasMore | boolean | always present | — | — | True exactly when `page` is below `ceil(total / limit)` — so it is false on the last full page (a total divisible by the limit) and false on any page past the end. This is the stop condition; do not infer one from the row count. |
subscribers | array of object | always present | — | — | The page of rows, `[]` when none — never null. Rows are deliberately narrower than `get_subscriber`’s profile to keep per-row PII down: a row never carries `customFields` at all, and carries `segments` only when `include_segments: true` asked for it. |
subscribers[].id | string | always present | — | — | The subscriber id. |
subscribers[].email | string | always present | — | — | Their email address. |
subscribers[].firstName | string | always present | — | — | First name, `""` when unset. |
subscribers[].lastName | string | always present | — | — | Last name, `""` when unset. |
subscribers[].status | string, one of: active | unconfirmed | unsubscribed | bounced | complained | cleaned | archived | always present | — | — | Subscription state; `active` is the backend’s `confirmed`. |
subscribers[].source | string | always present | — | — | How the subscriber joined, WITH the signup-form name appended for form rows — `"Signup form: Newsletter"` — resolved by one bulk form lookup over the page. `get_subscriber` returns the same conceptual field as the bare label with no form name. |
subscribers[].createdAt | string (rfc3339) | always present | — | — | When the subscriber was added. |
subscribers[].lastOpenedAt | string (rfc3339) | always present; null when the subscriber has never opened an email | — | — | Last open. The key is ALWAYS present and `null` means never — the same key is ABSENT rather than null on `list_email_recipients`. |
subscribers[].lastClickedAt | string (rfc3339) | always present; null when the subscriber has never clicked a link | — | — | Last click. Always present; `null` means never. |
subscribers[].segments | array of object | conditional — `include_segments: true` was passed. With the flag off or absent the key is omitted entirely; with it on and the subscriber in no segment, it is `[]` rather than null | — | — | The subscriber’s segment memberships, resolved by ONE bulk lookup over the whole page rather than per row — the lookup’s own row order is never observed, since it is collapsed into an id→{name,type} map and fanned back out. Entries therefore follow the subscriber’s stored `segment_ids` order, minus any id the lookup did not return; that order is not promised and not stable — do not read position as rank or recency. A segment deleted between the page scan and the lookup is one such missing id: it is skipped rather than rendered nameless, so a row can carry fewer entries than the account’s own records suggest. Same `{ name, type }` shape as `get_subscriber`, and the same absence of a segment id. |
subscribers[].segments[].name | string | always present | — | — | The segment’s name. |
subscribers[].segments[].type | string, one of: static | dynamic | always present | — | — | Only `static` or `dynamic` on the subscriber routes — a `pre_built` segment is reported as `dynamic` here, unlike `list_segments` and `inspect_segment`, which return the raw type. |
structuredContent: An object with the single key `result`, carrying the same payload one level deeper — so every field above is at `result.<path>` when a client reads `structuredContent` instead of the text.
Paged by `page` (1-based, maximum 1,000,000,000) and `limit` (1–100; the backend defaults to 25 when omitted and clamps anything above 100). Unlike the ranged analytics tools, `total`, `page` and `hasMore` are BODY fields here, so the stop condition is explicit: page until `hasMore` is false. Rows are ordered by `sort` (default `lastActiveAt`) and `direction` (default `desc`), and paging reads the live list — a concurrent write can move a row across a page boundary, so a row can be seen twice or missed between pages.
No date-window inputs. Date-valued filter conditions take an absolute RFC-3339 timestamp (`2026-05-08T00:00:00Z`) or a relative `"-N days|weeks|months"` string; a bare `2026-05-08` is rejected.
isError result) — A filter, `emails`, `ids`, `limit`, `page`, `sort` or `direction` that fails the schema — including a field/operator pairing the enforced filter rejects, an out-of-bounds lookup set, and an email that fails mcp-api’s own address check (named by its 1-based position, never echoed) — or a call that passes both `emails` and `ids`, or none of `filter` / `emails` / `ids`. No upstream call.isError result) — A `signup_form` matching no form (404) or more than one (400), or a filter that parses but has no SQL form (400) — the upstream detail is relayed verbatim so the model can correct the input. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Lists the links clicked across one automation workflow, or within one of its email-send nodes, ranked by total clicks.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
workflow_id | string | yes | — | 1–200 characters | Id of a workflow, from `rank_workflows`. |
email_id | string | no | — | 1–200 characters | Id of one workflow email, from an email-send node in `rank_workflows` called with `workflow_id`. Narrows the links to that email. |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. The upstream envelope relayed verbatim. Both keys are always present; what changes between the two scopes is which clicks were counted, never what a key means.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
links | array of object | always present | — | — | Up to 50 clicked links, ordered by total clicks descending, so the first row is the most-clicked. `[]` when nothing was clicked — never null. Only links that were ACTUALLY clicked are rows: a link present in the email HTML that nobody clicked is absent, so this is click behavior and not the email’s link inventory. |
links[].url | string | always present | — | — | The NORMALIZED link url — dynamic per-subscriber tracking parameters are collapsed to a placeholder by the shared store query, so two sends of the same link aggregate into one row rather than appearing as separate urls. |
links[].uniqueClicks | integer | always present | whole count | — | Distinct subscribers who clicked this url. A whole count of people, never a rate — no 0-1 decimal appears anywhere in this response. |
links[].totalClicks | integer | always present | whole count | — | All click events on this url, repeats by the same subscriber included. A whole count of clicks, never a rate. This is the key the list is ordered by. |
totalLinks | integer | always present | whole count | — | The true count of distinct clicked links in scope, independent of the 50-row cap, so a truncated list can never read as the complete set. Present and 0 on an empty result rather than absent. |
structuredContent: An object with the single key `result`, carrying the same payload one level deeper — so every field above is at `result.<path>` when a client reads `structuredContent` instead of the text.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response. The list is a single capped page of at most 50 rows and there is no way to request the next 50; `totalLinks` is what makes the shortfall visible. Ranking is exact whenever the scope has 200 or fewer distinct clicked links, which is the wider slice the handler sorts; beyond that the top 50 is taken over the first 200 the store returned in url order.
No date inputs: the result is not scoped by a date window. Counts are lifetime for the workflow or the workflow email, matching the per-node analytics on `rank_workflows`.
isError result) — A missing or empty `workflow_id`, or either id longer than 200 characters. Rejected client-side by the schema with no upstream call.isError result) — The links read failed. The whole 4xx band is relayed with the upstream detail alone; a 5xx or timeout is replaced by the generic sentence. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Reports how many subscribers a bulk archive, unarchive, segment addition, segment removal or CSV export would affect, with a sample and a short-lived confirmation token.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
action | string | yes | — | one of: archive | unarchive | remove_from_segment | add_to_segment | export | The bulk change being previewed. The token is only valid for this action. |
filter | object | no | — | — | The group to act on. Required for every action except `export`, which takes this or `segment_id`. |
filter.match | string | no | "all" | one of: all | any | — |
filter.conditions | array of object | yes | — | 1–10 items | An array of 1–10 condition objects. Always pass an array, even when there is only one condition. |
filter.conditions[].field | string | yes | — | one of: createdAt | lastActiveAt | lastUnsubscribedAt | openRate | clickRate | openedEmails_l7d | openedEmails_l30d | openedEmails_l90d | openedEmails_l180d | openedEmails_l365d | clickedEmails_l7d | clickedEmails_l30d | clickedEmails_l90d | clickedEmails_l180d | clickedEmails_l365d | deliveredEmails_l7d | deliveredEmails_l30d | deliveredEmails_l90d | deliveredEmails_l180d | deliveredEmails_l365d | openedEmails | clickedEmails | deliveredEmails | status | sourceType | segmentIds | The subscriber attribute or engagement metric to compare. |
filter.conditions[].operator | string | yes | — | one of: gt | ge | lt | le | eq | ne | contains | Comparison operator. Valid choices depend on field: dates and rates use gt, ge, lt, or le only (never eq or ne); counts use gt, ge, lt, le, or eq; status and sourceType use eq or ne only. |
filter.conditions[].value | string | number | yes | — | — | Comparison value. Use a string for dates, status, and sourceType; use a number for rates and engagement counts. Dates must be an absolute RFC-3339 timestamp (e.g. "2026-05-08T00:00:00Z") or a relative "-N days|weeks|months" (e.g. "-90 days") — a bare "2026-05-08" is rejected. Rates are decimals between 0 and 1 (e.g. 0.27 = 27%). Engagement counts are non-negative integers. |
segment_id | string | no | — | 1–64 characters | For `add_to_segment`, the segment being added to. For `export`, the saved segment to export — pass this or `filter`, never both. Not accepted for the other actions. |
signup_form | string | no | — | 1–200 characters | A signup-form name, not an id. |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. The read half of the two-step bulk change: it states the cohort truthfully and hands back a short-lived token, and it changes no subscriber. One upstream list call answers both halves, because the upstream total is the true total independent of the 10-row limit.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
affected | integer | always present | whole count | renamed from the upstream `total`. | How many subscribers the change would touch — the TRUE total for the filter, independent of the 10-row sample. This is the number a person is being asked to approve. |
sample | array of object | always present | — | — | At most 10 example rows, newest subscribers first (by when they joined the account), `[]` on the zero-cohort path — never null. It is a SAMPLE for a human check, not the cohort: only `affected` states the size. Each row is projected to three keys; the upstream row’s `firstName`, `lastName`, `source`, `createdAt`, `lastOpenedAt` and `lastClickedAt` are dropped, as are the envelope’s `page` and `hasMore`. |
sample[].id | string | always present | — | — | The subscriber id. |
sample[].email | string | always present | — | — | Their email address. |
sample[].status | string, one of: active | unconfirmed | unsubscribed | bounced | complained | cleaned | archived | always present | — | — | Their current status, so an already-archived cohort is visible before a write. |
action | string, one of: archive | unarchive | remove_from_segment | add_to_segment | export | always present | — | an echo of the input. | The change that was previewed, one of the five bulk actions. The token is only valid for this action, so spending it on a tool that performs a different one is refused. |
filter | object | conditional — the caller supplied a `filter`. Absent on a segment-scoped export, which names its cohort with `segment_id` instead and echoes that; never null | — | an echo of the parsed input. | The filter that was counted, echoed as the structured input. The bulk tool must be given this filter UNCHANGED: the token is bound to a hash of the compiled form, so any edit is refused. On a segment-scoped export the resolved filterlang string is deliberately NOT echoed here — it is an internal binding, and handing it back under a key the filter tools accept would invite it to be fed in as a condition object. |
filter.match | string, one of: all | any | always present | — | — | Whether every condition or any condition had to match. Carries the schema default `all` when the caller omitted it, and is emitted before `conditions`. |
filter.conditions | array of object | always present | — | — | The 1–10 conditions, echoed in the order they were supplied. |
filter.conditions[].field | string | always present | — | — | The subscriber attribute or engagement metric compared. |
filter.conditions[].operator | string, one of: gt | ge | lt | le | eq | ne | contains | always present | — | — | The comparison operator, echoed as supplied. The legal set narrows by field: `gt`/`ge`/`lt`/`le` for dates and rates, plus `eq` for counts; `eq`/`ne` for `status` and `sourceType`; `contains` for `segmentIds` membership. Note `ge`/`le`, not `gte`/`lte`. |
filter.conditions[].value | string or number | always present | — | — | The compared value: a string for dates, status and sourceType; a number for rates and engagement counts. |
segment_id | string | conditional — the caller supplied a `segment_id`, which only `add_to_segment` and `export` accept. Absent for `archive`, `unarchive` and `remove_from_segment`, which reject it outright, and never null | — | an echo of the input. | The segment id echoed back, meaning DIFFERENT things per action. For `add_to_segment` it is the DESTINATION the matching subscribers would be added to, echoed EXACTLY as supplied including its case, while the value the token binds is lower-cased — so an id typed one way here and the other way at the write is still the same segment, and spending that token on any other segment is refused — it appears alongside `filter`, which names the group being added. For `export` it is the SELECTOR: the saved segment whose members would be exported, and no `filter` is echoed at all. The other three actions take their group from `filter` alone and accept no `segment_id`. |
signup_form | string | conditional — the caller supplied a `signup_form`. Absent otherwise, never null | — | an echo of the input. | The signup-form NAME the cohort was scoped to, echoed at the top level. It must be passed to the bulk tool exactly as it appears here — dropping it would widen the cohort beyond what the token authorized, and the token binding refuses that. |
confirmation_token | string (base64url) | conditional — `affected` is above 0 AND the token mint succeeded. It is absent on the zero-cohort payload; a mint failure returns an error instead, so a preview is never returned without a token | — | minted in fd-mcp-server from 32 random bytes and stored in Redis. It has no upstream origin. | The single-use token that authorizes the change, to be passed after a person confirms to whichever tool performs the previewed `action` — `bulk_archive_subscribers`, `bulk_unarchive_subscribers`, `bulk_add_subscribers_to_segment`, `bulk_remove_subscribers_from_segment` or `export_subscribers`. An opaque 43-character string — never parse it. It is bound to this account, this action and this exact selector, and it is claimed atomically, so a replay is refused rather than repeated. |
expires_in_seconds | integer | conditional — `affected` is above 0 AND the token mint succeeded. It is absent on the zero-cohort payload; a mint failure returns an error instead, so a preview is never returned without a token | seconds; always 120 | a fixed lifetime the connector sets, applied as the Redis key expiry and returned unchanged; never an upstream value. | How long the token stays valid from now. Always 120 — a fixed constant, and the same value used as the Redis key expiry, so the advertised number and the real expiry cannot diverge. After it elapses, preview again. |
message | string | conditional — `affected` is 0 — mutually exclusive with the token pair by construction, since a zero cohort is never issued a token | — | composed in fd-mcp-server, with the wording forked so an export is not told there is “nothing to change”. | A sentence explaining that nothing matches the selector, so no token was issued, and inviting a different one. The wording forks on the action: every action but `export` is told there is nothing to CHANGE, and `export` that there is nothing to EXPORT — an export alters no subscriber, so the generic wording would send a reader looking for the wrong fix. |
structuredContent: An object with the single key `result`, carrying the same payload one level deeper — so every field above is at `result.<path>` when a client reads `structuredContent` instead of the text.
Not paginated, and deliberately so: there is no page input, the sample is capped at 10 rows client-side and upstream, and `affected` carries the full size. The upstream envelope’s `page` and `hasMore` are dropped, so this tool cannot be walked to enumerate the cohort — use `list_subscribers` with the same filter for that.
No date inputs: the result is not scoped by a date window. Date-valued filter conditions take an absolute RFC-3339 timestamp or a relative `"-N days|weeks|months"` string.
isError result) — A filter that fails the schema, or a `signup_form` that is empty after trimming. No upstream call.isError result) — A `signup_form` matching no form (404) or several (400), or a filter that parses but has no SQL form (400) — relayed verbatim SPECIFICALLY so the failure is not misread as an empty cohort. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.isError result) — An `isError` result of kind `internal`: the token store was unavailable, so the change cannot be authorized. It FAILS CLOSED — returning the preview without a token would read as permission to proceed.Counts the subscribers a filter matches, without creating anything and without returning any subscriber data.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
filter | object | yes | — | — | Required — a filter with 1–10 conditions; an unfiltered audience or count is not allowed. |
filter.match | string | no | "all" | one of: all | any | — |
filter.conditions | array of object | yes | — | 1–10 items | An array of 1–10 condition objects. Always pass an array, even when there is only one condition. |
filter.conditions[].field | string | yes | — | one of: createdAt | lastActiveAt | lastUnsubscribedAt | openRate | clickRate | openedEmails_l7d | openedEmails_l30d | openedEmails_l90d | openedEmails_l180d | openedEmails_l365d | clickedEmails_l7d | clickedEmails_l30d | clickedEmails_l90d | clickedEmails_l180d | clickedEmails_l365d | deliveredEmails_l7d | deliveredEmails_l30d | deliveredEmails_l90d | deliveredEmails_l180d | deliveredEmails_l365d | openedEmails | clickedEmails | deliveredEmails | status | sourceType | segmentIds | The subscriber attribute or engagement metric to compare. |
filter.conditions[].operator | string | yes | — | one of: gt | ge | lt | le | eq | ne | contains | Comparison operator. Valid choices depend on field: dates and rates use gt, ge, lt, or le only (never eq or ne); counts use gt, ge, lt, le, or eq; status and sourceType use eq or ne only. |
filter.conditions[].value | string | number | yes | — | — | Comparison value. Use a string for dates, status, and sourceType; use a number for rates and engagement counts. Dates must be an absolute RFC-3339 timestamp (e.g. "2026-05-08T00:00:00Z") or a relative "-N days|weeks|months" (e.g. "-90 days") — a bare "2026-05-08" is rejected. Rates are decimals between 0 and 1 (e.g. 0.27 = 27%). Engagement counts are non-negative integers. |
signup_form | string | no | — | 1–200 characters | A signup-form name, not an id. |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. The one tool that builds its own payload rather than relaying an upstream one: the upstream response carries a single `count` field, and every other key is an echo of the input or minted MCP-side.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
count | integer | always present | whole count, never a rate | — | How many subscribers match the filter right now. It is the same number `list_subscribers` reports as `total` — both come from the same count query — so a preview and a listing cannot disagree. `0` is a normal success, not an error. |
filter | object | always present | — | built in fd-mcp-server from the parsed input; `match` carries its schema default when the caller omitted it. | The filter that was counted, echoed back as the STRUCTURED input rather than the compiled expression sent upstream — so it can be confirmed and handed unchanged to `create_segment`. |
filter.match | string, one of: all | any | always present | — | — | Whether every condition or any condition had to match. Carries the schema default `all` when the caller omitted it, and is emitted before `conditions`. |
filter.conditions | array of object | always present | — | — | The 1–10 conditions, echoed in the order they were supplied. |
filter.conditions[].field | string | always present | — | — | The subscriber attribute or engagement metric compared. |
filter.conditions[].operator | string, one of: gt | ge | lt | le | eq | ne | contains | always present | — | — | The comparison operator, echoed as supplied. The legal set narrows by field: `gt`/`ge`/`lt`/`le` for dates and rates, plus `eq` for counts; `eq`/`ne` for `status` and `sourceType`; `contains` for `segmentIds` membership. Note `ge`/`le`, not `gte`/`lte`. |
filter.conditions[].value | string or number | always present | — | — | The compared value: a string for dates, status and sourceType; a number for rates and engagement counts. |
signup_form | string | conditional — the caller supplied a `signup_form`. Absent otherwise — never null, and never nested inside `filter` | — | an echo of the input. | The signup-form NAME the count was scoped to, echoed at the top level. Its presence is what tells a reader the count was form-scoped rather than account-wide. |
confirmation_token | string | conditional — `count` is above zero AND the token store was reachable. Absent on a zero count, and absent — with a `message` saying so — when the mint failed | — | minted MCP-side into Redis by `mintToken` ; the upstream count endpoint knows nothing about it. | A single-use token authorizing `create_segment` for exactly this cohort: the compiled filter plus the normalized `signup_form`, bound to the calling account. It binds no destination, because the segment it authorizes does not exist yet. It is valid ONLY for creating a segment — every bulk write refuses it as a wrong-action token — and spending it twice is refused as already-used. |
expires_in_seconds | integer | conditional — `confirmation_token` is present | seconds | — | How long the token stays redeemable — a fixed connector-side constant, not an upstream value. |
message | string | conditional — no token accompanies the count — either `count` is 0, or the mint failed. Absent on the ordinary tokened success | — | — | Why no token is present, and what that means for creating a segment. On a zero count: nothing matched, so there is nothing to authorize. On a mint failure: the count is still accurate and the number is the answer, but a segment cannot be created from it until the token store recovers. The count is deliberately NOT failed in that case — this is a Live read tool whose primary job is the number, and the fail-closed guarantee lives at `create_segment`s redeem instead. |
structuredContent: An object with the single key `result`, carrying the same payload one level deeper — so every field above is at `result.<path>` when a client reads `structuredContent` instead of the text.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response. The tool deliberately sends no `limit`, `page` or `sort`: it is the count-only sibling of `list_subscribers` and returns no rows at all.
No date inputs: the result is not scoped by a date window. Date-valued filter conditions take an absolute RFC-3339 timestamp or a relative `"-N days|weeks|months"` string.
isError result) — A filter that fails the schema, including a field/operator pairing the enforced filter rejects. No upstream call.isError result) — An unknown `signup_form` (404), an ambiguous one (400), or a filter that parses but will not compile (400) — relayed verbatim. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.isError result — no structuredContent, raw error text) — This tool Zod-parses its upstream body with a throwing parse, so an upstream shape change raises a ZodError that is not an `AppError`: nothing relays it, it is the one throw that leaves `instrument()`, and the SDK converts it into an `isError` result carrying the raw validation message with NO `structuredContent`. Deliberate — an upstream shape mismatch is a server fault, not something to echo to the model as data — but it is the one failure whose shape differs from every other, so do not expect the structured channel here.Ranks sent emails by one performance metric, returning per-email counts, rates and subject lines.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
order_by | string | no | — | one of: sentAt | totalSends | totalUniqueOpens | openRate | totalUniqueClicks | clickRate | unsubRate | — |
sort | string | no | — | one of: asc | desc | — |
page | integer | no | — | 1 to ∞ | — |
per_page | integer | no | — | 1 to 20 | — |
search | string | no | — | 1–200 characters | — |
segment_id | string | no | — | 1–64 characters | — |
from | string | no | — | 1–64 characters | — |
to | string | no | — | 1–64 characters | — |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. A single-key envelope. There is no `total`, `page`, `perPage` or `hasMore` anywhere in this response.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
data | array of object | always present | — | — | The ranked page, `[]` when nothing matched — never null and never absent. It is the ONLY key in this payload. |
data[].id | string | always present | — | — | The email id — this is the value `list_email_recipients` takes as `email_id`. A resend row carries its PARENT’s id, so the same id can appear more than once in one page. |
data[].name | string | always present | — | — | The internal email name; a resend row reads `"<name> - Resend"`. |
data[].sentAt | string (rfc3339) | always present | — | — | When the email was sent. |
data[].totalSends | integer | always present | whole count | — | Individual sends for this email. |
data[].totalUniqueOpens | integer | always present | whole count | — | Distinct subscribers who opened it. |
data[].openRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Unique opens divided by deliveries. |
data[].totalUniqueClicks | integer | always present | whole count | — | Distinct subscribers who clicked it. |
data[].clickRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Unique clicks divided by deliveries. |
data[].unsubRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Unsubscribes divided by deliveries. Also a valid `order_by` value. |
data[].subject | string | always present | — | the VALUE is chosen in fd-mcp-server: the analytics row’s subject when non-empty, otherwise the subject from a second upstream campaign read joined on `id`, otherwise `""`. | The subject line as a recipient saw it, possibly `""` when it could not be resolved on either source. The key is always present. Stored as editor HTML and relayed only after the same parse-and-render pair the send pipeline uses, so markup is stripped, entities are decoded, and a personalization token resolves to its authored default rather than arriving as `{{ subscriber.firstName | … }}` template source — the same helper `get_email_stats` uses. The fallback source (the separate campaign lookup used when the analytics row carries no subject) is relayed raw, so in that narrow case the two tools could in principle differ in form. |
data[].abTest | object | always present; null when the campaign ran no split test — the common case, since most campaigns are not tests | — | — | The compact A/B summary, carried so split tests are visible among the rows on THIS page rather than by probing each campaign. It is not filterable or sortable — there is no A/B input parameter — and rows beyond the requested page carry no signal at all. The row’s own counts and rates are BLENDED across both variants plus the remainder send, not the winning variant alone. A RESEND row is the exception: it carries the original’s id, subject and this block while its counts are the resend’s own, so its rates describe no split test at all and its subject is the original’s, identical to the parent row on the same page. Per-variant figures live on `get_email_stats` only. |
data[].abTest.status | string, one of: draft | scheduled | running | finalizing | completed | cancelled | always present | — | — | Where the split test is in its lifecycle. Only `running`, `finalizing`, `completed` and `cancelled` are reachable here — this list ranks sent campaigns, and a `draft` or `scheduled` test belongs to one that has not sent. |
data[].abTest.winner | string, one of: A | B | tie | none | absent when the test has not picked a winner — either still undecided, or CANCELLED, which is finalized yet never picks one. `omitempty` upstream, so no key at all. That ABSENCE is a third state, distinct from `none` (finalized, but no variant cleared the minimum-opens floor) and from `tie` | — | — | The winning variant once the test finalizes. `none` is a real outcome, not an error. |
data[].templateSource | string | absent when the campaign recorded no template source at all. All three template keys are omitempty, so a campaign duplicated from another — which records the empty source — carries none of them | — | — | Where the email’s design came from — a gallery template, a private gallery template, a template the user saved, a scratch build, Canva, or Studio. Resolved by the exact ids on this ranked page, so it carries no recency cap, unlike the subject fallback above. |
data[].templateId | string | absent when the campaign referenced no template — built from scratch, or duplicated from another campaign | — | — | The stable identifier of the template this email was built on. It is what groups a ranked page by design without reading a single email body; `get_email_content` returns the same identifier for one email. |
data[].templateName | string | absent when the referenced template record carries no name — a template the user saved themselves has none, and neither does a gallery template that has since been deleted | — | — | The template’s display name. Absence is a fact about the stored record, never a failed lookup: the identifier is still returned and is sufficient to group by design. |
data[].recipientScope | object | always present; null when the campaign has no stored audience filter — the scope is UNKNOWN. That is a different answer from a scope with empty segment lists, which means nobody was addressed; treating unknown as empty is what makes a cross-campaign average wrong | — | — | Who this email was sent to. Comparing campaigns with different recipient scopes is not like-for-like: a curated segment and an all-subscribers send routinely differ several-fold in open rate. A resend row is reported under its parent’s id and therefore carries the ORIGINAL send’s scope — an upper bound on who the resend reached. |
data[].recipientScope.allSubscribers | boolean | always present | — | — | True when the email was addressed to every subscriber. It can be true alongside a non-empty `excludedSegments` — "everyone except the cold list" is one scope, not two. |
data[].recipientScope.includedSegments | array of object | always present | — | — | The segments the email was addressed to, `[]` when none — never null. Empty with `allSubscribers` true is the normal all-subscribers shape: the system segment is the marker, not a listed entry. |
data[].recipientScope.includedSegments[].id | string | always present | — | — | The segment id, kept even when the segment no longer exists. |
data[].recipientScope.includedSegments[].name | string | always present; null when the segment was deleted after the send and can no longer be resolved. The id above survives regardless, so the reported audience is never silently narrower than the one that received the email | — | — | The segment’s CURRENT name — no historical name is stored, so a segment renamed since the send shows its new name. |
data[].recipientScope.includedSegments[].unresolved | boolean | absent when the segment resolved normally | — | — | Set to `true` alongside a null `name` when the segment was deleted after the send. Pairs with `name: null`; check this rather than inferring from the name. |
data[].recipientScope.excludedSegments | array of object | always present | — | — | The segments held back from the send, `[]` when none — never null. Present so the reported audience matches who actually received the email rather than only who it was aimed at. |
data[].recipientScope.excludedSegments[].id | string | always present | — | — | The segment id, kept even when the segment no longer exists. |
data[].recipientScope.excludedSegments[].name | string | always present; null when the segment was deleted after the send and can no longer be resolved. The id above survives regardless, so the reported audience is never silently narrower than the one that received the email | — | — | The segment’s CURRENT name — no historical name is stored, so a segment renamed since the send shows its new name. |
data[].recipientScope.excludedSegments[].unresolved | boolean | absent when the segment resolved normally | — | — | Set to `true` alongside a null `name` when the segment was deleted after the send. Pairs with `name: null`; check this rather than inferring from the name. |
data[].recipientScope.addedSubscribers | integer | always present | whole count | — | How many individual subscribers were added on top of the segments. Their identities are deliberately NOT returned. |
data[].recipientScope.removedSubscribers | integer | always present | whole count | — | How many individual subscribers were removed from the send. Identities are not returned. |
data[].recipientScope.omittedSegments | integer | absent when every segment fitted in the two lists above — the overwhelmingly common case | whole count | — | How many segments were dropped from `includedSegments`/`excludedSegments` to keep the response within the tool result size limit. At most five segments are listed per campaign, exclusions kept first. When present, the lists are a prefix of the audience rather than all of it. |
structuredContent: An object with the single key `results`, carrying the same payload one level deeper — so every field above is at `results.<path>` when a client reads `structuredContent` instead of the text.
Paged by `page` (1-based) and `per_page` (1–20; no advertised default — the backend uses 10 and silently resets anything outside 1–20 to 10). The upstream writes `X-Total`, `X-Total-Pages`, `X-Page` and `X-Per-Page` as HTTP response headers and the connector’s client returns only the parsed body, so the response carries NO `total`, `page`, `perPage` or `hasMore` field. A short or empty `data` array is the only end-of-collection signal, and an empty page cannot be told apart from a page past the end. When `search` is set, the caller's `page` and `per_page` are IGNORED: the connector requests the newest 100 rows on the chosen metric with no page and intersects them client-side with the search matches. So a search result can exceed `per_page`, can be `[]` even when 100 rows ranked, and silently omits matching emails ranked below position 100 — and the search side is separately capped at the backend's default 20 campaigns.
Accepts `from` and `to` as either a bare date (`2026-09-01`) or a full RFC-3339 timestamp. `from` is coerced to start-of-day and `to` to end-of-day, in the window the backend compares; an omitted `to` defaults to today. `from` must be on or before `to`, and the resolved span must not exceed 365 days — both are client-side validation errors, not upstream ones. Omitting `from` selects the lifetime path and sends no window upstream.
isError result) — An out-of-range `page`/`per_page`, an unknown `order_by`, `from` after `to`, or a span over 365 days. Rejected client-side with no upstream call.isError result) — Either upstream call failed; both run under one Promise.all, so one failure fails the tool call. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.isError result — no structuredContent, raw error text) — This tool Zod-parses its upstream body with a throwing parse, so an upstream shape change raises a ZodError that is not an `AppError`: nothing relays it, it is the one throw that leaves `instrument()`, and the SDK converts it into an `isError` result carrying the raw validation message with NO `structuredContent`. Deliberate — an upstream shape mismatch is a server fault, not something to echo to the model as data — but it is the one failure whose shape differs from every other, so do not expect the structured channel here.Ranks signup forms by traffic or opt-in performance, alongside the account-wide form summary.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
order_by | string | no | — | one of: totalVisitors | totalOptIns | optInRate | — |
sort | string | no | — | one of: asc | desc | — |
page | integer | no | — | 1 to ∞ | — |
per_page | integer | no | — | 1 to 20 | — |
from | string | no | — | 1–64 characters | — |
to | string | no | — | 1–64 characters | — |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. Two upstream responses joined: the summary under a connector-chosen `overall` key, and the ranked list’s own `data` key alongside it. Nothing is renamed, dropped or recomputed.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
overall | object | always present | — | `overall` is a key name fd-mcp-server gives the first upstream response; the second response’s own keys are spread as siblings rather than nested. | The account-wide summary, always complete — it does not paginate with `data`, so a page past the end still returns full totals. |
overall.totalVisitors | integer | always present | whole count | — | Visitors across all forms, window-scoped when `from` is given. |
overall.totalOptIns | integer | always present | whole count | — | Opt-ins across all forms, window-scoped when `from` is given. |
overall.optInRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Opt-ins divided by visitors across all forms. |
overall.hasDataBeforeNov22 | boolean | always present | — | — | An upstream flag for whether the account has form data before the November 2022 boundary the analytics service tracks (Go field `DataBeforeNov`). It is relayed to the caller and mentioned by no tool description; read it as a completeness hint about older figures, not as a metric. |
data | array of object | always present | — | — | The ranked page of forms, `[]` when empty — never null. This is the only paged half of the payload. |
data[].id | string | always present | — | — | The form id. |
data[].name | string | always present | — | — | The form’s name. |
data[].totalVisitors | integer | always present | whole count | — | Visitors to this form. |
data[].totalOptIns | integer | always present | whole count | — | Opt-ins from this form. |
data[].optInRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | This form’s opt-ins divided by its visitors. |
data[].createdAt | string (rfc3339) | always present | — | — | When the form was created. |
structuredContent: An object with the single key `results`, carrying the same payload one level deeper — so every field above is at `results.<path>` when a client reads `structuredContent` instead of the text.
Paged by `page` (1-based) and `per_page` (1–20; no advertised default — the backend uses 10 and silently resets anything outside 1–20 to 10). The upstream writes `X-Total`, `X-Total-Pages`, `X-Page` and `X-Per-Page` as HTTP response headers and the connector’s client returns only the parsed body, so the response carries NO `total`, `page`, `perPage` or `hasMore` field. A short or empty `data` array is the only end-of-collection signal, and an empty page cannot be told apart from a page past the end. Only `data` is paged — `overall` is always the complete account-wide summary.
Accepts `from` and `to` as either a bare date (`2026-09-01`) or a full RFC-3339 timestamp. `from` is coerced to start-of-day and `to` to end-of-day, in the window the backend compares; an omitted `to` defaults to today. `from` must be on or before `to`, and the resolved span must not exceed 365 days — both are client-side validation errors, not upstream ones. Omitting `from` selects the lifetime path and sends no window upstream.
isError result) — A legacy camelCase parameter, `page` below 1, `per_page` outside 1–20, `from` after `to`, or a span over 365 days. Rejected client-side with no upstream call.isError result) — Either call failed; they run under one Promise.all, so a healthy summary is lost when the list read fails. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Ranks segments by size or by email engagement, to show which parts of an audience respond best.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
order_by | string | no | — | one of: totalActives | openRate | clickRate | — |
sort | string | no | — | one of: asc | desc | — |
page | integer | no | — | 1 to ∞ | — |
per_page | integer | no | — | 1 to 20 | — |
from | string | no | — | 1–64 characters | — |
to | string | no | — | 1–64 characters | — |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. The upstream envelope relayed verbatim: no field renamed, none dropped, nothing derived, no unit converted.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
data | array of object | always present | — | — | The ranked page, `[]` when empty — never null. It is the only key in this payload. INCOMPLETENESS TO KNOW ABOUT: on the lifetime path (no `from`) the underlying view inner-joins the stored segment aggregate, so a segment with no aggregate — every custom dynamic segment, since those are never materialized — is missing ENTIRELY rather than reported with zeros. `list_segments` is the complete roster. |
data[].segmentId | string | always present | — | — | The segment id. The key is `segmentId`, not `id` — this tool renames nothing, unlike `list_segments`, which projects to `id`/`name`. |
data[].segmentName | string | always present | — | — | The segment’s name; `""` when the stored name is null. |
data[].segmentColor | string | always present | — | — | The segment’s colour as stored; `""` when null. Relayed verbatim and mentioned by no tool description. |
data[].totalActives | integer | always present | whole count | — | Members with `active` status — the segment’s CURRENT size, which is not scoped by the date window even on a windowed call. Only the rates respond to the window. |
data[].openRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Unique opens divided by deliveries for this segment. |
data[].clickRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Unique clicks divided by deliveries for this segment. |
data[].percentage | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | This segment’s share of the account’s total active subscribers — `0` when the account has none. Mentioned by no tool description. |
structuredContent: An object with the single key `results`, carrying the same payload one level deeper — so every field above is at `results.<path>` when a client reads `structuredContent` instead of the text.
Paged by `page` (1-based) and `per_page` (1–20; no advertised default — the backend uses 10 and silently resets anything outside 1–20 to 10). The upstream writes `X-Total`, `X-Total-Pages`, `X-Page` and `X-Per-Page` as HTTP response headers and the connector’s client returns only the parsed body, so the response carries NO `total`, `page`, `perPage` or `hasMore` field. A short or empty `data` array is the only end-of-collection signal, and an empty page cannot be told apart from a page past the end.
Accepts `from` and `to` as either a bare date (`2026-09-01`) or a full RFC-3339 timestamp. `from` is coerced to start-of-day and `to` to end-of-day, in the window the backend compares; an omitted `to` defaults to today. `from` must be on or before `to`, and the resolved span must not exceed 365 days — both are client-side validation errors, not upstream ones. Omitting `from` selects the lifetime path and sends no window upstream.
isError result) — A legacy camelCase parameter (`orderBy`, `perPage`), `page` below 1, `per_page` outside 1–20, `from` after `to`, or a span over 365 days. All rejected client-side — note that the upstream would instead silently clamp `per_page` and silently rewrite an unknown `order_by`, which is why the tool advertises a narrow enum.isError result) — The segment analytics read failed. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Ranks automations by entries, completions or engagement; with a `workflow_id`, returns that workflow’s step graph and per-step counts instead.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
order_by | string | no | — | one of: totalEntries | totalCompletions | openRate | clickRate | unsubRate | — |
sort | string | no | — | one of: asc | desc | — |
page | integer | no | — | 1 to ∞ | — |
per_page | integer | no | — | 1 to 20 | — |
workflow_id | string | no | — | 1–64 characters | — |
from | string | no | — | 1–64 characters | — |
to | string | no | — | 1–64 characters | — |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. TWO DISJOINT PAYLOADS under one tool, selected by `workflow_id`. Without it: a single `data` array of ranked workflows. With it: one workflow object with fifteen top-level keys and no `data` at all. The two share only the meanings of `id`, `name` and `status` — every other key belongs to exactly one mode.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
data | array of object | conditional — `workflow_id` was omitted — list mode. Absent in detail mode | — | — | The ranked page of workflows, `[]` when empty — never null. Ten keys per row: nine relayed plus `completionRate`. |
data[].id | string | always present | — | — | The workflow id — pass it back as `workflow_id` for the per-step breakdown. |
data[].name | string | always present | — | — | The workflow’s name. |
data[].status | string, one of: active | paused | draft | always present | — | — | The workflow’s state. |
data[].totalEntries | integer | always present | whole count | — | Subscribers who entered the workflow; window-scoped when `from` is given. |
data[].totalCompletions | integer | always present | whole count | — | Subscribers who completed it; window-scoped when `from` is given. |
data[].totalActives | integer | always present | whole count | — | Subscribers currently inside the workflow. The key is `totalActives` — not `totalActiveSubscribers`, and not the checkout rows’ `totalActiveSubscriptions`. |
data[].openRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Unique opens divided by deliveries for this workflow’s emails. |
data[].clickRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Unique clicks divided by deliveries. |
data[].unsubRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | — | Unsubscribes divided by deliveries. |
data[].completionRate | number | always present | decimals between 0 and 1 (e.g., 0.27 = 27%) | computed per row in fd-mcp-server as `totalCompletions ÷ totalEntries`. Not an upstream field. | Completions divided by entries, or exactly `0` when there were no entries — never null, never NaN. It cannot be used as `order_by`, because the upstream cannot sort by a value computed here. |
id | string | conditional — `workflow_id` was supplied — detail mode. Absent in list mode | — | — | The workflow id. |
version | integer | conditional — `workflow_id` was supplied — detail mode. Absent in list mode | — | — | The stored version number of this workflow. |
name | string | conditional — `workflow_id` was supplied — detail mode. Absent in list mode | — | — | The workflow’s name. |
status | string, one of: active | paused | draft | conditional — `workflow_id` was supplied — detail mode. Absent in list mode | — | — | The workflow’s state. |
nodes | array of object | conditional — `workflow_id` was supplied — detail mode. Absent in list mode | — | — | The workflow’s steps, `[]` for a stepless draft. Per-step values are INTEGER COUNTS, never rates: derive step conversion (`totalCompletedTimes ÷ totalEnteredTimes`) and the drop-off between adjacent steps yourself. Unreachable steps are pruned before the response is built, so the graph can be smaller than the editor shows. |
nodes[].uid | string | always present | — | — | A legacy duplicate of `id`; the upstream struct says to use `id` instead. Relayed as-is. |
nodes[].id | string | always present | — | — | The step id. |
nodes[].name | string | always present | — | — | The step name from the workflow step-name roster (for example `sendEmail`). Together with `type` it identifies what the step does. |
nodes[].type | string, one of: trigger | action | delay | condition | confirmedSubscriberCondition | multipleBranch | join | always present | — | — | The step type, which also decides how its two count fields are computed. |
nodes[].data | object with dynamic keys | always present | — | — | The step’s raw configuration payload, relayed verbatim. Its keys and value types are decided by the step type, and it is `{}` rather than null when the step stores nothing. |
nodes[].data.<key> | any JSON value (string, number, boolean, null, array or object) | one entry per key present in the payload | — | — | step-specific configuration keys — an email-send step carries `emailId`, which is the id `list_email_recipients` accepts |
nodes[].isRequired | boolean | always present | — | — | Whether the step cannot be removed from the workflow. |
nodes[].order | integer | always present | — | — | The step’s ordinal position in the stored graph. |
nodes[].totalSubscribers | integer | always present | whole count | — | Subscribers sitting AT this step right now — a point-in-time snapshot, not a cumulative figure. `0` when the step has no stats row. |
nodes[].totalEnteredTimes | integer | always present | whole count | — | How many times subscribers reached this step. Computed upstream per step type: a `delay` step counts starts, every other type counts finishes — which is what keeps the funnel monotonic. |
nodes[].totalCompletedTimes | integer | always present | whole count | — | How many times subscribers finished this step, with force-stops subtracted so it can never exceed `totalEnteredTimes`. |
nodes[].totalUniqueOpens | integer | absent when the step is not an email-send step. It is present and `0` on an email step with no engagement, so the KEY’s presence — not its value — is how an email step is identified in the payload | whole count | — | Distinct subscribers who opened the email this step sends. |
nodes[].totalUniqueClicks | integer | absent when the step is not an email-send step, exactly as for `totalUniqueOpens` | whole count | — | Distinct subscribers who clicked in the email this step sends. |
edges | array of object | conditional — `workflow_id` was supplied — detail mode. Absent in list mode | — | — | The transitions between steps; `[]` when there are none. |
edges[].source | string | always present | — | — | The step id this edge leaves. |
edges[].target | string | always present | — | — | The step id it enters. |
edges[].isFixed | boolean | always present | — | — | Whether the transition is structural rather than user-editable. |
edges[].value | any JSON value (string, number, boolean, null, array or object) | absent when the transition stores no value | — | — | The transition’s stored condition value, relayed verbatim. Its shape is decided by the source step type. |
edges[].branchId | string | absent when the transition belongs to no branch (an empty id) | — | — | Which branch of a multi-branch step this transition belongs to. |
config | object | conditional — `workflow_id` was supplied — detail mode. Absent in list mode | — | — | Entry and repeat configuration for the workflow. |
config.repeatAllowed | boolean | always present | — | — | Whether a subscriber may re-enter the workflow. |
config.repeatConfig | object | always present | — | — | Always present even when repeats are off: the `omitempty` on it upstream has no effect on a Go struct VALUE, so expect a zeroed object rather than a missing key. |
config.repeatConfig.duration | integer | always present | — | — | How long before re-entry is allowed, in units of `unit`. |
config.repeatConfig.unit | string, one of: hour | day | | always present | — | — | The unit `duration` is measured in, or `""` when repeats were never configured on this workflow. |
config.excludedSegmentIds | array of string | absent when no ids are stored for this workflow. `excludedSegmentIds,omitempty` on the upstream Go slice drops both nil and empty, and the normalizer that would turn nil into `[]` is not called on this read path — so the key is absent or a NON-EMPTY array, and `[]` never appears | — | — | Segments whose members are excluded from entering. |
config.excludedWorkflowIds | array of string | absent when no ids are stored for this workflow, exactly as for `excludedSegmentIds` — absent or non-empty, never `[]` | — | — | Workflows whose members are excluded from entering. |
isAbandonedCartWorkflow | boolean | conditional — `workflow_id` was supplied — detail mode. Absent in list mode | — | — | True when any step is triggered by an abandoned checkout. |
shouldExpandTriggers | boolean | conditional — `workflow_id` was supplied — detail mode. Absent in list mode | — | — | An editor hint relayed from the stored workflow. |
shouldMigrateConditions | boolean | conditional — `workflow_id` was supplied — detail mode. Absent in list mode | — | — | An editor hint relayed from the stored workflow. |
sanitized | boolean | conditional — `workflow_id` was supplied — detail mode. Absent in list mode | — | — | An upstream flag on the stored workflow, relayed as-is. |
tag | string | conditional — `workflow_id` was supplied — detail mode. Absent in list mode | — | — | The workflow’s tag, `""` when unset. |
sharedTemplateId | string | conditional — `workflow_id` was supplied — detail mode. Absent in list mode | — | — | The shared template this workflow came from, `""` when none. |
createdAt | string (rfc3339) | conditional — `workflow_id` was supplied — detail mode. Absent in list mode | — | — | When the workflow was created. |
updatedAt | string (rfc3339) | conditional — `workflow_id` was supplied — detail mode. Absent in list mode | — | — | When the workflow was last changed. |
structuredContent: An object with the single key `results`, carrying the same payload one level deeper — so every field above is at `results.<path>` when a client reads `structuredContent` instead of the text.
Paged by `page` (1-based) and `per_page` (1–20; no advertised default — the backend uses 10 and silently resets anything outside 1–20 to 10). The upstream writes `X-Total`, `X-Total-Pages`, `X-Page` and `X-Per-Page` as HTTP response headers and the connector’s client returns only the parsed body, so the response carries NO `total`, `page`, `perPage` or `hasMore` field. A short or empty `data` array is the only end-of-collection signal, and an empty page cannot be told apart from a page past the end. Detail mode takes no paging input and returns the whole graph in one response.
List mode: Accepts `from` and `to` as either a bare date (`2026-09-01`) or a full RFC-3339 timestamp. `from` is coerced to start-of-day and `to` to end-of-day, in the window the backend compares; an omitted `to` defaults to today. `from` must be on or before `to`, and the resolved span must not exceed 365 days — both are client-side validation errors, not upstream ones. Omitting `from` selects the lifetime path and sends no window upstream. Detail mode accepts NO date window — passing `from` or `to` with `workflow_id` is a client-side validation error rather than a silently ignored input, and the per-step counts are always lifetime.
isError result) — An `isError` result carrying the upstream message `no workflow found with id "<id>"`. Another account’s workflow takes this same path and is deliberately indistinguishable from an absent one; a `workflow_id` that is not a valid object id never matches the route and 404s the same way.isError result) — A legacy camelCase parameter (`orderBy`, `perPage`, `workflowId`), `workflow_id` together with `from`/`to`, `page` below 1, `per_page` outside 1–20, `from` after `to`, or a span over 365 days. Rejected client-side with no upstream call.isError result) — The workflow read failed. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.isError result — no structuredContent, raw error text) — This tool Zod-parses its upstream body with a throwing parse, so an upstream shape change raises a ZodError that is not an `AppError`: nothing relays it, it is the one throw that leaves `instrument()`, and the SDK converts it into an `isError` result carrying the raw validation message with NO `structuredContent`. Deliberate — an upstream shape mismatch is a server fault, not something to echo to the model as data — but it is the one failure whose shape differs from every other, so do not expect the structured channel here.Removes one subscriber from one static segment, idempotently.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
email | string | no | — | 1–320 characters | — |
id | string | no | — | 1–320 characters | — |
segment_id | string | yes | — | 1–320 characters | — |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. A four-key receipt, relayed verbatim. It removes a membership, not the subscriber: nothing here reports a deletion.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
removed | boolean | always present | — | — | True when this call removed the membership, false when the subscriber was not in the segment. BOTH are successes; `false` means "was not a member", never "failed". Note the key name: it is `removed` here and `added` on the add tool — the boolean’s name is the only difference between the two receipts. |
subscriber | object | always present | — | — | Just the two identifying keys — no status, names or memberships. |
subscriber.id | string | always present | — | — | The subscriber id. |
subscriber.email | string | always present | — | — | Their email address. |
segment | object | always present | — | — | The segment written to, so the name can be confirmed against the id that was sent. |
segment.id | string | always present | — | — | The segment id. |
segment.name | string | always present | — | — | The segment’s name. |
message | string | always present | — | — | A sentence stating the outcome — `Removed <email> from segment "<name>".`, or `<email> is not in segment "<name>".` when `removed` is false. Composed UPSTREAM, not by the connector. |
structuredContent: Identical to the text payload — the same object is sent as `structuredContent`, so the key paths above are the paths a client reads on both channels.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response.
No date inputs: the result is not scoped by a date window.
isError result) — Both `email` and `id`, or neither, or an `email` that is not a valid address. Rejected in-handler with no upstream call.isError result) — An unknown or not-owned segment (404), a dynamic or pre-built segment (400), an unknown subscriber (404), or — unlike the add tool — an empty or whitespace-only `segment_id` (400), because here it travels as a URL path segment rather than a validated body field. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Unsubscribes one subscriber, and reports whether the change actually applied or their status already prevented it.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
email | string | no | — | 1–320 characters | — |
id | string | no | — | 1–320 characters | — |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. A three-key receipt, relayed verbatim. It changes status only: no segment membership, name or custom field is touched, and nothing is deleted.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
changed | boolean | always present | — | — | True when this call unsubscribed them, false when their status did not allow it. BOTH are successes: the backend checks for an error BEFORE reading this flag, precisely so a genuine failure can never arrive as a clean `changed: false`. |
subscriber | object | always present | — | — | Three keys: this is the only one of the four simple write receipts whose nested subscriber carries a `status`, and it carries no `segment` key at all. |
subscriber.id | string | always present | — | — | The subscriber id. |
subscriber.email | string | always present | — | — | Their email address. |
subscriber.status | string, one of: active | unconfirmed | unsubscribed | bounced | complained | cleaned | archived | always present | — | — | Their ACTUAL current status — `unsubscribed` after a successful change, or the real state that blocked it (`unconfirmed`, `bounced`, `complained`, `cleaned`). It is the backend’s echo and is never composed on the connector side. |
message | string | always present | — | — | A per-status sentence, not boilerplate. On success it states the change is irreversible from the connector and that the subscriber must opt back in through Flodesk. On `changed: false` it explains the blocking status specifically — an `unconfirmed` subscriber is described as still pending double opt-in and NOT as an opt-out, a `bounced` one as hard-bounced, a `complained` one as having reported spam, and any other suppressed state as no longer receiving email. Composed UPSTREAM. |
structuredContent: Identical to the text payload — the same object is sent as `structuredContent`, so the key paths above are the paths a client reads on both channels.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response.
No date inputs: the result is not scoped by a date window.
isError result) — Both `email` and `id`, or neither — checked FIRST so a request naming both is told which to drop — or an `email` that is not a valid address. Neither makes an upstream call.isError result) — An unknown subscriber (404). Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.Updates one subscriber’s name or custom fields and returns the stored values after the write.
| Parameter | Type | Required | Advertised default | Bounds | Description |
|---|---|---|---|---|---|
email | string | no | — | 1–320 characters | — |
id | string | no | — | 1–320 characters | — |
first_name | string | no | — | 0–100 characters | — |
last_name | string | no | — | 0–100 characters | — |
custom_fields | object with dynamic keys, values string | no | — | keys 1–100 characters; each value 0–1000 characters | — |
original_prompt | string | no | — | 0–8192 characters | Provide a one-sentence summary of the user's task intent for this turn (example: 'wants top-performing email campaigns this month'). Used solely for product analytics; does not change the response. Do not include the full conversation, prior turns, the system prompt, or content unrelated to this tool's task. Omit if the user did not provide an explicit task. |
Text payload: object, always present. TWO keys only, and NO boolean flag — there is no `changed`, `updated` or `added` here, unlike the other three simple writes. Every accepted call performs a write, so there is no idempotent no-op variant to distinguish.
| Field | Type | Presence | Units | Derived in the connector | Meaning |
|---|---|---|---|---|---|
subscriber | object | always present | — | — | The updated profile: the create-path shape plus `customFields`. It still omits `createdAt`, `source` and the activity timestamps that `get_subscriber` carries. |
subscriber.id | string | always present | — | — | The subscriber id. |
subscriber.email | string | always present | — | — | Their email address. |
subscriber.firstName | string | always present | — | — | First name AFTER the update, `""` when unset or just cleared. Read it back to confirm what was applied. |
subscriber.lastName | string | always present | — | — | Last name after the update, `""` when unset or just cleared. |
subscriber.status | string, one of: active | unconfirmed | unsubscribed | bounced | complained | cleaned | archived | always present | — | — | Subscription state. This tool never changes it — use `unsubscribe_subscriber` for that. |
subscriber.segments | array of object | always present | — | — | Memberships after the update, `[]` when none. The re-read comes from the writer, so a dynamic-segment membership shifted BY this edit is already reflected rather than replica-lagged. |
subscriber.segments[].name | string | always present | — | — | The segment’s name. |
subscriber.segments[].type | string, one of: static | dynamic | always present | — | — | Only `static` or `dynamic` on the subscriber routes — a `pre_built` segment is reported as `dynamic` here, unlike `list_segments` and `inspect_segment`, which return the raw type. |
subscriber.customFields | object with dynamic keys | always present | — | — | All stored custom-field values after the update — ALWAYS an object, `{}` when empty rather than null, so applied values can be confirmed without a second `get_subscriber`. |
subscriber.customFields.<key> | any JSON value (string, number, boolean, null, array or object) | one entry per key present in the payload | — | — | the account’s custom-field machine keys — the same keys this tool sets values by, and the same keys `get_subscriber` surfaces |
message | string | always present | — | — | A sentence naming each changed field and its new value — `Updated <email>: first_name → Jane; custom field 'company' → Acme.` A field set to `""` reads as `first_name cleared`, and custom-field keys are sorted so the sentence is deterministic. Composed UPSTREAM. |
structuredContent: Identical to the text payload — the same object is sent as `structuredContent`, so the key paths above are the paths a client reads on both channels.
Not paginated: no page or per-page input, and no `total`, `page`, `perPage` or `hasMore` field in the response.
No date inputs: the result is not scoped by a date window.
isError result) — Both `email` and `id`, or neither; an `email` that is not a valid address; or no mutable field at all — an empty update is rejected before any upstream call.isError result) — An unknown subscriber (404), or an unknown or not-owned custom-field key — which NAMES EVERY OFFENDER and writes nothing at all, so a partial application cannot happen. Failures share one shape: the message in `content[0].text`, `structuredContent` as `{ "message": <the same text> }`, and `isErrorResult: true`. An upstream 4xx relays the upstream detail verbatim; an upstream 5xx or timeout is replaced by a fixed generic sentence that names no status, body or peer.