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
- Slack
- cron
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:readand, for the connect commands,accounts:write. Generate each in that workspace's Settings → API Keys. See Create an API key. - A
clients.jsonnaming 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_URLfor 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 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);
}# 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>')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-000000000001report 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.jsonis 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:
exporta winning campaign from one workspace, swap the sender ids, andapplyit with each client's key.
Next steps
- Keep campaigns in git and apply them, for one campaign template applied to many workspaces.
- Alert on disconnected senders with a reconnect link, the automatic version of
reconnect, run per workspace. - Export campaign analytics to your data warehouse, to put every client's numbers in one place.