@nqminds/flair-api-client
v0.1.1
Published
Client for the CMDstream API — sign in, then call the free and metered tiers
Downloads
189
Readme
CMDstream API — reference client
Zero-dependency Node client (18+), and the wire specification for anyone implementing it in another language.
Two endpoints:
| endpoint | tier | what gates it |
|---|---|---|
| POST /api/run/<target> | free — the public flair-run targets | signed caller + quota |
| POST /api/delivery/<op> | metered — paid work | signed caller + quota + entitlement ledger |
Getting started
npx @nqminds/flair-api-client loginAsks for your email, sends you a code, writes flair.config.json. That file
holds your private key — treat it as a password. It is generated on your
machine and never sent anywhere; we only ever see the public half.
npx @nqminds/flair-api-client whoami # who am I, and when does this expireCredentials stop working after about 13 months. Run login again before then.
The verification email can be slow, and that is out of our hands. Quitting while you wait is safe — the request is saved. When the code arrives:
npx @nqminds/flair-api-client login --code 4754Same request, same key. Starting a fresh login instead sends a second email
and invalidates the code in the first, so resume rather than retry.
Then use it from your own code:
npm install @nqminds/flair-api-clientOr don't — the wire specification below is complete, and plenty of people will sooner write twenty lines in their own language than take a dependency. Both are supported; the spec is the contract, this package is a convenience.
Making requests needs no dependencies at all: index.mjs is one file that
imports nothing outside Node. npm install --omit=optional gives you a working
caller. The Volt libraries are pulled in only to sign in, which happens once.
Quick start
import { createFlairClient } from "./index.mjs";
const client = createFlairClient({
baseUrl: "https://flair.nqm.ai",
configPath: "/path/to/your.volt.config.json",
});
const { result, quota } = await client.run("company-search", ["nquiringminds", "3"]);
console.log(result, `${quota.remaining}/${quota.limit} left this ${quota.window}`);Long work streams by default and survives a dropped connection:
const { result } = await client.run("company", ["00233462", "psc/get_psc.sh"], {
onProgress: ({ elapsedMs }) => process.stderr.write(`\r${Math.round(elapsedMs / 1000)}s`),
});Metered work, which must already be paid for in the entitlement ledger:
await client.deliver("pdf-jobs", {
companyNumber: "00233462",
plannedJobs: [{ dateKey: "2024-12-31" }],
});What you need
A Volt client config for your identity, holding:
credential.cert— your session certificate, written after you authenticatecredential.key— your private Ed25519 key. Never leaves your machine.credential.vc— an array; element 0 is your issuer-signed email credential
node cli.mjs login produces all three. An anonymous self-minted key will not
get in: the server requires a credential from the pinned issuer, and only a
verified email address gets one.
There is no separate "API credential". It is an ordinary sign-in; what differs is the rate-limit plan attached to your identity, which we set on our side. Nothing about your credential says "API", and nothing needs to.
The wire specification
Implement this if you are not using Node.
The envelope
Every request body is your request fields plus three auth fields:
{
"session": { /* see below — cert only, never your private key */ },
"vc": { /* ONE credential object, not the array */ },
"pop": { "iat": 1756000000000, "sig": "base64url…" },
"args": ["nquiringminds", "3"] // …and your actual request fields
}session
Only credential.cert is read by the server. Send this shape:
{
"client_name": "your-client",
"volt": { "id": "", "address": "", "http_address": "" },
"credential": { "cert": "-----BEGIN CERTIFICATE-----…", "session_id": "…" }
}Do not post your whole Volt config. It contains
credential.key, your private key. Posting it sends your private key to the server on every request, where it lands in request logs and proxy buffers. Send the cert and nothing else that is secret.
vc
credential.vc in a Volt config is an array. Send element 0, not the array.
Posting the array is rejected as credential-invalid, which looks like an
expired credential and is not.
pop — the part that is easy to get wrong
sig is an Ed25519 signature, base64url, over this exact UTF-8 string:
cmdstream:credits:v1|<label>|<iat>|<stableJSON(body without session/vc/pop)>- audience is the literal
cmdstream:credits:v1 - label identifies the route — see the table below
- iat is your clock in epoch milliseconds, as a decimal string
- body is everything except
session,vcandpop, serialised stably
Stable JSON means:
- object keys sorted ascending by code unit, at every level
undefined/ absent values dropped entirely- arrays keep their order — only object keys sort
- otherwise identical to
JSON.stringify(same escaping, same number format)
Signing is over the raw message bytes. Ed25519 hashes internally — do not pre-hash, and do not pass a digest algorithm.
Labels
| route | label |
|---|---|
| POST /api/run/<target> | run:<target> |
| POST /api/delivery/pdf-jobs | delivery:pdf-jobs |
| POST /api/delivery/tech-classify | delivery:tech-classify |
The label is inside the signed message, so a signature minted for one route
cannot be replayed at another. Use the wrong one and you get 401 pop-invalid.
Rules that are not obvious
Sign immediately before sending. A signature is valid for 60 seconds from
iat (plus 30s tolerance for a fast clock). If you build a batch and then feed
it through a queue, the tail arrives expired. This has already happened once in
our own browser client, behind a connection limit.
Long work must stream or poll. The production gateway caps total connection
duration, not idle time — heartbeats do not save you. Send
Accept: application/x-ndjson and the response is a line-per-frame stream:
{"type":"accepted","jobId":"…"} // take this first, before anything else
{"type":"progress","elapsedMs":10000}
{"type":"outcome","status":200,"ok":true,"result":{…}}If the connection dies, POST the same URL with { "jobId": "…" } (signed, same
label) every few seconds until state is settled. The work keeps running
server-side regardless of your connection — the job id is how you collect it.
Polls need their own signature. Re-sign each one. A poll ten minutes into a job cannot reuse the signature that started it.
Versioning — what you can rely on
Every response carries x-api-version: 1, on success and on failure.
Within a version we may:
- add a field to a response
- add an optional field to a request
- add a new target, op, or
reasonvalue - reword a human-readable
errorstring
Within a version we will not:
- remove or rename a response field
- change the type or meaning of an existing field
- make an optional request field required
- change what an existing
reasonvalue means
So: read the fields you know, ignore the ones you don't, and branch on reason
rather than on error text. A client that does those three things survives
every change this version permits.
Anything outside that gets a new version number and a /v2/ path, and v1 keeps
answering while you migrate. The header is how you'll notice.
Responses
Success:
{ "ok": true, "result": { /* whatever the backend script printed */ } }Failure carries a machine-readable reason:
| status | reason | meaning |
|---|---|---|
| 400 | bad-target, bad-args, too-many-args | malformed request |
| 401 | no-session | no cert sent |
| 401 | pop-missing, pop-stale, pop-invalid | signature absent, expired, or wrong |
| 402 | metered | free route, paid work — use /api/delivery/<op> |
| 402 | (delivery) | you have not paid for these resources |
| 403 | credential-absent, credential-invalid, credential-key-mismatch | credential problem |
| 404 | unknown-target | not a target we serve |
| 429 | quota-exceeded | see retry-after |
| 502 | bad-output | the backend ran but produced nothing usable |
pop-invalid almost always means your stable-JSON differs from ours by a byte.
Check key ordering at every nesting level first.
Quota
Every response carries:
x-quota-plan browser | api
x-quota-window minute | day
x-quota-limit requests allowed in that window
x-quota-remaining
x-quota-reset seconds until the window rolls
retry-after only on a 429Windows are fixed, not sliding, so allowance resets on the boundary rather than rolling. Polls count against a separate, much larger budget than work.
Limits are per identity, not per IP or per connection — so several machines sharing one credential share one budget. Ask us if you need it raised; it is one row on our side, no credential change.
One thing we would rather you heard from us
The free targets read public data, and the same data is reachable anonymously through the website. These rate limits therefore bound this API, not the underlying source. We are asking you to come through here because it is the supported path — it is the one we can see, support, raise limits on, and keep working. Scraping around it gets you no more data, no support, and no warning when something changes.
Free targets
company, charity, contracts and people are orchestrators: argument 1 is
the entity id, argument 2 is the script to run, and the orchestrator enforces its
own allowlist of those.
smartplot, company-search, company-advanced-search, officer-appointments,
company-names and directory-scrape are direct scripts taking their own
arguments.
Arguments must be single-line text: no newlines, no control characters, at most
8192 characters each, and at most 24 arguments (200 for company-names, 64 for
people).
