Two APIs, one graph
Build turns text you already have — a PDF you OCR'd, a lecture you transcribed, a page you scraped — into a trail: a graph of concepts, each one backed by a verbatim quote from your own material. Teach walks a learner through that trail: you send one message, and get back a tutor's reply streamed token by token, together with the mastery that answer earned and the graph it moved.
Extraction is yours. Structure, evidence-gating, the knowledge graph and the tutor are ours.
Getting started
Every request goes to https://api.graphmind.in and carries an API key. Mint one in the org console — the secret is shown once, at creation.
BASE=https://api.graphmind.in
KEY=gm_live_…
# 1. build a trail out of your own text
curl -sX POST $BASE/v1/trails \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"title":"Reinforcement Learning — Lecture 4",
"documents":[{"name":"lecture-4.pdf","segments":[
{"locator":"page 1","text":"…"},
{"locator":"page 2","text":"…"}]}]}'
# → {"trailId":"reinforcement-learning-lecture-4-a3f9","status":"building", …}
# 2. wait for it (or use a webhook)
curl -s $BASE/v1/trails/reinforcement-learning-lecture-4-a3f9 -H "Authorization: Bearer $KEY"
# → {"status":"ready", "outline":{…}}
# 3. tutor one of your learners on it — streamed
curl -N -X POST $BASE/v1/learners/alice/chat \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"trailId":"reinforcement-learning-lecture-4-a3f9",
"message":"I think the value function is just the reward?"}'GET /v1/trails/{id} or set a webhook, and only send learners at a trail once it reports ready.Authentication
Authorization: Bearer gm_live_…
Keys are minted per organization in the console. We store only a SHA-256 of the secret, so a key cannot be recovered after it is shown — lose it and mint another. Keys are revoked, never deleted, so the audit trail survives.
X-API-Key: gm_live_… is accepted as an alternative for clients that mangle Authorization across a redirect.
Build a trail
You send text; we send back structure. Every concept we extract must be backed by a quote that is literally present in what you submitted — that is a string containment check, not a prompt instruction — so a trail is made of your material rather than of what a model recalls about the topic.
/v1/trailsStarts a build and returns immediately. The build runs asynchronously.
{
"title": "Reinforcement Learning — Lecture 4",
"guide": "Focus on the maths, not the history",
"difficulty": 2,
"depth": 1,
"category": "ai",
"externalId": "lecture-4-2026",
"documents": [
{
"name": "lecture-4.pdf",
"segments": [
{ "locator": "page 1", "text": "…" },
{ "locator": "page 2", "text": "…" }
]
},
{
"name": "lecture-4.mp4",
"segments": [{ "locator": "00:14:32", "text": "…" }]
}
]
}locator is the field that matters most. Every concept carries a verbatim quote, and the locator is how that quote is addressed back to your source. Send a good one and a learner is told which minute of the lecture an idea came from; omit it and citations fall back to the filename.{ "trailId": "reinforcement-learning-lecture-4-a3f9",
"status": "building", "externalId": "lecture-4-2026",
"chars": 48210, "quotaUsed": 148210, "quota": 5000000 }Idempotency. Supplying externalId reserves it before any work begins, so a retry after a client timeout returns the original build rather than starting — and billing — a second one. The retry is answered with 200 and "idempotent": true.
/v1/trails/estimateThe same body, priced but not built. No model is called, so it is free and instant.
{ "chars": 48210, "segments": 42, "extractionCalls": 10,
"estimatedChunks": 25, "quotaUsed": 100000, "quota": 5000000,
"quotaRemaining": 4900000, "withinQuota": true }/v1/trails/{id}{ "id": "…", "status": "building",
"phase": "reading", "message": "Reading lecture-4.pdf, pages 1-6…",
"done": 3, "total": 9 }Poll this from the moment POST returns — the id is valid immediately, and the trail reports building straight away rather than appearing only once it has finished.
status goes building → ready | error. Typical phases: queued → extracting → reading → distilling → embedding → done. When ready, the response also carries an outline: modules → concepts → prerequisites, each concept with its quote and locator.
/v1/trailsEverything your organization owns, including drafts and rejected trails.
/v1/trails/{id}Permanent. Learner mastery recorded against the trail's concepts goes with it.
/v1/trails/{id}/review{ "action": "approve" | "reject", "note": "optional" }Builds are auto-approved today — a trail is published to your organization the moment it is ready. This endpoint records a verdict either way. Rejecting parks the trail back in draft, which every listing excludes: it stops being offered without being destroyed, and approve puts it back.
your callback URLSet an HTTPS callback in the console. When a build finishes we POST:
{ "trailId": "…", "externalId": "…", "status": "ready" | "error",
"subject": "…", "error": "", "at": 1756400000000 }Best-effort, with a 10-second deadline: a slow endpoint on your side never delays or fails a build. Treat GET /v1/trails/{id} as the source of truth.
Teach a learner
This is the half that runs the model. You send a learner's message; we pick what to teach next from their mastery graph, ground the reply in your own material, stream it back, silently grade what the learner demonstrated, and update the graph. One request per turn.
Learner identity
Your learners never sign in here and have no account with us. You assert a learnerId in the path — whatever you already call them: a UUID, an email, a row id — and we namespace it to your organization. Two organizations both having a learner called alice is fine; the graphs never touch.
Up to 128 characters, no control characters, no leading or trailing whitespace. It is the join key for everything below, so use a stable id — change it and you have created a new learner with no history.
/v1/learners/{learnerId}/openerThe tutor's first line of a session, and the thing that makes a learner start talking. It is a separate call from /chat because the prompt genuinely differs: a first-timer is welcomed to the trail, a returning learner is picked back up mid-thread. Costs one turn.
{ "trailId": "reinforcement-learning-lecture-4-a3f9", "stream": true }/v1/learners/{learnerId}/chat{
"trailId": "reinforcement-learning-lecture-4-a3f9",
"message": "I think the value function is just the reward?",
"stream": true
}provider or model field, and never will on this endpoint. Choosing the model, writing the tutor prompt and grading the answer are the product. If you want to run your own model, see bring your own model.The event stream
With "stream": true the response is text/event-stream. Events are named, and each carries one JSON object. They always arrive in this order:
event: ready
data: {"trailId":"…","learnerId":"alice","plan":{"focus":"Value function","depth":"explain",
"facet":"relation to reward","nextUp":["Bellman equation"],"mastered":["Reward"],
"sources":[{"cite":"lecture-4.pdf, page 7","text":"The value function is the expected…"}]}}
event: delta
data: {"text":"Close, but not quite — "}
event: delta
data: {"text":"the reward is what you get right now…"}
event: reply
data: {"text":"Close, but not quite — the reward is what you get right now… Why might a state
with a small immediate reward still have a high value?"}
event: mastery
data: {"concepts":[{"name":"Value function","mastery":0.31,"confidence":0.42,"status":"shaky",
"quality":"partial","difficulty":"explain","misconception":"value equals immediate reward"}],
"connections":[],"progress":0.18,"coverage":0.22,"nextUp":["Bellman equation"]}
event: done
data: {"ok":true,"ms":2140,"usage":{"turnsUsed":417,"turnQuota":20000,"month":"2026_08"}}200. Handle the error event; do not treat 200 as success. A turn that fails before any token is sent is refunded and does not count against your quota.text/event-stream and release it all at once. The events and their order are identical either way, so a correct client works regardless — but if your deltas arrive in one burst, look at what sits between you and us before you look at us.{
"trailId": "…", "learnerId": "alice",
"reply": "Close, but not quite — …",
"plan": { "focus": "Value function", "depth": "explain", "sources": [ … ] },
"mastery": { "concepts": [ … ], "progress": 0.18, "coverage": 0.22, "nextUp": [ … ] },
"usage": { "turnsUsed": 417, "turnQuota": 20000, "month": "2026_08" }
}/v1/learners/{learnerId}/next?trailId=What the tutor would do on the next turn, without running it: the focus concept, the depth to probe it at, the facet not yet demonstrated, and the source passages behind it. Free — no model is called. Use it to show a learner what is coming, or to drive your own model (see below).
/v1/learners/{learnerId}/progress?trailId={ "learnerId": "alice", "trailId": "…", "subject": "Reinforcement Learning — Lecture 4",
"progress": 0.18, "coverage": 0.22, "rank": 3, "ofLearners": 40,
"nextUp": ["Bellman equation", "Discount factor"],
"concepts": [
{ "concept": "Value function", "key": "value-function", "mastery": 0.31,
"target": 0.75, "classAvg": 0.44, "status": "shaky",
"frontier": true, "atTarget": false }
] }progress is mastery against target across the trail; coverage is how much of it has been touched at all. classAvg is across your organization's learners on that trail, and no one else's.
/v1/learners/{learnerId}/graph?trailId=The trail's concept graph with this learner's state painted onto it: mastery and target per node, which facets are covered, which concept is the frontier, plus prerequisite and similarity edges. One call, because structure and state fetched separately disagree the moment a turn lands between them.
{ "subject": "…",
"nodes": [{ "key": "value-function", "name": "Value function", "module": "Foundations",
"mastery": 0.31, "target": 0.75, "classAvg": 0.44, "status": "shaky",
"frontier": true, "atTarget": false, "inCourse": true,
"facets": [{ "name": "relation to reward", "covered": false }],
"nextFacet": "relation to reward" }],
"edges": [{ "source": "reward", "target": "value-function",
"type": "prereq", "weight": 1, "directed": true }] }/v1/learners/{learnerId}/history?trailId=The transcript we keep for that learner and trail. Empty if you send your own history on every turn.
/v1/learners{ "learners": [
{ "learnerId": "alice", "trails": ["reinforcement-learning-lecture-4-a3f9"],
"concepts": 12, "lastSeenAt": 1756400000000 }
] }/v1/learners/{learnerId}Erases a learner: their mastery, their enrolments, their transcript, the node itself. Irreversible, and deliberately so — when you are asked to delete a person's data this has to actually delete it. Trail content is untouched.
Usage & limits
/v1/usage{ "month": "2026_08",
"ingest": { "chars": 148210, "quota": 5000000, "remaining": 4851790 },
"tutoring": { "turns": 417, "quota": 20000, "remaining": 19583 } }Two meters, because they measure two costs. Characters buy the one-off build of a trail — one extraction call per 5,000 characters, plus an embedding per 2,000-character chunk. Turns buy every tutored exchange on it, forever after. An organization that imports a small library and teaches it to ten thousand people spends almost nothing on the first and everything on the second.
Every limit is enforced synchronously, so an over-limit request fails at submit with a 4xx you can act on — never as an async job that dies minutes later having already spent your quota. Need more than the defaults? Ask.
Errors
{ "error": "monthly character quota exhausted",
"used": 4990000, "quota": 5000000, "requested": 48210 }A 429 from the rate limiter also sets Retry-After and repeats it as retryAfter in the body.
Bring your own model
Everything above is mode 1: we run the tutor. There is a second mode for teams that want to keep the model call — and the learner's words — on their own side. In it we supply the steering, the graph and the mastery write, and you supply the intelligence: your model, your prompt, your infrastructure.
The primitive already exists here: GET /v1/learners/{id}/next returns exactly the context a tutoring model needs — which concept to teach, at what depth, which facet has not been demonstrated, which of your own passages to teach from, and what the learner has already mastered and must not be asked again. Feed that to your model today and you have most of mode 2 already.
What is still to come is the other half of the loop: an endpoint that accepts your model's assessment of a turn and applies it to the graph, plus the system prompt, skill file and MCP server that let a general-purpose assistant drive all of this without you writing an integration at all. Tell us if that is the shape you need — it changes what we build first.