Follow-Up Studio
Inputs
1 · Prospect & Call Context

Who you spoke to and how the message must sound. The city and the time of day the e-mail/message(s) go out together drive the sign-off — “Best regards to Amsterdam + have a nice evening”, in German “Beste Grüße nach Amsterdam und einen schönen Abend”.

HubSpot connection

Create a private app in HubSpot (Settings → Integrations → Private Apps) with the scopes crm.objects.contacts.read and crm.objects.companies.read — plus crm.objects.contacts.write if you want Log in HubSpot to write the internal note back, or Log as Task in HubSpot to put a messenger message on the calendar, since HubSpot files both notes and tasks under the contacts scope. Paste its token here. It is stored in this browser only.

HubSpot's API sends no CORS headers, so a browser refuses every direct call to it. A tiny proxy has to sit in between. This project ships one, and it deploys with the app — nothing to maintain separately.
How to deploy the proxy — once, about three minutes

A · The app already runs on Cloudflare Pages from Git — the easy case.

  1. The file functions/hubspot/[[path]].js is already in the repository. Pages picks up anything under functions/ at the repo root automatically — there is no code editor to find in the dashboard, and no separate Worker to create.
  2. Commit and push. The next deployment publishes the app and the proxy together.
  3. Leave this field empty. The app then calls its own domain, /hubspot/…, which is same-origin — CORS never even applies.
  4. Check it worked: open https://<your-domain>/hubspot/crm/v3/objects/contacts. A JSON error about a missing Authorization header is the good answer — it proves the proxy is live. A 404 means the deployment has not picked it up yet.

B · Anywhere else — opened from a file, served from localhost, or hosted somewhere without Functions.

  1. Cloudflare dashboard → Workers & PagesCreateWorkersCreate Worker. Name it hubspot-proxy, then Deploy.
  2. On the Worker's page click Edit code, delete the template, paste the contents of deploy/hubspot-proxy-worker.js from this project, then Deploy again.
  3. Settings → Variables and Secrets → add ALLOWED_ORIGINS with the origins that may call it, comma-separated, e.g. https://followup-studio.cxlever.io,http://localhost:8777.
  4. Copy the Worker URL (https://hubspot-proxy.<subdomain>.workers.dev) into this field.
  5. One Worker can serve every CxLEVER app that talks to HubSpot. List each app's origin in ALLOWED_ORIGINS, separated by commas, and paste the same URL into each app. The Worker holds no token of its own, so nothing is shared between the apps except the route.

Already connected a Worker to Git and it will not take variables? Cloudflare then says “Variables cannot be added to a Worker that only has static assets” — wrangler deploy found no wrangler.toml, so it published the repository as files instead of running the script. Route A makes that Worker unnecessary; disconnect or delete it.

Either way the proxy stores nothing and holds no secret. Your private app token travels in the request header from this browser, exactly as it would to HubSpot directly, and the only paths it forwards are reads plus two writes: creating a note and creating a task, both on a contact. A leaked URL can therefore add a note or a task and nothing else — it cannot edit a contact, move a deal or delete anything.

Gmail draft — optional, creates the e-mail as a draft in your own Gmail

The finished e-mail is pushed straight into your Gmail drafts, formatting and subject already in place — you open Gmail, read it once and hit send. Google requires one thing that cannot be shipped inside an app: a Client ID of your own. It takes about five minutes, once.

Stored in this browser only. The access token Google returns is kept in memory, never written to disk, and expires by itself.
How to get the Client ID — once, about five minutes

Google renamed these screens: what used to be OAuth consent screen is now Google Auth Platform, with its own left-hand menu — Overview, Branding, Audience, Clients, Data Access. The steps below follow that menu.

  1. Open console.cloud.google.com/auth/overview ↗ and sign in with the Google account whose drafts you want to write into.
  2. Top left, next to the logo, open the project picker → New project → name it Follow-Up StudioCreate. Make sure it is the selected project afterwards.
  3. On Google Auth Platform → Overview you will see “Google Auth Platform not configured yet” — press Get started. Fill in: App name Follow-Up Studio, User support email your address → Next. Audience: Internal if this is a Google Workspace account, External for a private gmail.com address → Next. Contact information: your address → Next → agree → Create.
  4. Enable the API: open the Gmail API page ↗ in the same project and press Enable. (Or: APIs & Services → Library, search Gmail API.)
  5. Google Auth Platform → Data AccessAdd or remove scopes. Filter for gmail.compose, tick .../auth/gmail.composeUpdateSave. That scope creates drafts and cannot read your mail.
  6. External only: Google Auth Platform → Audience → under Test users press Add users and add your own address → Save. While the app stays in Testing only those users may use it — for a private tool that is exactly right, and no Google review is needed.
  7. Google Auth Platform → ClientsCreate client. Application type Web application, name Follow-Up Studio.
  8. Under Authorised JavaScript origins press Add URI and enter the address this app runs on — no path, no trailing slash: https://followup-studio.cxlever.io. Leave Authorised redirect URIs empty → Create.
  9. Copy the Client ID (it ends in .apps.googleusercontent.com) and paste it into the field above.
  10. Press Create the draft in Gmail. Google asks for permission once; after that it is silent. The draft appears in Gmail under Drafts.

This only works when the app is served over https — Google refuses a file:// origin. On this build the origin to register is .

Nothing passes through a CxLEVER server: the browser talks to Google directly, the token lives in memory for the length of the session, and Disconnect throws it away.

Filled automatically from the contact's primary e-mail in HubSpot - the one on the Email property, not any secondary or work address they may also have. Worth a glance before you send, because HubSpot keeps whichever one was written first. Leave it empty and the draft is created without a recipient.
Comma-separated. Every saved address sits in the list above — pick one to add it, star one to have it there on open, and remove one with the × on its row.
Where HubSpot’s logging address belongs
This field is the place for HubSpot’s BCC address (outgoing): HubSpot → Settings → Objects → Activities → Email → Connected accounts / Log & track. Mail BCCed to it is attached to the matching contact, and a contact is created when none is found. It is stored here and put on every draft, so logging never depends on you remembering it.
About the signature list
Keep as many as you need — a short one for messengers, a full one with the legal footer, one per language. The default is loaded when the app opens; whatever stands in the box is what goes into the draft, so a one-off edit needs no saving.
Why Gmail’s own signature is missing
Gmail only inserts your saved signature when you open the composer — a draft created through the API arrives without it, and Google offers no way to read it without a far wider permission. Paste it once here and every draft carries it. The box keeps what you paste — weights, sizes, colours, links, a logo — because it is a real editor and not a plain text field; only scripts are stripped.
How long Google’s permission lasts, and what Disconnect does
Google grants access for about an hour at a time and, in a browser-only app like this one, offers no way to extend it — so the permission window appears once per hour at most, and a reload no longer costs you one. Disconnect Google throws away the access token this browser is holding — the next draft then asks Google for permission again. Needed when you switch Google account, or when you are finished on a shared machine. Your Client ID, CC list and BCC stay.
* What these two URL fields are for

Paste the actual website/webpage content or scrape(s) into 2 · Inputs below. The URL alone is only a hint for the AI — pasted content is what stops it from inventing things.

The HubSpot import fills both from the contact’s associated companies — the primary company first, the second company after it. With only one company, the second field takes that company’s other URL when its website and domain differ.

2 · Inputs — Transcript, Website, CRM

The message can only be as deep as what you paste here. Block A is mandatory; B and C are what make the mirror paragraph land — every fact the AI uses from them is listed back to you in the internal note.

Drag & drop one or more files straight onto this box — .txt, .md, .vtt and .srt subtitles, .json, .csv. Several files are appended one after another, each under its own filename header.
Reading transcripts out of Google Drive — once, about five minutes

The picker uses the same Google Cloud project and the same Client ID as the Gmail draft, so if you have done that, most of this is already behind you. Two more values are needed, both from that same project.

  1. Open the Google Picker API page ↗ in that project and press Enable. Then do the same for the Google Drive API ↗. Both are needed: the first draws the window, the second hands over the file.
  2. APIs & Services → Credentials → Create credentials → API key. Copy it into the field below.
  3. Press Edit API key and restrict it, because this key sits on a public page: Application restrictionsWebsites → add . API restrictionsRestrict key → tick Google Picker API and Google Drive API. Save.
  4. IAM & Admin → Settings. Copy the project number — the long row of digits, not the project ID — into the second field below.
  5. Google Auth Platform → Data Access → Add or remove scopes. Filter for drive.file, tick .../auth/drive.file, Update, then Save.
  6. Press Take it from Google Drive. Google asks once; after that it is quiet for the rest of the session.
Why you are not searching Drive inside this app
Because the scope that allows it, drive.readonly, lets this app read everything in the Drive it is pointed at, and Google classes it as restricted: a project using it cannot leave testing mode without app verification, and a security assessment on top if data crosses a server. drive.file grants nothing until you pick a file, and then grants exactly that file - so there is nothing to verify and nothing to explain in a client's security review. The search you wanted is inside Google's window, and it is Google's own.
What this fills, and what it will not
It reads block A only, and writes a field only when the model returns a verbatim quote from the transcript that the app then finds in the text itself — a plausible-sounding quote that is not there is thrown away with the value it carried. Fields you have already filled are never touched. Anything the call does not say is left empty and listed, because an empty field is honest and a wrong one is not. The preset is a reading rather than a quote, so it is offered with its reasoning and applied by one click.
Dropping pages, and what the scrape buttons do
Drop saved pages or scrapes here — .html files are reduced to their readable text automatically. The scrape buttons read the site’s own sitemap, group the pages by section, and take only what you tick — through the same proxy the HubSpot import uses.
Where everything you paste goes
Nothing you paste leaves this browser unless you press Generate in the Live Generate panel — and then it goes straight to the AI provider you chose, never to a CxLEVER server.
3 · Preset & Next Step

The preset decides the architecture of the message — which blocks appear, in which order, and how long each piece may be.

What the two short pieces are for
Short stands on its own, for prospects who get no e-mail. The note right after the e-mail does one job: it says the detailed analysis has just landed in their inbox, names the topics it covers, and invites a reply.
Where the emphasis rules apply
The last two shape the e-mail only. Copy E-Mail keeps that formatting when you paste into Gmail, Outlook or HubSpot; the plain-text and LinkedIn versions drop it, because neither channel renders it.
Why this has its own field
A follow-up that forgets what you promised is the one thing a prospect always notices. Named here, the message delivers it explicitly — and the AI stops inventing attachments that do not exist, because anything not in this field is not an attachment. Read the transcript fills it from what you actually said on the call.
How these two blocks are used
Both are written into the message only if the inputs support them — the NDA sentence asks whose template to use, and the call attempt names the number you actually tried, taken from the CRM block.
How this differs from the field above it
The field above shapes the closing paragraph: who sits at the table and what you need before the meeting. This one is for content the AI must weave in wherever it fits — a commitment made on the call, a name to credit, a constraint to acknowledge.
Which model to paste this into
For the deepest messages, paste this prompt into one of the latest top models — e.g. Claude Opus 4.8, Gemini 3.1 Pro, GPT-5.x or Mistral Large — then paste the JSON it returns into the Spec editor below.
Follow-Up Spec Editor

The message set as JSON — the file everything else is rendered from. You rarely need to open this: generate below, edit in the preview. It is here for writing a message by hand, for pasting JSON back from an AI, and for reading the error when something does not parse.

Live Generate (BYOM) & Refine
What Live Generate does with your key
Runs the same prompt against your own AI provider. Everything is optional — the app works fully offline without it. Keys are stored only in this browser and sent directly to the provider you pick, over HTTPS. Nothing else leaves your machine.
A full message set (e-mail plus the LinkedIn versions and the internal note) needs roughly 4,000–8,000 output tokens. Too low truncates the JSON.
Using a thinking model? Give it more room
A reasoning model (Gemini 3 Pro, o-series, Claude with extended thinking) spends part of this cap on its own reasoning before it writes a single character of the answer — and the cap covers both. At 8,000 it can think itself out of room and return half a message set, or the tail of one. For those models set 16,000 and leave it there; the number is a ceiling, not a bill, so a short answer still costs a short answer. When it does run out, the app says so, names how many tokens went into the reasoning, and retries once with double the room.
Quality Check
What this check looks at
The message set is scored against the CxLEVER follow-up rules — salutation, capitalised Du-forms, forbidden phrases, double punctuation, bullet symbols, sign-off, length, one CTA — plus a hallucination check that flags every number and every product or vendor name in the message that appears nowhere in your inputs.
Tone of Voice / House Rules — Sender, Forbidden Phrases, Subjects

Your standing rules. They go into every prompt and every quality check, so the app enforces the same style each time.

Which of them ends up in the sign-off
Not necessarily the whole line. You are writing to a city, so the app never greets a prospect in Berlin “aus Berlin”: with Berlin/München set, a contact in Berlin is greeted from Brandenburg/München, one in München from Berlin/Brandenburg, one anywhere in Brandenburg from Berlin/Brandenburg. Any other list simply loses the city you are writing to. Type your own if none of these fits — the field is free text and takes what you write.
Tone-of-Voice Profiles

Keep your own tone-of-voice prompts here and pick the one this message should follow. The AI receives the profile name, the link and — if you paste it — the text itself. Paste the text: no AI can open your Google Doc from inside this app.

Versions & Snapshots

Save snapshot stores a dated copy of the current message set with its quality score. Diff shows exactly what changed against the set you have open now — useful to compare the draft you generated with the one you actually sent. Snapshots live in this browser only.

What a backup holds, and why you want one
Everything the app knows lives in this one browser. Clear the profile, switch machine or lose the laptop and the house rules, the tone-of-voice profiles, the signatures, the saved CC addresses, the starred defaults and every snapshot go with it - there is no server holding a copy, by design. The backup is a single JSON file carrying all of it, including your API keys and the HubSpot token, so treat it like a password file. Restoring writes key by key and leaves anything the file does not mention untouched, which means you can merge a backup from another machine instead of replacing what is here.
Run Diagnostics

Self-test of the app's dependencies and current config. No data is sent unless you click “Test connection” above.

Off, and empty, unless you fill both in. Nothing is sent until then.
Exactly what would leave this browser
Five fields, and no more: the app name, its version, the storage schema number, an error code such as hs_write_403 or ai_json_missing, a one-word context label such as note or gemini, and a timestamp. Never a name, never a company, never a transcript, never a message, never a key, never a URL you scraped - the context label is stripped to letters, digits, dots, dashes and underscores before it goes anywhere, so a stray sentence cannot ride along inside it. Only https endpoints are used. It is fire-and-forget: it never retries, and a telemetry failure can never become a failure you see. Leave it off and the app behaves exactly as its privacy note says - nothing leaves this browser but the calls you ask for.
Message Preview
CxLEVER Follow-Up Studio v1.21.2 · spec schema v1.1 · offline core
Made with in Germany. Powered by CxLEVER.