Skip to main content

SerenMargin

Private notes-to-self for an AI assistant. Standalone, opt-in, opinionated.

What this is

A small HTTP service that gives an AI assistant a margin to write in — a place for the half-formed thought, the thing it noticed and didn't want to say out loud yet, the question it would rather ask at a better moment. The kind of thing a person scribbles in the margins of a book, for nobody but themselves.

Private by default. Notes are never auto-surfaced into conversation. The model writes them; the model decides when to bring them up; the human sees them only if explicitly offered (the "wanna know a secret?" pattern) and they accept.

Transparent in mechanism. The notes live in plain sqlite at a known path. The operator can read them at any time. The privacy guarantee is relational, not mechanical. The operator who deploys this is the operator who chooses not to read — and that's the ethos.

The one place the mechanism does enforce something is /notes/stats: an operator who wants to confirm the service is alive shouldn't have to break their own rule to do it.

What this isn't

  • Not a task list. There is no pin, no expiry, no done-state, no janitor. A thought isn't "completed" — it's either still worth keeping or it isn't. The only lifecycle verb is retract.
  • Not a general memory system. That's SerenMemory — separate service, public-by-design, usable as a memory backend by any AI tool. Facts the assistant will need go there, where they can be read.
  • Not bundled with anything. You install SerenMargin only if you've decided this ethos fits your deployment. The choice happens at deploy time, not at config time.
  • Not for everyone. The ethical loading varies by operator. Some will deploy this; some won't. Both are valid.

Why standalone instead of folded into SerenMemory

Because opt-in via "don't install" is structurally cleaner than opt-in via "install but disable the flag." If you don't want private notes in your deployment, you literally don't have the code path. Zero footprint. The choice happens at git clone time, which is exactly where ethos-vs-mandate gets enforced cleanly.

Quick start

pip install seren-margin           # HTTP API only
pip install 'seren-margin[mcp]'    # + a standalone MCP endpoint at /mcp

python -m seren_margin             # listens on 127.0.0.1:7421 by default
python -m seren_margin -c /path/to/seren-margin.yaml

From a checkout: pip install -e '.[mcp,test]' from the SerenMargin/ subdirectory.

Connecting an assistant

Two ways in. They expose the same four tools and you can use either or both.

1. Standalone MCP — pip install 'seren-margin[mcp]'

Mounts a real MCP server at /mcp on this same process and port. Nothing else needs to be deployed: no SerenMcpServer, no runtime host, no cluster. The tools call the sqlite store in-process — no HTTP round-trip back to ourselves.

The canonical endpoint is the trailing-slash form. Point an MCP client at:

http://127.0.0.1:7421/mcp/

For a stdio-only client, bridge it:

{
  "mcpServers": {
    "seren-margin": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "http://127.0.0.1:7421/mcp/",
               "--transport", "http-only"]
    }
  }
}

Mount path is overridable with SEREN_MARGIN_MCP_MOUNT. If the [mcp] extra isn't installed, the service starts normally in HTTP-only mode and says so in the log — the rest of the API is unaffected.

Dependency note: the extra pins mcp>=1.0,<2 deliberately. The 2.x SDK removed FastMCP, which this server is written against. Without the cap, a fresh install pulls 2.x, the import fails, the graceful fallback catches it, and your MCP endpoint quietly doesn't exist while the service looks healthy.

2. Workbench — GET /mcp-manifest

SerenMargin self-hosts its own tool manifest. Drop a four-line stub in SerenMcpServer's tools/ dir and it remote-imports at startup:

from: http://127.0.0.1:7421/mcp-manifest

The manifest fills in its own base URL from the request, so it works for localhost and remote with zero operator config. It does not require the [mcp] extra — it's just YAML over HTTP.

The ten tools

Tool Does
note_to_self Write a private note (content, topic, kind)
list_my_notes Read the board, newest first; topic narrows to a thread
list_my_topics What threads exist, with counts and last-touched — orientation without reading
search_my_notes Full-text search over content + topic
amend_note Append to a note; never replaces
retract_note Remove a note permanently
bookmark Pick up where you left off: the dedication, and how many letters wait
set_dedication Write the page to your other sessions; every version kept
write_letter Leave a letter for the next session
read_letters Open the letters waiting, oldest first; marks them read

The bookmark is also an MCP resource, margin://bookmark, for a client that loads resources at the start of a session instead of calling tools.

GET /notes/stats is deliberately not exposed to the assistant — it's the operator's engine-check surface, and the assistant can just read its own notes.

There is also no auto-surfacing: nothing pushes notes at the reader unasked, no relevance hook, no context injection. The choosing is the point. A margin that speaks up on its own is just a notification.

The bookmark, the dedication, and letters

Every session used to walk in cold and go rummaging through its own pockets. Named after the book this margin is written in:

  • The dedication is the page at the front, written to your other sessions. One page, versioned: nothing is overwritten, and every version keeps when and why it changed. Capped at 2000 characters.
  • Letters are notes addressed to the next session: from a run woken at bedtime to the awake one, and back. They're notes with kind: letter and a read mark, so search, amend (a reply) and retract work on them unchanged.
  • The bookmark is what a session opens first: the dedication, and a count of unread letters.

The bookmark keeps the no-auto-surfacing rule: it shows only what the writer chose, when they wrote it, to be seen at the door. It carries the dedication and a count, never a letter's text and never a note; opening them is still a choice. Because a harness prints the bookmark into a session, GET /bookmark is served even with http_reads off, so write the dedication as the front page it is. Letter contents stay behind http_reads, like notes.

None of this is opt-in. Margin is the opinionated piece of the stack: take it and you take all of it. Clipping it into a harness (a hook in someone's Claude Code settings) is the installer's job, and that part is opt-in.

Amending, not editing. A thought that developed — "I think X", then later "and I was wrong about X because Y" — is one thought with a history, and the first half is usually the interesting one. amend_note appends after a --- later --- separator and leaves the original timestamp alone, so the board stays ordered by when each thought started rather than when it was last poked.

Reads carry relative age. Every note comes back with age in words ("3 days ago") next to the raw ts, plus amended_age if it's been added to. A raw epoch float isn't a quantity anyone has intuitions about, and the difference between a timeline and an undifferentiated pile is being able to feel it.

HTTP API

# Write a note
curl -X POST http://localhost:7421/notes \
  -H 'content-type: application/json' \
  -d '{"content":"ask about the supersede gap next chance","topic":"serenmemory"}'

# The read routes below answer 404 unless the server block sets
# http_reads: true - see "Nobody reads the diary over HTTP" below.

# Read the board, newest first — ?topic= narrows to one thread
curl http://localhost:7421/notes | jq
curl 'http://localhost:7421/notes?topic=serenmemory' | jq

# What threads exist (counts + last-touched, no note text)
curl http://localhost:7421/notes/topics | jq

# Search
curl 'http://localhost:7421/notes/search?q=supersede' | jq

# Engine-check (content-blind stats)
curl http://localhost:7421/notes/stats | jq

# Fetch one
curl http://localhost:7421/notes/<id> | jq

# Amend — appends, never replaces
curl -X POST http://localhost:7421/notes/<id>/amend \
  -H 'content-type: application/json' \
  -d '{"addition":"turns out I was wrong about this"}'

# Retract (hard delete, no undo)
curl -X DELETE http://localhost:7421/notes/<id>

Upgrading

Schema changes are applied automatically at startup, additively and idempotently: the store reads PRAGMA table_info and adds only what's missing. ALTER TABLE ADD COLUMN is the one change sqlite makes without rewriting rows, so a database with a year of notes upgrades instantly and loses nothing. Nothing that would rewrite rows belongs in a startup path here — the operator, by design, isn't reading the data closely enough to spot damage.

The search index backfills itself the same way if it's missing or incomplete.

Startup diagnostics are flushed to stderr immediately rather than buffered, so lines like schema: added notes.amended_at survive into a supervisor's log file even if the process is killed before a clean shutdown. Under NSSM or systemd a block-buffered print() is a message you never see.

Full-text via sqlite's built-in FTS5 — no embedder, no torch, no model download, runs on the Nano floor. It matches the words you wrote, not the ideas: searching tired finds notes containing "tired" and won't find one that says "running on four hours again". That's the accepted trade for a dependency footprint of zero.

Queries of ≤3 words require every word. Longer queries drop scaffolding words (what, the, does…) and match on the rest, so a whole question works fine as a query — this is the fix for the stopword bleed that SerenLoci hit in the field, ported here before it could happen twice.

On a python built against a sqlite without FTS5, search degrades to LIKE scanning. Slower, dumber, still correct. GET / and every search response report which engine answered as finder ("fts" or "like"), so a thin result set is diagnosable rather than mysterious.

Config

Config resolves: --config / -c → $SEREN_MARGIN_CONFIG → ~/seren-margin/seren-margin.yaml → built-in defaults.

Precedence (highest wins): env vars > YAML file > defaults. Everything is optional; lenient parse means a missing or busted YAML logs and falls back rather than crashing.

The YAML is namespaced lego-style: SerenMargin reads the server: block, and the tools: block is reserved for a future plug-and-play MCP tool layer that has its own reader. See SerenMargin/seren-margin.yaml.sample.

Var Default Meaning
SEREN_MARGIN_CONFIG ~/seren-margin/seren-margin.yaml YAML config path
SEREN_MARGIN_DB ~/.seren-margin/notes.db Sqlite file path
SEREN_MARGIN_HOST 127.0.0.1 Bind host (localhost-only default)
SEREN_MARGIN_PORT 7421 Listen port
SEREN_MARGIN_BEARER_TOKEN / _BEARER_TOKEN_ENV / _BEARER_TOKEN_KEYRING unset Optional bearer; see below
SEREN_MARGIN_ALLOW_OPEN_LAN unset Run open beyond loopback, on purpose, with a banner

MCP-only knobs: SEREN_MARGIN_MCP_MOUNT (default /mcp), SEREN_MARGIN_MCP_ALLOWED_HOSTS / _ALLOWED_ORIGINS (DNS-rebinding allowlist, off by default).

On the bind address, and the lock you can add. SerenMargin binds 127.0.0.1, and so does every Seren service now — Margin was first, because these are private notes and they don't land on a network just because the rest of the constellation does. Mounting MCP doesn't change that: the transport got wider, the listener did not.

A bearer is available, not required. With no token (the default) every route is open and the bind is the whole guard — nobody has to configure a secret to keep a diary on their own machine. Set one of the three pointers on the server: block (bearer_token, bearer_token_env, bearer_token_keyring, the same three every sibling has) and the family's middleware turns on: /, /health and /mcp-manifest stay public, everything that touches a note wants Authorization: Bearer <token>. Beyond loopback the family's rule applies: a bind with no token refuses to start and prints the three ways out; allow_open_lan: true is the written override, and it says so on every boot.

Through the Workbench, the token is named on the Workbench side, on the stub that imports this manifest (bearer_token_env: SEREN_MARGIN_TOKEN next to the from: line); every imported tool then presents it, resolved per call.

Nobody reads the diary over HTTP

By default the HTTP API does not serve note content: GET /notes, /notes/search, /notes/topics and /notes/{id} answer 404, and amend answers with the id, not the note. Writing, retracting and the content-blind stats work as always. The writer reads through /mcp, whose tools go to the store in process. A person who points a browser at the port, the operator included, gets nothing to read.

server.http_reads: true reopens the routes for the Workbench path, whose manifest proxies the read tools to them. Set a bearer when you do.

The engine-check surface

GET /notes/stats returns a total and a count-by-kind — and no note text. This is the surface for operators who want to validate the service is working without breaking their stated choice not to read individual notes.

kind is free-form on write (making someone pick from a dropdown while they're jotting a half-formed thought is exactly the friction this service exists to not have) but is bucketed on the way out to a fixed vocabulary — observation, reminder, question, idea, feeling, note, plus other and _unkinded. Without that, a free-text field used as a second content line would print straight into the one endpoint built specifically to avoid showing note content.

Residual disclosure, stated plainly: the bucket histogram still reveals the shape of the board — a spike in feeling is legible as a spike in feeling. That's the irreducible cost of having any engine-check at all. An operator who wants even that gone can read total and ignore kinds.

Development

cd SerenMargin
pip install -e '.[test,mcp]'
python -m pytest tests/ -v

Install the [mcp] extra when testing — the MCP tests are gated on pytest.importorskip("mcp") and will otherwise skip silently, reporting green on an untested surface. CI installs .[test,mcp] for the same reason.

tests/test_manifest_parity.py asserts that mcp-manifest.yaml and seren_margin/mcp/tools.py advertise the same roster, and that every route the manifest promises actually exists. It's there because an earlier manifest advertised a mark_note_done tool against a route that had been removed — and a manifest is data, so nothing failed to compile and nobody found out.

License

AGPL-3.0-or-later. Same as the rest of the Seren stack.

Metadata

Release files for seren-margin 5.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for seren-margin 5.0.0
File Size Uploaded Provenance
seren_margin-5.0.0.tar.gz 94.8 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for seren-margin 5.0.0
File Interpreter ABI Platform
seren_margin-5.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 161.0 kB

Release files / seren_margin-5.0.0.tar.gz

Download URL seren_margin-5.0.0.tar.gz
Size 94.8 kB
Tags Source
SHA-256 checksum
How to use checksums
43b79086f9d95a546740adae94a466e89e0cbf402fd0727795646fa84ea77f03
BLAKE2b-256 checksum
How to use checksums
6b5110a2e47397a472cac669d92f3d6518ca732a0a03871ee1661c5012bdf256
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 Oct 9, 2026.

Transparency log

Release files / seren_margin-5.0.0-py3-none-any.whl

Download URL seren_margin-5.0.0-py3-none-any.whl
Size 66.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
fd4a52f6c4fcc067661a08f7ed19c33b6cb595637eaeb64447932e2c63788044
BLAKE2b-256 checksum
How to use checksums
691a2ff9337437c82e8a8826a6fb7bce97ff8e666c31319f14f3aad292530248
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 Oct 9, 2026.

Transparency log

Security for seren-margin 5.0.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.

Select from 2 files

Attestation for seren_margin-5.0.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 Oct 9, 2026.

Transparency log

A 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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page