A trigger is the node a flow starts from. A flow can have several; each starts the flow from itself.
| Trigger | Type | Starts | Runs |
|---|---|---|---|
| Manual | trigger/manual | When you press Test run / Run in the editor | The draft |
| Schedule | trigger/schedule | Every N minutes, hours or days (optionally at a time) | The published version, real run |
| Webhook | trigger/webhook | A request to /api/hook/<token> | The published version, real run |
| Form | trigger/form | A submission of the public page /form/<token> | The published version, real run |
| Flow input | flow/input | Another flow's Run flow node, or an error flow | See Flow composition |
Schedule, Webhook and Form need the flow to be published (see Versions). Without a published version they don't start.
Manual
No fields. Press Test run (⌘Enter) or Run in the editor. Output: { "startedAt": "2026-10-28T06:10:21.000Z" }.
Schedule
| Field | Example | Meaning |
|---|---|---|
every | 1 | How many units between starts. |
unit | minutes · hours · days · cron | The unit; cron uses a cron expression instead. |
at | 08:00 | Days only: the time of day, in the flow's timezone. |
mooflair's scheduler ticks every minute (started from server.ts) and starts each published flow whose schedule is due. With days and at, it starts once that time has passed today in the flow's timezone.
{ "id": "schedule", "type": "trigger/schedule", "name": "Every morning", "field": { "every": 1, "unit": "days", "at": "08:00" } }
Output:
{ "startedAt": "2026-10-28T01:00:12.000Z", "every": 1, "unit": "days" }
Cron
Set the unit to Cron for a cron expression: minute hour day month weekday, read in the flow's timezone. Each part takes *, a number, a range (1-5), a list (0,30) and a step (*/15, 5/10); weekdays are 0 (Sunday) to 6. 0 9 * * 1-5 starts it at 09:00 on weekdays. It starts once in a matching minute.
Webhook
-
Add a Webhook node, open it, press Create URL and Copy URL.
-
Pick the Methods it answers: any of
POST(default),GET,PUT,PATCH,DELETE,HEAD. -
Choose Auth: None, or A credential the call must carry: (every header it signs with must match), or JWT (the
Authorization: Bearer <token>must verify against its secret or public key; the claims come out asjwt). -
Choose Respond:
Respond Answer At once Response code (default 202) and Response headers, with{ "runId": "…" }.When the last node finishes Waits for the run's end, then Response data: all items (a JSON list), the first item's JSON (default), the first item's file, or no body. Code 200unless set.With a Respond node Waits for the run's node and answers with its status, headers and body (or file). Stream Server-sent events: delta{ text }while an with Stream the answer writes, thendone{ item }(the last node's first item) orerror.Both waiting modes wait up to Wait for the answer (at most 5 minutes); nothing by then answers
202with the run id. -
Optional limits and filters: Calls a minute (default 60) and Calls a day (0: no limit), CORS origins (a comma list,
*for any; the browser preflight is answered), an IP allowlist (IPs, or ranges like10.0.0.0/8), Ignore bots (link previewers and crawlers get403), Raw body (keep the text as it came). -
Publish the flow.
curl -X POST "https://app.example.com/api/hook/Xk2…token" -H "Content-Type: application/json" -d '{"keyword":"tumbler"}'
Answers:
| Status | Body | When |
|---|---|---|
202 | { "runId": "6ae19596c810b38d560717b2" } | Started. |
401 | { "error": "unauthorized" } | The auth didn't match. |
403 | { "error": "forbidden" } | Not in the IP allowlist, or a bot while bots are ignored. |
404 | { "error": "not found" } | Unknown token, or the trigger is disabled or not published. |
405 | { "error": "use POST" } | A method it doesn't answer. |
409 | { "error": "<reason>" } | It couldn't start (e.g. no engine). |
413 | { "error": "Files over 25 MB" } | The uploaded files are too big. |
429 | { "error": "Too many requests. Slow down." } | Over a limit (also 120 calls a minute per IP). |
The trigger's output (read it as {{ nodes.<trigger id>.output.body.keyword }}):
{
"method": "POST",
"header": { "content-type": "application/json", "user-agent": "curl/8.7.1" },
"query": {},
"body": { "keyword": "tumbler" },
"jwt": null,
"receivedAt": "2026-10-28T06:10:21.000Z"
}
A JSON body is parsed, a form body (multipart/form-data, x-www-form-urlencoded) becomes its fields, any other body stays text. Authorization and Cookie headers are left out. Files sent as multipart/form-data become the item's , by field name ({{ file.photo.name }}), 25 MB in all. New URL rotates the token; the old URL stops working.
Form
A public page at /form/<token> that anyone with the link can fill in.
| Field | Meaning |
|---|---|
token | The form URL (Create URL). |
title, description | Shown at the top of the page. |
question | The questions (below). |
button | The send button's label (default Send). |
respond | When it's sent: the end page right away. When the last node finishes: the page follows the run: each node answered At a public page shows as the next page, and the end page shows when the run ends. |
end | The end page: a message (thanks, or the last page's own) or Go to a URL (redirect). |
isCaptcha | The site captcha before the first send (on by default). |
limitMinute, limitDay | Sends a minute and a day (0: no limit), on top of 10 per IP in 10 minutes and 300 an hour. |
Each question has a name (its key in the answers), a label, required, and a kind:
| Kind | Shows | Extra |
|---|---|---|
text, textarea, number, email, password, date | An input | placeholder, default |
dropdown | A dropdown (several: checkboxes) | option (a comma list), isMultiple, default |
checkbox | Checkboxes; the answer is a list | option |
radio | One choice | option, default |
file | A file picker | accept (.pdf, image/*), isMultiple |
hidden | Nothing: it's the page URL's ?<name>= or its default | default |
html | Your HTML, shown sandboxed (no scripts); no answer | html |
{
"question": [
{ "name": "email", "label": "Email", "kind": "email", "required": true },
{ "name": "plan", "label": "Plan", "kind": "radio", "option": "Free, Pro", "required": true },
{ "name": "invoice", "label": "Invoice", "kind": "file", "accept": ".pdf" },
{ "name": "source", "kind": "hidden", "default": "site" }
]
}
On submit, answers are kept to the questions and checked (required ones, the listed options, file types, 25 MB of files in all), and the published flow starts with them. Files are the item's files by question name (invoice, then invoice_2…):
{ "answer": { "email": "[email protected]", "plan": "Pro", "invoice": ["march.pdf"], "source": "ads" }, "submittedAt": "2026-10-28T06:10:21.000Z" }
Chat
The Chat trigger (plan feature trigger.chat) lets people chat with a published flow.
- Add a Chat node and press Create URL. Its page is
/chat/<token>. - Set its Title, Subtitle, Opening messages (one a line) and the box placeholder.
- Choose Reply with: Chat respond nodes (each sends a message, with the item's files when Send the item's files is on) or The last node's text (when the run ends, the
textof the last node that finished, else its JSON). - Who can chat: Anyone with the link, With a user name and password (a : the page asks for them, the API takes
Authorization: Basic …), or Signed-in Mooflair users (theiruser: { id, name }is in the item). - Optional: Start with a button, Load the previous conversation (on by default; off: a new conversation every visit), Allow file uploads (with Allowed file types: they become the item's files,
file,file_2…, 25 MB in all), Custom CSS, Captcha before the first message, Messages per chat at most, Messages a minute per chat (default 10), Messages a day (0: no limit), and CORS origins for its JSON API. - Publish the flow.
Each message is { sessionId, message, user } in the trigger's output. A message starts a run, unless the chat's run is waiting at a Chat respond with Wait for the reply: then that run goes on with it (its files too). The chat waits up to 90 seconds for the replies. Conversations are kept 30 days after their last message (ChatSession). An remembers the conversation by default (memory key {{ item.sessionId }}); turn on its Stream the answer and the chat shows its words as they come.
On your site: <script src="https://<your mooflair>/api/chat/<token>/widget" async></script> adds a round button that opens the chat in a panel (the hosted page with ?embed=1: the chat fills the panel, no logo around it).
Its JSON API, for your own chat UI:
| Call | Answers |
|---|---|
GET /api/chat/<token>?sessionId=<id> | { isAllowed, message: [{ role, text, file, at }] } (no messages when Load the previous conversation is off) |
POST /api/chat/<token> (multipart: sessionId, message, captcha?, file…) | { message: [the replies] }; with Accept: text/event-stream: events draft { text } (the streamed answer so far), then done { message } or error { error }. 401 without the chat's auth, 402 past the plan's calls a month, 429 past a limit |
GET /api/chat/<token>/file?sessionId=&id= | { url }: a 10-minute link to a file a reply sent |
sessionId is 8 to 64 letters, digits, _ or -, chosen by your page (the hosted page keeps one per browser). Turn Public off to keep the chat in Studio only.
In Studio, a flow with a Chat trigger gets a chat button in the editor's header: the panel chats with the draft, each message a test run (its log and outputs show on the canvas). New chat starts a fresh session.
Polling triggers
Gmail, Google Sheets, Google Drive, Google Calendar, Notion and Airtable triggers check the app every few minutes (Check every, never faster than your plan's fastest polling) and start the published flow with what's new, one item each:
| Trigger | Events |
|---|---|
| Gmail | a new email (each fetched whole) |
| Google Sheets | a row is added; a row is added or changed |
| Google Drive | a file is added; added or changed |
| Google Calendar | an event is added; added or changed |
| Notion | a database page is added; added or changed |
| Airtable | a record is added; added or changed |
Each takes the credential and the fields of the listing it uses (the sheet's range, the Drive query, the database id…). Its first check only learns what's there: nothing old starts the flow. Changing the trigger's fields and publishing again starts over. Run by hand in Studio, a trigger fetches the latest record as a sample. What it has seen lives in FlowPoll (2,000 keys, its last error too).
Email (IMAP) checks a mailbox (unread ones, all, or your own IMAP search as JSON) and starts one run per new email: { uid, from, to, cc, subject, date, text, html, attachment } (or with its headers, or the raw source); Download attachments makes them the run's files. After: mark it read, or leave it.
Telegram
The Telegram trigger needs a . Publishing points the bot's webhook at the trigger's URL (with a secret only this server knows); each update you picked (messages, button presses, channel posts…) starts the published flow, the update as the item. Removing the trigger and publishing stops the bot's webhook.
MCP server
The MCP server trigger (plan feature trigger.mcp) makes the workspace an MCP server that AI apps (Claude, ChatGPT, an AI agent node elsewhere) can use.
- Add an MCP server node, press Create URL, and choose its auth: Bearer token (default; set the Secret), A header (its name and the secret), or none.
- In each flow you want to offer: Flow settings › Offer as an MCP tool, with a tool name and what it does. The flow needs a Flow input (its sample's keys become the tool's arguments) and a published version.
- Publish the flow with the trigger and give its URL to the MCP client.
It speaks JSON-RPC over Streamable HTTP at POST /api/mcp/<token>: initialize, tools/list (the workspace's published tool flows) and tools/call. A call runs that flow for real with the arguments as its input, waits for it (2 minutes at most) and answers with what its Return sends back, as text and, for one object, as structured content. A failed run answers isError: true with its error. At most 120 calls a minute.
FAQ
My schedule didn't start.
Publish the flow, check the trigger isn't disabled, and check the flow's timezone in Settings.
Can a webhook run a test run?
No. Webhooks, schedules and forms always run the published version for real.