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 importandaccounts 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"}
| Field | Meaning |
|---|---|
run | The run id: r_ and 8 characters |
rows | Input rows |
ok, created, updated | Rows that succeeded, by kind |
skipped | Rows Kenso left alone on purpose, counted by reason, for example {"duplicate":12} |
failed, notFound, invalid | Rows that did not go through: refused or errored, id not found, refused locally |
changed | Rows this invocation changed. 0 when a rerun had nothing left to do |
results, resultsNdjson | Absolute paths of the results files |
invalidRows | Path of invalid.csv, when rows were refused locally |
resumed | True when this invocation continued an earlier run |
durationMs | Wall time of this invocation |
pending | Rows 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) |
interrupted | True after Ctrl-C |
next | kenso 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.
| File | What it holds |
|---|---|
run.json | The command, its arguments, the input path and SHA-256, the workspace, the chunk plan and the status (planned, running, done, failed, interrupted) |
state.json | The chunks done so far, rewritten after every chunk |
results.csv | One line per input row: row,status,id,reason,error, then extra columns such as email or sendable |
results.ndjson | The same rows as JSON lines |
invalid.csv | Rows refused before sending, with the reason |
plan.json | The last dry run's plan |
summary.json | The 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":0and"alreadyDone":true, sends no writes and exits 0 (a command may still read first:contacts importreads the campaign, andcontacts update --match emaillooks 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
| Command | Rows per call | At once |
|---|---|---|
contacts import | 1,000 | 1 per campaign |
contacts update, remove, restore, pause, resume | 1,000 | 4 (--concurrency) |
contacts tag, untag | 1 per call, 25 per chunk | 4 chunks (--concurrency) |
people import | 200 | 4 (--concurrency) |
accounts import | 100 | 4 (--concurrency) |
studio fill | 500 ids per fill run | 1 |
--each | 1 | 4 (--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 reportedok(reasonalready_removed), so the undo still restores them.- Other 4xx answers: never retried. The row or chunk is marked
failedwith 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
- Pass a file path, never the rows:
--file leads.csv. - Run
--dry-run, read the plan, then run itsnext. - Read the summary. If
failed,notFoundandinvalidare 0, you are done. - Otherwise read only the problem rows, for example
grep -E '^[0-9]+,(failed|not_found|invalid),' results.csv | head -50, orinvalid.csv.