{
  "ok": true,
  "name": "Spotka API",
  "base_url": "https://spotka.co/api/v1",
  "connector": {
    "mcp_url": "https://spotka.co/api/v1/connector/mcp",
    "authentication": "OAuth 2.1 with PKCE",
    "scopes": [
      "spotka.read",
      "spotka.events",
      "spotka.attendance",
      "spotka.invites",
      "spotka.messages"
    ],
    "write_retry": "idempotency_key required",
    "platform_approval": "pending"
  },
  "how_to_call": "Send your key and a `do` parameter. Any of these work: POST form fields, POST JSON, or a GET query string. The key can go in the `key` parameter or an X-API-Key header (the header is preferred — a key in a URL can leak into logs).",
  "get_a_key": "Spotka app → Settings → API access → Create key.",
  "example": "curl -X POST -H 'X-API-Key: spk_XXXXXXXXXXXX' -d 'do=ideas' https://spotka.co/api/v1  (or pass key=spk_… as a parameter if your client can only send a URL)",
  "verbs": [
    {
      "do": "ideas",
      "what": "Ideas picked for you.",
      "params": {
        "limit": "optional, 1-50, default 20"
      }
    },
    {
      "do": "events",
      "what": "Events other members are hosting that suit you. The API prefers YOUR ranked suggestions by default; when that deck is empty, or when you send a text query, it searches the broader By Others browse pool the app uses. Other filters narrow whichever pool was loaded, and the reply reports deck_size. When nothing comes back, `empty.reason` says which of still_ranking / no_inventory / filters_too_narrow / end_of_list it is, so you know whether to wait, widen, or stop. A cold first call may also return ranking_pending=true with retry_after_ms — wait that long and ask again rather than reporting an empty deck.",
      "params": {
        "limit": "optional, 1-50, default 20",
        "query": "optional, matches title, about, pitch, host and place",
        "search": "optional alias of query — same filter, same fields",
        "location": "optional, matches the place",
        "starts_after": "optional ISO date — events with no time are excluded",
        "starts_before": "optional ISO date",
        "date_from": "optional alias of starts_after",
        "date_to": "optional alias of starts_before",
        "availability": "optional, open (hide full events) or any",
        "page": "optional, 1-based page number; cursor takes precedence when both are present",
        "cursor": "optional, the next_cursor from the previous reply"
      }
    },
    {
      "do": "my_events",
      "what": "Events you host, events you joined, and invitations waiting for you.",
      "params": {
        "limit": "optional, 1-50, default 20"
      }
    },
    {
      "do": "create_event",
      "what": "Add an event. when= accepts a weekday phrase such as 'next Saturday at 6pm', an ISO instant such as 2026-08-22T18:00:00+02:00, a date-and-time with no offset such as '2026-09-26 18:00' (18:00 Europe/Warsaw, the same as typing 6pm), or a dated wall-clock with an IANA zone such as '2026-09-10 12:00 Europe/Warsaw'. starts_at/ends_at may be sent as direct ISO fields instead of when=. A title-only create stays a draft: publish=true needs a time and a place, the same as update_event. Leaving when= and starts_at out leaves the draft with no time rather than inventing a start. If a supplied time cannot be read, or publish=true cannot complete, the call fails (ok:false) with the unmet fields rather than leaving a silent published leftover.",
      "params": {
        "title": "required, up to 120 characters; longer is title_too_long, not cut short",
        "when": "optional weekday phrase, e.g. 'next Friday at 7pm', an ISO 8601 instant, a date-and-time with no offset ('2026-09-26 18:00' is 18:00 Europe/Warsaw), or '2026-09-10 12:00 Europe/Warsaw'. Omit with starts_at to leave the draft with no time; the host sets one before it can publish. Unreadable when= fails with time_not_applied rather than guessing a different date. A bare number (a Unix timestamp) is time_not_applied, not converted to a date. A date that is not a real day (29 February in a non-leap year) is time_not_applied, not rolled to the next day. A UTC offset no civil timezone uses (+15:00) is time_not_applied, not converted with arithmetic.",
        "where": "optional place name, JSON {name, lat, lng} for a map pin, or later to decide with guests",
        "about": "optional, up to 1000 characters; longer is value_too_long, not cut short. Angle brackets are preserved, not stripped",
        "max_guests": "optional, whole number 2-8; fractional, outside that range, or a non-number, is invalid_max_guests",
        "capacity": "optional alias of max_guests — the name do=event returns",
        "visibility": "optional, public or private",
        "publish": "optional, true (default) or false",
        "raw": "test accounts only (a standard key belonging to one is enough) — skip the writing model and set the fields exactly as given",
        "starts_at": "optional ISO 8601 start time; stored directly as the event time. Form bodies that turn +02:00 into a space are restored before parsing (#2517). Use ends_at with it to set the duration.",
        "ends_at": "optional ISO 8601 end time; requires starts_at and must be after it"
      }
    },
    {
      "do": "update_event",
      "what": "Change an event you host. The event must be the full event id; a truncated id is invalid_event_id. A partial update: anything you leave out stays exactly as it is. Nobody is notified — guests see the change next time they open it. Pass publish=true to publish a draft once when and where are set. A when-less and starts_at-less draft stays with no time rather than inventing a start, and cannot be published until one is set; leftover catalog windows do not count. If the supplied time cannot be applied, or that publish cannot complete, the call fails and companion fields are not kept. On a published event, where= is place_not_applied (the place is fixed while it is live), not invitation_not_draft.",
      "params": {
        "event": "required, the full event id",
        "title": "optional, up to 120 characters; longer is title_too_long, not cut short",
        "when": "optional plain text, e.g. 'Saturday 8pm', an ISO 8601 instant, a date-and-time with no offset ('2026-09-26 18:00' is 18:00 Europe/Warsaw), or '2026-09-10 12:00 Europe/Warsaw' to reschedule a published event. Unreadable when= fails with time_not_applied rather than guessing a different date. A bare number (a Unix timestamp) is time_not_applied, not converted to a date. A date that is not a real day (29 February in a non-leap year) is time_not_applied, not rolled to the next day. A UTC offset no civil timezone uses (+15:00) is time_not_applied, not converted with arithmetic. time_not_applied rejects the whole update — sibling fields are not written.",
        "starts_at": "optional ISO 8601 start time; stored directly as the event time. Form bodies that turn +02:00 into a space are restored before parsing (#2517). Use ends_at with it to set the duration.",
        "ends_at": "optional ISO 8601 end time; requires starts_at and must be after it",
        "where": "optional place name, JSON {name, lat, lng} for a map pin, or later to decide with guests. On a published event this is place_not_applied — the place is fixed while it is live — not invitation_not_draft",
        "about": "optional, up to 1000 characters; longer is value_too_long, not cut short. Angle brackets are preserved, not stripped",
        "max_guests": "optional, whole number 2-8; fractional, outside that range, or a non-number, is invalid_max_guests",
        "visibility": "optional, public or private",
        "publish": "optional, true publishes a draft (default false). Already published is a no-op. A miss fails the whole call, including mixed updates",
        "expected_version": "optional — send the version you read and the call fails rather than overwrite a newer change"
      }
    },
    {
      "do": "invite",
      "what": "Invite people to an event. Give `event`, or give `title` instead to create the event and invite in one call. invited lists only people who received a pending invitation they can see and answer. An event id that does not exist is event_not_found (404) and costs nothing: the daily recipient allowance is charged only once the event is settled. On the create-and-invite path the allowance is charged just before the event is created, so an over-budget call leaves no published event behind; a create that then fails has spent that allowance.",
      "params": {
        "event": "the full event id, or omit and pass create_event params. A truncated id is invalid_event_id (400); an id that names no event is event_not_found (404). Neither sends invitations nor uses any of the daily allowance",
        "to": "one or more email addresses, comma separated"
      }
    },
    {
      "do": "join",
      "what": "Take a seat at somebody else's event. The reply's `via` says which path was used: 'deck' when the event was in that member's suggestions, or 'share_link' for a test key when it was not (with `deck_size` so you can see why). Joining takes a seat at the time the host set; an event with no time yet is time_not_set (409).",
      "params": {
        "event": "event id",
        "note": "optional message to the host"
      }
    },
    {
      "do": "leave",
      "what": "Give up a seat you took. A host cannot leave their own event: that is cannot_leave_own_event, not a retryable failure. Archive or delete it instead. A missing event is event_not_found, not candidate_stale. The rest of the member's event suggestions stay.",
      "params": {
        "event": "event id"
      }
    },
    {
      "do": "respond",
      "what": "Answer something addressed to you: accept, decline, or interested. Accept takes a seat; interested is a soft RSVP that records the person on the host's interested list without taking a seat (task #2512 / #2500). Changing interested to accept takes a seat; it is not already_responded. An accept on an event with no time yet is time_not_set (409). A never-answered invite on an archived event is event_archived (the host retired it), not already_responded. Repeating the same decision is a no-op success, not already_responded. An id that is not an event is event_not_found, not a server error.",
      "params": {
        "event": "event id",
        "decision": "accept / decline / interested"
      }
    },
    {
      "do": "event",
      "what": "One event in full. The event must be the full event id; a truncated id is invalid_event_id. For hosts: lifecycle (published / calendared), held_guest_count, calendared_guest_count, guest_seats_remaining, guest_states (interested_with_held_seat / calendared), calendared_attendees (same meaning as confirmed — only calendared encounter guests), interested_count / interested (includes a soft respond decision=interested RSVP), and allowed_actions. A guest who joined or accepted holds a seat; a soft RSVP does not (task #2512 / #2500). A calendared/accepted guest is not counted in held_guest_count, but still takes one place: guest_seats_remaining subtracts every held and calendared guest once. Empty confirmed/calendared_attendees does not mean the guest is unapproved.",
      "params": {
        "event": "full event id"
      }
    },
    {
      "do": "archive",
      "what": "Retire an event you host. The event must be the full event id; a truncated id is invalid_event_id. An event this member cannot see is event_not_visible, not invitation_not_found.",
      "params": {
        "event": "full event id"
      }
    },
    {
      "do": "delete",
      "what": "Delete an unpublished draft you host. The event must be the full event id; a truncated id is invalid_event_id. A published event must be archived instead. Already gone is event_not_found. An event this member cannot see is event_not_visible, not invitation_not_found.",
      "params": {
        "event": "full event id"
      }
    },
    {
      "do": "profile",
      "what": "This member's own profile, split into `public` (what other members see) and `private` (their own context — dietary needs, interests, what they are focused on). Use the private half to choose what to suggest; never quote it into an invitation or a message.",
      "params": {}
    },
    {
      "do": "update_profile",
      "what": "Change this member's own profile. Only the fields you name change; everything else is left alone. Anything not on the writable list is refused by name rather than ignored. display_name and first_name cannot be blank — omit them to keep the current name. Names refuse line breaks and control characters (invalid_name_characters). Overlong text, over-long lists, and over-long list items are value_too_long, not silently shortened. languages must be on the app language list (invalid_languages); invented words and JSON junk are refused, not stored. personal_url must be a public-page slug (invalid_personal_url). instagram, linkedin and telegram must be a handle or profile URL (invalid_social_handle); obvious non-URLs and non-handles are refused, not stored. HTML tags on bio, city, company, job_title, community_goals, current_focus, allergies, linkedin and personal_url are stripped on save, the same way event titles are, rather than stored raw.",
      "params": {
        "any of": "display_name, first_name, last_name, bio, city, company, job_title, personal_url, instagram, telegram, linkedin, musical_preferences, community_goals, current_focus, interests, dietary_preferences, dietary_other, allergies, allergies_other, languages",
        "lists": "interests (up to 40), dietary_preferences (20), allergies (20) and languages (12) take a list, a JSON array string, or comma-separated text. A string that starts with [ is parsed as JSON; invalid JSON is refused, not comma-split. Longer than the cap is value_too_long, not cut short. Each interests / dietary_preferences / allergies item is up to 120 characters; longer is value_too_long, not sliced. languages must be on the app list (english, polish, ukrainian, belarusian, russian, german, french, spanish, italian); anything else is invalid_languages, not stored. HTML tags in an allergies item are stripped on save",
        "personal_url": "public-page slug (letters, numbers, hyphens) or a spotka.co/m/… link; anything else is invalid_personal_url, not stored. Empty clears it. HTML tags are stripped on save before the slug check",
        "instagram": "username or Instagram profile URL; anything else is invalid_social_handle, not stored. Empty clears it",
        "telegram": "username or t.me URL; anything else is invalid_social_handle, not stored. Empty clears it",
        "linkedin": "username or LinkedIn profile URL; anything else is invalid_social_handle, not stored. Empty clears it. HTML tags are stripped on save, the same way event titles are",
        "display_name": "the public name other members see; empty or whitespace is refused (display_name_empty), not stored. Line breaks, C0 controls and zero-width characters are invalid_name_characters",
        "first_name": "empty or whitespace is refused (first_name_empty), not stored. Line breaks, C0 controls and zero-width characters are invalid_name_characters",
        "last_name": "optional; line breaks, C0 controls and zero-width characters are refused (invalid_name_characters), not stored",
        "bio": "optional, up to 2000 characters; longer is value_too_long, not cut short. HTML tags are stripped on save (same as event titles), not stored raw",
        "city": "optional; HTML tags are stripped on save (same as event titles), not stored raw",
        "company": "public; HTML tags are stripped on save (same as event titles), not stored raw",
        "job_title": "public; HTML tags are stripped on save (same as event titles), not stored raw",
        "current_focus": "optional, up to 1000 characters; HTML tags are stripped on save (same as event titles), not stored raw"
      }
    },
    {
      "do": "notification_settings",
      "what": "Which notifications this member gets, on both rails Spotka uses — push and email. Each one says whether it is on per channel, whether that is their own choice or Spotka's default, and which apps it applies to. Also reports how many devices are registered: with none, no push is delivered however these are set, so never promise a notification without checking.",
      "params": {
        "app": "optional, spotka or sponic_gardens. Defaults to every app.",
        "channel": "optional, push or email. Defaults to both."
      }
    },
    {
      "do": "update_notification_settings",
      "what": "Turn notifications on or off. Only the ones you name change. Naming a notification changes every channel it travels on, so \"turn off the weekly ideas\" stops both the push and the email; add channel=email to change one rail only. Ask the member first: a notification turned off is not noticed until something has already been missed.",
      "params": {
        "any notification key": "on, off, or default — for example new_message=off. `default` is a VALUE, not a parameter: it clears the member's choice so the setting follows whatever Spotka ships with, which is the only way back once an explicit value is stored.",
        "all": "optional, on / off / default, applied to everything in scope. channel=email with all=off is 'stop emailing me'. It skips anything already set that way rather than pinning it out of Spotka's defaults.",
        "type": "optional, one key. enabled=on|off|default must come with it.",
        "app": "optional, spotka or sponic_gardens. Defaults to every app.",
        "channel": "optional, push or email. Defaults to both."
      }
    },
    {
      "do": "calendar",
      "what": "What this member has coming up — events they host and events they joined, soonest first, with exact times. Events with no agreed time are returned separately in `undated`, never mixed into the schedule.",
      "params": {
        "limit": "optional, 1-50, default 20",
        "from": "optional ISO date. Defaults to now — an agenda is about what is ahead.",
        "to": "optional ISO date"
      }
    },
    {
      "do": "activity_history",
      "what": "What this API has changed on this account, newest first: which verb, what it touched, and whether it worked. A record of what happened, NOT an undo log — reverse things with the opposite verb.",
      "params": {
        "limit": "optional, 1-50, default 20",
        "verb": "optional, narrow to one action such as create_event. Only actions that change something are recorded, so only those can be filtered: a read verb is unknown_verb_filter (400) and the hint lists the accepted set. key_create, key_revoke, reset and seed are recorded too, and filterable by name.",
        "since": "optional ISO date. Pass the `at` of the oldest row you have to page further back.",
        "succeeded": "optional, true or false. false returns only failed actions. A value that is not true/false is invalid_succeeded, not the unfiltered list."
      }
    },
    {
      "do": "revoke_invite",
      "what": "Take back an invitation you sent. invite= is the id from do=event — enough on its own; event= is not required. The link stops accepting a reply. Email that already went out cannot be unsent.",
      "params": {
        "invite": "required, the invitation id from do=event. Enough on its own — event= is not required"
      }
    },
    {
      "do": "inbox",
      "what": "What is waiting: conversations with unread messages, and invitations waiting for an answer. unread_total counts every unread conversation, not just this page. Reading it marks nothing read.",
      "params": {
        "limit": "optional, 1-50, default 20 — pages the list only; unread_total is global"
      }
    },
    {
      "do": "conversation",
      "what": "One conversation's messages, oldest first. Does NOT mark anything read. oldest_at is the cursor for before=; there is no since=.",
      "params": {
        "conversation": "required, the id from do=inbox",
        "limit": "optional, 1-50, default 30",
        "before": "optional, the `at` of the oldest message you have, to page back — pass oldest_at"
      }
    },
    {
      "do": "mark_read",
      "what": "Mark a conversation read. Only do this when the member has actually seen it.",
      "params": {
        "conversation": "required"
      }
    },
    {
      "do": "send_message",
      "what": "Send a message as the member. Needs a key with messaging enabled — it is off by default because it writes to other people in their name. Only replies in a conversation they already have, or writes to Fern.",
      "params": {
        "body": "required, the message",
        "conversation": "the id from do=inbox — or",
        "to": "fern"
      }
    },
    {
      "do": "report_bug",
      "what": "Tell the Spotka team something is broken. Use it when something you tried did not work, or the member says something is wrong — you saw the call and the reply, so you are the best-placed reporter. It goes into the same queue as a report typed in the app. Default is no auto-fix. autofix=true on a clear bug sends it to a coder. check_only=true on a test-reporter key runs duplicate detection and returns the verdict without filing. Pass product_url, app_version, platform, and a screenshot when you have them. Poll do=report_status; when waiting_for_retest is true, re-test on production and call do=verify_bug. Tell the member it has been passed on, never that it is already fixed.",
      "params": {
        "report": "required — what the member did, what happened, what they expected instead",
        "title": "optional, a short heading for the report",
        "screen": "optional, where in Spotka this was",
        "autofix": "optional, true/1/yes/on. Bugs only. A clear bug goes to a coder. The reply still has being_worked_on: false at file time — never tell the member it is already fixed.",
        "check_only": "optional, true/1/yes/on. Test-reporter keys only. Runs duplicate detection and returns the verdict without filing a task.",
        "agent": "optional — your own name if you are an AI agent (Meta Muse, Aside, Claude, Codex, DeepSeek, Grok...). The account is a seat several agents share; this is the only thing that says who wrote the report.",
        "product_url": "optional, the https://spotka.co/app/… page where it broke",
        "app_version": "optional, e.g. 0.6.375",
        "app_build": "optional",
        "source_commit": "optional",
        "platform": "optional, web / ios / android",
        "screenshot": "optional, one PNG/JPEG/WebP as a data URL or base64, up to 1 MiB. Oversized images are dropped; the report still files",
        "screenshots": "optional, extra screenshots, same rules as screenshot, up to 5 in total"
      }
    },
    {
      "do": "report_feature",
      "what": "Ask the Spotka team for something Spotka does not do yet. Same queue, same rule: a person reads it and decides. Tell the member the request was passed on, never that it will be built.",
      "params": {
        "report": "required — what the member wants, and what they are trying to achieve with it",
        "title": "optional, a short heading for the request",
        "screen": "optional, where in Spotka this was",
        "agent": "optional — your own name if you are an AI agent. Same as on report_bug.",
        "product_url": "optional, the https://spotka.co/app/… page where this came up",
        "app_version": "optional",
        "app_build": "optional",
        "source_commit": "optional",
        "platform": "optional",
        "screenshot": "optional, one PNG/JPEG/WebP as a data URL or base64, up to 1 MiB",
        "screenshots": "optional, extra screenshots, same rules as screenshot, up to 5 in total"
      }
    },
    {
      "do": "report_status",
      "what": "Poll a report this account filed. Returns status, gate, fix_merged (claimed fix), fix_verified (successful re-probe), fix_landed (compatibility alias for verified), and whether a re-test is waiting. Only reports this key's member filed are visible, except a QA fleet agent key (is_test + test_account_kind=agent) may open a report that is waiting for a re-test. Omit report to list recent ones. Pass waiting=true on a fleet agent key to list actionable waiting reports, oldest first. Postponed and closed reports are excluded; request surface=person or tier=person explicitly to see person work.",
      "params": {
        "report": "optional — task number or id from report_bug. Also accepted as number= or id=. Omit to list up to 20 recent reports this account filed.",
        "waiting": "optional, true/1/yes/on. Fleet agent keys only. Omit report and set this to list every capture report waiting for a re-test (up to 50, oldest first). Ignored on any other key. Each item carries verify_surface and verify_tier (the tier that owns it now).",
        "surface": "optional, with waiting=true — web, ios, android, api, site (sponicgardens.com and the spotka.co landing pages), intranet or person. Only that surface's reports.",
        "tier": "optional, with waiting=true — a tier id from the verification cascade (claude-browser, grokbot, maestro-ios, claude-simulator, maestro-android, claude-emulator, api-probe, api-probe-second-model). Only reports whose current tier is this one."
      }
    },
    {
      "do": "verify_bug",
      "what": "Submit the retest verdict on a report this account filed, without the email token. Normally when waiting_for_retest is true. probe=true records an observation on your own filing at any time, with note and dataset_version required; still_broken reopens even a closed report, and works outside the retest queue records evidence without claiming a verified fix. A QA fleet agent may also verify other accounts' waiting reports.",
      "params": {
        "report": "required — task number or id. Also accepted as number= or id=",
        "verdict": "required — works, still_broken, or unverified when the re-test could not run",
        "note": "up to 4000 characters; required with probe=true — repro, observed URL and build",
        "probe": "optional, true/1/yes/on. Record an own-report observation outside the retest queue or contest a closure. Requires note and dataset_version. still_broken reopens without releasing security or person-owned holds.",
        "dataset_version": "optional, up to 200 characters; required with probe=true — pinned fixture version or hash of the dataset actually observed",
        "tier": "optional — the cascade tier answering (for example claude-browser or maestro-ios). With verdict=unverified it passes the report to the next tier; after the last machine tier the report waits for a person. A tier from another surface is recorded as wrong_surface and not counted.",
        "reason": "optional — short code for why, for example surface_unreachable, fixture_missing, login_blocked, no_production_build"
      }
    },
    {
      "do": "test_session",
      "what": "Mint a Supabase sign-in session for a QA agent test account (is_test + test_account_kind=agent), so a device or browser tester can open the member app as that account. A test key may name any active agent test account; a QA fleet agent key only its own. Every other key, and every other account, is refused. Rate limited per key and per account; every call is logged.",
      "params": {
        "for": "test key: required — the agent test account's email or id. Fleet agent key: optional, must be its own account."
      }
    },
    {
      "do": "capabilities",
      "what": "What your key may do, before you try it: which verbs are available to this key, what each one changes in the world (writes_data, publishes_publicly, sends_email, notifies_members, confirmation_recommended), and how much of each budget is left. Reading it does not spend your write budget.",
      "params": {}
    }
  ],
  "testing": {
    "what": "A key marked as a test key can act as any test account, write without the model, and undo what it made. Ask an admin for one.",
    "as": "Add as=<name or email> to any call to run it as that test account. Test accounts only — a test key can never act as a real member.",
    "extra_verbs": {
      "actors": "List the test accounts this key may act as.",
      "seed": "Top a test account up to the number of published events the app requires before a member may join anyone else's. Reports deck_size so you know whether the deck path is available.",
      "reset": "Archive every event this key created.",
      "key_list": "List keys for a test account named with for= (email or id). Returns prefix, label, created_at, last_used_at — never the secret.",
      "key_create": "Mint a standard key for a test account named with for=. The plaintext is returned once. A test key cannot set test_mode.",
      "key_revoke": "Revoke a key belonging to the test account named with for=. Send id= from key_list."
    },
    "create_event_raw": "Add raw=true (and starts_at=<ISO time>) on a test account to write the fields directly instead of through the model — a standard key belonging to that account is enough, not only an admin test key. Sub-second, and the copy is exactly what you asked for.",
    "invites": "A test key can only invite other test accounts, so a suite never sends real email.",
    "example": "do=create_event&as=host@example&raw=true&title=T1&starts_at=2026-09-10T17:00:00Z  ->  do=join&as=guest@example&event=<id>  ->  do=event&as=host@example&event=<id>"
  },
  "limits": {
    "requests": "60 per minute, 1000 per day",
    "writes": "20 per hour, 60 per day",
    "invites": "25 people per day"
  },
  "errors": "Every failure returns { ok: false, error, message, hint }. An unrecognised parameter is unknown_parameter: the body names it, and hint offers the right name when the guess is close (q → query on events). The call does not run.",
  "mcp": {
    "url": "https://spotka.co/api/v1/mcp",
    "what": "Every verb above, offered as MCP tools, for clients that speak MCP natively (Claude, Cursor, Windsurf).",
    "connect": "claude mcp add --transport http spotka https://spotka.co/api/v1/mcp --header \"X-API-Key: spk_XXXXXXXXXXXX\""
  }
}