Prepare a research brief for every cold call
When to use it: a rep has a call list to work and no time to research each account, so the opener collapses into a glance at the website. This puts a short note on every record before anyone dials: what the CRM already knows, one dated signal, what the person has said in public, and a single opener built on both.
Natural language input in Claude
"Brief every contact on this week's call list before anyone starts dialing."
│
▼
┌─────────────────────────────────────────────────┐
│ 1 · Read the call list in one pass │ hubspot_list op=members
│ Two calls per page of members, then one │ hubspot_object
│ company search per page for domains. │ data_rows
└────────────────────────┬────────────────────────┘
▼
╔═════════════════════════════════════════════════╗
║ 2 · Surface any prior contact ║ hubspot_object, data_write
║ Calls, notes and deals on the contact and its ║ a live thread changes the brief
║ company, before research. ║
╚════════════════════════╤════════════════════════╝
▼ every account, with or without a prior thread
┌───────┴─────────────────────────────┐
▼ ▼
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ 3 · Find a dated company signal │ │ 4 · Find the lead's own words │ serper_search, apify_run
│ Jobs and stack from TheirStack, │ │ Talks, quotes and posts by the │
│ news from Serper, each dated. │ │ person, never by the company. │
└────────────────┬─────────────────┘ └────────────────┬─────────────────┘
│ │
└───────┬─────────────────────────────┘
▼ signals and words in hand
╔═════════════════════════════════════════════════╗
║ 5 · Hold the brief to its sources ║ a namesake is worse than nothing
║ Every claim keeps its date and URL; nothing ║
║ found is written as nothing found. ║
╚════════════════════════╤════════════════════════╝
▼
┌─────────────────────────────────────────────────┐
│ 6 · Write one opener │ one line, never three variants
│ Under 25 words, naming the signal, and │
│ readable aloud without sounding read. │
└────────────────────────┬────────────────────────┘
▼
┌─────────────────────────────────────────────────┐
│ 7 · Save the brief on the record │──▶ HubSpot note on the contact the rep dials
│ One note on the contact the rep dials, │ hubspot_object op=add_note
│ updated rather than stacked. │ data_write
└─────────────────────────────────────────────────┘
The order is the point. The CRM is read before anything is bought outside it: an account a colleague spoke to last month needs a sentence about where that conversation stopped, not a well-researched pitch to someone who already said no. Steps 3 and 4 run in parallel for the whole list. The stack and post lookups are batched across the list; the hiring lookup is one call per domain, for the reason given in step 3.
Every run works from a ledger, [your call-brief ledger]: one row per contact, keyed on the contact id and kept from one run to the next. A row holds one pass at a time: the stage stamps and findings of the current pass, plus the id of the brief note already on the record. Each step writes its result there as soon as it has one, so a run that stops is restarted without repeating the per-record CRM loop or paying twice for research it already bought.
Set the ledger up once, before step 1.
data_get_schemaon [your call-brief ledger]. A schema whosekeyiscontact_idmeans the ledger is ready; a table that answers without that key gets it throughdata_patch_schemabefore any write. An unknown-datastore error means this is the first run: data_rows and data_write refuse a table that does not exist rather than creating it, so step 1 would fail on its first read.data_create_datastore, thendata_set_schemawithkeyset tocontact_id(declared as text, the form HubSpot returns ids in) andsignalandwordsdeclared asjson. The key is what makes a write carrying a contact id merge onto that contact's row. Without it every write appends another row, the ledger ends up with several rows per contact, and each resume rule below reads whichever one it meets first. data_set_schema replaces the whole schema, so any later change goes through data_patch_schema, or the key goes with it.oto_resource_v2— op=share withresource_typedatastore_namespace, the table's number asresource_id,audienceteam withgroup_id[the team that works the call list], androleeditor. A new table is private to whoever created it: a teammate's run would not see it, would create a second ledger of its own under the same name, and would pay for the research again. Put the table's number in the process in place of the placeholder, and address the ledger by that number on every call.- Every ledger write in the steps below is
rows=[…]withkey="contact_id", including a write that touches one contact. data_write refuseskeyalongsiderow, and the batch form names the dedup key on the call itself.
1. Read the call list in one pass
hubspot_property— op=list on contacts and companies, first, before any read that names a property. The label in the interface is not the name the API takes, and a LinkedIn URL field is usually a custom property with a portal-specific name. This check has to come first: the batch read behind the next call refuses a whole slice of 100 records withPROPERTY_DOESNT_EXISTwhen one name is wrong. It does not return that column empty.hubspot_list— find the list with op=search by name, then read it with op=members,object_typecontacts andpropertiesset (first name, last name, job title, email, associated company id, owner, last contacted, lead status, and [your LinkedIn URL property]). Withproperties, a page of 100 members costs two calls: a memberships page plus one batch read. Following bare ids with one read per member costs 101, and your portal's burst limit stops that loop part-way down the list, mid-record. Page withafteruntil no cursor comes back.missing_idsnames any member the batch read could not return (deleted, or outside the token's scope). The row stays in the response withpropertiesnull, and the run skips it.- Take the member ids once at the start of the run. A dynamic list recomputes on its own, and a brief set that shifts under the run leaves some records half-briefed.
- The process runs per contact. For a company list, op=members returns company ids, and each company is resolved to the person the rep will call with
hubspot_objectop=search on contacts. Put two filters in the group:INonassociatedcompanyidwith up to 100 of the company ids, andjobtitleCONTAINS_TOKEN[one keyword for the role the rep calls]. This tool sends a single filter group and ANDs it, so a second keyword takes a second search. A company with several matches keeps [your tie-break, e.g. the most recently active contact]. A company with none gets no brief, and the report lists it so the rep can choose someone.associatedcompanyidholds only a contact's primary company, so a contact linked to the company as a secondary association does not come back. hubspot_object— op=search on companies with a singleINfilter onhs_object_id, holding every associated company id from the page, returning name, domain, industry, employee count and owner. That is one search per page of members. The domain is the join key for every step after this one: a company name spelled differently in the CRM and in a signal source matches nothing, and a domain does not have that problem. A company with no domain on file still gets the prior-contact read in step 2, but no outside research, and its note says why.data_rows— the ledger rows for each page of members, withcontact_idinthat page's ids, paged bynext_cursor. A row whosepass_started_atfalls inside [your re-run window, e.g. 7 days] is a pass still in progress. A contact withbriefed_aton it is done and skipped. Any other resumes at its first empty stamp (prior_read_atfor step 2,researched_atfor step 3,words_atfor step 4), or goes straight to step 5 when all three are filled, and rebuilds the brief from theprior_summary,signalandwordsof the stages already done.data_write— every other contact (no row, or a pass older than the window) starts a new pass, in one batch per page:contact_id,pass_started_atset to now, and"@clear"on each per-pass field (prior_read_at,prior_summary,researched_at,signal,posts_run_id,posts_dataset_id,words_at,words,briefed_at). Without the clear, last month's stamps read as stages already done this time. The note fields stay:note_idnames the brief already on the record, which step 2 must ignore and step 7 updates, and anote_statusofunconfirmed(with itsnote_attempted_atandnote_first_line) still has to be settled in step 7.
2. Surface any prior contact
hubspot_property— op=get withobject_typecalls andproperty_namehs_call_disposition, once per run. A call's disposition is stored as an opaque id, and the property's options map each id to the label the brief can use ("connected", "left voicemail"). If the options come back empty, leave the disposition out and let the call body speak.hubspot_owners— once per run, mapping owner ids to names. It takes no parameters and returns one page of up to 100 active owners, and it cannot ask for the next page. Apaging.nextin the answer shows the page was cut. An owner id that is not on the page belongs to an owner past the first 100 or to a user who has since been deactivated. The note then says "another owner (id [id])" and does not drop the fact: the rep needs to know someone else holds the account more than they need the name.- Work the contacts still to read in chunks of [25]. Each chunk runs the reads below and ends with one ledger write.
hubspot_object— op=get on each contact withassociationsset to calls, notes, meetings, emails and deals, and op=get on each company withassociationsset to calls, notes, meetings and deals. Read the company, not only the contact: a deal created from the company record, or a note logged on it, is associated with the company alone, and a contact-only read misses the open deal another rep is working. Read each company once, even when several contacts on the list share it.- This is the one per-record loop in the process, and the tools force it: associations come back inline only on op=get, with no batch shape. Pace it under [your portal's burst limit]. The connector absorbs a short-window 429 with a few retries whose total wait is capped, then returns it as a named refusal. A 429 for a daily quota is not retried at all. Either refusal stops the run, and every chunk already written to the ledger stays done.
hubspot_object— op=search on contacts with two filters in one group, which are ANDed:INonassociatedcompanyidholding the chunk's company ids, andnotes_last_contactedGTE[the start of your recency window, e.g. 90 days ago]. Only colleagues who were actually reached come back, with their owner. Page withafteruntilpaging.nextis absent: a search page holds 100 rows, and a few reached colleagues per company can run past it with no warning. Each colleague found gets the same op=get with associations, inside the same paced loop.hubspot_object— the engagements themselves are read in batches, not one op=get per id. Pool every engagement id from the contact, company and colleague reads and drop duplicates (the same call is often associated with both a contact and its company), then run one op=search per engagement type (object_typenotes, calls, meetings, emails) with anINfilter onhs_object_idholding up to 100 ids. Ask for call body, disposition and timestamp; note body and timestamp; meeting title and outcome; email subject and direction.- Take the process's own briefs out before judging anything. Drop the row's stored
note_idfrom the pooled note ids, and after the notes search drop every note whosehs_note_body, with tags stripped and whitespace collapsed, begins with [Call brief]. That catches a brief from an earlier pass, one from an attempt the ledger never confirmed, and one written on a colleague who sits on another list. Left in, last run's brief is a note on the contact dated inside the recency window, and every contact briefed before comes back as a follow-up to the process's own note. Whatever is left inside the recency window is a live thread. hubspot_object— op=search on deals with anINfilter onhs_object_idholding the pooled deal ids, returningdealname,hs_is_closed,dealstageand the deal owner. Decide "open" fromhs_is_closed, not from a stage label: stage names differ per pipeline, andhubspot_propertyreturns no options fordealstageto read them from.data_write— the chunk's rows in one batch,rows=[…]withkey="contact_id":prior_read_at, and aprior_summaryholding the last thread's date, who spoke, the outcome, any promised next step, and any open deal or other owner by name. A run that stops loses at most the chunk in flight, and a restart reads the summary back instead of repeating the loop.- An open deal, or a contact or company owned by another rep, goes at the top of the note. The rep decides whether to call at all; the note just makes sure they know. When there is a thread, the brief changes job: it leads with the date, who spoke, the outcome and whatever next step was promised, and the opener in step 6 picks the conversation up from there. The external research still runs, one rank lower.
3. Find a dated company signal
theirstack_jobs_search— one call per account, with that account's domain as the only entry incompany_domain_orinsideextra. By default the response gives each posting onlycompany,job_title,date_posted,urlandlocation. It has no domain. A call carrying several domains therefore returns postings you can match back only by TheirStack's company name, which is the match the domain is there to avoid, and a posting lands on the wrong account. With one domain per call, every posting belongs to that account because of how the call was made.full=truewould addcompany_domainto each posting, and it would also add every posting's full description, which the brief never uses.- In the same call, put
job_title_orinextrawith [keywords for the function you sell into], setposted_at_max_age_daysto [60], and setlimitto [5].limitis the cost bound, now per account, so one employer with many postings cannot use up the budget meant for the rest of the list. TheirStack returns postings newest first by default, so the first row is the signal, and a full page just means the account is hiring. No second page is needed. - An empty
datais a normal answer on a small company, not an error. Credits are counted per record returned, so it costs a call and nothing more. Do not retry it; record that there was no posting and move on. Ametadata.truncated_resultsabove zero means the credit balance cut the answer, and the next page will not bring those rows back: report the shortfall and do not page for it. A 429 comes back without a retry from the connector, so wait and resend that one domain. theirstack_companies_search— this one stays batched. Send a chunk of [about 20] domains ascompany_domain_or, withcompany_technology_slug_orset to [the tools you integrate with or replace], both inextra. Only accounts that actually run one of those tools come back. Its default output carriesdomainnext totechnology_names, so each row joins to its account directly. It returns one row per company, so alimitequal to the chunk size holds every match on one page. Ametadata.truncated_companiesabove zero means the credit balance cut the answer, not the filter.serper_search— kind=news, the company name in quotes plus its domain or one distinguishing word,tbsset to the past year. Setcountryandlanguageexplicitly to the account's market: the tool's defaults target one market, and an account in another language searched with the defaults returns the wrong edition of Google News or nothing at all. Keep a result only when the snippet or page ties it to this company by domain or by that distinguishing word, because a common company name returns another company's funding round with complete confidence.- Rank what survives by recency first, then specificity: a posting for the function you sell into from last week beats a funding round from last year. Every signal keeps its date and its URL.
data_write— once an account's jobs, stack and news lookups are all in, itssignal(text, date and URL, or "none found") andresearched_atgo onto the row of every contact at that account, asrows=[…]withkey="contact_id". A restart skips an account whose rows already carryresearched_at, which would otherwise pay for the lookups twice. That stamp marks step 3 and nothing else: it says nothing about step 4, which keeps its own.
4. Find the lead's own words
serper_search— first, and only for a lead whose LinkedIn URL property read in step 1 is empty: kind=web withsite_filterset to linkedin.com/in and the name plus company, kept only when the result's title names the same company. It comes first because the posts run below needs every profile URL when it starts.apify_run— the leads' recent public posts, [the last 90 days] only, started asynchronously. Pick one profile-posts actor withapify_store_searchonce and pin its id in the process, rather than choosing a new one per run. Send every profile URL still to research (a row with neitherwords_atnorposts_run_id) in onerun_input, not one run per lead, and setmax_items,max_total_charge_usdandtimeout_secsso neither a pay-per-result actor nor a stuck run can overspend. Do not use the synchronous call for this. A whole list's scrape is exactly the job that runs past its 300 seconds, and the 408 it answers ends the wait, not necessarily the run. That error carries no run id, so a run still going on Apify's side can be neither collected nor aborted from here, and a retry pays for the scrape a second time.data_write— the returned runidanddefaultDatasetIdgo onto every row the run covers right away, asposts_run_idandposts_dataset_id. This is the step's resume rule: a row withposts_run_idand nowords_athas a run already paid for, so a restart collects that run with the two calls below and never starts another for that lead.serper_search— while the run goes: the person's full name in quotes with the company name, kind=web, then kind=videos for talks and podcast episodes. Accept a result only when something corroborates it: the same company, the same role, the same field. A namesake quoted on a call is worse than having nothing. These results are saved with the posts at the end of the step, so a restart before then repeats the searches. They are cheap to repeat; the posts run is not, which is why its id is saved the moment it starts.apify_run_status— poll withwait_for_finish(up to 60 seconds per call) until the status is SUCCEEDED, FAILED, TIMED-OUT or ABORTED.usageTotalUsdshows what it has cost so far. A run that is clearly stuck is stopped withapify_abort_run, which ends the billing. A run that did not succeed still keeps what it pushed, but a lead with no posts in it cannot be told apart from a lead the run never reached: that lead'swordsrecords that its posts were not checked, its brief says so rather than that the person does not post, and the report lists it.apify_dataset_items— readposts_dataset_idwithfieldsset to the post text, date, URL and [the actor's author profile URL field]. Match each post to its lead by that author URL, normalized the same way as the input: lowercase, with no query string and no trailing slash. A post whose author URL matches no lead is dropped, never assigned to the closest name.data_write— for each lead whose posts and searches are in,words_atandwords: every statement kept, each with its text, date, URL and whether the person or the company said it.words_atis this step's own stamp. The signal lookups in step 3 finish long before the posts run, soresearched_atis almost always set while the run is still going and cannot say whether this step is done. With what was kept saved on the row, a restart rebuilds the brief without buying anything again.- Never attribute to the person what the company said. A press release is the company talking. A repost with no comment of its own is not the person's words. A quote in an article attributed to them by name is, and it keeps the article's URL. Where only the company's words exist, label them as the company's.
5. Hold the brief to its sources
A gate before anything is written, applied per account:
- A signal with no date or no URL is dropped. The rep has to be able to open the source when the prospect pushes back.
- Every public statement is labeled as the person's or the company's, and nothing from step 4 survives without corroboration.
- If nothing dated turned up, the note says so in one line: "No dated signal found. Open on the problem." A note padded with industry commentary teaches the rep that the notes are decoration, and after the third one they stop reading the good ones too.
- Personal details a search turns up (health, family, anything off the business thread) never go into a CRM note that the whole team reads.
6. Write one opener
One line under 25 words that names the specific signal and ties it to the reason for the call, in the language the lead works in. It must read aloud without sounding read. One opener, never three: a rep scanning options while the phone rings picks none of them. When step 2 found a live thread, the opener picks up where that conversation stopped rather than introducing the company. The raw signal, with its date and URL, sits directly under the opener in the note, so the rep can improvise from the fact itself when the call goes somewhere the opener did not.
7. Save the brief on the record
- Settle every unconfirmed note first, whatever the dates on the row say. A row whose
note_statusisunconfirmedholds a note call that may or may not have landed. A restart inside the window skips step 2, so this check does not lean on step 2's reads.hubspot_object— op=get on the contact withassociations["notes"], then op=search withobject_typenotes, anINfilter onhs_object_idholding those note ids (100 per search),hs_timestampGTEthe row'snote_attempted_at, in epoch milliseconds, less [a margin for clock drift, e.g. 10 minutes], andhs_note_bodyreturned. A note whose body, with tags stripped and whitespace collapsed, begins with the row's storednote_first_lineis the note that call wrote. Its id goes onto the row asnote_idwithnote_statuswritten, plusbriefed_atwhennote_attempted_atis later than this pass'spass_started_at. No such note means the call never landed: clearnote_statusand brief the contact below. - Compose the body in this order: the prior thread or open deal, if any; the opener; directly under it, the raw signal with its date and URL; what the person has said, labeled; one line on anything else the CRM already knew; and a last line saying the note is rewritten on every pass, so a rep logs their own notes separately.
- Write the body as HTML.
hs_note_bodyis stored and displayed as rich text, so plain newlines collapse the brief into one run-on paragraph. Use one<p>per section,<br>between lines inside a section, and an<a href>for each source. The first paragraph is always [Call brief] and the run's date and time. It is what a reader scanning the timeline sees, what step 2 filters on and what the check above matches, so keep it to letters, digits, spaces, colons, brackets and hyphens: nothing that HTML escaping could rewrite. - A contact with no
note_idgets a new note.data_write— before the call, a one-rowrows=[…]withkey="contact_id"gives the rownote_statusunconfirmed,note_attempted_atset to now, andnote_first_lineholding that first paragraph's exact text. Thenhubspot_objectop=add_note withobject_typecontacts on the contact the rep dials. When the answer comes back, one more write puts itsidinnote_id, withnote_statuswrittenandbriefed_at, before the next contact. Because the attempt is written first, a run that dies during the call, and not only one that gets an error back, leaves a row the check above settles. - A 5xx on op=add_note is not retried by the connector, because the error does not say whether the note was written. Leave the row
unconfirmed, do not add a second note in this run, and let the next run's check settle it. HubSpot's search index trails a new note by a few seconds, so a check straight after the failure can miss a note that was written. - A contact with a
note_idalready carries a brief from an earlier pass, and it is updated rather than stacked.hubspot_object— op=update withobject_typenotes,object_idthe storednote_id, andproperties{hs_note_body: the new HTML,hs_timestamp: now}, so the refreshed brief moves to the top of the timeline; thenbriefed_atgoes onto the row. This call can be resent after a 5xx or a run that died: it writes the same body again and never creates a second note. A 404 means someone deleted the brief: clearnote_idand take the new-note path. - A contact whose row has
briefed_atis done for this pass and is skipped. When a person asks for a refresh inside the window, the contacts they name start a new pass in step 1 and land on the update path above. - The note goes on the record, not into a chat message or a document. The rep has the record open at the moment they dial, and a brief that lives anywhere else is a brief nobody finds while the phone is ringing.
Output
Report: how many contacts were briefed, how many briefs were new notes and how many updated an earlier one, how many had a dated signal and how many were flagged as having none, how many had a prior thread or an open deal at the top of the note, how many public quotes were kept or dropped for lack of corroboration, which leads' posts were not checked, which accounts got no outside research for lack of a domain, which companies on a company list had no matching contact, which owner ids could not be named, any TheirStack or Apify shortfall, and which contacts the ledger still shows as unfinished or unconfirmed, so the next run picks them up.