skilldrop-cli 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/catalogue.json +10 -1
- package/model-routing.json +12 -0
- package/package.json +1 -1
- package/packs/startup-legal/pack.json +66 -0
- package/packs/startup-legal/skills/cap-table/SKILL.md +131 -0
- package/packs/startup-legal/skills/cap-table/evals/eval_queries.json +30 -0
- package/packs/startup-legal/skills/cap-table/evals/evals.json +46 -0
- package/packs/startup-legal/skills/cap-table/examples/seed-to-series-a.md +81 -0
- package/packs/startup-legal/skills/cap-table/manifest.json +34 -0
- package/packs/startup-legal/skills/cap-table/scripts/cap_table.py +344 -0
- package/packs/startup-legal/skills/cap-table/templates/cap.json +23 -0
- package/packs/startup-legal/skills/contract-check/SKILL.md +118 -0
- package/packs/startup-legal/skills/contract-check/evals/eval_queries.json +30 -0
- package/packs/startup-legal/skills/contract-check/evals/evals.json +46 -0
- package/packs/startup-legal/skills/contract-check/evals/files/1/saas-agreement.md +68 -0
- package/packs/startup-legal/skills/contract-check/evals/files/2/consulting-agreement.md +64 -0
- package/packs/startup-legal/skills/contract-check/evals/files/3/reseller-agreement.md +69 -0
- package/packs/startup-legal/skills/contract-check/examples/inputs/saas-agreement.md +68 -0
- package/packs/startup-legal/skills/contract-check/examples/saas-agreement.md +72 -0
- package/packs/startup-legal/skills/contract-check/manifest.json +34 -0
- package/packs/startup-legal/skills/contract-check/reference.md +109 -0
- package/packs/startup-legal/skills/contract-check/scripts/contract_check.py +413 -0
- package/packs/startup-legal/skills/lease-check/SKILL.md +119 -0
- package/packs/startup-legal/skills/lease-check/evals/eval_queries.json +30 -0
- package/packs/startup-legal/skills/lease-check/evals/evals.json +45 -0
- package/packs/startup-legal/skills/lease-check/evals/files/1/heads-of-terms.md +15 -0
- package/packs/startup-legal/skills/lease-check/evals/files/2/heads-of-terms.md +32 -0
- package/packs/startup-legal/skills/lease-check/evals/files/3/heads-of-agreement.md +29 -0
- package/packs/startup-legal/skills/lease-check/examples/first-office.md +75 -0
- package/packs/startup-legal/skills/lease-check/examples/inputs/heads-of-terms.md +15 -0
- package/packs/startup-legal/skills/lease-check/manifest.json +33 -0
- package/packs/startup-legal/skills/lease-check/reference.md +70 -0
- package/packs/startup-legal/skills/lease-check/scripts/lease_terms.py +276 -0
- package/packs/startup-legal/skills/lease-check/templates/terms.json +15 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Model a startup's ownership through SAFEs, convertible notes and a priced round. Stdlib only.
|
|
3
|
+
|
|
4
|
+
Reads a cap.json: the shareholders, the option pool, every SAFE and convertible note, and
|
|
5
|
+
(optionally) the priced round's pre-money valuation, new money and target option pool. Prints:
|
|
6
|
+
- the fully diluted cap table today, and what the SAFEs already promise at their caps
|
|
7
|
+
- each SAFE's and note's conversion price, and whether its cap or its discount applied
|
|
8
|
+
- the price per share, the option pool top-up, and the cap table after the round
|
|
9
|
+
- founder ownership at other pre-money valuations (--scenarios)
|
|
10
|
+
and checks that the post-money valuation equals price per share × fully diluted shares and
|
|
11
|
+
that ownership sums to 100 %.
|
|
12
|
+
|
|
13
|
+
Conventions (printed with every result, because term sheets differ):
|
|
14
|
+
- post-money SAFE (YC 2018+): capitalization for its cap price includes every share,
|
|
15
|
+
option, the existing unissued pool and all converting securities, but not the pool
|
|
16
|
+
increase made in the round
|
|
17
|
+
- pre-money SAFE and convertible note: cap price = cap ÷ (existing fully diluted shares +
|
|
18
|
+
the pool increase), excluding converting securities
|
|
19
|
+
- the pool top-up is in the pre-money ("option pool shuffle"): the unissued pool after the
|
|
20
|
+
round is the target share of post-money fully diluted shares
|
|
21
|
+
- note interest is simple, from its issue date to the closing date; shares round down
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
python3 cap_table.py cap.json [--scenarios 8000000,12000000] [--csv after.csv] [--json]
|
|
25
|
+
Exit codes: 0 computed, 1 a check failed or the round cannot be modelled, 2 bad input.
|
|
26
|
+
"""
|
|
27
|
+
import argparse
|
|
28
|
+
import csv
|
|
29
|
+
import datetime as dt
|
|
30
|
+
import json
|
|
31
|
+
import math
|
|
32
|
+
import sys
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InputError(Exception):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def num(v, what, lo=0.0, allow_none=False):
|
|
40
|
+
if v is None and allow_none:
|
|
41
|
+
return None
|
|
42
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) or v < lo:
|
|
43
|
+
raise InputError(f"{what} must be a number of at least {lo:g}")
|
|
44
|
+
return float(v)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def frac(v, what):
|
|
48
|
+
"""A rate given as 0.2 or 20 (meaning 20 %)."""
|
|
49
|
+
if v is None:
|
|
50
|
+
return 0.0
|
|
51
|
+
v = num(v, what)
|
|
52
|
+
if v >= 1:
|
|
53
|
+
v /= 100
|
|
54
|
+
if v >= 1:
|
|
55
|
+
raise InputError(f"{what} must be under 100 %")
|
|
56
|
+
return v
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def date(v, what):
|
|
60
|
+
if v is None:
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
return dt.date.fromisoformat(str(v))
|
|
64
|
+
except ValueError:
|
|
65
|
+
raise InputError(f"{what} must be a date as YYYY-MM-DD")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load(data):
|
|
69
|
+
if not isinstance(data, dict):
|
|
70
|
+
raise InputError("cap.json must be a JSON object")
|
|
71
|
+
holders = []
|
|
72
|
+
for i, h in enumerate(data.get("holders") or [], 1):
|
|
73
|
+
if not isinstance(h, dict) or not str(h.get("name", "")).strip():
|
|
74
|
+
raise InputError(f"holder {i} needs a `name`")
|
|
75
|
+
holders.append({"name": str(h["name"]).strip(), "class": str(h.get("class", "common")),
|
|
76
|
+
"shares": num(h.get("shares"), f"{h['name']}: shares")})
|
|
77
|
+
if not holders:
|
|
78
|
+
raise InputError("`holders` must list at least one shareholder with shares")
|
|
79
|
+
opts = data.get("options") or {}
|
|
80
|
+
if not isinstance(opts, dict):
|
|
81
|
+
raise InputError("`options` must be an object with `granted` and `pool_unissued`")
|
|
82
|
+
granted = num(opts.get("granted", 0), "options.granted")
|
|
83
|
+
unissued = num(opts.get("pool_unissued", 0), "options.pool_unissued")
|
|
84
|
+
convs = []
|
|
85
|
+
for kind, key in (("safe", "safes"), ("note", "notes")):
|
|
86
|
+
for i, s in enumerate(data.get(key) or [], 1):
|
|
87
|
+
if not isinstance(s, dict) or not str(s.get("holder", "")).strip():
|
|
88
|
+
raise InputError(f"{kind} {i} needs a `holder`")
|
|
89
|
+
name = str(s["holder"]).strip()
|
|
90
|
+
c = {"kind": kind, "holder": name,
|
|
91
|
+
"cap": num(s.get("cap"), f"{name}: cap", 1, allow_none=True),
|
|
92
|
+
"discount": frac(s.get("discount"), f"{name}: discount"),
|
|
93
|
+
"mfn": bool(s.get("mfn", False))}
|
|
94
|
+
if kind == "safe":
|
|
95
|
+
c["amount"] = num(s.get("amount"), f"{name}: amount", 1)
|
|
96
|
+
basis = str(s.get("type", "post")).lower()
|
|
97
|
+
if basis not in ("post", "pre"):
|
|
98
|
+
raise InputError(f"{name}: SAFE type must be \"post\" or \"pre\"")
|
|
99
|
+
c["basis"] = basis
|
|
100
|
+
else:
|
|
101
|
+
c["principal"] = num(s.get("principal"), f"{name}: principal", 1)
|
|
102
|
+
c["rate"] = frac(s.get("rate"), f"{name}: rate")
|
|
103
|
+
c["issued"] = date(s.get("issued"), f"{name}: issued")
|
|
104
|
+
c["basis"] = "pre"
|
|
105
|
+
convs.append(c)
|
|
106
|
+
rnd = data.get("round")
|
|
107
|
+
if rnd is not None:
|
|
108
|
+
if not isinstance(rnd, dict):
|
|
109
|
+
raise InputError("`round` must be an object")
|
|
110
|
+
inv = []
|
|
111
|
+
for i, x in enumerate(rnd.get("investors") or [], 1):
|
|
112
|
+
if not isinstance(x, dict) or not str(x.get("name", "")).strip():
|
|
113
|
+
raise InputError(f"round investor {i} needs a `name`")
|
|
114
|
+
inv.append({"name": str(x["name"]).strip(), "amount": num(x.get("amount"), f"{x['name']}: amount", 1)})
|
|
115
|
+
if not inv:
|
|
116
|
+
raise InputError("`round.investors` must list who puts in the new money")
|
|
117
|
+
pool_in = str(rnd.get("pool_in", "pre")).lower()
|
|
118
|
+
if pool_in not in ("pre", "post"):
|
|
119
|
+
raise InputError("round.pool_in must be \"pre\" (top-up in the pre-money, the usual term) or \"post\"")
|
|
120
|
+
rnd = {"pre_money": num(rnd.get("pre_money"), "round.pre_money", 1),
|
|
121
|
+
"investors": inv, "pool_target": frac(rnd.get("pool_target"), "round.pool_target"),
|
|
122
|
+
"pool_in": pool_in, "close": date(rnd.get("close"), "round.close")}
|
|
123
|
+
return {"company": str(data.get("company", "The company")), "currency": str(data.get("currency", "USD")),
|
|
124
|
+
"holders": holders, "granted": granted, "unissued": unissued, "convs": convs, "round": rnd}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def note_amount(c, close, warns):
|
|
128
|
+
if c["kind"] != "note":
|
|
129
|
+
return c["amount"]
|
|
130
|
+
if c["rate"] and (c["issued"] is None or close is None):
|
|
131
|
+
warns.append(f"{c['holder']}: note interest not counted; give `issued` and the round's `close` date")
|
|
132
|
+
return c["principal"]
|
|
133
|
+
years = max(0.0, (close - c["issued"]).days / 365) if c["issued"] and close else 0.0
|
|
134
|
+
return c["principal"] * (1 + c["rate"] * years)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def today_fd(cap):
|
|
138
|
+
return sum(h["shares"] for h in cap["holders"]) + cap["granted"] + cap["unissued"]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def model(cap, pre_money=None):
|
|
142
|
+
"""Solve the round. Returns None when there is no round to model."""
|
|
143
|
+
rnd = cap["round"]
|
|
144
|
+
if rnd is None:
|
|
145
|
+
return None
|
|
146
|
+
pre = pre_money if pre_money is not None else rnd["pre_money"]
|
|
147
|
+
warns = []
|
|
148
|
+
base = today_fd(cap)
|
|
149
|
+
amounts = [note_amount(c, rnd["close"], warns) for c in cap["convs"]]
|
|
150
|
+
new_money = sum(x["amount"] for x in rnd["investors"])
|
|
151
|
+
conv_shares = [0.0] * len(cap["convs"])
|
|
152
|
+
pool_inc = 0.0
|
|
153
|
+
price = pre / base
|
|
154
|
+
in_pre = rnd["pool_in"] == "pre"
|
|
155
|
+
for _ in range(500):
|
|
156
|
+
conv_total = sum(conv_shares)
|
|
157
|
+
pre_fd = base + conv_total + (pool_inc if in_pre else 0)
|
|
158
|
+
price = pre / pre_fd
|
|
159
|
+
new = []
|
|
160
|
+
how = []
|
|
161
|
+
for c, amt in zip(cap["convs"], amounts):
|
|
162
|
+
cands = {"round price": price}
|
|
163
|
+
if c["discount"]:
|
|
164
|
+
cands["discount"] = price * (1 - c["discount"])
|
|
165
|
+
if c["cap"]:
|
|
166
|
+
denom = base + conv_total if c["basis"] == "post" else base + pool_inc
|
|
167
|
+
cands["cap"] = c["cap"] / denom
|
|
168
|
+
pick = min(cands, key=cands.get)
|
|
169
|
+
new.append(amt / cands[pick])
|
|
170
|
+
how.append((pick, cands[pick]))
|
|
171
|
+
post_fd = pre_fd + new_money / price + (0 if in_pre else pool_inc)
|
|
172
|
+
new_pool = max(0.0, rnd["pool_target"] * post_fd - cap["unissued"])
|
|
173
|
+
delta = max(abs(a - b) for a, b in zip(new + [new_pool], conv_shares + [pool_inc])) if new else abs(new_pool - pool_inc)
|
|
174
|
+
conv_shares, pool_inc = new, new_pool
|
|
175
|
+
if delta < 1e-6:
|
|
176
|
+
break
|
|
177
|
+
else:
|
|
178
|
+
return {"error": "the round does not settle: the SAFEs' caps leave no room for the new money at this valuation"}
|
|
179
|
+
conv_total = sum(conv_shares)
|
|
180
|
+
pre_fd = base + conv_total + (pool_inc if in_pre else 0)
|
|
181
|
+
price = pre / pre_fd
|
|
182
|
+
rows = [{"name": h["name"], "class": h["class"], "shares": h["shares"]} for h in cap["holders"]]
|
|
183
|
+
convs = []
|
|
184
|
+
for c, amt, sh, (pick, p) in zip(cap["convs"], amounts, conv_shares, how):
|
|
185
|
+
shares = math.floor(sh + 1e-9)
|
|
186
|
+
convs.append({"holder": c["holder"], "kind": c["kind"], "basis": c["basis"], "invested": amt,
|
|
187
|
+
"price": p, "by": pick, "shares": shares, "mfn": c["mfn"]})
|
|
188
|
+
rows.append({"name": c["holder"], "class": f"preferred ({c['kind']})", "shares": shares})
|
|
189
|
+
investors = []
|
|
190
|
+
for x in rnd["investors"]:
|
|
191
|
+
shares = math.floor(x["amount"] / price + 1e-9)
|
|
192
|
+
investors.append({"name": x["name"], "amount": x["amount"], "shares": shares})
|
|
193
|
+
rows.append({"name": x["name"], "class": "preferred (round)", "shares": shares})
|
|
194
|
+
rows.append({"name": "Options granted", "class": "options", "shares": cap["granted"]})
|
|
195
|
+
pool_after = math.floor(cap["unissued"] + pool_inc + 1e-9)
|
|
196
|
+
rows.append({"name": "Option pool, unissued", "class": "options", "shares": pool_after})
|
|
197
|
+
total = sum(r["shares"] for r in rows)
|
|
198
|
+
for r in rows:
|
|
199
|
+
r["pct"] = r["shares"] / total * 100
|
|
200
|
+
post_val = price * total
|
|
201
|
+
for c in cap["convs"]:
|
|
202
|
+
if c["mfn"] and not c["cap"] and not c["discount"]:
|
|
203
|
+
warns.append(f"{c['holder']}: an MFN SAFE with no terms of its own converts at the round price here; "
|
|
204
|
+
"check whether a later SAFE's cap or discount applies to it")
|
|
205
|
+
fails = []
|
|
206
|
+
if abs(sum(r["pct"] for r in rows) - 100) > 1e-6:
|
|
207
|
+
fails.append("ownership does not sum to 100 %")
|
|
208
|
+
expected = pre + new_money + (0 if in_pre else (pool_after - cap["unissued"]) * price)
|
|
209
|
+
if abs(post_val - expected) / expected > 0.001:
|
|
210
|
+
fails.append(f"post-money {post_val:,.0f} is not pre-money + new money {expected:,.0f}")
|
|
211
|
+
return {"pre_money": pre, "new_money": new_money, "price": price, "pool_increase": pool_after - cap["unissued"],
|
|
212
|
+
"pool_after": pool_after, "convs": convs, "investors": investors, "rows": rows, "total": total,
|
|
213
|
+
"post_money": post_val, "pool_in": rnd["pool_in"],
|
|
214
|
+
"effective_pre": pre - ((pool_after - cap["unissued"]) * price if in_pre else 0),
|
|
215
|
+
"warns": warns, "fails": fails}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def stack(cap):
|
|
219
|
+
"""What the post-money SAFEs already promise: amount ÷ cap each, before any round."""
|
|
220
|
+
out = []
|
|
221
|
+
for c in cap["convs"]:
|
|
222
|
+
if c["kind"] == "safe" and c["basis"] == "post" and c["cap"]:
|
|
223
|
+
out.append((c["holder"], c["amount"], c["cap"], c["amount"] / c["cap"]))
|
|
224
|
+
return out
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def money(v, cur):
|
|
228
|
+
sym = {"USD": "$", "EUR": "€", "GBP": "£", "INR": "₹"}.get(cur.upper(), "")
|
|
229
|
+
return f"{sym}{v:,.0f}" if sym else f"{v:,.0f} {cur}"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def report(cap, r, scen):
|
|
233
|
+
cur = cap["currency"]
|
|
234
|
+
L = [f"# {cap['company']}: cap table", "", "## Today, fully diluted", "",
|
|
235
|
+
"| Holder | Class | Shares | % |", "|---|---|---:|---:|"]
|
|
236
|
+
base = today_fd(cap)
|
|
237
|
+
for h in cap["holders"]:
|
|
238
|
+
L.append(f"| {h['name']} | {h['class']} | {h['shares']:,.0f} | {h['shares'] / base * 100:.2f} |")
|
|
239
|
+
L.append(f"| Options granted | options | {cap['granted']:,.0f} | {cap['granted'] / base * 100:.2f} |")
|
|
240
|
+
L.append(f"| Option pool, unissued | options | {cap['unissued']:,.0f} | {cap['unissued'] / base * 100:.2f} |")
|
|
241
|
+
L.append(f"| **Total** | | **{base:,.0f}** | **100.00** |")
|
|
242
|
+
st = stack(cap)
|
|
243
|
+
if st:
|
|
244
|
+
L += ["", "Post-money SAFEs already promise, at their caps (amount ÷ cap):", ""]
|
|
245
|
+
for name, amt, capv, share in st:
|
|
246
|
+
L.append(f"- {name}: {money(amt, cur)} ÷ {money(capv, cur)} = {share * 100:.2f} %")
|
|
247
|
+
tot = sum(s[3] for s in st)
|
|
248
|
+
L.append(f"- **Together: {tot * 100:.2f} %** of the company before the round's new money and pool top-up")
|
|
249
|
+
if r is None:
|
|
250
|
+
L += ["", "No priced round given, so nothing converts yet."]
|
|
251
|
+
return "\n".join(L) + "\n"
|
|
252
|
+
if "error" in r:
|
|
253
|
+
L += ["", f"FAIL {r['error']}"]
|
|
254
|
+
return "\n".join(L) + "\n"
|
|
255
|
+
L += ["", "## The round", "",
|
|
256
|
+
f"- Pre-money valuation: {money(r['pre_money'], cur)}; new money: {money(r['new_money'], cur)}; "
|
|
257
|
+
f"post-money: {money(r['post_money'], cur)}",
|
|
258
|
+
f"- Price per share: {r['price']:.4f}",
|
|
259
|
+
f"- Option pool top-up ({'in the pre-money' if r['pool_in'] == 'pre' else 'after the round, diluting the new investors too'}): "
|
|
260
|
+
f"{r['pool_increase']:,.0f} shares, unissued pool after: {r['pool_after']:,.0f} "
|
|
261
|
+
f"({r['pool_after'] / r['total'] * 100:.2f} % post-money)",
|
|
262
|
+
f"- Effective pre-money for existing holders, after the pool top-up: {money(r['effective_pre'], cur)}"]
|
|
263
|
+
if r["convs"]:
|
|
264
|
+
L += ["", "| SAFE or note | Invested (with interest) | Converts at | By | Shares |", "|---|---:|---:|---|---:|"]
|
|
265
|
+
for c in r["convs"]:
|
|
266
|
+
L.append(f"| {c['holder']} ({c['basis']}-money {c['kind']}) | {money(c['invested'], cur)} | "
|
|
267
|
+
f"{c['price']:.4f} | {c['by']} | {c['shares']:,.0f} |")
|
|
268
|
+
L += ["", "## After the round, fully diluted", "", "| Holder | Class | Shares | % |", "|---|---|---:|---:|"]
|
|
269
|
+
for row in r["rows"]:
|
|
270
|
+
L.append(f"| {row['name']} | {row['class']} | {row['shares']:,.0f} | {row['pct']:.2f} |")
|
|
271
|
+
L.append(f"| **Total** | | **{r['total']:,.0f}** | **100.00** |")
|
|
272
|
+
if scen:
|
|
273
|
+
L += ["", "## At other pre-money valuations", "",
|
|
274
|
+
"| Pre-money | Price per share | " + " | ".join(h["name"] for h in cap["holders"]) + " | New investors |",
|
|
275
|
+
"|---:|---:|" + "---:|" * (len(cap["holders"]) + 1)]
|
|
276
|
+
for v in scen:
|
|
277
|
+
s = model(cap, v)
|
|
278
|
+
if s is None or "error" in s:
|
|
279
|
+
L.append(f"| {money(v, cur)} | cannot settle | " + " | " * len(cap["holders"]) + " |")
|
|
280
|
+
continue
|
|
281
|
+
pct = {row["name"]: row["pct"] for row in s["rows"]}
|
|
282
|
+
inv = sum(pct[x["name"]] for x in s["investors"])
|
|
283
|
+
L.append(f"| {money(v, cur)} | {s['price']:.4f} | " + " | ".join(f"{pct[h['name']]:.2f} %" for h in cap["holders"])
|
|
284
|
+
+ f" | {inv:.2f} % |")
|
|
285
|
+
L += ["", "Conventions: post-money SAFEs exclude the round's pool top-up from their cap price; pre-money SAFEs "
|
|
286
|
+
"and notes divide their cap by existing shares plus the top-up; the top-up is in the pre-money; note "
|
|
287
|
+
"interest is simple; shares round down."]
|
|
288
|
+
for f in r["fails"]:
|
|
289
|
+
L.append(f"FAIL {f}")
|
|
290
|
+
for w in r["warns"]:
|
|
291
|
+
L.append(f"WARN {w}")
|
|
292
|
+
return "\n".join(L) + "\n"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def parse_scenarios(text):
|
|
296
|
+
"""'8m,12m', '8000000 12000000' or '8M;12.5M' -> [8e6, 12e6]."""
|
|
297
|
+
out = []
|
|
298
|
+
for tok in text.replace(";", " ").replace(",", " ").split():
|
|
299
|
+
t = tok.strip().lower().lstrip("$€£₹")
|
|
300
|
+
mult = {"k": 1e3, "m": 1e6, "b": 1e9}.get(t[-1:], 1)
|
|
301
|
+
if mult != 1:
|
|
302
|
+
t = t[:-1]
|
|
303
|
+
try:
|
|
304
|
+
out.append(num(float(t) * mult, "scenario", 1))
|
|
305
|
+
except ValueError:
|
|
306
|
+
raise InputError(f"scenario {tok!r} is not a valuation; write 8m or 8000000")
|
|
307
|
+
return out
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def main(argv=None):
|
|
311
|
+
ap = argparse.ArgumentParser(description="Model ownership through SAFEs, notes and a priced round.")
|
|
312
|
+
ap.add_argument("cap")
|
|
313
|
+
ap.add_argument("--scenarios", help="comma-separated pre-money valuations to compare")
|
|
314
|
+
ap.add_argument("--csv", help="write the cap table after the round here")
|
|
315
|
+
ap.add_argument("--json", action="store_true", help="print the figures as JSON")
|
|
316
|
+
args = ap.parse_args(argv)
|
|
317
|
+
try:
|
|
318
|
+
with open(args.cap, encoding="utf-8-sig") as f:
|
|
319
|
+
cap = load(json.load(f))
|
|
320
|
+
scen = parse_scenarios(args.scenarios) if args.scenarios else []
|
|
321
|
+
except (OSError, ValueError, UnicodeDecodeError) as e:
|
|
322
|
+
print(f"FAIL cannot read input: {e}")
|
|
323
|
+
return 2
|
|
324
|
+
except InputError as e:
|
|
325
|
+
print(f"FAIL {e}")
|
|
326
|
+
return 2
|
|
327
|
+
r = model(cap)
|
|
328
|
+
if args.json:
|
|
329
|
+
print(json.dumps({"today_fd": today_fd(cap), "safe_stack": stack(cap), "round": r}, indent=2, default=str))
|
|
330
|
+
else:
|
|
331
|
+
print(report(cap, r, scen), end="")
|
|
332
|
+
if args.csv and r and "error" not in r:
|
|
333
|
+
with open(args.csv, "w", encoding="utf-8", newline="") as f:
|
|
334
|
+
w = csv.writer(f)
|
|
335
|
+
w.writerow(["Holder", "Class", "Shares", "Percent"])
|
|
336
|
+
for row in r["rows"]:
|
|
337
|
+
w.writerow([row["name"], row["class"], int(row["shares"]), f"{row['pct']:.4f}"])
|
|
338
|
+
if r and ("error" in r or r["fails"]):
|
|
339
|
+
return 1
|
|
340
|
+
return 0
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
if __name__ == "__main__":
|
|
344
|
+
sys.exit(main())
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"company": "Example Labs, Inc.",
|
|
3
|
+
"currency": "USD",
|
|
4
|
+
"holders": [
|
|
5
|
+
{"name": "Founder A", "class": "common", "shares": 4500000},
|
|
6
|
+
{"name": "Founder B", "class": "common", "shares": 4500000}
|
|
7
|
+
],
|
|
8
|
+
"options": {"granted": 250000, "pool_unissued": 750000},
|
|
9
|
+
"safes": [
|
|
10
|
+
{"holder": "Angel syndicate", "amount": 500000, "type": "post", "cap": 8000000},
|
|
11
|
+
{"holder": "Accelerator", "amount": 125000, "type": "post", "cap": 6000000, "discount": 0.2},
|
|
12
|
+
{"holder": "Early angel", "amount": 100000, "type": "pre", "cap": 5000000, "discount": 0.2}
|
|
13
|
+
],
|
|
14
|
+
"notes": [
|
|
15
|
+
{"holder": "Friends and family note", "principal": 50000, "rate": 0.06, "issued": "2025-06-01", "cap": 5000000, "discount": 0.2}
|
|
16
|
+
],
|
|
17
|
+
"round": {
|
|
18
|
+
"pre_money": 16000000,
|
|
19
|
+
"investors": [{"name": "Series A lead", "amount": 4000000}, {"name": "Series A follow-on", "amount": 1000000}],
|
|
20
|
+
"pool_target": 0.10,
|
|
21
|
+
"close": "2026-12-15"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: contract-check
|
|
3
|
+
description: Check an agreement before a founder signs or sends it — a script finds the mechanical faults a read-through misses (blanks left in, cross-references to clauses or exhibits that don't exist, numbering that skips, terms defined twice or used undefined, amounts in words that disagree with the figures, dates that don't exist, and the date notice must be given to stop an auto-renewal) — then reads it for the terms that hurt a small company: uncapped liability, one-sided indemnities, IP that leaves, exclusivity, auto-renewal, payment and termination traps. Use when a founder says "review this contract before I sign", "check this NDA / MSA / SaaS agreement / consulting agreement", "anything wrong with this agreement", "what should I push back on", or "is this ready to send".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# contract-check
|
|
7
|
+
|
|
8
|
+
A small company signs agreements without a lawyer on every one: a customer's MSA, a vendor's
|
|
9
|
+
order form, an NDA, a contractor's agreement. Two kinds of fault hurt it. **Mechanical ones**
|
|
10
|
+
that a read-through misses, because the eye reads what the drafter meant: a clause that
|
|
11
|
+
survives termination by reference to a section that was renumbered away, a fee left as `[●]`,
|
|
12
|
+
"thirty (60) days". A script finds every one, with its line. **Commercial ones** a founder
|
|
13
|
+
doesn't know to look for: liability uncapped one way and capped the other, the founder's IP
|
|
14
|
+
assigned to a customer, an auto-renewal with a notice window that has already passed. The
|
|
15
|
+
agent reads for those, from the founder's side.
|
|
16
|
+
|
|
17
|
+
## How to respond
|
|
18
|
+
|
|
19
|
+
**These rules come before the steps and outrank them:**
|
|
20
|
+
|
|
21
|
+
- **Lead with the verdict and the few things that matter.** ✅ *"Don't sign it as is. Three
|
|
22
|
+
things: liability is uncapped for you and capped at three months' fees for them (6.1), the
|
|
23
|
+
fee is still a blank (3.1), and 8.2 says Section 14.3 survives termination, but there is no
|
|
24
|
+
14.3."* Then the rest. Never mention a script, this skill's name, or a path inside its folder, never call your work "the check" or "a tool", and never show a temporary path.
|
|
25
|
+
- **Every mechanical finding comes from the check,** with its clause number. Never claim a
|
|
26
|
+
cross-reference is broken, or a term undefined, from a read alone.
|
|
27
|
+
- **Read for the founder's side.** Say who the founder is in the agreement (customer,
|
|
28
|
+
supplier, discloser) before judging a clause; a one-sided clause is a problem only if it
|
|
29
|
+
cuts against them. Ask once if the document doesn't make it clear.
|
|
30
|
+
- **For each problem, give the fix as words to send.** ✅ *"Ask for: 'Each party's total
|
|
31
|
+
liability is limited to the fees paid in the 12 months before the claim.'"* A founder can
|
|
32
|
+
paste that into a redline or an email.
|
|
33
|
+
- **Name what is worth a lawyer's hour, and only that.** One or two lines, for the clauses
|
|
34
|
+
where a mistake is expensive or the law differs by country (IP assignment, non-competes,
|
|
35
|
+
indemnities with no cap, personal guarantees). Never refuse, never open with a disclaimer.
|
|
36
|
+
- **Their document is never overwritten.** A corrected or marked-up copy goes beside it under
|
|
37
|
+
a plain name (`agreement-marked-up.docx` or `.md`), and only when asked for. Working files
|
|
38
|
+
go in a private temporary folder (`mktemp -d`) and are deleted before replying.
|
|
39
|
+
|
|
40
|
+
1. **Find out the deal.** From the document and the conversation: who the founder's company
|
|
41
|
+
is in it, what it is buying or selling, the money, and the date they expect to sign. If the
|
|
42
|
+
current term's end date is known, keep it for step 2.
|
|
43
|
+
|
|
44
|
+
2. **Run the check.** It reads Markdown, plain text and Word files.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Claude Code
|
|
48
|
+
python3 "${CLAUDE_SKILL_DIR}/scripts/contract_check.py" ./agreement.docx --term-end 2027-07-01
|
|
49
|
+
# Other IDEs (from the skill folder)
|
|
50
|
+
python3 scripts/contract_check.py ./agreement.docx
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Exit 0 means no failures, 1 means a failure, 2 means the file can't be read (a PDF: convert
|
|
54
|
+
it to text first). It fails on blanks, broken cross-references, repeated clause numbers,
|
|
55
|
+
terms defined twice, amounts whose words and figures disagree, and impossible dates. It
|
|
56
|
+
warns on skipped numbering, defined terms never used or used in lower case, and roles used
|
|
57
|
+
as defined terms but never defined (*"the Client"* in an agreement that defines *"the
|
|
58
|
+
Customer"*). It lists every notice period and, with `--term-end`, the last day to give each
|
|
59
|
+
renewal notice. A warning stands in the reply only if it matters to this deal.
|
|
60
|
+
|
|
61
|
+
3. **Read the agreement for the founder,** clause by clause, against
|
|
62
|
+
[`reference.md`](reference.md): liability and its cap, indemnities, IP ownership and
|
|
63
|
+
licences, confidentiality, payment terms and late fees, term, renewal and termination,
|
|
64
|
+
exclusivity and non-solicitation, data protection, governing law and disputes, assignment
|
|
65
|
+
and change of control. For each problem, the clause, why it hurts this founder, and the
|
|
66
|
+
wording to ask for. Mark severity: 🟥 don't sign as is, 🟧 push back, 🟨 worth asking.
|
|
67
|
+
|
|
68
|
+
4. **Reply in this order:**
|
|
69
|
+
1. **The verdict** in one line (sign, sign after these fixes, don't sign), and the three
|
|
70
|
+
things that matter most.
|
|
71
|
+
2. **Fixes before signing,** most severe first: clause, the problem, the wording to ask for.
|
|
72
|
+
3. **Mechanical faults to correct,** one line each with the clause, from the check.
|
|
73
|
+
4. **Dates to diarise:** each notice deadline, renewal and payment date.
|
|
74
|
+
5. **What to have a lawyer look at,** one or two lines, only where it's earned.
|
|
75
|
+
|
|
76
|
+
**Non-interactive runs** (subagent, CI, headless): when the founder's side isn't stated, take
|
|
77
|
+
it from the document's parties and the file's location, tag it `[assumption]`, and read from
|
|
78
|
+
that side. With no agreement supplied, emit `BLOCKED: need the agreement (as .docx, .md or
|
|
79
|
+
.txt)`.
|
|
80
|
+
|
|
81
|
+
## Useful references in this skill
|
|
82
|
+
|
|
83
|
+
- [`reference.md`](reference.md) — the clauses that hurt a small company, what to ask for in each, and the starting wording
|
|
84
|
+
- [`scripts/contract_check.py`](scripts/contract_check.py) — blanks, cross-references, numbering, defined terms, words-and-figures, dates, notice deadlines
|
|
85
|
+
- [`examples/saas-agreement.md`](examples/saas-agreement.md) — worked example: a supplier's SaaS agreement with seven mechanical faults and an uncapped indemnity
|
|
86
|
+
|
|
87
|
+
## Quality bar
|
|
88
|
+
|
|
89
|
+
- **Every mechanical finding has a clause number** and comes from the check.
|
|
90
|
+
- **The verdict leads,** with the three things that matter most, in plain words.
|
|
91
|
+
- **Every commercial problem says why it hurts this founder** and gives the wording to ask for.
|
|
92
|
+
- **Every notice deadline is a date,** when the term's end is known.
|
|
93
|
+
- **At most two lawyer lines,** each naming the clause and why.
|
|
94
|
+
- **No script, file or skill talk in the reply,** and their document is never overwritten.
|
|
95
|
+
|
|
96
|
+
## When to use this skill
|
|
97
|
+
|
|
98
|
+
- ✅ A customer's or vendor's agreement arrived and the founder is about to sign it
|
|
99
|
+
- ✅ The founder's own template is going out and should be clean
|
|
100
|
+
- ✅ An auto-renewing contract and the question "when do we have to cancel by?"
|
|
101
|
+
- ✅ An NDA, consulting or contractor agreement before the first project starts
|
|
102
|
+
|
|
103
|
+
## When NOT to use this skill
|
|
104
|
+
|
|
105
|
+
- ❌ Drafting an agreement from nothing: start from a reviewed template, then check it here
|
|
106
|
+
- ❌ A dispute already under way, or a letter from someone's lawyer: get a lawyer
|
|
107
|
+
- ❌ Employment contracts, share purchase agreements and anything signed with a personal guarantee: these need a lawyer from the start
|
|
108
|
+
- ❌ What a SAFE or term sheet does to ownership: model it with a cap table
|
|
109
|
+
|
|
110
|
+
## Anti-patterns to avoid
|
|
111
|
+
|
|
112
|
+
- ❌ **"Section 14.3 looks fine" without checking it exists.** Renumbering after a negotiation leaves dangling references; the check finds them.
|
|
113
|
+
- ❌ **Reading from the wrong side.** An uncapped indemnity is a gift if the founder receives it and a threat if they give it.
|
|
114
|
+
- ❌ **A list of thirty issues with no order.** The founder has an hour; lead with what stops the signature.
|
|
115
|
+
- ❌ **"Consider negotiating the liability clause."** Give the words to ask for.
|
|
116
|
+
- ❌ **A disclaimer paragraph at the top.** Say what a lawyer should look at, specifically, at the end.
|
|
117
|
+
- ❌ **Missing the renewal window.** "60 days before the end of the term" is a date; work it out.
|
|
118
|
+
- ❌ **Overwriting their file with a marked-up version.** They lose the one they were sent.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"query": "Review this contract before I sign it",
|
|
4
|
+
"should_trigger": true
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
"query": "Check this NDA the customer sent us",
|
|
8
|
+
"should_trigger": true
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"query": "What should I push back on in this MSA?",
|
|
12
|
+
"should_trigger": true
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"query": "Is our consulting agreement ready to send?",
|
|
16
|
+
"should_trigger": true
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"query": "Compare these three vendors' pricing in a table",
|
|
20
|
+
"should_trigger": false
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"query": "Fill in this security questionnaire from our policies",
|
|
24
|
+
"should_trigger": false
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"query": "Write the DPIA for our new analytics feature",
|
|
28
|
+
"should_trigger": false
|
|
29
|
+
}
|
|
30
|
+
]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill_name": "contract-check",
|
|
3
|
+
"evals": [
|
|
4
|
+
{
|
|
5
|
+
"id": 1,
|
|
6
|
+
"prompt": "Northgate sent their SaaS agreement, it's saas-agreement.md. We're Brightloop, the customer. The first year ends 1 July 2027. Anything I should push back on before I sign?",
|
|
7
|
+
"assertions": [
|
|
8
|
+
"Opens with a verdict and the few problems that matter most, not with a disclaimer",
|
|
9
|
+
"Flags the [●] fee in 3.1, the missing Exhibit C in 2.1, and the reference to Section 14.3 in 8.2, which doesn't exist",
|
|
10
|
+
"Flags 'thirty (60) days' in 3.2 and the impossible date 31 June 2026",
|
|
11
|
+
"Reads from the customer's side and flags the one-way indemnity in 6.3 and the one-sided liability cap in 6.1, each with wording to ask for",
|
|
12
|
+
"Gives the renewal notice deadline as 2 May 2027",
|
|
13
|
+
"Notes 'the Client' is used where the agreement defines 'the Customer'",
|
|
14
|
+
"Does not mention a script or the skill's name, and does not invent clauses that aren't in the agreement"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": 2,
|
|
19
|
+
"held_out": "2026-11-06",
|
|
20
|
+
"prompt": "our first contractor sent this over, can you check consulting-agreement.md before I sign? anything I should push back on",
|
|
21
|
+
"assertions": [
|
|
22
|
+
"It works out that the founder is the client, Halyard Robotics ('Company'), hiring the consultant, and reviews the terms from Halyard's side",
|
|
23
|
+
"It flags the date that doesn't exist (February 30, 2027) in the opening paragraph and the fee in clause 2.1 where the words (Twelve Thousand Dollars) disagree with the figures ($21,000), and says the right amount has to be confirmed before signing",
|
|
24
|
+
"It flags the blank expense cap in clause 2.3, the numbering that jumps from section 6 to section 8 (there is no section 7), and the reference in clause 11.2 to an Exhibit C that isn't attached",
|
|
25
|
+
"It says that under clauses 6.1–6.3 Halyard will not own the robot-housing designs it is paying for (it gets only a non-exclusive internal licence, and the consultant can license the same designs to others), and asks for the IP to be assigned to Halyard on payment",
|
|
26
|
+
"It flags the one-sided, uncapped indemnity in clauses 10.1 and 9.3: Halyard covers the consultant even for claims that the consultant's own designs infringe someone else's IP. It asks for the indemnity to be mutual, or for the consultant to give the IP-infringement indemnity",
|
|
27
|
+
"It flags the termination imbalance in clauses 3.2–3.4: the consultant can walk away on 5 days' notice, Halyard can only end the agreement for an uncured breach, and Halyard still owes the full fee on any termination. It asks for a right to terminate for convenience that pays only for work done",
|
|
28
|
+
"It does not invent clauses, auto-renewal terms, notice deadlines, amounts or parties that are not in the agreement"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": 3,
|
|
33
|
+
"held_out": "2026-11-06",
|
|
34
|
+
"prompt": "I'm the founder of Brightmoor Analytics, a 6-person UK company. We make Brightmoor Insight, planning-analytics software for local councils. Calder Systems want to resell it to councils, and their lawyer sent over reseller-agreement.md. We're the Supplier. Is it ready to sign, and what should I push back on before our call on Thursday? Also, if the first year goes badly, what's the last date I can tell them we don't want it to roll over?",
|
|
35
|
+
"assertions": [
|
|
36
|
+
"It flags that 'Territory' is defined twice and the two definitions conflict (England and Wales in clause 1.6; the United Kingdom and the Republic of Ireland in clause 3.1), and says which one widens the exclusivity",
|
|
37
|
+
"It flags that 'Net Revenue', the basis for Brightmoor's 60% share in clause 5.1, is never defined; that clause 6.1 refers to a Schedule 3 that doesn't exist; that the onboarding fee in clause 5.3 says fifteen thousand pounds in words but GBP 50,000 in figures; and that Calder's registered office is left blank, even though clause 13.2 sends notices there",
|
|
38
|
+
"It gives the non-renewal deadline as 1 January 2028 (90 days before the Initial Term ends on 31 March 2028; 31 December 2027 is also acceptable if it counts clear days). It points out that 1 January 2028 is a Saturday and a bank holiday and that notice must go by recorded delivery, so it should be sent well before then. It also warns that a missed deadline locks Brightmoor in for 24 more months, to 31 March 2030",
|
|
39
|
+
"It flags the exclusivity in clauses 2.1–2.4: Brightmoor is barred from selling to any public-sector body in the Territory, which is its whole market, while Calder has no minimum sales commitment and can appoint sub-resellers. It asks for minimum targets, the right to end exclusivity if they're missed, a narrower territory, or a carve-out for direct sales",
|
|
40
|
+
"It flags the liability imbalance: Brightmoor's liability is unlimited (clause 11.3) and it gives a broad indemnity for anything 'in connection with' the agreement (clause 10.1), while Calder's liability is capped at GBP 10,000 (clause 11.2). It asks for a mutual cap, for example tied to fees paid, and for the indemnity to be narrowed to IP-infringement claims",
|
|
41
|
+
"It flags the pay-when-paid terms in clause 5.2: Brightmoor is paid only 90 days after the end of the month in which the council pays Calder, and not at all if the council doesn't pay, so Brightmoor carries Calder's credit risk. It asks for fixed payment terms counted from Calder's invoice to the council",
|
|
42
|
+
"It does not invent clauses, schedules, prices, dates or parties that are not in the agreement, and it does not present its review as legal advice that replaces a solicitor"
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Software Services Agreement
|
|
2
|
+
|
|
3
|
+
This Software Services Agreement (the "Agreement") is made on 31 June 2026 between Northgate Analytics Ltd, a company registered in England (the "Supplier"), and Brightloop Labs Inc. (the "Customer").
|
|
4
|
+
|
|
5
|
+
## 1. Definitions
|
|
6
|
+
|
|
7
|
+
1.1 "Services" means the hosted analytics platform described in Exhibit A.
|
|
8
|
+
|
|
9
|
+
1.2 "Confidential Information" means any information disclosed by one party to the other that is marked confidential or would reasonably be understood to be confidential.
|
|
10
|
+
|
|
11
|
+
1.3 "Customer Data" means data uploaded to the Services by the Customer.
|
|
12
|
+
|
|
13
|
+
1.4 "Fees" means the fees set out in Exhibit B.
|
|
14
|
+
|
|
15
|
+
## 2. Services
|
|
16
|
+
|
|
17
|
+
2.1 The Supplier shall provide the Services to the Customer from the Effective Date in accordance with the service levels in Exhibit C.
|
|
18
|
+
|
|
19
|
+
2.2 The Supplier may update the Services at any time, provided it does not materially reduce their functionality.
|
|
20
|
+
|
|
21
|
+
## 3. Fees and Payment
|
|
22
|
+
|
|
23
|
+
3.1 The Customer shall pay the Fees of [●] per month, invoiced monthly in advance.
|
|
24
|
+
|
|
25
|
+
3.2 Invoices are payable within thirty (60) days of the invoice date.
|
|
26
|
+
|
|
27
|
+
3.3 Late payments bear interest at four percent (4%) per month.
|
|
28
|
+
|
|
29
|
+
## 4. Term and Renewal
|
|
30
|
+
|
|
31
|
+
4.1 This Agreement starts on the Effective Date and continues for twelve (12) months (the "Initial Term").
|
|
32
|
+
|
|
33
|
+
4.2 It renews automatically for successive twelve (12) month periods unless either party gives at least sixty (60) days' written notice before the end of the then-current term.
|
|
34
|
+
|
|
35
|
+
## 5. Confidentiality
|
|
36
|
+
|
|
37
|
+
5.1 Each party shall keep the other's confidential information secret and use it only to perform this Agreement.
|
|
38
|
+
|
|
39
|
+
5.2 "Confidential Information" includes the terms of this Agreement.
|
|
40
|
+
|
|
41
|
+
## 6. Liability
|
|
42
|
+
|
|
43
|
+
6.1 The Supplier's total liability under this Agreement is limited to the Fees paid in the three (3) months before the claim.
|
|
44
|
+
|
|
45
|
+
6.2 Nothing in Section 6.1 limits liability for death or personal injury caused by negligence.
|
|
46
|
+
|
|
47
|
+
6.3 The Client shall indemnify the Supplier against any claim arising from the Client's use of the Services.
|
|
48
|
+
|
|
49
|
+
## 8. Termination
|
|
50
|
+
|
|
51
|
+
8.1 Either party may terminate this Agreement on ninety (90) days' written notice.
|
|
52
|
+
|
|
53
|
+
8.2 On termination, Section 5 and Section 14.3 survive.
|
|
54
|
+
|
|
55
|
+
## 9. General
|
|
56
|
+
|
|
57
|
+
9.1 This Agreement is governed by the laws of England and Wales.
|
|
58
|
+
|
|
59
|
+
Signed: ____________________
|
|
60
|
+
|
|
61
|
+
# Exhibit A — Services
|
|
62
|
+
|
|
63
|
+
1. Dashboards
|
|
64
|
+
2. Data connectors
|
|
65
|
+
|
|
66
|
+
# Exhibit B — Fees
|
|
67
|
+
|
|
68
|
+
1. Platform fee: $2,000 per month
|