← Accretion Desk / API
Tokens

Drive Accretion Desk from your own code

Everything the web page does is available over HTTP. Send the merger model the browser computes for one proposed acquisition and get the same review back: a verdict, the drivers of the EPS result, the assumptions to challenge, one response per flag, structure options backed by the sensitivity grids, diligence questions and a board summary. The natural use is a deal screen: a script builds the model for each candidate structure, asks for the review, and files the board summary next to the model.

One thing to be clear about before the first call: the model never does the arithmetic. The merger model is built by merger.js, the same file the web page loads, and the result is sent as facts, a JSON string. The model's job is judgement over those figures. See building the facts below.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }

The token is minted for this app (the guest endpoint takes {"slug":"accretion-desk"} in its body), so no slug header is needed afterwards. Send your token as Authorization: Bearer … on every call.

The input object IS the request body. There is no {"input": …} wrapper. A wrapped body returns a 200 with an unknown field 'input' warning, and the model never sees your facts.

Error codes

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits. Call /estimate first and top up.
forbidden403The token is valid but not for this app, or a guest token tried a metered run.
not_found404Unknown job id, unknown collection, or the app slug does not exist.
conflict409The same Idempotency-Key was replayed with a different body. Change the key or send the original input.
validation_error422A field is the wrong type. facts must be a string, not an object. A body that is not valid JSON at all comes back as a 400.
rate_limited429Too many requests. Back off and retry; do not tight-loop.
internal5xxA server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice.

1. Get a token

The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. Nothing on that page needs a developer tool — it reads the same storage the app itself uses and prints the token for you.

A guest token can call /me and /estimate. A review is metered, so it needs a personal token from signing in.

# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
#   https://accretion-desk.skillsafe.ai/tokens.html
#   export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; reviewing a deal needs a personal token
# from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" -d '{"slug":"accretion-desk"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest"}}

2. A tiny client

One helper that adds the headers, unwraps data and raises on error.

# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="accretion-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://accretion-desk.skillsafe.ai/tokens.html

call() {                  # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
  fi
}

3. Check the session and the balance

GET /me tells you whether the token is a guest or a person, and what the balance is. subject_type is guest or user — a guest can price a run but cannot start one — and credits is the wallet balance in credits. Compare it against min_credits from the next step before you run, so a shortfall surfaces as your own clear message rather than a 402.

call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}

4. Price the review (free)

The input object is exactly what the app's form submits. The first field is task. This app has one lane, so it is always review. A missing or unknown task is still answered as review, and the reply's lane says so.

taskwhat it does
reviewReviews the merger model: verdict (supportable, stretched, not_supportable), headline, merger consequences summary, drivers, challenges, flag responses, structure options, diligence questions, board summary and summary.
fieldtypemeaning
taskstring, required"review"
factsstring, requiredThe JSON-encoded output of Merger.buildFacts: inputs, purchase price, sources and uses, PPA, shares, three years of pro forma EPS, funding cost, breakeven, leverage, bridges, grids, flags and rules.
questionstringWhat you want to know, up to 2,000 characters. May be empty.
retry_notestringOnly when resubmitting after an unparseable reply: a plain instruction about the reply's shape.

The app declares an input schema with task and facts required, so an estimate of an empty body comes back with missing required field warnings. A warning is not a rejection: check the warnings array yourself before you run.

Building the facts

merger.js is plain JavaScript with no dependencies and exports itself to node. Download merger.js next to your script, put the deal in a JSON file with the field ids below (the page's Save deal .json button writes exactly this file), and let it build the body:

// make-body.js - node make-body.js deal.json "your question" > body.json
const fs = require("fs");
const M = require("./merger.js");            // https://accretion-desk.skillsafe.ai/merger.js
const file = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const res = M.compute(file.deal || file);
if (!res.ok) throw new Error(res.errors.join(" "));
process.stdout.write(JSON.stringify(M.buildInput(res, process.argv[3] || file.question || "")));

The deal fields (money in $ millions, shares in millions, prices in $; blanks take the defaults shown on the page):

field idmeaning
deal_nameDeal name
acq_nameAcquirer
acq_priceAcquirer share price ($)
acq_sharesAcquirer diluted shares (m)
acq_niAcquirer Year 1 net income ($m)
acq_growthAcquirer net income growth (%/yr)
acq_cashAcquirer cash on balance sheet ($m)
acq_debtAcquirer existing debt ($m)
acq_ebitdaAcquirer Year 1 EBITDA ($m)
tax_rateAcquirer marginal tax rate (%)
tgt_nameTarget
tgt_priceTarget unaffected share price ($)
tgt_sharesTarget diluted shares (m)
tgt_niTarget Year 1 net income ($m)
tgt_growthTarget net income growth (%/yr)
tgt_net_debtTarget net debt ($m)
tgt_bookTarget book value of equity ($m)
tgt_ebitdaTarget Year 1 EBITDA ($m)
tgt_debt_rateTarget interest rate on its debt (%)
refinanceRefinance the target's net debt at close
premium_pctOffer premium to unaffected price (%)
offer_priceOffer price per share ($) - overrides the premium
stock_pctStock share of the equity consideration (%)
cash_useAcquirer cash used in the deal ($m)
debt_ratePre-tax rate on new acquisition debt (%)
cash_rateInterest earned on cash today (%)
synergiesRun-rate pre-tax synergies ($m)
integration_costOne-time integration cost in Year 1, pre-tax ($m)
adv_feesAdvisory and transaction fees ($m)
fin_feesFinancing fees ($m)
fin_fee_yearsFinancing fee amortisation (years)
intang_pctExcess purchase price allocated to intangibles (%)
amort_yearsIntangible amortisation life (years)
new_debt_interest_after_tax_mdebt_int_at
pf_net_income_adjusted_mpf_ni_adj
integration_cost_after_tax_minteg_at
pf_eps_gaappf_eps_gaap

A worked body, from the page's stock-merger example (the facts string is abbreviated here):

{
  "task": "review",
  "facts": "{\"units\":\"money in $ millions, shares in millions, per-share values in $\",\"inputs\":{\"deal_name\":\"Harbor Foods / Verdant Kitchen\",\"acq_name\":\"Harbor Foods\",\"acq_price\":38,\"acq_shares\":480,\"acq_ni\":1520,\"acq_growth\":3,\"acq...",
  "question": "The board likes the growth story. Can a mostly-stock deal at this price be defended on EPS, and how much rests on the synergies?"
}
# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.js above, or take the worked example from this page.
INPUT=$(cat body.json)

call estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#   "markup_bps":1000,"hold_credits":...,"min_credits":...,"sponsor_enabled":false,
#   "input_checked":true,"warnings":[]}}
#
# estimate creates no job and charges nothing. hold_credits is what gets
# RESERVED; charged_credits after settlement is normally much lower.

5. Run it, then poll

POST /run returns a job_id; poll GET jobs/{job_id} until status is succeeded or failed. The reply is the string at data.output.output. The terminal job also carries charged_credits (the real price) and the truncated flag.

Always send an Idempotency-Key. Derive it from the input as the web app does, with the lane and an attempt counter: accretion-desk:review:<hash>:a1. A retried request with the same key returns the same job instead of billing a second run. Replaying a key with a different body is a 409, so bump the attempt suffix when you resend a changed body.

# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
KEY="accretion-desk:review:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

while :; do
  OUT=$(call "jobs/$JOB")
  STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
  sleep 2
done

# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
#   "output":{"output":"{\"lane\":\"review\",\"verdict\":\"stretched\", ...}"},
#   "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > review.json

6. Or stream it

POST /run-stream is the same call over server-sent events. Each delta event carries {"text": "..."}, a chunk of the reply, and the final done event carries status, charged_credits and truncated. A browser client may receive progress ticks rather than text deltas; the finished job from step 5 always has the whole reply.

# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag.
curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -H "Accept: text/event-stream" \
  -d "$INPUT"

# event: job    {"job_id":"job_..."}
# event: delta  {"text":"{\"lane\":\"review\",\"verdict\":\"stretched\","}
# event: done   {"status":"succeeded","charged_credits":...,"truncated":false}

7. Parse the reply

data.output.output is a string holding one JSON object. The web app strips any code fence, takes everything from the first { to the last }, parses it and normalizes it: an unknown verdict falls back to stretched, an unknown severity to medium, and missing arrays become empty. Then it checks the reply against the facts it sent. You should do the same.

# review.json holds data.output.output from step 5. Strip any fence, keep the object:
python3 - <<'EOF'
import json
t = open("review.json").read()
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
print(r["verdict"], "-", r["headline"])
for c in r["challenges"]:
    print(c["severity"], c["field"], c["concern"])
EOF

Invariants worth asserting

The output contract

{
  "lane": "review",
  "verdict": "supportable" | "stretched" | "not_supportable",
  "headline": "one sentence: the merger consequences in plain words, with the Year 1 and Year 3 accretion or dilution on a stated basis",
  "consequences": "3 to 5 sentences for a one-page merger consequences summary: price paid, how it is funded, what happens to EPS over three years on both bases, and what the case depends on",
  "drivers": [
    {"key": "a key from bridge_y1_adjusted or bridge_y1_gaap", "direction": "accretive" | "dilutive",
     "reading": "why this line moves EPS the way it does in this deal, quoting its figure"}
  ],
  "challenges": [
    {"field": "one input field id", "severity": "high" | "medium" | "low",
     "concern": "why this assumption may be wrong or flattering, quoting the relevant figure",
     "test": "what to check in diligence or which grid cell shows the sensitivity"}
  ],
  "flag_responses": [{"code": "a flag code from facts.flags", "response": "what the flag means for this deal and what to do about it"}],
  "structure_options": [
    {"option": "a change to price, mix or financing the user could consider",
     "evidence": "the grid cell or breakeven figure from facts that supports it, quoted exactly",
     "tradeoff": "what the change costs or risks"}
  ],
  "diligence_questions": ["a question for management or advisers that would resolve a key uncertainty"],
  "board_summary": "one paragraph a board member could read in a minute: the recommendation framed as analysis, the key numbers, the main risk",
  "summary": "two sentences: the verdict and why"
}

The flag codes

codeseveritymeaning
dilutive_y1_adjustedhighYear 1 adjusted EPS is below standalone.
dilutive_y1_gaap_onlymediumYear 1 is accretive on adjusted EPS but dilutive on GAAP.
dilutive_y3_adjustedhighStill dilutive on adjusted EPS in Year 3.
accretive_only_with_synergiesmediumAccretive only because of synergies.
stock_pe_gapmediumStock is issued at a lower P/E than the offer P/E.
target_loss_makinghighTarget Year 1 net income is zero or negative.
premium_highmediumPremium above 50%.
premium_negativehighOffer below the unaffected price.
premium_lowlowPremium below 10%.
synergy_heavymediumAfter-tax run-rate synergies are 50% or more of target Year 3 net income.
phase_in_fastmediumMore than 50% of synergies assumed in Year 1.
leverage_very_highhighPro forma net debt above 6x Year 1 EBITDA.
leverage_highmediumPro forma net debt above 4x Year 1 EBITDA.
leverage_unknownlowNew debt raised but EBITDA not given.
shareholder_votemediumNew shares are 20% or more of the current count.
target_holders_controlhighTarget holders would own 50% or more.
cash_exceeds_balancehighCash used is more than the cash on the balance sheet.
bargain_purchasehighEquity price below target book value.
no_book_valuelowNo book value, so no purchase price allocation.
no_intangibleslowExcess price with 0% allocated to intangibles.
fees_highlowFees above 3% of enterprise value.
accretion_extremehighAdjusted accretion of 100% or more in some year: the acquirer's standalone EPS is too small for the percentage to be stable.
no_synergieslowNo synergies assumed.

8. Use it in a deal screen

The verdict is built to gate on. not_supportable means the arithmetic does not carry the price or the leverage; stretched passes with the challenges you should keep with the model.

#!/bin/sh
# Screen a structure: fail the job when the verdict is "not_supportable".
set -e
node make-body.js deal.json "Can this be defended on EPS?" > body.json
INPUT=$(cat body.json)
KEY="accretion-desk:review:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
while :; do
  OUT=$(curl -sS "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
  S=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$S" = succeeded ] && break; [ "$S" = failed ] && exit 2; sleep 3
done
V=$(printf '%s' "$OUT" | python3 -c 'import sys,json;t=json.load(sys.stdin)["data"]["output"]["output"];print(json.loads(t[t.index("{"):t.rindex("}")+1])["verdict"])')
echo "verdict: $V"
[ "$V" != not_supportable ]

Truncation and partial results

When the balance sits between min_credits and hold_credits, the run is not refused. It executes with a reduced output cap and comes back with truncated: true. What you hold then is a prefix of the reply: the drivers and challenges may be complete while the board summary is missing. The web page shows the sections that arrived and says how many of the nine it recovered. From code, check the flag before you treat a reply as complete, then resubmit and increment the attempt suffix on the Idempotency-Key.