← toutes les doctrines
salesoto

Prepare a research brief for every cold call

Reads each contact on a HubSpot call list, finds recent company news or hiring and the person's public quotes, and saves a brief with an opener.

connecteurs utilisés

Ddata6
Ooto1
HubSpotHubSpot4
TheirStackTheirStack2
SerperSerper1
ApifyApify5

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_schema on [your call-brief ledger]. A schema whose key is contact_id means the ledger is ready; a table that answers without that key gets it through data_patch_schema before 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, then data_set_schema with key set to contact_id (declared as text, the form HubSpot returns ids in) and signal and words declared as json. 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 with resource_type datastore_namespace, the table's number as resource_id, audience team with group_id [the team that works the call list], and role editor. 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=[…] with key="contact_id", including a write that touches one contact. data_write refuses key alongside row, 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 with PROPERTY_DOESNT_EXIST when 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_type contacts and properties set (first name, last name, job title, email, associated company id, owner, last contacted, lead status, and [your LinkedIn URL property]). With properties, 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 with after until no cursor comes back. missing_ids names any member the batch read could not return (deleted, or outside the token's scope). The row stays in the response with properties null, 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_object op=search on contacts. Put two filters in the group: IN on associatedcompanyid with up to 100 of the company ids, and jobtitle CONTAINS_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. associatedcompanyid holds 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 single IN filter on hs_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, with contact_id in that page's ids, paged by next_cursor. A row whose pass_started_at falls inside [your re-run window, e.g. 7 days] is a pass still in progress. A contact with briefed_at on it is done and skipped. Any other resumes at its first empty stamp (prior_read_at for step 2, researched_at for step 3, words_at for step 4), or goes straight to step 5 when all three are filled, and rebuilds the brief from the prior_summary, signal and words of 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_at set 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_id names the brief already on the record, which step 2 must ignore and step 7 updates, and a note_status of unconfirmed (with its note_attempted_at and note_first_line) still has to be settled in step 7.

2. Surface any prior contact

  • hubspot_property — op=get with object_type calls and property_name hs_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. A paging.next in 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 with associations set to calls, notes, meetings, emails and deals, and op=get on each company with associations set 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: IN on associatedcompanyid holding the chunk's company ids, and notes_last_contacted GTE [the start of your recency window, e.g. 90 days ago]. Only colleagues who were actually reached come back, with their owner. Page with after until paging.next is 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_type notes, calls, meetings, emails) with an IN filter on hs_object_id holding 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_id from the pooled note ids, and after the notes search drop every note whose hs_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 an IN filter on hs_object_id holding the pooled deal ids, returning dealname, hs_is_closed, dealstage and the deal owner. Decide "open" from hs_is_closed, not from a stage label: stage names differ per pipeline, and hubspot_property returns no options for dealstage to read them from.
  • data_write — the chunk's rows in one batch, rows=[…] with key="contact_id": prior_read_at, and a prior_summary holding 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 in company_domain_or inside extra. By default the response gives each posting only company, job_title, date_posted, url and location. 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=true would add company_domain to each posting, and it would also add every posting's full description, which the brief never uses.
  • In the same call, put job_title_or in extra with [keywords for the function you sell into], set posted_at_max_age_days to [60], and set limit to [5]. limit is 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 data is 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. A metadata.truncated_results above 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 as company_domain_or, with company_technology_slug_or set to [the tools you integrate with or replace], both in extra. Only accounts that actually run one of those tools come back. Its default output carries domain next to technology_names, so each row joins to its account directly. It returns one row per company, so a limit equal to the chunk size holds every match on one page. A metadata.truncated_companies above 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, tbs set to the past year. Set country and language explicitly 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, its signal (text, date and URL, or "none found") and researched_at go onto the row of every contact at that account, as rows=[…] with key="contact_id". A restart skips an account whose rows already carry researched_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 with site_filter set 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 with apify_store_search once 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 neither words_at nor posts_run_id) in one run_input, not one run per lead, and set max_items, max_total_charge_usd and timeout_secs so 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 run id and defaultDatasetId go onto every row the run covers right away, as posts_run_id and posts_dataset_id. This is the step's resume rule: a row with posts_run_id and no words_at has 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 with wait_for_finish (up to 60 seconds per call) until the status is SUCCEEDED, FAILED, TIMED-OUT or ABORTED. usageTotalUsd shows what it has cost so far. A run that is clearly stuck is stopped with apify_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's words records 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 — read posts_dataset_id with fields set 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_at and words: every statement kept, each with its text, date, URL and whether the person or the company said it. words_at is this step's own stamp. The signal lookups in step 3 finish long before the posts run, so researched_at is 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_status is unconfirmed holds 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 with associations ["notes"], then op=search with object_type notes, an IN filter on hs_object_id holding those note ids (100 per search), hs_timestamp GTE the row's note_attempted_at, in epoch milliseconds, less [a margin for clock drift, e.g. 10 minutes], and hs_note_body returned. A note whose body, with tags stripped and whitespace collapsed, begins with the row's stored note_first_line is the note that call wrote. Its id goes onto the row as note_id with note_status written, plus briefed_at when note_attempted_at is later than this pass's pass_started_at. No such note means the call never landed: clear note_status and 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_body is 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_id gets a new note. data_write — before the call, a one-row rows=[…] with key="contact_id" gives the row note_status unconfirmed, note_attempted_at set to now, and note_first_line holding that first paragraph's exact text. Then hubspot_object op=add_note with object_type contacts on the contact the rep dials. When the answer comes back, one more write puts its id in note_id, with note_status written and briefed_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_id already carries a brief from an earlier pass, and it is updated rather than stacked. hubspot_object — op=update with object_type notes, object_id the stored note_id, and properties {hs_note_body: the new HTML, hs_timestamp: now}, so the refreshed brief moves to the top of the timeline; then briefed_at goes 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: clear note_id and take the new-note path.
  • A contact whose row has briefed_at is 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.

forkez cette doctrine dans votre espace oto.

ajouter à oto