Data model
These are the entities the MCP tools read and write. You don't query the database directly — the tools do — but knowing the shapes explains what the tool responses mean and why writes behave the way they do.
How the pieces relate
- A journal holds dated pages.
- A page has user ink (the OCR'd
structure_json) and zero or more typed blocks. - An agent_page_seed is the authoritative record of agent-placed content; it materializes into a block on the page.
- task_links track the identity and reconciled status of tasks across the page and Notion.
A note on calendar events. Penlog has a one-way "shape of day" feature that displays your calendar events on the page (read-only; Penlog never writes to your calendar). That's a UI feature, not part of the agent-facing model — there are no event tools in the MCP API, and tasks are the only thing that syncs two-way. A handwritten○is just classified as aneventsline instructure_json; it doesn't create or sync anything.
journals
The top-level container for a user's pages.
| Field | Notes |
|---|---|
id | Journal UUID. |
owner_id | The owning user. |
notation_legend | JSON legend inlined into OCR (see Notation). |
Penlog is single-journal per user. Tools resolve to the most-recently-touched journal automatically, so you rarely reference a journal id directly.
pages
One dated page within a journal — the unit agents read.
| Field | Notes |
|---|---|
id | Deterministic UUIDv5 over (journal_id, "YYYY-MM-DD"). |
date | The page's day. Unique per journal. |
has_ink | Whether the user has written ink on the page. |
structure_json | The typed lines (see Notation). |
ocr_text / ocr_markdown | Flat text renderings of the page. |
pdf_path / png_path | Storage paths; get_page returns signed URLs. |
The page id is deterministic: the iPad and the server derive the same UUID for a given (journal, date). That's why create_task_seed can target a future date without orphaning the iPad's local page. The OCR columns are owned by the extraction function — the iPad never writes them.
blocks
Typed content placed alongside ink.
| Field | Notes |
|---|---|
id | Block UUID. |
page_id | The page it sits on. |
type | "text" | "image" | "shape". |
source | "user" | "agent". |
content / position / size / style | What it is and where it sits. |
Agents write blocks; users write ink. The two never overlap, so there are no merge conflicts. A block is a materialized cache of a seed (see agent_page_seeds) — the seed is the source of truth.
task_links
The identity and reconciled status of a task.
| Field | Notes |
|---|---|
id | task_link_id — the handle update_task takes. |
task_text | Normalized task text. |
status | "todo" | "done". |
first_seen_date | When the task first appeared. |
completed_date | Set when status flips to done. |
source_page_id | The page the task was HANDWRITTEN on — null for agent-created tasks. |
line_id | Per-line identity carried from structure_json. |
notion_task_id / notion_synced_at | Notion linkage + last-write-wins checkpoint. |
Identity is line_id, not text — no fuzzy matching. source_page_id is null for agent-created tasks on purpose: it marks handwriting origin, and pull logic skips re-seeding a task onto the page it was handwritten on.
agent_page_seeds
The authoritative record for agent-placed content.
| Field | Notes |
|---|---|
source_kind | What kind of thing was seeded (e.g. "notion_task"). |
source_link_id | The id of the source row (e.g. a task_links id). |
page_id | The page seeded onto. |
block_id | FK to the materialized block (ON DELETE SET NULL). |
hidden / auto_hidden | Visibility tri-state — see below. |
UNIQUE on (source_kind, source_link_id, page_id) is the dedup gate. The seed is truth; the block is a cache materialized from it. (hidden, auto_hidden) is a tri-state: (false,false) active, (true,true) pending (arrived onto an inked page, awaiting Briefing opt-in), (true,false) user-hidden.
Two rules worth internalizing
- Seed is truth, block is cache. To change what an agent placed on a page, you act on the seed (via the tools); the block follows.
- The iPad owns task layout. A seed's on-screen position is
an initial guess — the iPad renumbers it on next open. Don't depend on the
exact
position_yyou write.