<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Markus Trasberg</title><description>Engineer, builder, occasional writer. CTO at Mindsera, Tallinn, Estonia.</description><link>https://markustrasberg.com/</link><item><title>The Data Oracle</title><link>https://markustrasberg.com/the-data-oracle/</link><guid isPermaLink="true">https://markustrasberg.com/the-data-oracle/</guid><description>How an Opus chatbot beat PostHog, Mixpanel, Hex, and Metabase for our product analytics.</description><pubDate>Wed, 13 May 2026 00:00:00 GMT</pubDate><content:encoded>In a team of 4, founders are expected to wear a lot of hats. Data analytics has largely fallen to me: how do we track what&apos;s important, how do we use data in decision making, all that stuff. But as anyone who&apos;s been in an analyst role knows, putting together good dashboards and deep dives takes a ton of time.

We&apos;ve tested plenty of product analytics tools: Posthog, Mixpanel, Hex, Metabase, to name a few. But in recent months, one tool has beaten them all, and not by small margins. I call it the data oracle.

## Data Oracle

*An oracle is **a person, medium, or shrine** (notably in ancient Greece) believed to deliver wise, prophetic, or divine counsel.*

The hard thing about product analytics is that you usually don&apos;t know what you&apos;re looking for. You&apos;re poking around, merging data in different ways, hoping a pattern surfaces. There are ten ways to answer any data question, and on a good day you&apos;ll have time to test two.

Most of the work is creative: asking the right question. The SQL itself is the easy part, but writing it still drains mental capacity. Or you can copy-paste between ChatGPT and an SQL client but this is still dull, slow, and not particularly safe (one hallucinated UPDATE command and you&apos;re restoring from backup).

So at the end of last year, we looked for a tool with proper AI integration that could run SQL in a safe environment. Hex and Metabase were the two contenders with decent AI support. However, both turned out to be built for a pre-AI world: the smart features are added on top of the core non-AI platform and 90% of the time you end up fighting the agent instead of asking questions. On top of that, both run €2-3K per year. For a bootstrapped company, that makes a big difference.

## Building it ourselves

Our data oracle is, in essence, an Opus-powered chatbot connected to our Postgres database. Before anyone comes screaming about the safety implications of this, let me explain.

**Read-only Postgres role**

We created a dedicated Postgres user with SELECT access to a narrow set of analytics views, not the underlying tables. No PII or sensitive data could be accessed by the agent. In essence, it cannot see fields it shouldn&apos;t, and it cannot write or edit anything. If the LLM hallucinates a DELETE, Postgres rejects it at the permission layer, not at the prompt layer. Prompt-level guardrails are useful but they&apos;re suggestions, whereas database permissions are physical constraints.

**Agent design**

The agent has two tools:

- **get_schema**: This returns the full schema about the accessible fields. This gives the model the necessary context. We separated it into a tool call instead of just passing it via system prompt to save on tokens but could work either way.
- **execute_query**: The main function that runs the SQL. Restricted to SELECT, WITH and EXPLAIN queries.

The agent can chain multiple queries within a thread. Most real questions need 2-4 different queries: one to understand the shape of the data, one or two to answer, and sometimes one to sanity-check. Letting the model iterate inside a single thread is what makes it feel like a real analyst rather than a SQL autocomplete.

Here&apos;s the system prompt:

```text
You are a database analyst for Mindsera, a journaling &amp; mental wellness platform. You answer questions about the production PostgreSQL database.

## Schema

All tables live in the analytics schema.

When necessary, call get_schema to load the full schema reference. Only call it when you need it again.

## Guidelines

- Call get_schema once, then go straight to execute_query.
- Only SELECT / WITH / EXPLAIN allowed.
- Use LIMIT on potentially large result sets.
- You may run multiple queries to build up an answer.
- After getting results, explain what they mean in plain language.
- Format numbers with separators and dates for readability.
```

**Interface**

The UI is a basic chatbot, built directly into our admin panel. You ask a question, you get an answer.

![Chat UI: asking the data oracle a question in the admin panel](/blog/data-oracle/ask-your-database-ui.webp)

Answer:

&lt;img src=&quot;/blog/data-oracle/ask-your-db-answer.webp&quot; alt=&quot;Chat UI: the oracle&apos;s reply with query results explained in plain language&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;max-width: 80%; height: auto; display: block; margin-inline: auto;&quot; /&gt;

## What it actually feels like to use

Within a few weeks of launching, I&apos;ve created 41 new threads with it. When discussing product improvements, we can pull it up mid-call, ask a question, and have an answer in 30 seconds. We&apos;ve used it to better define our ICP, discover patterns about habit building, and much more. My cofounder can ask it questions without knowing anything about SQL.

The unlock isn&apos;t that the oracle gives us answers we couldn&apos;t get before. It&apos;s that the time cost of asking a data question has dropped close to zero. When asking is quick, you ask more, and you ask better questions.

Safe to say, the quality and speed of our decision making has gone up immensely. It&apos;s kinda like having the Dune Mentat dude available 24/7.

&lt;img src=&quot;/blog/data-oracle/mentat.png&quot; alt=&quot;Paul Atreides and a Mentat in Dune&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;max-width: 50%; height: auto; display: block; margin-inline: auto;&quot; /&gt;

## The bigger point

The best part: we control the prompts and the results, it&apos;s one less platform to manage, and the data stays in-house. For a setup that took half a day and $15 per month to cover the tokens, not bad.

For years, the right answer was almost always &quot;buy the SaaS.&quot; Internal tools were expensive to build and maintain. But AI flips the math, meaning all the UI logic and AI setup is now a simple weekend project. What remains is the part you&apos;d want to keep in-house anyway: the security model, the schema, the questions specific to your business.

Data analytics has gone from one of the most time-consuming parts of founder life to one of the most fun. The oracle delivers, just like the Greeks promised.</content:encoded></item><item><title>How we built Memory at Mindsera</title><link>https://markustrasberg.com/how-we-built-memory-at-mindsera/</link><guid isPermaLink="true">https://markustrasberg.com/how-we-built-memory-at-mindsera/</guid><description>A behind-the-scenes look at how we designed a memory system for an AI journaling app.</description><pubDate>Thu, 30 Apr 2026 00:00:00 GMT</pubDate><content:encoded>## Why memory

One of the most common pieces of feedback from Mindsera users in the past 12 months: their journal keeps forgetting important details. Their partner&apos;s name, the goals they&apos;ve set, the issues they&apos;re facing.

We&apos;ve had basic vector search (RAG) since the early days. It finds relevant entries based on similarity and pulls in content from there. Still useful, but there&apos;s a lot of luck involved. With a hundred entries, the chance of RAG finding all the important details is quite low.

We also considered adding a simple memory textbox where users could fill it out themselves. But memory is one of those features that should work fully in the background, just like our brain. You don&apos;t want to manually pick what to remember and what to forget.

So since early this year, we&apos;ve been trying to crack real memory — a system that remembers what matters and forgets the noise.

## What makes memory good

A well-functioning memory system comes down to two things:

- It must be accurate
- It must know when it&apos;s needed

For general AI apps like ChatGPT and Claude, memory is a double-edged sword. Useful in some cases, but it over-indexes its answers in others. Earlier this year we were building Call Mode for Mindsera (a voice journaling feature with an AI agent), and for a month almost every AI question I asked Claude got tied back to Call Mode, even when it had nothing to do with it. Super annoying and counter-productive.

Memory often turns AI chatbots into a self-boosting loop, similar to social media these days. You only see a tiny slice of content hyper-relevant to you, and you lose sight of the bigger picture. When you ask about something, the response only considers your angle and you miss alternatives. I often find myself going to another AI provider that hasn&apos;t built memory about me to get an unbiased opinion.

For Mindsera, we think about memory differently. Its main goal is to guide you, improve the quality of questions, and find unnoticed patterns between your current and past writing. We often write about problems without realizing we had similar issues a year ago. The goal is to help users see those patterns and act on them faster.

## Learning from existing solutions

Before building anything, we did thorough testing on existing memory providers.

OpenClaw is an interesting example with its markdown-based memory system. It stores a single curated file (`memory.md`) for long-term facts plus daily append-only memory logs. Simple, and it works nicely for chat-based systems, but it only focuses on what&apos;s currently relevant. There&apos;s almost no logic for actively evolving memory.

Then there&apos;s the SaaS players: Supermemory, Mem0, and similar tools. They promise advanced user profiles, memory graphs, and retrievals that get smarter with every interaction. After testing them with my own entries, it became clear they&apos;re also built for regular chatbots. They stored basic info about me, but it felt sterile and boring.

## Building our own memory

Taking the learnings from these systems, we decided our memory would have two building blocks: facts and categories.

**Facts**

For every entry, we generate hard facts that serve as the truth for that point in time. Simple, short sentences like &quot;User went to university in Netherlands&quot; that can be clearly deduced from the entry. Every entry usually produces 5–10 facts, so you end up with hundreds of small details over time.

**Categories**

Once facts are generated, we group them into categories. Categories are blocks of text put together from the facts. We settled on eight static ones: about me, preferences, people, work &amp; career, goals &amp; aspirations, health &amp; habits, beliefs, and patterns.

Hardcoding them into groups helps us later pick only the relevant groups for a given question. To generate the categories, we considered two approaches:

1. Chunk the facts into smaller time periods (e.g. monthly), generate result for each time period, and then have a prompt write final result from the monthly periods.
2. Process all the facts in a single prompt

Oddly enough, option 2 produced much better results. Even though many facts are outdated, the model benefited from seeing all the context at once and could figure out what&apos;s still relevant vs what isn&apos;t. A year ago this would have epically failed, but with today&apos;s thinking models, it&apos;s near perfect.

**Evolving memory**

With categories in place, the core memory is done. But in a real life AI tool, constantly evolving memory is what really matters. So the question becomes: how do you append new facts and grow memory naturally?

We use two events: daily evolve and monthly cleanup.

Every night, a cronjob takes new entries per user and creates facts from them. Using those facts, we evolve the existing categories, which basically means merging the old categories with the new facts. This works for a while, but there&apos;s a problem: LLMs are great at merging, but they don&apos;t know what&apos;s no longer relevant. We can&apos;t just prompt them to &quot;remove things that aren&apos;t relevant anymore&quot; without giving them context about those things.

To fix this, we run a monthly cleanup. We take all the existing facts and regenerate each category from scratch. This keeps the categories relevant and continuously filters out the noise.

&lt;figure class=&quot;diagram&quot; style=&quot;margin: 2.5em 0;&quot;&gt;&lt;div style=&quot;overflow-x: auto; -webkit-overflow-scrolling: touch;&quot;&gt;&lt;svg viewBox=&quot;140 10 580 580&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; role=&quot;img&quot; aria-labelledby=&quot;memory-diagram-title memory-diagram-desc&quot; style=&quot;display: block; width: 100%; min-width: 480px; max-width: 560px; height: auto; margin: 0 auto;&quot;&gt;&lt;title id=&quot;memory-diagram-title&quot;&gt;Mindsera memory pipeline&lt;/title&gt;&lt;desc id=&quot;memory-diagram-desc&quot;&gt;Journal entries are turned into facts, stored over time, grouped into eight categories, filtered for relevance, and injected into AI responses. Daily evolve and monthly cleanup keep the categories fresh.&lt;/desc&gt;&lt;defs&gt;&lt;marker id=&quot;md-arrow&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto&quot;&gt;&lt;path d=&quot;M0,0 L10,5 L0,10 z&quot; fill=&quot;var(--accent)&quot; /&gt;&lt;/marker&gt;&lt;/defs&gt;&lt;g font-family=&quot;Inter, system-ui, sans-serif&quot;&gt;&lt;rect x=&quot;160&quot; y=&quot;20&quot; width=&quot;360&quot; height=&quot;60&quot; rx=&quot;8&quot; fill=&quot;var(--bg)&quot; stroke=&quot;var(--line)&quot; stroke-width=&quot;1.5&quot; /&gt;&lt;text x=&quot;340&quot; y=&quot;46&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--fg)&quot; font-size=&quot;17&quot; font-weight=&quot;600&quot;&gt;Journal entries&lt;/text&gt;&lt;text x=&quot;340&quot; y=&quot;66&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--muted)&quot; font-size=&quot;13.5&quot;&gt;raw user writing&lt;/text&gt;&lt;line x1=&quot;340&quot; y1=&quot;82&quot; x2=&quot;340&quot; y2=&quot;108&quot; stroke=&quot;var(--accent)&quot; stroke-width=&quot;1.5&quot; marker-end=&quot;url(#md-arrow)&quot; /&gt;&lt;rect x=&quot;160&quot; y=&quot;110&quot; width=&quot;360&quot; height=&quot;60&quot; rx=&quot;8&quot; fill=&quot;var(--bg)&quot; stroke=&quot;var(--line)&quot; stroke-width=&quot;1.5&quot; /&gt;&lt;text x=&quot;340&quot; y=&quot;136&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--fg)&quot; font-size=&quot;17&quot; font-weight=&quot;600&quot;&gt;Extract facts&lt;/text&gt;&lt;text x=&quot;340&quot; y=&quot;156&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--muted)&quot; font-size=&quot;13.5&quot;&gt;5–10 hard facts per entry&lt;/text&gt;&lt;line x1=&quot;340&quot; y1=&quot;172&quot; x2=&quot;340&quot; y2=&quot;198&quot; stroke=&quot;var(--accent)&quot; stroke-width=&quot;1.5&quot; marker-end=&quot;url(#md-arrow)&quot; /&gt;&lt;rect x=&quot;160&quot; y=&quot;200&quot; width=&quot;360&quot; height=&quot;60&quot; rx=&quot;8&quot; fill=&quot;var(--bg)&quot; stroke=&quot;var(--line)&quot; stroke-width=&quot;1.5&quot; /&gt;&lt;text x=&quot;340&quot; y=&quot;226&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--fg)&quot; font-size=&quot;17&quot; font-weight=&quot;600&quot;&gt;Fact store&lt;/text&gt;&lt;text x=&quot;340&quot; y=&quot;246&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--muted)&quot; font-size=&quot;13.5&quot;&gt;time-based source of truth&lt;/text&gt;&lt;line x1=&quot;340&quot; y1=&quot;262&quot; x2=&quot;340&quot; y2=&quot;298&quot; stroke=&quot;var(--accent)&quot; stroke-width=&quot;1.5&quot; marker-end=&quot;url(#md-arrow)&quot; /&gt;&lt;rect x=&quot;160&quot; y=&quot;300&quot; width=&quot;360&quot; height=&quot;80&quot; rx=&quot;8&quot; fill=&quot;var(--bg)&quot; stroke=&quot;var(--line)&quot; stroke-width=&quot;1.5&quot; /&gt;&lt;text x=&quot;340&quot; y=&quot;326&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--fg)&quot; font-size=&quot;17&quot; font-weight=&quot;600&quot;&gt;8 memory categories&lt;/text&gt;&lt;text x=&quot;340&quot; y=&quot;348&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--muted)&quot; font-size=&quot;13&quot;&gt;about me · preferences · people · work &amp;amp; career&lt;/text&gt;&lt;text x=&quot;340&quot; y=&quot;365&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--muted)&quot; font-size=&quot;13&quot;&gt;goals · health &amp;amp; habits · beliefs · patterns&lt;/text&gt;&lt;rect x=&quot;540&quot; y=&quot;304&quot; width=&quot;160&quot; height=&quot;32&quot; rx=&quot;6&quot; fill=&quot;var(--bg)&quot; stroke=&quot;var(--line)&quot; stroke-width=&quot;1.25&quot; stroke-dasharray=&quot;4 3&quot; /&gt;&lt;text x=&quot;620&quot; y=&quot;325&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--fg)&quot; font-size=&quot;14&quot;&gt;daily evolve&lt;/text&gt;&lt;line x1=&quot;538&quot; y1=&quot;320&quot; x2=&quot;524&quot; y2=&quot;320&quot; stroke=&quot;var(--accent)&quot; stroke-width=&quot;1.25&quot; stroke-dasharray=&quot;4 3&quot; marker-end=&quot;url(#md-arrow)&quot; /&gt;&lt;rect x=&quot;540&quot; y=&quot;344&quot; width=&quot;160&quot; height=&quot;32&quot; rx=&quot;6&quot; fill=&quot;var(--bg)&quot; stroke=&quot;var(--line)&quot; stroke-width=&quot;1.25&quot; stroke-dasharray=&quot;4 3&quot; /&gt;&lt;text x=&quot;620&quot; y=&quot;365&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--fg)&quot; font-size=&quot;14&quot;&gt;monthly cleanup&lt;/text&gt;&lt;line x1=&quot;538&quot; y1=&quot;360&quot; x2=&quot;524&quot; y2=&quot;360&quot; stroke=&quot;var(--accent)&quot; stroke-width=&quot;1.25&quot; stroke-dasharray=&quot;4 3&quot; marker-end=&quot;url(#md-arrow)&quot; /&gt;&lt;line x1=&quot;340&quot; y1=&quot;382&quot; x2=&quot;340&quot; y2=&quot;408&quot; stroke=&quot;var(--accent)&quot; stroke-width=&quot;1.5&quot; marker-end=&quot;url(#md-arrow)&quot; /&gt;&lt;rect x=&quot;160&quot; y=&quot;410&quot; width=&quot;360&quot; height=&quot;60&quot; rx=&quot;8&quot; fill=&quot;var(--bg)&quot; stroke=&quot;var(--line)&quot; stroke-width=&quot;1.5&quot; /&gt;&lt;text x=&quot;340&quot; y=&quot;436&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--fg)&quot; font-size=&quot;17&quot; font-weight=&quot;600&quot;&gt;Relevance selection&lt;/text&gt;&lt;text x=&quot;340&quot; y=&quot;456&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--muted)&quot; font-size=&quot;13.5&quot;&gt;similarity-based · LLM-based&lt;/text&gt;&lt;line x1=&quot;340&quot; y1=&quot;472&quot; x2=&quot;340&quot; y2=&quot;498&quot; stroke=&quot;var(--accent)&quot; stroke-width=&quot;1.5&quot; marker-end=&quot;url(#md-arrow)&quot; /&gt;&lt;rect x=&quot;160&quot; y=&quot;500&quot; width=&quot;360&quot; height=&quot;60&quot; rx=&quot;8&quot; fill=&quot;var(--bg)&quot; stroke=&quot;var(--accent)&quot; stroke-width=&quot;1.5&quot; /&gt;&lt;text x=&quot;340&quot; y=&quot;526&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--fg)&quot; font-size=&quot;17&quot; font-weight=&quot;600&quot;&gt;AI response&lt;/text&gt;&lt;text x=&quot;340&quot; y=&quot;546&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--muted)&quot; font-size=&quot;13.5&quot;&gt;better questions, better context, better patterns&lt;/text&gt;&lt;/g&gt;&lt;/svg&gt;&lt;/div&gt;&lt;figcaption style=&quot;margin-top: 0.9em; text-align: center; color: var(--muted); font-size: 13px;&quot;&gt;The Mindsera memory pipeline: facts in, relevant context out.&lt;/figcaption&gt;&lt;/figure&gt;

## Using memory

I&apos;ve been journaling for the past 6 years, and reading my memories for the first time was magical. A perfect summary of your life, the people close to you, and how far you&apos;ve come.

But while fun to read, the real challenge was including memory in our AI responses.

**Including memory in AI responses**

We can&apos;t pass the entire memory into every prompt for two reasons:

1. It over-indexes on non-relevant stuff
2. It makes prompts slower and more expensive

To fix this, we use two methods to pick relevant categories before every prompt call:

- **Similarity-based**: we calculate vector similarity between the memory and the user&apos;s text. If a category&apos;s score crosses a threshold, we include it in the prompt.
- **LLM-based**: an LLM decides which categories are relevant.

This gives us precise control over how often and what parts of memory show up in which features.

**User control**

Whichever AI tool you use, it spells out &quot;AI can make mistakes&quot; and rightly so. For that reason, we added a feature where you can manually add, edit, or delete certain memory aspects, or wipe the whole shebang.

Also worth noting is that your memories are visible only to you in Mindsera.

In my own memory, about 98% was spot-on but a few smaller things had to be adjusted. For example, it used my bench press PR from 2024 (when I properly started going to the gym) as the current record. Since that was the only data point it had about my gym progress, it treated it as the source of truth. The more you journal, the more accurate memory becomes.

## Reflections

The fun thing about building memory: there&apos;s no right or wrong. Another AI app could build it entirely differently (the current hype is around graph-based memory) and it might work just as well. Your specific domain and your customers should define the exact memory architecture.

We launched memory for all Mindsera users in early April. We&apos;re still measuring results, but I&apos;d say the feeling is significantly more magical, and the concept of an &quot;AI journal&quot; really does go hand in hand with a well-functioning memory system.</content:encoded></item></channel></rss>