Drive Kin Chronicle from your own code
The gap-question pass the page runs is available over HTTP. A natural use is a family tree you already keep elsewhere - a GEDCOM export, a spreadsheet of remembered dates, a folder of interview notes: a script turns it into lanes and dated events, asks what to ask next, and after the next visit to a relative sends a follow-up that checks which questions the new events answer.
Be clear about the split first: the model never sees your files, only the lane and event lines you build.
The caller parses the fuzzy dates (c. 1950, spring 1962, before 1945,
1940 - 1945) into decimal-year ranges, computes the facts (gaps per lane, contradictions, coverage) and
sends them along. The reply names lanes and events by id; refusing a question that names a lane you do not have, or a
date outside the span, is the caller's job. Context events are general history and must be shown as
context, unverified, never as facts about the family. The page's own date parser, checks and input builder
ship as plain scripts you can load in node - see the end of the examples.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest call takes {"slug":"kin-chronicle"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object: never wrap it as {"input": {…}} — that returns 200, warns unknown field 'input', and hides every field from the model.
Errors:
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: gap passes are metered and need a personal token. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field comes first
One system prompt serves two tasks. task picks the contract; anything else is read as gaps and the reply's meta line says so.
| task | When | Question lines carry |
|---|---|---|
gaps | The first pass over a timeline: where it is thin, what to ask a relative or look up, why the flagged contradictions might have happened, what general history was going on. | No status. |
followup | After you added answers (new events) since the last pass. Needs previous_json. | status on every question: answered / open for each earlier qid that was not dismissed, new for new ones. |
Every run body is a JSON object of string fields (the declared input schema; task, title, kind, today, span, lanes, events and facts_json are required, and /estimate warns when one is missing):
| Field | What it holds |
|---|---|
task | "gaps" or "followup". |
title | What the timeline is about, e.g. The Okavume-Quellby family. |
kind | family, house, club, care, career or free. |
today | The current year, e.g. 2026. |
span | The timeline's extent, 1908 - 2026. Every date in the reply must fall inside it. |
lanes | One line per lane: <lane id> | <name> | <kind> | <details>. Kinds: person, place, thread, context. Details such as parents: L2, L3; born c. 1941; died 2015. |
events | One line per event, in date order: <event id> | <lane ids> | <date label> [<from> - <to>] | <type> | <title> | <place> | <extra>. The bracket is the date's range in decimal years (? for an open end). extra may hold people:, source:, conf: strong|moderate|speculative, note: and CONTEXT-UNVERIFIED on an accepted context event. The page sends at most a fixed number of events and leaves a marker line such as (... 14 events in L2 between 1961 and 1974 not sent). |
eras | Optional: <era id> | <label> | <date range>, named periods the user drew. |
facts_json | JSON string: counts, min_gap_years, gaps (per lane: lane, from, to, years), contradictions (kind order, parent_age, place, duplicate or age; level conflict or check; lane, ids, text), anchors_missing, undated ids and coverage per lane. |
previous_json | followup only. JSON string: the last pass's questions (qid, lane, from, to, text, state open/answered/dismissed, answered_by, events_in_range), explanations (xid, ids), context (cid, title, state accepted/dismissed/pending) and events_added_since. |
clip_note | What was not sent, in words; empty when nothing was cut. |
retry_note | Only on a reformat retry, when the previous reply could not be read. |
The reply text is JSON Lines - one object per line, so a reply cut short still yields every whole line:
{"k":"meta","task":"gaps","summary":"..."}
{"k":"q","qid":"q1","lane":"L4","from":"1913","to":"1934","ask":"relative","who":"cousin Ifeoma","text":"...","why":"...","hint":"","status":"open|answered|new (followup only)"}
{"k":"q","qid":"q2","lane":"L6","from":"1938","to":"1983","ask":"record","who":"","text":"...","why":"...","hint":"1939 Register; electoral rolls for Bootle"}
{"k":"x","xid":"x1","ids":["e8","e7"],"kind":"order","text":"...","resolve":"..."}
{"k":"ctx","cid":"c1","lane":"L2","date":"1960","title":"...","scope":"local|national|world","why":"..."}
{"k":"end"}
- meta - exactly one, first;
summaryis two or three sentences on where the timeline is thin. - q - three to twelve gap-fill questions, each tied to ONE lane and a
from/torange insidespan.askisrelative(a question to put to a living person,whonames them when the timeline does) orrecord(hintnames the kind of record and where such records are kept, never a reference number or URL). - x - zero to six explanations of contradictions (1 to 4 event
ids;kindorder,parent_age,place,duplicate,age,sourceorother), phrased as possibilities, with the one check that would settle it inresolve. - ctx - zero to eight context events: widely documented general history (
laneor"*"), which the page shows as ghosted events labelled context, unverified until the user accepts one. Afollowupnever repeats a context proposal that was accepted or dismissed. - end - last line.
A gaps reply has no status fields; a followup reply gives every question one.
Worked examples
The bundled Okavume-Quellby example on the page (an invented three-generation family between Enugu and Liverpool), as the exact run bodies the page builds: the first pass, then a follow-up after four answers were added.
gaps: the Okavume-Quellby family, first pass
Input (seven lanes, 27 events; the checks found two conflicts, one place check and nine gaps):
{
"task": "gaps",
"title": "The Okavume-Quellby family",
"kind": "family",
"today": "2026",
"span": "1908 - 2026",
"lanes": "(lines - shown below)",
"events": "(lines - shown below)",
"eras": "(lines - shown below)",
"facts_json": "(a JSON string - decoded below)",
"clip_note": ""
}
lanes:
L1 | Ada Okavume-Quellby | person | parents: L2, L3; born 2 Feb 1968; sex: F
L2 | Grace Quellby (née Okavume) | person | parents: L4, L5; born c. 1941; died 2015; sex: F
L3 | Thomas Quellby | person | parents: L6; born 14 Mar 1938; died Nov 2001; sex: M
L4 | Samuel Okavume | person | born c. 1910; died 1959; sex: M
L5 | Comfort Okavume | person | born before 1920; sex: F
L6 | Edith Quellby | person | born 1912; died c. 1985; sex: F
L7 | Liverpool homes | place
events:
e1 | L4 | c. 1910 [1908.00 - 1913.00] | birth | Born | Nsukka | source: Family memory (Grace); conf: speculative
e11 | L6 | 1912 [1912.00 - 1913.00] | birth | Born (baptism register) | Bootle | source: Parish register; conf: strong
e12 | L6 | 1914 [1914.00 - 1915.00] | birth | Born (1939 Register) | Bootle | source: 1939 Register; conf: moderate
e2 | L5 | before 1920 [? - 1920.00] | birth | Born | Enugu area | conf: speculative
e3 | L4,L5 | c. 1936 [1934.00 - 1939.00] | marriage | Married | Enugu | conf: speculative; note: A church wedding, remembered by Grace's cousin Ifeoma.
e4 | L4 | 1938 – 1958 [1938.00 - 1959.00] | work | Headmaster, St Mary's school | Enugu | source: School centenary booklet; conf: moderate
e13 | L3,L6 | 14 Mar 1938 [1938.20 - 1938.20] | birth | Born | Liverpool | source: Birth certificate; conf: strong
e5 | L2,L5 | c. 1941 [1939.00 - 1944.00] | birth | Born | Enugu | conf: speculative; note: Grace always said she was born 'the year the rains failed'.
e14 | L3 | 1955 – 1964 [1955.00 - 1965.00] | work | Merchant Navy, Elder Dempster Lines | at sea | source: Discharge book; conf: strong
e6 | L2 | 1956 – 1960 [1956.00 - 1961.00] | education | Nursing school | Enugu | conf: moderate
e7 | L4 | 1959 [1959.00 - 1960.00] | death | Died | Enugu | source: Grave marker (photo); conf: moderate
e15 | L3 | Jun 1960 [1960.42 - 1960.50] | other | Ship docks at Lagos | Lagos | source: Discharge book; conf: strong
e16 | L3 | Jun 1960 [1960.42 - 1960.50] | other | Sister Jean's wedding | Liverpool | conf: speculative; note: Is he in the wedding photo? Jean says yes.
e8 | L4,L2 | Mar 1961 [1961.16 - 1961.25] | other | Letter from Samuel to Grace | Enugu | source: Letter, family papers; conf: moderate; note: In the tin box of papers, signed 'your father'.
e10 | L2 | 1962 – 1990 [1962.00 - 1991.00] | work | Nurse, Walton Hospital | Liverpool | conf: strong
e9 | L2 | spring 1962 [1962.16 - 1962.41] | migration | Sails to Liverpool | Liverpool | source: Passenger list (seen online); conf: moderate
e18 | L3 | 1965 – 1983 [1965.00 - 1984.00] | work | Docker, Gladstone Dock | Bootle | conf: moderate
e19 | L7,L2,L3 | 1966 – 1979 [1966.00 - 1980.00] | residence | 12 Corvell Road | Kirkdale, Liverpool | conf: strong
e17 | L2,L3 | Sep 1966 [1966.67 - 1966.75] | marriage | Married | Liverpool | source: Marriage certificate; conf: strong
e20 | L1,L2,L3 | 2 Feb 1968 [1968.09 - 1968.09] | birth | Born | Liverpool | source: Birth certificate; conf: strong
e21 | L2 | 1975 [1975.00 - 1976.00] | other | Trip home to Enugu | Enugu | conf: speculative
e22 | L7,L2 | 1979 – 2015 [1979.00 - 2016.00] | residence | 40 Pellin Lane | Litherland | conf: strong
e23 | L6 | c. 1985 [1983.00 - 1988.00] | death | Died | Bootle | conf: speculative
e24 | L1 | 1986 – 1989 [1986.00 - 1990.00] | education | University | Leeds | conf: strong
e25 | L1 | 1990s [1990.00 - 2000.00] | move | Moved to London for work | London | conf: moderate
e26 | L3 | Nov 2001 [2001.83 - 2001.92] | death | Died | Liverpool | source: Death certificate; conf: strong
e27 | L2 | 2015 [2015.00 - 2016.00] | death | Died | Litherland | source: Funeral order of service; conf: strong
eras:
E1 | Before Liverpool | 1936 – 1962
E2 | Corvell Road | 1966 – 1979
E3 | Pellin Lane | 1979 – 2015
facts_json, decoded:
{
"counts": {
"lanes": 7,
"people": 6,
"events": 27,
"dated": 27,
"undated": 0,
"context": 0
},
"min_gap_years": 10,
"gaps": [
{
"gid": "g1",
"lane": "L6",
"from": "1938",
"to": "1983",
"years": 44.8,
"open": false
},
{
"gid": "g2",
"lane": "L1",
"from": "2000",
"to": "2026",
"years": 26,
"open": true
},
{
"gid": "g3",
"lane": "L6",
"from": "1915",
"to": "1938",
"years": 23.2,
"open": false
},
{
"gid": "g4",
"lane": "L4",
"from": "1913",
"to": "1934",
"years": 21,
"open": false
},
{
"gid": "g5",
"lane": "L1",
"from": "1968",
"to": "1986",
"years": 17.9,
"open": false
},
{
"gid": "g6",
"lane": "L3",
"from": "1984",
"to": "2001",
"years": 17.8,
"open": false
},
{
"gid": "g7",
"lane": "L3",
"from": "1938",
"to": "1955",
"years": 16.8,
"open": false
},
{
"gid": "g8",
"lane": "L5",
"from": "1920",
"to": "1934",
"years": 14,
"open": false
},
{
"gid": "g9",
"lane": "L2",
"from": "1944",
"to": "1956",
"years": 12,
"open": false
}
],
"contradictions": [
{
"cid": "k1",
"kind": "order",
"level": "conflict",
"lane": "L4",
"ids": [
"e8",
"e7"
],
"text": "Letter from Samuel to Grace (Mar 1961) is after Samuel Okavume died (1959)."
},
{
"cid": "k2",
"kind": "duplicate",
"level": "conflict",
"lane": "L6",
"ids": [
"e11",
"e12"
],
"text": "Edith Quellby has two birth dates: 1912 and 1914 - they cannot both be right."
},
{
"cid": "k3",
"kind": "place",
"level": "check",
"lane": "L3",
"ids": [
"e15",
"e16"
],
"text": "Thomas Quellby is in Lagos and Liverpool at the same time (Jun 1960, Jun 1960)."
}
],
"anchors_missing": [
{
"lane": "L5",
"what": "death",
"text": "Comfort Okavume (born before 1920) has no death date."
}
],
"undated": [],
"coverage": {
"L1": 0.29,
"L2": 0.87,
"L3": 0.52,
"L4": 0.64,
"L5": 0.52,
"L6": 0.16,
"L7": 1
}
}
followup: the same family after four answers were added
Input (previous_json carries the first pass - five questions, one dismissed, two explanations, three context proposals - and the events added since; the accepted context event sits in events marked CONTEXT-UNVERIFIED):
{
"task": "followup",
"title": "The Okavume-Quellby family",
"kind": "family",
"today": "2026",
"span": "1908 - 2026",
"lanes": "(lines - shown below)",
"events": "(lines - shown below)",
"eras": "(lines - shown below)",
"facts_json": "(a JSON string - decoded below)",
"clip_note": "",
"previous_json": "(a JSON string - decoded below)"
}
lanes:
L1 | Ada Okavume-Quellby | person | parents: L2, L3; born 2 Feb 1968; sex: F
L2 | Grace Quellby (née Okavume) | person | parents: L4, L5; born c. 1941; died 2015; sex: F
L3 | Thomas Quellby | person | parents: L6; born 14 Mar 1938; died Nov 2001; sex: M
L4 | Samuel Okavume | person | born c. 1910; died 1959; sex: M
L5 | Comfort Okavume | person | born before 1920; sex: F
L6 | Edith Quellby | person | born 1912; died c. 1985; sex: F
L7 | Liverpool homes | place
events:
e1 | L4 | c. 1910 [1908.00 - 1913.00] | birth | Born | Nsukka | source: Family memory (Grace); conf: speculative
e11 | L6 | 1912 [1912.00 - 1913.00] | birth | Born (baptism register) | Bootle | source: Parish register; conf: strong
e12 | L6 | 1914 [1914.00 - 1915.00] | birth | Born (1939 Register) | Bootle | source: 1939 Register; conf: moderate
e2 | L5 | before 1920 [? - 1920.00] | birth | Born | Enugu area | conf: speculative
e30 | L4 | 1931 – 1935 [1931.00 - 1936.00] | education | Teacher training college | Awka | source: Told by cousin Ifeoma, 2026; conf: speculative
e3 | L4,L5 | c. 1936 [1934.00 - 1939.00] | marriage | Married | Enugu | conf: speculative; note: A church wedding, remembered by Grace's cousin Ifeoma.
e4 | L4 | 1938 – 1958 [1938.00 - 1959.00] | work | Headmaster, St Mary's school | Enugu | source: School centenary booklet; conf: moderate
e13 | L3,L6 | 14 Mar 1938 [1938.20 - 1938.20] | birth | Born | Liverpool | source: Birth certificate; conf: strong
e5 | L2,L5 | c. 1941 [1939.00 - 1944.00] | birth | Born | Enugu | conf: speculative; note: Grace always said she was born 'the year the rains failed'.
e29 | L3 | 1953 – 1955 [1953.00 - 1956.00] | work | Apprentice fitter, Bibby's | Liverpool | source: Told by his sister Jean, 2026; conf: moderate
e14 | L3 | 1955 – 1964 [1955.00 - 1965.00] | work | Merchant Navy, Elder Dempster Lines | at sea | source: Discharge book; conf: strong
e6 | L2 | 1956 – 1960 [1956.00 - 1961.00] | education | Nursing school | Enugu | conf: moderate
e7 | L4 | 1959 [1959.00 - 1960.00] | death | Died | Enugu | source: Grave marker (photo); conf: moderate
e15 | L3 | Jun 1960 [1960.42 - 1960.50] | other | Ship docks at Lagos | Lagos | source: Discharge book; conf: strong
e16 | L3 | Jun 1960 [1960.42 - 1960.50] | other | Sister Jean's wedding | Liverpool | conf: speculative; note: Is he in the wedding photo? Jean says yes.
e32 | L2 | 1 Oct 1960 [1960.75 - 1960.75] | context | Nigerian independence | Nigeria | CONTEXT-UNVERIFIED
e8 | L4,L2 | Mar 1961 [1961.16 - 1961.25] | other | Letter from Samuel to Grace | Enugu | source: Letter, family papers; conf: moderate; note: In the tin box of papers, signed 'your father'.
e10 | L2 | 1962 – 1990 [1962.00 - 1991.00] | work | Nurse, Walton Hospital | Liverpool | conf: strong
e9 | L2 | spring 1962 [1962.16 - 1962.41] | migration | Sails to Liverpool | Liverpool | source: Passenger list (seen online); conf: moderate
e18 | L3 | 1965 – 1983 [1965.00 - 1984.00] | work | Docker, Gladstone Dock | Bootle | conf: moderate
e19 | L7,L2,L3 | 1966 – 1979 [1966.00 - 1980.00] | residence | 12 Corvell Road | Kirkdale, Liverpool | conf: strong
e17 | L2,L3 | Sep 1966 [1966.67 - 1966.75] | marriage | Married | Liverpool | source: Marriage certificate; conf: strong
e20 | L1,L2,L3 | 2 Feb 1968 [1968.09 - 1968.09] | birth | Born | Liverpool | source: Birth certificate; conf: strong
e28 | L1 | 1972 – 1979 [1972.00 - 1980.00] | education | St Mary's primary school | Kirkdale | source: Ada's own memory; conf: moderate
e21 | L2 | 1975 [1975.00 - 1976.00] | other | Trip home to Enugu | Enugu | conf: speculative
e22 | L7,L2 | 1979 – 2015 [1979.00 - 2016.00] | residence | 40 Pellin Lane | Litherland | conf: strong
e23 | L6 | c. 1985 [1983.00 - 1988.00] | death | Died | Bootle | conf: speculative
e24 | L1 | 1986 – 1989 [1986.00 - 1990.00] | education | University | Leeds | conf: strong
e25 | L1 | 1990s [1990.00 - 2000.00] | move | Moved to London for work | London | conf: moderate
e26 | L3 | Nov 2001 [2001.83 - 2001.92] | death | Died | Liverpool | source: Death certificate; conf: strong
e31 | L1 | 2003 [2003.00 - 2004.00] | marriage | Married Daniel Varnoley | London | source: Marriage certificate; conf: strong
e27 | L2 | 2015 [2015.00 - 2016.00] | death | Died | Litherland | source: Funeral order of service; conf: strong
eras:
E1 | Before Liverpool | 1936 – 1962
E2 | Corvell Road | 1966 – 1979
E3 | Pellin Lane | 1979 – 2015
facts_json, decoded:
{
"counts": {
"lanes": 7,
"people": 6,
"events": 32,
"dated": 32,
"undated": 0,
"context": 1
},
"min_gap_years": 10,
"gaps": [
{
"gid": "g1",
"lane": "L6",
"from": "1938",
"to": "1983",
"years": 44.8,
"open": false
},
{
"gid": "g2",
"lane": "L6",
"from": "1915",
"to": "1938",
"years": 23.2,
"open": false
},
{
"gid": "g3",
"lane": "L1",
"from": "2004",
"to": "2026",
"years": 22,
"open": true
},
{
"gid": "g4",
"lane": "L4",
"from": "1913",
"to": "1931",
"years": 18,
"open": false
},
{
"gid": "g5",
"lane": "L3",
"from": "1984",
"to": "2001",
"years": 17.8,
"open": false
},
{
"gid": "g6",
"lane": "L3",
"from": "1938",
"to": "1953",
"years": 14.8,
"open": false
},
{
"gid": "g7",
"lane": "L5",
"from": "1920",
"to": "1934",
"years": 14,
"open": false
},
{
"gid": "g8",
"lane": "L2",
"from": "1944",
"to": "1956",
"years": 12,
"open": false
}
],
"contradictions": [
{
"cid": "k1",
"kind": "order",
"level": "conflict",
"lane": "L4",
"ids": [
"e8",
"e7"
],
"text": "Letter from Samuel to Grace (Mar 1961) is after Samuel Okavume died (1959)."
},
{
"cid": "k2",
"kind": "duplicate",
"level": "conflict",
"lane": "L6",
"ids": [
"e11",
"e12"
],
"text": "Edith Quellby has two birth dates: 1912 and 1914 - they cannot both be right."
},
{
"cid": "k3",
"kind": "place",
"level": "check",
"lane": "L3",
"ids": [
"e15",
"e16"
],
"text": "Thomas Quellby is in Lagos and Liverpool at the same time (Jun 1960, Jun 1960)."
}
],
"anchors_missing": [
{
"lane": "L5",
"what": "death",
"text": "Comfort Okavume (born before 1920) has no death date."
}
],
"undated": [],
"coverage": {
"L1": 0.52,
"L2": 0.87,
"L3": 0.55,
"L4": 0.7,
"L5": 0.52,
"L6": 0.16,
"L7": 1
}
}
previous_json, decoded:
{
"questions": [
{
"qid": "q1",
"lane": "L4",
"from": "1913",
"to": "1934",
"text": "Where did Samuel go to school, and how did he come to train as a teacher before St Mary's?",
"state": "open",
"answered_by": "",
"events_in_range": [
"e30"
]
},
{
"qid": "q2",
"lane": "L3",
"from": "1938",
"to": "1955",
"text": "What did Thomas do between leaving school and joining Elder Dempster in 1955?",
"state": "open",
"answered_by": "",
"events_in_range": [
"e29"
]
},
{
"qid": "q3",
"lane": "L1",
"from": "1968",
"to": "1986",
"text": "Which schools did Ada go to while the family lived on Corvell Road?",
"state": "open",
"answered_by": "",
"events_in_range": [
"e28"
]
},
{
"qid": "q4",
"lane": "L1",
"from": "2000",
"to": "2026",
"text": "What has Ada done since moving to London - work, homes, marriage?",
"state": "open",
"answered_by": "",
"events_in_range": [
"e31"
]
},
{
"qid": "q5",
"lane": "L6",
"from": "1938",
"to": "1983",
"text": "Where did Edith live and work after Thomas was born in 1938?",
"state": "dismissed",
"answered_by": "",
"events_in_range": []
}
],
"explanations": [
{
"xid": "x1",
"ids": [
"e8",
"e7"
]
},
{
"xid": "x2",
"ids": [
"e11",
"e12"
]
}
],
"context": [
{
"cid": "c1",
"title": "Nigerian independence",
"state": "accepted"
},
{
"cid": "c2",
"title": "Liverpool Blitz",
"state": "dismissed"
},
{
"cid": "c3",
"title": "Nigerian Civil War",
"state": "pending"
}
],
"events_added_since": [
"e28",
"e29",
"e30",
"e31",
"e32"
]
}
The page builds the input and checks the reply; the API does not
Over the API you get questions, explanations and context proposals as JSON Lines. Parsing fuzzy dates, building lanes, events and facts_json, and refusing a line that names a lane or event you do not have (or a date outside span) is the caller's job. The page's own code is plain scripts you can load in node after global.window = {}: chron.js (Kin.parseDate(text) for the fuzzy dates, Kin.buildInput(task, doc) builds this exact run body with clipping and marker lines, Kin.contradictions(doc), Kin.gaps(doc) and Kin.coverage(doc) are the facts) and io.js (CSV and a GEDCOM subset in and out, the Markdown interview sheet).
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope. Replace YOUR_TOKEN with the token from /tokens.html, or leave it and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN" # from https://kin-chronicle.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://kin-chronicle.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// kin-chronicle.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://kin-chronicle.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from https://kin-chronicle.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// KinChronicle.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class KinChronicle {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://kin-chronicle.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://kin-chronicle.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://kin-chronicle.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from https://kin-chronicle.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"kin-chronicle"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "kin-chronicle"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "kin-chronicle" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "kin-chronicle"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "kin-chronicle"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "kin-chronicle" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "kin-chronicle"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "kin-chronicle" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself (not wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra and model the concrete model it resolves to; markup_bps is 1000 (a 10% markup on the model cost).
# input.json: the run input object - {"task": "gaps", "title": "...", "kind": "family", "today": "2026", "span": "1908 - 2026", "lanes": "L1 | ...", "events": "e1 | ...", "facts_json": "{...}"}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "gaps", "title": "...", "kind": "family", "span": "1908 - 2026", "lanes": "...", "events": "...", "facts_json": "{...}"}
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task, title, kind, today, span, lanes, events, eras, facts_json, clip_note}
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the task and the input: a retry with the same key never bills twice. The page uses kin-chronicle-<task>-<inputHash>-a<attempt>. The job's output.output is the JSON Lines reply text; truncated: true means the balance cut it short, and every whole line before the cut still parses.
KEY="kin-chronicle-$(jq -r .task input.json)-$(shasum -a 256 input.json | cut -c1-16)-a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.jsonl # JSON Lines: meta, q, x, ctx, end
jq -c 'select(.k == "q") | {qid, lane, from, to, ask, text}' reply.jsonl
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "kin-chronicle-" + inp["task"] + "-" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + "-a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
lines = []
for raw in j["output"]["output"].splitlines(): # JSON Lines; skip a torn last line
try: lines.append(json.loads(raw))
except ValueError: pass
for q in (l for l in lines if l.get("k") == "q"):
print(q["qid"], q["lane"], q["from"], "-", q["to"], q["ask"], q["text"])
print(j["charged_credits"], j.get("truncated"))
import { createHash } from "node:crypto";
const key = `kin-chronicle-${input.task}-${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}-a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const lines = j.output.output.split("\n").flatMap((l) => { try { return [JSON.parse(l)]; } catch { return []; } });
for (const q of lines.filter((l) => l.k === "q")) console.log(q.qid, q.lane, q.from, q.to, q.ask, q.text);
console.log(j.charged_credits, j.truncated);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "strings", "time" to the imports
key := "kin-chronicle-" + input["task"].(string) + "-" + hex.EncodeToString(sum[:8]) + "-a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
for _, l := range strings.Split(j["output"].(map[string]any)["output"].(string), "\n") {
var line map[string]any
if json.Unmarshal([]byte(l), &line) == nil && line["k"] == "q" { fmt.Println(line["qid"], line["lane"], line["text"]) }
}
fmt.Println(j["charged_credits"], j["truncated"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "kin-chronicle-" + input.get("task") + "-" + HexFormat.of().formatHex(digest, 0, 8) + "-a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
for (String l : j.get("output").get("output").asText().split("\n")) {
try { JsonNode line = M.readTree(l); if ("q".equals(line.path("k").asText())) System.out.println(line.get("qid") + " " + line.get("text")); }
catch (Exception torn) { }
}
System.out.println(j.get("charged_credits"));
require "digest"
key = "kin-chronicle-#{input["task"]}-#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}-a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
lines = j["output"]["output"].lines.filter_map { |l| JSON.parse(l) rescue nil }
lines.select { |l| l["k"] == "q" }.each { |q| puts [q["qid"], q["lane"], q["from"], q["to"], q["text"]].join(" | ") }
puts j["charged_credits"]
$key = "kin-chronicle-" . $input["task"] . "-" . substr(hash("sha256", json_encode($input)), 0, 16) . "-a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
foreach (explode("\n", $j["output"]["output"]) as $l) {
$line = json_decode($l, true);
if (is_array($line) && ($line["k"] ?? "") === "q") echo $line["qid"], " ", $line["lane"], " ", $line["text"], "\n";
}
echo $j["charged_credits"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"kin-chronicle-{input["task"]}-{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}-a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
foreach (var l in j["output"]!["output"]!.GetValue<string>().Split('\n')) {
try { var line = JsonNode.Parse(l); if (line?["k"]?.GetValue<string>() == "q") Console.WriteLine($"{line["qid"]} {line["lane"]} {line["text"]}"); }
catch (JsonException) { }
}
Console.WriteLine(j["charged_credits"]);
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: kin-chronicle-$(jq -r .task input.json)-$(shasum -a 256 input.json | cut -c1-16)-a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "gaps", "title": "...", "lanes": "...", "events": "...", "facts_json": "{...}"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "kin-chronicle-" + rev["task"] + "-" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + "-a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `kin-chronicle-${rev.task}-${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}-a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "kin-chronicle-"+input["task"].(string)+"-"+hex.EncodeToString(rs[:8])+"-a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "kin-chronicle-" + input.get("task") + "-" + HexFormat.of().formatHex(rd, 0, 8) + "-a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "kin-chronicle-#{JSON.parse(rev)["task"]}-#{Digest::SHA256.hexdigest(rev)[0, 16]}-a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: kin-chronicle-" . json_decode($rev, true)["task"] . "-" . substr(hash("sha256", $rev), 0, 16) . "-a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"kin-chronicle-{input["task"]}-{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}-a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);