Kenso
Kenso CLI

Batch runs

Kenso CLI pages

A batch command takes a file, sends its rows to Kenso in chunks, and prints one JSON summary. Every input row gets one line in a results file, every run can be resumed, and nothing is written until you pass --yes.

The batch commands:

  • contacts import, update, remove, restore, pause, resume, tag, untag (Leads)
  • people import and accounts import (CRM, beta)
  • studio fill (Studio, beta)
  • any generated command run with --each <file>, once per row (see below)

Plan, then apply

Without --yes a batch command plans and changes nothing. --dry-run asks for the plan explicitly. --yes applies. Passing both is a usage error. Two exceptions to the plan below: studio fill without --yes prints its credit estimate and next, with no run or plan.json (Studio), and a non-GET --each without --yes or --dry-run is a usage error (exit 2) rather than a plan.

kenso contacts import --campaign 812 --file leads.csv --dry-run
kenso contacts import --campaign 812 --file leads.csv --yes

The dry run parses and checks every row, may call read-only endpoints (a campaign read, a credit estimate, a contacts search for --where), writes plan.json, and prints the plan:

{"run":"r_ubqu7wtx","dryRun":true,"command":"contacts remove","rows":3,"valid":3,"invalid":0,"chunks":1,"chunkSize":1000,"source":"ids","count":3,"firstIds":[11,12,13],"next":"kenso contacts remove --ids ids.txt --yes","plan":"/home/you/.kenso/runs/r_ubqu7wtx/plan.json"}

Every plan has run, dryRun, command, rows, valid, invalid, chunks, chunkSize, next (the exact command that applies it) and plan (the file), plus fields of its own command. It adds invalidRows when rows were refused, chunksDone when it continues an unfinished run, and alreadyDone: true when the apply would change nothing.

The summary

An applied run prints one JSON object:

{"run":"r_ubqu7wtx","rows":3,"ok":2,"created":0,"updated":0,"skipped":{},"failed":0,"notFound":1,"invalid":0,"changed":2,"results":"/home/you/.kenso/runs/r_ubqu7wtx/results.csv","resultsNdjson":"/home/you/.kenso/runs/r_ubqu7wtx/results.ndjson","resumed":false,"durationMs":412,"undo":"kenso contacts restore --run r_ubqu7wtx"}
FieldMeaning
runThe run id: r_ and 8 characters
rowsInput rows
ok, created, updatedRows that succeeded, by kind
skippedRows Kenso left alone on purpose, counted by reason, for example {"duplicate":12}
failed, notFound, invalidRows that did not go through: refused or errored, id not found, refused locally
changedRows this invocation changed. 0 when a rerun had nothing left to do
results, resultsNdjsonAbsolute paths of the results files
invalidRowsPath of invalid.csv, when rows were refused locally
resumedTrue when this invocation continued an earlier run
durationMsWall time of this invocation
pendingRows without a result yet, when the run did not finish
stopped{code, message, hint} when a stop condition ended the run (for example limit_reached)
interruptedTrue after Ctrl-C
nextkenso runs resume <runId> when the run can continue

Commands add their own fields: contacts import adds campaignId and notSendable, contacts remove adds undo, people import adds matched, addedToAccounts and emailFindsQueued, accounts import adds researchQueued and researchWaitingCredits, studio fill adds templateId, runs and the estimate.

Exit codes: 0 when every row succeeded or was skipped, 5 when some rows are failed, not_found or invalid, 6 when a limit stopped the run, 130 after Ctrl-C. The full table is in Output and errors.

The run folder

Each run keeps its files in ~/.kenso/runs/<runId>/ (KENSO_RUNS_DIR moves the root), or in --out <dir> when you pass it. A run in --out is also linked under the runs folder, so kenso runs finds it. The folder is mode 0700 and its files 0600.

FileWhat it holds
run.jsonThe command, its arguments, the input path and SHA-256, the workspace, the chunk plan and the status (planned, running, done, failed, interrupted)
state.jsonThe chunks done so far, rewritten after every chunk
results.csvOne line per input row: row,status,id,reason,error, then extra columns such as email or sendable
results.ndjsonThe same rows as JSON lines
invalid.csvRows refused before sending, with the reason
plan.jsonThe last dry run's plan
summary.jsonThe last summary printed

row is the 1-based number of the data row in the input (the CSV header is not counted). status is one of created, updated, ok, skipped, failed, not_found or invalid.

kenso runs list
kenso runs list --limit 5 -o json
kenso runs show r_ubqu7wtx

Rerun and resume

A run is identified by its command, its arguments, the SHA-256 of its input file, the workspace and the chunk size. --yes, --dry-run, --wait, --concurrency and the output flags are not part of it. Running the same command again on the same file:

  • continues an unfinished run (interrupted, stopped or crashed): finished chunks are skipped and the summary says "resumed":true.
  • answers from a finished run where every row succeeded: it prints that run's summary with "changed":0 and "alreadyDone":true, sends no writes and exits 0 (a command may still read first: contacts import reads the campaign, and contacts update --match email looks up the campaign's contacts).
  • starts a new run when the last one finished with failed or invalid rows, or when the file changed. Lead, people and account writes match existing records (an email in a campaign, a person by email or LinkedIn URL, an account by domain), so sending a row twice never creates a duplicate.

kenso runs resume <runId> does the same from the run id: it rebuilds the command from run.json and runs it with --yes from the folder it started in. It refuses when the input was stdin, or the file is gone or changed. Two processes cannot work on one run: the second gets run_in_progress (exit 7).

kenso runs resume r_ubqu7wtx

studio fill is the exception: a fill charges for every page it builds, so it never fills the same ids twice. See Studio.

Ctrl-C

The first Ctrl-C cancels the requests in flight and starts no new chunks. The CLI writes the state and prints the summary with "interrupted":true and "next":"kenso runs resume <runId>", then exits 130. A chunk that did not finish is sent again when you resume, which is safe because the writes match existing records. A second Ctrl-C quits at once.

Chunks, concurrency and rate limits

CommandRows per callAt once
contacts import1,0001 per campaign
contacts update, remove, restore, pause, resume1,0004 (--concurrency)
contacts tag, untag1 per call, 25 per chunk4 chunks (--concurrency)
people import2004 (--concurrency)
accounts import1004 (--concurrency)
studio fill500 ids per fill run1
--each14 (--concurrency)

--concurrency takes 1 to 16. When the API's RateLimit-Remaining header drops below 10, the CLI sends one chunk at a time until it is back above 30.

Retries:

  • 429: the CLI waits for Retry-After (at least 1 second, at most 60 seconds per wait) and tries again, with up to 15 minutes of waiting per command. After that it exits 6 (rate_limited).
  • 502, 503, 504 and network errors: up to 3 retries with backoff, for GET, PUT, PATCH and DELETE, and for the POST routes the CLI knows are safe to repeat.
  • contacts import: no quick retry. After a 5xx or a dropped connection it waits 130 seconds, longer than the gateway's request timeout, and resends the chunk once.
  • contacts remove: after a 5xx or a dropped connection it waits 2 seconds and resends once. Ids the first call already removed are reported ok (reason already_removed), so the undo still restores them.
  • Other 4xx answers: never retried. The row or chunk is marked failed with the API's code.

Kenso answers a bulk call with 200 even when some items fail, so the CLI reads each item's result and never trusts the HTTP status alone.

Progress

Progress goes to stderr, at most one line every 2 seconds (chunk 7/20, 7000 rows, 6 failed), and only when stderr is a terminal or you pass --progress. An agent gets only the summary.

Run any API operation once per row: --each

Every generated command takes --each <file>, a CSV or JSONL file or - for stdin. The command runs once per row through the same engine: one results line per row, resume, --concurrency. Row keys fill the route, query and body by API name (accountId) or flag name (account-id). A blank cell is left out and the literal null sends null. A GET runs right away; any other method needs --yes or --dry-run.

kenso accounts update --each stages.csv --dry-run
kenso accounts update --each stages.csv --yes --concurrency 8

With stages.csv:

accountId,stage,overwritePinned
501,engaged,true
502,customer,true

Each row is ok on a 2xx answer (with the created or returned id), not_found on 404, failed otherwise, or invalid when a required value is missing.

The token-saving pattern for agents

  1. Pass a file path, never the rows: --file leads.csv.
  2. Run --dry-run, read the plan, then run its next.
  3. Read the summary. If failed, notFound and invalid are 0, you are done.
  4. Otherwise read only the problem rows, for example grep -E '^[0-9]+,(failed|not_found|invalid),' results.csv | head -50, or invalid.csv.