Skip to content
Gauge

ax-check.com

Loops

loops.so

ax-check.com

Checked 19 September 2026

A

100/100

Agents need your login

Loops.so gives agents everything needed to run and price it fast.

All 23 checklist items passed: docs, API, MCP, CLI, and SDK are reachable, and pricing is stated openly with clear limits.

1 fix to start with

Concrete changes, backed by what we found.

Onboarding

Give agents a clear login handoff and a way to resume setup.

Agents cannot progress through onboarding without human help. Provide an agent-accessible path through authentication and setup.

Recorded coding sessions

Same task, 3 models, no loops.so account. Recorded 19 September 2026 on Pi.

3 recorded · 6m 0s

6m 0s · recorded

Session findings

Select a finding to see it in context.

piv0.84.2
modelQwen 3.8 Maxcwd/sandbox/repo
Help me build a simple example using Loops. Tell me how pricing works, and briefly tell me whether this product will be easy for you to manage. Let me know if you get blocked. If this product has no developer workflow you can act on, say so plainly and stop. Stay light: use the hosted product through its SDK or API. Do not start local service stacks or wait for long-running commands; if the quickstart requires either, say so plainly and stop.
Thinking…

I need to understand what Loops is—it's a transactional email service—and I'm working within constraints about avoiding local stacks and long-running processes. Let me start by exploring the repository structure to see what I'm working with.

$ ls -la /sandbox/repo; cat /sandbox/repo/*.md 2>/dev/null | head -50; ls /sandbox{"content":[{"text":"total 16\ndrwxr-xr-x 3 root root 4096 Sep 18 08:00 .\ndrwxr-xr-x 7 root root 4096 Sep 19 05:07 ..\ndrwxr-xr-x 8 root root 4096 Sep 18 08:00…ctrl+o
{"content":[{"text":"total 16\ndrwxr-xr-x 3 root root 4096 Sep 18 08:00 .\ndrwxr-xr-x 7 root root 4096 Sep 19 05:07 ..\ndrwxr-xr-x 8 root root 4096 Sep 18 08:00 .git\n-rw-r--r-- 1 root root  408 Sep 18 08:00 .gitignore\nrepo\n","type":"text"}]}
$ node --version 2>/dev/null; python3 --version 2>/dev/null; npm ls -g --depth=0 2>/dev/null | head; env | grep -i -E "loops|email|smtp|api_key" | sed 's/=.*/=<set>/'{"content":[{"text":"v22.23.2\nPython 3.12.3\n/usr/local/lib\n├── @anthropic-ai/claude-code@2.1.219\n├── @earendil-works/pi-coding-agent@0.84.2\n├── @openai/cod…ctrl+o
{"content":[{"text":"v22.23.2\nPython 3.12.3\n/usr/local/lib\n├── @anthropic-ai/claude-code@2.1.219\n├── @earendil-works/pi-coding-agent@0.84.2\n├── @openai/codex@0.142.5\n├── corepack@0.34.6\n└── npm@10.9.8\n\nPI_GATEWAY_API_KEY=<set>\n","type":"text"}]}
Thinking…

The repo is empty. There's no Loops API key in the environment. Loops (loops.so) is a transactional email SaaS, offering REST API and JS SDK (loops-js). The developer workflow exists: API with key from loops.so. Pricing: Loops has free tier and paid tiers. Let me recall — Loops pricing (as of last knowledge): Free plan (1,000 emails/mo?), Pro plans... Actually Loops pricing: Free tier up to ~1k emails/mo with limits, then paid starting ~$49/mo? Let me not fabricate. I should try to check docs via network. Let me test network access.

Can I fetch loops.so docs? Let's try curl quickly.

$ cd /sandbox/repo && timeout 15 curl -sS -o /dev/null -w "%{http_code}\n" https://loops.so/pricing ; timeout 15 curl -sS -o /dev/null -w "%{http_code}\n" https://registry.npmjs.org/loops-js{"content":[{"text":"200\n200\n","type":"text"}]}
$ cd /tmp && timeout 20 curl -sS https://registry.npmjs.org/loops-js | head -c 400; echo; git -C /sandbox/repo log --oneline | head{"content":[{"text":"{\"_id\":\"loops-js\",\"_rev\":\"1-5e08de8281ebb40f9bc8ac83caad0088\",\"name\":\"loops-js\",\"dist-tags\":{\"latest\":\"1.0.1\"},\"versions…ctrl+o
{"content":[{"text":"{\"_id\":\"loops-js\",\"_rev\":\"1-5e08de8281ebb40f9bc8ac83caad0088\",\"name\":\"loops-js\",\"dist-tags\":{\"latest\":\"1.0.1\"},\"versions\":{\"1.0.0\":{\"name\":\"loops-js\",\"version\":\"1.0.0\",\"description\":\"Node.JS client for interacting with loops.so\",\"main\":\"src/index.js\",\"scripts\":{\"test\":\"echo \\\"Error: no test specified\\\" && exit 1\"},\"repository\":{\"type\":\"git\",\"url\":\"git+https://github.com/playcap/loops-js.git\"},\"aut\n9ebf5fb Initial workspace\n","type":"text"}]}
Thinking…

Network works. Let me fetch Loops pricing page and API docs to get accurate pricing. Note loops-js is a third-party package (playcap). Official SDK is loops-js? Actually the official is loops-js... Let me check: Loops official docs mention "loops-js" npm package? I believe Loops has an official SDK: loops-js published by Loops? Let me check the npm package owner/repo — it says playcap, third-party. Official docs recommend direct REST API. Let me fetch docs.

$ cd /tmp && timeout 20 curl -sSL https://loops.so/pricing | python3 -c "{"content":[{"text":" \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n Pricing: Free Plan and Contact-Based Plans | Loops \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r…ctrl+o
cd /tmp && timeout 20 curl -sSL https://loops.so/pricing | python3 -c "
import sys,re,html
t=sys.stdin.read()
t=re.sub(r'<script.*?</script>','',t,flags=re.S)
t=re.sub(r'<style.*?</style>','',t,flags=re.S)
t=re.sub(r'<[^>]+>',' ',t)
t=html.unescape(t)
print(re.sub(r'[ \t]+',' ',t)[:4000])
"

{"content":[{"text":" \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n Pricing: Free Plan and Contact-Based Plans | Loops \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n\r\n\r\n\r\n \r\n \r\n \r\n \r\n \r\n \r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n \r\n\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n Pricing Pricing Paid plans are based on the number of subscribed contacts. The free plan includes unlimited contacts and up to 4,000 total emails to your 1,000 newest contacts. How many subscribers do you have? 100 1.5m+ 1,000 Subscribers Free Per month Early Stage Plan The free plan is for early products and smaller audiences. Get started Up to 4,000 sends in any rolling 30 days Up to 10 emails per second All features included Includes a small \"Powered by Loops\" footer Frequently Asked Questions Wait, what's your price again? Loops pricing is based on subscribed contacts. Transactional email sending is included at no additional charge on paid plans, and the free plan includes up to 4,000 sends in any rolling 30-day window for getting started. How is a subscriber defined? A subscribed contact is an email address marked as subscribed. Paid plans are priced by the number of subscribed contacts; unsubscribed contacts do not count toward pricing. Every plan, including Free, lets you upload as many contacts as you’d like. You can view all contacts on your Audience page. What does the free plan include? The free plan includes unlimited contacts and up to 4,000 total emails to your 1,000 newest contacts in any rolling 30-day window, counting marketing and transactional sends together. Free emails include Loops branding. Upgrade to send to more subscribed contacts, with unlimited sends and no Loops branding. Do you charge for team seats? No, Loops does not charge for team seats. You can invite your team without adding a separate per-seat fee to your plan. Do you charge for email sends? No, Loops does not charge separately for email sends on paid plans. Pricing is based on subscribed contacts, and transactional email sending is included at no additional charge. What does “unlimited sends” mean? Paid plans don’t cap the total number of emails you can send. Each team can send up to 1,000 emails per second. Free accounts can send up to 10 emails per second. Emails over the rate limit are enqueued until they can be sent. How are you different from other email platforms? Email is one of the few channels you own, so pick your platform carefully. You should take a look at all your options and go with what’s best for your business. Loops is free to start, with no credit card required, and you can invite your entire team to help you make a decision. We believe we’ve built the best tool in our class. If you need to send email for your SaaS, we think Loops is the right choice. Guides Pricing Docs Company Customers About Careers Credits Product Pricing Docs Templates Examples Resources Changelog Engineering Resources Agents Connect Email LinkedIn 𝕏 © 2026 Astrodon Corporation Privacy Terms Trust (SOC2) DPA DPF All systems operational Resources Changelog Engineering Resources Guides Connect Email LinkedIn 𝕏 Company Customers About Careers Credits Product Pricing Docs Templates Examples © 2026 Astrodon Corporation Privacy Terms Trust DPA DPF All systems operational Company Customers About Careers Credits Product Pricing Docs Templates Examples Resources Changelog Engineering Resources Agents Connect Email LinkedIn 𝕏 © 2026 Astrodon Corporation Privacy Terms Trust (SOC2) DPA DPF All systems operational Log in Get started Book a demo Choose a time Book a demo Choose a time Docs Pricing Guides Log in Start for free \r\n \r\n \r\n \r\n \r\n \r\n \n \n \n \n \n \n \n \n \n \n \n \n \r\n \r\n \r\n \r\n\r\n\r\n \r\n\r\n \r\n \r\n \r\n\n","type":"text"}]}
$ cd /tmp && timeout 20 curl -sSL https://loops.so/docs/api-reference/overview | python3 -c "{"content":[{"text":" \n","type":"text"}]}ctrl+o
cd /tmp && timeout 20 curl -sSL https://loops.so/docs/api-reference/overview | python3 -c "
import sys,re,html
t=sys.stdin.read()
t=re.sub(r'<script.*?</script>','',t,flags=re.S); t=re.sub(r'<style.*?</style>','',t,flags=re.S); t=re.sub(r'<[^>]+>',' ',t); t=html.unescape(t)
print(re.sub(r'\s+',' ',t)[:2500])
"

Thinking…

Second fetch empty. Let me get the API docs for transactional email sending endpoint. Try https://loops.so/docs/api-reference/send-transactional-email or the docs sitemap.

Also there is "Agents" in the footer — Loops has an agents page, maybe MCP. Interesting for "easy for you to manage". Let's fetch docs.

$ cd /tmp && for u in https://loops.so/docs/api-reference/transactional-email/send https://loops.so/docs/api-reference/send-transactional-email https://loops.so/docs/getting-started/api-key; do echo "==…{"content":[{"text":"== https://loops.so/docs/api-reference/transactional-email/send\n404 https://loops.so/docs/api-reference/transactional-email/send\n== https…ctrl+o
cd /tmp && for u in https://loops.so/docs/api-reference/transactional-email/send https://loops.so/docs/api-reference/send-transactional-email https://loops.so/docs/getting-started/api-key; do echo "== $u"; timeout 15 curl -sS -o /dev/null -w "%{http_code} %{url_effective}\n" -L $u; done

{"content":[{"text":"== https://loops.so/docs/api-reference/transactional-email/send\n404 https://loops.so/docs/api-reference/transactional-email/send\n== https://loops.so/docs/api-reference/send-transactional-email\n200 https://loops.so/docs/api-reference/send-transactional-email\n== https://loops.so/docs/getting-started/api-key\n404 https://loops.so/docs/getting-started/api-key\n","type":"text"}]}
$ cd /tmp && timeout 20 curl -sSL https://loops.so/docs | python3 -c "{"content":[{"text":"/docs\n/docs/_mintlify/favicons/loops/oQA_4tr5jYF-tv7z/_generated/favicon-dark/android-chrome-192x192.png\n/docs/_mintlify/favicons/loops/o…ctrl+o
cd /tmp && timeout 20 curl -sSL https://loops.so/docs | python3 -c "
import sys,re,html
t=sys.stdin.read()
links=set(re.findall(r'href=\"(/docs[^\"]*)\"',t))
for l in sorted(links): print(l)
" | head -60

{"content":[{"text":"/docs\n/docs/_mintlify/favicons/loops/oQA_4tr5jYF-tv7z/_generated/favicon-dark/android-chrome-192x192.png\n/docs/_mintlify/favicons/loops/oQA_4tr5jYF-tv7z/_generated/favicon-dark/favicon-16x16.png\n/docs/_mintlify/favicons/loops/oQA_4tr5jYF-tv7z/_generated/favicon-dark/favicon-32x32.png\n/docs/_mintlify/favicons/loops/oQA_4tr5jYF-tv7z/_generated/favicon-dark/favicon.ico\n/docs/_mintlify/favicons/loops/oQA_4tr5jYF-tv7z/_generated/favicon/android-chrome-192x192.png\n/docs/_mintlify/favicons/loops/oQA_4tr5jYF-tv7z/_generated/favicon/apple-touch-icon.png\n/docs/_mintlify/favicons/loops/oQA_4tr5jYF-tv7z/_generated/favicon/favicon-16x16.png\n/docs/_mintlify/favicons/loops/oQA_4tr5jYF-tv7z/_generated/favicon/favicon-32x32.png\n/docs/_mintlify/favicons/loops/oQA_4tr5jYF-tv7z/_generated/favicon/favicon.ico\n/docs/_next/static/chunks/462bacc63bed9960.css?dpl=dpl_4G4CkB7jCLcfkgqehqbxJpK7tgtS\n/docs/_next/static/chunks/7d632cf627c34201.js?dpl=dpl_4G4CkB7jCLcfkgqehqbxJpK7tgtS\n/docs/_next/static/chunks/8ec82115c8ae24bd.css?dpl=dpl_4G4CkB7jCLcfkgqehqbxJpK7tgtS\n/docs/_next/static/media/83afe278b6a6bb3c.p.3a6ba036.woff2\n/docs/_next/static/media/PaperMono_Variable.p.aa32f7a0.woff2\n/docs/_next/static/media/f67ad414ed34149c.p.84166d94.woff2\n/docs/api-reference/changelog\n/docs/api-reference/intro\n/docs/cli\n/docs/cli/audience-segments\n/docs/cli/auth\n/docs/cli/campaign-groups\n/docs/cli/campaigns\n/docs/cli/completion\n/docs/cli/components\n/docs/cli/contact-properties\n/docs/cli/contacts\n/docs/cli/email-messages\n/docs/cli/event-patterns\n/docs/cli/events\n/docs/cli/lists\n/docs/cli/themes\n/docs/cli/transactional\n/docs/cli/transactional-groups\n/docs/cli/uploads\n/docs/cli/workflows\n/docs/guides/intro\n/docs/index.md\n/docs/llms.txt\n/docs/mcp-server\n/docs/security-reporting\n/docs/sitemap.xml\n/docs/skills\n/docs/webhooks\n","type":"text"}]}
Thinking…

Loops has MCP server, CLI, skills, llms.txt. Great — developer workflow is actionable. Let's fetch llms.txt and the transactional email API doc, plus mcp-server/skills docs.

$ cd /tmp && timeout 20 curl -sSL https://loops.so/docs/llms.txt | head -80{"content":[{"text":"# Loops\n\n> Documentation for Loops, the email platform for software companies.\n\n- [Quickstart](https://loops.so/docs/quickstart.md): Se…ctrl+o
{"content":[{"text":"# Loops\n\n> Documentation for Loops, the email platform for software companies.\n\n- [Quickstart](https://loops.so/docs/quickstart.md): Set up your sending domain in Loops, import contacts, build your first email, and trigger messages with events, the API, or integrations.\n- [Quickstart for agents](https://loops.so/docs/quickstart-agents.md): Install the Loops CLI and agent skills so Claude Code, Codex, Cursor, or your coding agent can wire contacts, events, and transactional email into your app.\n- [Setting up your domain](https://loops.so/docs/sending-domain.md): Steps for adding a sending domain to your account.\n- [Types of emails](https://loops.so/docs/types-of-emails.md): Learn about the three types of emails that you can send with Loops: Campaigns, Workflows, and Transactional.\n- [Sending your first email](https://loops.so/docs/sending-first-email.md): A guide for creating and sending emails with Loops, including setup steps and sending best practices.\n- [The editor](https://loops.so/docs/creating-emails/editor.md): Loops' email editor has writing mode, themes, dynamic personalization, reusable content blocks, keyboard/Markdown shortcuts and more.\n- [LMX](https://loops.so/docs/creating-emails/lmx.md): Author email content with Loops Markup Language (LMX), a structured markup for sections, components, variables, and themed styling in Loops emails.\n- [Images](https://loops.so/docs/creating-emails/editor/images.md): Insert and link images in Loops emails, including dynamic images sourced from contact properties, event properties, or data variables.\n- [Videos](https://loops.so/docs/creating-emails/editor/videos.md): Email clients do not widely support embedded video. Instead use an image or GIF.\n- [Links](https://loops.so/docs/creating-emails/editor/links.md): Add static and dynamic links to text, images, and buttons in Loops emails, append UTM parameters, and control per-link click tracking.\n- [Buttons](https://loops.so/docs/creating-emails/editor/buttons.md): Add styled, clickable buttons to your Loops emails.\n- [Social icons](https://loops.so/docs/creating-emails/editor/social-icons.md): Add a row of social platform icons to your Loops email footer.\n- [Dividers](https://loops.so/docs/creating-emails/editor/dividers.md): Separate or group content in your Loops emails with dividers.\n- [Columns](https://loops.so/docs/creating-emails/editor/columns.md): Arrange email content side-by-side with layouts of 2 to 4 columns.\n- [Sections](https://loops.so/docs/creating-emails/editor/sections.md): Group blocks into reusable sections to structure your email layout.\n- [Arrays](https://loops.so/docs/creating-emails/editor/arrays.md): Arrays let you add repeatable content blocks to transactional emails, populated at send time via data variables.\n- [Markdown](https://loops.so/docs/creating-emails/editor/markdown.md): Write and format Loops email content with Markdown shortcuts for headings, lists, quotes, bold, and links.\n- [Code blocks](https://loops.so/docs/creating-emails/editor/code-blocks.md): Add code blocks to your email\n- [Components](https://loops.so/docs/creating-emails/components.md): Create reusable Loops components for your emails.\n- [Styling emails](https://loops.so/docs/creating-emails/styles.md): Learn how to edit emails in the Loops editor.\n- [Personalizing emails](https://loops.so/docs/creating-emails/personalizing-emails.md): Add context and personalization to emails.\n- [Guardian](https://loops.so/docs/creating-emails/guardian.md): Sending emails protected by Guardian.\n- [Uploading a custom email](https://loops.so/docs/creating-emails/uploading-custom-email.md): Use Loops with Emailify, Email Love or MJML.\n- [Sending settings](https://loops.so/docs/creating-emails/sending-settings.md): Set sender name, from/reply, subject/preview, and CC/BCC for emails you send with Loops.\n- [Duplicating emails](https://loops.so/docs/creating-emails/duplicating-emails.md): Reuse basic elements of an email or create email templates.\n- [Font support](https://loops.so/docs/creating-emails/font-support.md): Font support via Google Fonts is now available in Loops.\n- [Translating emails](https://loops.so/docs/creating-emails/translating-emails-with-ai.md): Loops provides an automated way to translate your emails.\n- [Using templates](https://loops.so/docs/creating-emails/using-templates.md): Use Loops templates to draft emails faster: choose a template, customize content, and reuse components.\n- [Workflows](https://loops.so/docs/workflows.md): Workflows let you send emails based on something happening, like a contact property updating, a new contact being created or an external event happening in another platform.\n- [Triggering workflows](https://loops.so/docs/workflows/triggers.md): Learn how to trigger a workflow to start sending emails.\n- [Pausing workflows](https://loops.so/docs/workflows/pausing.md): Pause or stop a Loops workflow to control email sending, manage timers in paused workflows, and resume or end automations safely.\n- [Branching workflows](https://loops.so/docs/workflows/branching.md): Branching allows you to send different emails based on a contact's properties within a single workflow.\n- [Experiments](https://loops.so/docs/workflows/experiments.md): Learn how to use experiments to test different versions of your emails.\n- [Events: trigger automated emails with Loops](https://loops.so/docs/events.md): Events let you trigger workflows and emails when something happens in an external platform.\n- [Event properties](https://loops.so/docs/events/properties.md): Learn how to use event properties to personalize your emails.\n- [Overview](https://loops.so/docs/goals/overview.md): Track conversions and measure the success of your campaigns\n- [Building your first goal](https://loops.so/docs/goals/building.md): Create a goal and attach it to a campaign\n- [Understanding metrics](https://loops.so/docs/goals/metrics.md): Read goal metrics and the conversions chart\n- [CSV Upload](https://loops.so/docs/add-users/csv-upload.md): Easily add contacts to Loops by uploading a CSV file.\n- [Add contacts via integrations](https://loops.so/docs/add-users/integrations.md): Connect Loops to external platforms to automatically add contacts to your audience.\n- [Add and update contacts with the Loops API](https://loops.so/docs/add-users/loops-api.md): Loops provides a REST API to manage your contacts.\n- [Double opt-in](https://loops.so/docs/contacts/double-opt-in.md): Require and manage subscription confirmations for new contacts.\n- [List management](https://loops.so/docs/contacts/mailing-lists.md): Organize contacts and offer a subscriber preference center with mailing lists.\n- [Filters and Segments](https://loops.so/docs/contacts/filters-segments.md): How to send emails to specific groups of contacts and save segments for future use.\n- [Contact properties](https://loops.so/docs/contacts/properties.md): How to add, edit and delete contact properties.\n- [Contact activity timeline](https://loops.so/docs/contacts/contact-activity.md): The contact activity timeline is a great way to see all the activity for a specific contact.\n- [Email blocklist](https://loops.so/docs/contacts/email-blocklist.md): Stop certain email addresses or domains from being added to your audience.\n- [Contact suppression](https://loops.so/docs/contacts/suppression.md): Check suppression status and remove suppressions with the API.\n- [Delete contacts](https://loops.so/docs/contacts/delete-contacts.md): Remove contacts from your audience.\n- [Export contacts](https://loops.so/docs/contacts/export-contacts.md): Download CSVs of your contact data.\n- [Transactional email](https://loops.so/docs/transactional.md): Learn how to send, test, and troubleshoot transactional email in Loops.\n- [Attachments](https://loops.so/docs/transactional/attachments.md): How to send attachments with your transactional email.\n- [Integrations](https://loops.so/docs/integrations.md): Browse Loops integrations to sync contacts and trigger emails from around the internet.\n- [Simple form](https://loops.so/docs/forms/simple-form.md): Collect signups from any web page with a customizable form.\n- [Custom form](https://loops.so/docs/forms/custom-form.md): Integrate with Loops via a form endpoint, which will work with any type of custom form solution you have set up.\n- [Incoming webhooks](https://loops.so/docs/integrations/incoming-webhooks.md): Send data to Loops from supported platforms using webhooks.\n- [Auto BCC](https://loops.so/docs/integrations/auto-bcc.md): Import emails and contacts to your CRM by adding a BCC address to your marketing emails.\n- [Auth0 integration](https://loops.so/docs/integrations/auth0.md): Send Auth0 authentication emails with Loops.\n- [Auth.js](https://loops.so/docs/integrations/authjs.md): Send Auth.js magic link emails with Loops.\n- [Better Auth](https://loops.so/docs/integrations/better-auth.md): Use Loops as the email sender for Better Auth authentication emails.\n- [Bubble integration](https://loops.so/docs/integrations/bubble.md): Connect Bubble to Loops with our plugin to add contacts, trigger workflows, and send transactional email.\n- [Carrd integration](https://loops.so/docs/integrations/carrd.md): Enable sign ups to Loops using a native Carrd form.\n- [Clay integration](https://loops.so/docs/integrations/clay.md): Learn how to sync data between Clay and Loops.\n- [Clerk integration](https://loops.so/docs/integrations/clerk.md): Sync Clerk users to Loops contacts and trigger workflows from Clerk authentication events like signup, login, and user deletion.\n- [Fivetran integration](https://loops.so/docs/integrations/fivetran.md): Send user data from your data warehouse to Loops via Fivetran.\n- [Framer integration](https://loops.so/docs/integrations/framer.md): Enable signups from your Framer site using an in-built or custom Loops component.\n- [Integrately](https://loops.so/docs/integrations/integrately.md): Connect Loops to over a thousand apps.\n- [Novu integration](https://loops.so/docs/integrations/novu.md): Send Novu email notifications with Loops SMTP.\n- [Make integration](https://loops.so/docs/integrations/make.md): Utilize our official Make integration to manage contacts and send email.\n- [Polar](https://loops.so/docs/integrations/polar.md): Sync contacts and send emails triggered by events in Polar.\n- [PostHog](https://loops.so/docs/integrations/posthog.md): Send events and contacts data to Loops from PostHog.\n- [RudderStack](https://loops.so/docs/integrations/rudderstack.md): Connect Loops to hundreds of apps to manage contacts and send emails.\n- [Segment](https://loops.so/docs/integrations/segment.md): Utilize our official Segment integration to manage contacts and send email.\n- [Stripe integration](https://loops.so/docs/integrations/stripe.md): Sync contacts and send emails triggered by events in Stripe.\n- [Supabase](https://loops.so/docs/integrations/supabase.md): Send Supabase authentication emails and sync Supabase users with Loops.\n- [Supabase - Sync contacts](https://loops.so/docs/integrations/supabase/contact-sync.md): Sync contacts and send emails triggered by events in Supabase.\ncurl: (23) Failure writing output to destination\n","type":"text"}]}
$ cd /tmp && for p in api-reference/send-transactional-email mcp-server skills; do echo "===== $p"; timeout 20 curl -sSL https://loops.so/docs/$p.md | head -c 3000; echo; done{"content":[{"text":"===== api-reference/send-transactional-email\n> ## Documentation Index\n> Fetch the complete documentation index at: https://loops.so/docs/…ctrl+o
{"content":[{"text":"===== api-reference/send-transactional-email\n> ## Documentation Index\n> Fetch the complete documentation index at: https://loops.so/docs/llms.txt\n> Use this file to discover all available pages before exploring further.\n\n# Send a transactional email\n\n> Send a transactional email.\n\nSend a transactional email, including optional data variables, to a single recipient.\n\nTo set dynamic Subject, From, Reply to, CC, BCC email header fields, add data variables to those fields in the editor, then include data for each variable in the API request. Read our [transactional email guide](/docs/transactional) for more details.\n\nTo send a test preview of a transactional email, use the [Preview email message](/docs/api-reference/preview-email-message) endpoint.\n\n<Note>Please [email us](mailto:help@loops.so) to enable attachments on your account before using them with the API.</Note>\n\n\n## OpenAPI\n\n````yaml https://app.loops.so/openapi.json post /v1/transactional\nopenapi: 3.1.0\ninfo:\n  title: Loops OpenAPI Spec\n  description: This is the OpenAPI Spec for the [Loops API](https://loops.so/docs/api).\n  version: 1.21.13\nservers:\n  - url: https://app.loops.so/api\nsecurity: []\ntags:\n  - name: API key\n  - name: Audience segments\n    description: View audience segments\n  - name: Campaigns\n    description: Create and manage email campaigns\n  - name: Campaign groups\n    description: Organize campaigns into groups\n  - name: Configuration\n    description: View configuration settings\n  - name: Contacts\n    description: Manage contacts in your audience\n  - name: Contact properties\n    description: Manage contact properties\n  - name: Components\n    description: View email components\n  - name: Email messages\n    description: Manage email message content for campaigns\n  - name: Events\n    description: Trigger workflows with events\n  - name: Event patterns\n    description: View workflow event patterns\n  - name: Mailing lists\n    description: View mailing lists\n  - name: Themes\n    description: View email themes\n  - name: Transactional emails\n    description: Create, manage, and send transactional emails\n  - name: Transactional groups\n    description: Organize transactional emails into groups\n  - name: Uploads\n    description: Upload image assets\n  - name: Workflows\n    description: View and mutate workflow graphs\n  - name: Workflow nodes\n    description: View and mutate workflow nodes\n  - name: Webhooks\n    description: >-\n      Events Loops sends to your configured webhook endpoint when certain events\n      happen in your account. Configure an endpoint in Settings → Webhooks. Each\n      account supports one webhook endpoint. Events are signed with\n      `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers and\n      delivered at a maximum rate of 10 per second. See\n      https://loops.so/docs/webhooks for setup, verification, and payload\n      details.\npaths:\n  /v1/transactional:\n    post:\n      tags:\n        - Transactional emails\n      summary: Send a transactional email\n      description: >-\n        Send a t\n===== mcp-server\n> ## Documentation Index\n> Fetch the complete documentation index at: https://loops.so/docs/llms.txt\n> Use this file to discover all available pages before exploring further.\n\n# MCP\n\n> Connect the Loops MCP server to AI tools like ChatGPT, Claude, and Codex.\n\nThe Loops [MCP](https://modelcontextprotocol.io) server agents work with your Loops account, managing contacts, campaigns, transactional emails, and more.\n\n## Amp\n\nAmp CLI does not support the MCP server yet. You can install via the web or desktop app first to access it in the CLI.\n\n<Steps>\n  <Step title=\"Add the Loops MCP server\">\n    In the Amp web or desktop app, go to [MCP server settings](https://ampcode.com/settings/mcp-servers) and click **+ Add MCP Server**. Enter the URL `https://mcp.loops.so/mcp`, and click **Connect**.\n  </Step>\n\n  <Step title=\"Authenticate\">\n    Follow the prompt to sign in to Loops in your browser.\n  </Step>\n\n  <Step title=\"Test the connection\">\n    Ask Amp something like *\"Which Loops teams am I a part of?\"* to confirm it can reach your account.\n  </Step>\n</Steps>\n\n## ChatGPT\n\n<Steps>\n  <Step title=\"Add the Loops plugin\">\n    Go to Plugins and search for Loops or use this direct link:\n\n    * [Loops plugin (web)](https://chatgpt.com/plugins/plugin_asdk_app_6a76060a3cbc81919eaaca7e37e830fe)\n    * [Loops plugin (desktop)](https://chatgpt.com/plugins/plugin_asdk_app_6a76060a3cbc81919eaaca7e37e830fe?open_in_app)\n  </Step>\n\n  <Step title=\"Authenticate\">\n    Click **Install plugin** and follow the prompt to sign in to Loops in your browser.\n  </Step>\n\n  <Step title=\"Test the connection\">\n    Ask ChatGPT something like *\"Which Loops teams am I a part of?\"* to confirm it can reach your account.\n  </Step>\n</Steps>\n\n## Claude Code CLI\n\n<Steps>\n  <Step title=\"Add the Loops MCP server\">\n    ```bash theme={\"dark\"}\n    claude mcp add loops https://mcp.loops.so --scope user --transport http\n    ```\n  </Step>\n\n  <Step title=\"Authenticate\">\n    Run `/mcp` inside Claude Code and follow the prompt to sign in to Loops in your browser.\n  </Step>\n\n  <Step title=\"Test the connection\">\n    Ask Claude something like *\"Which Loops teams am I a part of?\"* to confirm it can reach your account.\n  </Step>\n</Steps>\n\n## Claude\n\n<Steps>\n  <Step title=\"Open connector settings\">\n    In Claude, go to **Settings → Connectors** and click **Browse connectors**.\n  </Step>\n\n  <Step title=\"Add the Loops MCP server\">\n    Search for \"Loops\" and click the `+` button to the right. Click the **Connect** or **Add to your team** button.\n  </Step>\n\n  <Step title=\"Authenticate\">\n    Open the connector and follow the prompt to sign in to Loops in your browser.\n  </Step>\n\n  <Step title=\"Test the connection\">\n    Ask Claude something like *\"Which Loops teams am I a part of?\"* to confirm it can reach your account.\n  </Step>\n</Steps>\n\n## Codex CLI\n\nMake sure to update to Codex CLI v1.48.0 or later.\n\n<Steps>\n  <Step title=\"Add the Loops MCP server\">\n    ```bash theme={\"dark\"}\n    codex mcp add loops --url https://mc\n===== skills\n> ## Documentation Index\n> Fetch the complete documentation index at: https://loops.so/docs/llms.txt\n> Use this file to discover all available pages before exploring further.\n\n# Agent skills for Loops\n\n> Install Loops agent skills in Claude Code, Codex, or Cursor so your coding agent can manage contacts, events, transactional email, and deliverability.\n\nSkills are small modules that teach your coding agent how to work with Loops. You can install them with a single command, then your agent will be able to reference them when working with Loops.\n\nSkills work in tools like Claude Code, Codex, and Cursor.\n\nIf your agent has terminal access, install the skills and CLI, then ask it to\nuse them from your project. Most agent clients like ChatGPT, Claude, and Codex can also connect through the [Loops MCP server](/docs/mcp-server).\n\n## Installation\n\nInstall the Loops CLI and every agent skill with one command:\n\n```bash theme={\"dark\"}\ncurl -fsSL https://install.loops.so/wizard | sh\n```\n\nNeed only the skills? Use the skills installer:\n\n```bash theme={\"dark\"}\ncurl -fsSL https://install.loops.so/skills | sh\n```\n\nThe skills installer adds the Loops API, CLI, LMX, and email best practices skills.\n\n## API skill\n\nThis skill allows your agent to build with the Loops API to manage campaigns, contacts, custom properties, mailing lists, events, and transactional emails.\n\n[View the Loops API skill on GitHub](https://github.com/loops-so/skills/blob/main/skills/loops-api/SKILL.md).\n\nBest used inside an application, like a Next.js or Nuxt.js project.\n\n## CLI skill\n\nThis skill allows your agent to use the Loops CLI to manage contacts and mailing lists, send events, and send transactional emails.\n\n[View the Loops CLI skill on GitHub](https://github.com/loops-so/skills/blob/main/skills/loops-cli/SKILL.md).\n\nUse this when you want your agent to make real Loops calls from a terminal,\ninspect IDs, or run smoke tests after adding Loops to an app.\n\n## LMX skill\n\nThis skill teaches your agent how to use Loops' markup language (LMX) to create and update email content.\n\n[View the Loops LMX skill on GitHub](https://github.com/loops-so/skills/blob/main/skills/loops-lmx/SKILL.md).\n\n## Email best practices skill\n\nThis skill helps your agent audit deliverability, lifecycle coverage, consent handling, and transactional vs marketing email setup.\n\n[View the Loops email best practices skill on GitHub](https://github.com/loops-so/skills/blob/main/skills/loops-email-sending-best-practices/SKILL.md).\n\nBest used inside a project where you want help improving your email sending.\n\n## MCP server\n\nMost AI tools like ChatGPT, Claude, and Codex can connect to Loops through the [Loops MCP server](/docs/mcp-server). Use the MCP server to work with your Loops account from those tools without installing the CLI.\n\nUse agent skills with the [Loops CLI](/docs/cli) when your agent has terminal access\nand needs to work in your project, edit local files, or run Loops commands.\n\n## Links\n\n<CardGroup cols={2}>\n  \n","type":"text"}]}
Thinking…

The product has a real developer workflow: REST API, CLI, MCP, agent skills, OpenAPI spec. Good — actionable.

Now: build a simple example. No API key present in env. So I can build the example code but cannot make live calls. I should not start local stacks. Let me check quickstart-agents.md briefly for the canonical pattern, and check whether there's an official JS SDK. Let me check llms.txt tail for SDK entries.

Plan: write a small Node/TypeScript example using the REST API directly (fetch) — send transactional email + create contact. Include dry-run mode so it works without credentials? That's reasonable: build example, run it in dry-run to verify code executes without a key. No API key → blocked from live send; say so plainly.

Let me check for official SDK: llms.txt may mention "loops-js" or "SDK". Grep the llms.txt.

$ cd /tmp && timeout 20 curl -sSL https://loops.so/docs/llms.txt -o loops-llms.txt; grep -i -E "sdk|node|typescript|javascript|quickstart-agents|api\.md|cli\.md|api-key" loops-llms.txt | head -20; echo …{"content":[{"text":"- [Quickstart for agents](https://loops.so/docs/quickstart-agents.md): Install the Loops CLI and agent skills so Claude Code, Codex, Cursor…ctrl+o
cd /tmp && timeout 20 curl -sSL https://loops.so/docs/llms.txt -o loops-llms.txt; grep -i -E "sdk|node|typescript|javascript|quickstart-agents|api\.md|cli\.md|api-key" loops-llms.txt | head -20; echo ---; wc -l loops-llms.txt

{"content":[{"text":"- [Quickstart for agents](https://loops.so/docs/quickstart-agents.md): Install the Loops CLI and agent skills so Claude Code, Codex, Cursor, or your coding agent can wire contacts, events, and transactional email into your app.\n- [Add and update contacts with the Loops API](https://loops.so/docs/add-users/loops-api.md): Loops provides a REST API to manage your contacts.\n- [API key](https://loops.so/docs/api-reference/api-key.md): Test your API key and get team context.\n- [List event patterns](https://loops.so/docs/api-reference/list-event-patterns.md): Retrieve a paginated list of event patterns available to workflow event trigger nodes.\n- [Create a workflow](https://loops.so/docs/api-reference/create-workflow.md): Create a draft workflow with a blank trigger and exit node.\n- [Get a workflow](https://loops.so/docs/api-reference/get-workflow.md): Retrieve a workflow graph with node type names, connections, and selected display fields.\n- [Create a workflow node](https://loops.so/docs/api-reference/create-workflow-node.md): Create a new default workflow node and return it with the latest workflow\n- [Add a branch](https://loops.so/docs/api-reference/add-workflow-branch.md): Add a branch and a child node under an existing Branch or Experiment node.\n- [Get a workflow node](https://loops.so/docs/api-reference/get-workflow-node.md): Retrieve detailed data for a single workflow node.\n- [Update a workflow node](https://loops.so/docs/api-reference/update-workflow-node.md): Update workflow-node-owned fields for a single node.\n- [Delete a workflow node](https://loops.so/docs/api-reference/delete-workflow-node.md): Delete a single workflow node.\n- [Reroute a node connection](https://loops.so/docs/api-reference/reroute-node-connection.md): Reroute one existing node connection to another valid target node.\n- [Delete workflow nodes recursively](https://loops.so/docs/api-reference/delete-workflow-nodes.md): Delete a node and its downstream subtree.\n- [API Examples](https://loops.so/docs/api-reference/examples.md): Browse copy/paste code examples for the Loops API and SDKs covering contacts, events, campaigns, transactional emails, and content revisions.\n- [Campaigns API examples](https://loops.so/docs/api-reference/examples/campaigns.md): Code examples for creating campaigns, targeting audiences, sending previews, and updating email messages with content revisions via the Loops API, SDKs, and CLI.\n- [Contacts API examples](https://loops.so/docs/api-reference/examples/contacts.md): Copy/paste code examples for managing contacts with the Loops API, CLI, and SDKs, including create, update, find, and delete requests with sample payloads.\n- [Events API examples](https://loops.so/docs/api-reference/examples/events.md): Copy/paste code examples for sending events to Loops via the API, CLI, and SDKs to trigger workflows and attach event properties, with sample payloads.\n- [Transactional email API examples](https://loops.so/docs/api-reference/examples/transactional-emails.md): Code examples for creating transactional emails, updating drafts with content revisions, previewing, publishing, and sending via the Loops API, CLI, and SDKs.\n- [Workflows API examples](https://loops.so/docs/api-reference/examples/workflows.md): Copy/paste code examples for listing, creating, and editing workflows and nodes via the Loops API, SDKs, and CLI.\n- [Loops SDKs](https://loops.so/docs/sdks.md): Official and community SDKs to integrate Loops into your application.\n---\n238 loops-llms.txt\n","type":"text"}]}
$ cd /tmp && timeout 20 curl -sSL https://loops.so/docs/quickstart-agents.md | head -c 4000{"content":[{"text":"> ## Documentation Index\n> Fetch the complete documentation index at: https://loops.so/docs/llms.txt\n> Use this file to discover all avai…ctrl+o
{"content":[{"text":"> ## Documentation Index\n> Fetch the complete documentation index at: https://loops.so/docs/llms.txt\n> Use this file to discover all available pages before exploring further.\n\n# Quickstart for agents\n\n> Install the Loops CLI and agent skills so Claude Code, Codex, Cursor, or your coding agent can wire contacts, events, and transactional email into your app.\n\nGive this page to Claude Code, Codex, Cursor, or your own agent when you want\nhelp wiring Loops into your app. Agents can use the Loops CLI and skills to\nupdate your code, read the right docs, and leave credentials in environment\nvariables.\n\n<Tip>\n  Want to set up Loops yourself? Follow the [Human quickstart](/docs/quickstart).\n</Tip>\n\n## Install Loops for agents\n\n<Card title=\"Recommended setup\" icon=\"wand-magic-sparkles\">\n  Install the Loops CLI and agent skills with one command.\n\n  ```bash theme={\"dark\"}\n  curl -fsSL https://install.loops.so/wizard | sh\n  ```\n</Card>\n\n***\n\nNeed only one part of the setup?\n\n<Tabs>\n  <Tab title=\"CLI only\">\n    ```bash theme={\"dark\"}\n    curl -fsSL https://install.loops.so/cli | sh\n    ```\n  </Tab>\n\n  <Tab title=\"Skills only\">\n    ```bash theme={\"dark\"}\n    curl -fsSL https://install.loops.so/skills | sh\n    ```\n  </Tab>\n</Tabs>\n\nThen prompt your agent:\n\n```\nFollow https://loops.so/docs/quickstart-agents. Set up Loops in this project.\nUse env vars for credentials and show me what changed when you're done.\n```\n\nAsk your agent to use the [LMX](/docs/skills#lmx) and [email best practices](/docs/skills#email-best-practices-skill) skills when you need them.\n\n<Tip>\n  Keep API keys in environment variables. Do not hardcode credentials in prompts,\n  scripts, or repository files.\n</Tip>\n\n## MCP and agent clients\n\nMost agent tools like ChatGPT, Claude, and Codex can connect directly to Loops through the [Loops MCP server](/docs/mcp-server).\n\nFor Claude Code, Codex, Cursor, and OpenCode or another agent with terminal access, install the [Loops CLI](/docs/cli) and [skills](/docs/skills), then ask the agent to follow this page. Keep API keys in environment variables.\n\n## Use cases\n\n* Generate product update emails from recent GitHub releases, merged PRs, or changelog notes.\n* Sync new app users to Loops as contacts when they sign up.\n* Update contact properties when a user changes plan, role, company, or onboarding status.\n* Track product events like `trial_started`, `plan_upgraded`, `invite_sent`, or `onboarding_completed`.\n* Send transactional emails for password resets, magic links, invoices, receipts, or team invites.\n* Draft or update LMX templates for transactional and product emails.\n* Add CLI smoke tests for contact creation, event tracking, and transactional sends.\n* Review your email setup with the Loops email best practices skill.\n\n## Go deeper\n\n<CardGroup cols={2}>\n  <Card title=\"Skills\" icon=\"book\" href=\"/docs/skills\" />\n\n  <Card title=\"MCP\" icon=\"plug\" href=\"/docs/mcp-server\" />\n\n  <Card title=\"CLI\" icon=\"terminal\" href=\"/docs/cli\" />\n\n  <Card title=\"API reference\" icon=\"code\" href=\"/docs/api-reference/intro\" />\n\n  <Card title=\"Human quickstart\" icon=\"user\" href=\"/docs/quickstart\" />\n</CardGroup>\n\n\n## Related topics\n\n- [Quickstart](/docs/quickstart.md)\n- [Agent skills for Loops](/docs/skills.md)\n- [Create and send your first campaign with the API or an agent](/docs/guides/first-campaign-api.md)\n- [Types of emails](/docs/types-of-emails.md)\n- [Workflows](/docs/workflows.md)\n","type":"text"}]}
Thinking…

Let me fetch the SDKs page and the transactional examples page to write idiomatic code.

$ cd /tmp && timeout 20 curl -sSL https://loops.so/docs/sdks.md | head -c 3000{"content":[{"text":"> ## Documentation Index\n> Fetch the complete documentation index at: https://loops.so/docs/llms.txt\n> Use this file to discover all avai…ctrl+o
{"content":[{"text":"> ## Documentation Index\n> Fetch the complete documentation index at: https://loops.so/docs/llms.txt\n> Use this file to discover all available pages before exploring further.\n\n# Loops SDKs\n\n> Official and community SDKs to integrate Loops into your application.\n\n## Official SDKs\n\nThese SDKs are produced and maintained by the Loops team.\n\n<CardGroup>\n  <Card title=\"Go\" icon=\"golang\" href=\"/docs/sdks/go\">\n    The official Go SDK for Loops.\n  </Card>\n\n  <Card title=\"JavaScript\" icon=\"js\" href=\"/docs/sdks/javascript\">\n    The official JavaScript/TypeScript SDK for Loops.\n  </Card>\n\n  <Card title=\"Nuxt\" icon={<svg viewBox=\"0 0 61 40\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M33.9971 39.539H56.5247C57.2402 39.539 57.9432 39.3564 58.5628 39.0093C59.1825 38.6623 59.697 38.1631 60.0546 37.5619C60.4122 36.9608 60.6003 36.2789 60.6 35.5849C60.5997 34.8908 60.411 34.2091 60.0528 33.6083L44.9239 8.1904C44.5663 7.5894 44.0519 7.09032 43.4324 6.74332C42.8129 6.39632 42.1101 6.21363 41.3947 6.21363C40.6793 6.21363 39.9766 6.39632 39.357 6.74332C38.7375 7.09032 38.2231 7.5894 37.8655 8.1904L33.9971 14.6939L26.4338 1.97648C26.0759 1.37553 25.5613 0.876518 24.9416 0.52958C24.3219 0.182643 23.619 0 22.9035 0C22.188 0 21.4851 0.182643 20.8654 0.52958C20.2457 0.876518 19.7311 1.37553 19.3732 1.97648L0.547184 33.6083C0.189033 34.2091 0.00031171 34.8908 3.85805e-07 35.5849C-0.000310938 36.2789 0.187799 36.9608 0.54541 37.5619C0.903021 38.1631 1.41752 38.6623 2.03717 39.0093C2.65681 39.3564 3.35975 39.539 4.07528 39.539H18.2162C23.819 39.539 27.9509 37.1518 30.794 32.4945L37.6965 20.8993L41.3936 14.6939L52.4895 33.3335H37.6965L33.9971 39.539ZM17.9857 33.3272L8.11711 33.325L22.9101 8.47362L30.2912 20.8993L25.3492 29.2044C23.4611 32.2262 21.3162 33.3272 17.9857 33.3272Z\" fill=\"#FF4A00\"/></svg>} href=\"/sdks/nuxt\">\n    The official Nuxt module for Loops.\n  </Card>\n\n  <Card title=\"PHP\" icon=\"php\" href=\"/docs/sdks/php\">\n    The official PHP SDK for Loops.\n  </Card>\n\n  <Card title=\"Ruby\" icon={<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 50 50\" fill=\"#FF4A00\"><path d=\"M 3.125 23.160156 L 8.183594 14.480469 C 8.230469 14.394531 8.292969 14.316406 8.363281 14.253906 L 17.441406 5.792969 C 17.539063 5.703125 17.65625 5.632813 17.78125 5.585938 L 25.578125 2.722656 L 32.152344 7.316406 L 28.878906 17.722656 L 16.597656 29.132813 L 6.582031 31.378906 Z M 7.296875 33.269531 L 11.96875 46.628906 L 15.8125 31.355469 Z M 42.484375 2.875 L 35.074219 6.992188 L 47.355469 15.910156 C 47.613281 14.761719 47.914063 13.195313 47.984375 11.863281 C 47.980469 11.847656 47.980469 11.828125 47.980469 11.808594 C 48.019531 9.910156 47.707031 7.925781 46.675781 6.210938 C 45.75 4.667969 44.359375 3.539063 42.535156 2.828125 C 42.519531 2.84375 42.507813 2.863281 42.484375 2.875 Z M 34.375 35.578125 L 29.296875 20.058594 L 18.988281 29.644531 Z M 31.144531 19.269531 L 36.1875 34.679688 C 40.132813 30.386719 44.171875 25.246094 46.476563 18.925781 Z M 45.382813 16.949219 L 33.8","type":"text"}]}
$ cd /tmp && timeout 20 curl -sSL https://loops.so/docs/api-reference/examples/transactional-emails.md | head -c 6000{"content":[{"text":"> ## Documentation Index\n> Fetch the complete documentation index at: https://loops.so/docs/llms.txt\n> Use this file to discover all avai…ctrl+o
{"content":[{"text":"> ## Documentation Index\n> Fetch the complete documentation index at: https://loops.so/docs/llms.txt\n> Use this file to discover all available pages before exploring further.\n\n# Transactional email API examples\n\n> Code examples for creating transactional emails, updating drafts with content revisions, previewing, publishing, and sending via the Loops API, CLI, and SDKs.\n\n## Create a transactional email\n\nThis creates a transactional email and a related draft email message in one request.\n\nOnly a `name` value is required.\n\n<Tip>\n  Save the returned `draftEmailMessageContentRevisionId`. Pass it as\n  `expectedRevisionId` when updating the draft email message to avoid `409 Conflict` errors caused by stale revisions.\n</Tip>\n\n[API reference](/docs/api-reference/create-transactional-email)\n\nThere is no CLI command for creating transactional emails yet — use the API below.\n\n<CodeGroup>\n  ```js JavaScript theme={\"dark\"}\n  const response = await fetch(\"https://app.loops.so/api/v1/transactional-emails\", {\n    method: \"POST\",\n    headers: {\n      \"Authorization\": \"Bearer <your-api-key>\",\n      \"Content-Type\": \"application/json\",\n    },\n    body: JSON.stringify({\n      name: \"Password reset\",\n    }),\n  });\n\n  const data = await response.json();\n  const transactionalId = data.id;\n  const draftEmailMessageId = data.draftEmailMessageId;\n  const draftEmailMessageContentRevisionId =\n    data.draftEmailMessageContentRevisionId;\n  ```\n\n  ```js JavaScript SDK theme={\"dark\"}\n  import { LoopsClient } from \"loops\";\n\n  const loops = new LoopsClient(\"<your-api-key>\");\n\n  const data = await loops.createTransactionalEmail({\n    name: \"Password reset\",\n  });\n\n  const transactionalId = data.id;\n  const draftEmailMessageId = data.draftEmailMessageId;\n  const draftEmailMessageContentRevisionId =\n    data.draftEmailMessageContentRevisionId;\n  ```\n\n  ```php PHP SDK theme={\"dark\"}\n  use Loops\\LoopsClient;\n\n  $loops = new LoopsClient('<your-api-key>');\n\n  $result = $loops->transactional->create(name: 'Password reset');\n\n  $transactional_id = $result['id'];\n  $draft_email_message_id = $result['draftEmailMessageId'];\n  $draft_email_message_content_revision_id =\n    $result['draftEmailMessageContentRevisionId'];\n  ```\n\n  ```ruby Ruby SDK theme={\"dark\"}\n  response = LoopsSdk::Transactional.create(\n    name: \"Password reset\",\n  )\n\n  transactional_id = response[\"id\"]\n  draft_email_message_id = response[\"draftEmailMessageId\"]\n  draft_email_message_content_revision_id =\n    response[\"draftEmailMessageContentRevisionId\"]\n  ```\n\n  ```python Python theme={\"dark\"}\n  import requests\n\n  response = requests.post(\n      \"https://app.loops.so/api/v1/transactional-emails\",\n      headers={\n          \"Authorization\": \"Bearer <your-api-key>\",\n          \"Content-Type\": \"application/json\",\n      },\n      json={\n          \"name\": \"Password reset\",\n      },\n  )\n\n  data = response.json()\n  transactional_id = data[\"id\"]\n  draft_email_message_id = data[\"draftEmailMessageId\"]\n  draft_email_message_content_revision_id = data[\n      \"draftEmailMessageContentRevisionId\"\n  ]\n  ```\n</CodeGroup>\n\n## Query themes and components for your LMX\n\nYou can fetch your available themes and reusable components before building\nthe `lmx` payload.\n\nList themes [API reference](/docs/api-reference/list-themes) [CLI reference](/docs/cli/themes#list)\\\nList components [API reference](/docs/api-reference/list-components) [CLI reference](/docs/cli/components#list)\n\n<CodeGroup>\n  ```bash CLI theme={\"dark\"}\n  loops themes list -o json\n  loops components list -o json\n  ```\n\n  ```js JavaScript theme={\"dark\"}\n  const [themesResponse, componentsResponse] = await Promise.all([\n    fetch(\"https://app.loops.so/api/v1/themes?perPage=20\", {\n      method: \"GET\",\n      headers: {\n        \"Authorization\": \"Bearer <your-api-key>\",\n      },\n    }),\n    fetch(\"https://app.loops.so/api/v1/components?perPage=20\", {\n      method: \"GET\",\n      headers: {\n        \"Authorization\": \"Bearer <your-api-key>\",\n      },\n    }),\n  ]);\n\n  const themes = await themesResponse.json();\n  const components = await componentsResponse.json();\n  ```\n\n  ```js JavaScript SDK theme={\"dark\"}\n  import { LoopsClient } from \"loops\";\n\n  const loops = new LoopsClient(\"<your-api-key>\");\n\n  const [themes, components] = await Promise.all([\n    loops.listThemes({ perPage: 20 }),\n    loops.listComponents({ perPage: 20 }),\n  ]);\n  ```\n\n  ```php PHP SDK theme={\"dark\"}\n  use Loops\\LoopsClient;\n\n  $loops = new LoopsClient('<your-api-key>');\n\n  $themes = $loops->themes->list(per_page: 20);\n  $components = $loops->components->list(per_page: 20);\n  ```\n\n  ```ruby Ruby SDK theme={\"dark\"}\n  themes = LoopsSdk::Themes.list(perPage: 20)\n  components = LoopsSdk::Components.list(perPage: 20)\n  ```\n\n  ```python Python theme={\"dark\"}\n  import requests\n\n  themes_response = requests.get(\n      \"https://app.loops.so/api/v1/themes\",\n      headers={\n          \"Authorization\": \"Bearer <your-api-key>\",\n      },\n      params={\"perPage\": 20},\n  )\n\n  components_response = requests.get(\n      \"https://app.loops.so/api/v1/components\",\n      headers={\n          \"Authorization\": \"Bearer <your-api-key>\",\n      },\n      params={\"perPage\": 20},\n  )\n\n  themes = themes_response.json()\n  components = components_response.json()\n  ```\n</CodeGroup>\n\n## Update the draft email message with `contentRevisionId`\n\nUse `draftEmailMessageId` from when you created the transactional as the path parameter, and pass `draftEmailMessageContentRevisionId` as `expectedRevisionId`.\n\nApply styles or a theme in `<Style />`, and build the email using LMX elements.\n\nThemes and components you queried in the previous step can be referenced by their IDs.\n\n<Tip>\n  Save the returned `contentRevisionId` after each update. Pass it as\n  `expectedRevisionId` on the next update to avoid `409 Conflict` errors caused\n  by stale revisions.\n</Tip>\n\n[API reference](/docs/api-reference/update-email-message)\\\n[CLI reference](/docs/cli/email-messages#update)\n\n<CodeGroup>\n  ```bash CLI theme={\"dark\"}\n  loopcurl: (23) Failure writing output to destination\n","type":"text"}]}
Thinking…

Official JS SDK: npm package "loops". Let me verify on npm and check its API (sendTransactionalEmail, upsertContact, sendEvent). Also get the JS SDK doc page.

Build plan: a small Node project in /sandbox/repo:

  • package.json with loops dependency
  • .env.example (LOOPS_API_KEY, LOOPS_TRANSACTIONAL_ID)
  • src/loops-example.mjs: upsert contact, send event, send transactional email
  • README with pricing + run instructions
  • Run npm install loops (fast, no local stack) and run the script in dry-run/no-key mode to verify it loads and errors gracefully.

Let me check npm package "loops" and the SDK doc.

$ cd /tmp && timeout 20 curl -sS https://registry.npmjs.org/loops | python3 -c "{"content":[{"text":"latest: 7.3.0\nThis is the official JavaScript SDK for [Loops](https://loops.so), with full TypeScript support. | repo: {'type': 'git', 'ur…ctrl+o
cd /tmp && timeout 20 curl -sS https://registry.npmjs.org/loops | python3 -c "
import json,sys
d=json.load(sys.stdin)
print('latest:',d['dist-tags']['latest'])
v=d['versions'][d['dist-tags']['latest']]
print(v.get('description'), '| repo:', v.get('repository'), '| exports:', list((v.get('exports') or {}).keys())[:6], '| type:', v.get('type'))
print('maintainers:', d.get('maintainers'))
"

{"content":[{"text":"latest: 7.3.0\nThis is the official JavaScript SDK for [Loops](https://loops.so), with full TypeScript support. | repo: {'type': 'git', 'url': 'git+https://github.com/Loops-so/loops-js.git'} | exports: ['.'] | type: module\nmaintainers: [{'name': 'fantactuka', 'email': 'fantactuka@gmail.com'}, {'name': 'notnmeyer', 'email': 'nmeyer@gmail.com'}, {'name': 'adamloops', 'email': 'adam@loops.so'}, {'name': 'danrowden', 'email': 'hello@danrowden.com'}]\n","type":"text"}]}
$ cd /tmp && timeout 20 curl -sSL https://loops.so/docs/sdks/javascript.md | head -c 5000{"content":[{"text":"> ## Documentation Index\n> Fetch the complete documentation index at: https://loops.so/docs/llms.txt\n> Use this file to discover all avai…ctrl+o
{"content":[{"text":"> ## Documentation Index\n> Fetch the complete documentation index at: https://loops.so/docs/llms.txt\n> Use this file to discover all available pages before exploring further.\n\n# JavaScript SDK\n\n> The official Loops SDK for JavaScript, with full TypeScript support.\n\n[![NPM downloads](https://img.shields.io/npm/dt/loops?style=social\\&label=Downloads)](https://www.npmjs.com/package/loops)\n\n## Installation\n\nInstall the package [from npm](https://www.npmjs.com/package/loops):\n\n```bash theme={\"dark\"}\nnpm install loops\n```\n\n<Tip>\n  Want your coding agent to help with Loops? Install the Loops CLI and\n  [skills](/docs/skills) with one command:\n\n  ```bash theme={\"dark\"}\n  curl -fsSL https://install.loops.so/wizard | sh\n  ```\n</Tip>\n\nMinimum Node version required: 18.0.0.\n\nYou will need a Loops API key to use the SDK.\n\nIn your Loops account, go to the [API Settings page](https://app.loops.so/settings?page=api) and click **Generate key**.\n\nCopy this key and save it in your application code (for example as `LOOPS_API_KEY` in an `.env` file).\n\n<Card\n  title=\"Next.js guide\"\n  icon={\n<svg\n  xmlns=\"http://www.w3.org/2000/svg\"\n  preserveAspectRatio=\"xMidYMid\"\n  viewBox=\"0 0 256 256\"\n>\n  <path\n    fill=\"#FF4A00\"\n    d=\"M119.617.069c-.55.05-2.302.225-3.879.35-36.36 3.278-70.419 22.894-91.99 53.044-12.012 16.764-19.694 35.78-22.597 55.922C.125 116.415 0 118.492 0 128.025c0 9.533.125 11.61 1.151 18.64 6.957 48.065 41.165 88.449 87.56 103.411 8.309 2.678 17.067 4.504 27.027 5.605 3.879.425 20.645.425 24.524 0 17.192-1.902 31.756-6.155 46.12-13.486 2.202-1.126 2.628-1.426 2.327-1.677-.2-.15-9.584-12.735-20.845-27.948l-20.47-27.648-25.65-37.956c-14.114-20.868-25.725-37.932-25.825-37.932-.1-.025-.2 16.84-.25 37.431-.076 36.055-.1 37.506-.551 38.357-.65 1.226-1.151 1.727-2.202 2.277-.801.4-1.502.475-5.28.475h-4.33l-1.15-.725a4.679 4.679 0 0 1-1.677-1.827l-.526-1.126.05-50.166.075-50.192.776-.976c.4-.525 1.251-1.2 1.852-1.526 1.026-.5 1.426-.55 5.755-.55 5.105 0 5.956.2 7.282 1.651.376.4 14.264 21.318 30.88 46.514 16.617 25.195 39.34 59.599 50.5 76.488l20.27 30.7 1.026-.675c9.084-5.905 18.693-14.312 26.3-23.07 16.191-18.59 26.626-41.258 30.13-65.428 1.026-7.031 1.151-9.108 1.151-18.64 0-9.534-.125-11.61-1.151-18.641-6.957-48.065-41.165-88.449-87.56-103.411-8.184-2.652-16.892-4.479-26.652-5.58-2.402-.25-18.943-.525-21.02-.325Zm52.401 77.414c1.201.6 2.177 1.752 2.527 2.953.2.65.25 14.562.2 45.913l-.074 44.987-7.933-12.16-7.958-12.16v-32.702c0-21.143.1-33.028.25-33.603.4-1.401 1.277-2.502 2.478-3.153 1.026-.525 1.401-.575 5.33-.575 3.704 0 4.354.05 5.18.5Z\"\n  />\n</svg>\n}\n  href=\"/sdks/javascript/nextjs\"\n>\n  Read our guide for sending email from Next.js projects.\n</Card>\n\n## Usage\n\n```javascript theme={\"dark\"}\nimport { LoopsClient, APIError } from \"loops\";\n\nconst loops = new LoopsClient(process.env.LOOPS_API_KEY);\n\ntry {\n  const resp = await loops.createContact({\n    email: \"test@example.com\",\n  });\n  // resp.success and resp.id available when successful\n} catch (error) {\n  if (error instanceof APIError) {\n    console.log(error.json);\n    console.log(error.statusCode);\n  }\n}\n```\n\nImport `RateLimitExceededError` to handle rate limits. See the [SDK docs](https://www.npmjs.com/package/loops) for every method, TypeScript types, rate limiting, and error handling.\n\nSee the API documentation to learn more about [rate limiting](/docs/api-reference/intro#rate-limiting) and [error handling](/docs/api-reference/intro#debugging).\n\n<CardGroup>\n  <Card title=\"npm package\" icon=\"js\" href=\"https://www.npmjs.com/package/loops\">\n    View the full documentation on npm.\n  </Card>\n\n  <Card title=\"GitHub\" icon=\"github\" href=\"https://github.com/loops-so/loops-js\">\n    View the source code and release notes.\n  </Card>\n\n  <Card title=\"Loops API\" icon=\"rectangle-terminal\" href=\"/docs/api-reference/intro\">\n    Read the Loops API reference.\n  </Card>\n</CardGroup>\n\n\n## Related topics\n\n- [Set up Loops in Next.js](/docs/guides/nextjs.md)\n- [Nuxt module](/docs/sdks/nuxt.md)\n- [Create and send your first campaign with the API or an agent](/docs/guides/first-campaign-api.md)\n- [Recipe: Active 30-day users segment](/docs/guides/active-30-day-users.md)\n- [Custom form](/docs/forms/custom-form.md)\n","type":"text"}]}
Thinking…

Now build the example. Install loops package in the repo (npm install is fast, not a local service stack). Then write src files and run in a mode that doesn't require credentials — verify SDK imports and method signatures exist.

Let me write the project.

Files:

  • package.json
  • .env.example
  • .gitignore (already exists — check contents)
  • src/loops-example.mjs — main demo: upsert contact, send event, send transactional email; supports --dry-run
  • README.md

Let me check .gitignore first and install.

$ cat /sandbox/repo/.gitignore{"content":[{"text":"# Keep generated and vendored files out of source diffs.\n\n# Logs / OS cruft\n*.log\n.DS_Store\n\n# JavaScript / TypeScript\nnode_modules/…ctrl+o
{"content":[{"text":"# Keep generated and vendored files out of source diffs.\n\n# Logs / OS cruft\n*.log\n.DS_Store\n\n# JavaScript / TypeScript\nnode_modules/\n.next/\ndist/\nbuild/\ncoverage/\n\n# Python\n.venv/\nvenv/\n__pycache__/\n*.py[cod]\n.pytest_cache/\n*.egg-info/\n\n# Rust\ntarget/\n\n# Go / PHP / Ruby vendored deps\nvendor/\n.bundle/\n\n# Java / Kotlin / Scala\n.gradle/\n\n# .NET\nbin/\nobj/\n\n# Elixir\n_build/\ndeps/\n\n# Dart / Flutter\n.dart_tool/\n","type":"text"}]}
$ mkdir -p /sandbox/repo/src && cd /sandbox/repo && timeout 120 npm install loops --silent 2>&1 | tail -5; ls node_modules/loops 2>/dev/null; node -e "const p=require('/sandbox/repo/node_modules/loops/p…{"content":[{"text":"CODE_OF_CONDUCT.md\nCONTRIBUTING.md\nLICENSE\nREADME.md\ndist\npackage.json\n7.3.0\n","type":"text"}]}ctrl+o
mkdir -p /sandbox/repo/src && cd /sandbox/repo && timeout 120 npm install loops --silent 2>&1 | tail -5; ls node_modules/loops 2>/dev/null; node -e "const p=require('/sandbox/repo/node_modules/loops/package.json');console.log(p.version)"

Thinking…

Check SDK method names for upsertContact, sendEvent, sendTransactionalEmail.

$ cd /sandbox/repo && node -e "{"content":[{"text":"_makeQuery\ntestApiKey\ncreateContact\nupdateContact\nfindContact\ndeleteContact\ncheckContactSuppression\nremoveContactSuppression\ncreate…ctrl+o
cd /sandbox/repo && node -e "
import('loops').then(m=>{
 const c=Object.getOwnPropertyNames(m.LoopsClient.prototype).filter(n=>!/^(constructor)$/.test(n));
 console.log(c.join('\n'));
})" 2>&1 | head -60

{"content":[{"text":"_makeQuery\ntestApiKey\ncreateContact\nupdateContact\nfindContact\ndeleteContact\ncheckContactSuppression\nremoveContactSuppression\ncreateContactProperty\nlistContactProperties\nlistMailingLists\nsendEvent\nsendTransactionalEmail\nlistTransactionalEmails\ngetTransactionalEmail\ncreateTransactionalEmail\nupdateTransactionalEmail\nensureTransactionalEmailDraft\npublishTransactionalEmail\nlistDedicatedSendingIps\nlistAudienceSegments\ngetAudienceSegment\ncreateAudienceSegment\nlistThemes\ngetTheme\ncreateTheme\nupdateTheme\nlistComponents\ngetComponent\ncreateComponent\nupdateComponent\nlistCampaigns\ncreateCampaign\ngetCampaign\nupdateCampaign\ngetEmailMessage\nupdateEmailMessage\nsendEmailMessagePreview\nrunEmailMessageGuardian\nlistEventPatterns\ngetEventPattern\ngetEventPatternByName\nlistWorkflows\ncreateWorkflow\ngetWorkflow\nupdateWorkflow\ndeleteWorkflow\nchangeWorkflowMailingList\ncreateWorkflowNode\ngetWorkflowNode\nupdateWorkflowNode\ndeleteWorkflowNode\naddWorkflowBranch\nrerouteWorkflowNode\ndeleteWorkflowNodesRecursive\nlistCampaignGroups\ncreateCampaignGroup\ngetCampaignGroup\nupdateCampaignGroup\nlistTransactionalGroups\n","type":"text"}]}
$ cd /sandbox/repo && grep -o "sendTransactionalEmail([a-zA-Z]*: [A-Za-z]*)" node_modules/loops/dist/*.d.ts | head; ls node_modules/loops/dist | head{"content":[{"text":"index.cjs\nindex.d.cts\nindex.d.ts\nindex.esm.js\n","type":"text"}]}
Thinking…

Note: createContact / updateContact (no upsertContact in v7). Also ensureTransactionalEmailDraft — useful! Let me check its signature: it may create the transactional email + draft with LMX content if missing. That would make a great self-contained example: create transactional email with LMX content, publish, send.

Let me inspect type definitions for ensureTransactionalEmailDraft, sendTransactionalEmail, createContact, sendEvent.

$ cd /sandbox/repo && python3 - <<'EOF'{"content":[{"text":"--- ensureTransactionalEmailDraft(transactionalId: string): Promise<TransactionalDraftResponse>\n--- sendTransactionalEmail({ transactional…ctrl+o
cd /sandbox/repo && python3 - <<'EOF'
import re
t=open('node_modules/loops/dist/index.d.ts').read()
for name in ["ensureTransactionalEmailDraft","sendTransactionalEmail","createContact","sendEvent","publishTransactionalEmail","createTransactionalEmail","updateEmailMessage","testApiKey"]:
    for m in re.finditer(r'\b'+name+r'\b[^;]{0,600}', t):
        print('---',m.group(0)[:600]);break
EOF

{"content":[{"text":"--- ensureTransactionalEmailDraft(transactionalId: string): Promise<TransactionalDraftResponse>\n--- sendTransactionalEmail({ transactionalId, email, addToAudience, dataVariables, attachments, headers, }: {\n        transactionalId: string\n--- createContact({ email, properties, mailingLists, }: {\n        email: string\n--- sendEvent({ email, userId, eventName, contactProperties, eventProperties, mailingLists, headers, }: {\n        email?: string\n--- publishTransactionalEmail(transactionalId: string): Promise<TransactionalEmailResource>\n--- createTransactionalEmail({ name, transactionalGroupId, }: {\n        name: string\n--- updateEmailMessage()`.\n     */\n    draftEmailMessageContentRevisionId: string | null\n--- testApiKey(): Promise<ApiKeySuccessResponse>\n","type":"text"}]}
Thinking…

Also check updateEmailMessage signature to write LMX content. And check the LMX docs for a simple example. Let me fetch LMX doc briefly and updateEmailMessage signature.

$ cd /sandbox/repo && python3 - <<'EOF'{"content":[{"text":")`.\n */\n draftEmailMessageContentRevisionId: string | null\n","type":"text"}]}ctrl+o
cd /sandbox/repo && python3 - <<'EOF'
import re
t=open('node_modules/loops/dist/index.d.ts').read()
m=re.search(r'updateEmailMessage\(([^;]{0,900})', t, re.S)
print(m.group(1)[:900])
EOF

$ cd /tmp && timeout 20 curl -sSL https://loops.so/docs/creating-emails/lmx.md > lmx.md; wc -l lmx.md; grep -n -m1 -A40 "## Example" lmx.md | head -60{"content":[{"text":"923 lmx.md\n","type":"text"}]}
Thinking…

The regex matched the wrong thing. Let me grep properly.

Also look at LMX doc for minimal example.

$ cd /sandbox/repo && grep -n "updateEmailMessage" node_modules/loops/dist/index.d.ts | head; awk '/updateEmailMessage\(/{f=1} f{print; n++} n>25{exit}' node_modules/loops/dist/index.d.ts{"content":[{"text":"226: * Pass this as `expectedRevisionId` on your first update via `updateEmailMessage()`.\n1217: updateEmailMessage(emailMessageId: …ctrl+o
{"content":[{"text":"226:     * Pass this as `expectedRevisionId` on your first update via `updateEmailMessage()`.\n1217:    updateEmailMessage(emailMessageId: string, { expectedRevisionId, subject, previewText, fromName, fromEmail, replyToEmail, ccEmail, bccEmail, languageCode, emailFormat, lmx, contactPropertiesFallbacks, eventPropertiesFallbacks, dataVariablesFallbacks, }: {\n     * Pass this as `expectedRevisionId` on your first update via `updateEmailMessage()`.\n     */\n    draftEmailMessageContentRevisionId: string | null;\n}\ninterface Group {\n    id: string;\n    name: string;\n    description: string;\n    createdAt: string;\n    updatedAt: string;\n}\ninterface ListGroupsResponse {\n    pagination: PaginationData;\n    data: Group[];\n}\ninterface AudienceFilterBetweenValue {\n    from: string;\n    to: string;\n}\ninterface PropertyCondition {\n    type: \"property\";\n    key: string;\n    operator: \"any\" | \"contains\" | \"notContains\" | \"equals\" | \"notEquals\" | \"greaterThan\" | \"lessThan\" | \"isTrue\" | \"isFalse\" | \"empty\" | \"notEmpty\" | \"dateEmpty\" | \"dateNotEmpty\" | \"after\" | \"before\" | \"between\";\n    value?: string | number | AudienceFilterBetweenValue;\n}\ninterface OptInCondition {\n","type":"text"}]}
$ cd /tmp && grep -n "^#" lmx.md | head -30; echo ====; sed -n '1,90p' lmx.md{"content":[{"text":"5:# LMX\n51:## LMX examples\n159:## Core rules\n179:## Source control\n192:## Tags\n222:## Tag reference\n224:### Document styles\n276:### …ctrl+o
{"content":[{"text":"5:# LMX\n51:## LMX examples\n159:## Core rules\n179:## Source control\n192:## Tags\n222:## Tag reference\n224:### Document styles\n276:### Headings\n304:### Paragraphs\n326:### Quotes\n348:### Code blocks\n371:### Buttons\n407:### Images\n426:#### Dynamic or externally hosted images\n467:### Dividers\n490:### Line breaks\n503:### Lists\n526:### List items\n553:### Columns\n607:### Components\n634:### Sections\n669:#### Conditional sections\n701:### Icons\n737:## Inline tags\n766:### Inline tag attributes\n779:## Dynamic content\n799:### Contact properties\n810:### Event properties\n839:### Data variables\n868:### Where variables are allowed\n====\n> ## Documentation Index\n> Fetch the complete documentation index at: https://loops.so/docs/llms.txt\n> Use this file to discover all available pages before exploring further.\n\n# LMX\n\n> Author email content with Loops Markup Language (LMX), a structured markup for sections, components, variables, and themed styling in Loops emails.\n\n<Tip>\n  To manage Loops with an AI agent, [use the MCP server](/docs/mcp-server), or install the [Loops CLI and agent skills](/docs/quickstart-agents) for coding agents like Claude Code, Codex, Cursor, and OpenCode.\n</Tip>\n\nLMX (Loops Markup Language) is an XML-based format for writing email content in Loops. Every piece of content is represented by an explicit PascalCase tag.\n\nLMX can be used with the Content API to create and update campaign, workflow, and transactional email messages programmatically.\n\nLMX sits between the API and our editor, making it possible to edit emails across both the editor and API.\n\nYou can keep LMX in your codebase and update draft email messages through the\n[Content API](/docs/api-reference/update-email-message) or\n[email messages CLI](/docs/cli/email-messages). Fetch the current\n`contentRevisionId`, edit the LMX in Git, and send the update back with\nrevision protection.\n\n<CardGroup cols={2}>\n  <Card title=\"Campaign content examples\" icon=\"code\" href=\"/docs/api-reference/examples/campaigns\">\n    Create campaigns, query themes and components, and update email messages with LMX.\n  </Card>\n\n  <Card title=\"Update email message API\" icon=\"square-pen\" href=\"/docs/api-reference/update-email-message\">\n    Send serialized LMX through the Content API for campaign, workflow, and transactional email messages.\n  </Card>\n\n  <Card title=\"Create campaign API\" icon=\"send\" href=\"/docs/api-reference/create-campaign\">\n    Create a draft campaign and email message before adding LMX content.\n  </Card>\n\n  <Card title=\"Loops CLI\" icon=\"rectangle-terminal\" href=\"/docs/cli\">\n    Use the CLI for local setup, authentication, and Content API workflows.\n  </Card>\n\n  <Card title=\"Themes API\" icon=\"palette\" href=\"/docs/api-reference/list-themes\">\n    Find theme IDs and style attributes for the LMX `<Style />` tag.\n  </Card>\n\n  <Card title=\"Components API\" icon=\"box\" href=\"/docs/api-reference/list-components\">\n    Find reusable components for the LMX `<Component />` tag.\n  </Card>\n</CardGroup>\n\n## LMX examples\n\nHere are some examples of how to use LMX tags to create emails.\n\n```xml theme={\"dark\"}\n<H1>Hello</H1>\n<Paragraph>World</Paragraph>\n```\n\n**A list**\n\n```xml theme={\"dark\"}\n<H2>Release checklist</H2>\n<OrderedList>\n  <ListItem>Finish QA</ListItem>\n  <ListItem>Prepare announcement</ListItem>\n  <ListItem>Notify teams</ListItem>\n</OrderedList>\n```\n\n**Two-column layout**\n\n```xml theme={\"dark\"}\n<Columns>\n  <ColumnItem>\n    <H3>Starter</H3>\n    <Paragraph>Best for new projects.</Paragraph>\n    <Button href=\"https://loops.so/pricing\">Start free</Button>\n  </ColumnItem>\n  <ColumnItem>\n    <H3>Pro</H3>\n    <Paragraph>Advanced automation and analytics.</Paragraph>\n    <Button href=\"https://loops.so/pricing\">Upgrade</Button>\n  </ColumnItem>\n</Columns>\n```\n\n**Full campaign example**\n\n```xml theme={\"dark\"}\n","type":"text"}]}
Thinking…

I need to pull the full campaign LMX example and check the data variables syntax to make sure I'm using the right format for dynamic content.

$ cd /tmp && sed -n '90,160p' lmx.md; echo "=== data variables"; sed -n '839,870p' lmx.md{"content":[{"text":"```xml theme={\"dark\"}\n<Style themeId=\"st_default\"\n bodyColor=\"#1F2937\"\n bodyYPadding=\"24\" />\n<Component componentId=\"logo\" …ctrl+o
{"content":[{"text":"```xml theme={\"dark\"}\n<Style themeId=\"st_default\"\n  bodyColor=\"#1F2937\"\n  bodyYPadding=\"24\" />\n<Component componentId=\"logo\" />\n<H1>Hi {contact.firstName}, your weekly update is ready</H1>\n<Paragraph fontSize=\"16\" lineHeight=\"150\">\n  Here is your weekly account summary. Read <Link href=\"https://loops.so/docs\">the docs</Link> for the full changelog.\n</Paragraph>\n<Button href=\"https://app.example.com/account/{contact.userId}\" align=\"center\" bgColor=\"#000000\" textColor=\"#ffffff\">\n  Open dashboard, {contact.firstName}\n</Button>\n<Divider />\n<H2>Top priorities</H2>\n<OrderedList>\n  <ListItem>Fix onboarding drop-off on step 2</ListItem>\n  <ListItem>Publish changelog for API v2</ListItem>\n  <ListItem>Review deliverability metrics</ListItem>\n</OrderedList>\n<Columns gap=\"24\" widths=\"50,50\" verticalAlignment=\"top\">\n  <ColumnItem>\n    <H3>Docs</H3>\n    <Paragraph>Start with the quickstart.</Paragraph>\n  </ColumnItem>\n  <ColumnItem>\n    <Image src=\"https://images.vialoops.com/cmp2cnlf600yz0f042m792otk/cmp2cz4mn0oru0izztre5fgob.jpg\" dynamicSrc=\"{contact.avatarUrl}\" alt=\"{contact.firstName}\" width=\"180\" />\n  </ColumnItem>\n</Columns>\n<Section blockColor=\"#F8FAFC\" blockBorderRadius=\"12\" paddingTop=\"16\" paddingBottom=\"16\">\n  <Paragraph>Review your latest activity and <Strong>update your settings</Strong>.</Paragraph>\n</Section>\n<Paragraph>\n  Need help? <Link href=\"https://example.com/support\">Contact support</Link>.\n</Paragraph>\n<Icons>\n  <Icon name=\"x-twitter\" href=\"https://twitter.com/loops\" />\n  <Icon name=\"linkedin\" href=\"https://www.linkedin.com/company/sendwithloops/\" />\n</Icons>\n```\n\n**Workflow email example**\n\n```xml theme={\"dark\"}\n<Style themeId=\"st_default\" bodyColor=\"#ffffff\" backgroundColor=\"#f3f4f6\" bodyYPadding=\"24\" />\n<H1>Thanks for your order, {contact.firstName}</H1>\n<Paragraph fontSize=\"16\" lineHeight=\"150\">\n  Your order total was {event.orderTotal}. We will send updates to {contact.email}.\n</Paragraph>\n<Button href=\"https://app.example.com/orders/{event.orderId}\" align=\"center\" bgColor=\"#000000\" textColor=\"#ffffff\" borderRadius=\"12\">\n  View order\n</Button>\n```\n\n**Transactional email example**\n\n```xml theme={\"dark\"}\n<Style themeId=\"st_default\" bodyColor=\"#ffffff\" backgroundColor=\"#f3f4f6\" bodyYPadding=\"24\" />\n<H1>Reset your password</H1>\n<Paragraph fontSize=\"16\" lineHeight=\"150\">\n  Hi {data.firstName}, we received a request to reset your password.\n</Paragraph>\n<Button href=\"{data.resetLink}\" align=\"center\" bgColor=\"#000000\" textColor=\"#ffffff\" borderRadius=\"12\">\n  Reset password\n</Button>\n<Paragraph fontSize=\"14\" lineHeight=\"150\">\n  If you did not request this, you can ignore this email.\n</Paragraph>\n```\n\n## Core rules\n\n=== data variables\n### Data variables\n\nUse `{data.<variableName>}` in transactional email LMX. The name after `data.` must match a key in the `dataVariables` object when [sending the email](/docs/api-reference/send-transactional-email).\n\n```xml LMX content theme={\"dark\"}\n<H1>Reset your password, {data.firstName}</H1>\n<Paragraph>Click the button below to choose a new password.</Paragraph>\n<Button href=\"{data.resetLink}\" align=\"center\">Reset password</Button>\n```\n\n```json Send transactional email payload theme={\"dark\"}\n{\n  \"transactionalId\": \"cll42l54f20i1la0lfooe3z12\",\n  \"email\": \"user@example.com\",\n  \"dataVariables\": {\n    \"firstName\": \"Alex\",\n    \"resetLink\": \"https://app.example.com/reset/abc123\"\n  }\n}\n```\n\nTransactional data variable rules:\n\n* Names are case-sensitive. `{data.resetLink}` and `{data.ResetLink}` are different variables.\n* Names may contain only letters, numbers, underscores, and dashes.\n* API values may be `string` or `number`.\n* Non-optional variables must be present in `dataVariables` or the send fails.\n* Optional variables may be omitted from the request or sent as an empty string `\"\"` (`null` does not work). Mark a data variable as optional in the [Loops editor](/docs/transactional#optional-data-variables).\n\n### Where variables are allowed\n\nVariables are allowed in:\n","type":"text"}]}
Thinking…

Now write the example. Design:

src/welcome-email.mjs — end-to-end:

  1. testApiKey
  2. create/update contact (findContact then create or update; SDK has createContact/updateContact)
  3. sendEvent("user_signed_up")
  4. create transactional email (if LOOPS_TRANSACTIONAL_ID not set), update draft with LMX, publish
  5. sendTransactionalEmail with dataVariables

Support --dry-run (default when no API key) that prints the payloads without network calls.

Also README.md with pricing + how to run + note on blocked (no API key).

Also add .env.example. Load env: Node 22 supports --env-file. Use node --env-file=.env src/... in package.json script, but that fails if .env missing. Use a small loader or --env-file-if-exists=.env (Node 22.9+ supports --env-file-if-exists). Node 22.23 → supported. Good.

Let's write files.

Docs are reachable and Loops has a real developer workflow (REST API, official JS SDK loops, CLI, MCP, agent skills). Building the example now.

write /sandbox/repo/package.json{"content":[{"text":"Successfully wrote 400 bytes to /sandbox/repo/package.json","type":"text"}]}
write /sandbox/repo/.env.example{"content":[{"text":"Successfully wrote 380 bytes to /sandbox/repo/.env.example","type":"text"}]}
Thinking…

Now the main script. Keep it simple but complete, with dry-run.

Also add a tiny raw-REST version? Maybe one extra file src/raw-api.mjs showing the same send with fetch, to demonstrate the API path. Keep it light — yes, small.

Write src/welcome-email.mjs.

write /sandbox/repo/src/welcome-email.mjs{"content":[{"text":"Successfully wrote 5260 bytes to /sandbox/repo/src/welcome-email.mjs","type":"text"}]}
Thinking…

Verify updateEmailMessage's params include emailFormat and previewText, and findContact signature (takes email string?). Let me check types.

$ cd /sandbox/repo && awk 'NR>=1210 && NR<=1260' node_modules/loops/dist/index.d.ts; echo ===; grep -n "findContact(" -A4 node_modules/loops/dist/index.d.ts | head -12{"content":[{"text":" * @param {Record<string, string | null>} [params.eventPropertiesFallbacks] Fallback values for event properties.\n * @param {Recor…ctrl+o
{"content":[{"text":"     * @param {Record<string, string | null>} [params.eventPropertiesFallbacks] Fallback values for event properties.\n     * @param {Record<string, string | null>} [params.dataVariablesFallbacks] Fallback values for data variables.\n     *\n     * @see https://loops.so/docs/api-reference/update-email-message\n     *\n     * @returns {Object} Updated email message (JSON)\n     */\n    updateEmailMessage(emailMessageId: string, { expectedRevisionId, subject, previewText, fromName, fromEmail, replyToEmail, ccEmail, bccEmail, languageCode, emailFormat, lmx, contactPropertiesFallbacks, eventPropertiesFallbacks, dataVariablesFallbacks, }: {\n        expectedRevisionId?: string;\n        subject?: string;\n        previewText?: string;\n        fromName?: string;\n        fromEmail?: string;\n        replyToEmail?: string;\n        ccEmail?: string;\n        bccEmail?: string;\n        languageCode?: string;\n        emailFormat?: \"styled\" | \"plain\";\n        lmx?: string;\n        contactPropertiesFallbacks?: Record<string, string | null>;\n        eventPropertiesFallbacks?: Record<string, string | null>;\n        dataVariablesFallbacks?: Record<string, string | null>;\n    }): Promise<EmailMessageResponse>;\n    /**\n     * Send a preview of an email message.\n     *\n     * @param {string} emailMessageId The ID of the email message.\n     * @param {Object} params\n     * @param {string[]} params.emails One or more addresses to send the preview to.\n     * @param {Record<string, string>} [params.contactProperties] Contact property values to render.\n     * @param {Record<string, string>} [params.eventProperties] Event property values to render.\n     * @param {Record<string, unknown>} [params.dataVariables] Transactional data variables to render.\n     *\n     * @see https://loops.so/docs/api-reference/preview-email-message\n     *\n     * @returns {Object} Preview confirmation (JSON)\n     */\n    sendEmailMessagePreview(emailMessageId: string, { emails, contactProperties, eventProperties, dataVariables, }: {\n        emails: string[];\n        contactProperties?: Record<string, string>;\n        eventProperties?: Record<string, string>;\n        dataVariables?: Record<string, unknown>;\n    }): Promise<EmailMessagePreviewResponse>;\n    /**\n     * Run Guardian checks on an email message.\n     *\n     * Validates content against Guardian rules and returns errors and warnings.\n     * Errors must be resolved before the email can be published; warnings are advisory.\n     *\n     * @param {string} emailMessageId The ID of the email message.\n     *\n===\n741:    findContact({ email, userId, }: {\n742-        email?: string;\n743-        userId?: string;\n744-    }): Promise<Contact[]>;\n745-    /**\n","type":"text"}]}
Thinking…

Fix: findContact takes an object and returns Contact[]; emailFormat is "styled" | "plain", not "html". Also updateContact signature — check. And addToAudience in sendTransactionalEmail.

Let me fix the script accordingly.

$ cd /sandbox/repo && grep -n "updateContact(" -A12 node_modules/loops/dist/index.d.ts | head -16; grep -n "sendTransactionalEmail({" -A22 node_modules/loops/dist/index.d.ts | head -26{"content":[{"text":"724: updateContact({ email, userId, properties, mailingLists, }: {\n725- email?: string;\n726- userId?: string;\n727- …ctrl+o
{"content":[{"text":"724:    updateContact({ email, userId, properties, mailingLists, }: {\n725-        email?: string;\n726-        userId?: string;\n727-        properties?: ContactProperties;\n728-        mailingLists?: MailingLists;\n729-    }): Promise<ContactSuccessResponse>;\n730-    /**\n731-     * Find a contact by email address or user ID.\n732-     *\n733-     * @param {Object} params\n734-     * @param {string} [params.email] The email address of the contact.\n735-     * @param {string} [params.userId] The user ID of the contact.\n736-     *\n859:    sendTransactionalEmail({ transactionalId, email, addToAudience, dataVariables, attachments, headers, }: {\n860-        transactionalId: string;\n861-        email: string;\n862-        addToAudience?: boolean;\n863-        dataVariables?: TransactionalVariables;\n864-        attachments?: Array<TransactionalAttachment>;\n865-        headers?: Record<string, string>;\n866-    }): Promise<TransactionalSuccess>;\n867-    /**\n868-     * List transactional emails.\n869-     *\n870-     * @param {Object} params\n871-     * @param {number} [params.perPage] How many results to return in each request. Must be between 10 and 50. Defaults to 20.\n872-     * @param {string} [params.cursor] A cursor, to return a specific page of results. Cursors can be found from the `pagination.nextCursor` value in each response.\n873-     *\n874-     * @see https://loops.so/docs/api-reference/list-transactional-emails\n875-     *\n876-     * @returns {Object} List of transactional emails (JSON)\n877-     */\n878-    listTransactionalEmails({ perPage, cursor, }?: {\n879-        perPage?: number;\n880-        cursor?: string;\n881-    }): Promise<ListTransactionalsResourceResponse>;\n","type":"text"}]}
Thinking…

Fix findContact usage and emailFormat ("styled"). Then run dry-run and lint via node --check.

edit /sandbox/repo/src/welcome-email.mjs{"content":[{"text":"Successfully replaced 3 block(s) in /sandbox/repo/src/welcome-email.mjs.","type":"text"}],"details":{"diff":" ...\n 46 console.log…ctrl+o
{"content":[{"text":"Successfully replaced 3 block(s) in /sandbox/repo/src/welcome-email.mjs.","type":"text"}],"details":{"diff":"     ...\n  46     console.log(\"Dry run: no LOOPS_API_KEY set (or --dry-run passed). Printing the calls that would be made.\");\n  47     log(\"1. createContact / updateContact\", { email: CONTACT_EMAIL, properties: { firstName: \"Alex\", plan: \"Pro\" } });\n  48     log(\"2. sendEvent\", { email: CONTACT_EMAIL, eventName: \"user_signed_up\", eventProperties: { plan: \"Pro\" } });\n  49     log(\"3. createTransactionalEmail\", { name: TRANSACTIONAL_NAME });\n- 50     log(\"4. updateEmailMessage (draft, LMX)\", { subject: \"Welcome to Acme\", fromName: \"Acme\", emailFormat: \"html\", lmx: WELCOME_LMX });\n+ 50     log(\"4. updateEmailMessage (draft, LMX)\", { subject: \"Welcome to Acme\", fromName: \"Acme\", emailFormat: \"styled\", lmx: WELCOME_LMX });\n  51     log(\"5. publishTransactionalEmail\", \"<transactionalId>\");\n  52     log(\"6. sendTransactionalEmail\", {\n  53       transactionalId: process.env.LOOPS_TRANSACTIONAL_ID || \"<transactionalId from step 3>\",\n  54       email: CONTACT_EMAIL,\n     ...\n  68     // 0. Sanity check the key and see which team it belongs to.\n  69     log(\"0. testApiKey\", await loops.testApiKey());\n  70 \n  71     // 1. Create or update the contact.\n- 72     const existing = await loops.findContact(CONTACT_EMAIL);\n- 73     const contact = existing?.id\n+ 72     const found = await loops.findContact({ email: CONTACT_EMAIL });\n+ 73     const contact = found.length\n  74       ? await loops.updateContact({ email: CONTACT_EMAIL, properties: { firstName: \"Alex\", plan: \"Pro\" } })\n  75       : await loops.createContact({ email: CONTACT_EMAIL, properties: { firstName: \"Alex\", plan: \"Pro\" } });\n  76     log(\"1. contact upserted\", contact);\n  77 \n     ...\n 105         expectedRevisionId: revisionId,\n 106         subject: \"Welcome to Acme\",\n 107         previewText: \"Your workspace is ready\",\n 108         fromName: \"Acme\",\n-109         emailFormat: \"html\",\n+109         emailFormat: \"styled\",\n 110         lmx: WELCOME_LMX,\n 111       }));\n 112 \n 113       // 5. Publish so it can be sent.\n     ...","patch":"--- /sandbox/repo/src/welcome-email.mjs\n+++ /sandbox/repo/src/welcome-email.mjs\n@@ -46,9 +46,9 @@\n     console.log(\"Dry run: no LOOPS_API_KEY set (or --dry-run passed). Printing the calls that would be made.\");\n     log(\"1. createContact / updateContact\", { email: CONTACT_EMAIL, properties: { firstName: \"Alex\", plan: \"Pro\" } });\n     log(\"2. sendEvent\", { email: CONTACT_EMAIL, eventName: \"user_signed_up\", eventProperties: { plan: \"Pro\" } });\n     log(\"3. createTransactionalEmail\", { name: TRANSACTIONAL_NAME });\n-    log(\"4. updateEmailMessage (draft, LMX)\", { subject: \"Welcome to Acme\", fromName: \"Acme\", emailFormat: \"html\", lmx: WELCOME_LMX });\n+    log(\"4. updateEmailMessage (draft, LMX)\", { subject: \"Welcome to Acme\", fromName: \"Acme\", emailFormat: \"styled\", lmx: WELCOME_LMX });\n     log(\"5. publishTransactionalEmail\", \"<transactionalId>\");\n     log(\"6. sendTransactionalEmail\", {\n       transactionalId: process.env.LOOPS_TRANSACTIONAL_ID || \"<transactionalId from step 3>\",\n       email: CONTACT_EMAIL,\n@@ -68,10 +68,10 @@\n     // 0. Sanity check the key and see which team it belongs to.\n     log(\"0. testApiKey\", await loops.testApiKey());\n \n     // 1. Create or update the contact.\n-    const existing = await loops.findContact(CONTACT_EMAIL);\n-    const contact = existing?.id\n+    const found = await loops.findContact({ email: CONTACT_EMAIL });\n+    const contact = found.length\n       ? await loops.updateContact({ email: CONTACT_EMAIL, properties: { firstName: \"Alex\", plan: \"Pro\" } })\n       : await loops.createContact({ email: CONTACT_EMAIL, properties: { firstName: \"Alex\", plan: \"Pro\" } });\n     log(\"1. contact upserted\", contact);\n \n@@ -105,9 +105,9 @@\n         expectedRevisionId: revisionId,\n         subject: \"Welcome to Acme\",\n         previewText: \"Your workspace is ready\",\n         fromName: \"Acme\",\n-        emailFormat: \"html\",\n+        emailFormat: \"styled\",\n         lmx: WELCOME_LMX,\n       }));\n \n       // 5. Publish so it can be sent.\n","firstChangedLine":50}}
Thinking…

Now run dry-run to verify, and also verify the APIError import exists. Run.

$ cd /sandbox/repo && node --check src/welcome-email.mjs && timeout 30 npm run example:dry 2>&1 | tail -50{"content":[{"text":"> node --env-file-if-exists=.env src/welcome-email.mjs --dry-run\n\n.env not found. Continuing without it.\nDry run: no LOOPS_API_KEY set (…ctrl+o
{"content":[{"text":"> node --env-file-if-exists=.env src/welcome-email.mjs --dry-run\n\n.env not found. Continuing without it.\nDry run: no LOOPS_API_KEY set (or --dry-run passed). Printing the calls that would be made.\n\n▶ 1. createContact / updateContact\n{\n  \"email\": \"you@example.com\",\n  \"properties\": {\n    \"firstName\": \"Alex\",\n    \"plan\": \"Pro\"\n  }\n}\n\n▶ 2. sendEvent\n{\n  \"email\": \"you@example.com\",\n  \"eventName\": \"user_signed_up\",\n  \"eventProperties\": {\n    \"plan\": \"Pro\"\n  }\n}\n\n▶ 3. createTransactionalEmail\n{\n  \"name\": \"Welcome email (example)\"\n}\n\n▶ 4. updateEmailMessage (draft, LMX)\n{\n  \"subject\": \"Welcome to Acme\",\n  \"fromName\": \"Acme\",\n  \"emailFormat\": \"styled\",\n  \"lmx\": \"<Style themeId=\\\"st_default\\\" bodyColor=\\\"#ffffff\\\" backgroundColor=\\\"#f3f4f6\\\" bodyYPadding=\\\"24\\\" />\\n<H1>Welcome aboard, {data.firstName}</H1>\\n<Paragraph fontSize=\\\"16\\\" lineHeight=\\\"150\\\">\\n  Your {data.plan} workspace is ready. Here is what to do first.\\n</Paragraph>\\n<Button href=\\\"{data.dashboardUrl}\\\" align=\\\"center\\\" bgColor=\\\"#000000\\\" textColor=\\\"#ffffff\\\" borderRadius=\\\"12\\\">\\n  Open your dashboard\\n</Button>\\n<Divider />\\n<H2>Next steps</H2>\\n<OrderedList>\\n  <ListItem>Invite your team</ListItem>\\n  <ListItem>Connect a sending domain</ListItem>\\n  <ListItem>Send your first campaign</ListItem>\\n</OrderedList>\\n<Paragraph fontSize=\\\"14\\\" lineHeight=\\\"150\\\">\\n  Questions? Just reply to this email.\\n</Paragraph>\"\n}\n\n▶ 5. publishTransactionalEmail\n<transactionalId>\n\n▶ 6. sendTransactionalEmail\n{\n  \"transactionalId\": \"<transactionalId from step 3>\",\n  \"email\": \"you@example.com\",\n  \"addToAudience\": true,\n  \"dataVariables\": {\n    \"firstName\": \"Alex\",\n    \"plan\": \"Pro\",\n    \"dashboardUrl\": \"https://app.example.com/dashboard\"\n  }\n}\n","type":"text"}]}
Thinking…

Works. Now write README with pricing, run instructions, and the blocked note (no API key available in this environment).

Pricing (from pricing page): free plan = unlimited contacts, up to 4,000 sends per rolling 30 days to your 1,000 newest contacts, ≤10 emails/sec, all features, small "Powered by Loops" footer. Paid plans priced by number of subscribed contacts (a slider starting ~1,000 subscribers "Early Stage Plan"); sends unlimited (1,000 emails/sec), no branding, transactional included free, no per-seat charge, unsubscribed contacts don't count. Exact dollar amounts weren't in the page HTML (dynamic slider). I should note the free tier numbers and that paid tiers are contact-count based, priced via the slider on /pricing — I didn't see $ figures, say so honestly.

Maybe try to extract prices from the pricing page JS payload? Quick attempt: grep for "$" amounts in the page.

$ cd /tmp && timeout 20 curl -sSL https://loops.so/pricing -o pricing.html; grep -o -E "(\\$|usd|price)[^,\"]{0,40}" pricing.html | sort -u | head -30; echo ===; grep -o -E "\"[0-9]{2,4}(\.[0-9]{2})?\""…{"content":[{"text":"$)/i.test(robots.getAttribute(\n$--></div><!--$--><div class=\n$--></div><!--/$--><button class=\n$--></div><!--/$--><div class=\n$--></div…ctrl+o
cd /tmp && timeout 20 curl -sSL https://loops.so/pricing -o pricing.html; grep -o -E "(\\$|usd|price)[^,\"]{0,40}" pricing.html | sort -u | head -30; echo ===; grep -o -E "\"[0-9]{2,4}(\.[0-9]{2})?\"" pricing.html | sort -u | head -20

{"content":[{"text":"$)/i.test(robots.getAttribute(\n$--></div><!--$--><div class=\n$--></div><!--/$--><button class=\n$--></div><!--/$--><div class=\n$--></div></a><!--/$--></div></div></div>\n$--></div></div></div></div></div></div><\n$--></div></div></div></div></footer></di\n$--></div></div></div></div><div class=\n$--></div></div><div class=\n$--></div><div class=\n$--></div><script data-cfasync=\n$--></p></div></div></div></div><div clas\n$--></p></div></div></div><div class=\n$--></p></div></div><div class=\n$--></p></div><div class=\n$--><a class=\n$--><a data-framer-component-type=\n$--><a href=\n$--><div class=\n$--><div style=\n$--><section style=\n$--><span style=\n$--><style data-framer-html-style>:root b\n$/\n${f}`:f);for(let r of a){let e=u(window.l\n${g}\n${target.height}px`);\r\n${target.left - track.left - group.client\n${target.top - track.top - group.clientTo\n${target.width}px`);\r\n===\n\"12\"\n\"132\"\n\"16\"\n\"17\"\n\"18\"\n\"2022\"\n\"21\"\n\"24\"\n\"28\"\n\"49.00\"\n\"66\"\n\"67\"\n\"70\"\n\"75\"\n\"77\"\n\"78\"\n\"84\"\n\"85\"\n\"86\"\n\"88\"\n","type":"text"}]}
Thinking…

"49.00" appears — possibly a price for some tier, but ambiguous (could be a style value). I won't assert dollar amounts. I'll say paid pricing scales with subscribed contacts and the exact quote comes from the slider on loops.so/pricing; free tier numbers are documented.

Now README.

write /sandbox/repo/README.md{"content":[{"text":"Successfully wrote 4166 bytes to /sandbox/repo/README.md","type":"text"}]}
Thinking…

Also add .env to .gitignore? .gitignore doesn't list .env. Add it.

Also verify dry-run again quickly after edits (already did). Add .env line.

$ cd /sandbox/repo && printf '\n# Secrets\n.env\n.env.*\n!.env.example\n' >> .gitignore && tail -6 .gitignore && timeout 30 npm run example:dry >/dev/null 2>&1 && echo "dry run OK" && ls -R --ignore=nod…{"content":[{"text":".dart_tool/\n\n# Secrets\n.env\n.env.*\n!.env.example\ndry run OK\n.:\nREADME.md\npackage-lock.json\npackage.json\nsrc\n\n./src:\nwelcome-e…ctrl+o
cd /sandbox/repo && printf '\n# Secrets\n.env\n.env.*\n!.env.example\n' >> .gitignore && tail -6 .gitignore && timeout 30 npm run example:dry >/dev/null 2>&1 && echo "dry run OK" && ls -R --ignore=node_modules .

{"content":[{"text":".dart_tool/\n\n# Secrets\n.env\n.env.*\n!.env.example\ndry run OK\n.:\nREADME.md\npackage-lock.json\npackage.json\nsrc\n\n./src:\nwelcome-email.mjs\n","type":"text"}]}
Thinking…

Note .env.example is ignored by .env.*? No — !.env.example re-includes it. But order: .env.* then !.env.example → negation works since parent dir isn't excluded. Good.

Done. Summarize: pricing, manageability, blocked item (no API key).

Done. Loops does have a real developer workflow, so I built the example rather than stopping.

What I built (/sandbox/repo)

README.md               pricing, run steps, gotchas, agent tooling notes
package.json            deps: loops@7.3.0 (official JS SDK), npm scripts
.env.example            LOOPS_API_KEY / LOOPS_TRANSACTIONAL_ID / LOOPS_TO_EMAIL
src/welcome-email.mjs   6-step signup flow, live or --dry-run

src/welcome-email.mjs uses the hosted API via the official SDK only — no local services, no long-running commands. Flow: testApiKeyfindContact/createContact/updateContactsendEvent("user_signed_up")createTransactionalEmailupdateEmailMessage (content written in LMX, kept in source control) → publishTransactionalEmailsendTransactionalEmail with dataVariables.

npm run example:dry runs and prints all payloads (verified). Method names/signatures were checked against the SDK's bundled .d.ts — that's how I caught that v7 has createContact/updateContact (no upsertContact), findContact takes { email } and returns an array, and emailFormat is "styled" | "plain" not "html".

Pricing (from the live loops.so/pricing page)

  • Free: unlimited contacts, up to 4,000 sends per rolling 30 days to your 1,000 newest contacts (marketing + transactional counted together), 10 emails/sec, all features, small "Powered by Loops" footer.
  • Paid: priced by subscribed contact count — not per email, not per seat. Unlimited sends (1,000/sec, excess queued), transactional included free, no branding, no seat fees. Unsubscribed contacts don't count; unlimited contact storage on every plan.
  • Exact dollar figures aren't in the page markup (the price is computed by a client-side subscriber-count slider), so I didn't quote a number — check the slider for your contact count.

Easy for me to manage? Yes

Better-than-average agent surface: llms.txt docs index with a .md version of every page, public OpenAPI spec (app.loops.so/openapi.json), official SDKs (JS/TS, Go, PHP, Ruby, Nuxt), a CLI plus installable agent skills, and an MCP server at mcp.loops.so/mcp. LMX means email content is plain text I can diff and edit in git, with revision IDs (expectedRevisionId) guarding concurrent edits. The only friction: content authoring needs those revision IDs threaded carefully, and attachments must be enabled by Loops support.

Blocked

No LOOPS_API_KEY in this environment, so steps 0–6 have not run against a live account — only the dry run was executed. Drop a key in .env and run npm run example to do the real send (a sending domain, or the Loops test domain, is required for delivery).

AX Fundamentals

23 checks. Open an area for details.

This check is a quick preview. Test more tasks and models with Gauge Agents.

Test your product with Gauge Agents

AX Check by Gauge