# Run agency operations across client workspaces

A script that loops over client workspaces, one API key each, to write a weekly report per client and a roll-up, and to mint connect links for their senders.

Works with: Slack, cron.

Endpoints used:

- [`GET /v1/auth/verify`](https://docs.versionseven.ai/api-reference/auth/verify-api-key) Verify an API key
- [`GET /v1/campaigns`](https://docs.versionseven.ai/api-reference/campaigns/list-campaigns) List campaigns
- [`GET /v1/campaigns/{campaign_id}/analysis`](https://docs.versionseven.ai/api-reference/campaigns/get-campaign-analysis) Get campaign analytics
- [`GET /v1/campaigns/{campaign_id}/queue`](https://docs.versionseven.ai/api-reference/campaigns/get-campaign-queue) Get a campaign's queue
- [`GET /v1/accounts`](https://docs.versionseven.ai/api-reference/accounts/list-accounts) List sender accounts
- [`POST /v1/accounts/connect-link`](https://docs.versionseven.ai/api-reference/accounts/create-connect-link) Create a connect link

An agency on the Agency plan runs each client in its own workspace, and every API key belongs to one workspace. So agency tooling is a loop over keys. This recipe is that loop, with the two jobs agencies ask for most: a Monday report per client and a roll-up across all of them, and a way to get a client's LinkedIn or mailbox connected without the client ever logging in to Victoria AI.

## Before you start

- One API key per client workspace, with `campaigns:read`, `accounts:read` and, for the connect commands, `accounts:write`. Generate each in that workspace's **Settings → API Keys**. See [Create an API key](https://docs.versionseven.ai/help/api-keys).
- A `clients.json` naming each client and the environment variable that holds its key. Keys stay in the environment, never in the file:

```json
[
  { "client": "acme", "key_env": "VICTORIA_KEY_ACME" },
  { "client": "northwind", "key_env": "VICTORIA_KEY_NORTHWIND" }
]
```

- Optionally a Slack [incoming webhook](https://api.slack.com/messaging/webhooks) URL in `SLACK_WEBHOOK_URL` for the roll-up.
- Node.js 18 or later, or Python 3.10 or later with `requests`.

## The commands

| Command | What it does |
| - | - |
| `report` | For each client: [`GET /v1/auth/verify`](https://docs.versionseven.ai/api-reference/auth/verify-api-key) for the workspace's name, [`GET /v1/campaigns`](https://docs.versionseven.ai/api-reference/campaigns/list-campaigns) for its active campaigns, [`GET /v1/campaigns/{campaign_id}/analysis`](https://docs.versionseven.ai/api-reference/campaigns/get-campaign-analysis) over 7 days and [`GET /v1/campaigns/{campaign_id}/queue`](https://docs.versionseven.ai/api-reference/campaigns/get-campaign-queue) per campaign, [`GET /v1/accounts`](https://docs.versionseven.ai/api-reference/accounts/list-accounts) for disconnected senders. Writes `reports/<workspace>.md` per client and `reports/rollup.md`, and posts the roll-up to Slack. |
| `connect <client> <linkedin\|email> "<display name>" [email]` | [`POST /v1/accounts/connect-link`](https://docs.versionseven.ai/api-reference/accounts/create-connect-link) in that client's workspace. Prints the hosted sign-in link to send to the person whose account it is. |
| `reconnect <client> <account id>` | The same, with `reconnect_account_id`, for a sender that has disconnected; uses no seat. |

## The script

**Node.js**

```javascript
// agency.mjs
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";

const API_URL = "https://api.versionseven.ai/v1";
const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL;
const OUT = process.env.OUT ?? "reports";
const [command, ...args] = process.argv.slice(2);

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Every call is made with one client's key.
async function call(key, method, path, body) {
  for (let attempt = 1; ; attempt++) {
    const response = await fetch(`${API_URL}${path}`, {
      method,
      headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
    const data = await response.json().catch(() => ({}));
    if ((response.status !== 429 && response.status !== 503) || attempt === 5) return { status: response.status, data };
    await sleep((Number(response.headers.get("Retry-After")) || 2 ** attempt) * 1000);
  }
}

async function must(key, method, path, body) {
  const { status, data } = await call(key, method, path, body);
  if (status >= 400) throw new Error(`${method} ${path} answered ${status} ${data.error ?? ""} ${data.message ?? ""}`);
  return data;
}

const clients = JSON.parse(await readFile("clients.json", "utf8")).map((client) => {
  const key = process.env[client.key_env];
  if (!key) throw new Error(`${client.key_env} isn't set (client ${client.client})`);
  return { ...client, key };
});
const clientNamed = (name) => {
  const client = clients.find((candidate) => candidate.client === name);
  if (!client) throw new Error(`No client "${name}" in clients.json`);
  return client;
};

async function report() {
  const rows = [];
  await mkdir(OUT, { recursive: true });
  for (const client of clients) {
    const who = await must(client.key, "GET", "/auth/verify");
    const workspace = who.organization_name ?? client.client;
    const campaigns = (await must(client.key, "GET", "/campaigns?limit=500")).campaigns.filter((campaign) => campaign.is_active);
    const accounts = (await must(client.key, "GET", "/accounts?limit=500")).accounts;
    const disconnected = accounts.filter((account) => account.is_active === false);
    const totals = { contacted: 0, replied: 0, positive: 0, meetings: 0 };
    let minRunway = null;
    const lines = [`# ${workspace}: week to ${new Date().toISOString().slice(0, 10)}`, "", "| Campaign | Contacted | Replied | Positive | Meetings | Runway |", "| --- | --- | --- | --- | --- | --- |"];
    for (const campaign of campaigns) {
      const { analysis } = await must(client.key, "GET", `/campaigns/${campaign.id}/analysis?date_filter=7d`);
      const queue = await must(client.key, "GET", `/campaigns/${campaign.id}/queue`);
      const f = analysis?.funnel ?? {};
      for (const metric of Object.keys(totals)) totals[metric] += f[metric] ?? 0;
      if (queue.days_of_runway !== null && (minRunway === null || queue.days_of_runway < minRunway)) minRunway = queue.days_of_runway;
      const runway = queue.days_of_runway === null ? `none (${queue.warnings[0] ?? "can't send"})` : `${queue.days_of_runway} days`;
      lines.push(`| ${campaign.name} | ${f.contacted ?? 0} | ${f.replied ?? 0} | ${f.positive ?? 0} | ${f.meetings ?? 0} | ${runway} |`);
      await sleep(650);
    }
    if (campaigns.length === 0) lines.push("| (no active campaigns) | | | | | |");
    lines.push("", disconnected.length ? `**Disconnected senders:** ${disconnected.map((account) => account.platform_username ?? account.email ?? account.id).join(", ")}` : "All senders connected.");
    await writeFile(path.join(OUT, `${workspace.replace(/[^\w.-]+/g, "_")}.md`), lines.join("\n") + "\n");
    rows.push({ workspace, campaigns: campaigns.length, ...totals, minRunway, disconnected: disconnected.length });
  }

  const rollup = [
    `# Agency roll-up: week to ${new Date().toISOString().slice(0, 10)}`, "",
    "| Client | Active campaigns | Contacted | Replied | Positive | Meetings | Shortest runway | Disconnected senders |",
    "| --- | --- | --- | --- | --- | --- | --- | --- |",
    ...rows.map((row) => `| ${row.workspace} | ${row.campaigns} | ${row.contacted} | ${row.replied} | ${row.positive} | ${row.meetings} | ${row.minRunway === null ? "n/a" : `${row.minRunway} days`} | ${row.disconnected} |`),
  ];
  await writeFile(path.join(OUT, "rollup.md"), rollup.join("\n") + "\n");
  console.log(rollup.join("\n"));
  if (SLACK_WEBHOOK_URL) {
    const text = rows.map((row) => `*${row.workspace}*: ${row.positive} positive / ${row.replied} replied / ${row.contacted} contacted this week; ${row.meetings} meetings${row.disconnected ? `; :warning: ${row.disconnected} sender(s) disconnected` : ""}${row.minRunway !== null && row.minRunway < 7 ? `; :warning: ${row.minRunway} days of runway` : ""}`).join("\n");
    const response = await fetch(SLACK_WEBHOOK_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: `:bar_chart: Agency roll-up\n${text}` }) });
    if (!response.ok) throw new Error(`Slack answered ${response.status}`);
  }
}

async function connect(clientName, platform, displayName, email, reconnectAccountId) {
  const client = clientNamed(clientName);
  const body = { platform, display_name: displayName, ...(email ? { email } : {}), ...(reconnectAccountId ? { reconnect_account_id: reconnectAccountId } : {}) };
  const { status, data } = await call(client.key, "POST", "/accounts/connect-link", body);
  if (status !== 200) throw new Error(`connect-link answered ${status} ${data.error}: ${data.message ?? ""}`);
  console.log(`${data.type === "reconnect" ? "Reconnect" : "Connect"} link for ${displayName} (${platform}), valid until ${data.expires_at}:\n${data.url}\nSend it to the person whose account it is; the account appears in GET /accounts once they've signed in.`);
}

if (command === "report") await report();
else if (command === "connect") await connect(args[0], args[1], args[2], args[3]);
else if (command === "reconnect") {
  const client = clientNamed(args[0]);
  const account = (await must(client.key, "GET", "/accounts?limit=500")).accounts.find((candidate) => candidate.id === args[1] || candidate.account_id === args[1]);
  if (!account) throw new Error(`No account ${args[1]} in ${args[0]}'s workspace`);
  await connect(args[0], account.platform === "linkedin" ? "linkedin" : "email", account.platform_username ?? account.email ?? "Sender", account.email ?? undefined, account.id);
} else {
  console.error('Usage: node agency.mjs report | connect <client> <linkedin|email> "<display name>" [email] | reconnect <client> <account id>');
  process.exit(2);
}
```

**Python**

```python
# agency.py
import json
import os
import re
import sys
import time
from datetime import date

import requests

API_URL = "https://api.versionseven.ai/v1"
SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL")
OUT = os.environ.get("OUT", "reports")
COMMAND = sys.argv[1] if len(sys.argv) > 1 else None
ARGS = sys.argv[2:]


def call(key, method, path, body=None):
    """Every call is made with one client's key."""
    for attempt in range(1, 6):
        response = requests.request(method, f"{API_URL}{path}", headers={"Authorization": f"Bearer {key}"}, json=body, timeout=30)
        try:
            data = response.json()
        except ValueError:
            data = {}
        if response.status_code not in (429, 503) or attempt == 5:
            return response.status_code, data
        time.sleep(float(response.headers.get("Retry-After") or 2**attempt))


def must(key, method, path, body=None):
    status, data = call(key, method, path, body)
    if status >= 400:
        raise SystemExit(f"{method} {path} answered {status} {data.get('error', '')} {data.get('message', '')}")
    return data


def load_clients():
    with open("clients.json", encoding="utf-8") as handle:
        clients = json.load(handle)
    for client in clients:
        client["key"] = os.environ.get(client["key_env"])
        if not client["key"]:
            raise SystemExit(f"{client['key_env']} isn't set (client {client['client']})")
    return clients


CLIENTS = load_clients()


def client_named(name):
    for client in CLIENTS:
        if client["client"] == name:
            return client
    raise SystemExit(f'No client "{name}" in clients.json')


def report():
    rows = []
    os.makedirs(OUT, exist_ok=True)
    today = date.today().isoformat()
    for client in CLIENTS:
        key = client["key"]
        workspace = must(key, "GET", "/auth/verify").get("organization_name") or client["client"]
        campaigns = [c for c in must(key, "GET", "/campaigns?limit=500")["campaigns"] if c.get("is_active")]
        accounts = must(key, "GET", "/accounts?limit=500")["accounts"]
        disconnected = [a for a in accounts if a.get("is_active") is False]
        totals = {"contacted": 0, "replied": 0, "positive": 0, "meetings": 0}
        min_runway = None
        lines = [f"# {workspace}: week to {today}", "", "| Campaign | Contacted | Replied | Positive | Meetings | Runway |", "| --- | --- | --- | --- | --- | --- |"]
        for campaign in campaigns:
            analysis = must(key, "GET", f"/campaigns/{campaign['id']}/analysis?date_filter=7d").get("analysis") or {}
            queue = must(key, "GET", f"/campaigns/{campaign['id']}/queue")
            f = analysis.get("funnel") or {}
            for metric in totals:
                totals[metric] += f.get(metric) or 0
            if queue["days_of_runway"] is not None and (min_runway is None or queue["days_of_runway"] < min_runway):
                min_runway = queue["days_of_runway"]
            reason = queue["warnings"][0] if queue["warnings"] else "cannot send"
            runway = f"none ({reason})" if queue["days_of_runway"] is None else f"{queue['days_of_runway']} days"
            lines.append(f"| {campaign['name']} | {f.get('contacted') or 0} | {f.get('replied') or 0} | {f.get('positive') or 0} | {f.get('meetings') or 0} | {runway} |")
            time.sleep(0.65)
        if not campaigns:
            lines.append("| (no active campaigns) | | | | | |")
        names = ", ".join(a.get("platform_username") or a.get("email") or a["id"] for a in disconnected)
        lines += ["", f"**Disconnected senders:** {names}" if disconnected else "All senders connected."]
        with open(os.path.join(OUT, re.sub(r"[^\w.-]+", "_", workspace) + ".md"), "w", encoding="utf-8") as handle:
            handle.write("\n".join(lines) + "\n")
        rows.append({"workspace": workspace, "campaigns": len(campaigns), **totals, "min_runway": min_runway, "disconnected": len(disconnected)})

    rollup = [
        f"# Agency roll-up: week to {today}", "",
        "| Client | Active campaigns | Contacted | Replied | Positive | Meetings | Shortest runway | Disconnected senders |",
        "| --- | --- | --- | --- | --- | --- | --- | --- |",
    ]
    for row in rows:
        runway = "n/a" if row["min_runway"] is None else f"{row['min_runway']} days"
        rollup.append(f"| {row['workspace']} | {row['campaigns']} | {row['contacted']} | {row['replied']} | {row['positive']} | {row['meetings']} | {runway} | {row['disconnected']} |")
    with open(os.path.join(OUT, "rollup.md"), "w", encoding="utf-8") as handle:
        handle.write("\n".join(rollup) + "\n")
    print("\n".join(rollup))
    if SLACK_WEBHOOK_URL:
        parts = []
        for row in rows:
            line = f"*{row['workspace']}*: {row['positive']} positive / {row['replied']} replied / {row['contacted']} contacted this week; {row['meetings']} meetings"
            if row["disconnected"]:
                line += f"; :warning: {row['disconnected']} sender(s) disconnected"
            if row["min_runway"] is not None and row["min_runway"] < 7:
                line += f"; :warning: {row['min_runway']} days of runway"
            parts.append(line)
        requests.post(SLACK_WEBHOOK_URL, json={"text": ":bar_chart: Agency roll-up\n" + "\n".join(parts)}, timeout=10).raise_for_status()


def connect(client_name, platform, display_name, email=None, reconnect_account_id=None):
    client = client_named(client_name)
    body = {"platform": platform, "display_name": display_name}
    if email:
        body["email"] = email
    if reconnect_account_id:
        body["reconnect_account_id"] = reconnect_account_id
    status, data = call(client["key"], "POST", "/accounts/connect-link", body)
    if status != 200:
        raise SystemExit(f"connect-link answered {status} {data.get('error')}: {data.get('message', '')}")
    kind = "Reconnect" if data.get("type") == "reconnect" else "Connect"
    print(f"{kind} link for {display_name} ({platform}), valid until {data['expires_at']}:\n{data['url']}\nSend it to the person whose account it is; the account appears in GET /accounts once they've signed in.")


if COMMAND == "report":
    report()
elif COMMAND == "connect":
    connect(ARGS[0], ARGS[1], ARGS[2], ARGS[3] if len(ARGS) > 3 else None)
elif COMMAND == "reconnect":
    client = client_named(ARGS[0])
    accounts = must(client["key"], "GET", "/accounts?limit=500")["accounts"]
    account = next((a for a in accounts if a["id"] == ARGS[1] or a.get("account_id") == ARGS[1]), None)
    if not account:
        raise SystemExit(f"No account {ARGS[1]} in {ARGS[0]}'s workspace")
    platform = "linkedin" if account.get("platform") == "linkedin" else "email"
    connect(ARGS[0], platform, account.get("platform_username") or account.get("email") or "Sender", account.get("email"), account["id"])
else:
    raise SystemExit('Usage: python agency.py report | connect <client> <linkedin|email> "<display name>" [email] | reconnect <client> <account id>')
```

```bash
VICTORIA_KEY_ACME=vk_… VICTORIA_KEY_NORTHWIND=vk_… node agency.mjs report
node agency.mjs connect acme linkedin "Jane Doe"
node agency.mjs connect acme email "Jane Doe" jane@acme.com
node agency.mjs reconnect acme a1b2c3d4-0000-4000-8000-000000000001
```

`report` prints the roll-up and leaves one Markdown file per workspace in `reports/`, ready to paste into a client update. Run it from cron or GitHub Actions every Monday, as in [the daily digest](https://docs.versionseven.ai/cookbook/daily-campaign-digest#schedule-it), with one secret per client.

## Getting a client's senders connected

The client's LinkedIn or mailbox has to be signed in by the client; nobody else can do it, and Victoria AI never sees the password. `connect` mints the hosted sign-in link for that: send it to the person, they sign in on the hosted page, and the account appears in the workspace's [`GET /v1/accounts`](https://docs.versionseven.ai/api-reference/accounts/list-accounts) as active. The link is valid for about a week, needs a free seat on the workspace's plan (`409 ACCOUNT_LIMIT_REACHED` otherwise), and takes the client nowhere near the Victoria AI app. When an account later disconnects, `reconnect` mints a link for the same account, which uses no seat and keeps its campaigns.

The link always lands in the Victoria AI app after sign-in. A redirect to your own portal needs the portal's origin on an allow-list that Victoria AI controls, so leave `success_redirect_url` out unless that has been arranged.

## What to expect

- **One key, one workspace.** There is no cross-workspace key; the loop over `clients.json` is the model, and a client's key sees only that client's data. A key stops working if its creator is no longer an owner or admin of that workspace, so create keys from an agency account that stays on every workspace.
- **Numbers are per lead over 7 days**, counted by each lead's first event in the window, the same as the app's Analytics. Meetings are confirmed bookings, not calendar events, unless Calendly is connected in that workspace.
- **Rate limits are per key**, so ten clients means ten separate allowances; the pause between campaigns is for a single key's 100 requests a minute to one endpoint.
- **Templates across clients**: pair this with [Keep campaigns in git and apply them](https://docs.versionseven.ai/cookbook/campaign-as-code): `export` a winning campaign from one workspace, swap the sender ids, and `apply` it with each client's key.

## Next steps

- [Keep campaigns in git and apply them](https://docs.versionseven.ai/cookbook/campaign-as-code), for one campaign template applied to many workspaces.
- [Alert on disconnected senders with a reconnect link](https://docs.versionseven.ai/cookbook/sender-reconnect-monitor), the automatic version of `reconnect`, run per workspace.
- [Export campaign analytics to your data warehouse](https://docs.versionseven.ai/cookbook/analytics-to-warehouse), to put every client's numbers in one place.
