AppMate for AI agents

AppMate is built so coding agents (Claude, Cursor, Codex, etc.) can integrate it end-to-end without a human staring at docs. Two things make this work today, with more on the roadmap.

llms.txt

Per the llmstxt.org proposal, AppMate publishes a discovery file at the root:

text
https://appmate.cloud/llms.txt

It lists every doc page + canonical URLs the agent should read before writing integration code. The full long-form version (every page concatenated) is at /llms-full.txt.

Integration prompt

Copy this into Claude, Cursor, or any agent that can read URLs and edit your iOS project. It contains everything the agent needs to wire AppMate into a SwiftUI or UIKit app:

text
You're helping integrate AppMate into an iOS app's cancel flow.

Context:
- AppMate is a hosted service at https://appmate.cloud.
- It runs a short, App Store–safe cancel flow when users tap "Cancel
  Subscription" — collects a reason, optionally shows an offer or
  feedback path, always lets users continue to Apple's manage-subscriptions UI.

Steps to complete:
1. Ask the user for: their AppMate app slug, their app's custom URL scheme.
   If they don't have an AppMate account, point them to
   https://flow.appmate.cloud/login (free tier covers one app).
2. Add the Swift Package "https://github.com/fil-technology/appmate-ios" to
   the Xcode project (Package.swift or File → Add Package Dependencies).
   Pin to from: "0.2.0".
3. Add the URL scheme to Info.plist under CFBundleURLTypes if not already
   present.
4. At app launch, call RetentionFlow.configure(.init(appSlug:baseURL:urlScheme:))
   with appSlug from step 1, baseURL "https://cancel.appmate.cloud",
   urlScheme from step 1.
5. Replace the user's current "Cancel Subscription" button handler with
   RetentionFlow.startCancelFlow(userId:attributes:onAction:) — pass the
   user's id if available, and a switch on link.action handling at least
   .manageSubscription (call RetentionFlow.presentManageSubscriptions()).
6. Add .onOpenURL { url in if let link = RetentionFlow.deepLink(from: url)
   { handle(link) } else { /* existing handler */ } } to the SwiftUI App or
   application(_:open:options:).

Important:
- AppMate URLs are namespaced as {scheme}://retention-flow/action so they
  never collide with existing deep links. Always chain AppMate parsing
  BEFORE the existing handler.
- Never block the cancellation path. Always offer .manageSubscription as
  a fallback.

Docs: https://docs.appmate.cloud
SDK: https://github.com/fil-technology/appmate-ios
Deep link contract: https://docs.appmate.cloud/deep-links

MCP server

Published on npm as @fil-technology/appmate-mcp. Install via npx, paste an API token, and your agent gets typed tools like list_apps, create_app, publish_cancel_flow, list_waitlist_signups.

json
// claude_desktop_config.json — or .mcp.json for Cursor / Codex
{
  "mcpServers": {
    "appmate": {
      "command": "npx",
      "args": ["-y", "@fil-technology/appmate-mcp"],
      "env": {
        "APPMATE_TOKEN": "amk_…",
        "APPMATE_API_URL": "https://flow.appmate.cloud"
      }
    }
  }
}

Get a token at flow.appmate.cloud/admin/settings/api-tokens. Full REST docs at API reference. Source: github.com/fil-technology/appmate-mcp.

What the agent can do today

  • Read the deep-link contract and write parser-compatible code.
  • Add the Swift Package to your project + wire RetentionFlow.configure at launch.
  • Replace the Cancel Subscription handler with startCancelFlow.
  • Generate the embed iframe snippet for a waitlist on your marketing site. The embed at signup.appmate.cloud/embed/waitlist/{appSlug} picks up the same theme + accentColor + titleFont + formLayout the standalone page uses. Append ?transparent=1 to drop the card chrome when the host page already wraps the iframe in its own container.

Authoring flow configs — do/don't

Read this section BEFORE writing or editing a cancel flow config via the API. The schema only catches structural errors; the contract below catches behavioural mistakes that parse fine but break the user experience. The PUT endpoints return a warnings array with the same checks at runtime — use them to self-correct.

Pick the right action for each button

Every primaryButton / secondaryButton has an action that determines what actually happens when the user taps. Choose by what the user should EXPERIENCE, not by what feels semantically close:

  • return_to_app — close the cancel flow, send the user back to where they were in the app. No params.
  • manage_subscription— open Apple's subscription management UI. The required escape hatch — never hide it.
  • open_offer + offerId— fire a deep link that the iOS app interprets as “present this StoreKit promo offer.” The app does the actual presentation.
  • open_premium + optional paywallId — deep link to a specific paywall variant inside the app (e.g. cancel_save_monthly). Skip paywallId for the default paywall.
  • open_support + optional supportTopic + message — deep link the iOS app to open its support inbox prefilled. Topic is a string YOUR APP routes on (e.g. bug_report, billing).
  • open_feature + featureId — deep link to a specific screen/feature inside the app (e.g. onboarding, settings). The app must recognize the id.
  • external_url + url — open a public HTTPS URL in the browser. Use for docs, migration guides, refund policies. NEVER use for a URL that needs auth.
  • none — record the click, do nothing. Rare; use showThanksScreen: true on the response instead when you just want feedback.

showThanksScreen is opt-in for feedback-only paths

Setting showThanksScreen: trueon a response tells the renderer to record the click and land on a generic “Got it — thanks” screen INSTEAD of firing the deep link. Use it ONLY when the click itself is the entire signal you want — typically the missing-feature, too-clunky, or found-another-appreasons where you're collecting a reason, not routing the user.

Pair the label with the behaviour. When showThanksScreen is on, the label should be feedback-shaped— “Send feedback”, “Tell us why”, “Submit”. When it's off, the label should be destination-shaped— “Contact support”, “Claim 20% off”, “Open tutorial”. Mismatches are the #1 mistake the warnings system flags.

Bad vs. good

Both configs parse. The first one silently breaks the user experience — “Contact support” promises navigation, but showThanksScreen suppresses the deep link, so the user taps and lands on a generic thanks screen wondering if anything happened.

json
// ✗ BAD — label promises a destination, showThanksScreen blocks it
{
  "primaryButton": { "label": "Contact support", "action": "open_support" },
  "showThanksScreen": true
}

// ✓ GOOD #1 — label matches the feedback-only behaviour
{
  "primaryButton": { "label": "Send feedback", "action": "open_support" },
  "showThanksScreen": true
}

// ✓ GOOD #2 — actually opens the in-app support inbox, no thanks screen
{
  "primaryButton": {
    "label": "Contact support",
    "action": "open_support",
    "supportTopic": "bug_report",
    "message": "I'm seeing this issue:"
  }
}

Other patterns to follow

  • Always have a secondary button on response screens. Almost always manage_subscription — the App-Store-safe escape hatch.
  • Use open_feature with a real, app-known featureId. If the iOS app doesn't recognize featureId: "onboarding", the deep link fires but does nothing visible. Confirm the id with the iOS codebase before publishing.
  • Use open_offer with a stable offerId. Generic names like "offer" or "test" trigger a placeholder warning. The id should be unique per offer (e.g. ios_20_off_3_months).
  • Cover every reason in responses. The warnings system flags reasons without a configured response — the renderer redirects back to the picker, which feels broken.
  • Multi-select skips response screens. When reasonScreen.selectMode: "multi", the click goes straight to thanks. Don't bother authoring per-reason responses — they're dead config. Use multi for “rate which factors mattered”-style flows.
  • Test on a real device. URL schemes like quakemate://only resolve when the iOS app is installed. A desktop browser may show a permission prompt or appear to do nothing — that's expected. Use external_url for desktop-testable validations.

The warnings response

Every PUT /api/v1/apps/{id}/flows/... returns:

json
{
  "ok": true,
  "warnings": [
    {
      "code": "thanks_screen_blocks_navigation",
      "path": "/responses/technical_issue/primaryButton",
      "message": "Button labelled \"Contact support\" with action \"open_support\" suggests the user will be navigated somewhere, but the response has `showThanksScreen: true` which suppresses the deep link. ..."
    }
  ]
}

Treat any non-empty warnings array as your cue to re-read the config and probably re-PUT a fixed version BEFORE publishing. The warnings are stable codes — pattern-match thanks_screen_blocks_navigation, missing_response, placeholder_offer_id, etc., and fix them in a single follow-up call.

Authoring waitlist landing pages

The public URL at signup.appmate.cloud/{slug} renders a FULL landing page — hero, eyebrow chip, value-prop bullets, social-proof pill, themed background — not just a form. The visual treatment lives in config.hero, all fields optional. Omit the hero block entirely for the minimal card-on-neutral look (backward-compatible with old configs).

Pick a theme by intent, not by feel

  • minimal— clean white card on neutral background. Default. Use when the user's brand lives elsewhere on the page (e.g. a marketing site frame) or for conservative B2B.
  • gradient — pastel blobs + accent color. Marketing- launch energy. Use when this IS the brand surface and you want some emotional pull.
  • dark — dark hero, accent glow. Product-reveal vibe. Use for premium / pro-tier waitlists.
  • side_by_side— two-column desktop layout (story left, form right). Collapses to minimal on phone — that's the public renderer's honest responsive behaviour, not a bug. Use when you have 3+ bullets worth telling a story about.

Full waitlist config shape

json
{
  "type": "waitlist",
  "intro": {
    "title":            "Get AppMate Pro early",
    "subtitle":         "Unlimited apps, custom domains, webhooks…",
    "emailPlaceholder": "you@company.com",
    "submitLabel":      "Notify me",
    "legal":            "One email when Pro launches."
  },
  "success": {
    "title":   "You're on the list.",
    "body":    "We'll email you the moment Pro is ready.",
    "ctaLabel":"Back to the dashboard",
    "ctaUrl":  "https://flow.appmate.cloud/login"
  },
  "hero": {
    "theme":        "gradient",
    "eyebrow":      "Founder pricing",
    "accentColor":  "#7c3aed",
    "showCount":    true,
    "bullets": [
      { "icon": "📈", "title": "Unlimited apps",     "body": "Across your portfolio." },
      { "icon": "🪝", "title": "Webhooks + API",     "body": "Pipe events into your stack." },
      { "icon": "🎨", "title": "Custom domains",     "body": "Serve flows from your own brand." }
    ]
  }
}

Patterns to follow

  • When you have a real launch date or beta-window phrase, put it in hero.eyebrow(under 40 chars). It tightens the page's emotional hook without bloating the title.
  • accentColortints THREE things: the submit button, the eyebrow chip, and the gradient blob. Pick a colour that reads well on white AND has enough saturation that the chip's 12%-alpha background is still visible.
  • showCount: truerenders a “{N} on the waitlist” pill above the form. The renderer hides the pill when the count is < 3 — “1 person on the waitlist” looks worse than no pill. So you can ship this on every launch without orchestrating the cutover.
  • Cap bullets at 3 for the gradient and side_by_side themes — they look great with three, cluttered with five. The minimal theme tolerates 4–5 because they sit below the form, not next to it.
  • Use the live /examples?kind=waitlist gallery as your reference set. Each card's “Copy config” emits the exact JSON you'd PUT — a fast way to seed.

Starter templates

Six waitlist starter templates exist server-side. The user can click any from /examples?kind=waitlist and land in the editor with the full config pre-filled. From the MCP / REST side, you can just emit the same shape directly:

  • minimal_email_only — single email field on a white card. The baseline.
  • feature_tease— gradient, 3 bullets, social proof. Best when there's product personality to brag about.
  • launching_soon — dark theme, date-shaped eyebrow. Good for product reveals where the date is the story.
  • pro_upsell — side_by_side, premium bullets, founder- rate framing. The Pro/Plus tier waitlist.
  • private_beta— minimal, “Invite-only” eyebrow, downplays count. Positions access as scarce.
  • early_access_referral— gradient, “skip the line by inviting friends” framing.

Authoring feedback & report flows

Two more flow types live on the same dashboard + API surface as cancel + waitlist. Both are hosted, themeable forms with cursor- paginated submission lists. Same auth, same publish/draft model, same warnings response — just different schemas.

When to use which

  • Feedback— open-ended “tell us what you think” collection. Optional 1–5 star rating + free-text message + optional reply email. Lives at appmate.cloud/feedback/{appSlug}.
  • Report — categorised bug / abuse / spam channel. Required category picker (1–10 entries, snake_case ids) + free- text message + optional reply email. Lives at appmate.cloud/report/{appSlug}. The submit endpoint validates the posted category against the flow's live category list — unknown ids get a 422.

Feedback config example

json
{
  "type": "feedback",
  "intro": {
    "title":              "Send us feedback",
    "subtitle":           "Tell us what's working, what's broken.",
    "messagePlaceholder": "What's on your mind?",
    "submitLabel":        "Send feedback"
  },
  "rating": {     "enabled": true,  "prompt": "Rate your experience", "required": false },
  "emailField": { "enabled": true,  "placeholder": "you@example.com",   "required": false },
  "success": {
    "title": "Thanks — we got it.",
    "body":  "Your feedback is in front of the team."
  }
}

Report config example

json
{
  "type": "report",
  "intro": {
    "title":              "Report an issue",
    "subtitle":           "Tell us what went wrong.",
    "messagePlaceholder": "What happened?",
    "submitLabel":        "Submit report"
  },
  "categories": [
    { "id": "bug",     "label": "Bug or crash",       "emoji": "🐞" },
    { "id": "abuse",   "label": "Harassment or abuse","emoji": "🚫" },
    { "id": "spam",    "label": "Spam",               "emoji": "🧹" },
    { "id": "privacy", "label": "Privacy concern",    "emoji": "🔒" },
    { "id": "other",   "label": "Something else",     "emoji": "💬" }
  ],
  "emailField": { "enabled": true, "required": false },
  "success": {
    "title": "Report received.",
    "body":  "We'll review and follow up if we need more details."
  }
}

REST endpoints

  • GET /api/v1/apps/{id}/flows/feedback + PUT + POST /flows/feedback/publish.
  • GET /api/v1/apps/{id}/flows/report + PUT + POST /flows/report/publish.
  • GET /api/v1/apps/{id}/feedback/submissions?limit=&cursor=
  • GET /api/v1/apps/{id}/report/submissions?limit=&cursor=&category=

Both PUT endpoints return { ok: true, warnings: [] } — the warnings array is empty today (no rules wired) but the shape matches the cancel + waitlist endpoints so agents can use the same response handling code path.