# 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: [HTTP API](/docs/api) has every route.

## For agents

This page is markdown at [/docs.md](/docs.md) (or `/docs` with `Accept: text/markdown`), the API reference at [/docs/api.md](/docs/api.md), and both together at [/llms-full.txt](/llms-full.txt). With the CLI installed, `fr docs`, `fr docs api` and `fr docs all` print the same.

- **No prompt, no config file.** Set `FR_API_KEY` and `FR_WORKSPACE` in the environment and skip `fr login`; every command reads them. `fr whoami` prints the workspace ids the key can act in.
- **Read JSON.** Add `--json` to any command: stdout is then one JSON value (the object or array the command is about). `fr session watch --json` prints one JSON event per line instead.
- **Errors go to stderr** as one line starting `fr: `, ending in the server's error code and detail.
- **Exit codes:** `0` ok, `1` an error or a run that failed, `2` a usage error (a missing argument or an unknown command).
- **Waiting:** `--wait` and `fr run wait` poll every 2 seconds until the run is `succeeded` (exit 0) or `failed` (exit 1); `--timeout <secs>` caps the wait (default 1800) and exits 1 when it runs out. A run in `needs_input` is waiting on a reply: read it with `fr run events`, answer with `fr session send`.
- **Retries are safe for list writes.** `fr list push`, `set`, `add-column` and `sync` match by row key or column key, so running one twice leaves the same result. `fr agent run`, `fr campaign run` and `fr session send` start new work each time: do not retry them blindly; check `fr run list` first.
- **Find ids** with `fr agent list`, `fr campaign list`, `fr run list` and `fr list ls`.

## Install

Node 20 or newer.

```sh
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

```sh
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.

```sh
fr list ls
fr list show <listId> --csv > list.csv
fr list show <listId> --json > list.json
```

Create a list:

```sh
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.

```sh
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.

```sh
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.

```json
[
  { "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." } }
]
```

```sh
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:

```sh
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}}`.

```sh
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.

## Context files

Your own material for agents to read: a customer export, a CRM dump, a list of the accounts you already work with. CSV, TSV, XLSX, JSON and text are read; anything else is stored as is. Up to 32 MB per file.

```sh
fr context upload customers.csv --title "Customers by monthly orders" --description "One row per customer account, orders per month since 2025"
fr context ls
fr context show <fileId>
fr context download <fileId> -o customers.csv
```

The description is what an agent reads to decide how to use the file, so say what a row is and what the columns mean. A file that matches one already here (the same bytes, the same filename, or the same columns) is not stored: the command says why and exits 1. Run it again with `--on-conflict new_version` to replace the earlier file (it stays in version history; pass `--title` and `--description` again, a new version does not inherit them), `keep_both` to store both, or `skip`.

A context file is read as a whole. To work row by row (add a field, push rows back, send a column to a campaign), load it into a list instead: `fr list create` and `fr list push --file`.

## Agents, campaigns and runs

```sh
fr agent list
fr agent get <agentId>
fr agent create --name "..." [--description ...] [--category Prospecting] [--file agent.json]
fr agent run <agentId> [-m "what you want"] [--wait]
fr agent delete <agentId> [<agentId> ...]

fr campaign list
fr campaign get <campaignId>
fr campaign create --name "..." --agent <agentId> [--cron "0 7 * * 1-5"] [--tz Europe/Copenhagen] [--list <listId>]
fr campaign run <campaignId> [--as-of YYYY-MM-DD] [--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 status <sessionId>
fr session events <sessionId>
fr session watch <sessionId>

fr integration list
fr integration connect <int_id> [--field k=v ...] [--file fields.json]
fr integration disconnect <int_id>
```

An agent is the work (its instructions and tools); a campaign runs an agent on a schedule; a run is one execution of either, and a session is the conversation behind it. `fr agent run` and `fr campaign run` print the run id; `--wait` blocks until it finishes. `fr session watch` follows a session until it stops working and exits 1 if it failed.

## HTTP API

Every command is a request to `https://app.usefr.com/api/v1/<workspace>/...` with the header `Authorization: Bearer fr_...`. [HTTP API](/docs/api) lists every route; the list calls this page walks through:

```sh
# 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 |
