A memory service for AI agents. Agents write datapoints as things happen and later ask "what do we know about X" to get back the closest matches, scoped to a project, filtered by time, and fitted into a token budget they can paste straight into a prompt.
- Semantic recall with a local, multilingual embedding model. No external AI API.
- S3 as the only storage backend, with an optional Amazon S3 Vectors index for larger data.
- Idempotent writes, and updates and deletes by your own
sourceId. - Webhooks that turn events from other services into datapoints, with no code in between.
- Everything scoped to a
projectId.
Early but working: everything below is implemented and tested. That is ingest (single and batch), recall, delete, listing stored datapoints, and inbound webhooks. There is no auth yet, so run it on a private network. Not yet lists what is missing. See ARCHITECTURE.md for how it works and CONTRIBUTING.md to run it locally.
With Docker only, no Rust toolchain needed:
docker compose --profile app up --build # LocalStack + MuninnOr from source:
docker compose up -d # LocalStack with S3
cp .env.example .env # point the AWS SDK at LocalStack
cargo run # first run downloads the embedding modelThe service listens on http://localhost:8080. To have something to
recall, load a set of sample datapoints and list them:
cargo run --example seed # or: cargo run --example seed -- http://host:8080
curl 'localhost:8080/api/v1/datapoints?projectId=0192f0c0-5eed-7000-8000-000000000001'The seed uses sourceIds, so running it again updates the same
datapoints instead of adding copies.
The image bakes the embedding model in at build time, so containers start
without network access to the model hub. It runs as a non-root user and
shuts down cleanly on docker stop.
docker build -t muninn .
docker build -t muninn --build-arg EMBED_MODEL=bge-m3 . # another modelAgainst real AWS, pass credentials, region and buckets, and leave
AWS_ENDPOINT_URL unset:
docker run -p 8080:8080 \
-e AWS_REGION=eu-west-3 -e AWS_ACCESS_KEY_ID=... -e AWS_SECRET_ACCESS_KEY=... \
-e BUCKET=my-muninn-bucket \
-e VECTOR_STORE_TYPE=s3 -e VECTOR_BUCKET=my-muninn-vectors \
muninnEMBED_MODEL is fixed at build time: the image only contains the model
it was built with.
| Variable | Default | Purpose |
|---|---|---|
BUCKET |
muninn |
S3 bucket; created on startup if missing |
BIND |
0.0.0.0:8080 |
listen address |
EMBED_MODEL |
multilingual-e5-small |
embedding model, see below |
MODEL_CACHE |
./.fastembed_cache |
where model files are downloaded (/models in Docker) |
DOWNLOAD_ONLY |
1: download the model and exit without serving |
|
VECTOR_STORE_TYPE |
memory |
where the search index lives: memory or s3 (Amazon S3 Vectors) |
VECTOR_BUCKET |
S3 Vectors bucket; required when the type is s3 |
|
AWS_ENDPOINT_URL |
set to http://localhost:4566 for LocalStack |
|
AWS_* |
standard credentials and region |
By default the search index lives in memory and is rebuilt from S3 on
every start. Set VECTOR_STORE_TYPE=s3 and VECTOR_BUCKET
to keep it in Amazon S3 Vectors instead. The vector bucket and an index named after the model (for
example muninn-multilingual-e5-small) are created on startup if missing.
Use one vector bucket per environment, like the S3 bucket.
LocalStack does not emulate S3 Vectors, so this needs real AWS: unset
AWS_ENDPOINT_URL and use real credentials and region. The credentials
need s3vectors:GetIndex, CreateIndex, CreateVectorBucket,
PutVectors, QueryVectors and GetVectors on the vector bucket. You
can create the bucket yourself instead
(aws s3vectors create-vector-bucket --vector-bucket-name muninn-dev-vectors)
and drop CreateVectorBucket.
A policy for the credentials Muninn runs with. Replace muninn,
muninn-vectors, the region and the account id with yours, and drop the
s3vectors statement when the index lives in memory. When AWS denies a
request, Muninn names the action and resource it was refused.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::muninn"
},
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::muninn/*"
},
{
"Effect": "Allow",
"Action": [
"s3vectors:GetIndex",
"s3vectors:CreateIndex",
"s3vectors:CreateVectorBucket",
"s3vectors:PutVectors",
"s3vectors:QueryVectors",
"s3vectors:GetVectors",
"s3vectors:DeleteVectors"
],
"Resource": [
"arn:aws:s3vectors:eu-west-3:123456789012:bucket/muninn-vectors",
"arn:aws:s3vectors:eu-west-3:123456789012:bucket/muninn-vectors/*"
]
}
]
}The IAM console has no ready-made policy for S3 Vectors; create an inline policy on the user or role and paste this into the JSON editor.
EMBED_MODEL |
Languages | Max input | Download | Notes |
|---|---|---|---|---|
multilingual-e5-small |
100 | 512 tokens | ~450 MB | default; fast |
multilingual-e5-base |
100 | 512 tokens | ~1.1 GB | better quality |
bge-m3 |
100+ | 8192 tokens | ~2.2 GB | for long documents; slow on CPU |
bge-small-en-v1.5 |
English | 512 tokens | ~130 MB | smallest |
Content longer than the model's max input is stored and returned whole, but only the first part of it is used for search. Switching models re-embeds existing data on the next start.
All requests and responses are JSON. Errors look like
{ "error": { "code": "...", "message": "..." } }.
{
"projectId": "0192...uuid",
"sourceId": "slack:C024BE91L:1729166400.000100",
"label": "Decided on S3 Vectors",
"content": "We chose S3 Vectors over a separate vector DB because ...",
"timestamp": "2026-10-17T12:00:00Z",
"metadata": { "source": "slack", "channel": "#infra" },
"audience": "engineering"
}projectId, label and content are required; everything else is
optional. timestamp is when the thing happened and defaults to the time
of ingest. A timestamp more than 5 minutes ahead of the server clock is
rejected with 400 validation, since recall only sees a datapoint once
its timestamp has passed. metadata defaults to {}.
audience is a slug: 2 to 40 lowercase letters, digits and dashes,
starting and ending with a letter or digit. Omitting it, or sending
project, means the whole project; that is stored and returned as null.
Returns 201 with the stored datapoint and "deduplicated": false, "replaced": false. The server assigns the id.
sourceId is your own id for the datapoint, any non-blank string, unique
per project. Send it to be able to correct or delete the datapoint later.
- The first send creates the datapoint:
201. - Sending it again with nothing changed returns
200with"deduplicated": true. This makes retries safe. - Sending it again with any change replaces the datapoint:
200with"replaced": true. Theidstays the same. - On a resend,
label,timestamp,metadataandaudiencecan be left out to keep their stored values. Solabelis only required the first time. Send"audience": "project"to widen a stored audience back to the whole project.
The id is derived from the project and sourceId, so it is the same for
every send. sourceId and idempotencyKey can't be combined, since a
sourceId already makes retries safe.
For datapoints without a sourceId, idempotencyKey makes retries safe.
Sending the same request again with
the same key returns 200 with the original datapoint and
"deduplicated": true. Reusing a key for a different request returns
422 idempotency-key-reused and stores nothing. Keys are scoped to the
project. An empty idempotencyKey is rejected; omit the field instead.
{ "idempotencyKey": "optional", "items": [ { "...": "same as ingest, without idempotencyKey" } ] }Up to 100 items. All items are validated before anything is written; one
invalid item rejects the whole batch with 400 validation and a details
array naming the failing indexes. Returns 200 with a per-item result
list. With a batch key, a retry of the same
batch after a partial failure finishes the unfinished items and does not
duplicate the finished ones. An item that changed since the first attempt
gets 422 idempotency-key-reused in its result. Items with a sourceId
ignore the batch key and behave as in single ingest; a later item can
update an earlier one with the same sourceId.
{ "projectId": "0192...uuid", "sourceIds": ["slack:C024BE91L:1729166400.000100"], "ids": ["0192...uuid"] }Deletes datapoints by sourceId, by server id, or both: 1 to 1000 in
total. Returns 200 with one result per entry, sourceIds first, each
with the id, the sourceId when given, and deleted. deleted is
false when nothing was stored under it; that is not an error. A deleted
sourceId can be used again.
Deleting a datapoint that was stored with an idempotencyKey and then
retrying that same request stores it again.
GET /api/v1/datapoints?projectId=0192...uuid&audience=sales&label=call&limit=20
Lists a project's stored datapoints, read straight from S3, to check what is in there. It also works while the index is still loading.
| Parameter | Notes |
|---|---|
projectId |
required |
limit |
1 to 200, default 50 |
cursor |
nextCursor from the previous page |
sourceId |
only the datapoint with this sourceId |
audience |
only this audience; project means datapoints with none |
label |
only labels containing this text, ignoring case |
occurredFrom |
inclusive start on timestamp |
occurredTo |
inclusive end; start after end is 400 recall-range-invalid |
includeContent |
true to include content; off by default since it costs an extra read each |
Returns {"datapoints": [...], "nextCursor": "..."}. Each datapoint has
the same fields as in ingest, without content unless asked for, plus
contentTokens, contentSha256, embeddingModel and embeddingDim.
nextCursor is null on the last page. Pages are in id order, not by
time. One request reads at most 1000 datapoints, so with narrow filters a
page can have fewer than limit results, or none, and still a
nextCursor; keep going until it is null.
A webhook turns events that another service POSTs (Jira, GitHub, an alert system) into datapoints, without code in between. Each webhook belongs to a project and has a secret URL.
| Method and path | Does |
|---|---|
POST /api/v1/webhooks |
create; returns 201 with the URL |
GET /api/v1/webhooks?projectId=... |
list a project's webhooks |
GET /api/v1/webhooks/{id}?projectId=... |
one webhook |
PUT /api/v1/webhooks/{id} |
replace its settings; URL unchanged |
DELETE /api/v1/webhooks/{id}?projectId=... |
delete; {"deleted": false} if absent |
{
"projectId": "0192...uuid",
"name": "Jira support tickets",
"contentPath": "$.issue.fields.description",
"labelPath": "$.issue.fields.summary",
"occurredAtPath": "$.timestamp",
"idPath": "$.issue.key",
"audience": "support",
"filters": [
{ "path": "$.webhookEvent", "op": "is-any-of", "values": ["jira:issue_created", "jira:issue_updated"] },
{ "path": "$.issue.fields.labels", "op": "contains", "value": "customer" }
],
"paused": false
}| Field | Notes |
|---|---|
name |
required, 1 to 64 characters; the label when there is no labelPath |
contentPath |
required; $ is the whole body. A string is used as is, anything else as JSON text |
labelPath |
label from the event; falls back to name when missing |
occurredAtPath |
ISO 8601, or Unix seconds or milliseconds; falls back to the time received |
idPath |
the sender's id for the event, 1 to 200 characters; see below |
audience |
as in ingest |
filters |
up to 20; all must match |
paused |
a paused webhook refuses events with 409 |
Paths are JSON paths made of $, .name, ['name'] and [n], up to 500
characters. Wildcards, .., filters, slices and unions are rejected.
Filter operators, all case-sensitive:
op |
Matches when the value at path |
|---|---|
is |
equals value; objects and arrays never match |
is-any-of |
equals one of values (1 to 50) |
contains |
is a string containing value, or an array with an element equal to it |
exists |
is present and not null |
A path that is missing matches no filter.
Creating a webhook returns its settings plus a path like
/api/v1/webhooks/whk_.../<token>. Prefix it with Muninn's public address
and give it to the sender. The token is the only credential: there is no
signature check. It is stored hashed and not shown again; to replace it,
delete the webhook and create a new one. A project holds at most 20
webhooks.
POST /api/v1/webhooks/{id}/{token} with any content type, up to 1 MiB.
- Ingested:
202with{"eventId": "evt_...", "verdict": "ingested", "droppedBy": null, "datapointId": "...", "unchanged": false}. - Dropped:
200with"verdict": "dropped"anddroppedBysaying why: the first filter that failed, like"$.webhookEvent is jira:issue_created", or the path that resolved to nothing. Senders should not retry these.
Datapoints get metadata {"source": "webhook", "webhookId": "whk_..."}.
With an idPath, the datapoint's sourceId is {webhookId}:{id}, so a
later event with the same id replaces it, and it can be deleted by that
sourceId. Without one, an event that maps to exactly the same datapoint
as an earlier one is not stored twice. Either way a resend that changes
nothing returns "unchanged": true.
A body that is not JSON is the content as a whole, unless the webhook has filters, in which case it is dropped.
| HTTP | code | when |
|---|---|---|
| 404 | not-found |
wrong id or token, or the webhook was deleted |
| 409 | webhook-paused |
the webhook is paused |
| 413 | webhook-body-too-large |
body over 1 MiB |
| 429 | rate-limited |
over 600 events a minute for this webhook |
{
"projectId": "0192...uuid",
"query": "why did we pick S3 Vectors",
"limit": 20,
"level": "medium",
"occurredFrom": "2026-01-01T00:00:00Z",
"sort": "relevance"
}| Field | Notes |
|---|---|
query |
required, natural language, any supported language |
limit |
1 to 200, default 100 |
level |
token budget: low 8k (default), medium 16k, high 24k, extra-high 32k |
maxTokens |
explicit budget instead of level; never both |
queryTimestamp |
only datapoints at or before this instant are eligible; default now |
occurredFrom |
inclusive start of the time window |
occurredTo |
inclusive end; start after end is 400 recall-range-invalid |
sort |
relevance (default) or chronological, oldest first with rank kept |
Returns the selected datapoints with rank, score and contentTokens,
plus usedTokens and budgetTokens. Documents are never truncated; a
document that does not fit the remaining budget is skipped.
Always 200. The body is {"status": "ok"} once the search index has
been rebuilt from S3 and {"status": "loading"} before that. Ingest,
recall, delete and webhook events return 503 with code loading until
then. Listing datapoints and managing webhooks work meanwhile.
- Auth
- Filtering on
audiencein recall - Metadata filters on recall and listing
- Signature checks on webhook events
MIT, see LICENSE.