You're already calling an LLM.
Wrap it.
GyanSpark sits between your code and your provider. Every turn does two extra things automatically: it learns from the conversation so far, and it grounds the next reply in what's already known about that student. Your call signature doesn't change.
from openai import OpenAI
from gyanspark import GyanSpark
gs = GyanSpark(api_key=..., base_url=...) # once per process
def handle_message(roll_number, messages):
client = OpenAI()
client = gs.wrap(OpenAI(), roll_number=roll_number)
return client.chat.completions.create(
model="gpt-4o",
messages=messages,
) That's the whole integration for a chat feature. Everything below is detail you'll want eventually — what gets touched, what never does, and how to feed the graph from parts of your product that aren't chat at all.
Two ways the graph learns — and you'll probably need both.
A student's understanding doesn't only show up in chat. It shows up in what they got wrong on a quiz, how they answered an assignment and how they respond to various tools you have at your platform. GyanSpark takes both kinds of signal, and they land in the same graph.
A chat feature
You already send the student's message to a model. GyanSpark sees that exchange on its way through, so the graph grows on its own — you write no extra code.
gs.wrap(client, roll_number=...) A quiz, flashcard, or assignment
Generating the question is its own wrapped call — that half is already covered, and it's why the question comes out personalized. What we can't see is whether the student got it right. Send us the question and their actual response and the loop closes.
record_only=True One knowledge graph per student
Both paths write to the same place — what they've grasped, what they keep slipping on, and how that's moved over time.
Grounded replies
Retrieved and injected into your system prompt on the next call, automatically.
Better generated content
The next quiz, deck, or revision set is built against what this student actually needs.
Wrapping only your chatbot builds a Knowledge Graph (KG) from a single source: student questions and doubts. That's a solid start, but relying on one angle is limiting. By capturing quiz results, assignments, and every other platform interaction, we feed the KG a full-picture view of how each student learns—especially where they struggle. The more touchpoints that flow through us, the richer the KG becomes, unlocking deep, unmatched personalization for every student.
Install, then build one client at startup.
Requires Python 3.9+. Works with OpenAI, Anthropic, and Gemini — you don't need any of them installed for GyanSpark itself to install.
pip install gyanspark import os
from gyanspark import GyanSpark
# Once per process — it holds a connection pool and background workers.
# Don't build one per request.
gs = GyanSpark(
api_key=os.environ["GYANSPARK_API_KEY"],
base_url=os.environ["GYANSPARK_BASE_URL"],
)
Both credentials are required and there's no default base_url on purpose — a
silent fallback to production when you meant to point at staging is the kind of mistake
that costs a day.
Wrap the client, once per request.
wrap() is cheap — it allocates nothing but a small object — so call it per
request, with that request's student. Pick your provider; the pattern is identical
either way. The api_key and base_url below are editable —
click into either one and type your own; every other copy on this page updates to match,
so you can copy-paste a snippet that already has your real values.
from openai import OpenAI
from gyanspark import GyanSpark
gs = GyanSpark(api_key="gs-...", base_url="https://...") # once per process
def handle_message(roll_number: str, messages: list) -> str:
client = gs.wrap(OpenAI(), roll_number=roll_number)
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
)
return response.choices[0].message.contentimport anthropic
from gyanspark import GyanSpark
gs = GyanSpark(api_key="gs-...", base_url="https://...") # once per process
def handle_message(roll_number: str, messages: list) -> str:
client = gs.wrap(anthropic.Anthropic(), roll_number=roll_number)
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
system="You are a patient math tutor.", # kept; context appended
messages=messages,
)
return response.content[0].textfrom google import genai
from gyanspark import GyanSpark
gs = GyanSpark(api_key="gs-...", base_url="https://...") # once per process
def handle_message(roll_number: str, contents: list) -> str:
client = gs.wrap(genai.Client(api_key="..."), roll_number=roll_number)
response = client.models.generate_content(
model="gemini-3-flash-preview",
contents=contents,
)
return response.textExactly one call is intercepted
Everything else on the client — other methods, other attributes, streaming variants — is passed straight through and behaves exactly as it always did.
| Provider | Call | Context is added to |
|---|---|---|
| OpenAI | client.chat.completions.create | Leading system message |
| Anthropic | client.messages.create | The system= parameter |
| Gemini | client.models.generate_content | config.system_instruction |
Your own system prompt is kept
Context never replaces your instructions and never goes into the conversation itself — only into the system prompt slot, so there's nothing to strip out later.
messages = [
{"role": "system", "content": "You are a patient math tutor."},
{"role": "user", "content": "how do i factor this?"},
]
# The model receives:
# "You are a patient math tutor.\n\n<student context>"
# Your own instructions are kept. Context is appended to them.
On Anthropic, a system passed as a list of blocks gets a text block appended
and your blocks are left alone. On Gemini, your config is copied — every other
field carries over untouched and the object you passed is never modified, so it's safe to
build one config at startup and reuse it forever.
Generating the content is only half the loop.
Whatever you're building on top of an LLM — a quiz, a flashcard deck, a practice set, a written assignment, a lab simulation, a revision planner — generating it is a normal wrapped call, so what comes back is already personalized to that student. But that call only covers generation.
What the student actually did with it — right, wrong, or wrong in a specific and repeating way — never touches an LLM, so GyanSpark stays completely blind to it unless you send it back.
That gap is why we built record_only=True mode. It turns the wrapped method
into a pure recorder for that second half: pair what you showed the student with what
they did about it, bypass your provider entirely, and let the call quietly write to the
graph — returning None without triggering an LLM call.
Generation — however your tool wants it
Quiz, Q&A, and one more — click through the tabs below and the code updates instantly. These
are three examples chosen to show the range, not a menu of the only tools GyanSpark works
with. The prompt, the response shape, even whether you parse JSON at all — none of it is
fixed; wrap() doesn't inspect or care what you ask the model for.
import json
from openai import OpenAI
from gyanspark import GyanSpark
gs = GyanSpark(api_key="gs-...", base_url="https://...") # once per process
# Generating content for one of your own tools is a normal wrapped call —
# it comes back already grounded in what's known about this student.
#
# This prompt is one example, not a format you have to follow. Ask for
# whatever your tool needs: options to pick from, a flashcard front/back,
# a diagram spec, a practice set. GyanSpark never inspects the shape.
client = gs.wrap(OpenAI(), roll_number="A-101")
response = client.chat.completions.create(
model="gpt-4o",
messages=[{
"role": "user",
"content": """\
Generate one multiple-choice question on topic X. Return strict JSON only, no prose: \
{"question": "...", "options": ["...", "...", "...", "..."], "correct_index": 0}""",
}],
)
item = json.loads(response.choices[0].message.content)
shown_to_student = item["question"] # the question text your UI actually puts in front of them
# ...your tool renders it — the JSON exists only to drive that display...import json
import anthropic
from gyanspark import GyanSpark
gs = GyanSpark(api_key="gs-...", base_url="https://...") # once per process
# Generating content for one of your own tools is a normal wrapped call —
# it comes back already grounded in what's known about this student.
#
# This prompt is one example, not a format you have to follow. Ask for
# whatever your tool needs: options to pick from, a flashcard front/back,
# a diagram spec, a practice set. GyanSpark never inspects the shape.
client = gs.wrap(anthropic.Anthropic(), roll_number="A-101")
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
messages=[{
"role": "user",
"content": """\
Generate one multiple-choice question on topic X. Return strict JSON only, no prose: \
{"question": "...", "options": ["...", "...", "...", "..."], "correct_index": 0}""",
}],
)
item = json.loads(response.content[0].text)
shown_to_student = item["question"] # the question text your UI actually puts in front of them
# ...your tool renders it — the JSON exists only to drive that display...import json
from google import genai
from gyanspark import GyanSpark
gs = GyanSpark(api_key="gs-...", base_url="https://...") # once per process
# Generating content for one of your own tools is a normal wrapped call —
# it comes back already grounded in what's known about this student.
#
# This prompt is one example, not a format you have to follow. Ask for
# whatever your tool needs: options to pick from, a flashcard front/back,
# a diagram spec, a practice set. GyanSpark never inspects the shape.
client = gs.wrap(genai.Client(api_key="..."), roll_number="A-101")
response = client.models.generate_content(
model="gemini-3-flash-preview",
contents=[{"role": "user", "parts": [{"text": """\
Generate one multiple-choice question on topic X. Return strict JSON only, no prose: \
{"question": "...", "options": ["...", "...", "...", "..."], "correct_index": 0}"""}]}],
)
item = json.loads(response.text)
shown_to_student = item["question"] # the question text your UI actually puts in front of them
# ...your tool renders it — the JSON exists only to drive that display...from openai import OpenAI
from gyanspark import GyanSpark
gs = GyanSpark(api_key="gs-...", base_url="https://...") # once per process
# Same call, different shape. A written-solution tool has nothing to
# render options for, so plain text back is enough — no JSON, no parsing.
client = gs.wrap(OpenAI(), roll_number="A-101")
response = client.chat.completions.create(
model="gpt-4o",
messages=[{
"role": "user",
"content": """\
Set one open-ended task on topic X that the student answers by writing out \
their own solution and reasoning. Return only the task text.""",
}],
)
shown_to_student = response.choices[0].message.content
# ...your tool renders it; the student writes their solution in your editor...import anthropic
from gyanspark import GyanSpark
gs = GyanSpark(api_key="gs-...", base_url="https://...") # once per process
# Same call, different shape. A written-solution tool has nothing to
# render options for, so plain text back is enough — no JSON, no parsing.
client = gs.wrap(anthropic.Anthropic(), roll_number="A-101")
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
messages=[{
"role": "user",
"content": """\
Set one open-ended task on topic X that the student answers by writing out \
their own solution and reasoning. Return only the task text.""",
}],
)
shown_to_student = response.content[0].text
# ...your tool renders it; the student writes their solution in your editor...from google import genai
from gyanspark import GyanSpark
gs = GyanSpark(api_key="gs-...", base_url="https://...") # once per process
# Same call, different shape. A written-solution tool has nothing to
# render options for, so plain text back is enough — no JSON, no parsing.
client = gs.wrap(genai.Client(api_key="..."), roll_number="A-101")
response = client.models.generate_content(
model="gemini-3-flash-preview",
contents=[{"role": "user", "parts": [{"text": """\
Set one open-ended task on topic X that the student answers by writing out \
their own solution and reasoning. Return only the task text."""}]}],
)
shown_to_student = response.text
# ...your tool renders it; the student writes their solution in your editor...import json
from openai import OpenAI
from gyanspark import GyanSpark
gs = GyanSpark(api_key="gs-...", base_url="https://...") # once per process
# Neither a quiz nor a Q&A — quiz and Q&A aren't special cases with their
# own rules, so here's the general case. Ask for whatever shape your own
# tool needs; the field names below are placeholders for yours.
client = gs.wrap(OpenAI(), roll_number="A-101")
response = client.chat.completions.create(
model="gpt-4o",
messages=[{
"role": "user",
"content": """\
Generate content for <your tool> on topic X. Return strict JSON only, no \
prose, shaped however your tool needs — for example: \
{"field_1": "...", "field_2": "..."}""",
}],
)
item = json.loads(response.choices[0].message.content)
shown_to_student = item["field_1"] # whichever field(s) your UI actually displays
# ...your tool renders item however it needs to...import json
import anthropic
from gyanspark import GyanSpark
gs = GyanSpark(api_key="gs-...", base_url="https://...") # once per process
# Neither a quiz nor a Q&A — quiz and Q&A aren't special cases with their
# own rules, so here's the general case. Ask for whatever shape your own
# tool needs; the field names below are placeholders for yours.
client = gs.wrap(anthropic.Anthropic(), roll_number="A-101")
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
messages=[{
"role": "user",
"content": """\
Generate content for <your tool> on topic X. Return strict JSON only, no \
prose, shaped however your tool needs — for example: \
{"field_1": "...", "field_2": "..."}""",
}],
)
item = json.loads(response.content[0].text)
shown_to_student = item["field_1"] # whichever field(s) your UI actually displays
# ...your tool renders item however it needs to...import json
from google import genai
from gyanspark import GyanSpark
gs = GyanSpark(api_key="gs-...", base_url="https://...") # once per process
# Neither a quiz nor a Q&A — quiz and Q&A aren't special cases with their
# own rules, so here's the general case. Ask for whatever shape your own
# tool needs; the field names below are placeholders for yours.
client = gs.wrap(genai.Client(api_key="..."), roll_number="A-101")
response = client.models.generate_content(
model="gemini-3-flash-preview",
contents=[{"role": "user", "parts": [{"text": """\
Generate content for <your tool> on topic X. Return strict JSON only, no \
prose, shaped however your tool needs — for example: \
{"field_1": "...", "field_2": "..."}"""}]}],
)
item = json.loads(response.text)
shown_to_student = item["field_1"] # whichever field(s) your UI actually displays
# ...your tool renders item however it needs to...
That third tab isn't a real prompt — it's not meant to run as-is. It's there to show that
wrap() doesn't know or care what "content" means for your tool: swap the prompt
and the field you pull out of the response, and everything else on this page still applies.
Recording — a separate pass, no LLM in it
This is not a continuation of the code above and doesn't belong in the same request. Generation
happens when you build the activity; recording happens when the student finishes it, which may
be minutes or days later, in a different handler entirely. The two only share the
roll_number — so the call below looks the same no matter which tool, or which
generation shape, produced what the student saw.
from openai import OpenAI
from gyanspark import GyanSpark
gs = GyanSpark(api_key="gs-...", base_url="https://...") # once per process
# Runs whenever a student finishes something in your tool — a separate pass
# from generating it, often minutes or days later. Identical regardless of
# what your tool is or what shape the generation used: what GyanSpark reads
# is these two plain strings.
recorder = gs.wrap(OpenAI(), roll_number="A-101", record_only=True)
recorder.chat.completions.create(
model="gpt-4o", # ignored — OpenAI is never contacted
messages=[
{"role": "assistant", "content": shown_to_student}, # what you showed them
{"role": "user", "content": student_response}, # what they did with it, verbatim
],
) # -> Noneimport anthropic
from gyanspark import GyanSpark
gs = GyanSpark(api_key="gs-...", base_url="https://...") # once per process
# Runs whenever a student finishes something in your tool — a separate pass
# from generating it, often minutes or days later. Identical regardless of
# what your tool is or what shape the generation used: what GyanSpark reads
# is these two plain strings.
recorder = gs.wrap(anthropic.Anthropic(), roll_number="A-101", record_only=True)
recorder.messages.create(
model="claude-sonnet-5", # ignored — Anthropic is never contacted
max_tokens=1024,
messages=[
{"role": "assistant", "content": shown_to_student}, # what you showed them
{"role": "user", "content": student_response}, # what they did with it, verbatim
],
) # -> Nonefrom google import genai
from gyanspark import GyanSpark
gs = GyanSpark(api_key="gs-...", base_url="https://...") # once per process
# Runs whenever a student finishes something in your tool — a separate pass
# from generating it, often minutes or days later. Identical regardless of
# what your tool is or what shape the generation used: what GyanSpark reads
# is these two plain strings.
recorder = gs.wrap(genai.Client(api_key="..."), roll_number="A-101", record_only=True)
recorder.models.generate_content(
model="gemini-3-flash-preview", # ignored — Gemini is never contacted
contents=[
{"role": "model", "parts": [{"text": shown_to_student}]}, # what you showed them
{"role": "user", "parts": [{"text": student_response}]}, # what they did with it, verbatim
],
) # -> NoneSend the real response, not a score
Pass the student's own answer as the user message — the option text they
picked, the solution they wrote, the move they made — not "correct" or "incorrect". A
boolean tells the graph almost nothing; the actual attempt is what turns into a useful
node.
Prompt as assistant, response as user
Only the most recent pair is recorded, in that order — what you showed the student as
assistant, their attempt as user. A longer trail has its
earlier turns ignored, with a warning on the gyanspark logger.
Nothing comes back
In record_only mode specifically — unlike a normal wrapped call — your
provider is never contacted and the method just returns None, so problems
are silent by default. Enable the logger if you want to see them.
The same shape covers the tail of a chat session too: because the final exchange of a
conversation is never auto-recorded — nothing follows it to trigger the write — send it
through record_only when the session closes if that turn matters to you.
What to expect once it's in the call path.
Worth reading before you ship, not after.
It never breaks your chat
If GyanSpark is slow, unreachable, or has nothing to say yet, no context is added and your LLM call goes out exactly as if you'd never wrapped the client. The one exception is an invalid API key, which raises — a bad key quietly degrading to 'no memory, forever' is a far worse thing to debug.
Your objects are never modified
Context is merged into a copy that exists for that one request. The messages, contents, and config you passed come back exactly as you passed them — so what you save to your database is your conversation and nothing else.
Nothing is cached between calls
Context is fetched fresh every time, so a student who just had a breakthrough isn't described by last week's snapshot. It also means the wrapper behaves identically across processes, workers, and replicas — there's no sticky session state.
Learning happens in the background
Recording what the student just did never blocks your response and adds no latency to your reply. Fetching context does sit on the critical path — budget a short round trip before your LLM call goes out.
New students start empty
The first time a roll_number is seen there's nothing to ground a reply in, so no context is added. It builds from that conversation onward.
The last exchange isn't auto-recorded
GyanSpark learns from completed question-and-answer pairs in the history you pass, so the first message of a conversation records nothing — and the final exchange never does either, because nothing follows it to trigger the write. Record it explicitly with record_only when a session ends.
Options on wrap()
gs.wrap(llm_client, roll_number,
bypass=False, record_only=False) | Option | What it does |
|---|---|
roll_number required | Your own identifier for the student, and the only identity the SDK needs. Use whatever you already key students by. An unseen value creates the student for you. Not a True/False flag — always required, on every call. |
bypass | True GyanSpark is switched off for this client. You get the original, unwrapped client back — no context retrieval, no injection, nothing recorded. This helps maintain code reusability for different purposes within the platform while keeping behavior consistent. Falsedefault GyanSpark runs normally: context is retrieved, injected, and the exchange is recorded in the background. |
record_only | True The method becomes a pure recorder — your provider is never contacted, no LLM call goes out, and it returns None instead of a response. Falsedefault A normal wrapped call: your provider is contacted, context is injected, and the response comes back as usual. |
bypass and record_only are mutually exclusive — setting both
True at once raises ValidationError before either takes effect.
# A kill switch you can drive from config, without touching any call site.
client = gs.wrap(OpenAI(), roll_number="A-101", bypass=settings.MEMORY_DISABLED)
Arguments are still validated under bypass, so switching it back off can't
surprise you with a new error later.
Make it tell you what it's doing.
The wrapper is silent by design — which is exactly what you don't want on day one. Pass
debug=True and it narrates every turn.
gs = GyanSpark(api_key=..., base_url=..., debug=True) [gyanspark] turn : 3 conversation turn(s) in history; query='how do i factor it'
[gyanspark] write : queued 8c21-... in the background (2 turn(s), student=A-101)
[gyanspark] context : empty - student=A-101 is not enrolled yet
[gyanspark] context : skipped - nothing recorded to fetch yet.
No system prompt will be injected this turn.
[gyanspark] write : background write 8c21-... finished - recorded
# ...and on a later turn, once the student exists:
[gyanspark] context : got 412 chars (cutoff_k=3) for student=A-101
[gyanspark] context : <the exact text being added to your system prompt>
[gyanspark] inject : adding 412 chars to the system prompt That answers the four questions worth asking while wiring this up: whether the student is known to the graph yet, whether context came back and what it says, whether it was injected, and how the background write finished. Anything skipped says why it was skipped.
Leave the flag off in production. If you already configure logging, set the level yourself
instead — same output, your handlers:
logging.getLogger("gyanspark").setLevel(logging.DEBUG)
Errors, and which ones can actually reach you.
Everything importable from gyanspark. In practice only the first three
surface — the rest are handled internally so your LLM call still goes out.
| Exception | When |
|---|---|
ValidationError | Bad arguments to wrap() — a missing roll_number, an unsupported or async client, or both mode flags set. Raised before any network call. |
ConfigurationError | A missing api_key or base_url when constructing GyanSpark. |
AuthenticationError | Your API key is invalid or deactivated. |
GyanSparkTimeoutError | A request took too long. Handled internally — your LLM call still goes out. |
GyanSparkConnectionError | GyanSpark was unreachable. Handled internally — your LLM call still goes out. |
GyanSparkAPIError | Base class for the two above. |
from gyanspark import AuthenticationError
try:
response = client.chat.completions.create(model="gpt-4o", messages=messages)
except AuthenticationError:
... # your GyanSpark API key is invalid or deactivated
Async clients aren't supported by wrap() yet, and are rejected up front rather
than silently slowing your event loop.
On shutdown
The client holds a connection pool and background workers. Close it when your process ends.
gs.close()
# or let a context manager handle it
with GyanSpark(api_key=..., base_url=...) as gs:
... That's the whole surface area.
Book a demo if you want a walkthrough of what the graph looks like for your subject area, or start wiring it up now.