What leaves the machine when your agent remembers
Sovantica8 min read
Engrava 0.6.0 and the network: what a default install sends, and why auto_embed: false stops embedding on writes while a search still sends non-blank query text to a configured provider unless you supply a query_vector.
Engrava’s auto_embed field defaults to False and governs automatic embedding on writes only; it
does not govern query embedding, so with a hosted provider configured a search still sends non-blank
query text to that provider whenever you did not supply an explicit query_vector.
I know that because I replaced socket.connect and socket.create_connection with raising stubs to
confirm something I thought I already knew. The write completed with no connection attempted. The
search attempted a connection to port 443.
That’s the kind of thing “doesn’t phone home” hides, because the phrase covers two claims of very
different sizes. The small one is that the library never calls its own vendor - no usage ping, no
licence check, no update poll. That’s a property of the code and you can settle it quickly. The big
one is that nothing you store ever leaves your machine, and that one is a property of your
configuration rather than of the package. Engrava has an embeddings path: with a reachable hosted
provider configured, an auto-embedded write sends the thought’s essence and content, and a search
sends non-blank query text whenever no explicit query_vector is supplied.
So here is the second claim written out for Engrava 0.6.0, with the conditions attached, precisely enough that you can check it instead of taking my word. Everything below is about the Python library on PyPI; the MCP server is a separate package and I’ve flagged the one place the answer differs.
The plain install
pip install engrava pulls five runtime dependencies: pydantic, aiosqlite, numpy, click and PyYAML
(pyproject.toml at v0.6.0). No
third-party HTTP client is among them. The providers that need one sit behind extras -
embeddings-openai and embeddings-ollama bring httpx, embeddings-hf brings huggingface_hub,
embeddings-local brings sentence-transformers and torch.
That’s a weaker statement than “it cannot make a request”, and I’d rather give you the weaker true
one. Python can reach the network with the standard library alone, and this package does exactly
that in one place: the LongMemEval benchmark loader uses urllib.request to fetch a dataset from
huggingface.co. It sits outside the default import graph - import engrava does not pull
urllib.request into the process, while importing
engrava.benchmarks.longmemeval.dataset_loader does; I checked sys.modules both ways. But it is
in the package, and an article about outbound calls that skipped it would be doing the thing it is
complaining about.
For the memory path itself, here is the check rather than the assertion. Take a config whose only substantive line is where the file lives:
database:
path: ./agent.db
Then, in a process where socket.connect and socket.create_connection have been replaced with
raising stubs, open the store, write two thoughts and run a hybrid search. It completes and returns
both, ranked. The embeddings section parses as None, no provider object is constructed, and
nothing attempts a connection.
That test is worth nothing if it cannot fail, so here is the counter-control: the same denial
harness, with the openai-compatible provider configured and auto_embed=False. The write
completed with no connection attempted. The recall, with no supplied query vector, attempted
connects to port 443 at addresses that resolve from api.openai.com. Same harness, opposite result,
which is what makes the first run evidence rather than an absence of activity.
Then you want semantic search
Engrava 0.6.0 accepts four provider names in engrava.yaml, and on this question they are not
interchangeable.
sentence-transformer encodes in your own process, so the text itself does not go out - but the
weights have to be present first, and they are not shipped with the package. On a box with an empty
cache and HF_HUB_OFFLINE=1, loading all-MiniLM-L12-v2 fails with “We couldn’t connect to
‘https://huggingface.co’ to load the files, and couldn’t find them in the cached files”. That
traffic is model-weight
retrieval rather than your memories, and it comes from sentence-transformers rather than from
Engrava; in the empty-cache probe the weights had to be made available before local encoding could
begin.
ollama sends the text to whatever base_url names, which defaults to http://localhost:11434.
That default is on your box, and the field is caller-settable, so it is also the field that can end
up pointing somewhere else.
openai-compatible sends the text to whatever base_url names too - the difference is the default,
https://api.openai.com/v1. It will talk to a local OpenAI-shaped endpoint if you give it one.
huggingface sends the text to the hosted Hugging Face Inference API through huggingface_hub; its
provider constructor takes no base_url, so that endpoint is not caller-settable through this
configuration.
One detail is worth more than it looks. Write provider: openai-compatible with no base_url and
the parsed config holds base_url=None - the endpoint is not in your YAML anywhere. The
https://api.openai.com/v1 string is a fallback inside the provider-resolution function
(config.py at v0.6.0). The
file you’d open to see where your data goes doesn’t name the destination.
What travels is narrow and specific. With a reachable hosted provider configured, an auto-embedded
write sends the thought’s essence and content to that provider - not its metadata, not its edges,
not the rest of the store. With the same provider configured, a search sends non-blank query text to
it, and only when no explicit query_vector was supplied.
The field that covers less than its name
auto_embed defaults to False and governs automatic embedding on writes only; it does not govern
query embedding during search, which happens whenever a provider is configured, the query text is
non-blank and no explicit query_vector was passed.
I had assumed the opposite - that auto_embed: false plus a remote provider meant an inert store -
and the counter-control above is what corrected me.
The reason is one branch in the hybrid-search path. When the search has no vector to work with and
a provider exists, it fills one in by embedding the query text. The branch tests for the provider.
It does not test auto_embed. This is documented, which is somehow worse - the method’s own
docstring says “when None and an embedding provider is configured, the query text is
auto-embedded” - so nobody hid it and I still read the config field’s name instead of the search
path.
It is a defensible design. Without an embedding and without a caller-supplied query_vector there
is no vector axis to search on, so gating that on a write-side flag would quietly change what
retrieval does. The name is the problem, not the behaviour.
The setting that does decide the question is the one above it: no embeddings section, or
provider left unset, means no provider object is constructed and neither path has anything to
call.
What that choice costs
For a hosted provider, two consequences are worth separating: how many provider calls you make, and what those calls disclose.
On the first, the shape is visible in the code even without a price list. With a provider
configured, each create_thought or update_thought that re-embeds makes one provider call, and an
auto-embedded bulk_store batch is embedded in one provider call. A search makes one call when a
provider is configured, its query text is non-blank and no query_vector was supplied - and that
call embeds the current query, not the stored records it ranks. So provider-call volume follows
writes that re-embed, auto-embedded bulk batches, and searches meeting those conditions; it does not
follow how much has accumulated in the store. I’m not attaching a figure to it. That depends on your
model, your write volume and your endpoint, and a number I invented for an article would be worth
nothing to you.
On the second, the two calls disclose different things. An auto-embedded write hands the provider
the thought’s essence and content. A search with a provider configured, non-blank query text and no
supplied query_vector hands it what your user just typed, in their own words - on a code path the
write-side auto_embed field does not touch.
Going the other way, and what it takes with it
With no provider configured, search still runs with FTS5 keyword matching, recency, priority and graph signals fused into one score; the vector axis is skipped and its weight is redistributed across the remaining signals.
What that leaves out is worth being concrete about, because “keep it on the machine” is easy to say. FTS5 matches tokens, so without a vector for the query there is nothing in the fusion comparing the query’s meaning to a record’s. In the socket-denied run above, with two records stored, a query sharing content words with the Berlin-to-Lisbon record returned that record. A query asking the same thing in different words - “where does the person reside now” - returned both records and ranked the unrelated one first: FTS5 had no overlapping tokens to contribute, and the recency, priority and graph signals produced an ordering out of what was left.
Four routes keep the vector axis without sending text to a hosted service. Ollama bound to
localhost. sentence-transformer, where encoding is in-process once the weights are present.
openai-compatible pointed at an OpenAI-shaped endpoint on the same machine. Or vectors you compute
yourself, supplied either as the query_vector argument on a search or through a CallbackProvider
callable that itself makes no network calls - CallbackProvider wraps any (str) -> list[float]
function, it is not among the four names the YAML provider field accepts, so you wire it in Python
or not at all.
Where the MCP route differs
engrava-mcp 0.6.0 lists httpx among its default dependencies (its
pyproject.toml), deliberately,
so the HTTP-based providers work without a second install step. The “no third-party HTTP client is
present” observation from the top of this article does not carry over to it.
The configuration behaves the same: the server reads the same engrava.yaml, resolves no embedding
provider unless that file declares one, and logs at startup that vector search is inert when none is
resolved.
Three questions for whatever store you’re on
These aren’t specific to us.
What does a base install put on disk? A dependency list with no HTTP client in it is a fact rather than a promise, which makes it a better starting point than a README. It is also not a proof
- the standard library is always there, as the benchmark loader above shows - so read it as one piece of evidence and get a second one.
When you name a provider and no endpoint, where does the request go? Read the code that
constructs the provider, not the config file. In Engrava 0.6.0 the openai-compatible default
endpoint lives in resolve_embedding_provider(), and a YAML that omits base_url names no
destination at all.
Which operations embed - writes, queries, or both? Ask about queries separately and explicitly.
The write-side field is the one with the obvious name; in Engrava 0.6.0 the query path passes what
your user typed to a configured provider whenever the query text is non-blank and no query_vector
was supplied.
For our own answer to the third: at 0.6.0 the model calls in this package are embedding calls,
through a provider you configured. Consolidation is not one of them - nothing under
src/engrava/extensions/ references an embedding provider, and REFLECTION content is assembled from
centroids, keyword unions, TF-IDF n-grams and regex entities. A guard
test asserts
that modules under that directory import no LLM SDK, and discovers the files it checks from the
directory
rather than from a list, so a module added later is covered without anyone remembering. Its scope is
src/engrava/extensions/ and it deliberately does not cover src/engrava/embeddings/, which
imports httpx and huggingface_hub because that is its job.
If you want the literal inventory, an exhaustive search for HTTP and HTTPS strings under
src/engrava at tag v0.6.0 returns the OpenAI default, the Ollama default, the LongMemEval dataset
URL and one URL-shaped example inside a comment about FTS5 tokenisation. Read that as the endpoints
written down in the source, not as the whole outbound surface: it cannot show you an endpoint you
supplied through config, or one constructed inside a dependency.
For the memory write and search paths, omitting the embeddings section is what prevents an
embedding provider from being constructed at all:
database:
path: ./agent.db
# no embeddings section: no provider is constructed, so neither the write path
# nor the search path has anything to call
pip install engrava
The code and the architecture behind it: github.com/sovantica/engrava
Tags: engrava · agents · memory · privacy