Developers
The regulated financial advice and guidance Infrastructure layer
Whistle runs the whole regulated journey: fact find, suitability, consent, outcome and the evidence behind it. Call it from your backend, or let an AI agent drive it over MCP. Both walk the same journey and produce the same record.
API documentation
Authentication, the journey primitives, the evidence chain and every endpoint ; with a worked integration for both a backend and an AI agent.
Read the guide →The Whistle SDK
Generated from the same OpenAPI document the platform serves, so a contract change is a compile error rather than a production surprise.
Coming soonCore concept
The regime decides the outcome. You don't.
A policy declares one regulatoryBasis. The kind of output a session can produce is derived from it and cannot be overridden by a caller ; which is what stops a journey that may only lay out options from quietly producing something that reads like advice.
| Declared basis | Output kind | Reference | What the customer receives |
|---|---|---|---|
| regulated-advice | recommendation | I-ADV- | A personal recommendation, with the suitability letter retrievable for the life of the record. |
| guidance-only | comparison | I-GO- | A list of options to weigh up: a table, a shortlist, whatever the policy calls for. No recommendation event is ever written. |
The reference code says nothing about the tenant, the product or the provider. Those relationships live in the record, never in an identifier someone might parse.
How it is built
Design principles
Properties we hold to.
Policy is an artefact, not configuration
Every tenant's journey, copy, questions and rules compile to a content-hashed document in version control. Changing a disclosure is a reviewed change with an author and a date — and a rollback is a deployment, not a database edit.
specVersion · specHashFull traceability and determinism. Every answer is evidence
Fact Find, consent, interactions , rule decisions and outcomes append to an immutable chain, each stamped with the exact policy artefact in force at the time. Reconstructing what a customer was shown two years ago is a query, not an archaeology project.
GET /events/:sessionIdPrivacy Default-deny at the edge
Each section of a policy declares its audience. Anything marked internal is stripped before a response leaves the platform ; for a browser, a partner and an AI agent alike, by design not by habits.
audience: edge | internalA commitment has a type
When a customer commits, the policy declares what that instruction is ; an annuity purchase, an investment, a pension transfer ; and it carries only the fields that kind can have.
service_instructionProvenance on every write
Each fact and consent records how it was captured and by whom: the customer in a browser, an operator on the phone, or a partner's own system. A consent asserted on someone's behalf never looks like one they gave themselves.
capturedVia · actorService levels
What you can build against
API & MCP
Integration guide
One core journey, then an overlay determined by the regime your policy declares. Call it from your backend over REST, or let an agent drive it over MCP ; the primitives, the guards and the record are identical.
Before you begin
You need a backend that can hold a secret and a policy provisioned for your tenant. Credentials arrive out of band: a clientId, a clientSecret and your tenantId. The secret never reaches a browser.
Every path below sits under the API prefix, e.g. https://<host>/api/v1. Every call except the token needs your bearer token and tenantId; journey calls also carry the clientId the platform issued for that customer.
If you would rather see a journey working before you write anything, skip to the technical pilot ; a complete agent host we run on your tenant, using the same gateway, token and tools your own integration will use.
The shape of a journey
Five calls get you from nothing to a completed fact find. What happens at step six depends on the regime ; and only on the regime.
1 · Authenticate
Client credentials over HTTP Basic, server to server. The token is short-lived and carries the tenants your integration was granted.
curl -X POST https://<host>/api/v1/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=client_credentials" \
-d "audience=whistle-api"
# → { "access_token": "…", "token_type": "Bearer", "expires_in": 1800 }
One token endpoint,
/oauth/token, for both surfaces: theaudienceyou ask for ;whistle-apiorwhistle-mcp; is what decides where the token is good, and a token minted for one is refused by the other. Tokens are RS256 and last thirty minutes; the public keys are atGET /mcp-auth/.well-known/jwks.jsonif you want to verify them yourself.
2 · Customer context
Creates the customer's context and returns the clientId every later call uses. With your partner bearer token the journey is owned by your integration: the platform mints a fresh, tenant-bound clientId ; no cookie is set and no browser lead is touched.
POST /edge/client-context
{ "tenantId": "your-tenant" }
# → { "clientId": "…" }
Each partner call creates a new identity ; this boot does not resume. Call it once per customer journey, persist the
clientIdagainst your own customer record, and send it on every later call. Repeating the boot starts a different journey, not the same one.
Browser boots — the hosted journey and embedded widgets
Without a partner token the same endpoint boots an anonymous browser context: the platform resolves the lead behind the browser id (so refreshes and returning visits never fork into a new lead), returns the open sessionId to resume when one exists, and recognised says it has seen this person before ; without telling you who they are until they verify. Attribution ; channel, campaign, device ; is pinned to the lead at first touch and never overwritten, so pass it here if you have it.
POST /edge/client-context
{ "tenantId": "your-tenant", "browserId": "…", "channelCode": "partner-app" }
# → { "clientId": "…", "sessionId": "…", "leadId": "…", "recognised": false }
Identity
Verification for browser-booted customers is a magic link, driven by four calls. Until a customer verifies, they are a lead; after, they are a contact and the two generations are folded into one person.
- POST
/edge/identity/startSend the link - POST
/edge/identity/verifyExchange the token - POST
/edge/identity/restartRe-issue after expiry - POST
/edge/identity/logoutDrop the verified identity, keep the browser
3 · Session
A session is one customer's run through one policy. It carries the policy version and hash in force when it started, which is what makes the record reconstructable later.
- POST
/sessionsStart - GET
/sessionsList for this customer - GET
/sessions/:sessionIdRead one, with its resume point - POST
/sessions/:sessionId/advanceMove the journey on - POST
/sessions/:sessionId/abandonEnd without an outcome
4 · Fact find
Ask what comes next, answer it, repeat. The question set, its order and its validation all come from the compiled policy ; you are not maintaining a copy of the questionnaire.
- GET
/agent/advice-scopeQuestion set and pinned spec version (?tenantId=<slug>) - GET
/agent/questionsThe whole question set for this policy - GET
/agent/next-question/:clientIdThe next one to ask - POST
/agent/turnAnswer and receive the next, in one round trip - POST
/agent/submit-answerAnswer without advancing - POST
/agent/resubmit-answerCorrect an earlier answer - GET
/agent/summary/:clientIdEverything answered so far - POST
/agent/reset/:clientIdStart the fact find again
Preloading facts you already hold
If you know the customer's date of birth or pot value, seed it ; but a preloaded fact is not a confirmed one. It stays provisional until the customer confirms it, and the record says which it was.
- POST
/agent/preloadSeed facts with their provenance - POST
/agent/confirm-preloadsCustomer confirms; provenance changes
Facts directly
Where a surface writes facts itself rather than walking questions, the same validation applies.
- PUT
/sessions/:sessionId/factsWrite facts - GET
/sessions/:sessionId/factsRead them back
5 · Consent
Two different things, deliberately separate. General consent is the policy's own declaration; special-category consent is the Article 9 gate, and health, lifestyle or similar facts are refused until it is on file.
- POST
/sessions/:sessionId/consentPolicy consent - POST
/sessions/:sessionId/sensitive-data-consentArticle 9 gate
Writing a special-category fact before consent returns 403
SENSITIVE_CONSENT_REQUIRED. This is a platform guard, not a client-side rule ; it holds for a browser, your backend and an agent equally.
Which overlay applies
The basis is a property of the policy provisioned for your tenant, not a runtime negotiation: you are told it at onboarding, and it is not something a caller can set or switch. It is confirmed back to you at runtime on the payloads that carry an outcome ; the regulatoryBasis field on a comparison view and in a recommendation's engine block — and, on the MCP surface, by list_policies. The output kind is derived from it and never authored.
| regulatoryBasis | outputKind | Reference | Outcome route |
|---|---|---|---|
| regulated-advice | recommendation | I-ADV- | /agent/recommendation/:clientId + /letters/:sessionId |
| guidance-only | comparison | I-GO- | policy-specific ; annuity guidance today |
Overlay · regulated advice
When the fact find is complete the pipeline runs and produces the output kind the basis implies. Read the state rather than assuming it is ready ; and branch on it rather than waiting for one value. IN_PROGRESS and DOCUMENT_PENDING are the states worth polling on; AWAITING_ADVISER, AWAITING_RELEASE, DOCUMENT_FAILED, CONSENTED, ABANDONED and NO_SESSION each end the poll and each need their own thing said to the customer.
- GET
/agent/recommendation-state/:clientIdPending, ready or unavailable - GET
/agent/recommendation/:clientIdThe recommendation - GET
/sessions/:sessionId/recommendationThe same, keyed by session - GET
/letters/:sessionIdSuitability letters on file - GET
/letters/:sessionId/:letterId/downloadThe document itself
Overlay · guidance only
A guidance-only session ends in a list of options for the customer to weigh up. Nothing in that list is singled out, ranked as best for them, or described as suitable ; the moment it were, the session would be giving advice under a basis that does not permit it.
What the list is made of is the policy's business, not the platform's. It might be a comparison table, a shortlist of products, a set of illustrated outcomes. So the routes that build and read it belong to the policy rather than to the core journey, and they differ between guidance policies.
The routes below are the annuity guidance policy ; the only guidance surface implemented today. A future guidance policy would keep the regime, the consents, the evidence chain and the instruction model exactly as they are, and present its options through routes of its own shape. Treat this section as a worked example, not as the guidance-only contract.
Annuity guidance · building the list
The platform runs a quote run against the provider bound in the policy and returns the options it came back with. A run is queued and polled: the POST answers with the run as created, and you read the outcome back.
POST /sessions/:sessionId/quote-runs # → { quoteRunId, status: "REQUESTED" }
GET /sessions/:sessionId/quote-runs/:quoteRunId
# poll while status is REQUESTED or SENT.
# COMPLETED → read the options; FAILED (no provider returned a quote, or a
# provider request failed) and EXPIRED are terminal → surface that to the
# customer instead of fetching the comparison.
GET /sessions/:sessionId/comparison
- POST
/sessions/:sessionId/quote-runsAsk the provider panel for options - GET
/sessions/:sessionId/quote-runsEvery run on this session - GET
/sessions/:sessionId/quote-runs/:quoteRunIdOne run and what it returned - GET
/sessions/:sessionId/comparisonThe list the customer is shown - POST
/sessions/:sessionId/choose-quoteCustomer picks one - GET
/annuity/providersProvider brands on the panel
An option the customer is not eligible for is returned and marked, with the reason ; never quietly dropped. Removing it would narrow the field on the customer's behalf, which is a recommendation made by omission.
Commitment
When a customer commits, the policy declares what the resulting service instruction is. It carries only the fields that kind can hold ; an annuity purchase has a quote and a provider; an investment has a wrapper and an amount and no quote at all.
- GET
/instructions/:instructionIdStatus and milestones - PUT
/instructions/:instructionId/applicationApplication details, page by page
Reserved instruction types: annuity-guidance, investment, drawdown, retirement, savings, pension-transfer. A policy naming a type the platform has not implemented is refused at compile time rather than at runtime.
Agents over MCP
The same journey and the same guards, driven by a model instead of your backend. The platform still resolves the policy, owns the session and writes the record, so the agent holds no regulated state ; a conversation that drops loses nothing but the conversation. Use this instead of the REST setup above; nothing here depends on it.
1 · Credentials and the endpoint
The onboarding pack is the one the REST setup uses, plus the MCP endpoint. Your integration is granted the whistle-mcp audience. There is no self-registration, and the secret is shown once.
- Tenant slug
your-tenant - Client ID
wpc_… - Client secret
wpcs_…; stays on your backend, never in the agent's prompt or context - Token URL
https://<host>/api/v1/oauth/token; your<host>arrives at onboarding - MCP endpoint
https://<gateway-host>/<org>/mcp/<tenant>/server; issued per tenant at onboarding
The endpoint is an AI gateway, not the platform API. It terminates the MCP transport, carries your bearer token through, and is where per-tenant rate limits and tool-call logging sit. An agent never calls the HTTP routes in the reference below ; the tool surface is the whole contract.
2 · A token for the gateway
Your backend exchanges the credentials exactly as in step 1 of the REST journey, with the MCP audience.
curl -X POST "$WHISTLE_API/oauth/token" \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=client_credentials" \
-d "audience=whistle-mcp" \
-d "scope=whistle:policy:read whistle:advice:write"
# → { "access_token": "eyJ…", "token_type": "Bearer", "expires_in": 1800 }
Thirty minutes. Refresh on your side about a minute before expiry and hand the current token to the agent on each request, so the secret never reaches the model's context.
3 · Connect the agent
Any MCP client will do, over Streamable HTTP with the token as Authorization: Bearer. With the Claude API the connector does the protocol work for you.
{
"model": "claude-sonnet-5",
"mcp_servers": [{ "type": "url", "name": "whistle",
"url": "https://<gateway-host>/<org>/mcp/<tenant>/server",
"authorization_token": "<token from step 2>" }],
"tools": [{ "type": "mcp_toolset", "mcp_server_name": "whistle" }],
"messages": [{ "role": "user", "content": "Which propositions can you offer?" }]
}
# header: anthropic-beta: mcp-client-2025-11-20
Listing tools should return thirteen: the ten that walk the journey, plus get_section and submit_section for steps a policy marks as section-mode, and search_medical_reference. If it returns none, the token was minted for the wrong audience.
4 · The tools
They follow the order of the journey above. list_policies is the exception to every rule here: it takes no arguments at all, and what it returns is where the tenantSlug comes from ; there is no implicit tenant fallback. After that, the agent generates one clientId per customer and passes it with the slug on every call (search_medical_reference takes the slug but no clientId, since it reads a catalogue rather than a journey).
list_policies
First call, no arguments. The propositions this token may run, each with its exacttenantSlug, itsregulatoryBasisand the pinned spec version and hash. This is where the basis is confirmed at runtime.get_advice_scope
The question set and sections for a chosen tenant, with the spec version it is pinned to.preload_session·confirm_preloads
Seed facts you already hold, then have the customer confirm them.get_next_question
The pending question. Put it to the customer word for word.submit_answer·resubmit_answer
The answer unchanged, against the question's key. Repeat untildone: true.capture_consent
Only once the customer has read it and agreed. Takesconfirmed: true; the server fills in the provenance.request_advice·get_recommendation
Ask for the outcome, then read the state ; not a loop that waits forREADY. See the states below.
READYis one of nine states, and several journeys never reach it.IN_PROGRESSandDOCUMENT_PENDINGare the only two worth polling on.AWAITING_ADVISER(a referral),AWAITING_RELEASE,DOCUMENT_FAILED,CONSENTED,ABANDONEDandNO_SESSIONall end the poll: a client that waits forREADYeither spins forever or swallows a document failure. Treat a referral as a valid outcome to show the customer, not an error to retry.
Every tool result carries a
relayContract. It tells the model to relay regulated text and figures as they are and never to interpret, advise or reassure. Keep it in the agent's instructions ; on this surface it is doing the job your UI code does on the other.
The tool names are deliberately generic; what comes back is not. get_recommendation returns whatever the policy's declared basis makes it ; a recommendation, or a list of options ; under the same label the REST surface uses, and on a guidance-only policy it never carries a personal recommendation at all.
Prove it before you build
Two setups, one question: how do you know the integration is right before you have written the thing that uses it? Start with a host you did not write, reduce the first proof to a couple of calls, then turn the dials one at a time.
Technical pilot: run our hosted agent first
Before wiring up your own LLM host, use ours. It is a small, complete host that Whistle runs for you: a chat widget on a demo page, a backend that holds the credentials and mints tokens, Claude as the model, and an MCP client to the Whistle server. Same gateway, same token, same tools your host will use.
- See a whole journey on your tenant in a browser, end to end: the questions, a referral to a human where the policy calls for one, the outcome, consent.
- Prove the plumbing before you write code. If the pilot works, your credentials, your gateway mapping and your tenant grants are right ; three things that are miserable to debug through a half-built host of your own.
- Use it as the reference. A host has three jobs: mint and refresh the token, pass it to the MCP client on every request, and hold the model to the relay contract. The pilot does exactly those three and nothing else.
- Get it
We deploy it on your tenant and send you the link. Nothing to install. - Read it
We share the source ; about 1,400 lines of TypeScript, no framework beyond Fastify. Run it locally withpnpm dev:agent-hostand your step 1 credentials, then openhttp://localhost:4020/demo-partner. - Then
Keep the same three jobs; swap in your model and your UI. Nothing on the Whistle side changes.
The smallest thing that proves it
One token and one call. Everything upstream of the journey ; credentials, audience, tenant grant, gateway route, policy resolution ; either works here or does not.
# REST
TOKEN=$(curl -s -X POST "$WHISTLE_API/oauth/token" \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=client_credentials" \
-d "audience=whistle-api" | jq -r .access_token)
curl -s "$WHISTLE_API/agent/advice-scope?tenantId=$TENANT_SLUG" \
-H "Authorization: Bearer $TOKEN"
# → { "tenantId", "specId", "version", "sections": [...], "questions": [...] }
# MCP ; same proof, one tool call, no arguments
# list_policies → your slug, basis and spec hash; an empty list means the grant is missing
Read what comes back rather than checking for a 200. On REST,
specIdandversiontell you which compiled policy you actually resolved to ; the thing that makes a "works on my tenant" claim checkable. On MCP,list_policiesaddsregulatoryBasisand the spec hash, which is the one call that tells an agent which overlay it is about to run under.
Levers you can pull
Confidence is built by changing one thing at a time. These are the dials that exist on purpose.
- Mock or live exchange
Development tenants quote against a mock provider panel; the live exchange is a deployment setting, not a code change. Get the whole journey green before anyone's real rates are in play. - A different basis, same code
Point the same integration at a guidance-only policy and then at an advised one. Your calls do not change; the outcome, its label and its reference prefix do. It is the quickest way to see that the regime decides the shape, not your client. - Quote without keeping the run
Outside production, a quote request may carrypersist: falseto exercise the exchange without writing a run into the record. Production refuses it outright, so the escape hatch cannot follow you into live. - The record as your assertion
After any run,GET /events/:sessionIdand read what was actually recorded. > On a guidance-only policyRECOMMENDATION_GENERATEDnever appears ; assert its absence in your own suite and you have a regression guard that costs one call.
- Funnel and guardrails
/metrics/funneland/metrics/rulesshow where sessions stopped and which rules fired, for the runs you just drove rather than for a reporting period.- Credentials as the kill switch
Suspend, retire, revoke, close or hard-cut an integration without touching your deployment. Only closure, removing a tenant grant and a hard cut take effect on the next call ; see credential lifecycle below.
Going live
Everything above is the development environment. Production is a separate client ID, secret, base URL and MCP endpoint, and development credentials never work against it ; by construction, not by configuration. What does not change on the way over: the policy and its version hash, the tools, the contract, and the shape of the record.
The record
Every material act appends an immutable event stamped with the policy version and hash in force at the time ; the same chain whichever surface drove the journey.
GET /events/:sessionId
# FACT_RECORDED · SENSITIVE_DATA_CONSENT_CAPTURED · CONTACT_VERIFIED
# QUOTE_RUN_REQUESTED · QUOTE_RUN_COMPLETED · COMPARISON_GENERATED
# RECOMMENDATION_GENERATED · QUOTE_SELECTED · INSTRUCTION_ISSUED
# INSTRUCTION_MILESTONE_RECORDED · INSTRUCTION_STATUS_CHANGED · …
On a guidance-only policy RECOMMENDATION_GENERATED never appears. That absence is asserted end to end on every build ; a property of the platform rather than a convention.
- GET
/metrics/funnelJourney funnel for your tenant - GET
/metrics/rulesWhich guardrails fired
Security
- Token ; client credentials over HTTP Basic, server to server, short-lived.
- Tenant ; resolved from the token, never read from the body. A client may only act for a tenant it was granted.
- Audience ; policy sections marked internal are stripped by one serialiser before any response leaves, for browser, partner and agent alike.
- Provenance ; every fact and consent records how it was captured and by whom, so a consent your system asserts is distinguishable from one the customer gave directly.
- Special category ; refused until the Article 9 consent is on file.
Credential lifecycle
Credentials are managed for you and rotate without downtime: issue the new one, move over, retire the old. The important distinction is between stopping new tokens and refusing the ones already out there ; they are different controls, and only three of them are immediate.
Stops new tokens; issued ones drain
The integration is re-read on every authenticated call, but what that check enforces is the integration's own state and its tenant grants ; not which credential minted the token. So these bound the exposure to the remaining life of a token already issued, at most thirty minutes:
- Retire a credential ; it mints no more tokens; a rotation's old secret ends here.
- Revoke a credential ; same effect on tokens already issued. Revoking is about the secret, not about the sessions it opened.
- Suspend the integration ; the token endpoint refuses it; work already in flight finishes. Resume puts it back.
Refused on the next call
- Close the integration ; every authenticated call is denied from that moment, whatever token it carries.
- Remove a tenant grant ; denied for that tenant immediately; grants are read live on every request rather than trusted from the token.
- Hard cut ; the emergency stop. Immediate, cached so it does not depend on a database read, and reversible by us.
If you need an integration to stop now ; a leaked secret, an incident ; ask for a hard cut or a closure. Suspending or revoking is the right move for a planned change, but a token minted a minute earlier keeps working until it expires.
Endpoint reference
All paths under /api/v1. Everything except the token needs a bearer token and tenantId.
Authentication
- POST
/oauth/tokenAccess token ·audiencepicks the surface - GET
/mcp-auth/.well-known/jwks.jsonPublic keys
Context & identity
- POST
/edge/client-contextCreate or resume a customer - POST
/edge/identity/startSend a magic link - POST
/edge/identity/verifyVerify - POST
/edge/identity/restartRe-issue - POST
/edge/identity/logoutLog out
Sessions
- POST
/sessionsStart - GET
/sessionsList - GET
/sessions/:sessionIdRead - POST
/sessions/:sessionId/advanceAdvance - POST
/sessions/:sessionId/abandonAbandon - POST
/sessions/:sessionId/consentPolicy consent - POST
/sessions/:sessionId/sensitive-data-consentArticle 9 consent
Fact find
- GET
/agent/advice-scopeQuestion set and spec version - GET
/agent/questionsQuestion set - GET
/agent/next-question/:clientIdNext question - POST
/agent/turnAnswer + next - POST
/agent/submit-answerAnswer - POST
/agent/resubmit-answerCorrect - POST
/agent/preloadSeed facts - POST
/agent/confirm-preloadsConfirm seeded facts - GET
/agent/summary/:clientIdAnswers so far - POST
/agent/reset/:clientIdRestart - PUT
/sessions/:sessionId/factsWrite facts - GET
/sessions/:sessionId/factsRead facts
Outcome ; advice & targeted support
- GET
/agent/recommendation-state/:clientIdReadiness - GET
/agent/recommendation/:clientIdRecommendation - GET
/sessions/:sessionId/recommendationSame, by session - GET
/letters/:sessionIdSuitability letters - GET
/letters/:sessionId/:letterId/downloadDownload
Outcome ; guidance only
- POST
/sessions/:sessionId/quote-runsRequest quotes - GET
/sessions/:sessionId/quote-runsList runs - GET
/sessions/:sessionId/quote-runs/:quoteRunIdOne run - GET
/sessions/:sessionId/comparisonComparison - POST
/sessions/:sessionId/choose-quoteCommit - GET
/annuity/providersProvider panel
Commitment & record
- GET
/instructions/:instructionIdInstruction - PUT
/instructions/:instructionId/applicationApplication details - GET
/events/:sessionIdEvidence chain - GET
/metrics/funnelFunnel - GET
/metrics/rulesGuardrails fired