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.txtThat 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.ndjsonWrites 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' # falseRehearse the whole file first
connectsafely batch invites.ndjson --dry-runEvery 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:
refcomes back on every row, so you can update your CRM without re-deriving which record each result belongs to.waitedMsshows 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.failedis 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 allA 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 sendNote 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.
Related
- Not sending the wrong thing — rehearsal, validation, confirmation
- Recipes — complete sourcing and outreach runs
Not sending the wrong thing
Rehearse writes before they happen, catch bad arguments before they leave the machine, and make destructive actions impossible to run by accident.
When things go wrong
Exit codes and error envelopes that tell a caller what to do next — before it reads a single byte of output.
