The moment the project changed
Saturday afternoon of JacHacks Spring 2026. I had a graph-memory research agent half working. The idea was clean on paper: an LLM agent that remembered the papers it had read as nodes in a graph, connected by typed edges like Cites and BuildsOn, so follow-up questions could walk the graph instead of re-searching. I was nowhere near a demoable thing.
I was testing it with a query about drug interactions because the sample corpus I had lying around was a stack of pharmacology PDFs. The agent produced an answer. It was wrong in a specific way. It said two drugs were fine to combine, and the graph in memory had an edge between them labeled ContraindicatedWith. The LLM had ignored the edge and reasoned from the text of the abstract.
If the edge was there, and the answer that mattered was implied by the edge, then the agent was doing the wrong work. The graph knew the answer. The LLM was confabulating.
I closed the research-agent branch. I opened a new file. jacmind became a healthcare triage agent.
What graph-memory was, briefly
The original design was almost boring. Every document ingested got broken into claim nodes. Claims got typed edges to other claims. A walker (Jac's mobile compute primitive that traverses a graph) would start at a query node, spread out through Cites and BuildsOn edges collecting relevant claims, then hand the collected subgraph to an LLM for synthesis.
The pitch to myself was that this beats vector search on questions that are relational. "What papers does this one build on?" is a graph question. Cosine similarity gives you papers that sound the same, which is not the same thing at all. Walking BuildsOn edges gives you the actual answer.
The graph-memory version worked, sort of. It was slow because of embedding on ingest. It was also hallucinating over its own graph. When an edge said A ContraindicatedWith B, the LLM would sometimes agree, and sometimes ignore the edge and write a paragraph about how A and B are generally safe.
That is a bad property for a research agent. It is a disqualifying property for anything downstream of a patient.
Why I went to healthcare
Two reasons, in this order.
The first was the failure mode I had just found. A confident LLM overruling a hard fact in its own memory is exactly the shape of failure that kills people in medication contexts. If I could show that a Jac-native graph agent could catch what a prompted LLM missed, the demo would be legible to a judge in ten seconds.
The second was that JacHacks Spring 2026 had a Consumer Healthcare track and a Best Use of Jac track, and my project needed to sit in exactly that overlap to be competitive. Judges in the healthcare track want to see safety. Judges in the Jac track want to see the language doing something that a Python graph library would struggle with. Contraindication detection through typed edges is both.
So jacmind became a triage assistant. Patient reports symptoms, agent asks structured follow-ups, agent produces a triage report with a self-care recommendation, an urgency level, and a list of over-the-counter medications to consider or avoid based on the patient's current medication list. The last part is where the graph earns its keep.
Contraindication is a graph-edge problem, not a prompt problem
Here is the version I threw out.
Give an LLM the patient's current medications and the candidate OTC recommendation. Ask it: "Are any of these contraindicated?" Get an answer. Log it.
This works about 90% of the time, which is a terrible number for medicine. The failures are the interesting cases: NSAIDs on top of an anticoagulant, an SSRI with a triptan, acetaminophen with a specific class of hepatotoxic drug the model wasn't confident about. The model will happily produce a fluent paragraph explaining why the combination is fine, when the actual pharmacology is that the combination is contraindicated. You cannot cross-examine the paragraph. You cannot ask why. It is a plausible sentence and nothing more.
The Jac-native version looks different. Every medication is a node. Every known contraindication is a typed edge, ContraindicatedWith, between two medication nodes. When the ContraindicationChecker walker runs, it does not ask an LLM anything. It walks from each of the patient's current-medication nodes across every ContraindicatedWith edge and collects the neighbors. Then it intersects that set with the candidate OTC recommendations. Anything in the intersection is a hard block.
The difference is not accuracy in the abstract. The difference is auditability. When the walker fires, its traversal is a trace. I can print the walker's path, node by node, edge by edge, and hand it to a judge or a doctor or a regulator and say: this is why the agent said no. The LLM version cannot do that. The LLM version says no because a probability distribution said so.
There is a second, quieter benefit. The graph is separately editable. If I learn tomorrow that drug X and drug Y are contraindicated in the elderly but not in general, I add one edge with a condition attribute. I do not retrain, I do not re-prompt, I do not evaluate. The knowledge lives outside the model, in a place I can inspect.
The four walkers
jacmind has four walkers, and each of them has one job.
SymptomCollector opens the conversation and pulls a structured symptom report out of the user. It asks follow-ups until it has enough to reason about. It writes symptom nodes to the graph as it goes, connected to the session root.
DiagnosticReasoner takes the collected symptoms and does a differential. This one is LLM-heavy because differential diagnosis is a language task. The output is a ranked list of candidate conditions, each with a confidence.
ContraindicationChecker is the walker I described above. It runs after DiagnosticReasoner has produced candidate OTC recommendations, and it prunes the list by walking ContraindicatedWith edges from the patient's medication list. This one is deliberately not LLM-driven. That is the whole point.
TriageReportGenerator collects the outputs of the other three and produces a readable report. Urgency level, self-care recommendation, medication guidance, and a "when to see a doctor" section. This one is LLM-heavy again, but by the time it runs the safety decisions have already been made in the graph.
Reading them back in order: collect, reason, check, report. Each walker's inputs are the previous walker's graph writes. If a judge asks how the agent decided something, I can point at the walker.
The tool-calling detour
I lost about four hours on Saturday night trying to get Jac's litellm-backed tool-calling to work reliably. I wanted the DiagnosticReasoner walker to be able to call out to a medical web search when it needed evidence beyond the model's training. The natural way to do that in most agent frameworks is tool calling: define the tool, hand it to the model, let the model decide when to invoke it.
It was flaky. Not broken, flaky. The model would sometimes call the tool, sometimes describe calling the tool in prose without actually calling it, and sometimes hallucinate a tool response and continue as if the tool had returned. Each of those failure modes is bad in its own way, and the combination made the walker's behavior non-deterministic in a way I could not reason about.
I gave up on it around midnight and inverted the flow. The walker calls web_search directly, as a normal function, based on a deterministic condition (did DiagnosticReasoner produce a candidate with confidence below a threshold?). If a search fires, the walker collects the results, then hands them to byLLM inside the walker as context. The LLM never decides to call a tool. The walker decides, the LLM synthesizes.
This is less elegant on paper. It is also much more reliable, and reliability is the property that matters for a demo. The walker-orchestrated version fired the search when I expected it to, every time, and the LLM never invented a search result that had not happened.
The stack ended up as Groq for the fast synthesis calls, Gemini for the heavier reasoning in DiagnosticReasoner, and DuckDuckGo as the search backend. Nothing exotic. The interesting choice was structural: walkers own the control flow, LLMs own the language.
What Jac gave me that a Python graph library would not
I could have written jacmind in Python with networkx and it would have worked. So why Jac?
The honest answer is that walkers are the right abstraction for this problem in a way that no Python graph library gets to. When I write a walker in Jac, the graph traversal is not a for-loop over neighbors. It is a piece of mobile compute that lives at a node, decides where to go next, and moves. That maps directly onto how I want to think about a triage agent. SymptomCollector lives at the session root and moves outward as it learns. ContraindicationChecker lives at a medication node and hops across ContraindicatedWith edges. The code reads the way the algorithm reads.
The second thing Jac gave me is that byLLM is a language feature, not a library import. When a walker needs to hand off to an LLM, the syntax is short and the walker's state is available to the call by default. In Python with networkx plus OpenAI's SDK, gluing those two together is fine, but it is glue. In Jac it is a keyword.
The third thing, and the one that mattered most on Saturday night, is that when I threw out the tool-calling approach and inverted control back to the walker, the language did not fight me. The walker was always the natural home for control flow. Jac just made it obvious.
jacmind shipped to JacHacks Spring 2026 in both the Consumer Healthcare and Best Use of Jac tracks. The demo path is: report a symptom, watch the four walkers fire in order, inspect the ContraindicationChecker's traversal trace, see the hard-blocked medications, read the triage report. The last step is the LLM's. The one that matters most is not.
See also
- /blog/medsimplify-gemma4-easy-read, the other healthcare-AI project, sitting on the discharge side of the same patient journey.
- /blog/mini-apps-while-learning-nextjs, the broader arc of small apps I built around this hackathon window.