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 thirteen 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
keep_with_note Keep a picture (clip, sound, PDF) with a note; note_to_self takes keep too
look_at_my_note A note and its pictures, shown as images
drop_from_note Take one picture off a note

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.

Keepsakes: pictures kept with notes

A note can be about a picture, and the picture used to be a path on some box's gallery: not in the margin, not in its snapshots, not under its rules about who reads it. Now it is kept with the note. The bytes go in a keepsakes/ folder beside the database under their sha256 and nothing else (seren_sinew.blobs); every word about them (caption, where it came from, what it is) is a row in notes.db. A listing of the folder, or of a snapshot, says nothing. Retract the note and its pictures go; drop one and its bytes go once no other note keeps them. Snapshots hardlink each picture from the previous snapshot, so twenty-two kept snapshots of a clip cost one clip. look_at_my_note hands them back as image content, so the writer sees what it kept instead of reading a path.

Where a keepsake may be taken from is the owner's to say, in the keepsakes: block: folders on the margin's box (the Workbench gallery make_image saves to) and URL prefixes. Nothing listed means nothing can be kept, and the refusal says so; a tool that could read any file on the box or fetch any address on the LAN is not a diary's business. What is kept is decided by its first bytes, never its filename: a picture, a clip, a sound or a PDF; a text file is a note.

keepsakes:
  sources:
    - ~/seren/stores/workbench/gallery
    - https://
  max_bytes: 268435456        # one keepsake, 256 MB
  max_total: 34359738368      # the folder, 32 GB

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.

The viewer: a status page, not a window

GET /viewer is the margin's page on the shared Seren baseplate, so its status reads like the Halls and the Loci viewer instead of a bare JSON page. It is the engine check and nothing else: the counts /notes/stats serves (total, kinds bucketed to a fixed vocabulary, keepsakes as a count and a size), the backups /stores serves (snapshots kept, the newest, Lodestar's last pull, the last rehearsal's verdict) and the version and finder from /. Its script calls those three routes and no other; tests/test_viewer.py reads the script and fails the build if a fourth appears. No note, topic, letter or dedication is ever loaded by the page.

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

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.1.2
File Size Uploaded
seren_margin-5.1.2.tar.gz 116.0 kB Details

Built distribution (wheel)

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

Total release size: 197.3 kB

Release files / seren_margin-5.1.2.tar.gz

Download URL seren_margin-5.1.2.tar.gz
Size 116.0 kB
Tags Source
SHA-256 checksum
How to use checksums
7b1c5a44983bc32cc27c12acb319f4214024135781f7d1d890d30b1e4d72dc32
BLAKE2b-256 checksum
How to use checksums
32914f73aeaac44eb98de83ee8574583ed72dd540f02cedf0695278a78cae6c6
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 16, 2026.

Transparency log

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

Download URL seren_margin-5.1.2-py3-none-any.whl
Size 81.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0aabbb17e61163a8473ed06fde045efb85e238a2e5ee242676e99ef4d605074b
BLAKE2b-256 checksum
How to use checksums
a39e73347a06a1da4e4f636e32041bc62c7383a070b71173a1ce03166185bebe
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 16, 2026.

Transparency log

Release history Release notifications | RSS feed

5.1.3

2 release files

This release

5.1.2 This release

2 release files

5.0.1

2 release files

5.0.0

2 release files

2.6.0

2 release files

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