Developers
Reflect Day has no backend and no API of ours to call. Every entry is a JSON file in a private, app-scoped folder in your own Google Drive — that folder is the interface. This page is the contract: the on-Drive format any client, ours or yours, has to respect to stay compatible.
coverMediaId, rediscoverHidden and embedding all arrived after entries already existed without them). If your client ever sees a field it doesn't recognise, the rule is always the same: leave it exactly as you found it. See “Updating an entry” below.Where everything lives
Reflect Day stores everything in the Drive API's appDataFolder — a folder that is invisible in the regular Drive UI and only reachable through the API, scoped entirely to whichever app requested it. One JSON file per entry, plus the entry's attached media as ordinary Drive files in the same folder.
Authenticating
You need the https://www.googleapis.com/auth/drive.appdata scope, nothing broader. Two ways to get it:
Use the ready-made client. Reflect's own CLI already runs an installed-app OAuth flow against a registered “Reflect CLI” Desktop client, and you're welcome to point your own tool at the same one instead of creating a Google Cloud project:
client_id: 178341901500-i59u33ekkd1kk26p9tkjmbc7peikgq21.apps.googleusercontent.com
client_secret: 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
flow: Authorization Code + PKCE, loopback redirect (RFC 8252 §7.3)
redirect_uri: http://127.0.0.1:<any free port you bind>Publishing a client secret looks alarming, but for an installed app it isn't one — RFC 8252 §8.5 says exactly this: a client embedded in something users run on their own machine can't keep a secret confidential anyway, so Google (and the RFC) treat it as a public identifier, not a credential. The actual protection is PKCE, which binds each authorization code to the one request that created it.
Or bring your own. Register a “Desktop app” OAuth client in Google Cloud Console, enable the Drive API, and use the same scope. Worth doing for anything you hand to other people: a client borrowing the ids above shows “Reflect CLI” on Google's consent screen, so someone granting access to your tool sees our name and not yours. (Which cuts both ways — if you are the one being asked to approve “Reflect CLI”, that screen tells you nothing about who wrote the thing asking. Grant it only to a tool you actually trust with the whole journal.)
Either way, the result is a normal Google OAuth access/refresh token pair. From there you're just calling the standard Drive v3 API (files.list, files.create, files.get, files.update) with spaces=appDataFolder.
Entry files
One file per entry, named <id>.json, where id is YYYY-MM-DD-HHMMSS — the exact moment the entry was created. That moment is minted once and never changes; date always agrees with the date encoded in the filename. Here's a full example:
// appDataFolder/2026-09-04-091530.json
{
"id": "2026-09-04-091530",
"content": "<p>Slept badly, good coffee.</p>",
"date": "2026-09-04",
"time": "9:15 AM",
"tags": ["sleep"],
"media": ["1AbCdEfGhIjKlMnOpQrStUvWxYz"],
"location": {
"name": "Cascais",
"address": "Cascais, Lisboa, Portugal",
"lat": 38.6979,
"lng": -9.4215
},
"weather": { "tempC": 18, "condition": "cloudy" },
"mood": 0.6,
"favourite": false,
"rediscoverHidden": false,
"coverMediaId": "1AbCdEfGhIjKlMnOpQrStUvWxYz",
"embedding": [0.0123, -0.0456, "... 1 vector, opaque"],
"client": {
"app": "your-client-slug",
"device": "optional label",
"audio": ["1QqWwEeRrTtYyUuIiOoPpAaSs"]
},
"updatedAt": "2026-09-04T09:15:30.000Z",
"metadata": {
"version": 1,
"fieldTimestamps": {
"content": "2026-09-04T09:15:30.000Z",
"contentHash": "1865543916",
"lastSync": "2026-09-04T09:15:31.284Z"
}
}
}content is the entry text as HTML, and it is always plaintext on Drive. The app encrypts entry text in its own on-device database (IndexedDB) — under a per-device key by default, or under a password once the user sets one — but it always decrypts before uploading, so Drive itself never holds ciphertext. You never need to handle encryption to read or write an entry.
date is a local calendar day (YYYY-MM-DD) and time is a display string, not a machine timestamp: the app writes local 12-hour clock time like 9:15 AM, no timezone, no seconds. Write it in that shape or the app will show whatever you put there verbatim. updatedAt is the one real timestamp on the entry, ISO 8601 in UTC.
media is a list of Drive file IDs, in display order — the files themselves are separate uploads, see below. tags, location, weather, mood (0–1), favourite and rediscoverHidden are plain metadata, all optional. coverMediaId picks which attached media represents the entry.
client records what created the entry — app and device are set once and never edited afterward, so pick a short slug for client.app that identifies your tool. client.audio is the exception to “never edited”, and the one field most likely to catch you out: it holds Drive file IDs of voice recordings the entry was dictated from. Those are real media files in the same folder, but they are not listed in media — see “Media files”.
Content is untrusted markup
content is real HTML, not a restricted markup language — but treat it as untrusted the moment you read it back, the same way the official app does. Drive is plaintext, so a tampered file, a buggy client, or a stray script tag from a legacy import all end up looking identical to an ordinary entry. The app never renders content directly: every render path runs it through DOMPurify's default safe-HTML profile first, which keeps the formatting a rich-text editor actually produces — paragraphs, headings, bold/italic/underline/strike, ordered and bulleted lists including task checkboxes, links, images, code blocks, blockquotes, highlighted text — while dropping <script>, inline event handlers like onerror, and javascript: URLs.
There is no separate, hand-maintained allowlist to match against — it's whatever a standard HTML sanitizer's default safe profile allows, which is intentionally broad on formatting and narrow on anything that executes. Do the same in your own client before you inject an entry's content into a DOM; don't assume a file from your own Drive folder is safe to trust just because it's yours.
A Royal letter is stored within content as a <section data-letter-style="royal">. Its title is an ordinary heading and its fields are ordinary paragraphs, identified by data-letter-part (title, subtitle, date, salutation, closing, or signature). The body is a <div data-letter-body> containing regular text blocks. Preserve these wrappers and attributes when editing to retain the letter's appearance. Fonts, borders and ornaments are supplied by the app; they are not attachments or text embedded in the entry. The date line is free text and does not change the entry's immutable date field.
When two devices disagree
Two devices can edit the same entry offline and meet later, and it is worth being precise about what happens then, because it is not a per-field merge. The app compares two timestamps for the whole entry — the Drive file's modifiedTime against the local copy's updatedAt — and the newer side wins content, tags, media, coverMediaId, location and weather outright, whole values replaced rather than merged. A tag added on the phone while the laptop was editing the text does not survive; the later writer takes the entry. (One asymmetry worth knowing: location and weather are only taken from the newer side when it actually has one, so removing either doesn't propagate — the other device's value stays.)
Four fields have their own rule, and they are the whole of the exception list:
favourite — true from either side wins, so a heart is never lost. rediscoverHidden — newest write wins (not an OR, or an unhide could never beat a stale hidden flag from the other device). client.audio — the union of both sides, because recordings are only ever added. embedding — see below.
metadata.fieldTimestamps is named for an ambition the code hasn't reached: despite the name it is not a general per-field timestamp map, and nothing today reads a timestamp for any field other than content. The app writes exactly three keys — content (ISO, when the text was last edited), contentHash (a cheap hash of content as uploaded, which is how the app tells “the text really changed” from “same text, new timestamp”) and lastSync (ISO, when this file was last uploaded). metadata.version is written as 1 and not read by anything.
Extra per-field timestamps of your own are harmless, and stamping them is a reasonable bet on the future — just don't expect anything to act on them yet. The one key worth getting right is contentHash: carry it back unchanged when you didn't touch content, and recompute it when you did.
// contentHash — 32-bit signed, over the exact `content` string,
// iterated by UTF-16 code unit, then stringified.
let h = 0
for (let i = 0; i < content.length; i++) {
h = ((h << 5) - h + content.charCodeAt(i)) | 0
}
const contentHash = String(h)Updating an entry
The one rule that matters most: read the whole file, change only the fields you mean to change, write everything else back untouched. Never reserialize an entry from a struct that only knows the fields you've implemented — that silently deletes every field you haven't heard of, on every entry your client touches. That's also what keeps this format able to grow: a field invented after your client was written still survives it. client.audio is the concrete warning here — a client that rebuilds client from { app, device } drops the only reference to a recording, and the audio file is then adrift in the folder with nothing pointing at it.
Deleting an entry
Never delete the Drive file. Overwrite it, same filename, with a tombstone:
// same filename, entire body replaced — never delete the file itself
{
"id": "2026-09-04-091530",
"deleted": true,
"deletedAt": "2026-09-05T18:02:11.000Z"
}A hard delete can't propagate — there's nothing left for another device to notice. The tombstone is what every client checks for and removes locally.
Media files
Photos, video, audio and PDFs are uploaded as ordinary binary files in the same appDataFolder, one Drive file per attachment. When you list the folder, anything that isn't application/json is media, not an entry.
An entry reaches its media through media — and through client.audio, which is the trap: dictation recordings are attached to an entry without ever appearing in the media array. A sweep that treats “file not in anyone's media” as garbage will decide those recordings are orphans and take them with it, and a voice note is exactly the kind of thing nobody has a second copy of.
Which is the shape of the wider rule: don't delete a media file you can't account for. The app doesn't either — it can report unreferenced files, and then asks. An upload that landed while an entry was still being written looks exactly like an orphan for a few seconds.
Files that aren't entries
A few JSON files in the same folder are not entries: settings.json, dictionary.json, one device-key-<hex>.json per device, and the device list's device-info-<hex>.json and device-erase-<hex>.json. Skip them — don't edit, delete, or count them. A device-erase- file is a request, made by the user, that one device wipe its local copy; writing one by mistake erases that device. The simplest test: a real entry always has a top-level id string; none of these do.
settings.json carries the user's own AI API key, and device-key-<hex>.json is the raw key that decrypts one device's local copy of the journal. Don't read them, don't copy them anywhere, and above all don't log them — a debug dump of “every file in the folder” is the easiest way to spill both.Semantic search & embeddings
embedding: number[] | null is optional and powers nothing but in-app semantic search and the AI assistant's retrieval. A client that never looks at it is a complete, correct client.
If you don't want to generate vectors yourself, the simplest correct behaviour is to leave the field exactly as you found it — unless you changed content, in which case you must delete it rather than leave it stale. A vector computed from text that no longer exists is worse than no vector: it silently mis-ranks search forever instead of just being absent. Today, the official app is the only client that fills in a missing embedding, using the user's own Gemini key (gemini-embedding-001) — it happens lazily, the next time that app syncs, so leaving the field out is enough to get it filled in eventually.
If you do want to generate your own: nothing pins clients to one model or vector size, but there's no cross-model compatibility either — a vector from a different model isn't wrong, exactly, it's just not comparable to the others. The delete-on-edit rule above is the part that actually matters; get that right regardless of whose model you use.
This is a live app under active development. If anything here stops matching what you see in your own Drive, trust your own data over this page.