# OmniSignal MCP Server — API Documentation

OmniSignal exposes its content-strategy backend as an MCP (Model Context Protocol) server, so AI agents (Claude, Cursor, custom agents) can run your entire content operation: onboard a brand, generate strategies, scan signals, and work the content calendar. Transport is Streamable HTTP on a single endpoint; auth is an `omni_sk_...` API key.

- **Transport:** Streamable HTTP (single endpoint)
- **Server URL:** `https://<api-host>/mcp (same host as the OmniSignal REST API)`
- **Auth:** `Authorization: Bearer omni_sk_...` — an MCP API key from your dashboard
- **Pricing:** credit-based, 1 credit = $0.15, metered to Stripe on every paid call. Tools not listed in the price table are free.
- **API keys:** dashboard → profile page → **MCP / API access** section → create key (shown once, `omni_sk_` prefix; only the SHA-256 hash is stored). Up to 10 active keys; revoke anytime.
- **Key scopes:** keys are created with `read` + `generate` scopes. `strategy_generate` requires the `generate` scope.

## Client setup

Replace `<api-host>` with the OmniSignal API host and paste your key.

### Claude Desktop (claude_desktop_config.json)

```json
{
  "mcpServers": {
    "omnisignal": {
      "type": "streamable-http",
      "url": "https://<api-host>/mcp",
      "headers": {
        "Authorization": "Bearer omni_sk_YOUR_KEY_HERE"
      }
    }
  }
}
```

### Kimi Code (~/.kimi-code/mcp.json)

```json
{
  "mcpServers": {
    "omnisignal": {
      "url": "https://<api-host>/mcp",
      "headers": {
        "Authorization": "Bearer omni_sk_YOUR_KEY_HERE"
      }
    }
  }
}
```

### Cursor (~/.cursor/mcp.json)

```json
{
  "mcpServers": {
    "omnisignal": {
      "type": "streamable-http",
      "url": "https://<api-host>/mcp",
      "headers": {
        "Authorization": "Bearer omni_sk_YOUR_KEY_HERE"
      }
    }
  }
}
```

### Generic Streamable HTTP client

```http
POST https://<api-host>/mcp
Authorization: Bearer omni_sk_YOUR_KEY_HERE
Content-Type: application/json
Accept: application/json, text/event-stream
```

## Onboarding playbook (for agents)

Run these tools **in order** to set up a workspace end-to-end:

1. `onboarding_get_readiness` — See what is missing (mandatoryInputsComplete, missing[], firstStrategyComplete, ready). Call first and between steps.
2. `onboarding_update_profile` — Set brand, industry, targetAudience (mandatory). Optionally website + competitors.
3. `onboarding_update_goals` — Set at least one goal (mandatory).
4. `onboarding_update_social_handles` — The user's social handles per platform.
5. `onboarding_update_brand_voice` — (Optional) Brand voice — improves all generated content.
6. `onboarding_analyze_website` — (Optional assist) Pre-fill industry/ICP/competitors from the brand website.
7. `strategy_generate` — 150 credits, charged at submission (auto-refunded on failure). Returns strategyId immediately.
8. `strategy_get_status` — Poll (free) until status "complete". Then onboarding_get_readiness reports ready=true.

## Pricing & billing

**1 credit = $0.15 = 1 Stripe meter unit** (meter event `omnisignal_mcp_requests`). Usage is reported on every paid call. Two ways to pay:

### Monthly allotment packages

| Package | Price/month | Monthly credits | Effective $/credit | Overage |
|---|---:|---:|---:|---|
| Package S | $25 | 250 | $0.100 | billed automatically in 250-credit blocks |
| Package M | $49 | 550 | $0.089 | billed automatically in 550-credit blocks |
| Package L | $99 | 1200 | $0.083 | billed automatically in 1200-credit blocks |
| Package XL | $199 | 2600 | $0.077 | billed automatically in 2600-credit blocks |

Overage is **not blocked** — e.g. using 560 credits on Package M bills 2 × $49 that month (round-up blocks).

### PAYG (pay-as-you-go)

$0 due today — start for free with 50 included starter credits (one-time, PAYG only, never billed), then $0.15/credit billed daily in arrears.

### Per-tool prices

**Onboarding**

| Tool | Credits | $ |
|---|---:|---:|
| `onboarding_get_readiness` | 0 | — |
| `onboarding_update_profile` | 0 | — |
| `onboarding_update_goals` | 0 | — |
| `onboarding_update_social_handles` | 0 | — |
| `onboarding_update_brand_voice` | 0 | — |
| `onboarding_update_content` | 0 | — |
| `onboarding_connect_platform` | 0 | — |
| `onboarding_analyze_website` | 0 | — |

**Profile & context**

| Tool | Credits | $ |
|---|---:|---:|
| `profile_get` | 0 | — |
| `strategy_get_latest` | 0 | — |
| `strategy_list` | 0 | — |
| `data_summary` | 0 | — |

**Strategy**

| Tool | Credits | $ |
|---|---:|---:|
| `strategy_generate` | 150 | $22.50 |
| `strategy_get_status` | 0 | — |
| `strategy_get_swot` | 0 | — |
| `strategy_get_themes` | 0 | — |
| `strategy_analyze_post_idea` | 8 | $1.20 |
| `strategy_competitor_gap` | 12 | $1.80 |

**Signals**

| Tool | Credits | $ |
|---|---:|---:|
| `signals_list` | 5 | $0.75 |
| `signals_scan` | 25 | $3.75 |
| `signals_scan_status` | 0 | — |
| `signals_generate` | 10 | $1.50 |
| `signals_dismiss` | 0 | — |
| `signals_snooze` | 0 | — |
| `sources_list` | 0 | — |
| `sources_discover` | 10 | $1.50 |

**Calendar**

| Tool | Credits | $ |
|---|---:|---:|
| `calendar_get_day` | 5 | $0.75 |
| `calendar_get_week` | 15 | $2.25 |
| `calendar_get_upcoming` | 0 | — |
| `calendar_get_post` | 3 | $0.45 |
| `calendar_search_posts` | 3 | $0.45 |
| `calendar_update_post` | 0 | — |
| `calendar_create_variants` | 8 | $1.20 |
| `calendar_export` | 5 | $0.75 |

**Data collection**

| Tool | Credits | $ |
|---|---:|---:|
| `data_collect` | 10 premium / 3 cheap | varies |
| `data_points_list` | 2 | $0.30 |

**Credits & billing meta**

| Tool | Credits | $ |
|---|---:|---:|
| `credits_get_balance` | 0 | — |
| `credits_get_pricing` | 0 | — |

Tools with 0 credits are free forever. Per-day spend cap: default 500 credits (configurable via `PUT /api/credits/settings`).

## Tool reference

38 tools, grouped by domain.

### Onboarding

All free — required so an agent can complete the entire setup without the UI. Call onboarding_get_readiness first and between steps.

#### `onboarding_get_readiness` — **free**

Check onboarding readiness: whether the mandatory inputs (profile.brand, profile.industry, profile.goals, profile.targetAudience) are complete and whether the first strategy has been generated.

**Returns:** `{ mandatoryInputsComplete: boolean, missing: string[], firstStrategyComplete: boolean, ready: boolean }`

- Call this FIRST and between every setup step. ready=true means the workspace is fully set up.

#### `onboarding_update_profile` — **free**

Set core profile fields. Merges — only provided fields change.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `brand` | `string` | no | Brand or company name |
| `website` | `string` | no | Website URL (stored as profile.websiteUrl) |
| `industry` | `string` | no | Industry/sector (2-4 words) |
| `competitors` | `string[]` | no | Competitor names or handles |
| `targetAudience` | `string` | no | Ideal customer profile — who buys from this brand |

**Returns:** `{ success: true, updated: string[] }`

- brand, industry and targetAudience are mandatory inputs for strategy generation.

#### `onboarding_update_goals` — **free**

Set marketing goals (≥1 goal is a mandatory input), KPIs, and target audience.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `goals` | `string[]` | no | Marketing goals, e.g. ["Grow LinkedIn to 10k followers"] |
| `kpis` | `{ primary?: string, secondary?: string[], targetRange?: string }` | no | — |
| `targetAudience` | `string` | no | — |

**Returns:** `{ success: true }`

- At least one of goals, kpis, or targetAudience is required per call.

#### `onboarding_update_social_handles` — **free**

Set social media handles per platform. Accepts a single handle or an array per platform; merged + deduplicated like the dashboard does.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `linkedin` | `string | string[]` | no | — |
| `twitter` | `string | string[]` | no | — |
| `instagram` | `string | string[]` | no | — |
| `youtube` | `string | string[]` | no | — |
| `tiktok` | `string | string[]` | no | — |
| `facebook` | `string | string[]` | no | — |
| `reddit` | `string | string[]` | no | — |
| `threads` | `string | string[]` | no | — |

**Returns:** `{ success: true, updated: string[] }`

#### `onboarding_update_brand_voice` — **free**

Set the brand voice description — injected into all AI content generation.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `brandVoice` | `string (≤500 chars)` | yes | Tone/style of the brand, e.g. "Technical, direct, helpful" |

**Returns:** `{ success: true }`

#### `onboarding_update_content` — **free**

Set content context used to enrich strategy generation: past strategy notes, content ideas/drafts, existing content plan.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `historyNotes` | `string (≤2000 chars)` | no | Past strategy docs/insights |
| `contentIdeas` | `string (≤2000 chars)` | no | Content ideas or drafts |
| `existingContentPlan` | `string (≤3000 chars)` | no | Existing content plan |

**Returns:** `{ success: true }`

#### `onboarding_connect_platform` — **free**

Mark a platform as connected (same write as the dashboard platform connection).

| Parameter | Type | Required | Description |
|---|---|---|---|
| `platform` | `'linkedin' | 'instagram' | 'twitter' | 'youtube' | 'facebook'` | yes | — |
| `connected` | `boolean` | yes | — |
| `username` | `string` | no | Handle/channel ID on that platform |

**Returns:** `{ success: true }`

#### `onboarding_analyze_website` — **free**

Analyze a website to pre-fill industry, ICP (target audience), competitors, and brand voice. Replicates the dashboard "analyze website" assist (site parse + AI extraction + competitor discovery).

| Parameter | Type | Required | Description |
|---|---|---|---|
| `websiteUrl` | `string` | yes | Full URL of the brand website |

**Returns:** `{ success: true, analysis: { industry: string, icp: string, competitors: string[], brandVoice: string } }`

- May take up to ~20s (site parse). Apply the result with onboarding_update_profile / onboarding_update_brand_voice.

### Profile & context

Free reads that drive engagement and give agents the lay of the land.

#### `profile_get` — **free**

Get the user profile: brand, industry, goals, target audience, competitors, brand voice, social handles, connected platforms, and MCP credit usage.

**Returns:** `{ email, profile, connectedPlatforms, creditUsage: { usedThisCycle, cycleStart, lifetimeSpent, dailyCap }, mcpBilling: { mode, packageId } | null, onboardingCompleted }`

#### `strategy_get_latest` — **free**

Get the most recently COMPLETED strategy document (full output: summary, themes, insights, recommendations, platform coverage).

**Returns:** `Strategy document (large single doc). Errors if no completed strategy exists.`

#### `strategy_list` — **free**

List strategy generation history (metadata only).

**Returns:** `{ total: number, strategies: [{ strategyId, status, generatedAt, timeframe }] }`

#### `data_summary` — **free**

Summary of collected data points (counts by platform/type, latest collection time) — what raw material strategy generation has to work with.

**Returns:** `{ totalDataPoints, byPlatform, byType, ... }`

### Strategy

The premium tools. strategy_generate is async and charged on submission (refunded on failure).

#### `strategy_generate` — **150 credits** ($22.50) — scope: `generate`

Generate a full content strategy (async). Charged 150 credits AT SUBMISSION; automatically refunded if generation fails. Returns a strategyId immediately — poll with strategy_get_status.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `timeframe` | `'7d' | '30d' | '90d'` | no | Calendar timeframe (default 30d) |
| `idempotencyKey` | `string` | no | Client-generated key so retries do not double-charge |

**Returns:** `{ strategyId, status: "pending", creditsCharged: 150, usedThisCycle, monthlyCredits, estimatedSeconds, pollWith }`

- Requires the generate scope on the API key.
- Only one generation can run at a time (concurrency guard; error generation_in_progress with the running strategyId).
- Requires profile.brand/industry/goals/targetAudience — see onboarding_get_readiness.
- Usage over the monthly package allotment is allowed — Stripe auto-bills overage in round-up blocks.

#### `strategy_get_status` — **free**

Poll strategy generation status/progress.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `strategyId` | `string` | yes | — |

**Returns:** `{ strategyId, status: "pending" | "generating" | "complete" | "error", progress: { stage, stageNumber, totalStages, percent } | null, ... }`

#### `strategy_get_swot` — **free**

Get the SWOT analysis from the most recent completed strategy.

**Returns:** `SWOT object from strategy.output.swotAnalysis.`

#### `strategy_get_themes` — **free**

Get content themes from the latest strategy plus the top 20 tags used across the calendar.

**Returns:** `{ themes: [{ name, rationale, expectedImpact }], tags: string[], strategyId }`

#### `strategy_analyze_post_idea` — **8 credits** ($1.20)

Validate a post idea against current web signal (1 AI/search call): traction summary, keyword tags, confidence, and a suggested optimized angle.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `title` | `string` | yes | The post idea / title to analyze |
| `industry` | `string` | no | — |

**Returns:** `{ summary, tags: string[], confidence: 0-1, suggestedTitle, suggestedDraft }`

#### `strategy_competitor_gap` — **12 credits** ($1.80)

AI competitor gap analysis: topics/formats/angles the competitor covers that this brand does not, with immediate actions.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `competitor` | `string` | yes | Competitor name |

**Returns:** `{ success: true, analysis: { topicGaps, formatGaps, angleOpportunities, immediateActions, confidenceLevel, dataQuality }, competitor }`

### Signals

Daily trends, competitor moves, and opportunities. signals_list is chunked in windows of 10 (cursor-based).

#### `signals_list` — **5 credits** ($0.75)

List active signals in windows of 10 (cursor-based). Charged per window.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `cursor` | `string` | no | Opaque cursor from the previous call (omit for first window) |
| `priority` | `'low' | 'medium' | 'high' | 'critical'` | no | — |
| `type` | `string` | no | Signal type, e.g. trend, competitor, opportunity, warning, insight |

**Returns:** `{ signals: Signal[], count, total, offset, windowSize: 10, nextCursor: string | null }`

- Pass the returned nextCursor to fetch the next window; null means end of feed. Cursor is base64 of the numeric offset.

#### `signals_scan` — **25 credits** ($3.75)

Run a fresh signal scan from monitored sources (async, scraping + LLM). Returns a scanJobId immediately — poll with signals_scan_status.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `sources` | `MonitoredSource[]` | no | Sources to scan; defaults to the user's discovered monitored sources |

**Returns:** `{ scanJobId, status: "processing", message }`

- Errors if no monitored sources exist — call sources_discover first.

#### `signals_scan_status` — **free**

Poll a signal scan job status.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `scanJobId` | `string` | yes | — |

**Returns:** `{ scanJobId, status: "processing" | "complete" | "error", signalsFound?, dataPointsAdded?, completedAt? }`

#### `signals_generate` — **10 credits** ($1.50)

Generate fresh signals from already-collected data (1 LLM call) and store them.

**Returns:** `{ success: true, signalCount: number }`

#### `signals_dismiss` — **free**

Dismiss (archive) a signal so it no longer appears in the feed (free — encourages feed hygiene).

| Parameter | Type | Required | Description |
|---|---|---|---|
| `signalId` | `string` | yes | — |

**Returns:** `{ success: true }`

#### `signals_snooze` — **free**

Snooze a signal for N days; it reappears afterwards (free).

| Parameter | Type | Required | Description |
|---|---|---|---|
| `signalId` | `string` | yes | — |
| `days` | `number (1-30)` | no | Default 7 |

**Returns:** `{ success: true, snoozedUntil: ISO string }`

#### `sources_list` — **free**

List monitored sources.

**Returns:** `{ sources: MonitoredSource[], count }`

#### `sources_discover` — **10 credits** ($1.50)

Discover monitoring sources for this account (LLM + scraping): LinkedIn voices, Reddit communities, search queries, websites.

**Returns:** `{ sources: MonitoredSource[], stored: true, count }`

### Calendar

The generated content calendar, served in day-sized chunks. calendar_get_week is the bulk-discount alternative to 7 day calls.

#### `calendar_get_day` — **5 credits** ($0.75)

Get all calendar posts for one day. Charged per day — plan a week day-by-day or use calendar_get_week for a bulk discount.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `date` | `string (YYYY-MM-DD)` | yes | — |

**Returns:** `{ date, posts: Post[], total, calendarGeneratedAt }`

- Archived/rejected posts are excluded (same default as the dashboard).

#### `calendar_get_week` — **15 credits** ($2.25)

Get all calendar posts for a week (Monday-based start). Bulk discount vs 7 day calls.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `weekStart` | `string (YYYY-MM-DD)` | yes | — |

**Returns:** `{ posts: Post[], weekStart, weekEnd, total }`

#### `calendar_get_upcoming` — **free**

Get upcoming posts for the next N days (free).

| Parameter | Type | Required | Description |
|---|---|---|---|
| `days` | `number (1-90)` | no | Default 7 |
| `status` | `'planned' | 'published' | 'archived' | 'rejected'` | no | — |

**Returns:** `{ posts: Post[], today, through, days, total }`

#### `calendar_get_post` — **3 credits** ($0.45)

Get one full calendar post including playbook, script, slides, and variants.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `postId` | `string` | yes | — |

**Returns:** `{ post: Post }`

#### `calendar_search_posts` — **3 credits** ($0.45)

Search calendar posts by text, platform, format, or status.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `q` | `string` | no | — |
| `platform` | `string` | no | — |
| `format` | `string` | no | — |
| `status` | `'planned' | 'published' | 'archived' | 'rejected'` | no | — |
| `limit` | `number (1-100)` | no | Default 20 |

**Returns:** `{ posts: Post[], total, limit }`

- At least one search parameter is required.

#### `calendar_update_post` — **free**

Update a calendar post status, notes, or schedule (free). Only the provided fields change.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `postId` | `string` | yes | — |
| `status` | `'planned' | 'published' | 'archived' | 'rejected'` | no | — |
| `notes` | `string` | no | — |
| `date` | `string (YYYY-MM-DD)` | no | Reschedule to this date |
| `time` | `string (HH:MM)` | no | — |

**Returns:** `{ success: true }`

#### `calendar_create_variants` — **8 credits** ($1.20)

Generate 2 A/B variants of a calendar post (1 AI call) and store them on the post.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `postId` | `string` | yes | — |

**Returns:** `{ success: true, variants: [{ id, hook, body, cta, rationale, generatedAt }], postId }`

#### `calendar_export` — **5 credits** ($0.75)

Export the content calendar as CSV, ICS (iCalendar), or JSON.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `format` | `'csv' | 'ics' | 'json'` | yes | — |

**Returns:** `csv/ics: { format, content: string }; json: { format, calendar }`

### Data collection

Fresh raw data feeds strategy and signals. Priced per source class (third-party scraping cost).

#### `data_collect` — **10 premium / 3 cheap credits**

Collect fresh data from a source and store it as data points.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `source` | `'anysite_linkedin_posts' | 'anysite_twitter_posts' | 'anysite_instagram_posts' | 'exa_search' | 'hackernews_search' | 'rss_feed'` | yes | — |
| `query` | `string` | yes | Search query / keywords (ignored for rss_feed) |
| `feedUrl` | `string` | no | RSS feed URL (rss_feed source only) |

**Returns:** `{ success: true, source, collected: number }`

- Premium sources (10 cr): anysite_linkedin_posts, anysite_twitter_posts, anysite_instagram_posts — per-call scraping fees.
- Cheap sources (3 cr): exa_search, hackernews_search, rss_feed.

#### `data_points_list` — **2 credits** ($0.30)

List collected data points (paginated). Filter by source, platform, or type.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `limit` | `number (1-100)` | no | Default 50 |
| `offset` | `number` | no | Default 0 |
| `source` | `string` | no | — |
| `platform` | `string` | no | — |
| `type` | `string` | no | — |

**Returns:** `{ dataPoints: DataPoint[], total, limit, offset, hasMore }`

### Credits & billing meta

Free — lets agents budget before running paid tools.

#### `credits_get_balance` — **free**

Get monthly credit usage: billing mode, monthly allotment, used/remaining this cycle, cycle dates, daily cap usage, lifetime spend, and the 10 most recent transactions.

**Returns:** `{ mode, packageId, monthlyCredits, usedThisCycle, remainingThisCycle, cycleStart, cycleEnd, dailyCap, spentToday, lifetimeSpent, creditUsd, recentTransactions }`

#### `credits_get_pricing` — **free**

Machine-readable price list: every tool's credit price, the credit→USD rate, monthly allotment packages, PAYG rate, and the daily cap. Call this to budget before running paid tools.

**Returns:** `{ creditUsd, defaultDailyCap, toolPrices, packages: [{ id, name, monthlyCredits, priceUsdPerMonth, effectiveUsdPerCredit, overageNote }], payg, howToBuy }`

## Async job pattern

- Long-running tools never block: strategy_generate and signals_scan return a job id immediately.
- strategy_generate is CHARGED AT SUBMISSION (150 credits) and automatically refunded if the pipeline ends in error.
- Poll with the free status tools: strategy_get_status (progress.percent over 6 stages) and signals_scan_status.
- UI-triggered generations by MCP users debit the equivalent 150 credits automatically (chargedVia ui-mcp), with the same refund-on-error guarantee.

## Response chunking

- signals_list — 10 signals per call, cursor-based. Pass the returned nextCursor (base64-encoded offset) for the next window; null means end of feed.
- calendar_get_day — one day of posts per call (1-3 posts). calendar_get_week is the bulk-discount alternative (15 cr vs 7 × 5 cr).
- calendar_get_post — one full post (playbook/script/slides) per call.
- Chunking keeps tool responses small for LLM context windows and spreads cost across consumption calls.

## Error codes

| Code | Where | Payload | Meaning |
|---|---|---|---|
| `insufficient_credits` | Any paid tool (MCP error, message prefixed with the code) | `{ code: "insufficient_credits", creditUsd: 0.15 }` | No MCP billing configured — the user must subscribe to a monthly package or PAYG in the dashboard (MCP / API access section). |
| `daily_cap_exceeded` | Any paid tool | `{ code: "daily_cap_exceeded", spentToday: number, dailyCap: number }` | Per-day spend cap reached (default 500 credits). Raise it via PUT /api/credits/settings. |
| `-32001 (HTTP 401)` | POST /mcp — transport level | `{ jsonrpc: "2.0", error: { code: -32001, message: "Unauthorized: ..." }, id: null }` | Missing or invalid MCP API key. Keys are created in the dashboard profile page (MCP / API access section). |
| `generation_in_progress` | strategy_generate | `{ code: "generation_in_progress", strategyId: string }` | A strategy generation is already running — poll it with strategy_get_status instead of starting a new one. |

Job states: strategy `pending → generating → complete | error`; scans `processing → complete | error`.

## Guardrails

- Per-day spend cap: default 500 credits/day, user-configurable via PUT /api/credits/settings — protects against runaway agents looping tools.
- PAYG "Start for free": 50 starter credits granted once on PAYG activation, consumed before billable usage and never reported to Stripe metered billing. Package subscriptions include no free credits.
- Idempotency: pass idempotencyKey to strategy_generate so agent retries never double-charge. All ledger writes are idempotent server-side as well.
- Hard fail with clear errors (insufficient_credits / daily_cap_exceeded) — never silent degradation.
- Automatic 150-credit refund when a strategy generation ends in error (both MCP- and UI-triggered generations by MCP users).
- Free calls stay free forever — they drive the engagement that leads to paid calls.
