Writing a Setup Pack
A Setup Pack is one JSON file that adds automations, smart lists, message templates and deal pipelines to an Uplevel workspace, together with the tags, contact stages, lead sources, custom fields and email templates they use. Installing a newer version of the same pack upgrades it in place. This guide, the JSON Schema beside it and the example packs come from the code that checks a pack when it's imported.
How to write one
- Start from an example pack below and this guide.
- Write the pack. Give every item a key, and wherever one item uses another the pack carries, write that item's key.
- Check it with
SetupPackService.AnalyzeSetupPackImport(MCP toolsetup_pack_analyze_setup_pack_import), passing the whole file ascontent_json. Nothing in the workspace changes. A pack that can't be read is refused with the path of the problem and what's allowed there, such asautomations[0] (auto-welcome): graph.nodes[2] (sms): Unknown block kind "action.send_text". Action kinds: …. A pack that can be read comes back with what it would add, theconflictsthat need a decision, andwarningsfor fields it ignores, for items it uses but doesn't carry, and for smart list conditions a list can't run. - Fix the file and check again until it's accepted and the warnings are ones you mean.
- Install it with
SetupPackService.InstallSetupPack(MCP toolsetup_pack_install_setup_pack): the samecontent_json, plusdecisionswith one{"key", "action"}for each conflict, the action one of itsallowed_actions. It installs in the background and returnsjob_id;JobsService.GetJobfollows it toinstalledand what changed. Automations always arrive as drafts that are off, so nothing sends until someone in the workspace turns them on.
The file
format: always"uplevelre.setup-pack".schemaVersion:2.1is also read, for a pack without a brand.packId: a UUID generated once and kept for every version of the pack. It's how an install of a newer version finds the earlier one.name: required, up to 160 characters.version: required, up to 40, such as"1.0.0".description: up to 2,000.author: up to 160.exportedAt: optional, an RFC 3339 timestamp.automations,smartLists,messageTemplates,pipelines: up to 100 each. A pack needs at least one item across these four, or a brand.resources: up to 500 tags, contact stages, lead sources, custom fields and email templates the other items use.brandandassets: a brand pack's brand and the files it uses. Build them withSetupPackService.ComposeBrandPack(setup_pack_compose_brand_pack) or by exporting a workspace's brand rather than by hand.assetsalso carries the images email templates show (Email images, below), with or without a brand. At most 32 files.- The file is at most 10 MB. Fields the importer doesn't know are ignored and reported as warnings.
- The JSON Schema is published at uplevelre.com/setup-packs/schema.json. Set
$schemato it and an editor checks the file as you write.
Keys
Every item has a key: up to 200 characters, unique among items of its kind, and kept the same in every version so an upgrade updates what the earlier version installed. Write keys as lower-case words joined by dashes, starting with the kind: tag-hot-lead, auto-speed-to-lead, list-new-this-week, tmpl-missed-call, pipe-buyers.
Wherever a block's config or a smart list's filters take the id of a tag, contact stage, lead source, smart list, email template, custom field, pipeline or pipeline stage, a pack writes the key of the item it carries. Installing turns each key into the id of what it created, or of the existing item the installer chose to reuse. Anything else a pack refers to, such as a teammate, team, pond, website, form, appointment type or Program, can't travel: installing asks to use one with the same name or to clear it. List every referenced item in the automation's references with its name so it can be matched.
Automations
Each entry is {"key": "auto-…", "document": {…}}. The document:
format:"uplevelre.automation-template".version:1.name: required.descriptionandcategory: optional text.settings, optional:allow_reenrollment,stop_on_reply(true or false) anddaily_send_limit(1–10,000) are all required when settings are set;send_window_startandsend_window_endare minutes after midnight in workspace time (0–1439, not equal);send_window_dayslists weekdays, 0 = Sunday to 6 = Saturday, each once;send_window_skip_holidaysholds sends on holidays.references: one{"type", "id", "name"}for each item the graph uses,idwritten exactly as in the graph. Types:tag,stage,smart_list,user,team,pond,website,form,email_template,custom_field,pipeline,deal_stage,appointment_type,appointment_outcome,program,relationship_label,automation,lead_source.graph: the workflow, in the format below.
Graph
{"nodes":[{"id":"n1","kind":"trigger.contact_created","config":{},"position":{"x":0,"y":0}}],"edges":[{"id":"e1","source":"n1","target":"n2","handle":"next"}]}
- "handle" names the source block's output: "next" for linear blocks, "true"/"false" for control.if, "each"/"done" for control.loop, and for control.ai_branch one handle per outcome (its
key) plus the built-in "unclear" and "unavailable" — always wire those two somewhere, normally an action.notify to a human.
- The graph must be acyclic — repetition is expressed with control.loop, never a back-edge. Triggers cannot receive connections. A block with no outgoing edges ends its path.
Blocks
The notes below name Jan's tools for looking up ids (list_tags and so on). In a pack, write the key of the item the pack carries instead, as Keys describes.
Triggers (entry points; a workflow needs at least one, 41 kinds):
- trigger.transaction_created ("Transaction created") — starts when a transaction is deliberately created. Config: none.
- trigger.transaction_status_changed ("Transaction stage changed") — starts when a transaction changes closing stage. Config: none.
- trigger.transaction_closed ("Transaction closed") — starts when a sale actually closes; this is the revenue event. Config: none.
- trigger.transaction_fell_through ("Transaction fell through") — starts when a transaction falls through. Config: none.
- trigger.contact_created ("Contact created") — fires for every new contact (manual, email parsing, inbound SMS, website form, consumer portal, Ask Jan). Bulk imports deliberately do NOT fire it. Assignment/tag/stage actions wired directly after it run INLINE at lead creation, before the Lead Routing catch-all. Config: source — only contacts that arrived through this channel (HOW they arrived, not the lead source): "manual" | "website_form" | "consumer_portal" | "sms" | "email_parsing" | "assistant" (empty = any); source_in — array form of the channel filter — matches any listed channel (the compiled Lead Routing trigger uses ["website_form", "consumer_portal", "email_parsing"]); lead_source_in — only contacts whose LEAD SOURCE (where the lead came from: Zillow, Referral, Open House…) is one of these lead source ids — get ids from list_lead_sources. Composes with the channel filter. "Zillow lead → set stage, send intro, create call task" is this filter plus the actions; exclude_newsletter — true to skip newsletter-intent signups (they stay in the shared pool under Lead Routing); website_id — only website-form leads from this website (website id); property_contains — only leads whose originating website's listing address / MLS key contains this text.
- trigger.tag_added ("Tag added") — fires when the configured tag is applied to a contact. Config: tag_id (required) — the tag that starts this workflow (tag id).
- trigger.stage_changed ("Stage changed") — fires when a contact moves to a stage. Config: stage_id — only this stage (contact-stage id; empty = any stage).
- trigger.contact_field_changed ("Contact field changed") — fires when one contact field takes a new value — a built-in field or any custom field — however it changed (an edit in the app, the API, Jan, a form or AI write, another automation). Saving an unchanged value never fires it. THE block for "when Property Type is set to Condo, start the condo series" or "when a phone number is added"; never build a smart list plus a schedule for this. Fires again each time the field changes; a change this workflow makes itself with action.set_field never restarts it. Config: field (required) — first_name | last_name | email | phone | address | city | state | zip_code | company | job_title | birthday | anniversary | stage | lead_source | owner | "custom_field"; field_key — the custom field's key (required when field is custom_field); match — "any" (default, every change) | "filled" (was empty, now has a value) | "equals" (the new value is
value) | "cleared" (now empty); value — match equals only: the new value, compared case-insensitively. A select field's option value or label; "true"/"false" for a checkbox; YYYY-MM-DD for a date; the id for stage, lead_source, and owner. - trigger.sms_received ("Text received") — fires on an inbound SMS from a contact. Config: frequency — how often it can start the workflow for one contact: "limited" (default) or "every_reply"; frequency_limit — with limited, at most this many starts (1-100, default 5); frequency_period — per rolling "hour", "day" (default), "week" or "month". Every reply still ends runs that stop on reply, whatever the frequency.
- trigger.manual ("Manual start") — fires only when the user runs the automation on chosen contacts (or a smart list). Config: none.
- trigger.website_form_submitted ("Website form submitted") — fires on EVERY website form submission — including resubmissions by existing contacts, which contact_created misses. Config: website_id — only this website (empty = any); form_ids — only these forms (array of form ids; empty = any form); property_contains — only websites built for a listing whose address / MLS key contains this text.
- trigger.website_visit ("Website visit") — fires when a known contact returns to a workspace website (throttled to once per day per contact). Config: website_id — only this website (empty = any).
- trigger.listing_viewed ("Listing viewed") — fires when a known contact views a listing page on a workspace website. Config: min_views — only once the contact's running view count reaches this number; property_contains — only listings whose address / MLS key contains this text; frequency — how often it can start the workflow for one contact and one listing: "limited" (default) or "every_view"; frequency_limit — with limited, at most this many starts (1-100, default 1); frequency_period — per rolling "hour", "day" (default), "week" or "month". The view count rises on every view whatever the frequency, so min_views still fires when the count is reached.
- trigger.consumer_registered ("Consumer signed up") — fires when a visitor creates a consumer home-search account; the enrolled contact is the account's CRM contact. Config: website_id — only this website (empty = any).
- trigger.listing_favorited ("Listing favorited") — fires when a signed-in consumer favorites a listing. Config: website_id — only this website (empty = any); property_contains — only listings whose address / MLS key contains this text.
- trigger.search_saved ("Search saved") — fires when a signed-in consumer saves a search. Config: website_id — only this website (empty = any).
- trigger.newsletter_signup ("Newsletter signup") — fires on a newsletter-intent form submission (alongside website_form_submitted). Config: website_id — only this website (empty = any).
- trigger.listing_match ("Listing match") — fires when the property-alert scan pairs a new workspace listing with a contact's buying preferences. Config: property_contains — only listings whose address / MLS key contains this text (empty = any).
- trigger.date_reached ("Date reached") — fires when a contact date (birthday, anniversary, or a DATE/DATETIME/MONTH_DAY custom field) or a deal's expected closing date plus an offset lands on today, in the workspace timezone; recurring dates re-fire every year. With date_source deal_close_date the enrolled contact is the deal's contact, the deal rides along as {{deal_name}}/{{deal_stage}}/{{deal_value}}/{{deal_close_date}} plus {{property_address}} when the deal has a property, open and won deals both count, a moved closing date re-arms the trigger, and a deal marked lost or deleted never fires it and stops the runs it already started — the block to use for closing-week checklists, closing-day congratulations, and post-close review asks. Config: date_source — transaction_inspection_date | transaction_financing_date | transaction_close_date (projected) | transaction_actual_close_date (actual), or "birthday" (default) | "anniversary" | "occasion" (the contact's occasions; fires once per matching occasion, so a two-time buyer gets two runs a year, and {{property_address}} carries the occasion's property) | "move_in_date" (exact contact move-in date) | "custom_field" | "deal_close_date" (the deal's expected closing date); occasion_kind — occasion only: an occasion kind slug such as home_purchase_anniversary or lease_renewal (empty = every occasion). Yearly occasions re-fire every year; one-time occasions fire once; field_key — the DATE/DATETIME/MONTH_DAY custom field key (required when date_source is custom_field); pipeline_id — deal_close_date only: only deals in this pipeline (empty = any pipeline); offset_days — days relative to the date: -7 fires a week before, 0 on the day (default), 30 a month after; yearly — true (default for birthday/anniversary/MONTH_DAY) to recur every year; false fires once on a literal value that includes a year (closing dates are always literal); fire_at — time of day HH:MM in the workspace timezone (default 09:00).
- trigger.schedule ("Schedule") — recurringly enrolls every member of a smart list (daily/weekly/monthly/quarterly/yearly at a time of day, workspace timezone). Config: frequency — "daily" (default) | "weekly" | "monthly" | "quarterly" | "yearly"; at_time (required) — time of day HH:MM in the workspace timezone; weekday — 0 (Sunday) - 6 (Saturday); required when weekly; day_of_month — 1-28; required when monthly/quarterly/yearly; quarter_month — 1-3, which month of the quarter; required when quarterly; month — 1-12; required when yearly; smart_list_id (required) — the smart list whose members are enrolled each run; only_new — true to enroll each contact at most once ever; false (default) re-enrolls every run.
- trigger.webhook_received ("Webhook received") — fires when an external system POSTs JSON to this automation's unique webhook URL (Zapier, Make, custom forms); the payload is matched to a contact by email/phone and available as trigger context. Config: allow_create — true (default) to create a contact when the payload matches nobody; false enrolls existing contacts only.
- trigger.task_completed ("Task completed") — fires when a task linked to a contact is completed. Config: task_type — only tasks of this type: todo | call | email | meeting | follow_up (empty = any).
- trigger.task_overdue ("Task overdue") — fires when a task linked to a contact passes its due date without being completed. Config: task_type — only tasks of this type (empty = any).
- trigger.appointment_scheduled ("Appointment scheduled") — fires when an appointment is booked with a contact. Config: appointment_type_id — only appointments of this type, e.g. buyer consults (empty = any).
- trigger.appointment_upcoming ("Appointment upcoming") — fires a chosen lead time before a scheduled appointment starts (e.g. 24 hours before, for reminders); rescheduled appointments re-fire at the new time. Config: lead_value (required) — how long before the start time (with lead_unit; 5 minutes - 30 days); lead_unit — minutes | hours | days (default hours); appointment_type_id — only appointments of this type (empty = any).
- trigger.appointment_completed ("Appointment completed") — fires when an appointment's outcome marks it completed (e.g. a showing happened — ask for feedback); the outcome is available to If branches via the appointment_outcome condition. Config: appointment_type_id — only appointments of this type (empty = any).
- trigger.appointment_cancelled ("Appointment cancelled") — fires when an appointment is cancelled. Config: appointment_type_id — only appointments of this type (empty = any).
- trigger.appointment_no_show ("Appointment no-show") — fires when an appointment's outcome marks the contact a no-show (re-book sequences); the specific outcome is available to If branches via the appointment_outcome condition. Config: appointment_type_id — only appointments of this type (empty = any).
- trigger.showing_requested ("Showing requested") — fires when a visitor submits the website Request Showing block — a Showing appointment is created in requested status for the agent to confirm; confirmation later fires Appointment scheduled. Config: website_id — only requests from this website (empty = any); property_contains — only requests whose listing address contains this text (empty = any).
- trigger.meeting_booked ("Meeting booked") — fires when a visitor books a meeting through the agent's booking page or a website booking block (auto-confirmed) — the contact and appointment already exist when it fires. Config: none.
- trigger.email_opened ("Email opened") — fires the first time a contact opens a tracked email (campaign or automation send). Config: none.
- trigger.email_clicked ("Email clicked") — fires the first time a contact clicks a link in a tracked email. Config: none.
- trigger.email_bounced ("Email bounced") — fires when a tracked email to the contact bounces (list hygiene: tag, task, or switch channels). Config: none.
- trigger.email_replied ("Email reply received") — fires when the contact emails back on a connected mailbox (a teammate's Gmail or a Team Inbox address) — the email twin of trigger.sms_received. The reply text rides in the trigger context, so control.ai_branch and the reply_text condition work the same on both channels. Config: frequency — how often it can start the workflow for one contact: "limited" (default) or "every_reply"; frequency_limit — with limited, at most this many starts (1-100, default 5); frequency_period — per rolling "hour", "day" (default), "week" or "month". Every reply still ends runs that stop on reply, whatever the frequency.
- trigger.deal_created ("Deal created") — fires when a deal linked to a contact is created (manually, by the API, or by another automation); deals without a contact never fire. Config: pipeline_id — only deals in this pipeline (empty = any).
- trigger.deal_stage_changed ("Deal stage changed") — fires when a contact's deal moves to another pipeline stage; from/to filters make specific transitions targetable. Config: pipeline_id — only deals in this pipeline (empty = any); from_deal_stage_id — only moves out of this stage (empty = any); to_deal_stage_id — only moves into this stage (empty = any).
- trigger.deal_won ("Deal won") — fires when a contact's deal moves into a closed-won stage. Config: pipeline_id — only deals in this pipeline (empty = any).
- trigger.deal_lost ("Deal lost") — fires when a contact's deal moves into a closed-lost stage. Config: pipeline_id — only deals in this pipeline (empty = any).
- trigger.deal_stuck_in_stage ("Deal stuck in stage") — fires when an open deal has sat in one stage for N days without moving (pipeline hygiene: "Under Contract for 14 days → notify + task"); any stage move re-arms it. Config: days (required) — how many days the deal must sit in the stage (1-365); pipeline_id — only deals in this pipeline (empty = any); deal_stage_id — only this stage (empty = any stage).
- trigger.program_subscribed ("Added to Program") — fires when a contact becomes a direct Program subscriber (from the contact record, a form, an import, another automation, or Ask Jan) — "added to Market Updates → send a confirmation text"; list-derived delivery changes fire nothing. Config: program_id — only this Program (empty = any Program).
- trigger.program_unsubscribed ("Removed from Program") — fires when a contact's direct Program subscription ends (an unsubscribe or explicit removal). Config: program_id — only this Program (empty = any Program).
Actions (24 kinds):
- action.create_transaction ("Create transaction") — starts a transaction linked to the run's contact and deal, only when this action was explicitly added; retries do not duplicate it. Config: name — name with merge tags; defaults to the linked deal/contact; side — buyer | seller | both, default buyer.
- action.send_sms ("Send text") — sends an SMS from the workspace's default number; soft-skips when the contact has no phone or opted out. Config: body (required) — the message text; merge tags allowed.
- action.send_email ("Send email") — sends a 1:1 written or designed email (via the automation author's connected Gmail when available, else the marketing transport); soft-skips on no email / opt-out. Config: subject (required) — the email subject; merge tags allowed; content_mode — richtext (the standard email; default) | plain | rich; write richtext bodies as plain text or light Markdown and the server converts them into the formatted document; body — the email body with merge tags; required in plain mode. In richtext mode it is the text mirror of rich_text_doc — or, when no document is supplied, plain text or light Markdown the server converts into the standard-email document; rich_text_doc — the standard-email document (richtext mode), rendered server-side at send time; filled in from body when omitted; rich_text_html — legacy composer HTML from before the server renderer; ignored when rich_text_doc is present; design_document — the design document from the email builder, as an object; required in rich mode and rendered canonically at send time; html_content — legacy client preview snapshot only; never trusted for execution; template_id — legacy email-template reference (sys-* built-ins supported); new template choices are snapshotted into design_document; topic (required) — required: what kind of email this is. "personal" = a 1:1 email from the agent (a follow-up, a check-in, a note about their search or their deal): no marketing footer, no topic to unsubscribe from. Otherwise a subscription topic key (built-in or Program) for marketing email: contacts unsubscribed from that topic are skipped and the email carries the unsubscribe footer. Either way, Do not contact, an unsubscribe from all email, and bounces stop the send.
- action.create_task ("Create task") — creates a CRM task linked to the contact. Config: title (required) — task title; merge tags allowed; description — longer detail; task_type — todo | follow_up | call | email | text | showing; priority — low | medium | high; due_in_value — due this many units after the step runs (with due_in_unit; ≤ 365 days); due_in_unit — minutes | hours | days | business_days (skips workspace weekends and holidays); remind_minutes_before — email reminder lead time in minutes; assign_to — "owner" (contact owner, default), a member user id, or "relationship:<label id>" for whoever holds that relationship on the contact (the Lender, TC, …; skips when nobody does); show_on_calendar — true to place the task on the calendar.
- action.add_tag ("Add tag") — applies an existing tag to the contact. Config: tag_id (required) — the tag to add (tag id).
- action.set_field ("Set field") — writes a value you choose into one contact field, or clears it — "mark them Hot after they reply", "set Follow-up date to 14 days from today". Use it for state a workflow leaves behind instead of inventing a tag. Stage and owner have their own blocks (action.set_stage, action.assign_owner). Config: field (required) — address | city | state | zip_code | company | job_title | lead_source | "custom_field"; field_key — the custom field's key (required when field is custom_field). Text, number, currency, percentage, rating, select, checkbox, date, URL, email, and phone fields take a value; any field can be cleared; mode — "set" (default) | "clear" | "today_plus" (date fields: today plus offset_days, workspace timezone); value — mode set: the value. A select field's option value or label; "true"/"false" for a checkbox; YYYY-MM-DD for a date; the lead source id for lead_source; offset_days — mode today_plus: days from today, 0-3650 (default 0).
- action.mark_do_not_contact ("Mark do not contact") — blocks personal email, texts, calls and marketing, including queued sends; leaves subscriptions unchanged. Use for stop replies, never a same-named tag. This action cannot clear the restriction. Config: reason — optional reason, up to 2,000 characters; merge tags allowed, including {{reply}}.
- action.set_email_subscription ("Add to / remove from Program") — adds the contact to (or removes them from) a Program's sticky subscriber roster, or a built-in email topic — the "automations convert" leg of the audience model ("enters Hot Leads → add to Market Updates"); a subscribe never overrides a prior opt-out, and the automation is recorded as the membership's provenance. Config: topic (required) — the Program topic (program:<id>) or a built-in topic; op (required) — subscribe | unsubscribe.
- action.remove_tag ("Remove tag") — removes a tag from the contact. Config: tag_id (required) — the tag to remove (tag id).
- action.set_stage ("Change stage") — moves the contact to a stage. Config: stage_id (required) — the target contact-stage id.
- action.create_deal ("Create deal") — creates a deal in a pipeline, linked to the contact; with skip_if_open_deal (the default) a contact who already has an open deal in that pipeline is left alone, so appointment reschedules cannot spawn duplicates. Config: pipeline_id — the pipeline (empty = the workspace's default pipeline); deal_stage_id — the starting pipeline-stage id (empty = the pipeline's first open stage); name — deal name template; merge tags allowed (default "{{full_name}}", e.g. "{{full_name}} — Buyer"); value — deal dollar value, digits with up to 2 decimals; skip_if_open_deal — true (default) skips when the contact already has an open deal in the pipeline.
- action.move_deal_stage ("Move deal stage") — moves the contact's open deal (in the target stage's pipeline) to the chosen stage; moving into a closed-won/lost stage closes the deal and fires the matching triggers. Config: deal_stage_id (required) — the target pipeline-stage id; missing — "skip" (default) soft-skips when the contact has no open deal there; "create" starts one directly in the target stage; name — deal name template used only when missing = create (default "{{full_name}}").
- action.assign_owner ("Assign to") — assigns the contact to a workspace member — one specific teammate, a round-robin rotation with a cross-run cursor, or the most recently active roster member. This is the same primitive Settings -> Lead Routing compiles into; claim "if_unowned" makes it a polite claim that never steals an owned lead. Config: strategy — "specific" (default) | "round_robin" | "activity"; user_id — the member's user id (required for strategy "specific"); roster_member_ids — member user ids in the rotation (round_robin/activity; combine with roster_team_ids, at least one entry total); roster_team_ids — team ids whose current members join the rotation; claim — "always" (default — reassigns even when owned) | "if_unowned" (only claims ownerless contacts, the Lead Routing semantic); make_private — true to also move the contact into the assignee's private book.
- action.return_to_pond ("Return to pond") — moves the contact into the selected pond (unowned, shared with the pond's member/team audience). Config: pond_id (required) — the destination lead pond id; claim — "always" (default — clears an existing owner) | "if_unowned" (only ponds ownerless contacts).
- action.set_relationship ("Set relationship") — makes a workspace member the contact's Lender, Transaction coordinator, ISA, or any relationship the workspace configures — adding them as a collaborator (sharing the contact) when needed; a one-per-contact relationship moves from its previous holder, who stays a plain collaborator. Config: relationship_label_id (required) — the relationship label id (list_contact_relationship_labels); user_id (required) — the member's user id (list_team_members).
- action.add_collaborator ("Add collaborator") — shares the contact with a workspace member as a plain collaborator (no relationship label) — the team hand-off primitive; already-collaborating members are left as they are, and an explicit add outranks a temporary pond share. Config: user_id (required) — the member's user id (list_team_members).
- action.remove_collaborator ("Remove collaborator") — removes a workspace member's share of the contact, relationship label included; soft-skips when they are not a collaborator or are shared through a pond (the pond owns that share). Config: user_id (required) — the member's user id (list_team_members).
- action.run_automation ("Run automation") — starts the contact in another automation — the hand-off that lets a short intake workflow route into the right long nurture. Enrolls exactly like a manual run: the target's own re-enrollment policy and one-live-run rule apply, so it soft-skips when the contact is already in it or already went through it; also soft-skips when the target is turned off. Never the automation itself; at most 10 hand-offs in a chain. Config: automation_id (required) — the automation to start the contact in (list_automations; must be a different automation).
- action.stop_automations ("Stop other automations") — stops the contact's live runs of other automations — all of them, or one chosen automation — so a hotter sequence or a human takeover ends the generic nurture; the stopped runs exit with the reason recorded and cannot resume. Never stops the automation it belongs to; soft-skips when nothing else was running. Config: scope — "all" (default — every other automation the contact is in) | "specific" (one automation); automation_id — the automation to stop (required when scope is "specific"; must be a different automation).
- action.add_note ("Add note") — adds a timeline note on the contact. Config: body (required) — the note text; merge tags allowed.
- action.ai_extract ("AI: read conversation") — AI reads the contact and recent conversation, fills buying preferences or custom fields, and leaves a summary note. Config: instructions (required) — what the AI should look for; fields — array of {key, label, description}; min_bedrooms, min_bathrooms, move_in_date (YYYY-MM-DD), and transaction_intent (buy/rent) update first-class buying preferences; other keys fill custom fields.
- action.ai_message ("AI: write a message") — composes the message FRESH at every execution from the contact's live context (recent activity, conversation, stage, season, your brand voice) instead of sending one fixed body — the block for a recurring check-in that must not read like the same text three times. Sends through the normal send path, so opt-outs, suppression, the send window and the daily ceiling all apply. Review mode decides whether it sends itself or parks in the approval queue; if the AI is unavailable the block sends (or queues) its mandatory fallback text, never nothing. Config: channel — "sms" (default) or "email"; brief (required) — what this message should accomplish, in plain language — "a warm, casual check-in on their home search; reference anything they've done recently". The AI writes the words, you write the intent; tone — optional voice hint layered on the workspace brand voice, e.g. "friendly, never salesy"; length — "short" | "medium" | "long" (default short for texts, medium for email); include_activity — true (default) also shows the AI the contact's recent listing views and site visits; include_conversation — true (default) also shows the AI the last few messages either way; review — "approve" (default) parks each draft in the review queue for a human; "auto" sends what the AI wrote; expiry_hours — how long a queued draft stays sendable before it is too stale (1-336 hours, default 48); an expired draft is skipped and the workflow continues; fallback_body (required) — REQUIRED static message (merge tags allowed) sent — or queued — when the AI cannot compose. A missed touch must never be silent; fallback_subject — subject for the fallback email; required when channel is "email"; topic — required when channel is email: "personal" for a 1:1 email from the agent (no marketing footer, no topic to unsubscribe from), or a built-in or Program subscription topic key for marketing email; Do not contact, an unsubscribe from all email, and bounces always stop the send.
- action.webhook ("Webhook") — POSTs {event, contact, run} JSON to an external URL. Config: url (required) — the destination URL; must start with http:// or https://.
- action.notify ("Notify teammate") — emails a written or designed internal alert to the contact's owner (or a chosen teammate) — never the contact; not counted against the daily send limit. Config: recipient — "owner" (contact owner, default; falls back to the automation author), a member user id, or "relationship:<label id>" for whoever holds that relationship on the contact (the Lender, TC, …; skips when nobody does); subject (required) — the alert subject; merge tags allowed; content_mode — richtext (the standard email; default) | plain | rich; write richtext bodies as plain text or light Markdown and the server converts them into the formatted document; body — the alert message with merge tags; required in plain mode. In richtext mode it is the text mirror of rich_text_doc — or, when no document is supplied, plain text or light Markdown the server converts into the standard-email document; rich_text_doc — the standard-email document (richtext mode), rendered server-side at send time; filled in from body when omitted; rich_text_html — legacy composer HTML from before the server renderer; ignored when rich_text_doc is present; design_document — the design document from the email builder, as an object; required in rich mode and rendered canonically at send time; html_content — legacy client preview snapshot only; never trusted for execution.
Controls (4 kinds):
- control.wait ("Wait") — pauses the path, then continues via the "next" output — for a duration (including business days), until a time of day, or until a contact date or the deal's closing date. Config: mode — "duration" (default) | "until_time" (next HH:MM, workspace timezone) | "until_date" (a contact date field or the deal's closing date ± offset); value — how long to wait (with unit; required in duration mode); unit — minutes | hours | days | business_days (default hours; business days skip workspace weekends and holidays); at_time — time of day HH:MM (required in until_time mode; optional send hour in until_date mode, default 09:00); weekday — until_time only: 0 (Sunday) - 6 (Saturday) waits for that day; empty = today/tomorrow; date_source — transaction_inspection_date | transaction_financing_date | transaction_close_date (projected) | transaction_actual_close_date (actual), or until_date only: "birthday" | "anniversary" | "move_in_date" (exact contact move-in date) | "custom_field" | "deal_close_date" (required in until_date mode; deal_close_date waits on the deal that started the run, else the contact's open deal, re-reads it on every wake so a moved closing date re-times the wait, and ends the path if the deal is marked lost); field_key — until_date only: the DATE/DATETIME/MONTH_DAY custom field key when date_source is custom_field; Month/Day and yearless dates wait for the next annual occurrence; offset_days — until_date only: days relative to the date (-7 = a week before); birthdays/anniversaries project to their next occurrence, a past literal date continues immediately.
- control.if ("If / else") — evaluates a condition and routes to the "true" or "false" output (either may be left unconnected). Config: condition (required) — a condition document (see the condition grammar).
- control.ai_branch ("AI: branch on reply") — asks the AI which ONE of your outcomes the contact's reply belongs to, then continues down that outcome's own output. Place it after a reply trigger (trigger.sms_received / trigger.email_replied). Outputs: one handle per outcome (the outcome's
key) plus the built-in "unclear" (ambiguous or low confidence) and "unavailable" (AI off or failing) handles — wire both to a human. It never soft-skips and never writes to the contact unless save_to_field is set. Config: answer_type — choice (default), yes_no (two outcomes, No first then Yes), or score (outcomes ordered from 0 upward as rubric levels). The run stores an accepted string, boolean, or probability-weighted score; uncertainty never becomes false. Score is a rubric rating, never money or an exact quantity.; question (required) — the plain-language question about the reply, e.g. "Does this contact want to see the home?"; outcomes (required) — 2-6 outcomes: [{key, name, description}].keyis the output handle (lowercase a-z0-9_, unique, never "unclear"/"unavailable"),nameis the label, anddescriptionis the semantic guidance WITH example phrases — "Positive/excited reaction — things like 'that's awesome', 'love it', 🔥". Examples are what the AI matches meaning against; write them, do not list keywords; include_conversation — true also shows the model the last few messages (default false — it reads the triggering reply alone); save_to_field — optional compatible contact custom-field key: text for a choice label, Boolean for yes/no, Number for a rubric score. Only accepted answers are saved; the run log remains the decision record. - control.loop ("Loop") — repeats the "each" output on an interval up to
timescycles (the final cycle also fires "done"), or until the optionaluntilcondition is true. Config: every_value (required) — repeat interval (with every_unit; 5 minutes – 365 days); every_unit — minutes | hours | days (default hours); times (required) — max cycles, 1 – 100; until — optional stop condition document — checked before each cycle.
Bounds: at most 200 blocks per workflow; waits and task due times 1 minute – 365 days; loop interval 5 minutes – 365 days; loop cycles 1 – 100.
Condition documents (control.if condition, control.loop until): {"mode":"all"|"any","rules":[…]} with at least one rule. Rule kinds:
- {"kind":"has_tag","tag_id":"<tag id>"} — the contact carries the tag
- {"kind":"stage_is","stage_id":"<stage id>"} — the contact is in the stage
- {"kind":"lead_source_in","lead_source_ids":["<lead source id>"]} — the contact's lead source is one of the listed sources
- {"kind":"has_email"} — the contact has an email address
- {"kind":"has_phone"} — the contact has a phone number
- {"kind":"in_smart_list","smart_list_id":"<smart list id>"} — the contact currently matches the smart list
- {"kind":"custom_field","field_key":"<key>","op":"<op>","value":"<text>"} — compare a custom field (value not needed for exists/not_exists)
- {"kind":"submitted_form_is","form_id":"<form id>"} — the run started from this submitted form
- {"kind":"submitted_field","field_key":"<submission key>","op":"<op>","value":"<text>"} — compare an answer from the triggering form submission
- {"kind":"is_new_contact","expected":true|false} — whether this submission created the contact (default true)
- {"kind":"submission_count","op":"equals"|"not_equals"|"gt"|"gte"|"lt"|"lte","value":<n>} — compare this contact's submission count for the triggering form
- {"kind":"email_engagement","event":"opened"|"clicked","scope":"run"|"automation"|"any","within_days":<n?>} — the contact opened/clicked a tracked email (scope run = sent earlier in this run, the default; automation = any run of this automation; any = any tracked email)
- {"kind":"owner_responded"} — a teammate (not an automation) has personally texted, emailed, or called the contact since this run started
- {"kind":"run_task_completed"} — a task created earlier in this run has been completed
- {"kind":"last_activity_older_than","days":<n>} — the contact's last recorded activity is older than N days (or they have none)
- {"kind":"has_open_deal","pipeline_id":"<id?>","deal_stage_id":"<id?>"} — the contact has an open deal, optionally narrowed to a pipeline and/or pipeline stage (both filters optional)
- {"kind":"decision","field_key":"<upstream AI branch node id>","op":"equals"|"not_equals"|"gte"|"lte","value":"<expected value>"} — compare a recorded accepted decision. Missing/uncertain decisions never match. Use {{decision.<node id>}} in templates, with a fallback. Yes/no values are true/false; scores are rubric ratings.
- {"kind":"reply_text","op":"contains"|"not_contains"|"equals"|"not_equals","value":"<text>"} — the reply that started this run matches the text, case-insensitively and with no AI call (promo codes, exact phrases). Only meaningful after a reply trigger; for meaning rather than wording, use control.ai_branch
- {"kind":"appointment_outcome","outcome_id":"<id?>","category":"completed"|"no_show"|"cancelled"|"rescheduled"|"other"} — the triggering appointment's recorded outcome matches (set outcome_id, category, or both; only meaningful after an appointment trigger)
custom_field ops: exists | not_exists | equals | not_equals | contains | gt | lt.
Merge tags ({{name}} or {{name|fallback}} in SMS/email/note bodies, subjects, task titles; a blank or unknown merge tag uses the optional fallback): {{first_name}} (contact first name), {{last_name}} (contact last name), {{full_name}} (contact full name), {{email}} (contact email), {{phone}} (contact phone), {{address}} (contact address formatted on one line), {{street}} (contact street address), {{city}} (contact city), {{state}} (contact state / province), {{zip_code}} (contact ZIP / postal code), {{country}} (contact country when available), {{source}} (contact lead source), {{stage}} (contact stage), {{custom.<key>}} (contact custom field by key), {{decision.<node_id>}} (accepted output of an earlier reply decision in this automation run), {{form.<field_key>}} (value submitted in the triggering form by dynamic field key), {{agent_name}} (contact owner / sending agent full name), {{agent_first_name}} (contact owner / sending agent first name), {{agent_last_name}} (contact owner / sending agent last name), {{agent_email}} (contact owner / sending agent email), {{agent_phone}} (contact owner / sending agent phone), {{agent_photo_url}} (contact owner / sending agent headshot (their profile photo) as an image URL; empty when they have none, so an image using it is left out), {{relationship.<label>.name}} (full name of the collaborator holding that relationship on the contact (lender, transaction_coordinator, isa, showing_agent, or any label the workspace configures); blank when nobody holds it), {{relationship.<label>.first_name}} (first name of the collaborator holding that relationship), {{relationship.<label>.last_name}} (last name of the collaborator holding that relationship), {{relationship.<label>.email}} (email of the collaborator holding that relationship), {{relationship.<label>.phone}} (phone of the collaborator holding that relationship), {{company_name}} (branding business name, preferred in client-facing messages; falls back to workspace name), {{workspace_name}} (internal workspace label, retained for backward compatibility), {{brokerage_name}} (branding brokerage name), {{workspace_phone}} (branding phone number), {{workspace_address}} (branding address formatted on one line), {{workspace_website}} (workspace default website), {{tagline}} (branding tagline), {{license_number}} (branding real-estate license number), {{workspace_email}} (branding business email), {{market_areas}} (branding market areas served, comma-separated), {{specialties}} (branding specialties, comma-separated), {{property_address}} (property address supplied by the automation trigger), {{property_url}} (property URL supplied by the automation trigger), {{property_price}} (property price supplied by the automation trigger), {{property_beds}} (property bedroom count supplied by the automation trigger), {{property_baths}} (property bathroom count supplied by the automation trigger), {{property_sqft}} (property square footage supplied by the automation trigger), {{reply}} (what the contact wrote in the reply that started this run (text or email)), {{appointment_time}} (appointment date and time in the workspace timezone (the triggering appointment, else the contact's next upcoming one)), {{appointment_type}} (appointment type name, e.g. Buyer Consult), {{appointment_location}} (appointment location or meeting link), {{deal_name}} (deal name (the triggering deal, else the contact's open deal)), {{transaction_name}} (Name of the transaction that started this automation), {{transaction_stage}} (Current closing stage in the transaction event), {{transaction_close_date}} (Projected closing date in the transaction event), {{transaction_actual_close_date}} (Actual closing date in the transaction event), {{deal_stage}} (deal pipeline stage name), {{deal_value}} (deal dollar value), {{deal_close_date}} (deal expected closing date, e.g. September 30, 2026 (the triggering deal, else the contact's open deal)), {{current_date}} (today's date in the workspace timezone, e.g. August 16, 2026), {{current_year}} (current four-digit year), {{view_in_browser}} (hosted copy of this campaign email for the recipient), {{unsubscribe_url}} (recipient email unsubscribe URL)
Smart lists
Each entry: key, name (required, up to 160 characters), description, color (hex, such as #ef4444), icon, and filters, a version 2 filter document:
{"version": 2, "root": {"type": "and", "children": [
{"type": "cond", "field": "tags", "op": "has_any", "value": ["tag-hot-lead"]},
{"type": "cond", "field": "created_at", "op": "in_last_days", "value": 7}
]}}- Groups are
{"type": "and"}or{"type": "or"}withchildren, and{"type": "not", "child": …}. A condition is{"type": "cond", "field", "op", "value"}. At most 8 levels deep and 100 nodes, a list value holds at most 250 items, and the document is at most 64 KiB. tags,stageandsourcetake the keys of carried tags, contact stages and lead sources. A custom field condition names the field ascustom:<key of the carried custom field>.- Fields whose values are other workspace ids (
owner,originating_website,website_last_visit_at,visibility_team,relationship,deal_stage) don't travel; leave them out of a pack.
Fields and operators
Operator sets by kind:
- text: eq, neq, contains, not_contains, starts_with, ends_with, in, not_in, is_known, is_unknown
- postal: eq, neq, contains, not_contains, starts_with, ends_with, in, not_in, between, is_known, is_unknown
- enum: eq, neq, in, not_in, is_known, is_unknown
- multi: has_any, has_all, has_none, is_known, is_unknown
- multi_any: has_any, has_none, is_known, is_unknown
- number: eq, neq, gt, gte, lt, lte, between, is_known, is_unknown
- date: before, on_or_before, after, on_or_after, on, between, in_last_days, in_last_weeks, in_last_months, in_next_days, older_than_days, this_week, this_month, this_quarter, this_year, is_known, is_unknown
- website_date: before, on_or_before, after, on_or_after, on, between, in_last_days, in_last_weeks, in_last_months, in_next_days, older_than_days, this_week, this_month, this_quarter, this_year, is_known, is_unknown
- recurring_date: is_today, in_next_days, this_month, before, on_or_before, after, on_or_after, on, between, is_known, is_unknown
- occasion: is_today, in_next_days, this_month, has, has_not
- boolean: has, has_not
- month_day (custom recurring-date fields): is_today, in_next_days, this_month, is_known, is_unknown
- contact (custom contact-reference fields): eq, neq, is_known, is_unknown
Fields (id (kind) — note): Contact:
- any_text (text) — searches name, email, phones, address, company, job title and notes together
- name (text) — full name
- first_name (text)
- last_name (text)
- email (text) — matches every email on the contact
- phone (text) — values must be phone numbers (e.g. "555-123-4567")
- company (text)
- job_title (text)
- notes (text) — the profile notes text
Address:
- address (text) — street address
- city (text)
- state (text)
- zip_code (postal) — between takes ["low","high"] zip strings
Classification:
- stage (enum) — values are contact-stage IDS from the workspace values
- source (enum) — values are lead-source IDS from the workspace values
- tags (multi) — values are tag IDS from the workspace values
- tag_names (multi) — values are WORKSPACE tag NAMES (exact); prefer
tagswith ids
Websites:
- originating_website (enum) — values are website IDS from the workspace values; the first site that created this contact
- website_last_visit_at (website_date) — value is {"website_id":"<website id>","value":<date value>}; for is_known/is_unknown omit only the nested value
Assignment & visibility:
- owner (enum) — values are member user IDS, or "me" for the current user
- visibility (enum) — values: just_me | shared | workspace
- visibility_team (multi) — values are team IDS the contact is shared with
- relationship (multi) — values are relationship label IDS (or slugs) from the workspace values — the roles collaborators hold on the contact (Lender, TC, …); has_none [<lender id>] = contacts with no lender; is_unknown = no relationships at all
Dates:
- created_at (date)
- updated_at (date)
- last_activity_at (date) — last logged activity
- last_contacted (date) — last personal call, text, or email in either direction; excludes automation, campaigns, and batch sends; older_than_days includes never-contacted
- last_outbound_contact_at (date) — last personal outbound call, text, or email; excludes automation, campaigns, and batch sends
- last_engagement_at (date) — latest contact-driven website visit or email-open engagement signal
- last_site_visit_at (date) — website engagement
- last_email_open_at (date) — email engagement
- birthday (recurring_date) — is_today/in_next_days/this_month match the month+day every year
- anniversary (recurring_date) — is_today/in_next_days/this_month match the month+day every year
- occasion (occasion) — any occasion on the contact, e.g. in_next_days 30 = has an occasion in the next 30 days
- occasion:home_purchase_anniversary (occasion) — one kind of occasion: occasion:<kind slug>, using a slug from Occasion kinds below (birthday and anniversary included)
- target_transaction_date (date)
Numbers:
- lead_score (number) — 0-100 lead score from what the contact does
- lead_score_band (enum) — cold (0-39) | warm (40-69) | hot (70-100)
- min_bedrooms (number) — minimum bedrooms; 0 includes studios
- min_bathrooms (number) — minimum bathrooms; half increments
Dates:
- move_in_date (date) — target move-in date; use in_next_days for moving in the next 60 days
Contact:
- transaction_intent (text) — buy | rent
Numbers:
- budget_min (number) — dollars
- budget_max (number) — dollars
- pre_approval_amount (number) — dollars
Related records:
- appointments (boolean) — has/has_not any appointment
- appointment_count (number)
- appointment_date (date) — matches contacts with an appointment in the window
- tasks (boolean)
- task_count (number)
- open_tasks (boolean)
- overdue_tasks (boolean) — an open task whose due date has passed on the caller's clock
- due_today_tasks (boolean) — an open task due today and not yet overdue
- task_due_date (date) — due date of OPEN tasks
- deals (boolean)
- deal_count (number)
- deal_stage (multi_any) — values are deal-stage IDS from the workspace values
- deal_value (number) — dollars; matches contacts with any deal in the range
- has_closed_deal (boolean) — won or lost
- transactions (boolean)
- transaction_count (number)
- activity_type (multi_any) — values: email | call | meeting | note | task_completed
- activity_count (number)
- activity_notes (boolean) — has/has_not timeline notes
- activity_note_count (number)
- automations (boolean) — has/has_not automation runs
- automation_count (number)
- pending_deal (boolean) — an open deal in an open stage
Status:
- starred (boolean) — starred by the current user
- email_opt_out (boolean)
- email_delivery (enum) — values: bounced | temporary_bounce | spam_complaint | suppressed; a delivery problem with the contact's address (suppressed = marketing email held for either reason). It is evidence the address is out of date, not that the person lost interest
- subscribed_topic (enum) — values: newsletter | property_alerts | market_updates | promotions | check_in; matches contacts whose stated subscription consent for the topic is
subscribed - social_network (enum) — values: facebook | instagram | linkedin | x | tiktok | youtube | threads | pinterest | zillow | website | other; matches contacts with a confirmed profile on the network
- sms_opt_out (boolean)
- pre_approved (boolean)
- current_homeowner (boolean)
- has_name (boolean) — first or last name present
- social_profiles (boolean) — has a confirmed social profile
- social_needs_review (boolean) — a suggested social profile waits to be confirmed or dismissed
Message templates
Each entry: key; name (required, up to 120 characters); channel, one of sms, email, both (both when empty); category (up to 60); subject (one line, up to 300); body (required, up to 20,000, with the merge tags above); and richTextDoc, the email's rich text document. Leave richTextDoc out and the email is built from body.
Pipelines
Each entry: key, name, description, and stages, 1 to 50 of {"key", "name", "color", "probability", "stageType"}. stageType is one of OPEN, CLOSED_WON, CLOSED_LOST (OPEN when left out); a pipeline needs at least one CLOSED_WON and one CLOSED_LOST stage, and stage names are unique. probability is 0–100; won stages are always 100 and lost stages 0. Automations refer to a pipeline by its key and to a stage by the stage's key.
Resources
Each entry: key, kind, name (required, up to 200 characters) and data, an object whose fields depend on the kind. Fields left out take their defaults.
tag,contact_stage,lead_source:{"color": "#ef4444"}.custom_field:fieldType, one ofTEXT,TEXTAREA,NUMBER,CURRENCY,DATE,DATETIME,SELECT,MULTISELECT,BOOLEAN,URL,EMAIL,PHONE,MONTH_DAY,PERCENTAGE,USER,CONTACT,RATING;description;options, required forSELECTandMULTISELECT, as[{"value": "condo", "label": "Condo"}];placeholder;isRequired(default false),showInForm(default true) andshowInTable(default false).email_template:description,category,designDocument(the email builder's design document: its blocks are inworkspace_branding_get_brand_kit),htmlContentandthumbnail. Images inside it travel as email images, below.
Email images
An email template names each image by its web address. To carry an image, so installed copies don't keep loading it from where it's hosted now, add it to assets:
{"key": "email-image-1", "role": "email_image", "fileName": "open-house.jpg", "mimeType": "image/jpeg",
"byteSize": 48213, "sha256": "…", "data": "<base64>",
"sourceUrl": "https://cdn.example.com/open-house.jpg"}sourceUrlis the address exactly as the email templates write it, indesignDocument,htmlContentorthumbnail. At least one carried email template must use it, and no two files may share one.- The file is a PNG, JPEG, WebP, GIF or AVIF image of 10 MB or smaller, embedded as base64
dataor linked by an httpsurl. Exporting embeds images of 256 KB or smaller and links larger ones. - When installing adds or updates an email template, it copies the images that template uses into the workspace's media library and replaces each
sourceUrlin itsdesignDocument,htmlContentandthumbnailwith the copy's address. An address followed by more of a web address (such as?w=600) is a different address and stays as it is. A template the install keeps or reuses is left alone, and its images aren't copied. - The analyzer lists the carried images in
email_imagesand warns, with the template's path, about each image an email template shows that the pack doesn't carry.
Decisions on install
The analyzer lists a conflict for each thing installing can't decide alone, with its allowed_actions and a suggested_action:
name_conflict: the workspace already has an item with that name. Resources and pipelines canreuseit orcreate_copy; automations, smart lists and message templates cancreate_copyorskip, and pipelines can alsoskip.locally_modified: an item an earlier version installed was edited in the workspace.keep_localoruse_pack.dependency_mapping: an automation uses something the pack doesn't carry.reusethe match the analyzer found, passtarget_idto use another, orclearit.
Example packs
- Speed to Lead Starter: Every new lead gets a text inside a minute, a call task for the owner, a Hot Lead tag while they are fresh, and a smart list that keeps this week's new leads in one place.
The complete files come with this guide.