Synapto
Your AI agent forgets everything between sessions. Synapto fixes that.
Flat-file memory (MEMORY.md) doesn't scale — no search, no structure, no decay. Synapto gives any MCP-compatible agent a real memory: store once, recall by meaning, watch bad memories fade and good ones persist.
# remember — while working inside ~/src/acme/api
"acme/api publishes events through an outbox table, never directly from the request handler"
# recall — weeks later, different session, same repository
"How does acme/api publish events?"
→ [stable] acme/api publishes events through an outbox table ... (score=0.94, trust=0.65)
Works with Claude Code, Cursor, Windsurf, Codex, LangGraph, Agno, or any MCP client.
Each repository gets its own memory partition automatically: the tenant is derived from the
working directory's git remote, so acme/api never sees acme/web's notes unless you ask.
Cross-agent handoffs
Pass work between Codex, Claude Code, Cursor, and other agents in plain language. Synapto stores the structured state under the hood, so the next agent can continue from a memory ID instead of a long pasted brief.
You → Codex: Plan this feature and leave a handoff for Claude to implement.
Codex → You: Handoff created for Claude: b0e1506e-d1b7-4bee-9223-4d0f8d18a1b2
You → Claude: Continue from Synapto handoff b0e1506e-d1b7-4bee-9223-4d0f8d18a1b2.
Claude → You: I read the handoff, fetched its context, and can continue.
| What you say | What Synapto does |
|---|---|
| "Codex, leave this for Claude." | Stores a project memory with metadata.kind = "agent_handoff". |
| "Claude, continue from this handoff ID." | Fetches the full memory with get_memory and verifies the metadata. |
| "Any handoffs for me?" | Uses recall to find ranked candidates, then fetches the relevant packet. |
| "Mark it ready for review." | Appends a follow-up memory with the same task_id. |
See Cross-agent handoffs for the lifecycle, schema, and Claude/Cursor recipes.
Try it in 60 seconds
Docker:
git clone https://github.com/ramonlimaramos/synapto.git && cd synapto
docker compose up -d
docker compose exec synapto synapto search "hello world"
Local:
pip install synapto
createdb synapto && psql -d synapto -c "CREATE EXTENSION vector;"
synapto init
synapto search "hello world"
What it does
Search — Ask a question, get the best memory. Behind the scenes, three signals (vector similarity, full-text, and compositional algebra) are fused into one score. You just call recall.
Scopes — A memory can declare where it applies: repo:acme/api, language:python, skill:code-review, global:all. recall(scopes=[...]) returns only what applies to the context you are in, and an exact-key metadata_filter narrows further ("every finding with failure_class = missing_docstring", with a true total, not a page size).
Provenance — Every memory records who wrote it: human, agent, or consolidation. Recall can filter by origin, and forget refuses to delete a human-authored memory unless told explicitly.
Graph — Entities are auto-extracted and linked. Ask "what depends on Kafka?" and get an answer via graph traversal, not keyword guessing.
Decay — Core memories live forever. Ephemeral notes fade in hours. Working context lasts about a week. Memories that get used stay alive; unused ones sink.
Trust — Mark memories as helpful or not. Bad info gets demoted 2x faster than good info gets promoted. Over time, your memory self-cleans.
Handoffs — Tell one agent to leave work for another in natural language.
Synapto turns that into a structured handoff memory, and the receiver continues
with get_memory, context_ids, and follow-up updates.
Quickstart
Prerequisites
- Python 3.11+
- PostgreSQL 14+ with pgvector
- Redis 7+
Install and initialize
pip install synapto
createdb synapto && psql -d synapto -c "CREATE EXTENSION vector;"
synapto init # or: synapto init --interactive
Connect to your agent
The recommended way is uvx with --refresh — every restart pulls the latest version from PyPI, no manual upgrades:
Claude Code — register the server at user scope, so it is available in every project:
claude mcp add --scope user synapto \
-e CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 \
-- uvx --refresh synapto serve
That writes the following entry to ~/.claude.json (the file Claude Code actually reads for user-scoped servers — ~/.claude/.mcp.json is not consulted):
{
"mcpServers": {
"synapto": {
"type": "stdio",
"command": "uvx",
"args": ["--refresh", "synapto", "serve"],
"env": {
"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1"
}
}
}
}
Set CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 for Claude Code so Synapto remains the single memory sink instead of duplicating new memories into Claude's flat-file auto-memory.
Claude Code starts the server in the session's working directory, which is what makes per-repository tenants work: open a session inside ~/src/acme/api and memories land in tenant acme/api. If you run the server from a checkout instead of PyPI, use uv run --project /path/to/synapto synapto serve rather than uv --directory ... — --directory changes the working directory before the server starts, and every session would then derive the checkout's own tenant.
Restart Claude Code after changing the configuration so the MCP subprocess receives the new environment.
Cursor (.cursor/mcp.json):
{
"mcpServers": {
"synapto": {
"command": "uvx",
"args": ["--refresh", "synapto", "serve"]
}
}
}
Why
--refresh? Without it,uvxreuses the cached environment across restarts, so a new Synapto release on PyPI will not be picked up until the cache expires or you runuv cache clean synaptomanually.--refreshtellsuvto re-resolve the package on every launch, adding 1–3 seconds to startup in exchange for "always on the latest version" — the right default for an alpha project that ships often. Drop the flag (or pin a version like"synapto==0.2.0") if you want to freeze the version.
Restart your agent. Synapto tools appear automatically, and any future release will be live on the next restart.
Default Memory Routing
Synapto is designed to be the primary memory sink for MCP-compatible agents. Agents should call recall before non-trivial work and call remember when users provide durable context instead of writing new memory into flat files.
| User signal | Tool action | Recommended type/layer |
|---|---|---|
| "always X", "never Y", "from now on" | Store as a rule | feedback / core, subtype workflow |
| "don't do X", "that's wrong" | Store as a correction | feedback / core, subtype communication or workflow |
| "we use X for Y", "our architecture is..." | Store as project context | project / stable, subtype stable |
| "this sprint", "current PR", "release plan" | Store as active work | project / working, subtype working |
| "tracked in Linear", "dashboard is..." | Store as external reference | reference / stable, subtype external_system |
| "I work on...", "my preference is..." | Store as user context | user / stable, subtype preference |
subtype is optional and free-form. Recommended values include code_style, workflow, tooling, testing, security, communication, external_system, documentation, role, preference, skill, and constraint.
Tenants and scopes
A tenant is the partition a memory lives in; a scope is where inside that partition it applies. Tenants keep repositories apart, scopes keep a Python rule from firing in a TypeScript file.
Tenants are derived, not typed
Every tool resolves the tenant in this order:
- An explicit
tenant=argument — validated, never repaired.Acme/API,git@github.com:acme/api.git, andacme/api/are all rejected with the canonical form named in the error. - The
originremote of the git repository containing the server's working directory, normalised toowner/name(https://github.com/acme/api.git→acme/api). [defaults] tenantin the config file, orSYNAPTO_DEFAULT_TENANT.default.
Leave step 3 unset unless you have a reason: default is then an honest bucket for "no repository here", and nothing written outside a checkout is silently attributed to one.
Older installs that stored memories under hand-typed spellings (api, acme-api, acme/api) can be collapsed onto the canonical tenant:
synapto maintain --merge-tenants --dry-run # report the proposed mapping
synapto maintain --merge-tenants --apply # move the memories, record the aliases
Every target is a canonical, lowercase tenant. A fold from a canonical spelling (api → acme/api) is recorded in tenant_aliases (one hop, never a chain), and every tool follows that alias on reads and writes, so the old spelling keeps working. A legacy spelling the grammar rejects (Acme/API) is folded onto its lowercase form and records no alias — step 1 already refuses it and names the lowercase form — and a store holding only Acme/API is offered acme/api rather than left unreachable. --apply runs the printed plan as one transaction: a group refused midway undoes the ones before it.
Scopes are typed
A scope is "<type>:<key>". Six types exist: global, product, repo, language, skill, workflow. global:all is the only global key, and it cannot be combined with other scopes on the same memory.
remember("Run ruff before every commit", scopes=["repo:acme/api", "language:python"])
recall("pre-commit checks", scopes=["repo:acme/api", "language:python"])
A scoped recall returns a memory when it is global:all, or when every scope type the memory declares is satisfied by one of the query's scopes of that type — a memory tagged repo:acme/api and language:python needs both to match; one tagged only language:python needs just the language. Memories stored without scopes are not returned by a scoped recall: unscoped means "never governed", not "applies everywhere" — use global:all for that. A recall without scopes ignores the axis entirely. Unknown types and malformed keys are rejected, never guessed.
domain= still works on remember and recall but is deprecated: it is one free-form label, whereas scopes are typed and plural. New callers should use scopes.
MCP Tools
| Tool | What it does |
|---|---|
remember |
Store a memory with optional scopes, origin and metadata (entities and search vectors are created automatically) |
recall |
Search memories by meaning; narrow by scopes, metadata_filter, origin, depth_layer, subtype |
ping |
Check MCP transport health without touching PostgreSQL, Redis, or embeddings |
get_memory |
Fetch the complete content and metadata for one recalled memory |
get_memories |
Fetch complete content for multiple recalled memories |
update_memory |
Replace, append, or patch fields (including scopes) on an existing memory |
relate |
Link two entities ("acme/api" --[publishes]--> "orders.created") |
forget |
Soft-delete a memory; human-authored memories require allow_human=true |
trust_feedback |
Mark a memory as helpful or unhelpful |
find_contradictions |
Find memory pairs that disagree |
graph_query |
Walk the knowledge graph (N-hop) |
list_entities_tool |
Browse known entities |
memory_stats |
View counts and distribution |
maintain |
Run decay and ephemeral cleanup |
agent_handoff_template |
Build the structured remember payload for a cross-agent handoff |
handoff_inbox_template |
Build the recall call that lists handoffs waiting for an agent |
Tool Field Limits
Synapto validates known hard limits before hitting the database, so MCP clients get actionable errors instead of raw Postgres exceptions.
| Field | Limit |
|---|---|
content |
Text; no Synapto length limit |
summary |
Max 255 characters |
memory_type |
Max 20 characters |
subtype |
Optional free-form subcategory, max 50 characters |
domain |
Deprecated in favour of scopes; max 50 characters |
depth_layer |
Max 20 characters |
tenant |
Canonical owner/name, max 100 characters |
scopes |
Up to 20 unique "<type>:<key>" entries per memory or query |
origin |
One of human, agent, consolidation |
recall.metadata_filter |
A flat JSON object of scalars, up to 20 keys; nested values are rejected because containment would not mean equality |
get_memories.memory_ids |
Max 20 IDs per call |
recall.preview_chars |
Clamped to 0-1000 characters |
CLI
synapto serve # start MCP server
synapto init # create tables, indexes and extensions
synapto doctor # check postgres, redis, embeddings health
synapto search "kafka topics" # search from terminal
synapto stats # memory statistics
synapto migrate status # show applied/pending migrations
synapto migrate up # apply pending migrations (serve does this on start)
synapto migrate down --to 7 # roll back everything after version 7
synapto maintain --merge-tenants --dry-run # propose a tenant merge; --apply performs it
synapto export -o backup.json # export memories
synapto import MEMORY.md --format markdown # migrate from flat files
synapto migrate-memories # detect and import other agents' memory files
synapto configure-mcp --client claude-code --tenant acme/api # write the MCP entry to ~/.claude.json
synapto configure-mcp --client cursor # write the MCP entry for Cursor
configure-mcp --client claude-code updates only mcpServers.synapto in ~/.claude.json, keeps every other key in that file, prints the entry that will load, and names ~/.claude/.mcp.json if an older release left one there — Claude Code never read it.
Migrations ship inside the package and synapto serve applies any that are pending when it starts, so upgrading the package is enough to upgrade the schema.
Depth Layers
| Layer | Half-life | Example |
|---|---|---|
core |
Forever | "Our API uses REST, never GraphQL" |
stable |
~6 months | "Auth service is in Go, everything else is Python" |
working |
~1 week | "Currently refactoring the payment module" |
ephemeral |
~6 hours | "Debugging: the timeout was 30s, changed to 60s" |
How it works under the hood
When you call recall("kafka patterns"), Synapto runs three searches in parallel and fuses the results:
- Vector similarity (pgvector HNSW) — finds semantically close memories
- Full-text search (tsvector + BM25) — finds keyword matches
- HRR compositional algebra — detects if "kafka" plays a structural role in the memory, not just appears as a word
The scores are combined via Reciprocal Rank Fusion — the HRR similarity enters on the same scale, worth at most one leg for an identical vector and nothing within the noise floor — then multiplied by decay, trust, and a depth-layer weight (core 1.3, stable 1.25, working 1.0, ephemeral 0.7 — chosen by the sweep in docs/eval/layer_sweep.md). Ties are broken by created_at DESC, id, so equal scores come back in the same order every call and the newest memory wins. Filters — tenant, scopes, metadata, origin, layer, subtype — are applied inside the SQL before ranking, so a filtered recall is a smaller search, not a trimmed page.
HRR (Holographic Reduced Representations) also enables queries that no vector database can do:
probe("kafka")— find memories where Kafka is structurally involved (not just mentioned)reason(["kafka", "acme/api"])— find memories about both entities simultaneously (vector-space AND)contradict()— find memory pairs that share entities but say different things
More in docs/hrr.md.
Configuration
Config file: ~/.synapto/config.toml
[postgresql]
dsn = "postgresql://localhost/synapto"
[redis]
url = "redis://localhost:6379/0"
[embeddings]
provider = "" # auto-select (sentence-transformers locally, openai if API key set)
model = ""
device = "" # optional sentence-transformers device override, e.g. "cpu"
[defaults]
# tenant = "acme/api" # fallback used only outside a git checkout; see "Tenants and scopes"
[decay]
ephemeral_max_age_hours = 24
purge_after_days = 30
All values can be overridden with environment variables: SYNAPTO_PG_DSN, SYNAPTO_REDIS_URL, SYNAPTO_EMBEDDING_PROVIDER, SYNAPTO_EMBEDDING_MODEL, SYNAPTO_EMBEDDING_DEVICE, SYNAPTO_DEFAULT_TENANT.
SYNAPTO_DEFAULT_TENANT (and [defaults] tenant) is a fallback, not a pin: it is consulted only when the working directory has no usable git remote. Set it when the server runs somewhere that is not a checkout — a container, a CI job, a shared shell — and you want those writes to land in a named partition. Inside repositories the derived tenant wins, and an unset fallback resolves to default. Like an explicit tenant= argument, a non-canonical value here is rejected rather than repaired, and the error names the config source instead of blaming the caller.
Using as a Python library
from synapto.db.postgres import PostgresClient
from synapto.db.migrations import run_migrations, ensure_hnsw_index
from synapto.embeddings.registry import get_provider
from synapto.search.hybrid import hybrid_search
pg = PostgresClient("postgresql://localhost/synapto")
await pg.connect()
await run_migrations(pg)
provider = get_provider()
await ensure_hnsw_index(pg, provider.dimension)
results = await hybrid_search(pg, provider, "outbox pattern", tenant="myproject")
for r in results:
print(f"[{r.depth_layer}] trust={r.trust_score:.2f} {r.content}")
Documentation
| HRR deep dive | Compositional algebra, probe, reason, contradict |
| Trust scoring | Feedback loop and contradiction workflow |
| Cross-agent handoffs | Coordinate planning, implementation, and review across agents |
| Migrations | Versioned SQL files with rollback |
| Claude Code | Setup and usage with Claude Code |
| Cursor | Setup and usage with Cursor |
| LangGraph | Using Synapto as a LangGraph tool |
| Agno | Using Synapto with Agno agents |
| Retrieval eval | Golden set, ablation and layer sweep — what the ranker was measured to do |
| Releasing | Version PR, preflight, dispatch, verify |
Development
git clone https://github.com/ramonlimaramos/synapto.git
cd synapto
uv sync --extra dev # or: python -m venv .venv && pip install -e ".[dev]"
uv run synapto init
uv run ruff check src/ tests/ scripts/
CI runs ruff check, bandit, pip-audit, a wheel build verified by scripts/verify_wheel.py, and the test suite on Python 3.11, 3.12 and 3.13. A separate release preflight workflow runs scripts/preflight_release.py on every pull request: it checks release.yml and the GitHub release environment against the hardened pipeline, so a drift turns the next pull request red instead of failing on release day. Releases themselves are dispatched by hand from main; see RELEASING.md.
Three conventions are enforced by tests rather than review:
- SQL lives in
synapto/sql/. Every statement is a static constant with%(name)sparameters, one module per owner; Python selects statements, it never composes them.tests/unit/test_sql_lives_in_the_sql_package.pywalks the AST of both sides. - Migrations are inventoried. A new file under
synapto/_migrations/must be added toEXPECTEDintests/unit/test_migration_resources.pyandEXPECTED_MIGRATIONSinscripts/verify_wheel.pyin the same commit, and a migration is never renumbered once merged. See docs/migrations.md. - The destructive surface is declared. Every constant in
synapto/sql/that deletes, soft-deletes, drops, truncates or moves rows between tenants must appear in theSURFACEsnapshot oftests/unit/test_destructive_surface.pywith its reach — MCP tool, CLI command or library-only — and the test modules that prove refusal, happy path and "the rest was not touched". A new destructive statement fails the suite until it is declared.
Running the tests
The PostgreSQL-backed tests are destructive — they roll migrations down (dropping and recreating columns) and truncate tables. They therefore refuse to run against anything but a disposable database, and they never read your production SYNAPTO_PG_DSN.
Create a throwaway database once:
createdb synapto_test
psql -d synapto_test -c "CREATE EXTENSION IF NOT EXISTS vector; CREATE EXTENSION IF NOT EXISTS pg_trgm;"
Then point SYNAPTO_TEST_PG_DSN at it:
SYNAPTO_TEST_PG_DSN=postgresql://localhost/synapto_test uv run pytest
Two fail-closed rules protect your real data:
- No DSN, no connection. With
SYNAPTO_TEST_PG_DSNunset, the database-backed tests are skipped — never silently pointed at a default.pytestalone still runs every test that does not need PostgreSQL. - The database must be named
*_test. The suite asks the live connection forcurrent_database()and aborts before any destructive setup if the name does not end in_test. Parsing the DSN is not enough: a DSN can omit the database name, and service files can redirect it.
Retrieval eval
tests/eval is a golden-set harness that measures what recall actually returns, not just that filters apply. It seeds a synthetic corpus (tests/eval/corpus.toml, ~200 memories under tenant acme/eval) through remember, runs the cases in tests/eval/golden/<signal>.toml through hybrid_search, and reports MRR@10 and Recall@5 per signal — layer, trust, decay, hrr, scopes, metadata, general. The numbers are compared with the committed tests/eval/baseline.json; a move beyond ±0.02 in either direction fails, so a ranking change cannot merge without re-baselining in the same PR. It runs with the rest of the suite (same SYNAPTO_TEST_PG_DSN, same deterministic offline embeddings, no model download).
The baseline is a measurement of the current ranker, not a target: a case the ranker gets wrong today stays in the set with its low score.
To add a case, append a [[case]] to the signal's file (query, expected corpus key, optional scopes, metadata_filter, depth_layer), add any new memory to corpus.toml with a stable key, then re-baseline — the corpus digest recorded in the baseline changes, and the gate says so:
SYNAPTO_TEST_PG_DSN=postgresql://localhost/synapto_test SYNAPTO_EVAL_WRITE_BASELINE=1 uv run pytest tests/eval
Commit the resulting baseline.json next to the change that moved it. Keep the corpus synthetic (acme/* only); a test rejects anything else.
The same harness drives a ranking-signal ablation: each signal (HRR leg, decay, trust, layer weight, vector leg, keyword leg) is switched off in turn — as a variant of the production SQL and a zeroed HRR leg, applied in the test process only — and the golden set is re-run, with a noise floor from five shuffled-insertion runs deciding what counts as a real move. It is opt-in because it takes tens of seconds and produces a document rather than a pass/fail:
SYNAPTO_TEST_PG_DSN=postgresql://localhost/synapto_test SYNAPTO_EVAL_ABLATION=1 uv run pytest tests/eval
The report lands in docs/eval/ablation.md; docs/eval/README.md holds the reading of the latest run and the follow-up issues it produced.
A layer-weight sweep uses the same harness to choose the depth-layer multipliers: ten spreads are run under full and no-hrr, a spread is admissible when every layer case ranks its target first, the best overall MRR@10 among admissible spreads wins, and a tie within the noise floor goes to the narrower. The current 1.3 / 1.25 / 1.0 / 0.7 came from it; change DEPTH_BOOST only with a new run committed next to it:
SYNAPTO_TEST_PG_DSN=postgresql://localhost/synapto_test SYNAPTO_EVAL_LAYER_SWEEP=1 uv run pytest tests/eval
The report lands in docs/eval/layer_sweep.md.
License
MIT
Metadata
Release files for synapto 0.8.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| synapto-0.8.0.tar.gz | 484.7 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| synapto-0.8.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 620.7 kB
Release files / synapto-0.8.0.tar.gz
| Download URL | synapto-0.8.0.tar.gz |
|---|---|
| Size | 484.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1c0cd15a84c378b46862c042f47beb37e3e2ca4599b7e56953502e0e01e498e6
|
|
BLAKE2b-256 checksum How to use checksums |
be701f989c152a967e81eb1c078c8fbce9ad261a258b72e5ae55619d3d97c140
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 6, 2026.
Transparency logRelease files / synapto-0.8.0-py3-none-any.whl
| Download URL | synapto-0.8.0-py3-none-any.whl |
|---|---|
| Size | 136.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
826a759ca392e4aa395013952efad6de15e634a53f263403943a636413032504
|
|
BLAKE2b-256 checksum How to use checksums |
7b583ee67bb078a23f14925fc7fdd29ff6fe1069f7e395018f0871b0a046e86e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 6, 2026.
Transparency logSecurity for synapto 0.8.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for synapto-0.8.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 6, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.