# Origamy playbook spec v1. Reference: /playbooks/spec-format/
spec_version: 1
id: real-estate.enquiry-to-viewing
title: Property enquiry to viewing
sector: real-estate
source:
  kind: synthesized
  notes: >-
    No agency has built this with us yet. Written from the base patterns: the call-task
    escalation ladder of the first-contact follow-up, a booking form that copies the slot time
    onto the person, an hourly reminder over a time-window segment, and a daily follow-up after
    the viewing. Speed to lead (a call within the hour) is the common advice for agents.
derived_from: base.first-contact-follow-up
vocabulary:
  person: lead
  people: leads
  group: sales team
summary: >-
  Every property enquiry gets an SMS with a link to book a viewing, and an agent task due within
  the hour that goes to a manager if nobody picks it up. Viewings are confirmed and reminded,
  and the agent is asked to follow up the day after.
when_to_use:
  - Enquiries reach you from listings, your website, Instagram or a printed board, and the first agent to call usually wins.
  - You want every viewing booked in one place, with a reminder so people turn up.
  - You want to know which listings get enquiries and who still needs a call.

requires:
  data_in:
    - how: public_form
      what: "The Enquiry form this playbook creates. Put its link on each listing as ?property=<listing code> so the form knows which property it is about."
    - how: booking_form
      what: "The Viewing booking form this playbook creates: each booking saves its time on the lead, which drives the reminders."
  channels:
    - channel: sms
      for: The viewing link, the booking confirmation and the reminder
      needs: An SMS provider, or Origamy managed SMS after business verification (KYC).
  events:
    - onboarding.submitted
    - booking.created
    - booking.cancelled
  traits:
    - property_of_interest
    - viewing_at
    - enquiry_call
    - enquiry_note
    - viewing_follow_up

parameters:
  - key: org_name
    ask: true
    question: What should messages call your agency?
    label: Agency name
    type: string
    example: Northwind Homes
  - key: agent_team
    ask: true
    question: Which team calls new enquiries and follows up after viewings? (one person is fine)
    label: Agents
    type: team_name
    default: Sales agents
  - key: manager_team
    ask: true
    question: Who should hear if an enquiry isn't called in time?
    label: Manager team
    type: team_name
    default: Sales managers
  - key: office_phone
    ask: true
    question: Which number should leads call to change a viewing?
    label: Office phone
    type: string
    default: "{{ workspace.phone }}"
  - key: call_due_hours
    ask: true
    question: How many hours do agents have to call a new enquiry?
    label: Hours to call
    type: hours
    default: 1
  - key: escalate_after_hours
    label: Hours after which an uncalled enquiry goes to the manager
    type: hours
    default: 2
  - key: reminder_hours
    label: Hours before a viewing for the reminder
    type: hours
    default: 2
  - key: follow_up_time
    label: Time of the daily follow-up run after viewings
    type: time
    default: "09:00"
  - key: follow_up_due_hours
    label: Hours the agent has to call after a viewing
    type: hours
    default: 24
  - key: report_cron
    label: When the weekly report arrives
    type: cron
    default: "0 8 * * 1"
  - key: default_country
    label: Default phone country on the forms
    type: country_code
    default: "{{ workspace.country }}"
  - key: timezone
    label: Time zone for the schedules and report
    type: timezone
    default: "{{ workspace.timezone }}"
  - key: greeting_fallback
    label: Greeting when a first name is missing
    type: string
    default: there

timings:
  - The enquiry SMS goes straight away, and the call task is created at once, due in {{ params.call_due_hours }} hours.
  - If nobody has responded after {{ params.escalate_after_hours }} hours, the task escalates to {{ params.manager_team }}.
  - A booked viewing is confirmed by SMS at once and reminded {{ params.reminder_hours }} hours before (the run is hourly).
  - The day after a viewing, at {{ params.follow_up_time }}, the agent gets a task to call, due in {{ params.follow_up_due_hours }} hours.
  - The weekly report arrives on cron {{ params.report_cron }} in {{ params.timezone }}.

customer_must:
  - "Publish the Enquiry form and put its link on every listing with the property code on the end, for example the form link followed by ?property=FLAT-12. Use short codes with no spaces."
  - "Publish the Viewing booking form and set your viewing days and hours in the form builder (the date and time slot question). If you show several properties, the same link works for all of them."
  - Add your agents to the {{ params.agent_team }} team and managers to {{ params.manager_team }}. If you are a one-person agency, put yourself in both. A task assigned to an empty team reaches nobody.
  - Connect an SMS provider or finish business verification, switch the follow-ups on, and test with your own phone and a fake enquiry.
  - Tell leads when you collect their details, and only use them to follow up on the property they asked about.

objects:
  - ref: team_agents
    kind: team
    about: Calls new enquiries and follows up after viewings.
    body: { name: "{{ params.agent_team }}" }
  - ref: team_managers
    kind: team
    about: Hears about enquiries nobody called in time, and gets the weekly report.
    body: { name: "{{ params.manager_team }}" }

  - ref: form_enquiry
    kind: form
    about: The enquiry form. A hidden field takes the property code from the link on the listing.
    body:
      name: Property enquiry
      slug: property-enquiry
      description: Ask about a property and we'll call you.
      draft_state:
        version: 1
        meta: { layout: classic, submit_label: Send enquiry, success_message: "Thank you! We'll text you a link to book a viewing, and an agent will call you soon." }
        pages:
          - id: p1
            title: Tell us what you're looking for
            description: We'll get back to you quickly.
            fields:
              - { id: f_first, key: first_name, type: short_text, label: First name, required: true, maps_to: { mode: custom, key: first_name, trait: true } }
              - { id: f_last, key: last_name, type: short_text, label: Last name, required: false, maps_to: { mode: custom, key: last_name, trait: true } }
              - { id: f_phone, key: phone, type: phone, label: Phone number, required: true, maps_to: { mode: mapped, slot: profile.phone }, config: { country_picker: true, default_country: "{{ params.default_country }}", verify: "off" } }
              - { id: f_email, key: email, type: email, label: Email address, required: false, maps_to: { mode: mapped, slot: profile.email } }
              - { id: f_budget, key: budget, type: short_text, label: "Your budget (a number or range, optional)", required: false, maps_to: { mode: custom, key: budget } }
              - id: f_timing
                key: move_in
                type: single_select
                label: When do you want to move in?
                required: false
                maps_to: { mode: custom, key: move_in }
                options: [{ value: asap, label: As soon as possible }, { value: within_a_month, label: Within a month }, { value: one_to_three_months, label: In one to three months }, { value: just_looking, label: Just looking }]
              - id: f_heard
                key: heard_from
                type: single_select
                label: How did you hear about us?
                required: false
                maps_to: { mode: custom, key: heard_from }
                options: [{ value: instagram, label: Instagram }, { value: whatsapp, label: WhatsApp }, { value: website, label: Our website }, { value: board, label: A sign or board }, { value: friend, label: A friend }, { value: other, label: Other }]
              - { id: f_property, key: property_ref, type: hidden, label: "", required: false, maps_to: { mode: custom, key: property_of_interest, trait: true }, config: { param: property } }
      verification_settings: { required: false }
  - ref: form_viewing
    kind: form
    about: The viewing booking form. Each booking sends booking.created with the slot time.
    body:
      name: Book a viewing
      slug: book-a-viewing
      description: Pick a time to see the property.
      draft_state:
        version: 1
        meta: { layout: classic, submit_label: Book viewing, success_message: "Booked! We'll text you a confirmation." }
        pages:
          - id: p1
            title: Pick a time
            description: Choose a day and time that suits you.
            fields:
              - { id: f_first, key: first_name, type: short_text, label: First name, required: true, maps_to: { mode: custom, key: first_name, trait: true } }
              - { id: f_phone, key: phone, type: phone, label: Phone number, required: true, maps_to: { mode: mapped, slot: profile.phone }, config: { country_picker: true, default_country: "{{ params.default_country }}", verify: "off" } }
              - { id: f_slot, key: viewing_slot, type: date_slot, label: When would you like to view?, required: true, maps_to: { mode: mapped, slot: booking.slot } }
              - { id: f_property, key: property_ref, type: hidden, label: "", required: false, maps_to: { mode: custom, key: property_of_interest, trait: true }, config: { param: property } }
      verification_settings: { required: false }

  - ref: seg_viewing_soon
    kind: segment
    about: A viewing in the next few hours.
    body:
      name: Viewing within hours
      description: "viewing_at within the next {{ params.reminder_hours }} hours."
      segment_type: dynamic
      status: active
      definition:
        operator: and
        conditions:
          - { type: trait, trait_key: viewing_at, cond_operator: within_next, value: "{{ params.reminder_hours }}h" }
  - ref: seg_viewed
    kind: segment
    about: A viewing in the last day.
    body:
      name: Viewed yesterday
      description: viewing_at within the last 24 hours.
      segment_type: dynamic
      status: active
      definition:
        operator: and
        conditions:
          - { type: trait, trait_key: viewing_at, cond_operator: within_last, value: 1d }

  - ref: q_enquiries
    kind: saved_question
    about: How many enquiries came in (used by the weekly report).
    body:
      name: "Report: property enquiries"
      data_plane_id: default
      viz_type: number
      viz_settings: {}
      visibility: workspace
      query_ast:
        table: events
        filters:
          operator: and
          conditions:
            - { field: event_name, op: eq, value: onboarding.submitted }
            - { field: properties.form_id, op: eq, value: "{{ ref.form_enquiry.id }}" }
        aggregations: [{ fn: distinct, field: canonical_user_id, alias: people }]
  - ref: q_enquiries_by_property
    kind: saved_question
    about: Enquiries per property code, so you can see which listings pull.
    body:
      name: "Report: enquiries by property"
      data_plane_id: default
      viz_type: table
      viz_settings: {}
      visibility: workspace
      query_ast:
        table: events
        filters:
          operator: and
          conditions:
            - { field: event_name, op: eq, value: onboarding.submitted }
            - { field: properties.form_id, op: eq, value: "{{ ref.form_enquiry.id }}" }
        aggregations: [{ fn: count, alias: enquiries }]
        breakouts: [{ field: properties.property_ref }]
        order_by: [{ field: enquiries, direction: desc }]
        limit: 50
  - ref: q_viewings
    kind: saved_question
    about: How many viewings were booked (used by the weekly report).
    body:
      name: "Report: viewings booked"
      data_plane_id: default
      viz_type: number
      viz_settings: {}
      visibility: workspace
      query_ast:
        table: events
        filters:
          operator: and
          conditions:
            - { field: event_name, op: eq, value: booking.created }
            - { field: properties.form_id, op: eq, value: "{{ ref.form_viewing.id }}" }
        aggregations: [{ fn: distinct, field: canonical_user_id, alias: people }]
  - ref: report_weekly
    kind: report
    about: Each week, enquiries, enquiries per property, viewings booked and the state of the call tasks, to the managers.
    body:
      name: "Weekly: enquiries and viewings"
      description: Enquiries, which properties they were about, viewings booked and call tasks.
      schedule_cron: "{{ params.report_cron }}"
      timezone: "{{ params.timezone }}"
      enabled: false
      spec:
        period: last_7_days
        compare: previous_period
        week_start: monday
        sections:
          - { id: enquiries, kind: saved_question, title: New enquiries, viz: number, params: { question_id: "{{ ref.q_enquiries.id }}" } }
          - { id: by_property, kind: saved_question, title: Enquiries by property, viz: table, params: { question_id: "{{ ref.q_enquiries_by_property.id }}" } }
          - { id: viewings, kind: saved_question, title: Viewings booked, viz: number, params: { question_id: "{{ ref.q_viewings.id }}" } }
          - { id: tasks, kind: tasks_summary, title: Calls and follow-ups, params: {} }
      recipients: [{ type: team, id: "{{ ref.team_managers.id }}" }]
      channel_policy: { mode: email_only }

  - ref: tmpl_enquiry_sms
    kind: template
    about: Instant reply to an enquiry, with the link to book a viewing.
    body:
      name: Enquiry received (SMS)
      channel: sms
      category: Enquiries
      style: text
      status: active
      subject: ""
      metadata: {}
      variables: {}
      body: "Hi {{ traits.first_name | default: \"{{ params.greeting_fallback }}\" }}, thanks for asking about {{ traits.property_of_interest | default: \"our property\" }} with {{ params.org_name }}. Book a viewing here: {{ ref.form_viewing.url }} An agent will also call you soon. Reply STOP to opt out."
  - ref: tmpl_viewing_confirm
    kind: template
    about: Viewing confirmation.
    body:
      name: Viewing booked (SMS)
      channel: sms
      category: Viewings
      style: text
      status: active
      subject: ""
      metadata: {}
      variables: {}
      body: "{{ params.org_name }}: your viewing is booked for {{ properties.slot_start_ts | date: \"%a %-d %b, %H:%M\" }}. An agent will meet you there. To change it, call {{ params.office_phone }}. Reply STOP to opt out."
  - ref: tmpl_viewing_soon
    kind: template
    about: Short reminder a couple of hours before.
    body:
      name: Viewing today (SMS)
      channel: sms
      category: Viewings
      style: text
      status: active
      subject: ""
      metadata: {}
      variables: {}
      body: "{{ params.org_name }}: see you at {{ traits.viewing_at | date: \"%H:%M\" }} for your viewing. Running late or can't make it? Call {{ params.office_phone }}. Reply STOP to opt out."

  - ref: wf_enquiry
    kind: workflow
    about: SMS with the viewing link, then an agent call task that escalates to a manager; the outcome is saved.
    body:
      name: "Enquiry: reply and call"
      description: When an enquiry comes in, an SMS with the viewing link and a call task for the agents.
      enabled: false
      trigger_type: event
      trigger_config: { event_name: onboarding.submitted, form_id: "{{ ref.form_enquiry.id }}" }
      steps:
        - { id: s1, type: action, config: { action_type: send_message, channel_config: { mode: single, single: { channel: sms, template_id: "{{ ref.tmpl_enquiry_sms.id }}" } } } }
        - id: s2
          type: human_task
          config:
            human_task:
              title: "Call enquiry: {{ traits.first_name }} {{ traits.last_name }}"
              instructions: |-
                New enquiry from {{ traits.first_name }} {{ traits.last_name }} on {{ traits.phone }}.
                Property: {{ traits.property_of_interest | default: "not given" }}
                Budget: {{ properties.budget | default: "not given" }}. Moving in: {{ properties.move_in | default: "not given" }}.

                Call them now and offer a viewing. If you can't reach them, try again later today.
                When done, choose an outcome and add a short note.
              assignee_refs: [{ type: team, id: "{{ ref.team_agents.id }}" }]
              strategy: round_robin
              completion_policy: first_response_wins
              outcome_options:
                - { id: viewing_booked, label: Viewing booked, intent: positive }
                - { id: call_back, label: Call back later, intent: neutral }
                - { id: not_interested, label: Not interested, intent: neutral }
                - { id: not_reached, label: Could not reach, intent: negative }
              due_hours: "{{ params.call_due_hours }}"
              wait: true
              wait_timeout_hours: 72
              notify: immediate
              escalation_ladder:
                - { after_hours: "{{ params.escalate_after_hours }}", to: [{ type: team, id: "{{ ref.team_managers.id }}" }] }
        - { id: s3, type: action, config: { action_type: set_trait, traits: { enquiry_call: "{{ properties.task_outcome | default: \"unknown\" }}", enquiry_note: "{{ properties.task_note }}" } } }
      re_entry_policy: every_time
      re_entry_cooldown_hours: 0
      goal: {}
      exit_conditions: []
  - ref: wf_booked
    kind: workflow
    about: When a viewing is booked, save its time on the lead and confirm it.
    body:
      name: "Viewing booked"
      description: Copies each booking's time onto the lead and sends the confirmation.
      enabled: false
      trigger_type: event
      trigger_config: { event_name: booking.created, form_id: "{{ ref.form_viewing.id }}" }
      steps:
        - { id: s1, type: action, config: { action_type: set_trait, traits: { viewing_at: "{{ properties.slot_start_ts }}" } } }
        - { id: s2, type: action, config: { action_type: send_message, channel_config: { mode: single, single: { channel: sms, template_id: "{{ ref.tmpl_viewing_confirm.id }}" } } } }
      re_entry_policy: every_time
      re_entry_cooldown_hours: 0
      goal: {}
      exit_conditions: []
  - ref: wf_cancelled
    kind: workflow
    about: A cancelled booking clears the saved time, so no reminder goes out.
    body:
      name: "Viewing cancelled"
      description: Clears the saved viewing time when a booking is cancelled.
      enabled: false
      trigger_type: event
      trigger_config: { event_name: booking.cancelled, form_id: "{{ ref.form_viewing.id }}" }
      steps:
        - { id: s1, type: action, config: { action_type: set_trait, traits: { viewing_at: "" } } }
      re_entry_policy: every_time
      re_entry_cooldown_hours: 0
      goal: {}
      exit_conditions: []
  - ref: wf_reminder
    kind: workflow
    about: Hourly run over "viewing within hours"; one reminder per viewing.
    body:
      name: "Viewing reminder"
      description: Every hour, a short reminder to leads whose viewing is a few hours away.
      enabled: false
      trigger_type: schedule
      trigger_config: { segment_id: "{{ ref.seg_viewing_soon.id }}", rrule: FREQ=HOURLY, starts_at: "{{ workspace.tomorrow }}T07:00", timezone: "{{ params.timezone }}" }
      steps:
        - { id: s1, type: action, config: { action_type: send_message, channel_config: { mode: single, single: { channel: sms, template_id: "{{ ref.tmpl_viewing_soon.id }}" } } } }
      re_entry_policy: cooldown
      re_entry_cooldown_hours: 12
      goal: {}
      exit_conditions: []
  - ref: wf_after_viewing
    kind: workflow
    about: 'Daily run over "viewed yesterday": an agent follow-up task; the outcome is saved.'
    body:
      name: "Viewing follow-up call"
      description: The day after a viewing, a task for the agents to ask how it went.
      enabled: false
      trigger_type: schedule
      trigger_config: { segment_id: "{{ ref.seg_viewed.id }}", rrule: FREQ=DAILY, starts_at: "{{ workspace.tomorrow }}T{{ params.follow_up_time }}", timezone: "{{ params.timezone }}" }
      steps:
        - id: s1
          type: human_task
          config:
            human_task:
              title: "How was the viewing? {{ traits.first_name }} {{ traits.last_name }}"
              instructions: |-
                {{ traits.first_name }} {{ traits.last_name }} ({{ traits.phone }}) viewed {{ traits.property_of_interest | default: "a property" }} yesterday.
                Call to ask how it went and what the next step is. Note what they said.
              assignee_refs: [{ type: team, id: "{{ ref.team_agents.id }}" }]
              strategy: round_robin
              completion_policy: first_response_wins
              outcome_options:
                - { id: offer, label: Making an offer, intent: positive }
                - { id: second_viewing, label: Second viewing, intent: positive }
                - { id: not_interested, label: Not interested, intent: neutral }
                - { id: not_reached, label: Could not reach, intent: negative }
              due_hours: "{{ params.follow_up_due_hours }}"
              wait: true
              wait_timeout_hours: 168
              notify: immediate
        - { id: s2, type: action, config: { action_type: set_trait, traits: { viewing_follow_up: "{{ properties.task_outcome | default: \"unknown\" }}" } } }
      re_entry_policy: cooldown
      re_entry_cooldown_hours: 168
      goal: {}
      exit_conditions: []

eval:
  name: 83_real_estate_enquiry_to_viewing
  mode: task
  vertical: ""
  prompt: >-
    We're an estate agency. When someone sends an enquiry about a property, text them a link to
    book a viewing and give our agents a task to call them within an hour. If nobody calls in
    two hours, tell the sales manager. Remind people two hours before their viewing, and the
    day after a viewing give the agent a task to ask how it went.
  expect_artifacts: [form, segment, workflow, message_template]
  forbid_terms: [guaranteed]
