GyanSpark GyanSpark

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.

your_existing_handler.py
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.

the important part

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.

automatic

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=...)
one extra call

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.

step 1

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.

terminal
pip install gyanspark
app.py
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.

step 2

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.

chat.py
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.content
chat.py
import 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].text
chat.py
from 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.text

Exactly 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.

ProviderCallContext 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.

merging.py
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.

step 3 · don't skip this

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.

generate_quiz.py
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...
generate_quiz.py
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...
generate_quiz.py
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...
generate_qa.py
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...
generate_qa.py
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...
generate_qa.py
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...
generate_custom.py
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...
generate_custom.py
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...
generate_custom.py
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.

record_response.py
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
    ],
)   # -> None
record_response.py
import 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
    ],
)   # -> None
record_response.py
from 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
    ],
)   # -> None

Send 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.

behavior

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.

reference

Options on wrap()

signature
gs.wrap(llm_client, roll_number,
        bypass=False, record_only=False)
OptionWhat 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.

kill_switch.py
# 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.

while you integrate

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.

app.py
gs = GyanSpark(api_key=..., base_url=..., debug=True)
stdout
[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)

reference

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.

ExceptionWhen
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.
handling.py
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.

shutdown.py
gs.close()

# or let a context manager handle it
with GyanSpark(api_key=..., base_url=...) as gs:
    ...
get started

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.