← PortCo Desk / API
Get a token

Driving PortCo Desk from your own code

Everything the page does, you can do over HTTP: price a lane for free, run it, stream it, and read back the same structured analysis the browser renders. The whole free prescan — the table reader, every variance, every derivable ratio, MOIC, the IRR and the sensitivity grid — runs in the browser and is not an API: what you send as prescan_facts is your own arithmetic, and the model is instructed to treat it as ground truth.

Base URL

https://api.skillsafe.ai/v1/app-api

Every path below is relative to that. https://deploy.skillsafe.ai is the same host under a different name.

Two headers, and no slug header

Every authenticated call carries exactly two headers, plus Idempotency-Key on a run:

Authorization: Bearer aut_...
Content-Type: application/json
Idempotency-Key: portco-desk:monitor:<hash>:a1   # on /run and /run-stream only

There is no app-slug header. The slug appears in exactly one place in this whole API — the body of POST /guest. Sending an invented slug header changes nothing: the call still returns 200 and the app is still resolved from your token, which is why the mistake survives testing. Do not add one.

The response envelope

Every response, success or failure, has the same outer shape:

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

Read data on success and error.code on failure. Note the one place this nests twice: a finished run's data.output.output is a string holding the analysis JSON, so you parse that string to get the envelope described below.

Error codes

HTTPerror.codeWhat it means and what to do
400VALIDATION_ERRORThe body was not a JSON object, or a field was the wrong type. Note what this does not catch: a body wrapped in an input key is accepted, not rejected — see the warning above.
401UNAUTHORIZEDNo Authorization header, or the token is expired or revoked. Mint a fresh one; a guest token is short-lived.
402PAYMENT_REQUIREDThe balance is below min_credits for this lane. Compare /me against estimate.hold_credits before you run — a 402 after submitting is a defect in your client.
403FORBIDDENA guest token tried to run a metered lane on an app that does not sponsor guest runs. Sign in for a personal token.
404NOT_FOUNDUnknown job id, or the app slug in a /guest body does not exist.
409CONFLICTAn Idempotency-Key was reused with a different body. Reuse a key only for a retry of the same input.
429RATE_LIMITEDBack off and retry. Never tight-loop; the poll interval in these samples is one second for that reason.
503UNAVAILABLEThe model provider is unavailable. Retry with the same Idempotency-Key — that is exactly what it is for.

The four lanes

One system prompt, one model, four contracts. The app routes on an explicit task field, so set task first — it is the only field that changes what comes back.

taskNameThe question it answersExtra blockSource skill
monitorMonitorWhere is the company against the plan it was underwritten on, and what is driving each gap?drivers@anthropics/portfolio-monitoring
unitUnit economicsDo the unit economics support the plan, and which ratio is load-bearing?metrics@anthropics/unit-economics
planValue creationWhat closes the gap, and what happens in the first hundred days?levers@anthropics/value-creation-plan
returnsExit caseOn this trajectory, what does the exit look like, and does it clear the hurdle?cases@anthropics/returns-analysis

The intended pipeline is monitorunitplanreturns, with each result's conclusions passed forward in carryover. Every lane also stands alone: calling returns first returns a complete answer.

The input contract

One JSON object, and the object is the request body. There is no input wrapper. These fields are taken from the app's own submit path, not from intent.

The wrapper mistake does not fail loudly — verify this one yourself

A body shaped {"input": {…}} is accepted, not rejected. Sending one to /estimate returns 200 with a plausible hold_credits, because the platform passes your object straight through as the input — so the model receives a payload whose only key is input, never sees task at all, and answers whichever lane it guesses. On /run that is a billed run against a payload the prompt cannot read.

Verified against this app on 2026-08-19: the unwrapped body priced at 1,833 credits and the wrapped one at 1,719, both 200 ok:true with error: null. There is no error to catch, so the only defence is sending the object itself. The same is true of an invented app-slug header: a bogus value returns 200 and changes nothing.

FieldTypeMeaning
taskstring, requiredThe lane: monitor, unit, plan or returns. Document and set this first — it is the only field that changes the output contract. An unrecognised value is not an error: the model picks the closest lane and names the one it chose.
packstring, requiredThe reporting pack: a metrics table of actuals against plan, usually with a prior-year column. Pipe, tab, semicolon, comma or aligned-space columns all read. The primary input for every lane.
dealstringDeal terms as key: value lines — entry equity, entry EBITDA, entry multiple, entry net debt, hold years, years elapsed, exit multiple, exit EBITDA, exit net debt, target IRR, target MOIC, covenant. The returns lane needs them.
stagestringbuyout | growth | carve-out | platform | venture | unknown. Changes what “good” looks like, not what is reported.
emphasisstringgeneral | revenue | margin | cash | exit. Changes the ordering of findings, never hides one.
contextstringFree text about the period. The model attributes it (“the cover note states…”) rather than asserting it, and never lets it override a pack row.
carryoverstringA digest of the previous lane's conclusions, for the monitorunitplanreturns pipeline. Empty on a first run.
prescan_factsobjectYour own arithmetic, handed over as ground truth: stats, variances, derived, returns, sensitivity and flags. Send at least the empty shape; every flags[].id you send must come back in coverage_check.
retry_notestringOnly on a reformat retry. A correction directive about the model's own previous reply — never user content, and never quoted back.

The output contract

One JSON object, identical in every lane apart from one extra block. This is exactly what the page's own parser reads:

{
  "lane": "monitor",
  "title": "Q3 FY26: the base is shrinking while the headline holds",
  "company": "Lumen Analytics",
  "period": "Q3 FY26",
  "verdict": "at-risk",
  "headline": "Revenue missed by 7.1% and the gap sits entirely in new business, not renewals.",
  "summary": "Three to five sentences of context.",
  "checks": [
    {
      "name": "plan comparability",
      "status": "pass",
      "note": "One sentence."
    }
  ],
  "findings": [
    {
      "id": "PD-001",
      "title": "New business is running at two thirds of plan",
      "severity": "high",
      "area": "revenue",
      "metric": "New ARR",
      "detail": "What the figures show and why it matters.",
      "so_what": "The consequence if nothing changes, in the pack's units.",
      "action": "The next action, and the role that owns it."
    }
  ],
  "coverage_check": [
    {
      "prescan_id": "PC-001",
      "status": "confirmed",
      "note": "why"
    }
  ],
  "data_gaps": [
    "A line item the pack does not carry, and what it would unlock."
  ],
  "next_steps": [
    "Ordered, concrete, at most six."
  ],
  "disclaimer": "This is an analysis of the pasted pack, not investment advice, a valuation or an audit."
}

verdict is on-track only when nothing critical or high is open; watch means the plan is still reachable; at-risk means it will be missed unless something changes. findings[].severity is critical | high | medium | low and findings[].area is one of revenue, margin, cash, customers, operations, capital, reporting, exit.

checks comes back as exactly the requested lane's eight named checks, in the lane's order, each pass | warn | fail | unknown. Render against that roster rather than against what arrived: a check the model skipped should show as missing, not shorten your table.

monitor — Monitor

  1. plan comparability
  2. revenue against plan
  3. revenue composition
  4. margin and cost quality
  5. cash and working capital
  6. customer and retention signals
  7. leverage and covenant headroom
  8. reporting integrity

unit — Unit economics

  1. revenue quality and recurrence
  2. gross margin and its waterfall
  3. customer acquisition cost
  4. payback against the hold
  5. lifetime value and LTV to CAC
  6. gross and net revenue retention
  7. cohort direction
  8. growth efficiency and burn

plan — Value creation

  1. gap to plan is quantified
  2. revenue levers
  3. cost levers
  4. capital and working-capital levers
  5. operational and reporting levers
  6. sizing is traceable to the pack
  7. sequencing and dependencies
  8. accountability and measurement

returns — Exit case

  1. entry basis is stated
  2. exit assumptions are explicit
  3. EBITDA trajectory is supported by the pack
  4. multiple assumption is called out
  5. leverage and cash conversion
  6. value-creation bridge attribution
  7. hold period sensitivity
  8. hurdle comparison

Worked examples

Pick a language once and every block on this page follows. The choice is remembered in this browser.

1. A tiny client

Two headers, the {data}/{error} envelope, and one place to add Idempotency-Key. Everything after this reuses it.

2. Get a token

A guest token is enough for /me and /estimate. Running a lane is metered and this app does not sponsor guest runs, so it needs a personal token — the token panel hands you one without opening DevTools.

3. Check the session and the balance

GET /me is free and tells you whether you are a user or a guest, and what the balance is.

4. Price the lane &mdash; free

POST /estimate creates no job and charges nothing. It returns model (gpt-5.6-terra), model_alias (gpt-terra), markup_bps (1000), hold_credits, min_credits and sponsor_enabled. Re-estimate on every lane change.

5. Run it and poll

POST /run returns a job_id; poll GET /jobs/{id} to a terminal status. This is metered.

6. Or stream it

POST /run-stream is the same body with an SSE response — job, delta, done and error events. Keep partial output on a mid-flight failure rather than discarding it, and handle done.truncated as “response cut short” rather than presenting a clipped answer as complete.

7. Worked example: the Monitor lane

Where is the company against the plan it was underwritten on, and what is driving each gap? Derived from @anthropics/portfolio-monitoring. Its extra block is drivers.

8. Worked example: the Unit economics lane

Do the unit economics support the plan, and which ratio is load-bearing? Derived from @anthropics/unit-economics. Its extra block is metrics.

9. Worked example: the Value creation lane

What closes the gap, and what happens in the first hundred days? Derived from @anthropics/value-creation-plan. Its extra block is levers.

10. Worked example: the Exit case lane

On this trajectory, what does the exit look like, and does it clear the hurdle? Derived from @anthropics/returns-analysis. Its extra block is cases.

11. Conventions worth copying