Frontrunner CLI
fr runs your Frontrunner workspace from a terminal: agents, campaigns, runs, sessions and the lists they produce. It uses the same API as the app, so anything below also works from your own code over HTTPS.
Install
Node 20 or newer.
mkdir -p ~/.local/bin
curl -fsSL https://app.usefr.com/cli/fr -o ~/.local/bin/fr
chmod +x ~/.local/bin/fr
fr --version
If fr is not found, add ~/.local/bin to your PATH.
Create a key
In Frontrunner open Settings, then API keys, then Create key. The key is shown once. It acts as you: it reaches the workspaces you are a member of and nothing else, and it stops working the moment you revoke it on the same page.
Sign in
fr login --key fr_... --ws <workspace>
fr whoami
fr login writes ~/.frrc (readable only by you) and verifies the key. fr whoami prints the workspace ids your key can act in; pass one of them as --ws. If you have exactly one, fr login picks it for you.
Lists
Pull a list. --csv prints every column with a key column first; --json prints the raw API response, rows included.
fr list ls
fr list show <listId> --csv > list.csv
fr list show <listId> --json > list.json
Create a list:
fr list create --name "Design partners" --column company:Company --column why:"Why now":longtext
Writing to a list
The case this was built for: a tool of yours writes one line per lead (an opener), that line has to become a variable on the list a campaign targets, and it has to reach the leads already sitting in the Instantly campaign so the copy can use it.
1. Pull the list with everything your tool needs. Keep the key column: it is how rows are matched on the way back.
fr list show <listId> --json > leads.json
2. Add the field, once. Existing rows keep their cells; the new column is empty until you fill it.
fr list add-column <listId> --key opener --label "Opener" --type longtext
Types: text (default), longtext, number, date, url, email, phone.
3. Write the line per row and push it back. A row is matched by its key: an existing key updates that row's cells, a new key adds a row. Only the cells you send change; an empty CSV cell leaves the stored value alone. Columns in the file must exist on the list; --add-columns adds any missing ones as text.
[
{ "key": "jane-doe-1a2b3c", "cells": { "opener": "Saw the Series A note. Congrats on the growth hire." } },
{ "key": "acme.com", "cells": { "opener": "Your pricing page changed twice this month." } }
]
fr list push <listId> --file openers.json
fr list push <listId> --file list.csv --add-columns
The file is JSON as above, or CSV with a key column plus one column per field (what fr list show --csv prints, so pull, edit and push round-trips). One row without a file:
fr list set <listId> acme.com --field opener="Your pricing page changed twice this month."
4. Send the field to the leads already in the Instantly campaign, matched by the list's email column. =personalization fills Instantly's own {{personalization}} field; a bare column name makes a custom variable of that name, so --var opener gives the copy {{opener}}.
fr list sync <listId> --instantly <campaignId> --var opener=personalization
fr list sync <listId> --instantly <campaignId> --var opener --var segment
Leads the campaign does not hold are listed, not added, and no mail is sent. Rows marked not relevant are skipped. Run it again after you improve the line; the value is overwritten. The campaign id is the one in the Instantly URL, or fr campaign get <campaignId> shows it for a campaign Frontrunner runs.
Agents, campaigns and runs
fr agent list
fr agent get <agentId>
fr agent run <agentId> [-m "what you want"] [--wait]
fr campaign list
fr campaign get <campaignId>
fr campaign run <campaignId> [--wait]
fr run list [--campaign <campaignId>]
fr run get <runId>
fr run wait <runId>
fr run events <runId>
fr session new --agent <agentId> [-m "text"]
fr session send <sessionId> "text"
fr session watch <sessionId>
--json prints the raw API response. --wait blocks until the run finishes and exits non-zero if it failed. fr --help lists everything, including create and delete.
HTTP API
Every command is a request to https://app.usefr.com/api/v1/<workspace>/... with the header Authorization: Bearer fr_.... The three list calls:
# pull: { list, rows, suppressions }
curl -s https://app.usefr.com/api/v1/<workspace>/lists/<listId> -H "Authorization: Bearer $FR_API_KEY"
# add a field
curl -s -X POST https://app.usefr.com/api/v1/<workspace>/lists/<listId>/columns \
-H "Authorization: Bearer $FR_API_KEY" -H "Content-Type: application/json" \
-d '{"columns":[{"key":"priority","label":"Priority","type":"text"}]}'
# update or add rows (up to 2000 per call): { counts: { added, updated, reconfirmed, suppressed } }
curl -s -X POST https://app.usefr.com/api/v1/<workspace>/lists/<listId>/rows \
-H "Authorization: Bearer $FR_API_KEY" -H "Content-Type: application/json" \
-d '{"rows":[{"key":"acme.com","cells":{"opener":"Your pricing page changed twice this month."}}]}'
# carry a column onto the leads in an Instantly campaign: { updated, missing, failed, skipped }
curl -s -X POST https://app.usefr.com/api/v1/<workspace>/lists/<listId>/instantly-variables \
-H "Authorization: Bearer $FR_API_KEY" -H "Content-Type: application/json" \
-d '{"campaignId":"<instantly campaign id>","variables":{"opener":"personalization"}}'
GET /api/v1/whoami checks a key. GET /api/v1/<workspace>/lists lists the lists without their rows.
Configuration
| Where | What |
|---|---|
~/.frrc | {"baseUrl","apiKey","workspace"}, written by fr login |
FR_API_KEY, FR_WORKSPACE, FR_BASE_URL | override the file |
--key, --ws, --url | override both, for one command |
Errors
| Message | Meaning |
|---|---|
invalid_api_key | the key is wrong or revoked; create a new one in Settings |
workspace_not_allowed, forbidden | you are not a member of that workspace |
no workspace set | pass --ws <id> or run fr login --ws <id> |
unauthorized | no key reached the server; check FR_API_KEY or ~/.frrc |
list_not_found | the list id is wrong or belongs to another workspace; fr list ls prints yours |
