Automation · by IFTTT Inc.
Build your own IFTTT.
When X happens, do Y. IFTTT caps you at 2 applets for free and charges monthly for filter code, multi-step applets, and faster polling. It's a job queue.
What you're paying now
$108 per year
IFTTT Pro+ for one person: $8.99 × 12 months on annual billing = $107.88. Pro is $35.88 per year but caps you at 20 applets and has no filter code.
Build it in any tool
Paste into Claude Code, ChatGPT, Codex, Replit, Lovable, or Cursor. The agent fetches everything it needs.
Paste anywhere
Build my own IFTTT. Fetch https://buildyourown.software/like/ifttt/llms.md and follow it: build the app from the build prompt, then write and run the tests from the test plan. Ask me before changing the data model or the non-goals.
Claude Code
claude "Build my own IFTTT. Fetch https://buildyourown.software/like/ifttt/llms.md and follow it: build the app from the build prompt, then write and run the tests from the test plan. Ask me before changing the data model or the non-goals."
Codex CLI
codex "Build my own IFTTT. Fetch https://buildyourown.software/like/ifttt/llms.md and follow it: build the app from the build prompt, then write and run the tests from the test plan. Ask me before changing the data model or the non-goals."
01
What it does
IFTTT runs 'applets'. An applet watches one service for a trigger (a new RSS item, a webhook, a time of day, an email) and does an action on another service (post to Slack, append a row to a Google Sheet, send a push notification). You map fields from the trigger into the action with 'ingredients'.
The free plan gives you 2 applets. Pro gives you 20 applets, multi-action applets, webhooks, and faster polling. Pro+ gives you unlimited applets, filter code (a JavaScript snippet between trigger and action), AI steps, and multiple accounts per service.
What most people actually run is a handful of applets against a few services they already have API keys or webhooks for. That's a trigger loop, a template renderer, a job queue, and a run log. It's a weekend project and it runs on a $5 VPS.
02
What it costs
List prices from the vendor's own pricing page. The fine print is where the money goes.
Free
$0
forever
- –2 applets
- –Standard applet speed
- –Unlimited applet runs
- –DIY or published applets
Pro
$2.99
per month billed annually ($35.88 per year)
- –20 applets
- –Multi-action applets
- –Webhooks
- –Fastest applet speed
- –Increased rate limits
The page shows a 'save 40%' toggle for annual billing, so month-to-month costs more than $2.99. Verify the monthly figure on the page before you quote it.
Pro+
$8.99
per month billed annually ($107.88 per year)
- –Unlimited applets
- –Filter code (JavaScript)
- –AI services
- –Multiple account connections per service
- –Prioritized support
Same annual-toggle caveat as Pro. Verify the month-to-month price.
The fine print
- Two free applets is the whole free plan. A third one, or an applet with two actions, means paying.
- Filter code (the JavaScript step that makes applets useful) is Pro+ only, so the interesting automations cost $107.88 a year. Pro's $35.88 doesn't get you there.
- 'Standard' polling speed on Free can mean an hour between a trigger firing and the action running. 'Fastest' is a paid feature.
- The 'save 40%' toggle means the advertised $2.99 and $8.99 are annual-billing prices. Month-to-month is higher and the page hides it behind the toggle.
- Applets stop working when a service changes its API or leaves the IFTTT platform. You can't fix them yourself; you wait for IFTTT.
- Connecting a second account of the same service (two Gmail accounts, two Slack workspaces) is Pro+ only.
Prices checked September 10, 2026 at ifttt.com. They change. Check before you quote them.
03
What you actually use
The headline features, and an honest call on whether a typical team needs each one.
10 of 13 headline features are worth building for a typical user. The rest is what you're paying for.
- Build it
Webhook trigger
A URL you POST to from anywhere. The JSON body becomes ingredients.
Why: This is the trigger that covers everything else. One route handler.
- Build it
Schedule trigger
Fire every day at 9am, every Monday, every 15 minutes.
Why: A cron expression plus a timezone. The only hard part is DST, and a library handles that.
- Build it
RSS / Atom trigger
Fire once per new item in a feed.
Why: Poll the feed, remember the GUIDs you've seen, fire on the new ones. Two hours of work.
- Build it
Email received trigger
Fire when mail arrives at an address you control.
Why: Resend, Postmark, and Cloudflare all turn inbound email into a webhook. You just parse it.
- Build it
'Value changed' trigger
Poll a URL and fire when a JSON value changes (price, stock count, status).
Why: IFTTT doesn't have this in a general form. Yours will, and it replaces a dozen niche services.
- Build it
Actions: HTTP, email, Slack, Discord, Sheets, notes, push
The seven actions that cover most personal automation.
Why: Each one is a function that takes a rendered config and makes one API call.
- Build it
Ingredient templates
Map trigger fields into action fields with {{title}} style placeholders.
Why: A small renderer with dot paths and a few filters (upper, truncate, date). Unit test it well.
- Build it
Filter code
A JavaScript snippet that can skip the run or reshape the data.
Why: Pro+ charges $107.88 a year for this. A sandbox with a timeout is an afternoon.
- Build it
Multi-action applets
One trigger, several actions in order.
Why: An array instead of a single object. There's no reason to gate it.
- Build it
Run log with retries
See every run, its payload, what each action got, and retry failures.
Why: You'll debug applets by reading this. IFTTT's own activity log is thin.
- Maybe
AI transform step
Summarize, classify, or rewrite the trigger payload with an LLM before the action.
Why: One OpenRouter call between trigger and action. Add it when you have a use.
- Skip
OAuth connections to 800+ services
Sign in with Spotify, Fitbit, Philips Hue, and so on.
Why: This is what IFTTT is actually selling, and it's the part you can't rebuild. Use webhooks and API keys for the services you care about.
- Skip
Mobile app, location, and smart home triggers
Fire when you arrive home or a sensor reads high.
Why: Your phone's Shortcuts app can POST to your webhook trigger. Smart home is Home Assistant's job.
04
How it works under the hood
The data model, the flows that matter, and the integrations you need. This is what the build prompt is based on.
Data model
Applet
id · name · enabled · trigger_type · definition (json: trigger, filter, transform, actions[]) · webhook_token · last_fired_at · run_count · created_at · updated_at
The definition is the whole applet as one JSON document so you can export and import files.
TriggerState
applet_id · next_run_at · last_polled_at · seen_keys (json, last 500) · last_value (json) · etag · last_modified
One row per applet. The poller reads and writes this; nothing else does.
Event
id · applet_id · source · dedupe_key · payload (json) · received_at
Unique on (applet_id, dedupe_key). A duplicate RSS GUID or email Message-ID inserts nothing.
Run
id · applet_id · event_id · status (queued | running | succeeded | failed | skipped | dead) · attempt · max_attempts · next_attempt_at · locked_by · locked_at · started_at · finished_at · error · steps (json)
steps holds the rendered input, output, and duration of the filter, transform, and each action.
Secret
name · value_encrypted · created_at
Referenced as {{secrets.NAME}} in configs. Redacted from run logs.
Key flows
Webhook fires an applet
- 1.Something POSTs to /api/hooks/<applet_id>/<token> with a JSON body.
- 2.The route checks the token, inserts an event with the body, headers, and query as payload, and inserts a queued run.
- 3.It returns 202 with the run id in under 50ms. Nothing else happens in the request.
- 4.The worker claims the run, renders templates, runs the filter, runs each action, and stores every step.
Scheduler tick
- 1.Every 15 seconds the worker selects enabled applets whose next_run_at is due.
- 2.Cron applets get an event with the fire time in UTC and local time, then next_run_at advances using the applet's timezone.
- 3.RSS and poll applets fetch their URL, compare against TriggerState, and insert one event per new item or one event for a changed value.
- 4.If the worker was down for a while, cron applets fire once on restart. Missed slots are dropped.
Run a job
- 1.Claim one queued run per applet at a time by setting locked_by and status = running in a single update.
- 2.Build the context: trigger ingredients, secrets, applet meta.
- 3.Run the filter in a sandbox with a 1 second timeout. skip() ends the run as skipped.
- 4.Optionally call OpenRouter for the AI transform and add its output to the context.
- 5.Run each action in order. A failed action fails the run; earlier actions are not rolled back and are not re-run on retry.
- 6.On a retryable failure, set next_attempt_at with exponential backoff. After max_attempts, mark dead.
Debug from the run log
- 1.Open a run and see the event payload, the filter's console output, and each action's rendered input and raw response.
- 2.Click Replay to create a fresh run from the same event after fixing the applet.
- 3.Click 'Send test event' on the applet to paste a sample payload and run it without waiting for a real trigger.
Integrations
- SQLite (better-sqlite3) or PostgresrequiredApplets, events, runs, and trigger state. SQLite is fine for one user.
- isolated-vm or QuickJSrequiredSandbox for filter code with a timeout and no network.
- cronerrequiredCron parsing with timezone support so schedules survive DST.
- rss-parserrequiredParse RSS and Atom feeds.
- Inbound email webhook (Resend, Postmark, or Cloudflare Email Workers)optionalThe email received trigger.
- ResendoptionalThe send email action.
- Slack and Discord incoming webhooksoptionalPost messages without OAuth.
- Google Sheets API (service account)optionalAppend rows. Share the sheet with the service account's email.
- ntfy.sh or PushoveroptionalPush notifications to your phone.
- OpenRouteroptionalOptional AI transform step.
05
The build prompt
The full spec the one-line command points at. Paste it directly if your tool can't fetch URLs, or edit the data model and non-goals first to fit your team.
# Build a personal automation runner (replacing IFTTT)
You are building a self-hosted automation runner for one person or a small team. It replaces IFTTT for people who run a handful of "when this happens, do that" applets against services they already have API keys or webhooks for. Build the whole thing end to end. Reliability of the queue and correctness of the scheduler matter more than the number of integrations.
## Stack
- Next.js (App Router) with TypeScript and Tailwind for the UI and HTTP endpoints
- A separate long-running worker process (`pnpm worker`) in the same repo that owns the scheduler and the job queue. Share code through `lib/`.
- SQLite via `better-sqlite3` with Drizzle. Keep the schema portable so `DATABASE_URL=postgres://...` also works.
- `isolated-vm` for the filter sandbox. If it won't build on the target platform, fall back to `quickjs-emscripten` behind the same interface in `lib/sandbox.ts`.
- `croner` for cron parsing with timezone support
- `rss-parser` for RSS and Atom
- `googleapis` for Sheets with a service account
- `resend` for outbound email
- `zod` for every config schema
- `date-fns` and `date-fns-tz` for date formatting in templates. Never do timezone math by hand.
- Deploy target: a single VPS or Replit Reserved VM running `pnpm start` (web) and `pnpm worker` under a process manager. Also provide a `Dockerfile` that runs both.
If the repo already has a different stack, use that and keep the behavior identical.
## Auth
Single admin. `ADMIN_PASSWORD` in env. A login page sets a signed cookie session. Every page and every `/api/*` route requires the session except `/api/hooks/*` and `/api/inbound-email/*`, which authenticate with per-applet tokens in the URL.
## Data model
All tables have `id` (uuid text), `created_at`, `updated_at`.
- `applets`: `name`, `enabled` (bool, default true), `trigger_type` (`webhook` | `schedule` | `rss` | `email` | `poll`), `definition` (json, see below), `webhook_token` (32 random bytes base64url, unique), `last_fired_at` (nullable), `run_count` (int, default 0).
- `trigger_state`: `applet_id` (pk, FK), `next_run_at` (nullable), `last_polled_at` (nullable), `seen_keys` (json array, capped at 500, newest last), `last_value` (json, nullable), `etag` (nullable), `last_modified` (nullable), `last_error` (nullable).
- `events`: `applet_id`, `source` (same enum as trigger_type plus `manual`), `dedupe_key` (nullable), `payload` (json), `received_at`. Unique index on (`applet_id`, `dedupe_key`) where `dedupe_key` is not null.
- `runs`: `applet_id`, `event_id`, `status` (`queued` | `running` | `succeeded` | `failed` | `skipped` | `dead`), `attempt` (int, default 1), `max_attempts` (int, default 5), `next_attempt_at`, `locked_by` (nullable), `locked_at` (nullable), `started_at`, `finished_at`, `error` (nullable text), `steps` (json array). Index on (`status`, `next_attempt_at`) and on (`applet_id`, `created_at`).
- `secrets`: `name` (unique, `[A-Z0-9_]+`), `value_encrypted` (AES-256-GCM with `SECRETS_KEY` from env).
### Applet definition JSON
Store the whole applet in `definition` so it can be exported to a file and imported back. Shape:
```json
{
"version": 1,
"trigger": { "type": "rss", "config": { "url": "https://example.com/feed.xml", "interval_min": 15 } },
"filter": { "code": "if (trigger.title.includes('[draft]')) skip('draft');" },
"transform": { "model": "openai/gpt-4o-mini", "prompt": "Summarize in one sentence: {{trigger.content}}", "output": "summary" },
"actions": [
{ "type": "slack", "config": { "webhook_url": "{{secrets.SLACK_WEBHOOK}}", "text": "New post: {{trigger.title}} {{trigger.link}}" } },
{ "type": "note", "config": { "folder": "posts", "filename": "{{trigger.published_at | date:'yyyy-MM-dd'}}-{{trigger.title | slug}}.md", "content": "# {{trigger.title}}\n\n{{ai.summary}}" } }
],
"retry": { "max_attempts": 5 }
}
```
`filter` and `transform` are optional. `actions` has at least one entry. Validate the whole thing with zod on save and on import, with a per-type config schema for every trigger and action. Reject unknown keys.
## Templates (`lib/template.ts`, pure, fully unit-tested)
Every string in an action config is a template. Syntax:
- `{{path}}` where path is dot and bracket notation into the context: `{{trigger.body.user.name}}`, `{{trigger.items[0].title}}`.
- Filters with a pipe: `{{trigger.title | upper}}`. Chain them: `{{trigger.content | strip_html | truncate:140}}`.
- Filters to implement: `upper`, `lower`, `trim`, `truncate:N` (adds `…`), `default:'text'`, `json` (JSON.stringify), `strip_html`, `slug` (lowercase, dashes, ASCII only, max 80 chars), `date:'format'` (format an ISO string or epoch ms with date-fns using the applet's `timezone`, default UTC), `urlencode`.
- The context has: `trigger` (ingredients from the event payload), `ai` (transform output), `vars` (values set by the filter), `secrets` (resolved lazily), `applet` (`id`, `name`), `run` (`id`, `attempt`), `now` (ISO UTC), `now_local` (ISO in applet timezone).
- A missing path renders as an empty string and adds a warning `"unresolved: trigger.foo"` to the step's `warnings` array. It never throws.
- A path that resolves to an object or array renders as JSON.
- Escaping: `\{{` renders a literal `{{`.
- `renderConfig(config, context)` walks an action config recursively, renders every string leaf, and returns `{ rendered, warnings, secretsUsed }`. Secret values used anywhere are replaced with `[redacted]` in whatever gets stored in `runs.steps`.
## Triggers
Every trigger produces an event `payload` with the ingredients below. Put each trigger in `lib/triggers/<type>.ts` exporting `configSchema`, `ingredients` (names and descriptions for the UI picker), `sample` (a realistic sample payload for testing), and either `handleRequest` (webhook, email) or `poll` (schedule, rss, poll).
### 1. `webhook`
- `POST /api/hooks/<applet_id>/<webhook_token>`. Also accept `GET` with query params so simple services can hit it.
- Config: `secret_header` (optional, e.g. `X-Hub-Signature-256`), `hmac_secret` (optional). When both are set, verify an HMAC-SHA256 of the raw body and return 401 on mismatch.
- Parse the body by content type: JSON, `application/x-www-form-urlencoded`, `multipart/form-data` (fields only), otherwise raw text in `body.raw`.
- Ingredients: `body` (parsed), `headers` (lowercased keys, drop `authorization` and `cookie`), `query`, `method`, `received_at`.
- Wrong applet id or token: 404 with an empty body. Applet disabled: 200 with `{ "queued": false, "reason": "disabled" }`.
- Success: insert the event and a queued run, return 202 `{ "run_id": "...", "event_id": "..." }`. Do no other work in the request. Response time under 50ms.
### 2. `schedule`
- Config: `cron` (5-field cron, validated by croner), `timezone` (IANA string, required).
- Ingredients: `fired_at` (ISO UTC), `fired_at_local` (ISO with offset in the applet timezone), `date` (`yyyy-MM-dd` local), `time` (`HH:mm` local), `weekday` (`Monday`), `timezone`.
- On save or enable, compute `trigger_state.next_run_at` from `now` using croner with `{ timezone }`.
- Scheduler tick: select applets with `next_run_at <= now`. For each, insert one event with `dedupe_key = next_run_at` (so a double tick can't double fire), then set `next_run_at` to the next occurrence after `now`. Do not advance from the missed slot. That means downtime yields one catch-up fire and no backlog.
- DST: `0 2 * * *` in `America/New_York` on the spring-forward day is skipped or fired at 03:00 per croner's behavior. Document which in the README and write a test that pins it.
### 3. `rss`
- Config: `url`, `interval_min` (integer, minimum 5, default 15), `fire_on_first_poll` (bool, default false), `max_items_per_poll` (default 20).
- Poll with `If-None-Match` and `If-Modified-Since` from trigger_state. A 304 updates `last_polled_at` and stops.
- Item key: `guid`, else `id`, else `link`, else a SHA-256 of `title + published`. Items whose key is in `seen_keys` are ignored.
- First poll ever (empty `seen_keys`): store all keys, fire nothing unless `fire_on_first_poll`.
- New items fire oldest first, one event each, `dedupe_key = item key`.
- Ingredients: `title`, `link`, `content` (full content or description), `summary` (first 300 chars of content with HTML stripped), `author`, `published_at` (ISO, null if the feed has none), `categories` (array), `feed_title`, `feed_url`.
- A fetch or parse error sets `trigger_state.last_error`, shows on the applet page, and does not create a run. After 3 consecutive errors, back the interval off to 60 minutes until a success.
### 4. `email`
- `POST /api/inbound-email/<INBOUND_TOKEN>` where `INBOUND_TOKEN` is a single env value you paste into the provider's webhook settings. Support Resend, Postmark, and Cloudflare Email Workers payloads with an adapter per provider in `lib/email-inbound/`, detected from the payload shape.
- Normalize to: `from` (address), `from_name`, `to` (array of addresses), `cc`, `subject`, `text`, `html`, `date` (ISO), `message_id`, `attachments` (array of `{ filename, content_type, size, url }`, contents not stored).
- Applet config: `to_alias` (optional; matches `<alias>@` prefix of any `to` address), `from_contains` (optional substring), `subject_regex` (optional). All set rules must match. An applet with no rules matches every inbound email.
- One email can match several applets; each gets its own event with `dedupe_key = message_id`.
- No match: return 200 and write a line to the worker log. Never return 4xx to the provider for a routing miss; they'll retry.
- Ingredients: all the normalized fields above, plus `text_first_line`.
### 5. `poll`
- Config: `url`, `method` (`GET` | `POST`, default GET), `headers` (object, values may be templates so secrets work), `body` (optional), `interval_min` (integer, minimum 1, default 5), `path` (dot/bracket path into the JSON response, e.g. `data.price` or `items[0].status`), `condition` (`changed` | `equals` | `not_equals` | `gt` | `lt` | `contains`, default `changed`), `target` (value for the non-`changed` conditions).
- Fetch with a 15 second timeout. Non-2xx or non-JSON sets `last_error` and stops.
- Extract the value at `path`. Compare with `trigger_state.last_value` using deep equality for `changed`, and numeric comparison for `gt` and `lt` (coerce strings that parse as numbers).
- First poll: store `last_value`, fire nothing.
- `changed` fires when the value differs from the stored one. `equals` and the others fire when the condition becomes true and was false on the previous poll. Treat it as an edge, so a price that stays under the target fires once and then stays quiet until it goes back over and drops again.
- Ingredients: `value`, `previous_value`, `url`, `path`, `changed_at`, `response` (the full JSON body, truncated to 64KB), `status`.
## Actions
Each action lives in `lib/actions/<type>.ts` exporting `configSchema`, `fields` (for the UI form: name, label, kind `text | textarea | select | secret | json`, help text), and `run(renderedConfig, ctx) => Promise<{ output, retryable? }>`. Throw `ActionError(message, { retryable })` on failure.
- `http`: `url`, `method`, `headers`, `body`, `content_type` (default `application/json`). 20 second timeout. Store `status`, `headers`, `body` (truncated to 64KB) as output. 5xx, 429, and network errors are retryable; other 4xx are not.
- `email`: `to`, `subject`, `body_markdown`. Render markdown to HTML and send with Resend from `EMAIL_FROM`. Retryable on 5xx and 429.
- `slack`: `webhook_url`, `text`, `blocks` (optional JSON). Post to the incoming webhook. Slack returns `ok` as plain text; treat anything else as failure.
- `discord`: `webhook_url`, `content`, `username` (optional). Discord caps `content` at 2000 characters: split on line breaks into chunks and send them in order, 1 second apart. 429 with `retry_after` is retryable.
- `sheets`: `spreadsheet_id`, `sheet_name`, `columns` (array of templates, one per cell). Append with `spreadsheets.values.append`, `valueInputOption: USER_ENTERED`, `insertDataOption: INSERT_ROWS`. Auth with a service account from `GOOGLE_SERVICE_ACCOUNT_JSON`. The README tells the user to share the sheet with the service account email. Output: the updated range.
- `note`: `folder` (relative to `NOTES_ROOT`), `filename`, `content`, `mode` (`create` | `append`, default `create`). Resolve the final path and refuse anything outside `NOTES_ROOT` (check with `path.resolve` and a prefix test; reject `..`, absolute paths, and null bytes). Sanitize the filename to `[a-zA-Z0-9._ -]`. `create` with an existing file appends ` (2)`, ` (3)` before the extension. Create the folder if missing. Output: the absolute path written.
- `push`: `provider` (`ntfy` | `pushover`). ntfy: `server` (default `https://ntfy.sh`), `topic`, `title`, `message`, `priority` (1 to 5), `click` (URL), `tags` (comma list), optional `token`. Pushover: `user_key`, `app_token`, `title`, `message`, `priority` (-2 to 2), `url`. Both truncate `message` to the provider limit and record it as a warning.
## Filter code (`lib/sandbox.ts`)
- Run the applet's `filter.code` in an isolate with a 1 second CPU timeout and a 32MB memory limit. No `require`, no `fetch`, no `process`, no timers.
- Globals inside: `trigger` (a deep copy of the ingredients), `applet` (`id`, `name`), `vars` (empty object), `skip(reason?)` which throws a sentinel that ends the run as `skipped`, `set(key, value)` which writes into `vars`, `console.log` which appends to the step's `logs` array (capped at 100 lines, 1KB each), and `Date`.
- Return `{ vars, logs, skipped, reason }`. `vars` must survive structured clone; drop functions.
- Any thrown error, syntax error, or timeout fails the run with `status = failed`, `error` = the message and line number, and no retry.
## AI transform (optional, `lib/transform.ts`)
- Config: `model`, `prompt` (a template), `output` (key name, default `output`), `json` (bool, default false).
- Call OpenRouter chat completions with `OPENROUTER_API_KEY`, 30 second timeout, `temperature: 0`. When `json` is true, ask for JSON in the system prompt and parse it; on parse failure store the raw text under `ai.<output>` and add a warning.
- The result goes into `ctx.ai[output]`. Retryable on 429 and 5xx, once, before the run's normal retry policy applies.
- Skip the step entirely and warn if `OPENROUTER_API_KEY` is not set.
## Worker (`worker/index.ts`)
One process running two loops.
**Scheduler loop**, every 15 seconds:
1. Select enabled applets with trigger_type in (`schedule`, `rss`, `poll`) and `next_run_at <= now` (or null).
2. For each, run the trigger's `poll`, which may insert events and runs, then set `next_run_at` = now + interval (or the next cron occurrence).
3. Errors in one applet's poll never stop the loop. Log them to `trigger_state.last_error`.
**Executor loop**, concurrency `WORKER_CONCURRENCY` (default 4):
1. Claim a run in one statement: update the oldest `queued` run whose `next_attempt_at <= now` and whose applet has no other run in `running`, setting `status = running`, `locked_by = <worker id>`, `locked_at = now`, `started_at = now`. Returning nothing means idle; sleep 500ms.
2. Load the applet and event. Build the context.
3. Steps, each appended to `runs.steps` as `{ kind, name, input, output, warnings, logs, started_at, duration_ms, error }`: `filter` (if present), `transform` (if present), then one `action` step per action in order.
4. Action failure: stop. If the error is retryable and `attempt < max_attempts`, set `status = queued`, `attempt += 1`, `next_attempt_at = now + backoff` where backoff is 30s, 2m, 10m, 1h, 6h for attempts 2 through 6. Otherwise `failed` (non-retryable) or `dead` (retries exhausted).
5. Retries re-run the whole step list from the beginning. Earlier successful actions run again; document this and make actions idempotent where the provider allows it (use `message_id` as an idempotency key for email).
6. Stale locks: a run in `running` with `locked_at` older than 10 minutes goes back to `queued` with the same attempt number.
7. Update `applets.run_count` and `last_fired_at` when a run finishes.
8. Retention: delete runs and events older than `RUN_RETENTION_DAYS` (default 30) once an hour.
## HTTP endpoints
All under `/api`, JSON in and out, session cookie required unless noted.
- `POST /hooks/:appletId/:token` and `GET /hooks/:appletId/:token`: webhook trigger. Token auth, no session.
- `POST /inbound-email/:inboundToken`: email trigger. Token auth, no session.
- `GET /applets`, `POST /applets`, `GET /applets/:id`, `PUT /applets/:id`, `DELETE /applets/:id`, `POST /applets/:id/enable`, `POST /applets/:id/disable`.
- `POST /applets/:id/test`: body is a payload; creates a `manual` event and a queued run; returns the run id.
- `POST /applets/preview`: body is `{ definition, sample }`; returns every action config rendered against the sample with warnings. Does not save or run anything.
- `POST /applets/filter-test`: body is `{ code, sample }`; runs the sandbox and returns `{ vars, logs, skipped, reason, error }`.
- `GET /runs?applet_id=&status=&from=&to=&cursor=`: paginated, 50 per page, newest first.
- `GET /runs/:id`, `POST /runs/:id/retry`, `POST /runs/:id/replay`.
- `GET /secrets` (names and created_at only), `POST /secrets`, `DELETE /secrets/:name`.
- `GET /export` (all applets as a JSON array), `POST /import` (body is that array, `?overwrite=true` to replace existing ids).
- `GET /health`: returns `{ ok, worker_seen_at }` where `worker_seen_at` is a heartbeat the worker writes every 15 seconds. Returns 503 when the heartbeat is older than 60 seconds so a monitor can tell you the worker died. No session required.
Deleting an applet deletes its events, runs, and trigger state. Disabling keeps everything and stops the scheduler from polling it; queued runs for a disabled applet still finish.
## Environment variables
Document every one of these in `.env.example` with a comment.
- `ADMIN_PASSWORD`, `SESSION_SECRET`, `SECRETS_KEY` (32 bytes base64), `DATABASE_URL` (default `file:./data/app.db`)
- `PUBLIC_URL` (used to build webhook URLs shown in the UI), `INBOUND_TOKEN`
- `RESEND_API_KEY`, `EMAIL_FROM`
- `GOOGLE_SERVICE_ACCOUNT_JSON` (the whole JSON, base64 encoded)
- `NOTES_ROOT` (absolute path, default `./data/notes`)
- `NTFY_SERVER`, `NTFY_TOKEN`, `PUSHOVER_APP_TOKEN`, `PUSHOVER_USER_KEY` (defaults the push action can fall back to)
- `OPENROUTER_API_KEY`
- `WORKER_CONCURRENCY` (default 4), `RUN_RETENTION_DAYS` (default 30), `LOG_LEVEL`
## Limits and edge cases
- Cap every stored payload and response body at 64KB. Store `truncated: true` on the event or step when you cut it.
- Cap `runs.steps` console logs at 100 lines. Cap `seen_keys` at 500 and drop the oldest.
- The webhook route must accept bodies up to 1MB and reject larger ones with 413 before parsing.
- A poll or RSS URL must be `http` or `https`. Refuse `file:`, `localhost`, `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, and link-local addresses on save, and again at fetch time after DNS resolution, so a template can't be used to reach the worker's own network. Same rule for the `http` action URL, with an `ALLOW_PRIVATE_URLS=true` env escape hatch for people running everything on a LAN.
- Redirects: follow at most 3, re-check the private address rule on each hop.
- All timestamps are stored as ISO 8601 UTC strings. Only the `date` template filter and the schedule trigger ever touch a timezone, and both take an IANA name.
- Empty template result for a required action field (for example a Slack `text` that renders to an empty string) fails the action with a clear message instead of posting a blank message.
- Two events with the same `dedupe_key` for the same applet: the second insert is ignored silently and no run is created. Log it at debug level.
- If the worker crashes mid-step, the stale lock rule requeues the run and the retry starts from the first step. Say so in the run detail page when `attempt > 1`.
## Screens
Left nav: Applets, Runs, Settings.
1. **Applets (`/`)**: table with name, trigger and action icons, enabled toggle, last run status and time, runs in the last 24h, and a "New applet" button.
2. **New applet (`/applets/new`)**: two steps on one page.
- Step 1: pick a trigger from cards (Webhook, Schedule, RSS, Email, Poll), then its config form. Schedule has a cron field with a plain-English preview of the next 3 fire times in the selected timezone. Webhook shows the URL after save.
- The cron field accepts presets from a dropdown (every hour, every day at 9:00, weekdays at 9:00, every Monday at 8:00, every 15 minutes) that fill in the expression.
- The RSS and poll forms have a "Fetch now" button that runs the trigger's fetch without saving and shows the first three items or the extracted value, so the user can confirm the URL and path before saving.
- The email form shows the full inbound address the alias maps to, using `INBOUND_DOMAIN` from env.
- Step 2: pick an action from cards, then its config form. Every text field has an "Insert ingredient" dropdown listing the trigger's ingredients (plus `ai.*` and `vars.*` when those steps exist) and inserts `{{trigger.name}}` at the cursor. A "Preview" button renders every field against the trigger's `sample` payload and shows the result with unresolved paths highlighted.
- The `note` action form shows the resolved absolute path preview for the sample. The `sheets` form has an "Add column" button that appends a template field. The `push` form switches its fields when the provider changes.
- Below step 2: "Add filter code" (code editor with a "Test filter" button that runs it against the sample and shows logs, vars, and whether it skipped), "Add AI transform", and "Add another action".
- Save validates with zod and shows field-level errors.
3. **Applet detail (`/applets/[id]`)**: the definition rendered as forms (editable), the webhook URL with a copy button, `last_error` if any, "Send test event" (a JSON textarea prefilled with the sample that creates a `manual` event and a run), "Export JSON", and the last 50 runs.
4. **Runs (`/runs`)**: global list with filters for applet, status, and date range. Auto-refresh every 5 seconds while any run is `queued` or `running`.
5. **Run detail (`/runs/[id]`)**: the event payload, then each step as a card with rendered input, output, warnings, console logs, duration, and the error. Buttons: "Retry" (re-queue this run with `attempt += 1`) and "Replay" (create a new run from the same event).
6. **Settings (`/settings`)**: secrets (add, rotate, delete; values never shown after save), the inbound email webhook URL, Google service account email (parsed from the JSON) and a "test" button that reads the sheet title, default ntfy server and Pushover keys, `NOTES_ROOT` display, and "Export all applets" / "Import applets" (a JSON file; import keeps ids and webhook tokens, and asks before overwriting an existing id).
Keep the UI plain. Server components for lists, client components only for the editor and the auto-refreshing runs table.
## Non-goals
Do not build OAuth connections to third-party services, a mobile app, location or smart-home triggers, user accounts beyond the single admin, billing, or a marketplace of shared applets. Leave a TODO comment if tempted.
## Acceptance criteria
1. `POST /api/hooks/<id>/<token>` with a JSON body returns 202 with a run id in under 50ms, and the run's event payload contains `body`, `headers`, and `query`. A wrong token returns 404 and creates nothing.
2. `renderTemplate` resolves dot and bracket paths, applies chained filters, renders missing paths as empty strings with a warning, and escapes `\{{`.
3. A schedule applet with `0 9 * * *` in `America/New_York` fires at 09:00 local on a day before and a day after the March DST change (13:00 and 14:00 UTC). After 3 hours of simulated downtime spanning one fire time, it fires exactly once on restart.
4. An RSS applet's first poll creates no runs. A second poll with two new items creates two runs, oldest first. A third poll with the same items creates none. A 304 response creates none and updates `last_polled_at`.
5. A poll applet with `condition = changed` fires only when the value at `path` differs from the stored one, and the event has both `value` and `previous_value`. With `condition = lt` and `target = 100`, values 120, 90, 80, 110, 70 fire twice (at 90 and at 70).
6. An inbound email matching an applet's `to_alias` creates a run with `from`, `subject`, and `text` populated. An email matching no applet returns 200 and creates nothing. The same `message_id` posted twice creates one event.
7. Filter code that calls `skip('x')` ends the run as `skipped` with no action step. Filter code that throws marks the run `failed` with the message. `while (true) {}` fails within 2 seconds and the worker keeps processing other runs. `fetch` and `require` are undefined inside the sandbox.
8. An `http` action against a URL returning 500 is retried with the documented backoff and goes `dead` after 5 attempts. A URL returning 400 fails on the first attempt with no retry.
9. Two runs for the same applet execute one after the other; runs for different applets execute concurrently (assert overlap with timestamps).
10. The `sheets` action calls `values.append` with the rendered columns in order and `USER_ENTERED` (mocked client).
11. The `note` action writes the file under `NOTES_ROOT`, appends ` (2)` on a name collision, and rejects `../escape.md` and absolute paths with a non-retryable error.
12. A config containing `{{secrets.SLACK_WEBHOOK}}` resolves the real value at run time, and the stored step input shows `[redacted]` in its place.
13. Export produces a JSON file that, imported into an empty database, recreates every applet with the same ids and webhook tokens.
14. A `discord` action with 4,500 characters of content sends three messages in order, none over 2,000 characters.
## Deliverables
- The app, the worker, migrations, and a seed script that creates one applet of each trigger type with realistic configs and 20 sample runs in mixed states.
- `lib/template.ts`, `lib/sandbox.ts`, and each trigger's `poll` function with unit tests.
- `fixtures/` with sample inbound payloads for Resend, Postmark, and Cloudflare, a sample RSS feed and Atom feed, and a sample poll response.
- README covering environment variables, how to run web and worker together, how to set up the inbound email webhook on each provider, how to create a Google service account and share a sheet with it, how to get a Slack or Discord webhook URL, ntfy and Pushover setup, and the DST behavior of the scheduler.
- `Dockerfile` and `docker-compose.yml` that run web and worker with a persistent SQLite volume.
Build in this order: the template renderer and its tests, then the data model and the queue with the `http` action, then the webhook trigger so you can fire runs by hand, then the scheduler and the polling triggers, then the sandbox, then the remaining actions, then the UI. Run the worker and fire a real webhook after each step.06
Where to build it
Any of these will take the prompt. Use the one you already pay for.
- Claude CodeAgentic coding in your terminal
Best fit. There's a worker process, a sandbox, and a template engine, all of which need tests. Claude Code can write the tests and run the worker locally while it builds.
- ReplitBuild and host in the browser
Works. Replit's Reserved VM deployment keeps the worker loop alive. Autoscale deployments sleep, so the scheduler would stop.
- ChatGPT / CodexPlan, then build with Codex
Use ChatGPT to cut the trigger and action list down to the ones you'll use, then have Codex build the trimmed spec.
- LovablePrompt-to-app with Supabase built in
Not a great fit. The UI is small and the value is in the worker, which Lovable's Supabase edge functions can't run as a long-lived loop. You'd need pg_cron plus a queue table.
- OpenRouterBring your own model
Only for the AI transform step. One call with the trigger payload and a prompt, output back into the template context.
07
Test it
Walk the checklist by hand once, then let the agent write the automated tests.
Manual checklist
- Create a webhook applet, curl its URL with a JSON body, and confirm a run appears in the log within a second with the body as the payload.
- curl the same URL with a wrong token and confirm you get a 404 and nothing is logged.
- Create a schedule applet for 2 minutes from now in your timezone, then confirm it fires once and next_run_at moves to tomorrow.
- Stop the worker for 10 minutes across a scheduled time, restart it, and confirm the applet fired exactly once.
- Point an RSS applet at a feed, confirm the first poll creates no runs, then wait for a new post and confirm exactly one run.
- Point a poll applet at a JSON URL you control, change the value, and confirm one run with both value and previous_value in the payload.
- Send an email to your inbound address and confirm the run has from, subject, and text body filled in.
- Add filter code that calls skip() when the title contains 'draft' and confirm those runs show as skipped with no action taken.
- Add filter code with an infinite loop and confirm the run fails after about a second and the worker keeps going.
- Point an HTTP action at a URL that returns 500 and confirm the run retries with growing delays, then goes dead after 5 attempts.
- Reference {{secrets.SLACK_WEBHOOK}} in an action and confirm the run log shows it as [redacted].
- Append a row to a Google Sheet and confirm the columns land in order with dates as dates.
- Export all applets to JSON, delete one, import the file, and confirm it comes back with the same id and webhook URL.
Generate automated tests
Paste this into the same tool that built the app. It writes tests against the acceptance criteria from the build prompt.
Write automated tests for the automation runner in this repo. Treat the acceptance criteria below as the spec. Use Vitest for unit and integration tests against a temporary SQLite file (or a test Postgres if `DATABASE_URL_TEST` is set), and Playwright for the applet editor flow. Mock outbound HTTP with `msw` or `nock`, and mock the Google Sheets client, Resend, and OpenRouter at the module boundary so the suite runs offline. Run the real worker loop in-process against the test database for queue and scheduler tests, with an injected clock.
## Acceptance criteria to cover
1. `POST /api/hooks/<id>/<token>` returns 202 with a run id in under 50ms and the event payload has `body`, `headers`, and `query`. Wrong token returns 404 and inserts nothing. A disabled applet returns 200 with `queued: false`.
2. `renderTemplate`: dot paths, bracket paths, every filter (`upper`, `lower`, `trim`, `truncate`, `default`, `json`, `strip_html`, `slug`, `date` with timezone, `urlencode`), chained filters, missing path renders empty with a warning, `\{{` escapes, objects render as JSON.
3. Schedule `0 9 * * *` in `America/New_York` produces fire times of 13:00 UTC on 2026-03-07 and 14:00 UTC on 2026-03-09. With the clock advanced 3 hours past one fire time, the scheduler creates exactly one event, and a second tick creates none.
4. RSS: first poll inserts no runs; second poll with two new items inserts two runs oldest first; third poll with the same feed inserts none; a 304 inserts none and updates `last_polled_at`; a feed with no GUIDs dedupes on link.
5. Poll trigger: `changed` fires only on a differing value and the event carries `value` and `previous_value`. `lt` with target 100 over the sequence 120, 90, 80, 110, 70 fires exactly twice.
6. Inbound email: a Resend payload, a Postmark payload, and a Cloudflare payload each normalize to the same shape. A matching `to_alias` creates a run; no match returns 200 and creates nothing; the same `message_id` twice creates one event.
7. Sandbox: `skip('x')` yields `skipped` and no action step; a thrown error yields `failed` with the message; `while (true) {}` fails within 2 seconds while a concurrent run for another applet still completes; `typeof fetch` and `typeof require` are `"undefined"` inside the sandbox; `set('k', 1)` shows up as `{{vars.k}}` in the action.
8. `http` action: a 500 response is retried with `next_attempt_at` offsets of 30s, 2m, 10m, 1h, 6h and the run is `dead` after attempt 5 fails; a 400 response marks the run `failed` on attempt 1 with no `next_attempt_at`.
9. Ordering: two queued runs for one applet never overlap (assert `started_at` of the second is after `finished_at` of the first); runs for two applets overlap when each action sleeps 500ms.
10. `sheets` action calls `values.append` once with the rendered columns in order, `valueInputOption: "USER_ENTERED"`, and the configured range.
11. `note` action writes under `NOTES_ROOT`, appends ` (2)` on collision, and rejects `../escape.md`, `/etc/passwd`, and a filename containing a null byte with a non-retryable error.
12. Secrets: `{{secrets.SLACK_WEBHOOK}}` resolves to the real value in the outbound request (assert on the mock) and appears as `[redacted]` in the stored step input.
13. Export then import into an empty database recreates every applet with identical ids, webhook tokens, and definitions.
14. `discord` action with 4,500 characters sends three requests in order, each `content` at most 2,000 characters, split on line breaks.
15. Playwright: create a webhook-to-slack applet through the two-step editor, insert an ingredient with the dropdown, use Preview against the sample, save, then send a test event and see the run succeed on the run page.
## Fixtures
- `fixtures/inbound/resend.json`, `fixtures/inbound/postmark.json`, `fixtures/inbound/cloudflare.json`: one inbound email each, same subject and body, so the normalization test can compare them field by field.
- `fixtures/feeds/rss.xml` and `fixtures/feeds/atom.xml`: five items each with stable GUIDs or ids and published dates spanning three days. `fixtures/feeds/rss-no-guid.xml` for the link fallback.
- `fixtures/poll/`: a JSON response per value in the sequence 120, 90, 80, 110, 70 under `data.price`.
- `fixtures/applets/`: one valid definition per trigger type and one invalid definition with an unknown key, used by the zod validation tests and the import test.
- `fixtures/export.json`: three applets with fixed ids and tokens for criterion 13.
## Layout
- `tests/unit/`: `template.test.ts` (criterion 2, table-driven), `sandbox.test.ts` (7), `chunk.test.ts` (14), `notes-path.test.ts` (11), `email-normalize.test.ts` (6, the adapter part).
- `tests/integration/`: worker and API against the test database with a fake clock. Cover 1, 3, 4, 5, 6, 8, 9, 10, 12, 13.
- `tests/e2e/`: Playwright for 15, with the Slack webhook mocked via an environment flag.
- `tests/helpers/`: `db.ts` (create and migrate a fresh SQLite file per test file), `clock.ts` (the injected clock), `worker.ts` (start and stop the worker loops in-process), `http.ts` (the msw server with handlers for Slack, Discord, ntfy, Pushover, and a generic `/status/:code` endpoint).
- Also add a zod validation test file, `tests/unit/definition.test.ts`, that loads every fixture in `fixtures/applets/` and asserts the valid ones parse and the invalid one fails with the unknown key named in the error.
## Rules
- Name every test after its criterion: `test("AC8: 500 retries with backoff then dead after 5 attempts")`.
- Use `vi.useFakeTimers()` or the worker's injected clock for anything involving time. Never sleep for real except in criterion 9, and cap that at 2 seconds total.
- Use the fixtures in `fixtures/`. If any are missing, create realistic ones, including a Postmark inbound payload with an attachment and an Atom feed with `<id>` but no `<guid>`.
- Add `pnpm test` and a GitHub Actions workflow that runs unit and integration tests on push and the Playwright spec on pull requests.
- Run the whole suite. Fix the app where the app is wrong and the test where the test is wrong. Report per-criterion pass/fail and what you changed.IFTTT is a trademark of IFTTT Inc.. This page is independent research and is not affiliated with or endorsed by IFTTT Inc.. Last updated 2026-09-10.