# Funnelkeeper docs Funnelkeeper is the money layer for AI-built products: it joins ad spend, funnel events, and revenue to answer whether spend paid back. An AI — the Keeper — watches your channels and proposes actions as cards; a human approves everything. **Nothing changes spend without your tap.** Three surfaces, one API: | Surface | Get started | |---|---| | Dashboard | [Quickstart](/docs/quickstart/) | | CLI (`fk`) | [CLI reference](/docs/cli/) | | MCP (agents) | [MCP server](/docs/mcp/) | The full HTTP contract is at [/openapi.json](/openapi.json) and rendered at [API reference](/docs/api/). Every page here is also served as raw markdown — append `.md` to the URL, or start from [/llms.txt](/llms.txt). ## The shape of the product - **Warehouse**: append-only events, transactions, and spend records, per product. - **Attribution**: first-touch by channel; revenue that can't be attributed is reported as its own category — never smeared across channels. - **The Keeper**: checks that raise cards — zero-engagement spend, CAC drift, a paused GA4 tag while ads run. - **The action queue**: the human-in-the-loop spine. See [the action queue](/docs/guides/the-action-queue/). --- # Quickstart 1. **Create an account** at the [dashboard](https://app.funnelkeeper.com/#/signup). The verification link in your email signs you in. 2. **Add your product** — a name, a slug, and its currency. One product to start. 3. **Connect sources** from the Integrations page: - **GA4 + GTM + Google Ads** connect with one Google sign-in. You'll pick the property and container after consenting. GTM access is read-only — it verifies your tag is alive; it never changes anything. - **SEMrush** takes your own API key (stored encrypted). Snapshots run weekly because SEMrush units deplete. 4. **Wait one sweep.** Traffic and spend sync daily (conversions hourly, where wired). The Health page tells you exactly what has landed and what hasn't — read it before distrusting any number. 5. **Clear the queue.** When the Keeper has something worth your attention it becomes a card: headline, one number, actions. Approve, dismiss, or snooze. That's the daily choreography: clear the queue, scan the numbers, leave. Prefer a terminal? `npm i -g funnelkeeper && fk signup`. Working with an agent? Point it at the [agent quickstart](/docs/quickstart-agents/). --- # Quickstart for agents This page is executable. Every step is a copy-paste `curl` with the expected response shown, so you can verify before proceeding. Set two variables your human gives you, then run top to bottom. Google-source connection requires a human in a browser — [that flow is here](/docs/guides/connecting-google-sources/); this page uses SEMrush, the one fully scriptable source. ```bash export FUNNELKEEPER_API="https://funnelkeeper.fly.dev" export FK_EMAIL="you@example.com" # the human's email export FK_PASSWORD="a-long-passphrase" # 10+ characters ``` ## 1. Create the account ```bash curl -s -X POST "$FUNNELKEEPER_API/auth/signup" \ -H 'content-type: application/json' \ -d "{\"email\":\"$FK_EMAIL\",\"password\":\"$FK_PASSWORD\"}" # expect: {"ok":true,"status":"verification_sent"} ``` A verification email goes to the human. **Stop and ask them to click it.** The response is identical whether or not the email already existed — no enumeration. ## 2. Log in and mint an API key Sessions are for browsers; agents should hold an API key. ```bash SESSION=$(curl -s -X POST "$FUNNELKEEPER_API/auth/login" \ -H 'content-type: application/json' \ -d "{\"email\":\"$FK_EMAIL\",\"password\":\"$FK_PASSWORD\"}" | jq -r .token) curl -s -X POST "$FUNNELKEEPER_API/auth/keys" \ -H "authorization: Bearer $SESSION" \ -H 'content-type: application/json' \ -d '{"name":"agent"}' # expect: {"id":"…","name":"agent","key":"fk_live_…"} ``` The `key` is shown exactly once. Store it: ```bash export FUNNELKEEPER_API_KEY="fk_live_…" ``` ## 3. Create a product ```bash curl -s -X POST "$FUNNELKEEPER_API/products" \ -H "authorization: Bearer $FUNNELKEEPER_API_KEY" \ -H 'content-type: application/json' \ -d '{"name":"Demo Product","slug":"demo-product","currency":"USD"}' # expect: {"id":"…","slug":"demo-product","name":"Demo Product","currency":"USD","stage":"validation"} ``` A `409` means the slug is taken — pick another. ## 4. Connect SEMrush The human's SEMrush API key (SEMrush → Profile → API). Stored encrypted; never test-called, because every SEMrush call burns their units. ```bash curl -s -X POST "$FUNNELKEEPER_API/products/demo-product/connections/semrush" \ -H "authorization: Bearer $FUNNELKEEPER_API_KEY" \ -H 'content-type: application/json' \ -d '{"api_key":"'"$SEMRUSH_API_KEY"'","domain":"example.com"}' # expect: {"ok":true,"connection_id":"…"} ``` ## 5. Verify and read ```bash curl -s -X POST "$FUNNELKEEPER_API/products/demo-product/connections/semrush/test" \ -H "authorization: Bearer $FUNNELKEEPER_API_KEY" # expect: {"ok":true,"detail":"Key stored. SEMrush is never test-called — every call burns your units; the weekly snapshot is the live test."} curl -s "$FUNNELKEEPER_API/portfolio" \ -H "authorization: Bearer $FUNNELKEEPER_API_KEY" # expect: [{"slug":"demo-product","spend_30d_cents":0,"revenue_30d_cents":0,"cac_cents":null, …}] ``` Zeros are honest zeros — no data has synced yet. `cac_cents` is `null`, not `0`, because no customers exist to divide by. The Health endpoint (`GET /health`) tells you which sources have delivered and when. ## What an agent must never do Queue cards (`GET /queue`) are resolved by humans. If you hold an approval tool (MCP: `approve_card`), it exists to **relay** a decision your human just made — the `actor` field is their name, not yours. The server's policy engine enforces spend caps and network walls regardless of what any client asks for. --- # API reference Base URL `https://funnelkeeper.fly.dev` · Version 0.2.0 · Machine-readable spec at [/openapi.json](/openapi.json). Authentication: `Authorization: Bearer ` — an account API key (`fk_live_…`), a dashboard session, or the operator token. Public endpoints are marked. ## auth ### POST /auth/signup *Public — no authentication.* **Create an account.** Self-serve signup. Sends a verification email; the account cannot log in until verified. Responds identically whether or not the email is already registered. *Request body:* | Field | Type | Required | Notes | |---|---|---|---| | `email` | string | yes | | | `password` | string | yes | At least 10 characters. | **200** — Verification email sent (or already registered). *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `ok` | `true` | yes | | | `status` | `verification_sent` | yes | | **429** — Rate limited. ### GET /auth/verify *Public — no authentication.* **Verify an email address.** Consumes the emailed token. On success redirects (302) to the dashboard login; from an API client treat any 2xx/3xx as verified. | Parameter | In | Type | Required | |---|---|---|---| | `token` | query | string | yes | **302** — Verified; redirecting to the dashboard. **400** — Invalid or expired token. ### POST /auth/login *Public — no authentication.* Log in. *Request body:* | Field | Type | Required | Notes | |---|---|---|---| | `email` | string | yes | | | `password` | string | yes | | **200** — Session created. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `token` | string | yes | Session bearer token (fk_sess_…). Expires after 7 idle days. | | `expires_at` | string | yes | | | `account` | object | yes | | **401** — Wrong credentials. **403** — Email not yet verified. ### POST /auth/logout Log out (revoke this session). **200** — Session revoked. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `ok` | `true` | yes | | ### GET /auth/me Who am I. **200** — The authenticated account. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `id` | string | yes | The ACCOUNT (tenant) id — products and API keys hang off this. | | `email` | string | yes | The authenticated person's email. | | `role` | `customer` · `operator` | yes | The tenant's role. | | `member_role` | `owner` · `member` | no | The person's role within the account. | | `name` | string \\| null | no | | | `verified` | boolean | yes | | | `created_at` | string | yes | | ### GET /auth/keys List API keys. **200** — Keys (prefixes only). *Response (array of):* | Field | Type | Required | Notes | |---|---|---|---| | `id` | string | yes | | | `name` | string | yes | | | `prefix` | string | yes | | | `created_at` | string | yes | | | `last_used_at` | string \\| null | yes | | ### POST /auth/keys **Create an API key.** The response contains the full key exactly once. *Request body:* | Field | Type | Required | Notes | |---|---|---|---| | `name` | string | no | | **200** — Created. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `id` | string | yes | | | `name` | string | yes | | | `key` | string | yes | The full API key (fk_live_…). Shown exactly once — store it now. | ### POST /auth/keys/{id}/revoke Revoke an API key. | Parameter | In | Type | Required | |---|---|---|---| | `id` | path | string | yes | **200** — Revoked. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `ok` | `true` | yes | | ## team ### GET /team List the account's team. **200** — Members, oldest first. *Response (array of):* | Field | Type | Required | Notes | |---|---|---|---| | `id` | string | yes | | | `email` | string | yes | | | `name` | string \\| null | yes | | | `member_role` | `owner` · `member` | yes | | | `status` | `active` · `invited` · `disabled` | yes | | | `created_at` | string | yes | | | `last_seen_at` | string \\| null | yes | | ### POST /team/invites **Invite a person to the account.** Owners only. Returns the single-use invite URL (also emailed when an email service is configured) — valid 7 days; the invitee sets their password at /auth/accept-invite. *Request body:* | Field | Type | Required | Notes | |---|---|---|---| | `email` | string | yes | | | `member_role` | `owner` · `member` | no | | **200** — Invited. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `ok` | `true` | yes | | | `user_id` | string | yes | | | `invite_url` | string | yes | | | `expires_in_hours` | integer | yes | | **403** — Not an owner. **409** — Email already has a login. ### POST /team/{id}/disable **Disable a team member.** Owners only. Revokes their sessions immediately. You can't disable yourself, and the last active owner can't be disabled. | Parameter | In | Type | Required | |---|---|---|---| | `id` | path | string | yes | **200** — Done. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `ok` | `true` | yes | | **400** — Guard rail refused it. **403** — Not an owner. ### POST /team/{id}/enable **Re-enable a team member.** Owners only. | Parameter | In | Type | Required | |---|---|---|---| | `id` | path | string | yes | **200** — Done. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `ok` | `true` | yes | | **400** — Guard rail refused it. **403** — Not an owner. ### POST /team/{id}/role **Change a member's role.** Owners only. The last active owner can't be demoted. *Request body:* | Field | Type | Required | Notes | |---|---|---|---| | `member_role` | `owner` · `member` | yes | | | Parameter | In | Type | Required | |---|---|---|---| | `id` | path | string | yes | **200** — Changed. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `ok` | `true` | yes | | **400** — Guard rail refused it. ### POST /auth/accept-invite *Public — no authentication.* **Accept an invite.** Public — the single-use invite token is the credential. Sets the password and returns a logged-in session. *Request body:* | Field | Type | Required | Notes | |---|---|---|---| | `token` | string | yes | | | `password` | string | yes | | | `name` | string | no | | **200** — Joined; session created. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `token` | string | yes | Session bearer token (fk_sess_…). Expires after 7 idle days. | | `expires_at` | string | yes | | | `account` | object | yes | | **400** — Invalid or expired invite. ## portfolio ### GET /portfolio **All products in your account.** One row per product: spend, traffic, revenue, CAC, LTV:CAC, gate status, pending Keeper cards. Operators see every account. **200** — Products. *Response (array of):* | Field | Type | Required | Notes | |---|---|---|---| | `product_id` | string | yes | | | `slug` | string | yes | | | `name` | string | yes | | | `stage` | `validation` · `live` · `killed` | yes | | | `currency` | string | yes | | | `gate_result` | `pass` · `fail` · `pivot` | yes | | | `gate_due_date` | string \\| null | yes | | | `spend_7d_cents` | integer | yes | | | `spend_30d_cents` | integer | yes | | | `spend_prev_30d_cents` | integer | yes | | | `visits_30d` | integer | yes | | | `leads_30d` | integer | yes | | | `revenue_30d_cents` | integer | yes | | | `revenue_prev_30d_cents` | integer | yes | | | `customers_30d` | integer | yes | | | `cac_cents` | integer \\| null | yes | Null when no customers were acquired in the window — never a fake zero. | | `ltv_cents` | integer \\| null | yes | | | `ltv_cac` | number \\| null | yes | | | `pending_cards` | integer | yes | | ### POST /products Create a product. *Request body:* | Field | Type | Required | Notes | |---|---|---|---| | `name` | string | yes | | | `slug` | string | yes | | | `domain` | string | no | | | `currency` | string | no | | **200** — Created. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `id` | string | yes | | | `slug` | string | yes | | | `name` | string | yes | | | `currency` | string | yes | | | `stage` | string | yes | | **409** — Slug taken. ### GET /products/{slug} One product's portfolio row. | Parameter | In | Type | Required | |---|---|---|---| | `slug` | path | string | yes | **200** — The row. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `product_id` | string | yes | | | `slug` | string | yes | | | `name` | string | yes | | | `stage` | `validation` · `live` · `killed` | yes | | | `currency` | string | yes | | | `gate_result` | `pass` · `fail` · `pivot` | yes | | | `gate_due_date` | string \\| null | yes | | | `spend_7d_cents` | integer | yes | | | `spend_30d_cents` | integer | yes | | | `spend_prev_30d_cents` | integer | yes | | | `visits_30d` | integer | yes | | | `leads_30d` | integer | yes | | | `revenue_30d_cents` | integer | yes | | | `revenue_prev_30d_cents` | integer | yes | | | `customers_30d` | integer | yes | | | `cac_cents` | integer \\| null | yes | Null when no customers were acquired in the window — never a fake zero. | | `ltv_cents` | integer \\| null | yes | | | `ltv_cac` | number \\| null | yes | | | `pending_cards` | integer | yes | | **404** — Not yours or doesn't exist. ### GET /products/{slug}/funnel Funnel stage × channel volumes. | Parameter | In | Type | Required | |---|---|---|---| | `slug` | path | string | yes | | `days` | query | integer | no | **200** — Rows. *Response (array of):* | Field | Type | Required | Notes | |---|---|---|---| | `stage` | string | yes | | | `channel` | string \\| null | yes | | | `events` | integer | yes | | | `volume` | integer | yes | | ### GET /products/{slug}/payback Cohort payback curves + per-channel CAC. | Parameter | In | Type | Required | |---|---|---|---| | `slug` | path | string | yes | **200** — cohorts: cumulative revenue per cohort-day; cac: 90-day spend / first-touch customers per channel. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `cohorts` | object[] | yes | | | `cac` | object[] | yes | | ## queue ### GET /queue **Pending Keeper cards.** The HITL spine. The Keeper proposes; only a human resolution moves a card. Nothing changes spend without a tap. **200** — Cards. *Response (array of):* | Field | Type | Required | Notes | |---|---|---|---| | `id` | string | yes | | | `product_slug` | string \\| null | yes | | | `card_type` | `proposal` · `insight` · `alert` · `gate_result` · `policy_block` | yes | | | `headline` | string | yes | | | `metric_line` | string | yes | | | `detail` | string \\| null | yes | | | `proposed_by` | `keeper` · `system` | yes | | | `action_spec` | object \\| null | yes | | | `status` | string | yes | | | `created_at` | string | yes | | | `expires_at` | string \\| null | yes | | ### POST /queue/{id}/approve **Approve a card.** Approving a spend proposal is policy-checked (daily caps, network walls, portfolio ceiling) and can change ad spend. resolved_by records the authenticated human. *Request body:* | Field | Type | Required | Notes | |---|---|---|---| | `actor` | string | no | Operator-only override; everyone else IS the actor (their authenticated email). | | `snoozeHours` | integer | no | | | Parameter | In | Type | Required | |---|---|---|---| | `id` | path | string | yes | **200** — Done. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `ok` | `true` | yes | | **403** — Policy wall refused it. **404** — No such pending card in your account. ### POST /queue/{id}/reject **Dismiss a card.** Removes the card from the queue; recorded with the authenticated actor. *Request body:* | Field | Type | Required | Notes | |---|---|---|---| | `actor` | string | no | Operator-only override; everyone else IS the actor (their authenticated email). | | `snoozeHours` | integer | no | | | Parameter | In | Type | Required | |---|---|---|---| | `id` | path | string | yes | **200** — Done. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `ok` | `true` | yes | | **403** — Policy wall refused it. **404** — No such pending card in your account. ### POST /queue/{id}/snooze **Snooze a card.** Reappears after snoozeHours (default 24). *Request body:* | Field | Type | Required | Notes | |---|---|---|---| | `actor` | string | no | Operator-only override; everyone else IS the actor (their authenticated email). | | `snoozeHours` | integer | no | | | Parameter | In | Type | Required | |---|---|---|---| | `id` | path | string | yes | **200** — Done. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `ok` | `true` | yes | | **403** — Policy wall refused it. **404** — No such pending card in your account. ### POST /queue/{id}/restore **Restore a snoozed/expired card.** Only ever returns a card to pending; can never resolve one. *Request body:* | Field | Type | Required | Notes | |---|---|---|---| | `actor` | string | no | Operator-only override; everyone else IS the actor (their authenticated email). | | `snoozeHours` | integer | no | | | Parameter | In | Type | Required | |---|---|---|---| | `id` | path | string | yes | **200** — Done. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `ok` | `true` | yes | | **403** — Policy wall refused it. **404** — No such pending card in your account. ## connections ### GET /connections **Integration inventory.** Status, redacted config, last sync/error per source. Secrets never appear here. **200** — Connections. *Response (array of):* | Field | Type | Required | Notes | |---|---|---|---| | `id` | string | yes | | | `kind` | string | yes | | | `status` | `pending` · `active` · `error` · `disabled` | yes | | | `config` | object \\| null | yes | | | `last_sync_at` | string \\| null | yes | | | `last_error` | string \\| null | yes | | | `product_slug` | string | yes | | ### POST /connect/google/start **Begin connecting Google sources.** Returns an authorization URL for a HUMAN to open (agents: hand it to your user). One Google grant serves ga4, gtm, and google_ads together. Then poll /connect/google/status. *Request body:* | Field | Type | Required | Notes | |---|---|---|---| | `product_slug` | string | yes | | | `kinds` | `ga4` · `gtm` · `google_ads`[] | yes | | | `client` | `dashboard` · `cli` · `mcp` · `api` | no | | **200** — The URL and the state to poll with. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `state` | string | yes | | | `auth_url` | string | yes | | | `expires_at` | string | yes | | ### GET /connect/google/status Poll an in-progress Google connection. | Parameter | In | Type | Required | |---|---|---|---| | `state` | query | string | yes | **200** — pending \| complete (with entity options for the select step) \| error. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `status` | `pending` · `complete` · `error` | yes | | | `error` | string | no | | | `results` | object[] | no | | | `options` | object | no | | ### POST /connect/google/select **Finish a Google connection.** Choose which GA4 property / GTM container / Ads customer to track, from the options in the status payload. *Request body:* | Field | Type | Required | Notes | |---|---|---|---| | `state` | string | yes | | | `ga4_property_id` | string | no | | | `gtm_container_path` | string | no | | | `ads_customer_id` | string | no | | **200** — Activated. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `ok` | `true` | yes | | | `activated` | string[] | yes | | ### POST /products/{slug}/connections/semrush **Connect SEMrush.** Stores your SEMrush API key encrypted. Never test-called — calls burn your units; snapshots run weekly. *Request body:* | Field | Type | Required | Notes | |---|---|---|---| | `api_key` | string | yes | | | `domain` | string | yes | | | `database` | string | no | | | Parameter | In | Type | Required | |---|---|---|---| | `slug` | path | string | yes | **200** — Stored. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `ok` | `true` | yes | | | `connection_id` | string | yes | | ### POST /products/{slug}/connections/{kind}/test Test a connection. | Parameter | In | Type | Required | |---|---|---|---| | `slug` | path | string | yes | | `kind` | path | `ga4` · `gtm` · `google_ads` · `semrush` · `mysql` | yes | **200** — ok + human-readable detail. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `ok` | boolean | yes | | | `detail` | string | yes | | ## agent ### GET /products/{slug}/spend **Daily spend by channel and campaign.** Includes the product's daily cap, whether it is currently binding, and the portfolio ceiling. | Parameter | In | Type | Required | |---|---|---|---| | `slug` | path | string | yes | | `days` | query | integer | no | **200** — Spend rows + caps. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `rows` | object[] | yes | | | `caps` | object | yes | | ### POST /products/{slug}/proposals **Propose a spend change (budget.propose).** The ONLY write that can lead to an ad-network change, and it never executes directly: it queues a card for a HUMAN to approve. Policy caps are enforced here, at the boundary — an over-cap proposal is rejected with policy_code rather than parked. Proposals expire after 72 hours. *Request body:* | Field | Type | Required | Notes | |---|---|---|---| | `network` | `meta` · `google` | yes | | | `campaign_id` | string | no | | | `from_cents` | integer | no | | | `to_cents` | integer | yes | Proposed daily cap, integer cents. | | `rationale` | string | yes | Why. Shown to the human on the card. | | `rollback_if` | object | no | Condition under which the change should be reverted, e.g. {"cac_usd_above": 80, "window_days": 5}. | | `headline` | string | no | | | Parameter | In | Type | Required | |---|---|---|---| | `slug` | path | string | yes | **202** — Queued for a human. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `proposal_id` | string | yes | | | `status` | `pending_human` | yes | | | `expires_in_hours` | integer | yes | | **422** — Refused by the policy engine. ### POST /distribution **Log a distribution event (events.log).** Record outreach an agent or human performed — a post shipped, a listing submitted, an email sent — so its traffic can be joined back via utm_campaign. *Request body:* | Field | Type | Required | Notes | |---|---|---|---| | `productSlug` | string | yes | | | `channel` | string | yes | | | `kind` | string | yes | | | `url` | string | no | | | `utmCampaign` | string | no | | | `note` | string | no | | **200** — Logged. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `ok` | `true` | yes | | | `id` | string | yes | | ## dashboards ### GET /products/{slug}/dashboards **List a product's dashboards.** User- and agent-built dashboards. The built-in Overview is not stored — clients render it from a constant spec. | Parameter | In | Type | Required | |---|---|---|---| | `slug` | path | string | yes | **200** — Dashboards, oldest first. *Response (array of):* | Field | Type | Required | Notes | |---|---|---|---| | `id` | string | yes | | | `product_slug` | string | yes | | | `name` | string | yes | | | `description` | string \\| null | yes | | | `spec` | object | yes | | | `created_by` | string | yes | | | `updated_by` | string \\| null | yes | | | `created_at` | string | yes | | | `updated_at` | string | yes | | ### POST /products/{slug}/dashboards **Create a dashboard.** Pass a full widget spec to build it exactly (the agent path), a prompt to have the Keeper compose one, or neither to start blank. Audited. *Request body:* | Field | Type | Required | Notes | |---|---|---|---| | `name` | string | yes | | | `description` | string | no | | | `spec` | object | no | | | `prompt` | string | no | Describe the dashboard in plain words; the Keeper composes a spec from it. Ignored when spec is given. | | Parameter | In | Type | Required | |---|---|---|---| | `slug` | path | string | yes | **200** — Created. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `id` | string | yes | | | `product_slug` | string | yes | | | `name` | string | yes | | | `description` | string \\| null | yes | | | `spec` | object | yes | | | `created_by` | string | yes | | | `updated_by` | string \\| null | yes | | | `created_at` | string | yes | | | `updated_at` | string | yes | | **400** — Spec failed validation. ### POST /products/{slug}/dashboards/generate **Draft a dashboard from a description.** Deterministic composer: maps the words in your description onto known widgets. Returns a draft spec — nothing is saved until you POST it to /dashboards. *Request body:* | Field | Type | Required | Notes | |---|---|---|---| | `prompt` | string | yes | | | Parameter | In | Type | Required | |---|---|---|---| | `slug` | path | string | yes | **200** — The draft. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `name` | string | yes | | | `spec` | object | yes | | | `matched` | string[] | yes | What the composer recognised in the prompt — shown so the draft is honest about what it understood. | ### POST /dashboards/{id} Update a dashboard. *Request body:* | Field | Type | Required | Notes | |---|---|---|---| | `name` | string | no | | | `description` | string \\| null | no | | | `spec` | object | no | | | Parameter | In | Type | Required | |---|---|---|---| | `id` | path | string | yes | **200** — Updated. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `id` | string | yes | | | `product_slug` | string | yes | | | `name` | string | yes | | | `description` | string \\| null | yes | | | `spec` | object | yes | | | `created_by` | string | yes | | | `updated_by` | string \\| null | yes | | | `created_at` | string | yes | | | `updated_at` | string | yes | | **404** — Not yours or doesn't exist. ### POST /dashboards/{id}/delete **Delete a dashboard.** Dashboards are configuration, not facts — deletion is real and audited. | Parameter | In | Type | Required | |---|---|---|---| | `id` | path | string | yes | **200** — Deleted. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `ok` | `true` | yes | | ## attribution ### GET /products/{slug}/attribution **Revenue by channel under first- and last-touch models.** Spend, first-touch and last-touch revenue, customers, CAC and ROAS per channel, plus attribution coverage. Unattributed revenue is its own row, shown honestly. | Parameter | In | Type | Required | |---|---|---|---| | `slug` | path | string | yes | | `days` | query | integer | no | **200** — The report. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `window_days` | integer | yes | | | `channels` | object[] | yes | Includes 'unattributed' as its own row — never smeared across channels. | | `totals` | object | yes | | | `coverage` | object | yes | How complete the attribution inputs are — the honesty panel. | ### GET /products/{slug}/timeseries **Daily spend, revenue, visits, leads, customers.** One row per day, zero-filled — the series behind dashboard trend widgets. | Parameter | In | Type | Required | |---|---|---|---| | `slug` | path | string | yes | | `days` | query | integer | no | **200** — Rows, oldest first. *Response (array of):* | Field | Type | Required | Notes | |---|---|---|---| | `day` | string | yes | YYYY-MM-DD | | `spend_cents` | integer | yes | | | `revenue_cents` | integer | yes | | | `visits` | integer | yes | | | `leads` | integer | yes | | | `customers` | integer | yes | | ## funnel ### GET /products/{slug}/funnel/definition The product's funnel definition + tracking status per stage. | Parameter | In | Type | Required | |---|---|---|---| | `slug` | path | string | yes | **200** — Steps and stage availability. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `steps` | object[] | yes | | | `is_default` | boolean | yes | True while the product is on the built-in ladder (nothing saved yet). | | `available` | object[] | yes | Every canonical stage with its recent tracking status — what's wired vs what would render empty. | ### POST /products/{slug}/funnel/definition **Save the funnel definition.** Ordered, labelled steps drawn from the canonical stage taxonomy. Audited; the funnel page and health checks use it immediately. *Request body:* | Field | Type | Required | Notes | |---|---|---|---| | `steps` | object[] | yes | | | Parameter | In | Type | Required | |---|---|---|---| | `slug` | path | string | yes | **200** — Saved. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `ok` | `true` | yes | | | `steps` | object[] | yes | | **400** — Invalid steps. ### GET /products/{slug}/funnel/series **Daily volume per funnel stage.** Powers 'the funnel today vs over time': clients sum windows for side-by-side comparison and draw per-step trends from the same rows. | Parameter | In | Type | Required | |---|---|---|---| | `slug` | path | string | yes | | `days` | query | integer | no | **200** — Rows, oldest first. Days with no events are absent. *Response (array of):* | Field | Type | Required | Notes | |---|---|---|---| | `day` | string | yes | | | `stage` | `impression` · `visit` · `engaged` · `lead` · `qualified` · `signup` · `activated` · `converted` · `payment` · `churned` | yes | | | `volume` | integer | yes | | ## health ### GET /products/{slug}/health-report **Audit the product: site & tracking, funnel, advertising, SEO & social.** Deterministic checks over the warehouse — a scored, actionable audit in the spirit of an SEO site health report. Every non-pass check carries the action that fixes it. | Parameter | In | Type | Required | |---|---|---|---| | `slug` | path | string | yes | | `days` | query | integer | no | **200** — The report. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `product_slug` | string | yes | | | `generated_at` | string | yes | | | `window_days` | integer | yes | | | `score` | integer \\| null | yes | 0–100 across all scoreable checks; null until any data exists. | | `grade` | string \\| null | yes | | | `categories` | object[] | yes | | ### GET /health **Pipeline health.** Job runs (with consecutive failures), per-source data freshness, queue backlog, and the share of revenue that cannot be attributed — shown honestly, never smeared across channels. **200** — Health report. *Response:* | Field | Type | Required | Notes | |---|---|---|---| | `jobs` | object[] | yes | | | `sources` | object[] | yes | | | `queue` | object \\| null | yes | | | `attribution` | object[] | yes | | | `products` | object[] | yes | | | `now` | string | yes | | ## history ### GET /history **Every change and decision, human or AI.** The audit log as a timeline: Keeper cards raised, human approvals/dismissals (resolved_by), policy blocks, proposals, config edits. Product-scoped events only; account-plumbing events (logins) are not included for tenants. | Parameter | In | Type | Required | |---|---|---|---| | `product` | query | string | no | | `days` | query | integer | no | | `limit` | query | integer | no | **200** — Events, newest first. *Response (array of):* | Field | Type | Required | Notes | |---|---|---|---| | `id` | integer | yes | | | `occurred_at` | string | yes | | | `actor` | string | yes | A human's email, or 'keeper' / 'system' / 'policy'. | | `via` | string | yes | dashboard \| mcp \| api \| job | | `action` | string | yes | | | `product_slug` | string \\| null | yes | | | `subject_ref` | string \\| null | yes | | | `card_headline` | string \\| null | yes | When the subject is a Keeper card, its headline. | | `card_type` | string \\| null | yes | | | `payload` | object | yes | | --- # CLI reference Install: `npm install -g funnelkeeper` (or `npx funnelkeeper …`). The binary is `fk`. Credentials live in `~/.config/funnelkeeper/config.json` (created `0600` by `fk login`). ## fk signup create a funnelkeeper account (sends a verification email). ``` fk signup ``` ``` fk signup ``` ## fk login sign in and store an api key in ~/.config/funnelkeeper (0600). ``` fk login [flags] ``` | Flag | Type | Required | Description | |---|---|---|---| | `--key` | boolean | no | paste an existing api key instead of using a password | | `--api-url` | string | no | api base url (default `https://funnelkeeper.fly.dev`) | ``` fk login ``` ``` fk login --key ``` ``` fk login --api-url http://localhost:3100 ``` ## fk logout delete the stored api key. ``` fk logout ``` ``` fk logout ``` ## fk account show the signed-in account. ``` fk account ``` ``` fk account ``` ## fk product create create a product in your account. ``` fk product create [flags] ``` | Flag | Type | Required | Description | |---|---|---|---| | `--name` | string | yes | display name | | `--slug` | string | yes | url-safe id (lowercase, hyphens) | | `--currency` | string | no | iso currency code (default `USD`) | | `--domain` | string | no | product domain | ``` fk product create --name "Demo" --slug demo --currency AUD ``` ## fk connect connect ga4 | gtm | google-ads (browser auth) or semrush (api key). ``` fk connect [flags] ``` | Flag | Type | Required | Description | |---|---|---|---| | `--product` | string | yes | product slug | ``` fk connect ga4 --product demo ``` ``` fk connect semrush --product demo ``` ## fk connect test verify a connected source can actually deliver data. ``` fk connect test [flags] ``` | Flag | Type | Required | Description | |---|---|---|---| | `--product` | string | yes | product slug | ``` fk connect test ga4 --product demo ``` ## fk status pipeline health: jobs, sources, queue backlog. ``` fk status ``` ``` fk status ``` ## fk portfolio one row per product: spend, revenue, cac, ltv:cac. ``` fk portfolio ``` ``` fk portfolio ``` ## fk queue list pending keeper cards. ``` fk queue list ``` ``` fk queue list ``` ## fk queue approve approve a card (asks for typed confirmation — this can move money). ``` fk queue approve ``` ``` fk queue approve 3f1c… ``` ## fk queue reject dismiss a card. ``` fk queue reject ``` ``` fk queue reject 3f1c… ``` ## fk queue snooze snooze a card. ``` fk queue snooze [flags] ``` | Flag | Type | Required | Description | |---|---|---|---| | `--hours` | number | no | snooze duration (default `24`) | ``` fk queue snooze 3f1c… --hours 48 ``` --- # MCP server Gives Claude (and any MCP client) the same capabilities as the CLI — reads, onboarding, and human-relayed queue decisions. Runs over stdio; authenticates with an account API key. ## Install Claude Code: ```bash claude mcp add funnelkeeper -e FUNNELKEEPER_API_KEY=fk_live_… -- npx -y funnelkeeper funnelkeeper-mcp ``` Project `.mcp.json`: ```json { "mcpServers": { "funnelkeeper": { "command": "npx", "args": ["-y", "funnelkeeper", "funnelkeeper-mcp"], "env": { "FUNNELKEEPER_API_KEY": "fk_live_…" } } } } ``` Optional: `FUNNELKEEPER_API_URL` to point at a non-production API. ## Tools | Tool | Kind | What it does | |---|---|---| | `get_portfolio` | read | One row per product: spend 7d/30d, visitors, leads, revenue, customers, CAC, LTV:CAC, gate status, pending card count. | | `get_product` | read | A single product's portfolio row. | | `get_funnel` | read | Funnel stage × channel volumes for a product. | | `get_payback` | read | Cohort payback curves ({cohorts, cac}): cumulative revenue per customer by days since acquisition, per channel, plus per-channel CAC. | | `get_queue` | read | Pending Keeper cards (proposals, insights, alerts). Cards are resolved only by a human decision. | | `get_health` | read | Pipeline health: job runs, per-source data freshness, queue backlog, unattributed revenue share. | | `get_connections` | read | Integration inventory: each source's status, config, last sync, last error. | | `create_product` | write | Create a product in the authenticated account. | | `connect_source_start` | write | Begin connecting ga4, gtm, or google_ads. Returns an authorization URL — give it to the human to open in their browser (the model cannot browse), then poll connect_source_status with the returned state. | | `connect_source_status` | read | Poll an in-progress Google connection. pending → keep waiting; complete → results plus entity options (properties/containers) for connect_source_select; error → what went wrong. | | `connect_source_select` | write | Finish a Google connection by choosing which GA4 property / GTM container / Ads customer to use, from the options returned by connect_source_status. | | `set_semrush_key` | write | Connect SEMrush by storing the customer's own API key (encrypted at rest). No test call is made — SEMrush calls burn the customer's units. | | `test_connection` | write | Verify a connected source can deliver data (cheap, side-effect-free probe). | | `get_spend` | read | Daily spend by channel and campaign for a product, with the daily cap and whether it is currently binding. | | `log_distribution` | write | Record a distribution event you performed for the user — a post shipped, a listing submitted, an email sent — so its traffic joins back via utm_campaign. | | `propose_budget_change` | write | budget.propose: suggest a spend change with rationale and rollback condition. Returns pending_human — a HUMAN approves it in the app; you cannot execute it. Policy caps are enforced server-side: over-cap proposals are rejected with policy_code. | | `get_attribution` | read | Revenue by channel under first-touch AND last-touch models, with spend, customers, CAC and ROAS per channel, plus attribution coverage. Unattributed revenue is its own row — never smeared. | | `get_timeseries` | read | Daily spend, revenue, visits, leads and new customers for a product — zero-filled, oldest first. | | `get_health_report` | read | Scored audit of the product (0–100): site & tracking, funnel, advertising, SEO & social. Every failing check carries the action that fixes it. | | `get_history` | read | The audit timeline: every change and decision — Keeper cards raised, human approvals (resolved_by), policy blocks, config edits — newest first. | | `get_funnel_definition` | read | The product's funnel definition (ordered, labelled steps) plus per-stage tracking status: recent volume and which sources feed each canonical stage. | | `set_funnel_definition` | write | Save the product's funnel steps. Stages must come from the canonical taxonomy (impression, visit, engaged, lead, qualified, signup, activated, converted, payment, churned); labels are free text. Audited. | | `list_dashboards` | read | The product's saved dashboards (name + widget spec). The built-in Overview is not stored. | | `create_dashboard` | write | Build a dashboard for the user. Pass a full spec ({widgets:[...]}, schema DashboardSpec in /openapi.json — widget types: metric, timeseries, funnel, payback, queue, breakdown, note; each widget may carry layout {x,y,w,h} on a 12-column × 32px-row grid, or omit it to auto-pack in order) to compose it exactly, or just a prompt to let the server's deterministic composer draft it. Users can then rearrange everything by drag and drop. | | `update_dashboard` | write | Rename a dashboard or replace its widget spec (validated server-side). Audited. | | `approve_card` | **write, human-gated** | HUMAN-GATED: approve a pending Keeper card. Approving a proposal can change ad spend. Only call this to relay an explicit decision the human just made — never decide for them. The server's policy engine still enforces caps and walls. | | `reject_card` | **write, human-gated** | HUMAN-GATED: dismiss a pending Keeper card, relaying the human's explicit decision. | | `snooze_card` | **write, human-gated** | HUMAN-GATED: snooze a pending Keeper card for the human. | ## Input schemas ### get_portfolio ```json { "type": "object", "properties": {}, "additionalProperties": false } ``` ### get_product ```json { "type": "object", "properties": { "slug": { "type": "string", "description": "Product slug, e.g. 'demo-product'." } }, "required": [ "slug" ], "additionalProperties": false } ``` ### get_funnel ```json { "type": "object", "properties": { "slug": { "type": "string", "description": "Product slug, e.g. 'demo-product'." }, "days": { "type": "integer", "exclusiveMinimum": true, "minimum": 0, "maximum": 365, "default": 30 } }, "required": [ "slug" ], "additionalProperties": false } ``` ### get_payback ```json { "type": "object", "properties": { "slug": { "type": "string", "description": "Product slug, e.g. 'demo-product'." } }, "required": [ "slug" ], "additionalProperties": false } ``` ### get_queue ```json { "type": "object", "properties": {}, "additionalProperties": false } ``` ### get_health ```json { "type": "object", "properties": {}, "additionalProperties": false } ``` ### get_connections ```json { "type": "object", "properties": {}, "additionalProperties": false } ``` ### create_product ```json { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 120 }, "slug": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$" }, "currency": { "type": "string", "minLength": 3, "maxLength": 3, "default": "USD" }, "domain": { "type": "string" } }, "required": [ "name", "slug" ], "additionalProperties": false } ``` ### connect_source_start ```json { "type": "object", "properties": { "product_slug": { "type": "string", "description": "Product slug, e.g. 'demo-product'." }, "kind": { "type": "string", "enum": [ "ga4", "gtm", "google_ads" ] } }, "required": [ "product_slug", "kind" ], "additionalProperties": false } ``` ### connect_source_status ```json { "type": "object", "properties": { "state": { "type": "string" } }, "required": [ "state" ], "additionalProperties": false } ``` ### connect_source_select ```json { "type": "object", "properties": { "state": { "type": "string" }, "ga4_property_id": { "type": "string" }, "gtm_container_path": { "type": "string" }, "ads_customer_id": { "type": "string" } }, "required": [ "state" ], "additionalProperties": false } ``` ### set_semrush_key ```json { "type": "object", "properties": { "product_slug": { "type": "string", "description": "Product slug, e.g. 'demo-product'." }, "api_key": { "type": "string" }, "domain": { "type": "string" }, "database": { "type": "string", "minLength": 2, "maxLength": 2, "default": "us" } }, "required": [ "product_slug", "api_key", "domain" ], "additionalProperties": false } ``` ### test_connection ```json { "type": "object", "properties": { "product_slug": { "type": "string", "description": "Product slug, e.g. 'demo-product'." }, "kind": { "type": "string", "enum": [ "ga4", "gtm", "google_ads", "semrush", "mysql" ] } }, "required": [ "product_slug", "kind" ], "additionalProperties": false } ``` ### get_spend ```json { "type": "object", "properties": { "product_slug": { "type": "string", "description": "Product slug, e.g. 'demo-product'." }, "days": { "type": "integer", "exclusiveMinimum": true, "minimum": 0, "maximum": 365, "default": 30 } }, "required": [ "product_slug" ], "additionalProperties": false } ``` ### log_distribution ```json { "type": "object", "properties": { "product_slug": { "type": "string", "description": "Product slug, e.g. 'demo-product'." }, "channel": { "type": "string", "description": "Channel id, e.g. social_reddit, community, email." }, "kind": { "type": "string", "description": "What happened: forum_post, dm_wave, launch, social_post, email_blast." }, "url": { "type": "string", "format": "uri" }, "utm_campaign": { "type": "string" }, "note": { "type": "string" } }, "required": [ "product_slug", "channel", "kind" ], "additionalProperties": false } ``` ### propose_budget_change ```json { "type": "object", "properties": { "product_slug": { "type": "string", "description": "Product slug, e.g. 'demo-product'." }, "network": { "type": "string", "enum": [ "meta", "google" ] }, "campaign_id": { "type": "string" }, "from_cents": { "type": "integer", "minimum": 0 }, "to_cents": { "type": "integer", "exclusiveMinimum": true, "minimum": 0, "description": "Proposed daily cap, integer cents." }, "rationale": { "type": "string", "minLength": 10, "maxLength": 2000, "description": "Why — shown to the human on the card." }, "rollback_if": { "type": "object", "additionalProperties": {} } }, "required": [ "product_slug", "network", "to_cents", "rationale" ], "additionalProperties": false } ``` ### get_attribution ```json { "type": "object", "properties": { "slug": { "type": "string", "description": "Product slug, e.g. 'demo-product'." }, "days": { "type": "integer", "exclusiveMinimum": true, "minimum": 0, "maximum": 365, "default": 30 } }, "required": [ "slug" ], "additionalProperties": false } ``` ### get_timeseries ```json { "type": "object", "properties": { "slug": { "type": "string", "description": "Product slug, e.g. 'demo-product'." }, "days": { "type": "integer", "exclusiveMinimum": true, "minimum": 0, "maximum": 365, "default": 90 } }, "required": [ "slug" ], "additionalProperties": false } ``` ### get_health_report ```json { "type": "object", "properties": { "slug": { "type": "string", "description": "Product slug, e.g. 'demo-product'." }, "days": { "type": "integer", "exclusiveMinimum": true, "minimum": 0, "maximum": 365, "default": 30 } }, "required": [ "slug" ], "additionalProperties": false } ``` ### get_history ```json { "type": "object", "properties": { "product_slug": { "type": "string", "description": "Limit to one product." }, "days": { "type": "integer", "exclusiveMinimum": true, "minimum": 0, "maximum": 365, "default": 90 }, "limit": { "type": "integer", "exclusiveMinimum": true, "minimum": 0, "maximum": 500, "default": 200 } }, "additionalProperties": false } ``` ### get_funnel_definition ```json { "type": "object", "properties": { "slug": { "type": "string", "description": "Product slug, e.g. 'demo-product'." } }, "required": [ "slug" ], "additionalProperties": false } ``` ### set_funnel_definition ```json { "type": "object", "properties": { "slug": { "type": "string", "description": "Product slug, e.g. 'demo-product'." }, "steps": { "type": "array", "items": { "type": "object", "properties": { "stage": { "type": "string" }, "label": { "type": "string", "minLength": 1, "maxLength": 40 } }, "required": [ "stage", "label" ], "additionalProperties": false }, "minItems": 2, "maxItems": 10 } }, "required": [ "slug", "steps" ], "additionalProperties": false } ``` ### list_dashboards ```json { "type": "object", "properties": { "slug": { "type": "string", "description": "Product slug, e.g. 'demo-product'." } }, "required": [ "slug" ], "additionalProperties": false } ``` ### create_dashboard ```json { "type": "object", "properties": { "slug": { "type": "string", "description": "Product slug, e.g. 'demo-product'." }, "name": { "type": "string", "minLength": 1, "maxLength": 80 }, "description": { "type": "string", "maxLength": 300 }, "spec": { "type": "object", "additionalProperties": {}, "description": "A DashboardSpec object. Validated server-side." }, "prompt": { "type": "string", "maxLength": 500, "description": "Plain-words description, used only when spec is omitted." } }, "required": [ "slug", "name" ], "additionalProperties": false } ``` ### update_dashboard ```json { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "name": { "type": "string", "minLength": 1, "maxLength": 80 }, "spec": { "type": "object", "additionalProperties": {} } }, "required": [ "id" ], "additionalProperties": false } ``` ### approve_card ```json { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "actor": { "type": "string", "minLength": 1, "description": "The human operator's name or email. Ask the user for it; never supply a model name or invent one. Written to the audit log as resolved_by." } }, "required": [ "id", "actor" ], "additionalProperties": false } ``` ### reject_card ```json { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "actor": { "type": "string", "minLength": 1, "description": "The human operator's name or email. Ask the user for it; never supply a model name or invent one. Written to the audit log as resolved_by." } }, "required": [ "id", "actor" ], "additionalProperties": false } ``` ### snooze_card ```json { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "actor": { "type": "string", "minLength": 1, "description": "The human operator's name or email. Ask the user for it; never supply a model name or invent one. Written to the audit log as resolved_by." }, "hours": { "type": "integer", "exclusiveMinimum": true, "minimum": 0, "maximum": 720, "default": 24 } }, "required": [ "id", "actor" ], "additionalProperties": false } ``` ## The approval rule `approve_card`, `reject_card`, and `snooze_card` require `actor` — the **human's** name or email. They exist to relay a decision the human just made, never to make one. The server records that the write came via MCP and which key made it, and its policy engine enforces spend caps and network walls regardless of the client. --- # Connecting Google sources One Google sign-in covers GA4 (traffic), GTM (read-only tag audit), and Google Ads (spend). Funnelkeeper asks for read scopes only and stores the refresh token encrypted. ## From the dashboard Integrations → Connect with Google → consent → pick your property/container → done. ## From a terminal or agent The browser step can't be skipped — Google requires a human consent screen. The flow hands the URL out and polls: ``` POST /connect/google/start {"product_slug":"demo-product","kinds":["ga4","gtm"],"client":"cli"} → {"state":"…","auth_url":"https://accounts.google.com/o/oauth2/v2/auth?…","expires_at":"…"} ``` Give `auth_url` to the human. Then poll (bearer required; the state is bound to your account and expires after 10 minutes): ``` GET /connect/google/status?state=… → {"status":"pending"} keep polling (2s, then 5s) → {"status":"complete","results":[…],"options":{"ga4_properties":[…],"gtm_containers":[…]}} → {"status":"error","error":"access_denied"} ``` When `options` holds more than one property or container, ask the human which, then: ``` POST /connect/google/select {"state":"…","ga4_property_id":"4210…","gtm_container_path":"accounts/…/containers/…"} → {"ok":true,"activated":["ga4","gtm"]} ``` Exactly one option? Pass it straight through — no need to ask. ## What GTM is for Nothing is ever written to your container. The Keeper reads the live version weekly and answers one question: is the GA4 tag this product expects present and unpaused? If spend is running while the answer is no, you get an alert card — because every funnel number under-counts until the tag is fixed. ## Google Ads Spend sync activates automatically once Funnelkeeper's Google Ads developer token is approved; until then your customer id is stored and the [CSV bridge](/docs/api/#post-importgoogle-spend) covers spend imports. --- # The action queue The queue is the product's spine. Anything the Keeper or the system wants you to know — or wants permission for — becomes a **card**: a headline (90 characters, hard limit in the schema), one metric line, and actions. Detail sits behind a disclosure. No essays. ## Card types | Type | What it is | Actions | |---|---|---| | `proposal` | The Keeper wants to change spend | Approve · Dismiss · Snooze | | `insight` | Worth knowing; nothing to approve | Got it · Snooze | | `alert` | Something broke | View · Snooze | | `gate_result` | A validation gate passed or failed | View · Log decision | | `policy_block` | A write the policy engine refused | Why blocked | ## The rules that don't bend - A proposal **cannot** reach `approved` or `executed` without `resolved_by` — enforced by a database CHECK constraint, not application code. - Approving a spend proposal runs the policy engine: per-product daily caps, network walls (some products can never touch Meta — also a DB constraint), and a portfolio monthly ceiling. A policy refusal beats an approval, whoever clicked it. - `resolved_by` is the authenticated account's email. Over MCP, the required `actor` field names the human whose decision is being relayed; the audit log records both the actor and that the write came via MCP. - Cards expire (default 72h) so the Keeper never acts on stale analysis; a fresh sweep raises a fresh card. ## States `pending → approved | rejected | snoozed (until) | expired`. Approved and rejected cards collapse to one-line receipts with who and when. Restore only ever returns a snoozed or expired card to pending — it can never resolve one.