# Message new LinkedIn connections

You have access to your own LinkedIn session, a web scraper, a job
postings search, and a handful of tables you can read and write. Run
this **[on weekday mornings]** for **[the connections your outbound
invitations brought in]**.

## 0. Set up (skip if you're already connected)

If you don't already have live tool access for this, connect it
first. This prompt is only as real as the tools behind it:

1. Get a Tulina account: book a demo at https://tulina.ai/book.
2. Add Tulina's MCP server to your assistant: https://mcp.tulina.ai/mcp
3. In Tulina, connect your own API keys for the tools this
   prompt uses: your LinkedIn account, web scraping, and job postings
   search.

Once connected, your assistant has real tool access and the rest of
this prompt runs as written.

## 0b. Already on Tulina? Three things around the run

An account and the MCP server are not the whole setup. Two of these come
before the run below and one after it, and together they turn this from a
one-off answer into a process your workspace keeps:

1. **Activate this process's connectors first.** Call
   `oto_connector(op="list")`, match it against the tools named in step 3
   above, and `oto_connector(op="select", name="...")` every one that
   isn't active yet. Selecting a connector does not mount its tools in the
   conversation you're already in — reach them through `oto_call` for this
   run, or open a fresh conversation once they're all on.
2. **Attach the work to an existing project.** `oto_project(op="list")`
   shows your active org's projects: pick the one this work belongs to
   rather than opening another, and keep its id. The project is where this
   process, the tables it writes to and the connectors it uses hang
   together.
3. **When the run is done, save it as a process — with its graph.** Write
   the body with `oto_procedure(op="set", ...)`, then attach it with
   `oto_project(op="link", project_id=..., target_type="procedure",
   target_ref="<your slug>")`. The body has to carry a drawing: read
   `oto_guide(op="read", slug="procedure-flowchart")` and follow it
   exactly — ONE untagged fenced block in that grammar, opening with the
   trigger and a quoted example of what you'd type to start a run. Tulina
   parses that drawing back into the graph it renders as the process's
   default view, which is what makes it come out in the same style as
   every other process in the app; a drawing the grammar can't read falls
   back to raw characters instead. Saving a process needs org-admin
   rights — without them, hand the finished body to someone who has them.

## 1. Check the account before spending

### The tables, and the one key they share

Every match in every table runs on the normalized LinkedIn URL:
`https://www.linkedin.com/in/<public identifier>`, no trailing slash,
no query parameters.

| Table | What it holds |
|---|---|
| The pool | One row per person; its status is the truth. A person enters once, is judged once, and is never messaged twice. |
| The approval queue | The only table the reviewer works in: undecided pairs only, never more than ten. |
| The tracking table | One row per person messaged: step, dates of both messages, reply. Written by the run, never by hand. |
| The source lists | The LinkedIn URLs of people in your email campaigns, with their tier, and of people your sourcing tool found. |
| The exclusions | People, domains and companies you never contact: opt-outs, customers, friends, people who replied to a campaign and their colleagues. |
| The decisions log | One dated line per reviewer decision, with its reasons and evidence. Read before every run. |

- The LinkedIn session must answer connected and alive. A seat can
  stay linked while its session is dead, and this check is free.
- The pool must answer a read. If either check fails, stop and say
  which: a run that cannot read the inbox or write the pool spends
  nothing.
- If the LinkedIn tools are missing altogether, the connector is most
  likely paused rather than missing a key. Report it that way.

## 2. List the newly accepted connections

- Read your connections, newest first, asking only for name,
  headline, public identifier, member id and connection date so each
  page stays small. Keep paging until a whole page is already in the
  pool. Dedupe on the member id, never on the page offset: the cursor
  can return the same person twice.
- Look each normalized URL up in the pool and in the exclusions. A
  known person stops there, with one exception: a known row with no
  connection date is an invitation you queued earlier. When it shows
  up as accepted, stamp the date and the row resumes where it was.
- A new person becomes one row: name, headline, company, URL,
  connection date, status `to_draft`, fit `to_judge`. Nothing else is
  written yet.

## 3. Match each one to its outbound source

Compare the normalized URL, in this order:

1. **Your email campaigns' list.** The provenance is the tier on that
   row. Reload the list whenever a campaign starts: a list that lags
   turns a sourced lead into an organic one.
2. **Your sourcing tool's list**, matched on the public identifier.
3. **Neither: organic.** Add the person to the exclusions with the
   reason "organic" and set the pool row to `rejected`. Your personal
   network is out of scope.

**Never guess a provenance.** A connection that looks like a sourced
lead but is on neither list waits in `to_draft` with "source to
confirm, reload the list".

## 4. Close anyone already talked to

This is the step that protects the relationship. For each open row:

- **Find the thread.** List the inbox page by page with the cursor
  and index the one-to-one threads on the attendee's profile URL,
  normalized, once per run rather than once per person. When the
  attendee names did not resolve, fall back on the attendee's provider
  id. **Only a listing paged to the end proves there is no thread**: a
  partial listing raises no error, it just shows fewer people.
- **Read it in full.** Any message written from your account, however
  old, closes the row as `already_contacted`: tick `thread_verified`
  and note the date of your last message. Campaign files are not
  evidence; the inbox is.
- **Decide who wrote each message** by comparing its sender id with
  your own account's id. Never rely on the message's "is sender" flag:
  it has been seen false on every thread of an account.
- **An unreadable thread is not a blank thread.** An error or a
  timeout leaves the row in `to_draft` for the next run.
- **Exclusions.** The person, their company's domain or their URL on
  the list sets the row to `rejected` with the list's reason.
- **One person per company.** When two open rows share a company,
  keep the one closest to the buying decision and reject the other as
  "colleague, one person per company".
- **One channel at a time.** If anyone at that company received an
  email from your campaigns in the last **[7 days]**, or is still in a
  live email sequence, the row waits with "recent email". Read this
  from **[your email send log]**; if it cannot be read, the row waits
  and the report says why.

A row moves on only with `thread_verified` ticked and no exclusion
touching it.

## 5. Research the company, then judge the fit

Load **[your fit guide]** and the decisions log first. A reason that
recurs in the log counts as a rule until the reviewer contradicts it.

1. **Confirm the person is still there.** Read their profile by
   public identifier and check that the identifier returned is the
   one you asked for: a mismatch is a redirect onto someone else.
   Judge the *current* company and correct the row when the profile
   disagrees. Keep the provider id, which sending needs. A response
   saying some sections were throttled is a rate limit, not missing
   data: retry that person later.
2. **Read the profile's description first.** One matching **[your
   standing no-go profiles]** is an immediate `no_go`, however strong
   the lead looks. So is a contact address on a different domain than
   the stated company.
3. **Gather public sources.** Scrape the company's home, pricing,
   customers, team and careers pages, allowing cached copies of stable
   pages. Search its job postings by company domain with a small
   limit: it bills per company record returned, and an empty result is
   normal on small companies, never proof they are not hiring. The
   person's own LinkedIn activity may steer the angle, but it is never
   quoted.
4. **Write the verdict**: `go`, `weak` or `no_go`, with two or three
   signals written one per line as "fact (source: URL)", a reason, a
   trigger and an angle. **A signal without a URL actually opened does
   not exist.** A `no_go` ends the row as `rejected`.

## 6. Write the two messages

Load **[your copy guide]** and re-read the rewrite comments in the
decisions log first. For each `go` or `weak` row, most recent
connection first:

1. **Message one: an observation and its question.** One verified
   public observation about how this company handles the problem you
   solve, from a URL you opened, then one question about their
   workflow that can be answered in a line. No pitch. **The twenty
   test:** if the message could go unchanged to twenty people with
   the same title, rewrite it.
2. **Message two: a different angle.** One question. Never a reminder
   of message one and never an exit line. For a row returned with a
   rewrite comment, the comment outranks everything else.
3. **Run your copy checklist** on both, item by item. A message that
   fails is rewritten, never proposed.
4. **Queue the pair** in the approval queue: name, company, title,
   URL, both messages and a one-line "why" (source, verdict, the
   observation used), decision empty. Store both messages on the pool
   row and set it to `awaiting_decision`.

**At most [ten] proposals per run, and never more than ten undecided
rows in the queue.** A `weak` row gets a shorter, more careful
message, never a generic one. When no observation holds up, the row
stays `to_draft` with "no solid observation".

## 7. Approve, rewrite or drop each pair

A person opens the approval queue and sets one decision per row:

- `send`: both messages go out exactly as written, character for
  character.
- `rewrite`, with a comment: step 6 rewrites it next run and queues
  it again.
- `no`: the person is never messaged. "Exclude" in the comment adds
  them to the exclusions.

For `no` and `rewrite`, the reviewer ticks the reasons and pastes the
evidence. The next run **collects the decisions first**: `send` sets
the pool row to `approved`, `rewrite` to `to_fix` with the comment,
`no` to `rejected`. Reasons and evidence are copied onto the pool row
and appended as one dated line to the decisions log **before** the
row leaves the queue. When the same reason shows up three times, the
report proposes it as a rule for the fit or copy guide, and the
reviewer confirms it.

Make the pool table enforce this where your tables allow it: message
one and `thread_verified` required once a row is `approved` or
`sent`, and a status lifecycle that cannot jump from `to_draft`
straight to `sent`.

## 8. Re-check just before sending

For each `approved` row, in this order. One failure and nothing
sends: the row goes to `to_fix` with the reason, and the report names
the check.

1. **The connection is accepted.** If not, it is a pending
   invitation: the row stays `approved` and is listed under "awaiting
   acceptance". This is the only check that is not a failure.
2. **The copy exists**, both messages.
3. **The thread is blank, read just now.** Never trust step 4, which
   may be days old. An unreadable thread postpones the send.
4. **The person is still in the role.** A changed company sends the
   row to `to_fix` with "no longer there, re-judge on the new
   company". A role check older than **[7 days]** no longer counts.
5. **The email window is clear**, read again now.

**A check that cannot be run counts as a failed check.**

## 9. Send, then follow up once

**Message one**, in the order the decisions were made: send the
approved text unchanged, in the existing thread if there is one,
otherwise to the provider id from step 5. Set the pool row to `sent`
with the date and create the tracking row.

**Message two**, for `sent` rows at least **[5 business days]** old
with no second message: read the thread first. If the person
replied, message two never goes and the conversation belongs to a
person from here on. Otherwise send it exactly as approved and move
the tracking row on.

**Pacing:** at most **[15 messages per run]**, both messages
combined, on business days and within **[your business hours]**. The
rest wait for the next run.

**Tracking**, every run: a reply moves the tracking row to `replied`
with a one-line summary, and colleagues still open in the pool are
rejected as "colleague of someone who replied". A negative reply or
an opt-out puts the person and their company in the exclusions.
Silence after message two: nothing more. Two messages, never three;
after **[10 business days]** the row closes as `ended_no_reply`.

## Rules

- One sender on the account: while this runs, nothing else sends
  messages or invitations from it.
- Nothing sends without a `send` decision on that exact text.
- This process sends no invitations. It only works on connections
  already accepted.
- The inbox is the truth.
- Only a person lifts an exclusion.
- A second run on the same day is a no-op: the shared URL key, the
  thread re-read before every send, the send dates and the terminal
  statuses see to it.

## Output

Report: connections added and their sources; rows closed as already
contacted or excluded; verdicts; pairs queued; decisions collected,
with their reasons; rows blocked by a pre-send check, naming the
check; rows awaiting acceptance; first and second messages sent;
replies received; rows waiting past the caps or on a provenance; any
thread that could not be read. Nothing estimated.
