Drive DCF Studio programmatically
Everything the web app does, over REST. The model extracts and defends
assumptions; it never does the arithmetic, so what you get back is a structured
ASSUMPTIONS object you feed into your own engine (or into
dcf.js, which ships with this app and runs in Node unchanged).
Base URL and envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api. There is
no /apps/{slug}/ segment — the slug is bound to your
token when it is minted at /guest. Getting that wrong returns
404 not_found.
Every response is one of two shapes:
{"data": {...}} on success, {"error": {"code": "...", "message":
"..."}} on failure. HTTP status and error.code always agree.
| Status | Code | What it means |
|---|---|---|
| 400 | validation_error | The body was not an object, or a field was not a string. |
| 401 | unauthorized | Missing, malformed or revoked bearer token. |
| 402 | payment_required | Balance below min_credits. Call /estimate and /me first - that pair is free and is exactly what the app's preflight does. |
| 404 | not_found | Almost always the route: there is no /apps/{slug}/ segment. The slug is bound to the token at /guest. |
| 409 | conflict | An Idempotency-Key replay whose in-flight original has not finished. |
| 429 | rate_limited | Back off and retry; the data endpoints allow 120 requests/minute. |
| 503 | unavailable | Upstream model capacity. Retry with the same Idempotency-Key. |
1. A token and a tiny client
Grab a token from the token page — it reads the one this
browser already holds, shows whether it is a personal or guest token, and gives you a
SKILLSAFE_TOKEN shell export to copy. You never need the DevTools console.
Fully scripted callers can mint a guest token with POST /guest instead.
# A guest token is enough for /me and the free /estimate call.
# Sign in on https://dcf-studio.skillsafe.ai/tokens.html for a personal token
# whose runs bill your own account.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"dcf-studio"}'
# -> {"data":{"token":"aut_...","subject_type":"guest","credits":0}}
export SKILLSAFE_TOKEN="aut_YOUR_TOKEN_HERE"
import os, json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def call(path, body=None, method=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
BASE + path, data=data,
method=method or ("POST" if data else "GET"),
headers={"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json"})
with urllib.request.urlopen(req) as r:
payload = json.load(r)
if "error" in payload and payload["error"]:
raise RuntimeError(payload["error"])
return payload["data"]
# A guest token, if you do not have one yet:
# print(call("/guest", {"slug": "dcf-studio"})["token"])
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // or read it from your own secret store
async function call(path, body, opts = {}) {
const res = await fetch(BASE + path, {
method: body ? "POST" : "GET",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
...(opts.idempotencyKey ? { "Idempotency-Key": opts.idempotencyKey } : {})
},
body: body ? JSON.stringify(body) : undefined
});
const payload = await res.json();
if (payload.error) throw new Error(payload.error.message || payload.error.code);
return payload.data;
}
// A guest token, if you do not have one yet:
// const { token } = await call("/guest", { slug: "dcf-studio" });
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
func call(path string, body any, idemKey string) (map[string]any, error) {
var buf *bytes.Buffer = bytes.NewBuffer(nil)
method := "GET"
if body != nil {
b, _ := json.Marshal(body)
buf = bytes.NewBuffer(b)
method = "POST"
}
req, _ := http.NewRequest(method, base+path, buf)
req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_TOKEN"))
req.Header.Set("Content-Type", "application/json")
if idemKey != "" {
req.Header.Set("Idempotency-Key", idemKey)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var payload struct {
Data map[string]any `json:"data"`
Error map[string]any `json:"error"`
}
json.NewDecoder(res.Body).Decode(&payload)
if payload.Error != nil {
return nil, fmt.Errorf("%v", payload.Error)
}
return payload.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.util.Map;
public class DcfStudio {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv("SKILLSAFE_TOKEN");
static final HttpClient CLIENT = HttpClient.newHttpClient();
static String call(String path, String jsonBody, String idemKey) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json");
if (idemKey != null) b.header("Idempotency-Key", idemKey);
b = (jsonBody == null)
? b.GET()
: b.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
HttpResponse<String> res = CLIENT.send(b.build(),
HttpResponse.BodyHandlers.ofString());
return res.body(); // {"data": ...} or {"error": ...}
}
}
require "net/http"
require "json"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def call(path, body = nil, idem_key: nil)
uri = URI(BASE + path)
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idem_key if idem_key
req.body = JSON.dump(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise payload["error"].to_s if payload["error"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
function call(string $path, ?array $body = null, ?string $idemKey = null) {
$token = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";
$headers = [
"Authorization: Bearer {$token}",
"Content-Type: application/json",
];
if ($idemKey) { $headers[] = "Idempotency-Key: {$idemKey}"; }
$ch = curl_init(BASE . $path);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (!empty($payload["error"])) {
throw new RuntimeException(json_encode($payload["error"]));
}
return $payload["data"];
}
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
public static class DcfStudio {
const string Base = "https://api.skillsafe.ai/v1/app-api";
static readonly HttpClient Client = new HttpClient();
public static async Task<JsonElement> CallAsync(
string path, object body = null, string idemKey = null) {
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
var req = new HttpRequestMessage(
body == null ? HttpMethod.Get : HttpMethod.Post, Base + path);
req.Headers.Add("Authorization", "Bearer " + token);
if (idemKey != null) req.Headers.Add("Idempotency-Key", idemKey);
if (body != null)
req.Content = new StringContent(
JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var res = await Client.SendAsync(req);
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
if (doc.RootElement.TryGetProperty("error", out var err)
&& err.ValueKind != JsonValueKind.Null)
throw new Exception(err.ToString());
return doc.RootElement.GetProperty("data");
}
}
/me and the free
/estimate. Metered runs need a personal token so they bill your account.2. Who am I, and what is my balance?
GET /me is free. Compare credits against
min_credits from step 3 before you ever call /run; that pair is
the whole credit preflight, and it is what stops a 402 arriving after you have
already sent a 40 KB filing.
curl -s "https://api.skillsafe.ai/v1/app-api/me" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN"
# -> {"data":{"subject_type":"user","subject_id":"usr_...","credits":48210}}
me = call("/me")
print(me["subject_type"], me["credits"])
const me = await call("/me");
console.log(me.subject_type, me.credits);
me, err := call("/me", nil, "")
if err != nil { panic(err) }
fmt.Println(me["subject_type"], me["credits"])
System.out.println(call("/me", null, null));
me = call("/me")
puts "#{me['subject_type']} #{me['credits']}"
$me = call("/me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await DcfStudio.CallAsync("/me");
Console.WriteLine(me.GetProperty("credits"));
3. Estimate — free, no job, no charge
The request body is the input object directly, not
{"input": {...}}. These are the exact fields the web app sends:
| Field | Type | Required | Notes |
|---|---|---|---|
company | string | no | The company name or ticker as the user typed it. May be empty - the model prefers the name found in financials when they disagree. |
financials | string | yes* | The pasted material: historical revenue and operating figures, share count, share price, debt, cash, tax rate and market data (risk-free yield, beta, bond yield). Messy is fine. The web app clips this to 40,000 characters from the middle, keeping both ends and inserting a bracketed marker naming how much was removed. |
assumptions | string | no | A JSON assumptions object the caller already has, passed as a string. Treated as the user's own view: kept unless the financials contradict it, with any override reported under CHECKS. Clipped to 8,000 characters. |
notes | string | no | Free-prose guidance ("management guides 9-11%", "use 7 years"). Clipped to 2,000 characters. |
retry_note | string | no | Sent only by the app's automatic one-shot reformat retry, describing what was wrong with the shape of the previous reply. If you send it yourself, use a different Idempotency-Key from the first attempt - a replayed key returns the original job, not the reformatted one. |
* Either financials or assumptions must be non-empty.
With neither, the model returns VERDICT: Not enough to value and a placeholder
assumptions block rather than inventing a company.
# Free. Creates no job and charges nothing.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "company": "Havenmark Fluid Systems (HVMK)", "financials": "HAVENMARK FLUID SYSTEMS, INC. (NASDAQ: HVMK)\nNet revenue FY2025 1,071.0 ...", "assumptions": "", "notes": "Management guides FY2026 revenue growth of 9-11%."}'
# -> {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":1587,"min_credits":150,
# "sponsor_enabled":false}}
payload = {
"company": "Havenmark Fluid Systems (HVMK)",
"financials": open("hvmk-10k-excerpt.txt").read(),
"assumptions": "", # or a prior assumptions JSON, as a string
"notes": "Management guides FY2026 revenue growth of 9-11%.",
}
est = call("/estimate", payload) # free: no job, no charge
print(est["model"], est["hold_credits"], est["min_credits"])
me = call("/me")
if me["credits"] < est["min_credits"]:
raise SystemExit("top up first - the run would 402")
const payload = {
company: "Havenmark Fluid Systems (HVMK)",
financials: filingText,
assumptions: "", // or a prior assumptions JSON, as a string
notes: "Management guides FY2026 revenue growth of 9-11%."
};
const est = await call("/estimate", payload); // free: no job, no charge
const me = await call("/me");
if (me.credits < est.min_credits) throw new Error("top up first");
payload := map[string]any{
"company": "Havenmark Fluid Systems (HVMK)",
"financials": filingText,
"assumptions": "",
"notes": "Management guides FY2026 revenue growth of 9-11%.",
}
est, err := call("/estimate", payload, "") // free: no job, no charge
if err != nil { panic(err) }
fmt.Println(est["model"], est["hold_credits"])
String body = """
{"company":"Havenmark Fluid Systems (HVMK)",
"financials":"...",
"assumptions":"",
"notes":"Management guides FY2026 revenue growth of 9-11%."}
""";
System.out.println(call("/estimate", body, null)); // free
payload = {
"company" => "Havenmark Fluid Systems (HVMK)",
"financials" => File.read("hvmk-10k-excerpt.txt"),
"assumptions" => "",
"notes" => "Management guides FY2026 revenue growth of 9-11%."
}
est = call("/estimate", payload) # free: no job, no charge
puts est["model"], est["hold_credits"]
$payload = [
"company" => "Havenmark Fluid Systems (HVMK)",
"financials" => file_get_contents("hvmk-10k-excerpt.txt"),
"assumptions" => "",
"notes" => "Management guides FY2026 revenue growth of 9-11%.",
];
$est = call("/estimate", $payload); // free: no job, no charge
echo $est["model"], " ", $est["hold_credits"], PHP_EOL;
var payload = new {
company = "Havenmark Fluid Systems (HVMK)",
financials = filingText,
assumptions = "",
notes = "Management guides FY2026 revenue growth of 9-11%."
};
var est = await DcfStudio.CallAsync("/estimate", payload); // free
Console.WriteLine(est.GetProperty("model"));
model is gpt-5.6-terra, model_alias is
gpt-terra, markup_bps is 1000. Present
hold_credits as reserved, never as the price — the settled charge
is usually far lower.4. Run and poll — metered
Send an Idempotency-Key on every run. Derive it from a hash of the input so
that a retry of the same submission joins the original job instead of billing twice. If you
resubmit with a retry_note to fix a malformed reply, that is a
different request and needs a different key (the app uses
the same hash with a -reformat suffix) — replaying the first key returns
the original job and deduped: true, i.e. the same broken answer.
# METERED. The body is the input object DIRECTLY - not {"input": {...}}.
# Always send an Idempotency-Key: a retried request then joins the original
# job instead of starting and billing a second one.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: dcf-studio-3f2a91c40b7e5da2" \
-d '{ "company": "Havenmark Fluid Systems (HVMK)", "financials": "HAVENMARK FLUID SYSTEMS, INC. (NASDAQ: HVMK)\nNet revenue FY2025 1,071.0 ...", "assumptions": "", "notes": "Management guides FY2026 revenue growth of 9-11%."}'
# -> {"data":{"job_id":"job_..."}}
curl -s "https://api.skillsafe.ai/v1/app-api/jobs/job_..." \
-H "Authorization: Bearer $SKILLSAFE_TOKEN"
# poll until status is "succeeded"; the reply text is data.output.output
import hashlib, time
# Derive the key from the input so a retry of the SAME submission is free.
key = "dcf-studio-" + hashlib.sha256(
json.dumps(payload, sort_keys=True).encode()).hexdigest()[:16]
job = call("/run", payload) # METERED
while True:
j = call("/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "canceled"):
break
time.sleep(1.5)
reply = j["output"]["output"] # the plain-text contract below
print(j.get("charged_credits"), j.get("truncated"))
const job = await call("/run", payload, { idempotencyKey: key }); // METERED
let j;
do {
await new Promise(r => setTimeout(r, 1500));
j = await call(`/jobs/${job.job_id}`);
} while (!["succeeded", "failed", "canceled"].includes(j.status));
const reply = j.output.output; // the plain-text contract below
job, err := call("/run", payload, key) // METERED
if err != nil { panic(err) }
for {
j, _ := call("/jobs/"+job["job_id"].(string), nil, "")
st := j["status"].(string)
if st == "succeeded" || st == "failed" || st == "canceled" {
fmt.Println(j["output"])
break
}
time.Sleep(1500 * time.Millisecond)
}
String job = call("/run", body, key); // METERED
// then poll GET /jobs/{job_id} until status is succeeded/failed/canceled
require "digest"
key = "dcf-studio-" + Digest::SHA256.hexdigest(JSON.dump(payload))[0, 16]
job = call("/run", payload, idem_key: key) # METERED
loop do
j = call("/jobs/#{job['job_id']}")
break puts(j["output"]["output"]) if %w[succeeded failed canceled].include?(j["status"])
sleep 1.5
end
$key = "dcf-studio-" . substr(hash("sha256", json_encode($payload)), 0, 16);
$job = call("/run", $payload, $key); // METERED
do {
sleep(2);
$j = call("/jobs/" . $job["job_id"]);
} while (!in_array($j["status"], ["succeeded", "failed", "canceled"], true));
echo $j["output"]["output"];
var job = await DcfStudio.CallAsync("/run", payload, key); // METERED
JsonElement j;
do {
await Task.Delay(1500);
j = await DcfStudio.CallAsync($"/jobs/{job.GetProperty("job_id")}");
} while (j.GetProperty("status").GetString() is not ("succeeded" or "failed" or "canceled"));
Console.WriteLine(j.GetProperty("output").GetProperty("output"));
The terminal job carries output.output (the reply text),
charged_credits and truncated. When truncated is
true the output cap was reached: show it as incomplete rather than as a finished model.
5. Stream it — metered
/run-stream takes the same body and the same key and emits SSE. The section
headings arrive in contract order, which is what the web app uses to advance its progress
stages. Keep the accumulated text even if the stream dies: a partial reply usually still
contains a complete ASSUMPTIONS block, and the app repairs a block cut mid-JSON
by closing what was open — never by guessing missing values.
# Server-sent events. Same body, same Idempotency-Key rules.
curl -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: dcf-studio-3f2a91c40b7e5da2" \
-d '{ "company": "Havenmark Fluid Systems (HVMK)", "financials": "HAVENMARK FLUID SYSTEMS, INC. (NASDAQ: HVMK)\nNet revenue FY2025 1,071.0 ...", "assumptions": "", "notes": "Management guides FY2026 revenue growth of 9-11%."}'
# event: job data: {"job_id":"job_..."}
# event: delta data: {"text":"COMPANY: Havenmark Fluid Systems (HVMK)\n"}
# event: delta data: {"text":"VERDICT: Well grounded\n"}
# event: done data: {"charged_credits":118,"truncated":false}
req = urllib.request.Request(
BASE + "/run-stream",
data=json.dumps(payload).encode(),
headers={"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json",
"Idempotency-Key": key})
raw = ""
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().strip()
if line.startswith("data:"):
evt = json.loads(line[5:].strip())
if "text" in evt:
raw += evt["text"]
# `raw` is the full reply; parse it with the contract below.
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key
},
body: JSON.stringify(payload)
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", raw = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop();
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const evt = JSON.parse(line.slice(5).trim());
if (evt.text) raw += evt.text;
}
}
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewBuffer(bodyBytes))
req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
scanner := bufio.NewScanner(res.Body)
var raw strings.Builder
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") { continue }
var evt struct{ Text string `json:"text"` }
json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &evt)
raw.WriteString(evt.Text)
}
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
CLIENT.send(req, HttpResponse.BodyHandlers.ofLines())
.body()
.filter(l -> l.startsWith("data:"))
.forEach(System.out::println);
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.dump(payload)
raw = +""
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
next unless line.start_with?("data:")
evt = JSON.parse(line[5..].strip) rescue next
raw << evt["text"].to_s
end
end
end
end
$ch = curl_init(BASE . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . (getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"),
"Content-Type: application/json",
"Idempotency-Key: " . $key,
]);
$raw = "";
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$raw) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "data:")) {
$evt = json_decode(trim(substr($line, 5)), true);
if (isset($evt["text"])) { $raw .= $evt["text"]; }
}
}
return strlen($chunk);
});
curl_exec($ch);
curl_close($ch);
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Add("Authorization", "Bearer " + token);
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(
JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
var res = await Client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
while (await reader.ReadLineAsync() is string line) {
if (!line.StartsWith("data:")) continue;
var evt = JsonDocument.Parse(line[5..].Trim()).RootElement;
if (evt.TryGetProperty("text", out var t)) raw.Append(t.GetString());
}
6. The output contract
The reply is plain text in a fixed section order. This is the contract the app's
parseResult decodes, taken from the parser rather than from intent:
COMPANY: then VERDICT: (exactly one of Well grounded,
Check the assumptions, Not enough to value) then SUMMARY:,
then a line reading exactly ASSUMPTIONS: followed by a single JSON object, then
ANALYSIS:, CHECKS: and NOTES:.
COMPANY: Havenmark Fluid Systems (HVMK)
VERDICT: Well grounded
SUMMARY: All load-bearing inputs are in the 10-K excerpt and market screen.
ASSUMPTIONS:
{
"company": "Havenmark Fluid Systems", "ticker": "HVMK", "currency": "USD",
"share_price": 28.40, "shares_out_m": 58.4,
"total_debt_m": 620.0, "cash_m": 145.0,
"tax_rate": 0.24,
"base_revenue_m": 1071.0,
"years": 5,
"da_pct_rev": 0.046, "capex_pct_rev": 0.055, "nwc_pct_delta_rev": 0.10,
"wacc_inputs": { "risk_free": 0.042, "beta": 1.30, "erp": 0.055,
"pretax_cost_debt": 0.058 },
"scenarios": {
"bear": { "growth": [0.07,0.06,0.05,0.04,0.03],
"ebit_margin": [0.148,0.148,0.148,0.148,0.148],
"terminal_growth": 0.02, "wacc_delta": 0.01 },
"base": { "growth": [0.10,0.085,0.07,0.06,0.05],
"ebit_margin": [0.148,0.150,0.152,0.153,0.155],
"terminal_growth": 0.025, "wacc_delta": 0 },
"bull": { "growth": [0.11,0.10,0.09,0.07,0.06],
"ebit_margin": [0.150,0.155,0.160,0.165,0.170],
"terminal_growth": 0.03, "wacc_delta": -0.005 }
}
}
ANALYSIS:
**Bear:** ... **Base:** ... **Bull:** ... **Key drivers:** ...
CHECKS:
- Terminal growth vs. WACC: ...
NOTES:
**Extracted:**
- Revenue base $1,071.0M - "Net revenue ... 1,071.0" FY2025 column.
**Assumed:** None.
**Ignored:**
- Buyback authorization - a capital-return decision, not an operating driver.
Valuation confidence: 90%, complete four-year revenue/margin history.
ASSUMPTIONS block fails validation fails the
whole parse. The bands the engine enforces: years is an integer 3–10;
growth and ebit_margin have exactly years entries in
every scenario; tax_rate 0–0.6; terminal_growth
−0.02–0.06; beta 0.1–3.5. A scenario whose terminal growth is
not at least 25 bp below its WACC is reported as not computable rather than valued —
the perpetuity diverges there, and a confident wrong number is worse than none.7. Computing the model yourself
dcf.js is the whole valuation engine and is served from this origin. It
exports under module.exports in Node and window.DCF in a browser,
so the numbers you compute are identical to the ones the page shows.
// curl -s https://dcf-studio.skillsafe.ai/dcf.js -o dcf.js
const DCF = require("./dcf.js");
const problems = DCF.validateAssumptions(assumptions);
if (problems.length) throw new Error(problems.join("; "));
const model = DCF.buildModel(assumptions);
console.log(model.scenarios.base.implied_price); // implied value per share
console.log(model.scenarios.base.upside); // vs assumptions.share_price
console.log(model.reverse.implied_terminal_growth);// what today's price implies
console.log(model.checks); // the sanity findings
process.stdout.write(DCF.toCSV(model)); // the full model as CSV
# The engine is JavaScript, but the contract is plain JSON - re-implement it,
# or shell out to Node:
import json, subprocess
model = json.loads(subprocess.check_output(
["node", "-e",
'const D=require("./dcf.js");'
'process.stdout.write(JSON.stringify(D.buildModel(JSON.parse(process.argv[1]))))',
json.dumps(assumptions)]))
print(model["scenarios"]["base"]["implied_price"])