> ## Documentation Index
> Fetch the complete documentation index at: https://docs.teamfollowup.ai/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Use the public API base URL: https://api.teamfollowup.ai/api.
> Authenticate public API requests with Authorization: Bearer YOUR_API_KEY.
> Use product-owned terms: agents, campaigns, projects (GoHighLevel sub-accounts), contacts, calls, outcomes, skills, cadences, phone numbers.
> Scope requests by locationId rather than projectName.
> Read the guide linked from each API reference group before recommending an endpoint.

# How to book appointments in Google Calendar with n8n

> Your TFU Live agent offers open times from Google Calendar and books the one the caller picks. n8n connects the two.

TFU AI cannot book into Google Calendar by itself yet. n8n can do it for you.
Your agent asks n8n for open times and offers them. When the caller picks one,
n8n books it in Google Calendar. Google Calendar stays your only diary.

> [!NOTE]
>
> Does the sub-account use GoHighLevel? Then connect Google Calendar inside
> GoHighLevel, and book into that GoHighLevel calendar instead.

## How it works

You add one workflow to n8n and two workflows to your agent.

| Agent workflow | What n8n does |
| - | - |
| **Find a time** | Reads your busy times in Google Calendar. Sends back up to six open times. |
| **Book the time** | Checks that the time is still open. Creates the event, or sends back new times. |

n8n only offers times inside your opening hours. It refuses any other time, even
if the agent asks for one.

## Before you start

* An n8n account. n8n Cloud connects Google with one button.
* The Google account whose calendar gets the bookings.
* A [TFU Live](https://docs.teamfollowup.ai/agents/tfu-live.md) agent.

## Step 1: Add the workflow to n8n

**1. Copy the workflow**

Open **The n8n workflow** below and copy the JSON.

**2. Paste it into n8n**

In n8n, create a new workflow. Click the empty canvas and paste. If n8n
asks whether you are pasting from an AI tool, click **Skip**.

You can also save the JSON as a file. Then open the **...** menu at the
top and choose **Import**, then **From file**.

![The n8n workflow: Webhook, Read busy times, Find open times, Book it?, Create event and Reply to TFU AI.](https://mintcdn.com/teamfollowupai/cVnAKVFj-MMj_OiN/images/how-to/calendars/n8n-workflow-light.png?fit=max\&auto=format\&n=cVnAKVFj-MMj_OiN\&q=85\&s=71ec315d240980a264a7a401fbdd1f2d)

> [accordion] The n8n workflow
>
> ```json theme={"dark"}
> {
>   "name": "TFU AI: Google Calendar booking",
>   "nodes": [
>     {
>       "id": "20aa38b9-0d53-4ed4-9e03-0ba7a3a04a7e",
>       "name": "Webhook",
>       "type": "n8n-nodes-base.webhook",
>       "typeVersion": 2.1,
>       "position": [
>         0,
>         0
>       ],
>       "webhookId": "d99aa4e0-1d4c-4484-b607-fe0915a893ff",
>       "parameters": {
>         "httpMethod": "POST",
>         "path": "tfu-calendar",
>         "authentication": "headerAuth",
>         "responseMode": "responseNode",
>         "options": {}
>       }
>     },
>     {
>       "id": "d2c42663-fae4-41cd-9f94-d3c462d9292d",
>       "name": "Read busy times",
>       "type": "n8n-nodes-base.googleCalendar",
>       "typeVersion": 1.3,
>       "position": [
>         220,
>         0
>       ],
>       "alwaysOutputData": true,
>       "parameters": {
>         "resource": "calendar",
>         "operation": "availability",
>         "calendar": {
>           "__rl": true,
>           "mode": "list",
>           "value": "primary",
>           "cachedResultName": "primary"
>         },
>         "timeMin": "={{ $now }}",
>         "timeMax": "={{ $now.plus({ days: 15 }) }}",
>         "options": {
>           "outputFormat": "bookedSlots"
>         }
>       }
>     },
>     {
>       "id": "bc54eea3-3edd-4332-bdab-e355ce43501c",
>       "name": "Find open times",
>       "type": "n8n-nodes-base.code",
>       "typeVersion": 2,
>       "position": [
>         440,
>         0
>       ],
>       "parameters": {
>         "jsCode": "// Your settings. Change these to match your business.\nconst TIMEZONE = 'America/Chicago';\nconst OPEN_HOURS = {\n  mon: ['09:00', '17:00'],\n  tue: ['09:00', '17:00'],\n  wed: ['09:00', '17:00'],\n  thu: ['09:00', '17:00'],\n  fri: ['09:00', '17:00'],\n};\nconst APPOINTMENT_MINUTES = 30;\nconst NOTICE_MINUTES = 120;\nconst DAYS_AHEAD = 14; // Up to 14. Read busy times reads 15 days.\nconst TIMES_PER_DAY = 3;\nconst MAX_TIMES = 6;\n\n// You do not need to change anything below this line.\nconst body = $('Webhook').first().json.body || {};\nconst fields = body.fields || {};\nconst contact = body.contact || {};\nconst busy = $input.all()\n  .map(item => item.json)\n  .filter(slot => slot.start && slot.end)\n  .map(slot => [DateTime.fromISO(slot.start).toMillis(), DateTime.fromISO(slot.end).toMillis()]);\n\nconst now = DateTime.now().setZone(TIMEZONE);\nconst earliest = now.plus({ minutes: NOTICE_MINUTES });\nconst lastDay = now.startOf('day').plus({ days: Math.min(DAYS_AHEAD, 14) });\nconst DAY_NAMES = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];\n\n// Every open time from today to the last day.\nconst open = [];\nfor (let day = now.startOf('day'); day < lastDay; day = day.plus({ days: 1 })) {\n  const hours = OPEN_HOURS[DAY_NAMES[day.weekday - 1]];\n  if (!hours) continue;\n  const [openAt, closeAt] = hours.map(time => {\n    const [hour, minute] = time.split(':').map(Number);\n    return day.set({ hour, minute });\n  });\n  for (let start = openAt; start.plus({ minutes: APPOINTMENT_MINUTES }) <= closeAt; start = start.plus({ minutes: APPOINTMENT_MINUTES })) {\n    const end = start.plus({ minutes: APPOINTMENT_MINUTES });\n    const taken = busy.some(([from, to]) => from < end.toMillis() && to > start.toMillis());\n    if (start >= earliest && !taken) open.push(start);\n  }\n}\n\nconst label = time => time.toFormat(\"cccc d LLLL 'at' h:mm a\");\nconst code = time => time.toFormat(\"yyyy-LL-dd'T'HH:mm\");\n\n// The times to offer: from the day the caller asked for, a few per day.\nfunction offer() {\n  const asked = /^\\d{4}-\\d{2}-\\d{2}$/.test(fields.day || '') ? DateTime.fromISO(fields.day, { zone: TIMEZONE }) : now;\n  const perDay = {};\n  const times = [];\n  for (const time of open) {\n    if (time < asked.startOf('day') || times.length >= MAX_TIMES) continue;\n    const day = time.toISODate();\n    perDay[day] = (perDay[day] || 0) + 1;\n    if (perDay[day] <= TIMES_PER_DAY) times.push(`${label(time)} (code ${code(time)})`);\n  }\n  return { times: times.join('\\n') || 'none', count: times.length };\n}\n\nif (fields.action === 'book') {\n  const wanted = DateTime.fromISO(String(fields.start || ''), { zone: TIMEZONE });\n  const time = wanted.isValid ? open.find(slot => slot.toMillis() === wanted.toMillis()) : undefined;\n  if (time) {\n    const name = [contact.firstName, contact.lastName].filter(Boolean).join(' ') || 'New lead';\n    return [{ json: {\n      book: true,\n      event: {\n        start: time.toISO(),\n        end: time.plus({ minutes: APPOINTMENT_MINUTES }).toISO(),\n        summary: `Call with ${name}`,\n        description: [`Phone: ${contact.phone || 'not given'}`, `Email: ${contact.email || 'not given'}`, 'Booked by your TFU AI agent.'].join('\\n'),\n      },\n      answer: { booked: 'yes', when: label(time), times: 'none', count: 0 },\n    } }];\n  }\n  return [{ json: { book: false, answer: { booked: 'no', when: 'none', ...offer() } } }];\n}\n\nreturn [{ json: { book: false, answer: { booked: 'no', when: 'none', ...offer() } } }];\n"
>       }
>     },
>     {
>       "id": "bc0a614c-7030-4bfb-a7ca-05df2c59e5c4",
>       "name": "Book it?",
>       "type": "n8n-nodes-base.if",
>       "typeVersion": 2.2,
>       "position": [
>         660,
>         0
>       ],
>       "parameters": {
>         "conditions": {
>           "options": {
>             "caseSensitive": true,
>             "leftValue": "",
>             "typeValidation": "strict",
>             "version": 2
>           },
>           "conditions": [
>             {
>               "id": "9bc3c1e1-4633-4204-ba0c-6ad4ccbeb42b",
>               "leftValue": "={{ $json.book }}",
>               "rightValue": "",
>               "operator": {
>                 "type": "boolean",
>                 "operation": "true",
>                 "singleValue": true
>               }
>             }
>           ],
>           "combinator": "and"
>         },
>         "options": {}
>       }
>     },
>     {
>       "id": "3e123159-27bc-466f-b915-c48c1a513bc8",
>       "name": "Create event",
>       "type": "n8n-nodes-base.googleCalendar",
>       "typeVersion": 1.3,
>       "position": [
>         880,
>         -100
>       ],
>       "parameters": {
>         "calendar": {
>           "__rl": true,
>           "mode": "list",
>           "value": "primary",
>           "cachedResultName": "primary"
>         },
>         "start": "={{ $json.event.start }}",
>         "end": "={{ $json.event.end }}",
>         "useDefaultReminders": true,
>         "additionalFields": {
>           "summary": "={{ $json.event.summary }}",
>           "description": "={{ $json.event.description }}"
>         }
>       }
>     },
>     {
>       "id": "131b1647-8dfe-43f9-9408-5523a793c84a",
>       "name": "Reply to TFU AI",
>       "type": "n8n-nodes-base.respondToWebhook",
>       "typeVersion": 1.4,
>       "position": [
>         1100,
>         0
>       ],
>       "parameters": {
>         "respondWith": "json",
>         "responseBody": "={{ $('Find open times').first().json.answer }}",
>         "options": {}
>       }
>     }
>   ],
>   "connections": {
>     "Webhook": {
>       "main": [
>         [
>           {
>             "node": "Read busy times",
>             "type": "main",
>             "index": 0
>           }
>         ]
>       ]
>     },
>     "Read busy times": {
>       "main": [
>         [
>           {
>             "node": "Find open times",
>             "type": "main",
>             "index": 0
>           }
>         ]
>       ]
>     },
>     "Find open times": {
>       "main": [
>         [
>           {
>             "node": "Book it?",
>             "type": "main",
>             "index": 0
>           }
>         ]
>       ]
>     },
>     "Book it?": {
>       "main": [
>         [
>           {
>             "node": "Create event",
>             "type": "main",
>             "index": 0
>           }
>         ],
>         [
>           {
>             "node": "Reply to TFU AI",
>             "type": "main",
>             "index": 0
>           }
>         ]
>       ]
>     },
>     "Create event": {
>       "main": [
>         [
>           {
>             "node": "Reply to TFU AI",
>             "type": "main",
>             "index": 0
>           }
>         ]
>       ]
>     }
>   },
>   "settings": {
>     "executionOrder": "v1"
>   }
> }
> ```

## Step 2: Connect Google Calendar

**1. Sign in with Google**

Open **Read busy times**. Under **Credential**, create a new credential.
Click **Sign in with Google**, choose the account and click **Allow**.

**2. Choose the calendar**

Under **Calendar**, keep **primary** or choose another calendar.

**3. Do the same in Create event**

Open **Create event**. Choose the same credential and the same calendar.

![Read busy times with the Google Calendar credential, the primary calendar and the Booked Slots output.](https://mintcdn.com/teamfollowupai/cVnAKVFj-MMj_OiN/images/how-to/calendars/n8n-google-light.png?fit=max\&auto=format\&n=cVnAKVFj-MMj_OiN\&q=85\&s=48e7ad7b8d5cb6c4e13de5c15c60b0c9)

> [!NOTE]
>
> Self-hosted n8n has no one-click Google sign-in. Create your own Google
> client first, as in
> [n8n's Google guide](https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/).
> If that Google app stays in testing mode, the sign-in ends after seven days.
> Then sign in again.

## Step 3: Protect the webhook

Anyone with the webhook address could book into your calendar. A secret header
stops that. n8n refuses a request without it.

**1. Open the Webhook node**

**Authentication** is already **Header Auth**. Under **Credential for
Header Auth**, create a new credential.

**2. Set the secret**

Enter `X-Webhook-Secret` as the **Name**. Enter a long random string as
the **Value**. Keep a copy for Step 6, then click **Save**.

![The Header Auth credential, with X-Webhook-Secret as the name and a hidden value.](https://mintcdn.com/teamfollowupai/cVnAKVFj-MMj_OiN/images/how-to/calendars/n8n-header-auth-light.png?fit=max\&auto=format\&n=cVnAKVFj-MMj_OiN\&q=85\&s=0d53528a17a7558333349ef04fde2241)

## Step 4: Set your opening hours

Open **Find open times**. Change the settings at the top of the code. Do not
change anything below the line that says so.

| Setting | What it does | Default |
| - | - | - |
| `TIMEZONE` | Your business time zone, such as `America/Chicago`. The agent offers times in it. | `America/Chicago` |
| `OPEN_HOURS` | When you open and close each day, in 24-hour time. Leave a day out to close it. | Monday to Friday, `09:00` to `17:00` |
| `APPOINTMENT_MINUTES` | How long an appointment lasts. Times start this far apart. | `30` |
| `NOTICE_MINUTES` | How soon the first time can be. | `120` |
| `DAYS_AHEAD` | How many days ahead to look. Up to `14`. | `14` |
| `TIMES_PER_DAY` | The most times offered on one day. | `3` |
| `MAX_TIMES` | The most times the agent gets. | `6` |

![The Find open times code, with the settings at the top.](https://mintcdn.com/teamfollowupai/cVnAKVFj-MMj_OiN/images/how-to/calendars/n8n-hours-light.png?fit=max\&auto=format\&n=cVnAKVFj-MMj_OiN\&q=85\&s=3c472c65c56217af243ce9b943bfebc7)

## Step 5: Publish and copy the address

**1. Publish**

Click **Publish** at the top right, then **Publish** again.

**2. Copy the Production URL**

Open **Webhook**. Under **Webhook URLs**, click **Production URL** and
copy it. The **Test URL** works only while n8n listens for a test event.

![The Webhook node, with the Production URL shown and Header Auth chosen.](https://mintcdn.com/teamfollowupai/cVnAKVFj-MMj_OiN/images/how-to/calendars/n8n-webhook-light.png?fit=max\&auto=format\&n=cVnAKVFj-MMj_OiN\&q=85\&s=d9aa7e630c4be9bac00758b93ddc52ce)

## Step 6: Add Find a time to your agent

Open the agent and click **Configure myself**. On **Brain**, click **Actions**.
Click **Add a workflow**, then **Start blank**.

**1. Name it**

Enter `Find a time` as the **Workflow name**. Under **When to run it**,
enter `The lead wants to book a call, or asks when we are free. Also run it again when they ask about a different day.` Click **Done**.

![The Find a time workflow, with its name and when to run it.](https://mintcdn.com/teamfollowupai/cVnAKVFj-MMj_OiN/images/how-to/calendars/find-a-time-settings-light.png?fit=max\&auto=format\&n=cVnAKVFj-MMj_OiN\&q=85\&s=f7d3c0dcfc8ad5f3f3811b5a987a2286)

**2. Ask for a day**

Click **+** after the workflow, then **Collect information**.

| Setting | Value |
| - | - |
| What to collect | `Preferred day` |
| How the AI asks | `Ask which day suits them. If they have no preference, leave this empty.` |
| Save to | **Only for this call, not saved** |
| Data type | **Date** |
| Required | Off |

![Collect information set to Preferred day, a date, not required.](https://mintcdn.com/teamfollowupai/cVnAKVFj-MMj_OiN/images/how-to/calendars/find-a-time-collect-light.png?fit=max\&auto=format\&n=cVnAKVFj-MMj_OiN\&q=85\&s=1f53144267248e8e940ebe808c7ee7a3)

**3. Call n8n**

Click **+** after it, then **Webhook with response**.

| Setting | Value |
| - | - |
| Endpoint URL | The Production URL from Step 5 |
| Method | `POST` |
| Timeout (ms) | `10000` |
| Fields | `action` from **Fixed text** `find`. Then `day` from **Collected answer** **Preferred day**. |
| Headers | `X-Webhook-Secret`, with the value from Step 3. **Secret** turns on by itself. |
| Use from the response | Name `times`, path `times`. Name `count`, path `count`. |

![Webhook with response with the n8n address, the action and day fields, the secret header and the times and count values.](https://mintcdn.com/teamfollowupai/cVnAKVFj-MMj_OiN/images/how-to/calendars/find-a-time-webhook-light.png?fit=max\&auto=format\&n=cVnAKVFj-MMj_OiN\&q=85\&s=5deddbcfa26eda446f7100b50e3109f1)

**4. Tell the agent what to say**

On **Success**, add **Steer the agent** with this guidance:

```text wrap theme={"dark"}
The open times are below, one per line. Offer two of them, on different days if you can. Say only the day and the time, never the code. Never offer a time that is not on this list. If the list says none, say there are no open times in the next two weeks and offer a call back from the team. When the caller picks a time, run Book the time with the code of that time.
{{response.times}}
```

On **Failed**, add another **Steer the agent**:

```text wrap theme={"dark"}
You could not check the calendar. Say sorry, you cannot see the diary right now. Offer a call back from the team to book. Never say a time is free.
```

![The finished Find a time workflow: Collect information, Webhook with response, then Steer the agent on Success and on Failed.](https://mintcdn.com/teamfollowupai/cVnAKVFj-MMj_OiN/images/how-to/calendars/find-a-time-workflow-light.png?fit=max\&auto=format\&n=cVnAKVFj-MMj_OiN\&q=85\&s=5e1d51fe23cc768d15f3ab1405c161d4)

## Step 7: Add Book the time to your agent

Click **Add a workflow**, then **Start blank** again.

**1. Name it**

Enter `Book the time` as the **Workflow name**. Under **When to run it**,
enter `The lead picked one of the times that Find a time gave you and said yes to it.`

**2. Get the chosen time**

Add **Collect information**. Enter `Time code` under **What to collect**.
Under **How the AI asks**, enter `Use the code of the time the caller picked, from Find a time. Never ask the caller for a code.` Keep **Text** and **Required**.

**3. Call n8n**

Add **Webhook with response**. Use the same address, method, timeout and
header as in Step 6.

| Setting | Value |
| - | - |
| Fields | `action` from **Fixed text** `book`. Then `start` from **Collected answer** **Time code**. |
| Use from the response | `booked`, `when` and `times`, each with the same path. |

![Webhook with response with the action and start fields, the secret header and the booked, when and times values.](https://mintcdn.com/teamfollowupai/cVnAKVFj-MMj_OiN/images/how-to/calendars/book-the-time-webhook-light.png?fit=max\&auto=format\&n=cVnAKVFj-MMj_OiN\&q=85\&s=1498e095a38f2384dea59dc496bb4057)

**4. Check the answer**

On **Success**, add **Condition**. Choose **booked** as the **Field**,
**equals** as the **Comparison** and `yes` as the **Value**. Name the
route `Booked`, and name **Otherwise** `Not booked`.

![The Condition: booked equals yes on the Booked route, and Not booked when no route matches.](https://mintcdn.com/teamfollowupai/cVnAKVFj-MMj_OiN/images/how-to/calendars/book-the-time-condition-light.png?fit=max\&auto=format\&n=cVnAKVFj-MMj_OiN\&q=85\&s=07e43362aa12c9ff8d909f625b44651f)

**5. Tell the agent what to say**

Add **Steer the agent** on each route.

On **Booked**:

```text wrap theme={"dark"}
The appointment is booked for {{response.when}}. Tell the caller the day and time, and say the team will call them then. Do not run Book the time again.
```

On **Not booked**:

```text wrap theme={"dark"}
That time was just taken, so nothing was booked. Say sorry and offer two of these new times instead. Say only the day and the time, never the code. When the caller picks one, run Book the time again with its code.
{{response.times}}
```

On **Failed**:

```text wrap theme={"dark"}
You could not confirm the booking. Do not say it is booked. Say sorry, the diary did not respond, and offer a call back from the team to book.
```

**6. Save**

Click **Save changes**.

![The finished Book the time workflow: Collect information, Webhook with response, a Condition with Booked and Not booked, and Steer the agent on each path.](https://mintcdn.com/teamfollowupai/cVnAKVFj-MMj_OiN/images/how-to/calendars/book-the-time-workflow-light.png?fit=max\&auto=format\&n=cVnAKVFj-MMj_OiN\&q=85\&s=e8227fa0fc2bfc17932c9e3164299093)

## Step 8: Test it with a real call

> [!WARNING]
>
> The agent test in the builder never calls n8n. Both workflows take the
> **Failed** path there, and the agent says it cannot see the diary. Test with
> the campaign instead.

**1. Start a campaign test in your browser**

Click **Test**, choose **Web**, then click **Run** next to **Campaign**.
Enter a name and a phone number, then click **Start campaign session**.
This is a real call in your browser, so the booking is real too.

![The Run a test menu with Web chosen, and Run next to Campaign.](https://mintcdn.com/teamfollowupai/cVnAKVFj-MMj_OiN/images/how-to/calendars/test-campaign-web-light.png?fit=max\&auto=format\&n=cVnAKVFj-MMj_OiN\&q=85\&s=8fd110c3e5285c6f1df91ea5bedb604e)

**2. Book a time**

Ask for a call, ask for a day and pick a time. The conversation shows
each workflow as it runs.

![A test call: the caller asks for Wednesday, Find a time runs, the agent offers two times, Book the time runs, and the agent confirms the booking.](https://mintcdn.com/teamfollowupai/cVnAKVFj-MMj_OiN/images/how-to/calendars/test-call-transcript-light.png?fit=max\&auto=format\&n=cVnAKVFj-MMj_OiN\&q=85\&s=b6ff1d415199219aa19567a9c4e6ac5c)

**3. Check n8n and Google Calendar**

In n8n, open **Executions**. A booking shows every step in green. The
event is in Google Calendar, with the lead's phone and email.

![An n8n execution where every step ran, through Create event to Reply to TFU AI.](https://mintcdn.com/teamfollowupai/cVnAKVFj-MMj_OiN/images/how-to/calendars/n8n-execution-light.png?fit=max\&auto=format\&n=cVnAKVFj-MMj_OiN\&q=85\&s=3ee734a6b084e279690029c2db7bb580)

## What this setup does not do

* It does not cancel or move a booking. Do that in Google Calendar.
* The event has no guests, so Google sends no invite.
* Two callers who book the same time in the same second can both get it. This
  is rare. Move one of them in Google Calendar.

## Troubleshooting

> [accordion] The agent says it cannot see the diary
>
> Open **Executions** in n8n. If nothing ran, check three things. The
> workflow is published. TFU AI uses the **Production URL**. The secret is
> the same in TFU AI and in n8n. If a run failed, open it to see the step
> that stopped. n8n must answer within 10 seconds.

> [accordion] The agent offers times when you are busy
>
> **Read busy times** reads one calendar. Choose the calendar you book into,
> and choose the same one in **Create event**.

> [accordion] The times are a few hours off
>
> Set `TIMEZONE` in **Find open times**, then publish again. Google Calendar
> may show the event in your own time zone. The time is still right.

> [accordion] You moved n8n to a new address
>
> Enter the secret again in both **Webhook with response** steps. TFU AI keeps
> a secret only for the address it was saved with.

## Next steps

* [Workflows](https://docs.teamfollowup.ai/agents/tfu-live-capabilities.md): every step you can add.
* [Test your agent](https://docs.teamfollowup.ai/agents/tfu-live-testing.md): the other ways to test.
* [TFU Live authoring](https://docs.teamfollowup.ai/api-reference/tfu-live.md): build agents over the API.
* [How-to guides](https://docs.teamfollowup.ai/how-to/overview.md): more step-by-step guides.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.