Facebook Pixel
CLI

Sending at a human rhythm

Run a list of LinkedIn actions with randomised spacing between writes — because accounts get flagged on rhythm as much as on volume.

Twenty connection requests is a normal week for a salesperson. Twenty connection requests fired back-to-back at exactly 2.0-second intervals is a script, and LinkedIn can tell the difference. The volume is fine; the rhythm is what gets an account restricted.

ConnectSafely enforces caps server-side, so you cannot exceed a limit from here. But a cap cannot fix the shape of a burst that has already left your machine. That is what batch is for.

The problem with a loop

The obvious way to send thirty invitations is a loop:

# don't do this
while read -r id; do
  connectsafely send-connection-request --profile-id "$id"
done < prospects.txt

That sends thirty requests as fast as the network allows, in a perfectly even cadence. And when an agent is asked to "connect with these thirty people", a loop is exactly what it writes — pacing itself is not something a language model reliably imposes on its own tool calls.

The batch file

One JSON object per line. A command, its args, and an optional ref you can use to join results back to your own records.

{"command":"send-connection-request","args":{"profileId":"ada-lovelace","customMessage":"Loved your talk on analytical engines."},"ref":"lead-1041"}
{"command":"send-connection-request","args":{"profileId":"grace-hopper","customMessage":"Your COBOL retrospective was the best thing I read this month."},"ref":"lead-1042"}
{"command":"visit-profile","args":{"profileId":"alan-turing"},"ref":"lead-1043"}

A JSON array works too, if that is what your tooling produces. Then:

connectsafely batch invites.ndjson

Writes are spaced 30 to 90 randomised seconds apart. Not 60 seconds every time — a fixed interval is as machine-like as no interval at all. Reads are not paced: they cost nothing against your caps, and spacing them out would only make the run slow for no protective gain.

"Read" here means what the command does, not which HTTP verb it uses. A people search is a POST — its filters are too large for a query string — but it changes nothing on your account, so it is not paced and not counted by --kind write. To see the classification for any command:

connectsafely commands --kind write            # everything that changes your account
connectsafely schema search-people | jq '.writes'   # false

Rehearse the whole file first

connectsafely batch invites.ndjson --dry-run

Every entry is resolved and printed; nothing is sent, and nothing waits. This catches a malformed row, a misspelled command or a missing required field across the whole file in one pass, before the first real write.

Reading the results

{
  "summary": { "total": 3, "attempted": 3, "succeeded": 2, "failed": 1 },
  "results": [
    { "index": 0, "command": "send-connection-request", "ref": "lead-1041",
      "ok": true, "status": 200, "waitedMs": 0, "payload": { "success": true } },
    { "index": 1, "command": "send-connection-request", "ref": "lead-1042",
      "ok": false, "waitedMs": 61432,
      "error": { "code": "conflict", "message": "Invitation already sent",
                 "remediation": "The action conflicts with current state…" } },
    { "index": 2, "command": "visit-profile", "ref": "lead-1043",
      "ok": true, "status": 200, "waitedMs": 0, "payload": { "success": true } }
  ]
}

Three things to notice:

  • ref comes back on every row, so you can update your CRM without re-deriving which record each result belongs to.
  • waitedMs shows the actual pause, so you can see the pacing happened and how long the run really took.
  • One failure does not abandon the rest. Row 1 conflicted; rows 0 and 2 still ran. summary.failed is non-zero, and the process exits non-zero, so a pipeline still notices.

Pass --stop-on-error when a later row depends on an earlier one and continuing would compound a mistake. summary.attempted will be lower than summary.total, which is how you tell "everything ran and some failed" from "we stopped early".

Tuning the rhythm

connectsafely batch invites.ndjson --pace 45-120   # slower and wider
connectsafely batch invites.ndjson --pace 60       # a fixed 60s — see the warning below
connectsafely batch invites.ndjson --no-pace       # no wait at all

A word on the last two. --pace 60 and --no-pace both remove the randomness that is doing most of the protective work. They exist for staging environments and for runs of read-only commands. If you reach for --no-pace on real outreach, you are choosing speed over the account — make that a decision, not a default.

The wider you can afford to go, the better. Thirty invitations at --pace 45-120 takes roughly forty minutes. That is a slow afternoon for a script and an entirely ordinary one for a person.

Building the file

batch pairs naturally with a search that wrote NDJSON:

connectsafely search-people \
  --keywords "VP Marketing" --count 25 \
  --fields 'people.profileId,people.firstName,people.headline' \
  --output ndjson > prospects.ndjson

jq -c '{
  command: "send-connection-request",
  args: { profileId: .profileId },
  ref: .firstName
}' prospects.ndjson > invites.ndjson

connectsafely batch invites.ndjson --dry-run   # look first
connectsafely batch invites.ndjson             # then send

Note what did not happen: twenty-five profiles were sourced and turned into calls without any of them passing through an AI model's context. See Cutting the cost of every answer.

Personalisation still matters

Pacing protects the account. It does not make bulk-identical outreach acceptable, and identical text sent to twenty-five people is the single fastest way to earn the complaints that get an account restricted regardless of rhythm.

Write customMessage per row, from something specific in the profile you just read. If you are generating messages with a model, generate them into the batch file first, read them, and then send — which is another reason the file is a file.

On this page