Cookbook
View as Markdown

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.

Last updated

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.
  • A clients.json naming each client and the environment variable that holds its key. Keys stay in the environment, never in the file:
[
  { "client": "acme", "key_env": "VICTORIA_KEY_ACME" },
  { "client": "northwind", "key_env": "VICTORIA_KEY_NORTHWIND" }
]
  • Optionally a Slack incoming webhook URL in SLACK_WEBHOOK_URL for the roll-up.
  • Node.js 18 or later, or Python 3.10 or later with requests.

The commands

CommandWhat it does
reportFor each client: GET /v1/auth/verify for the workspace's name, GET /v1/campaigns for its active campaigns, GET /v1/campaigns/{campaign_id}/analysis over 7 days and GET /v1/campaigns/{campaign_id}/queue per campaign, GET /v1/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 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

// 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);
}
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, 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 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: export a winning campaign from one workspace, swap the sender ids, and apply it with each client's key.

Next steps