--- url: /markdown-like.md --- # Markdown-like Markwhen is not markdown, but it is heavily inspired by markdown. Markwhen has its own [parser](https://github.com/mark-when/parser) but does not have a "built in" output format, aside from JSON. This is unlike markdown, which [historically produces `html`](https://daringfireball.net/projects/markdown/). While markdown blocks [correspond to html elements](https://github.github.com/gfm/), there is no analog in markwhen nor is there a preferred rendering for markwhen. The timeline view is the most popular view to render markwhen, but it is not the only one. Remaining agnostic to whatever view is rendering markwhen is a goal of the language. This distinguishment of markwhen from markdown is on purpose: the content, structure, and most likely desired rendering of markwhen is different than markdown. They are definitly similar, but it is its own type of file. *** #### Markdown rendering ![](/images/md.png) #### Markwhen rendering ![](/images/mw.png) *** ::: tip Read more Read more about what the parser produces and how you can use its intermediate JSON artifact [here](/parser). ::: ## Similarities to markdown ### Goals Like markdown, markwhen aims to be eminently readable without any additional tooling. [From John Gruber](https://daringfireball.net/projects/markdown/#:~:text=The%20overriding%20design%20goal%20for%20Markdown%E2%80%99s%20formatting%20syntax%20is%20to%20make%20it%20as%20readable%20as%20possible.): `The overriding design goal for Markdown’s formatting syntax is to make it as readable as possible.` So it is with markwhen as well - a markwhen document should be self-explanatory without the need for it to be rendered or processed. With that and a general desire for compatibility in mind, here's what is familiar to both markwhen and markdown: ### Forgiveness Like markdown, the markwhen parser is quite forgiving (though that doesn't mean it will fix your mistakes) Almost any text file can be parsed with the markwhen parser and it will produce *something*. ### Links ``` [link text](http://example.com) ``` ### Images ``` ![alt text](http://example.com/image.png) ``` ### Lists ``` - list item 1 - list item 2 ``` ### Checklists ``` - [] not done - [x] done ``` ### Frontmatter ::: info An important difference from markdown with regards to frontmatter is that markwhen does not require frontmatter to be sandwiched between three dashes `---` - read more [here](/syntax/header). ::: ```mw --- title: My markwhen document author: name: Bob Smith --- ... rest of document ... ``` ## Differences from markdown * No support for multiline blocks, like code blocks or tables (yet) --- --- url: /journal-language.md --- # Journal Language Markwhen is a *journal language* - it is a list of dated entries, like a journal. Other examples of journal languages could include [org mode](https://orgmode.org/) or [iCal](https://icalendar.org/). --- --- url: /parser.md --- # Parser [Markwhen parser on github](https://github.com/mark-when/parser) Though views are the most visible output of markwhen, the actual output of the parser is a `JSON` object. Each view ingests this intermediate object to produce its visualization. ::: tip Read more Read more about [how views work](/visualizations/). ::: The parser produces an array of timelines and associated metadata: ```js const mw = parse(`title: my timeline now: my birthday`); console.log(JSON.stringify(mw)); // { events: ... } ``` ```ts export interface Timeline { ranges: Range[]; foldables: { [index: number]: Foldable }; events: EventGroup; header: any; ids: IdedEvents; metadata: TimelineMetadata; } ``` ```ts export interface TimelineMetadata { earliestTime: DateTimeIso; latestTime: DateTimeIso; maxDurationDays: number; preferredInterpolationFormat: string | undefined; } ``` Events are kept in a tree structure. To facilitate traversing and dealing with nodes, you can use utility functions from the parser library: ```js import { iter, get, getLast, flat, flatMap, isEvent, } from "@markwhen/parser"; const mw = parse(...) // Use `iterate` to iterate through the tree for (const { path, eventy } of iter(mw)) { // Here, path is the path to the event or section } // Path in the tree. const path = [3, 1, 0] const specificNode = get(mw, path) // The rightmost node of the tree const lastInTree = getLast(mw) // Sections are flattened to return an array of events only const eventsOnly = flat(mw) // The first line is separated into the `datePart` and "the rest" - the title would be considered "the rest" const eventTitles = flatMap(mw, (event) => event.firstLine.restTrimmed) // Determine whether an eventy has an event as its value const event = isEvent(mw) ``` ## Paths Events and sections are often referred to by their paths in the tree, **starting from the root, or top, of the tree**. For example, say we have the following markwhen document: ``` Path Text -------------------------------- [0] 2008: Entrance exam [1] # Education [1, 0] 2009: Start school [1, 1] ## Sophomore year [1, 1, 0] 2010: Advanced classes [1, 2] 2011: More classes [2] 2012: New job ``` Since we are essentially dealing with arrays of arrays of arrays ad infinitum, we can refer to values by their indicies. For example, going **up** the tree, `2010: Advanced classes` is the first element (index `0`) of the second element (index `1`) of the second element (index `1`) of the top level tree. Since we have a reference to the head of the tree, we can refer to that event by its path going **down**: `[1, 1, 0]`. That is, to get to `2010: Advanced classes` from the root of the tree, we take the item at index `1`, and then the item at index `1` of that array, and then the item at index `0` of that array. Another view of the tree, viewing it as an actual array of arrays (in pseudocode): ``` markwhen = ["2008: Entrance exam", "# Education": [ "2009: Start school", "## Sophomore year": [ "2010: Advanced classes" ], "2011: More classes", ], "2012: New job" ] ``` If we were indexing into the array to get to the value of "2010: Advanced classes", we would say `markwhen[1][1][0]`, therefore its path is `[1, 1, 0]`. An invalid path for this tree would be `[0, 0]`, since the first element is not an array and therefore cannot be indexed into. --- --- url: /cli.md --- # @markwhen/mw `mw` is the [markwhen](https://docs.markwhen.com) command line interface (CLI). You can use it to parse markwhen files and optionally render a view of it (timeline+gantt/calendar/resume). All html output is self-contained; js and css are inlined and there are no external scripts. ## Installation ```sh npm i -g @markwhen/mw ``` ## Usage ```sh mw [] [-o ] [-d ] ``` |Option|Description| |---|---| |`outputType`|one of `json` | `timeline` | `calendar` | `resume`| |`destination`|File to write to. Output type can be inferred from the filename if `outputType` is not specified; i.e., files ending in `timeline.html` will produce the timeline view, files ending in `json` will produce the raw parse output.| Parse markwhen document and output json: ```sh mw project.mw # -> outputs timeline.mw.json ``` Render a timeline view: ```sh mw my_markwhen_file.mw timeline.html # -> outputs timeline.html (timeline+gantt view) ``` Render a calendar view: ```sh mw ThingsToDo.mw ThingsToDo-calendar.html # -> outputs ThingsToDo-calendar.html (calendar view - inferred from the filename) ``` --- --- url: /syntax.md --- # Syntax The example here is indented but indentation is optional. That being said, if you do indent like the below example, a monospaced font is recommended. ```mw{1-7,14-16,16-20,24,29,31,32} --- title: Project plan #Project1: #d336b1 #Danielle: yellow timezone: America/New_York --- # All Projects ## Project 1 #Project1 // Supports ISO8601 2025-01/2025-03: Sub task #John 2025-03/2025-06: Sub task 2 #Michelle More info about sub task 2 - [ ] We need to get this done - [x] And this - [ ] This one is extra 2025-07: Yearly planning ## Project 2 #Project2 2025-04/4 months: Larger sub task #Danielle contact: imeal@example.com // Supports American date formats 03/2025 - 1 year: Longer ongoing task #Michelle assignees: [Michelle, Johnathan] location: "123 Main Street, Kansas City, MO" - [x] Sub task 1 - [x] Sub task 2 - [ ] Sub task 3 - [ ] Sub task 4 - [ ] so many checkboxes omg ``` ## Quick Reference | Item | Syntax | Example | | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | [Event](/syntax/events) | `[DateRange\|EDTFDateRange]:[EventDescription]` | `08/2015-05/2017: CS degree #Education` | | [EDTFDateRange](/syntax/dates-and-ranges) | `[EDTFDate\|RelativeDate\|now][/EDTFDate\|RelativeDate\|now]` | `2004-02-01/2005`, `2005/2006-02`, `2005/now`, `2018/6 months` | | [EDTFDate](/syntax/dates-and-ranges#edtf-date) | `YYYY(-MM(-DD)?)?` | `2000-06-01`, `1892`, `1492-01` | | [DateRange](/syntax/dates-and-ranges#date-ranges) | `[Date][-Date]` | `1998-06/01/2000` | | [Date](/syntax/dates-and-ranges#dates). `now` is a special keyword which means what you think it does | `[HumanDate\|ISO8601\|RelativeDate\|now]` | `01/30/1888` | | HumanDate. Defaults to American formatting (Month/Day/Year, can be overridden in [header](/syntax/header)) | `[m/d-]yyyy` | `2002` or `01/2002` or `12/25/1901` | | Casual date. | `(day)?(jan\|feb\|mar\|apr\|may\|jun\|jul\|aug\|sep\|oct\|nov\|dec)(day)?(year)(time)?` | `4 January 1996` or `Oct 8 2012` or `March 16 12:19pm` or `9:15pm` or `06:30` | | ISO8601 format. The `T` and `Z` are required. | `YYYY-MM-DD`T`HH:MM:SS:MS`Z | `1859-05-09T12:01:01Z` | | [Relative Date](/syntax/dates-and-ranges#relative-dates) (base this date off of another) | `[after] [!eventId] Amount` | `after !Birthday 3 weeks 2 days`, `2 days - 3 months 4 days 8 seconds`, `!ww1 21 years - 6 years` | | Amount (used in relative dates) | `[digit] [milliseconds\|seconds\|minutes\|hours\|days\|weeks\|months\|years]` | `after !Birthday 3 weeks`, `2 days - 3 months`, `!ww1 21 years - 6 years` | | [Event description](/syntax/event-descriptions/) | `([text]\|[Tag]\|[Link])*` | `07/2014: 4th of July in DC ![](https:/linktomyimage.com/imagelink.png) #Travel @sue @greg` | | [Tag](#tags) | `#[tag name]` | `1999: The Matrix #Movies` | | [Link](#links) | `[display text](link)` | `05/25/2021: [cascade.page](https://cascade.page) featured on [Hacker News](https://news.ycombinator.com/item?id=27282842)` | | [Photos](#photos). Markdown-style image format. | `![optional alt text](image link)` | `07/2017: 4th of July in DC ![](https://example.com/image.png)` | | [Reference](#references). Reference and link to other markwhen pages. | `@[other markwhen name]` | `09/2019: Dinner with @karl` or `2020-2022: COVID-19 Pandemic @jenny/covid @covidtimeline` | | Comment | `//[text]` | `// this is a comment` | --- --- url: /syntax/events.md --- # Events An event is a [date range](/syntax/dates-and-ranges) followed by a colon followed by an [event description](/syntax/event-descriptions/): ```mw 12/2012: End of the world 1961: Year after 1960 Later, 1962 would happen 1 year: 1962, just as predicted 2020-02-22T12:13:14Z-now: How long the pandemic has been going on? 12/7/1941: Pearl Harbor attacked Launched US into WWII 2022-02-22T16:27:08.369Z: More specific thing 2021-01-02T06:27:00Z-2022: ongoing project work until the end of 2022 1892/2021-08-12: Example of EDTF date range ``` --- --- url: /syntax/dates-and-ranges.md --- # Dates and Ranges Markwhen supports a variety of date formats and mechanisms for expressing periods of time. [Extended date time format](https://www.loc.gov/standards/datetime/) (EDTF) is the recommended syntax for expressing dates and ranges. When parsing, EDTF takes precedence over other date formats mentioned here -- if there is some ambiguity in how a date range is expressed, and it fits the EDTF range format, it will be parsed as EDTF. Every event has an associated **date range**, whether it has an explicitly written end date or not. A **date range** is a **period from one date to another**. ## EDTF Date An EDTF date is essentially the first part of a full ISO8601 date, whose regex could be expressed as `\d{4}(-\d{2}(-\d{2})?)?`: ``` 1981 2012-05 2022-01-30 ``` ## EDTF Date Ranges Markwhen is currently level 0 EDTF compliant, supporting ranges such as: ``` 1964/2008 2004-06 / 2006-08 2004-02-01/ 2005-02-08 2004-02-01 /2005-02 2004-02-01/2005 2005/2006-02 2005/now 2018/6 months ``` Open-ended ranges are not supported. Ranges start and end with either a [EDTF Date](#edtf-date) or [Relative Date](#relative-dates) or the special keyword `now`. ::: warning Note While the `now` keyword is and will continue to be supported, it is not recommended due to its ambiguity. `now` could mean when the author wrote the markwhen document, it could mean when the document was parsed, etc. Try to use specific dates (i.e., `2025-03-01` instead of just `March`) as much as possible. ::: ## Non-EDTF Dates Other date formats besides EDTF are supported out of the box. Human readable dates are supported, like `1665`, `03/2222`, `09/11/2001`, `18 March 2026`, `Aug 30 9:45am`, as well as ISO8601 dates, like `2031-11-19T01:35:10Z`. Human readable slash dates default to American Month/Day/Year. You can define additional date formats in the [header](/syntax/header) with `dateFormat` rules. ## Non-EDTF Date Ranges A non-EDTF date range is typically `Date[-Date]`; that is, one date optionally followed by a dash (`-`) or the word `to` and another date. If an end date is not specified, the range is as long as its granularity. For example, the event ```mw 2001: A Space Odyssey ``` starts January 1, 2001, and lasts through December 31, 2001. | Example | Inferred Range | Explanation | | ------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `2024` | `2024-01-01T00:00:00Z` to `2025-01-01T00:00:00Z` | From the start of 2024 to the end of 2024 | | `04/1776` | `1776-04-01T00:00:00Z` to `1776-05-01T00:00:00Z` | From the start of April 1776 to the end of April 1776 | | `01/01/2024` | `2024-01-01T00:00:00Z` to `2024-01-02T00:00:00Z` | From the start of January 1, 2024, to the end of January 1, 2024 (the whole day). | | `11/11/2024-12/12/2024` | `2024-11-11T00:00:00Z` to `2024-12-13T00:00:00Z` | From the start of November 11, 2024, to the end of December 12, 2024. | | `2031-11-19T01:35:10Z-2099-08-04T18:22:48Z` | `2031-11-19T01:35:10Z` to `2099-08-04T18:22:48Z` | Exactly as specific as the ISO dates say. | | `January 3 - Apr 6` | `2022-01-01T00:00:00Z` to `2022-04-07T00:00:00Z` | As this documentation was written in 2025, the year 2025 is inferred. Note how the range extends to the **end** or April 6, which makes it the beginning of April 7. **This type of date range is discouraged, due to the lack of explicit year** | | `now - 10 years 6 months 3 days` | `now` to 10 years, 6 months, and 3 days later | `now` is whatever time the timeline is **rendered**, not when it was **written**. `10 years 6 months 3 days` is a [relative date](#relative-dates). | | `3:30pm - 4:30pm` | Today's date, from `15:30` to `16:30` | When a time is by itself, it is based off of the last date seen, or, if there isn't any, today. | | `1 Jan 1998 to 11/11/2011 8am` | `1998-01-01T00:00:00Z` to `2011-11-11T08:00:00Z` | | | `Nov 11 02:30` | `2011-11-11T02:30:00Z` to `2011-11-11T02:30:00Z` | When a time is specified (hour/minute), the granularity is instant. | ::: warning Ambiguous formats Markwhen is meant to be easy to pick up and immediately useful. Part of that simplicity means including support for dates and date ranges that are probably less specific than they should be. For example, `April 1 - June 18`, `Nov 11 2:30`, and `2020 - now` are all perfectly valid markwhen date ranges but, due to either their lack of year or changing ranges, **will mean something different when parsed in the future**. You should **think twice about using any date syntax that is ambiguous** to ensure it's really what you want. ::: ## Custom Date Formats Use `dateFormat` in the header to define your own date syntax. A rule has a `pattern` that matches the date text before the event colon and then describes how to turn that match into a date range. For example, this adds European day/month/year slash dates: ```mw dateFormat: - pattern: '^\d{1,2}/\d{1,2}/\d{4}$' fromFormat: d/M/yyyy 05/09/2026: Parsed as 5 September 2026 ``` Use capture groups when you need to rearrange parts of the text before parsing it: ```mw dateFormat: - pattern: '^week (\d{2})/(\d{4})$' from: '$2-W$1' fromFormat: "kkkk-'W'WW" duration: 1 week week 03/2026: Third ISO week of 2026 ``` Use `from` and `to` for custom ranges: ```mw dateFormat: - pattern: '^(\d{2}\.\d{2}\.\d{4}) - (\d{2}\.\d{2}\.\d{4})$' from: group: 1 format: MM.dd.yyyy to: group: 2 format: MM.dd.yyyy 01.15.2026 - 01.18.2026: Long kickoff ``` By default custom `dateFormat` rules are tried before built-in parsing. To change that, use the expanded form with `priority`: ```mw dateFormat: priority: last # first | last | only rules: - pattern: '^release (\d{4}_\d{2}_\d{2})$' from: group: 1 format: yyyy_MM_dd duration: 1 day ``` `fromFormat`, `toFormat`, and object `format` values use [Luxon format tokens](https://moment.github.io/luxon/#/formatting?id=table-of-tokens). Use `iso` as the format for ISO date/time captures. ## Relative Dates If you have events that are based off of, or relative to, other events, you can describe their relationship to get the range you want. For example, say you are working on a project tracker. You could outline the phases of your project by using absolute dates, like the following: ```mw // To indicate we are using European date formatting dateFormat: - pattern: '^\d{1,2}/\d{1,2}/\d{4}$' fromFormat: d/M/yyyy - pattern: '^(\d{1,2}/\d{1,2}/\d{4}) - (\d{1,2}/\d{1,2}/\d{4})$' from: group: 1 format: d/M/yyyy to: group: 2 format: d/M/yyyy - pattern: '^(\d{1,2}/\d{4})$' fromFormat: M/yyyy // 2 weeks 01/01/2023 - 14/01/2023: Phase 1 #Exploratory // Another 2 weeks 15/01/2023 - 31/01/2023: Phase 2 #Implementation // 1 month 02/2023: Phase 3 #Implementation // 3 days, after a one week buffer 07/03/2023 - 10/03/2023: Phase 4 - kickoff! #Launch ``` ![](/images/nonrelative_dates.png) However, as soon as something changes (say something slips or an estimate was wrong), you would have to go through all events and change their dates manually. This would be especially troublesome if the change is early on. With relative dates, we can express the same timeline like so: ```mw // 2 weeks 01/01/2023 - 2 weeks: Phase 1 #Exploratory // Another 2 weeks 2 weeks: Phase 2 #Implementation // 1 month 1 month: Phase 3 #Implementation // One week after phase 3 ends, a 3 days kickoff event 1 week - 3 days: Phase 4 - kickoff! #Launch ``` ![](/images/relative_dates.png) Relative dates base themselves off the previous date, and this goes all the way back to our first date, `01/01/2023`. ## Event IDs This works well enough for serial dates that are each dependent on the last, but what if we have multiple events that are all dependent on the same event? We can do that using event ids: ```mw{2} 01/01/2023 - 2 weeks: Phase 1 #Exploratory id: Phase1 // Another 2 weeks after !Phase1 2 weeks: Phase 2, in parallel with Phase 3 #Implementation // 1 month after !Phase1 1 month: Phase 3, in parallel with Phase 2 #Implementation // 3 days, after a one week buffer 1 week - 3 days: Phase 4 - kickoff! #Launch ``` The word `after` is optional, we could say `!Phase1 2 weeks: Phase 2, in parallel with Phase 3 #Implementation` to have the same effect. Relative dates will first attempt to refer to the event that was specified by a provided event id. For `!Phase1 2 weeks: Phase 2`, the event with the id `Phase1` is looked for, is checked for when it ends, and is used as the reference upon which `2 weeks` is based. If we can't find the event id, or no event id is given, the relative date is instead based upon the last date in the timeline - "last" here meaning most recently written, as the timeline is parsed from top to bottom. So if we have a timeline like this: ```mw 2020: Pandemic 2021 - 2023: More pandemic 1 year: Less pandemic? ``` `1 year` is based off the last date seen, which would be `2023`, or, more specifically, the end of `2023`. This also means that we can base our end date off of our start date: ```mw 12/25/2022: Christmas 5 days - 3 days: New Years' stuff ``` Here, `5 days` is five days after the previously seen date (`12/25/2022`), which would make it `12/30/2022`, while `3 days` is three days after the previous date, which is our start date of `12/30/2022`. Two relative dates together, like `x days - y weeks: ...`, can therefore essentially be read as `x days after the previous event and lasts for y weeks`. The only exception to this is the shorthand singular relative date, like `x years:...`, which means `immediately after the last event and lasts for x years`. ## Due dates In the same way you can represent an event taking place after a prior event, you can indicate that an event should come *before* another. Let's say we wanted to get some things done before Christmas: ```mw{2} 2022-12-25: Christmas id: Christmas before !Christmas 1 month: Buy presents before !Christmas 2 weeks: Get a tree ``` By using [event ids](#event-ids), we specify the due date, and specify the amount of time before that event. Like all event ids, the id must be defined earlier in the document in order to be able to reference it; something like the following would **not** work: ```mw before !Christmas 1 month: Buy presents before !Christmas 2 weeks: Get a tree 2022-12-25: Christmas !Christmas ``` because the event with the id of `!Christmas` is after the events that refer to it. A good way to deal with this is to define the known dates at the start of your timeline and then look at a [sorted view](#sorting) to see them in order. Similar to relative events that are dependent on preceding events, events with due dates can also have start and end times: ```mw 2022-12-25: Christmas !Christmas before !Christmas 1 week - 1 month: Buy presents ``` Here, the `Buy presents` event *ends* 1 week *before* Christmas, and lasts for 1 month. When dealing with relative events, the first part of the range (if there is one) can be considered the "buffer," while the second part is the duration. If there is only one part (no range; `before !Christmas 1 month` instead of `before !Christmas 1 week - 1 month`), then it will abut the event it is basing itself off of with the specified duration. Also similarly to other relative events, if no event id is specified, it will be dependent on the previous event. `Before` and `by` can both be used to represent happening before another event. These are equivalent: ```mw by !Chistmas 1 day: ... before !Christmas 1 day: ... ``` ## Dependencies We can express both the start and end date of an event as being relative to other events: ```mw{6,7} 2025-09-08: School starts id: school 2025-11-23: Thanksgiving break begins id: thanksgiving !school / !thanksgiving: Time in school before break // == 2025-09-09 / 2025-11-22 ``` Here, `!school / !thanksgiving` goes from the end of the `!school` event to the beginning of the `!thanksgiving` event. Most of the time, this is probably what we want - from the end of the first event to the start of the second. However, what if we want to align the start of two events? Or the end of two events? We can do that simply by using `.start` or `.end` modifiers when referencing event ids: ```mw 2025-11-23 / 2025-11-28: Thanksgiving break id: thanksgiving 2025-12-23 / 2026-01-03: Winter break id: winter !thanksgiving.start / !winter.end: Thanksgiving break to winter break, inclusive // == 2025-11-23 / 2026-01-03 ``` We can mix and chain `.start` and `.end` modifiers as much as we want, so long as the resulting date ranges make sense: ```mw 2021-04-09 / 10 days: Steven in London id: steven 2021-06-04 / 1 week: Isabella out of office id: isabella 2021-06-12 / 2021-06-22: Work trip id: trip !isabella.start - !trip.end: From the start of Isabella being out of office to the end of the work trip !steven.end / !isabella.end: From the end of Steven in London to the end of Isabella being out of office by !trip.end 1 year / 1 month: An even lasting one month, that ends one year before the end of the work trip // Anonymous .start and .end modifiers refer to the previously defined event .start / 2 months: From the start of the previous event, lasting for 2 months ``` ![](/images/dependencies.png) ## Week days ![](/images/weekdays.png) When using relative dates you can also take advantage of being able to specify `week days` - this calculates durations based on how many non-weekend days it takes. For example: ```mw July 13, 2022 - 5 week days: Item estimate 10 week days: Second part of item ``` July 13, 2022 is a Wednesday, and we're counting 5 week days, so we go Wednesday, Thursday, Friday (3), and then the following Monday and Tuesday (2), which gets us to the end of July 19 (technically midnight July 20, a Wednesday). The second event starts after the first and lasts 10 weekdays, which would take us to two Wednesdays into the future, or 14 calendar days. `Week`, `work`, and `business` are supported as prefixes to `day` when working with weekdays. These are all equivalent: ```mw 10 business days: ... 10 weekdays: ... 10 work days: ... ``` Week days do not take into account holidays - only weekends. It also assumes a 5 day work week, unfortunately. [Hopefully soon it will be 4 days](https://4dayweek.io). ## Recurring events To have an event repeat itself some number of times, you can use recurrence syntax between the event range and the event description: ```mw October 7, 1989 every year for 10 years: ... 2025-03-04 every week for 12 weeks: ... 2022-01/2022-03 every 2 years x9: ... Feb 1 2023 every 6 months for 10 times: ... ``` Recurrence syntax essentially takes the form of ``` every (duration) (for (number of times | duration)) | x(amount) ``` ![](/images/recurring_syntax.png) ![](/images/recurring_timeline.png) --- --- url: /syntax/properties.md --- # Properties Events and sections can have optional properties. Properties are key-value pairs of the form `key: value` that are **immediately after the first line** of an event or section definition, i.e. ```mw{2,3} 2025-04-30: Carpooling to work riders: [Tom, Jerry] fee: $4 Some day I'll have my own car ``` ::: tip Indentation While some of examples are indented (like above), indentation in markwhen is optional. ::: In this example, `riders: [Tom, Jerry]` is one key-value pair and `fee: $4` is another. They are stored in the `event.properties` field as an object and may be used by visualizations. **Properties must follow on the line(s) after the event or section definition.** You can't put properties at the end of an event description or in the middle of it. ::: warning Warning Unlike the [header](/syntax/header), event properties cannot use multiline list syntax for arrays -- the dash syntax will be interpreted as a list as part of the event description. You'll need to use [flow style](https://www.yaml.info/learn/flowstyle.html) if you want an array value for an event or section property: ##### 🚫 This won't work: ```mw 2025-09-04: Meeting recipients: - Melissa - Roger - Don ``` ##### βœ… This will work: ```mw 2025-09-04: Meeting recipients: ["Melissa", "Roger", "Don"] ``` ::: ## Timezone `timezone` or `tz` is a special property of an event to set its timezone specifically: ```mw{2,5} 2025-08-03 10am: Meeting timezone: America/Los_Angeles 2025-08-04 11am: Return flight tz: -5 ``` ## Event id [Event ids](/syntax/dates-and-ranges#event-ids) can be used to create relative dates and let subsequent events refer to it: ```mw{2} 2025-08-03 10am: Meeting id: meeting ``` ## Prop Order There is an additonal field on sections and events, `propOrder`, which is a `string[]` of keys in the order that they were defined in the document. For example in the following markwhen document: ```mw 2026: Event fun: yes travel: ['America', 'Europe', 'Africa'] people: Family ``` The properties of the (only) event are: ```json { "fun": "yes", "travel": ["America", "Europe", "Africa"], "people": "Family" } ``` while `propOrder` will be: ```json ["fun", "travel", "people"] ``` You can use `propOrder` to maintain order of property definitions, if desired. --- --- url: /syntax/event-descriptions.md --- # Event Descriptions An event description is everything after the date range of the event, **up to the next event**. Event descriptions can span multiple lines. For `12/2012: End of the world`, the event description is just `End of the world`. For the following event: ```mw 1961: Year after 1960 Later, 1962 would happen ``` the event description is ```mw Year after 1960 Later, 1962 would happen ``` Event descriptions can include [tags](#tags), [links](#links), [photos](#photos), and [references](#references). ## Tags Events can be tagged to visually indicate they belong to some category. Simply add your tag text in any part of an event's description to tag it: ```mw 2022: Happy 95th Birthday Queen Elizabeth #UK #Royalty ``` ## Links Links are similar to markdown links: link display text in brackets followed by the url in parentheses: ```mw 2018 - 3 years: [Google](www.google.com) ``` ## Locations For vizualizations that support locations, add a property to the event: ```mw{2,5} 09/2018: Road trip to Seattle locations: [Devil's Tower, Glacier National Park, Seattle] 1999-05-25: A fond memory location: Sam's bar and grill ``` ## Photos Markdown-style images are supported: `![optional alt text](image link)` ## Task list Markdown task lists are supported: ```mw now: Things to do - [ ] unfinished task - [x] finished task ``` ## Percent Indicate that an event is some percent complete by including `0`-`100%` in your event, and the event bar will be partially filled in to show the completion percentage. ![](/images/percents.png) When no [percent](#percent) is present, the completion ratio of tasks will be used to represent the percent complete and will be indicated in the event bar. --- --- url: /syntax/sections.md --- # Sections Events can be organized into sections using markdown-style headers. Use `#` through `######` (1-6 hash marks) to create sections at different nesting levels. Sections automatically close when: * A section of the same or higher level (fewer or equal `#` marks) is encountered * The end of the document/page is reached For example, ```mw{1,7,12,16} # The 90s 1991: Desert Storm 1994: Friends premiered 05/14/1998: Series finale of Seinfeld ## The 2000s 03/2005: Premiere of The Office (US) // The 2000s section auto-closes when The 2010s starts ## The 2010s 2020: Pandemic // This starts a new top-level section # Other Events 2022: Other things happen ... ``` The number of `#` marks determines the nesting level - `#` is the outermost level, `##` is nested inside `#`, and so on up to `######`. ## Sections vs Groups By default, sections are rendered as "groups" - collapsible containers for events. You can change the visual style using the `style` property: ```mw # My Section style: section 2024: Event in section-styled container ``` When `style: section` is set, the section extends to the full width of the timeline: Read more about how sections are handled by the [parser](/parser). --- --- url: /syntax/header.md --- # Header / Frontmatter The header of a timeline indicates things about the timeline like visual preferences and metadata. It is the first part of a timeline; that is, anything before the first event is considered the header. The header is parsed as yaml, similar to frontmatter in some markdown parsers. Also similarly to frontmatter, you may (but are not required to) sandwich the header between three dashes (`---`). ```mw --- title: Timeline key: - entry --- Dec 29 2029: Some date ``` is parsed the same as ```mw title: Timeline key: - entry Dec 29 2029: Some date ``` Custom visualizations may prescribe special header values they might look for when parsing your markwhen document. Some typical header items are as follows: ## Timezone Indicate what timezone this markwhen document is relative to: ```mw timezone: Europe/London ``` or ```mw timezone: -6 ``` ## Title Indicate the title of the page by adding a title attribute to your header: ```mw title: Party Planning ``` This will also show up as the title of the browser tab. ## Description If the title isn't descriptive enough, or you want to add more context, add a description to the timeline: ```mw description: These are the main events for the party, try to stick to the plan!! ``` ## Viewers ::: tip Meridiem feature Specifying viewers in the header is a [Meridiem](https://meridiem.markwhen.com)-specific feature. ::: Limit access to your timeline by adding a `meridiem.view:` entry: ```mw meridiem: view: - onlymypeople@example.com - myteam@example.com ``` Wildcards are also supported: ```mw // Anyone can view meridiem: view: "*" ``` Lack of a `meridiem.view` or `meridiem.edit` entry in the header indicates that the document is private. See [access control](/meridiem/sharing). ## Editors ::: tip Meridiem feature Specifying editors in the header is a [Meridiem](https://meridiem.markwhen.com)-specific feature. ::: Allow others to edit your shared document with an `meridiem.edit` field in the header: ```mw meridiem: edit: - onlymypeople@example.com - myteam@example.com ``` Wildcards are also supported: ```mw // Anyone can edit meridiem: edit: "*" ``` Lack of an `edit` entry indicates that no one besides the owner can edit it. ## Tags You can indicate the color you want certain tagged events to appear like so: ```mw #Travel: blue #Education: green #Economics: #abc // hex color ``` Hex colors are supported (e.g., `#a13bbb`). So, if you have an event like the following ```mw 2012-2013: Germany and Italy #Travel ``` it will be colored as blue in the timeline view. ### Advanced Documentation for advanced tag configurations, including timezones and reminders, can be found [here](/syntax/tags). ## Date formatting Non-ISO8601 slash dates default to American formatting (Month/Day/Year). Add `dateFormat` rules in the header to support other date syntaxes. ```mw dateFormat: - pattern: '^\d{1,2}/\d{1,2}/\d{4}$' fromFormat: d/M/yyyy 05/09/2026: Parsed as 5 September 2026 ``` Rules can also use capture groups, custom ranges, durations, and `priority: first | last | only`. [See custom date format examples](/syntax/dates-and-ranges#custom-date-formats). ## Imports ::: tip Meridiem feature Importing other documents is a [Meridiem](https://meridiem.markwhen.com)-specific feature. ::: You can import other shared markwhen into your own for viewing purposes, simply by adding it to an `import` list in the header: ```mw import: - blake/info-timeline - priya/q3-q4 ``` Events from imported timelines will be merged into visualizations. ## Header Quick Reference | Item | Syntax | Example | | ----------------------------------------------------- | ---------------------------- | ----------------------------------------------- | | Coloring tags | `#[tag name]: ` | `#Movies: aquamarine` | | Date format. Add custom date parsing rules. | `dateFormat: [...]` | `dateFormat:\n - pattern: ...` | | Title of the page | `title: ` | `title: My timeline` | | Description of the page | `description: <description>` | `description: If anything looks off here, lmk!` | | Viewers | `view: <viewer emails>` | `view: you@example.com` | | Editors | `edit: <editor emails>` | `edit: otherperson@example.com` | | Timezones | `timezone: <timezone>` | `timezone: +5` or `timezone: America/New_York` | --- --- url: /syntax/tags.md --- # Tags Events in markwhen can be tagged with the `#` syntax: ```mw 2020: Mostly uneventful year #covid ``` Here, the event has the tag `#covid`. In views that support different colors, this event will have a distinct color from other, untagged events. ## Colors You can specify a tag's color in the header: ```mw{2} --- #covid: blue --- 2020: Mostly uneventful year #covid ``` ## Multiple tags Events and sections support multiple tags, but their behavior with given properties becomes undefined -- or at least less defined. Basically, do not expect smart merging of tags' properties: ```mw #school: red #work: yellow 2021: Was working while in school #school #work ``` Events tagged with both `#school` and `#work` will not be colored orange because `#school` is red and `#work` is yellow. It will be one or the other. Similarly with timezones -- an event can't simultaneously have two different timezones. You should be specific with your tags. --- --- url: /syntax/timezones.md --- # Timezones > Oh man, I don't like timezones > > β€” Rob Koch, Markwhen creator and maintainer We can (and should) specify a timezone for our markwhen documents by adding a `timezone` or `tz` header entry and similar for events: ```mw{2,12} --- timezone: America/New_York #covid: blue #london: color: green --- 2020: Mostly uneventful year #covid # Year abroad #london 2023-06-01: the king was coronated timezone: Europe/London ... ``` This way, any event that isn't otherwise explicitly given a zone, like `2023-06-01: the king was coronated` is, will be in the `America/New_York` zone. Time zones can be specified by their name, like `America/Los_Angeles`, or by a offset, like `+5` or `-3`. ::: tip Luxon Much of markwhen's parsing, including timezone parsing, is done with [Luxon](https://moment.github.io/luxon/#/). Read more about timezones on [luxon's documentation](https://moment.github.io/luxon/#/zones) ::: ::: warning When to specify a timezone The general advice is you should always specify a timezone - less ambiguity is better. Otherwise, you should specify a timezone if or when you start to use markwhen for things like calendaring or keeping track of responsibilities, where your events are measured in hours and minutes instead of days, months, and years. ::: ## Different start and end zones If your start and end ranges for an event are different, separate out the zones with `from` and `to`: ```mw 2025-06-09 11am / 2025-06-09 6:40pm: πŸ›« Going home to NYC from LA timezone: from: America/Los_Angeles to: America/New_York ``` --- --- url: /meridiem.md --- # Meridiem ![](/images/meridiem.png) [Meridiem](https://meridiem.markwhen.com) is a markwhen editor brought to you by [the team that develops markwhen](https://github.com/kochrt) that includes: * Syntax highlighting * Bidirectional editing (views can edit the document, in addition to editing the text directly) * Event hover highlighting * [Collaborative editing](/meridiem/sharing) * Integrated tag color picker * Support for multiple views * Work with online and offline documents * [Per-document Access control](/meridiem/sharing) * [Document editing API and OAuth apps](/meridiem/api/) * Light and dark modes * Incremental reparsing * [Snippets](/meridiem/snippets.md) * Keymaps (VS Code, Vim, Emacs) * And more ## Desktop app Meridiem is available both as a web app and as a desktop app. The latest version is {{ latestBinaryVersion }} and can be downloaded here. --- --- url: /meridiem/sharing.md --- # Sharing Markwhen documents shared via Meridiem support live collaborative editing - simply share the link with those you want to collaborate with. Documents are private by default. ## Publicly viewable To let anyone view your document, add the following in the header: ```mw meridiem: view: "*" ``` ## Public access To let anyone view **and** edit your document, add the following in the header: ```mw meridiem: view: "*" edit: "*" ``` ::: info `edit` permissions automatically confer `view` permissions, so the `view` field in the example above is technically superfluous. ::: ## Restriced access To restrict editing and viewing to specific people, set those specific email addresses in the view and/or edit fields: ```mw meridiem: edit: - jim@company.com - susan@example.com ``` ::: warning Note Previously, `edit` and `view` fields were top level header items -- that syntax has been deprecated. While that syntax will work in the meantime, you should convert any top level `edit` or `view` fields to be nested under `meridiem`. #### Previously ```mw edit: - joan@sterlingcooper.com - don@sterlingcooper.com view: "*" ``` #### Now ```mw meridiem: edit: - joan@sterlingcooper.com - don@sterlingcooper.com view: "*" ``` ::: --- --- url: /meridiem/snippets.md --- # Snippets Snippets allow you to define autocompletions that active as you type. A `prefix` is the text that you type to trigger the snippet, and the `template` is the text that gets inserted replacing the prefix. For example, you may find typing `check` easier than `- [] ` - you can define a snippet that will make that replacement: ![](/images/snippets1.png) ![](/images/snippets2.png) ![](/images/snippets6.png) ![](/images/snippets4.png) ![](/images/snippets7.png) `${}` are cursor positions after the replacement has been made. Read more about snippets and their syntax [here](https://codemirror.net/docs/ref/#autocomplete.snippet). --- --- url: /meridiem/api.md --- # Meridiem Apps Meridiem supports apps that act on a user's behalf via the [OAuth API](/meridiem/api/). If you want the fastest path, start with the [OAuth quickstart](/meridiem/api/oauth). If you want a concrete model for what an app can be, read [Build an app like Remark.ing](/meridiem/api/build-remarking-app). If you are connecting an agent, see [MCP](/meridiem/api/mcp). To create an app, ensure that you are signed into Meridiem. Go to `Settings` -> `Developer` -> Under `Apps`, click `Create`. Enter your app's information (it can be changed later) and click `Save`. Your app's `app_id` and `app_secret` will be generated, and there will be additional fields to add more supplemental data: * `Optional` icon url that will display on the OAuth page * `Optional` scope justification that will also be displayed on the OAuth page, for explaining the purpose of requesting the scopes that your app requests. * `Optional` `doc_changed_webhook` url for receiving signed document change webhooks. For webhook payloads, signature headers, and verification details, see [Webhooks](/meridiem/api/webhooks). ## Start here | Page | Use it for | | ---- | ---------- | | [OAuth quickstart](/meridiem/api/oauth) | Signing users in and calling the API with access tokens | | [MCP](/meridiem/api/mcp) | Connecting agents like Codex to Meridiem documents | | [API playground](/meridiem/api/api) | Trying document, media, and aggregate endpoints | | [Build an app like Remark.ing](/meridiem/api/build-remarking-app) | Understanding the shape of a real Meridiem app | ## Good first apps Meridiem apps usually do one or more of these things: * Read markwhen documents and turn them into a focused UI. * Append new entries from a form, button, automation, SMS, email, or agent. * Store app-level settings in the document header. * Store structured per-entry details as event properties. * React to document changes through webhooks. For cross-document feeds, search, dashboards, and jobs, see [Aggregate queries](/meridiem/api/aggregate-queries). For more examples, see [What can you build?](/meridiem/api/what-can-you-build). --- --- url: /meridiem/api/oauth.md --- # OAuth Quickstart This page shows the smallest useful Meridiem OAuth flow: send a user to Meridiem, receive an authorization code, exchange it for tokens, and call the API. If you are using an auth library, Meridiem behaves like an OAuth/OIDC provider. If you are building by hand, start here. If you are connecting an MCP client or agent, start with [MCP](/meridiem/api/mcp). MCP uses the same OAuth endpoints, but agent clients often use OAuth Client ID Metadata Documents instead of manually created Meridiem apps. ## Create an app In Meridiem, open `Settings` -> `Developer` -> `Apps` -> `Manage Apps` -> `Create App`. ![](/images/app1.png) ![](/images/app2.png) ![](/images/app3.png) ![](/images/app4.png) ![](/images/app5.png) You will enter: | Field | What to put there | | ----- | ----------------- | | `Name` | The app name shown to users | | `Description` | A short explanation of the app | | `Redirect URIs` | One or more callback URLs, one per line | You'll need to enter at least one redirect uri to create the app -- don't worry, you can change it later, and it isn't super important right now. After saving, Meridiem gives you an `app_id` and `app_secret`. For browser apps, mobile apps, and vibe-coded prototypes, prefer PKCE so the app secret does not need to live in the client. For trusted server-side apps, you can exchange the code with the app secret. ## Send the user to Meridiem Build an authorization URL: ```js const authorizeUrl = new URL("https://meridiem.markwhen.com/authorize"); authorizeUrl.searchParams.set("client_id", appId); authorizeUrl.searchParams.set("response_type", "code"); authorizeUrl.searchParams.set("redirect_uri", "https://example.com/auth/callback"); authorizeUrl.searchParams.set("scope", "openid docs.read:* docs.write:*"); authorizeUrl.searchParams.set("state", crypto.randomUUID()); // PKCE, recommended for public clients. authorizeUrl.searchParams.set("code_challenge", codeChallenge); authorizeUrl.searchParams.set("code_challenge_method", "S256"); window.location.href = authorizeUrl.toString(); ``` The user sees an authorization page, chooses whether to connect, and Meridiem redirects back to your `redirect_uri`: ```txt https://example.com/auth/callback?code=code_...&state=... ``` ## Exchange the code Exchange the code at `/oauth/token`. ::: code-group ```js [PKCE] const response = await fetch("https://meridiem.markwhen.com/oauth/token", { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify({ grant_type: "authorization_code", client_id: appId, redirect_uri: "https://example.com/auth/callback", code, code_verifier: codeVerifier, }), }); const tokens = await response.json(); ``` ```js [Server-side] const response = await fetch("https://meridiem.markwhen.com/oauth/token", { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify({ grant_type: "authorization_code", client_id: appId, client_secret: appSecret, redirect_uri: "https://example.com/auth/callback", code, }), }); const tokens = await response.json(); ``` ::: The response looks like: ```ts { access_token: string; refresh_token: string; exp: string; scopes: string[]; token_type: "Bearer"; id_token?: string; } ``` `id_token` is returned when the user grants `openid`. ## Call the API ```js const response = await fetch("https://meridiem.markwhen.com/api/v1/docs", { headers: { authorization: `Bearer ${tokens.access_token}`, }, }); const { docs } = await response.json(); ``` ## Refresh tokens Access tokens expire. Keep the latest refresh token and rotate it whenever you refresh: ```js const response = await fetch("https://meridiem.markwhen.com/oauth/token", { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify({ grant_type: "refresh_token", refresh_token: storedRefreshToken, }), }); const nextTokens = await response.json(); ``` If an API call returns `401` or `405`, refresh and retry once. ## Scopes | Scope | Allows | | ----- | ------ | | `openid` | Read the user's email and username | | `docs.read:*` | Read all documents the user grants to the app | | `docs.write:*` | Create and change documents the user grants to the app | | `docs.read:{doc}` | Read one document | | `docs.write:{doc}` | Write one document | | `media.read:*` | List uploaded media | | `media.write:*` | Create upload links and delete uploaded media | Write access implies read access for the same document. Users can later remove app access in Meridiem settings. ## Common gotchas * The `redirect_uri` in the token exchange must exactly match one registered redirect URI. * Authorization codes expire quickly and can only be used once. * Use `state` to protect your callback from cross-site request forgery. * Use PKCE for browser apps and prototypes. Do not ship `app_secret` in frontend code. * API document paths use a user-facing document name/path. Responses include the immutable `doc_id`. For callback, PKCE, status code, and document access problems, see [OAuth troubleshooting](/meridiem/api/troubleshooting). --- --- url: /meridiem/api/build-remarking-app.md --- # Build an App Like Remark.ing [Remark.ing](https://remark.ing) is a good model for what Meridiem apps can do. It lets people write normal markwhen documents in Meridiem, then turns selected entries into a social feed. You do not have to build a full social network. The pattern is useful for dashboards, journals, CRMs, habit trackers, publishing tools, personal automations, and agent-built apps. ## The basic idea Remark.ing does four things: 1. Asks the user to connect Meridiem. 2. Reads the user's documents. 3. Looks for documents that opt into Remark.ing through header data. 4. Renders entries as posts and writes new entries back to Meridiem. That same loop is the foundation for most Meridiem apps: ```txt connect Meridiem -> choose docs -> read entries -> show useful UI -> write changes back ``` ## What lives where Use markwhen text for the user's timeline-like content: ```mw --- title: Garden notes timezone: America/Los_Angeles garden: type: personal-log public: false --- 2026-04-03: Planted tomatoes variety: sungold bed: west 2026-04-10: Added compost bed: west ``` Use the header for document-level settings: ```mw --- title: Garden notes timezone: America/Los_Angeles garden: public: false theme: green defaultBed: west --- ``` Use event properties for per-entry data: ```mw 2026-04-03: Planted tomatoes variety: sungold bed: west source: seedling ``` As a rule of thumb: | Put it in | When it describes | | ------------------ | ------------------------------------------------------ | | Header | The whole document, app settings, visibility, defaults | | Event text | What happened, what the user wants to read | | Event properties | Structured details about one event | | Markdown documents | Long-form pages, documentation, essays, drafts | | Markwhen documents | Dated entries, schedules, histories, logs, feeds | ## Opt in with a header Remark.ing only shows documents that explicitly opt in: ```mw --- remarking: view: "*" description: | Notes from my workshop. --- ``` Your app can use the same pattern with your own namespace: ```mw --- mealplanner: view: "*" meals: - breakfast - dinner --- ``` Namespacing keeps your app's settings from colliding with other tools. ## Ask for OAuth access Start with the smallest scopes that make your app work: ```txt openid docs.read:* ``` If your app creates or edits documents, ask for write access: ```txt openid docs.read:* docs.write:* ``` If your app manages uploaded images or video: ```txt openid docs.read:* docs.write:* media.read:* media.write:* ``` Then send the user through the [OAuth quickstart](/meridiem/api/oauth). ## Read documents List the documents the user granted to your app: ```js const { docs } = await fetch("https://meridiem.markwhen.com/api/v1/docs", { headers: { authorization: `Bearer ${accessToken}`, }, }).then((r) => r.json()); const remarkingDocs = docs.filter((doc) => doc.header?.remarking?.view); ``` Each document includes a `path` like `rob/notes` and a stable `doc_id`. ## Read entries For feed-like apps, entries are often easier than raw document text: ```js const entries = await fetch( `https://meridiem.markwhen.com/api/v1/docs/${username}/doc/${docName}/entries`, { method: "POST", headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json", }, body: JSON.stringify({ property_filter: { status: "published", }, }), }, ).then((r) => r.json()); ``` You can also fetch the full document when your app needs text editing or parsing: ```js const doc = await fetch( `https://meridiem.markwhen.com/api/v1/docs/${username}/doc/${docName}`, { headers: { authorization: `Bearer ${accessToken}`, }, }, ).then((r) => r.json()); console.log(doc.text); console.log(doc.parsed); ``` ## Write a new entry To append a new entry, patch the document: ```js const result = await fetch( `https://meridiem.markwhen.com/api/v1/docs/${username}/doc/${docName}`, { method: "PATCH", headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json", }, body: JSON.stringify({ edits: [ { text: "dt{yyyy-MM-dd HH:mm}: Published from my app\n status: published", timezone: "America/Los_Angeles", }, ], }), }, ); const { added } = await result.json(); ``` `dt{...}` is replaced on the server using [Luxon](https://moment.github.io/luxon/#/) date formatting. ## Update document settings Use the header endpoint when your app changes document-level settings: ```js await fetch( `https://meridiem.markwhen.com/api/v1/docs/${username}/doc/${docName}/header`, { method: "PATCH", headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json", }, body: JSON.stringify({ merge: { mealplanner: { defaultMeal: "dinner", }, }, }), }, ); ``` ## Use webhooks for freshness Polling works for prototypes. For production apps, add a `doc_changed_webhook` URL in your app settings and listen for `doc.changed` events. See [Webhooks](/meridiem/api/webhooks). ## Agent checklist If you are asking an agent to build a Meridiem app, give it this checklist: * Register a Meridiem app and record the app ID. * Use PKCE unless the token exchange happens on a trusted server. * Request only the scopes the app needs. * Store `access_token` and `refresh_token`; refresh on `401` or `405`. * Use `/api/v1/docs` to discover available documents. * Use header fields for app-level configuration. * Use event properties for structured per-entry data. * Use markwhen for dated logs and markdown for long-form pages. * Use webhooks when the app should react to document changes. ## A tiny app prompt You can hand this to a coding agent: ```txt Build a small Meridiem OAuth app. It should: - connect to Meridiem with OAuth PKCE - request openid and docs.read:* - list documents from /api/v1/docs - show documents where header.myapp.enabled is true - fetch entries for the selected document - render the entries in reverse chronological order - refresh the Meridiem token if an API call returns 401 or 405 Use header.myapp for document-level settings and event properties for per-entry metadata. ``` --- --- url: /meridiem/api/what-can-you-build.md --- # What Can You Build? Meridiem OAuth turns markwhen documents into a user-owned data source. Your app can ask for permission, read the user's documents, transform them into a focused experience, and write changes back. Think of Meridiem as the place where the data stays understandable. Your app can be the specialized interface. ## App ideas | App | Uses markwhen for | Uses markdown for | | --- | ----------------- | ----------------- | | Personal CRM | Contact history, reminders, follow-ups | Contact notes and scripts | | Field journal | Observations with dates, places, tags | Long-form trip reports | | Habit tracker | Daily check-ins, streak events | Reflections | | Publishing tool | Posts, drafts, scheduled entries | Essays and pages | | Project dashboard | Milestones, decisions, blockers | Project briefs | | Garden planner | Planting, watering, harvest logs | Care notes | | Workout log | Sessions, PRs, injuries | Training plans | | Lightweight CRM | Calls, meetings, deal changes | Account notes | ## Header or event property? Use the header for settings that apply to the whole document: ```mw --- crm: accountType: customer owner: rob syncToDashboard: true --- ``` Use event properties for facts about one entry: ```mw 2026-02-12: Call with Acme type: call sentiment: positive next: send proposal ``` Use the event description for human-readable context: ```mw 2026-02-12: Call with Acme type: call They want a smaller pilot before committing to the annual plan. ``` ## Designing for users A good Meridiem app should make the document better, not hide it forever. * Keep generated text readable in Meridiem. * Use a namespace for your app's header fields. * Avoid overwriting user text when an append or header merge is enough. * Prefer normal markwhen syntax over opaque blobs. * Let users export, inspect, and edit their own data. ## Start with a recipe If you know the kind of app you want, start with [App recipes](/meridiem/api/app-recipes). The recipes show common document shapes, scopes, and first API calls. --- --- url: /meridiem/api/app-recipes.md --- # App Recipes These recipes are starting points for Meridiem apps. They are intentionally plain: a human can understand them, and a coding agent can turn them into an app without needing to invent the data model from scratch. Each recipe follows the same pattern: 1. Ask the user to connect Meridiem. 2. Find documents that opt into your app through the header. 3. Read entries or document text. 4. Render a focused interface. 5. Write readable markwhen or markdown back to Meridiem. ## Feed App Use this for social feeds, status streams, release logs, public journals, or team updates. ### Document shape ```mw --- feedapp: view: "*" title: Workshop notes avatar: https://example.com/avatar.jpg timezone: America/Los_Angeles --- 2026-03-01 09:30: Shipped the first prototype status: published tags: product,prototype The rough edges are still there, but the loop works. ``` ### Store in the header * Whether the document participates in the feed. * Display name, avatar, description, and theme. * Defaults for new posts. ### Store on events * `status`, such as `draft` or `published`. * Tags, category, source, or visibility. * Any app-specific ranking or grouping field. ### Scopes Start with: ```txt openid docs.read:* ``` Add `docs.write:*` if the app lets users post, edit, or delete from your UI. ### First API calls ```js const { docs } = await meridiem("/api/v1/docs"); const feedDocs = docs.filter((doc) => doc.header?.feedapp?.view); ``` ```js const entries = await meridiem( `/api/v1/docs/${username}/doc/${docName}/entries`, { method: "POST", body: JSON.stringify({ property_filter: { status: "published" }, }), } ); ``` ## Dashboard App Use this for project dashboards, lightweight CRMs, operations boards, and team overviews. ### Document shape ```mw --- dashboard: enabled: true owner: Maya area: Launch timezone: UTC --- 2026-04-10: Landing page ready status: done priority: high owner: Maya 2026-04-15: Press kit status: blocked priority: medium owner: Chris ``` ### Store in the header * Dashboard membership and display settings. * Team, project, owner, or account metadata. * Default filters. ### Store on events * Status, priority, owner, estimate, customer, amount, stage. * Dates that matter to the item. * Links in the description when humans should be able to read them. ### Scopes ```txt openid docs.read:* docs.write:* ``` ### First API calls List docs, filter on `header.dashboard.enabled`, then read entries. To update a card, append a new event instead of mutating history when possible: ```mw 2026-04-16: Press kit unblocked status: active priority: medium owner: Chris ``` That keeps the document as a useful audit trail. ## Journal App Use this for field notes, private logs, therapy notes, garden journals, workouts, or research diaries. ### Document shape ```mw --- journalapp: enabled: true moodTracking: true timezone: America/New_York --- 2026-05-02 21:10: Evening check-in mood: calm energy: low Walked after dinner. The rain made everything quieter. ``` ### Store in the header * Journal settings and prompts. * Whether the journal is private or shareable. * Default timezone. ### Store on events * Mood, energy, weather, location, category. * Prompt IDs or source if an agent generated a prompt. ### Scopes For a read-only reflection app: ```txt openid docs.read:* ``` For an app that writes check-ins: ```txt openid docs.read:* docs.write:* ``` ### First write ```js await meridiem(`/api/v1/docs/${username}/doc/${docName}`, { method: "PATCH", body: JSON.stringify({ edits: [ { text: "dt{yyyy-MM-dd HH:mm}: Evening check-in\n mood: calm\n energy: low\n\nWalked after dinner.", timezone: "America/New_York", }, ], }), }); ``` ## Reminder App Use this for follow-ups, lightweight task systems, habit nudges, or scheduled prompts. ### Document shape ```mw --- reminderapp: enabled: true timezone: America/Los_Angeles --- 2026-06-01 09:00: Send renewal note remind: true channel: email status: open ``` ### Store in the header * Notification defaults. * Quiet hours. * Preferred channels. ### Store on events * Whether an entry should trigger a reminder. * Channel, status, recurrence, or external task ID. ### Scopes ```txt openid docs.read:* docs.write:* ``` Use [Webhooks](/meridiem/api/webhooks) if your reminder app needs to reschedule when documents change. ### Agent instruction Tell the agent: ```txt Find documents where header.reminderapp.enabled is true. Read entries where properties.remind is true and properties.status is open. Show upcoming reminders ordered by from_ts. When a reminder is completed, append a completion event instead of deleting the original. ``` ## Publishing App Use this for blogs, newsletters, changelogs, documentation snippets, and small websites. ### Markwhen for posts ```mw --- publisher: site: rob-notes title: Rob's notes timezone: America/Los_Angeles --- 2026-07-01: Building with Meridiem status: published slug: building-with-meridiem The key is keeping the source document pleasant to read. ``` ### Markdown for pages Use markdown documents for pages that are not naturally time-based: ```md # About I write about small tools, timelines, and local-first software. ``` ### Store in the header * Site title, author, theme, canonical URL. * Publishing defaults. ### Store on events * `status`, `slug`, `summary`, `tags`, `canonical`. * Per-post image or social metadata. ### Scopes ```txt openid docs.read:* docs.write:* media.read:* media.write:* ``` Add media scopes only if the app uploads images or video. ## A reusable agent prompt ```txt Build a Meridiem app from this recipe. Requirements: - Use OAuth PKCE. - Store app settings in header.<appNamespace>. - Store per-entry metadata as markwhen event properties. - Use /api/v1/docs to discover documents. - Use /api/v1/docs/:user/doc/:docName/entries for entry lists. - Use PATCH /api/v1/docs/:user/doc/:docName with edits when appending. - Refresh the token and retry once when an API call returns 401 or 405. - Keep the generated markwhen readable to a person opening the document in Meridiem. ``` --- --- url: /meridiem/api/aggregate-queries.md --- # Aggregate Queries Aggregate queries are for apps that need to read across the documents they are allowed to see. They are a good fit for feeds, search, dashboards, publishing jobs, notifications, and cross-document views. The important idea is simple: ```txt scopes decide what the app may see docs narrows what the app asks for ``` If an app has `docs.read:*` or `docs.write:*`, it can read all documents that user granted to the app. If it has document-specific scopes like `docs.read:journal`, it can read only those matching documents. Either way, aggregate endpoints return only authorized rows. ## When to use aggregate queries Use aggregate queries when your app wants to: * show a feed from many documents * find documents that opt into your app through header data * search or filter entries across documents * run a background job without first listing and fetching every document * resolve user-facing document paths into stable `doc_id`s Use the regular document endpoints when your app is editing one known document, appending entries, or rendering a single document. ## Server-side auth Aggregate endpoints use app credentials: ```js const basicAuth = btoa(`${appId}:${appSecret}`); ``` Send them as Basic auth: ```js headers: { authorization: `Basic ${basicAuth}`, } ``` Keep `app_secret` on your server. Do not put it in browser code, mobile app bundles, or public repositories. This is different from the main document API, which uses a user OAuth access token: ```js headers: { authorization: `Bearer ${accessToken}`, } ``` ## Scope behavior Aggregate endpoints check each returned document or entry against the app's authorized scopes. | Authorized scope | What aggregate queries can return | | ---------------- | --------------------------------- | | `docs.read:*` | All readable documents granted by that user | | `docs.write:*` | All writable documents granted by that user; write implies read | | `docs.read:{doc}` | That matching document | | `docs.write:{doc}` | That matching document; write implies read | The `{doc}` part may be the stable `doc_id` or a user-facing document name/path, depending on how the user granted access. The optional `docs` request field narrows results: | `docs` value | Meaning | | ------------ | ------- | | omitted or `[]` | Return everything the app is authorized to read | | `["alice"]` | Return authorized docs owned by `alice` | | `["alice/journal"]` | Return `alice`'s `journal` doc if authorized | | `["alice/projects/roadmap"]` | Return a nested document path if authorized | ## Find participating documents A common app pattern is to ask users to opt documents into your app with header data: ```mw --- feedapp: enabled: true title: Workshop notes timezone: America/Los_Angeles --- 2026-07-01: First public note status: published ``` Then query metadata for documents whose header contains that setting: ```js const basicAuth = btoa(`${appId}:${appSecret}`); const response = await fetch( "https://meridiem.markwhen.com/api/v1/aggregate/metadata", { method: "POST", headers: { authorization: `Basic ${basicAuth}`, "content-type": "application/json", }, body: JSON.stringify({ header_filter: [{ feedapp: { enabled: true } }], limit: 50, }), }, ); const docs = await response.json(); ``` Each returned document includes `uid`, `username`, `doc_id`, `header`, `updated_at`, and `doc_path`. Store `doc_id` when you need a stable identity. Use `doc_path` or `canonical_url` for display and links. ## Build a recent entries feed For feed-style apps, query entries directly: ```js const response = await fetch( "https://meridiem.markwhen.com/api/v1/aggregate/entries", { method: "POST", headers: { authorization: `Basic ${basicAuth}`, "content-type": "application/json", }, body: JSON.stringify({ header_filter: [{ feedapp: { enabled: true } }], property_filter: [{ status: "published" }], from_ts_gte: "2026-01-01T00:00:00.000Z", limit: 20, }), }, ); const entries = await response.json(); ``` Entries include the parsed entry, event properties, tags, the owning document header, `doc_id`, `doc_path`, and a `canonical_url` when Meridiem can build one. Use `limit` and `offset` for pagination: ```js body: JSON.stringify({ limit: 20, offset: page * 20, }); ``` ## Narrow to a user or document If your app already knows the owner or document path, pass `docs`: ```js body: JSON.stringify({ docs: ["alice"], limit: 20, }); ``` or: ```js body: JSON.stringify({ docs: ["alice/journal"], property_filter: [{ status: "published" }], }); ``` This does not grant access by itself. It only narrows the authorized results. ## Fetch document content Use the content endpoint when your app needs one whole document: ```js const params = new URLSearchParams({ username: "alice", doc_path: "journal", }); const response = await fetch( `https://meridiem.markwhen.com/api/v1/aggregate/content?${params}`, { headers: { authorization: `Basic ${basicAuth}`, }, }, ); const doc = await response.json(); ``` The response includes `content`, `header`, `doc_type`, `doc_version`, `updated_at`, `doc_id`, and `doc_path`. ## Resolve paths Document paths can change. If your app stores long-lived references, resolve a user-facing path to stable identity: ```js const params = new URLSearchParams({ username: "alice", doc_path: "projects/roadmap", }); const response = await fetch( `https://meridiem.markwhen.com/api/v1/aggregate/documents/resolve?${params}`, { headers: { authorization: `Basic ${basicAuth}`, }, }, ); const { uid, doc_id, canonical_doc_path } = await response.json(); ``` Later, ask for the current path: ```js const params = new URLSearchParams({ uid, doc_id }); const response = await fetch( `https://meridiem.markwhen.com/api/v1/aggregate/documents/canonical?${params}`, { headers: { authorization: `Basic ${basicAuth}`, }, }, ); const { doc_path } = await response.json(); ``` ## Practical checklist * Keep aggregate calls on your server. * Request the smallest scopes your app needs. * Treat `docs` as a filter, not an authorization mechanism. * Store `doc_id` for stable references. * Use `doc_path` and `canonical_url` for user-facing links. * Use `header_filter` for document-level opt-in. * Use `property_filter` for entry-level workflow state. * Use [webhooks](/meridiem/api/webhooks) when your aggregate view needs to stay fresh. For exact request and response shapes, see the [API reference](/meridiem/api/api#aggregate-queries). --- --- url: /meridiem/api/mcp.md --- # MCP Meridiem exposes an authenticated [Model Context Protocol](https://modelcontextprotocol.io/) server for agents that can read and write a user's Meridiem documents. ```txt https://meridiem.markwhen.com/_mcp ``` The MCP server uses OAuth. A client starts with the MCP URL, discovers the protected resource metadata, sends the user through Meridiem authorization, and then calls MCP tools with the returned bearer token. ## Connect Codex Codex can connect to Meridiem using Meridiem's hosted Codex client metadata document: ```bash codex mcp add meridiem \ --url https://meridiem.markwhen.com/_mcp \ --oauth-resource https://meridiem.markwhen.com/_mcp \ --oauth-client-id https://meridiem.markwhen.com/.well-known/oauth-client/codex.json ``` Then authenticate: ```bash codex mcp login meridiem --scopes 'openid,docs.read:*,docs.write:*' ``` Meridiem will ask the user to approve the requested document access. ## Connect Claude Code Claude Code can connect to Meridiem as a remote HTTP MCP server: ```bash claude mcp add --transport http meridiem https://meridiem.markwhen.com/_mcp ``` Then open Claude Code and run: ```txt /mcp ``` Choose the Meridiem server and follow the browser login flow. Claude Code supports OAuth Client ID Metadata Documents, so it can use Meridiem's OAuth discovery metadata without a manually created Meridiem app. If you want to restrict the requested scopes, add the server from JSON configuration: ```bash claude mcp add-json meridiem \ '{"type":"http","url":"https://meridiem.markwhen.com/_mcp","oauth":{"scopes":"openid docs.read:* docs.write:*"}}' ``` Claude Code uses a loopback callback URL during OAuth. Meridiem accepts loopback redirect URIs with OS-assigned ports for metadata-document clients. ## Discovery Meridiem publishes OAuth discovery documents: | Document | URL | | -------- | --- | | MCP protected resource metadata | `https://meridiem.markwhen.com/.well-known/oauth-protected-resource/_mcp` | | OAuth authorization server metadata | `https://meridiem.markwhen.com/.well-known/oauth-authorization-server` | | Codex client metadata | `https://meridiem.markwhen.com/.well-known/oauth-client/codex.json` | The protected resource metadata advertises the MCP resource: ```json { "resource": "https://meridiem.markwhen.com/_mcp", "authorization_servers": ["https://meridiem.markwhen.com"], "scopes_supported": ["docs.read:*", "docs.write:*"], "bearer_methods_supported": ["header"] } ``` ## Client metadata documents Meridiem supports OAuth Client ID Metadata Documents. In this flow, the OAuth `client_id` is an HTTPS URL that serves a JSON metadata document for the client. Third-party clients normally host their own metadata document. Meridiem fetches it, validates it, and uses it as the client's registration. Meridiem only hosts the Codex metadata document as a convenience for users who want a ready-to-copy setup. A public client metadata document looks like this: ```json { "client_id": "https://agent.example/.well-known/oauth-client.json", "client_name": "Example Agent", "client_uri": "https://agent.example", "redirect_uris": [ "http://127.0.0.1/callback", "http://127.0.0.1/callback/*", "http://localhost/callback", "http://localhost/callback/*" ], "token_endpoint_auth_method": "none" } ``` Requirements: * `client_id` must exactly match the metadata document URL. * `client_name` is shown to the user on the authorization screen. * `redirect_uris` must include every redirect URI shape the client may use. * Public clients must use `token_endpoint_auth_method: "none"` and PKCE with `S256`. For loopback redirect URIs, Meridiem follows the native-app OAuth pattern: a registered loopback URI without a port can match the same loopback host with an OS-assigned port at authorization time. For example, registering `http://localhost/callback` allows a runtime redirect like `http://localhost:61264/callback`. ## Authorization parameters MCP clients should include: | Parameter | Value | | --------- | ----- | | `response_type` | `code` | | `client_id` | The client metadata document URL | | `redirect_uri` | One of the client's registered redirect URI shapes | | `scope` | Usually `openid docs.read:* docs.write:*` | | `resource` | `https://meridiem.markwhen.com/_mcp` | | `code_challenge_method` | `S256` | The token exchange happens at: ```txt https://meridiem.markwhen.com/oauth/token ``` Tokens issued for the MCP resource are audience-bound to `https://meridiem.markwhen.com/_mcp`. --- --- url: /meridiem/api/schema-guide.md --- # Schema Guide Meridiem apps work best when the source documents stay pleasant to read. A good schema should help your app without turning a markwhen document into a database dump. Use this guide when deciding what belongs in the header, what belongs on an event, and what should remain plain prose. ## The short version | Put data here | When it describes | | ------------- | ----------------- | | Header | The whole document, app settings, defaults, display metadata | | Event properties | One dated entry, task, post, update, observation, or record | | Event description | Human-readable detail about that entry | | Markdown document | A long-form page, essay, note, or reference that is not naturally event-shaped | | External system | Large binary data, private secrets, high-volume logs, or data users should not edit directly | If a human opens the document in Meridiem, they should be able to understand what your app is doing. ## Namespacing Put app-specific header data under a namespace. Use a short lowercase key that belongs to your app. ```mw --- mealplanner: enabled: true defaultMeal: dinner shoppingList: true timezone: America/Los_Angeles --- ``` Do not put lots of unrelated top-level keys in the header: ```mw --- enabled: true defaultMeal: dinner shoppingList: true --- ``` Namespacing makes it possible for multiple apps to use the same document. ## Header fields Header fields are best for document-level state: ```mw --- publisher: site: rob-notes title: Rob's notes theme: simple defaultStatus: draft timezone: America/Los_Angeles --- ``` Good header fields: * Are stable across many entries. * Describe how your app should treat the whole document. * Are safe for users to edit by hand. * Have clear defaults when missing. Use header fields for: * App opt-in: `enabled`, `view`, `sync`. * Display: `title`, `description`, `avatar`, `theme`. * Defaults: `defaultStatus`, `defaultProject`, `timezone`. * Integrations: `webhookEnabled`, `externalProjectId`. ## Event properties Event properties are best for structured data about one entry: ```mw 2026-08-04: Call with Acme type: call account: acme sentiment: positive next: send proposal They want a smaller pilot before committing to the annual plan. ``` Good event properties: * Use simple names. * Are consistent across entries. * Are useful for filtering, sorting, grouping, or rendering. * Do not duplicate the entire event description. Use event properties for: * Status: `draft`, `published`, `open`, `done`. * Category: `type`, `project`, `area`, `kind`. * Ownership: `owner`, `assignee`, `account`. * App behavior: `remind`, `channel`, `pinned`. * Display hints: `image`, `slug`, `summary`. ## Event descriptions Use the description for detail meant to be read by a person: ```mw 2026-08-04: Call with Acme type: call account: acme They asked for a smaller pilot. Follow up with a one-page proposal and pricing for 20 seats. ``` Descriptions are the right place for context, notes, links, paragraphs, and messy human nuance. If your app turns every sentence into a property, the document gets hard to write. ## Markdown or markwhen? Use markwhen when dates are central: ```mw 2026-09-01: Draft published status: published slug: launch-notes ``` Use markdown when the document is a page: ```md # Launch notes This page explains what changed, why it matters, and where to start. ``` A publishing app might use both: * Markwhen for posts, changelog entries, and scheduled drafts. * Markdown for about pages, documentation pages, and evergreen essays. ## Stable names Pick property names that you can live with. Prefer: ```mw 2026-10-01: Send renewal note status: open priority: high owner: Maya ``` Avoid: ```mw 2026-10-01: Send renewal note Status: Open p: 1 assigned_to_user_visible_display_name: Maya ``` Rules of thumb: * Use lowercase property names. * Prefer short words over abbreviations. * Use the same value vocabulary everywhere. * Avoid renaming fields casually. * Treat user-written documents as long-lived data. ## Public and private fields Assume users may read and edit their documents directly. Do not store app secrets in the header or event properties. Good: ```mw --- publisher: site: rob-notes sync: true --- ``` Bad: ```mw --- publisher: apiSecret: sec_... --- ``` If your app needs secrets, store them in your own backend. ## Generated fields Sometimes an app needs IDs or generated metadata. Keep those fields obvious and minimal: ```mw 2026-11-12: Contract signed account: acme appId: deal_123 ``` Generated fields should not dominate the document. If you need a large generated payload, store it elsewhere and keep only a readable reference in markwhen. ## Evolving a schema Schemas change. Plan for it. Start with versioned header metadata: ```mw --- crm: enabled: true schemaVersion: 1 --- ``` When your app reads older documents, migrate gently: ::: code-group ```ts [TypeScript] type CrmHeader = { enabled?: boolean; schemaVersion?: number; owner?: string; }; function normalizeCrmHeader(header: { crm?: CrmHeader }) { const crm = header.crm || {}; return { enabled: crm.enabled === true, schemaVersion: crm.schemaVersion || 1, owner: crm.owner || "unassigned", }; } ``` ```python [Python] def normalize_crm_header(header): crm = header.get("crm") or {} return { "enabled": crm.get("enabled") is True, "schemaVersion": crm.get("schemaVersion") or 1, "owner": crm.get("owner") or "unassigned", } ``` ```java [Java] record CrmSettings(boolean enabled, int schemaVersion, String owner) {} CrmSettings normalizeCrmHeader(Map<String, Object> header) { Map<String, Object> crm = (Map<String, Object>) header.getOrDefault( "crm", Map.of() ); return new CrmSettings( Boolean.TRUE.equals(crm.get("enabled")), crm.get("schemaVersion") instanceof Number n ? n.intValue() : 1, crm.get("owner") instanceof String owner ? owner : "unassigned" ); } ``` ::: When possible, support old field names for a while instead of breaking users immediately. ## Reading properties The entries endpoint returns structured entry rows. Your app can filter by properties on the server: ::: code-group ```ts [TypeScript] const entries = await fetch( `https://meridiem.markwhen.com/api/v1/docs/${username}/doc/${docName}/entries`, { method: "POST", headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json", }, body: JSON.stringify({ property_filter: { status: "published", }, }), } ).then((response) => response.json()); ``` ```js [JavaScript] const entries = await meridiemFetch( `/api/v1/docs/${username}/doc/${docName}/entries`, { method: "POST", body: JSON.stringify({ property_filter: { status: "published" }, }), } ); ``` ```python [Python] entries = requests.post( f"https://meridiem.markwhen.com/api/v1/docs/{username}/doc/{doc_name}/entries", headers={"authorization": f"Bearer {access_token}"}, json={"property_filter": {"status": "published"}}, timeout=20, ).json() ``` ::: ## Updating header fields Use the header endpoint when changing document-level app settings: ::: code-group ```ts [TypeScript] await fetch( `https://meridiem.markwhen.com/api/v1/docs/${username}/doc/${docName}/header`, { method: "PATCH", headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json", }, body: JSON.stringify({ merge: { publisher: { defaultStatus: "published", }, }, }), } ); ``` ```python [Python] requests.patch( f"https://meridiem.markwhen.com/api/v1/docs/{username}/doc/{doc_name}/header", headers={"authorization": f"Bearer {access_token}"}, json={ "merge": { "publisher": { "defaultStatus": "published", } } }, timeout=20, ) ``` ::: Prefer `merge` for app settings. Use `set` only when replacing a value is definitely what the user expects. ## What not to do Avoid giant JSON blobs: ```mw 2026-12-01: Imported item payload: {"very":"large","deeply":{"nested":"object"}} ``` Avoid unreadable encoded state: ```mw 2026-12-01: Imported item state: eyJ2ZXJ5IjoibGFyZ2UifQ ``` Avoid making every user-visible word generated and fragile: ```mw 2026-12-01: {{generated_title_123}} renderMode: card_v4_final ``` Better: ```mw 2026-12-01: Imported invoice from Acme source: acme externalId: inv_123 status: pending ``` ## App schema examples ### Feed ```mw --- feedapp: enabled: true title: Workshop notes description: Notes from the bench --- 2026-03-01: Shipped the prototype status: published tags: product,prototype ``` ### CRM ```mw --- crm: account: acme owner: Maya stage: pilot --- 2026-04-12: Follow-up call type: call sentiment: positive next: send proposal ``` ### Journal ```mw --- journal: enabled: true prompts: evening --- 2026-05-02 21:10: Evening check-in mood: calm energy: low ``` ### Publishing ```mw --- publisher: site: rob-notes defaultStatus: draft --- 2026-07-01: Building with Meridiem status: published slug: building-with-meridiem ``` ## Agent schema prompt ```txt Design a Meridiem schema for this app. Return: - The app namespace to use in the document header. - Header fields, their types, defaults, and whether users can edit them. - Event properties, their allowed values, and examples. - Which data should be human prose in event descriptions. - Which data should be markdown documents instead of markwhen events. - What data should not be stored in Meridiem. - A migration plan with schemaVersion. - Three example markwhen documents using the schema. Keep the generated markwhen readable to a person editing it directly. ``` --- --- url: /meridiem/api/troubleshooting.md --- # OAuth Troubleshooting Most Meridiem app problems are one of five things: * The callback URL does not exactly match. * The authorization code was exchanged with the wrong PKCE verifier or app secret. * The app has an access token but not the scopes it needs. * The user can access Meridiem, but not the specific document. * The app is using a document `doc_id` where the API expects a document name/path, or the other way around. Start by logging the request URL, response status, and response body from every OAuth and API call. A small amount of boring logging saves a heroic amount of guessing. ## Callback URL mismatch The `redirect_uri` must match one of the app's registered redirect URIs exactly. Scheme, host, port, path, and trailing slashes all matter. These are different: ```txt http://localhost:3000/auth/callback http://localhost:3000/auth/callback/ http://127.0.0.1:3000/auth/callback https://localhost:3000/auth/callback ``` The same `redirect_uri` must be used in both places: 1. The `/authorize` URL. 2. The `/oauth/token` authorization-code exchange. ::: code-group ```ts [TypeScript] const redirectUri = "http://localhost:3000/auth/callback"; const authorizeUrl = new URL("https://meridiem.markwhen.com/authorize"); authorizeUrl.searchParams.set("client_id", appId); authorizeUrl.searchParams.set("response_type", "code"); authorizeUrl.searchParams.set("redirect_uri", redirectUri); authorizeUrl.searchParams.set("scope", "openid docs.read:*"); authorizeUrl.searchParams.set("state", state); // Later, in the callback: await fetch("https://meridiem.markwhen.com/oauth/token", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ grant_type: "authorization_code", client_id: appId, redirect_uri: redirectUri, code, code_verifier: codeVerifier, }), }); ``` ```python [Python] redirect_uri = "http://localhost:3000/auth/callback" token_response = requests.post( "https://meridiem.markwhen.com/oauth/token", json={ "grant_type": "authorization_code", "client_id": app_id, "redirect_uri": redirect_uri, "code": code, "code_verifier": code_verifier, }, timeout=20, ) ``` ::: ## State mismatch Use `state` to connect the callback to the login attempt you started. Generate it before redirecting to Meridiem, store it in a session or local storage, and compare it when the user returns. ```ts const expectedState = sessionStorage.getItem("meridiem_oauth_state"); const callbackUrl = new URL(window.location.href); const returnedState = callbackUrl.searchParams.get("state"); if (!expectedState || returnedState !== expectedState) { throw new Error("OAuth state mismatch"); } ``` If state does not match, do not exchange the code. ## PKCE problems PKCE has two values: | Value | Sent when | Meaning | | ----- | --------- | ------- | | `code_challenge` | `/authorize` | A transformed version of the verifier | | `code_verifier` | `/oauth/token` | The original secret string | For `S256`, the challenge is: ```txt base64url(sha256(code_verifier)) ``` ::: code-group ```ts [TypeScript] function base64Url(bytes: ArrayBuffer) { return btoa(String.fromCharCode(...new Uint8Array(bytes))) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/g, ""); } export async function createPkce() { const verifierBytes = crypto.getRandomValues(new Uint8Array(32)); const codeVerifier = base64Url(verifierBytes); const digest = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(codeVerifier) ); return { codeVerifier, codeChallenge: base64Url(digest), codeChallengeMethod: "S256", }; } ``` ```js [Node.js] import crypto from "node:crypto"; const codeVerifier = crypto.randomBytes(32).toString("base64url"); const codeChallenge = crypto .createHash("sha256") .update(codeVerifier) .digest("base64url"); ``` ```python [Python] import base64 import hashlib import secrets def b64url(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") code_verifier = b64url(secrets.token_bytes(32)) code_challenge = b64url(hashlib.sha256(code_verifier.encode("ascii")).digest()) ``` ::: Common PKCE mistakes: * Sending `code_challenge` again during the token exchange instead of `code_verifier`. * Recomputing a new verifier in the callback instead of storing the original verifier. * Adding `=` padding to the base64url value. * Sending `code_challenge_method=plain` when the challenge was created with `S256`. ## Token exchange examples ::: code-group ```ts [TypeScript] type MeridiemTokenResponse = { access_token: string; refresh_token: string; exp: string; scopes: string[]; token_type: "Bearer"; id_token?: string; }; export async function exchangeCode(params: { appId: string; code: string; redirectUri: string; codeVerifier: string; }) { const response = await fetch("https://meridiem.markwhen.com/oauth/token", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ grant_type: "authorization_code", client_id: params.appId, redirect_uri: params.redirectUri, code: params.code, code_verifier: params.codeVerifier, }), }); if (!response.ok) { throw new Error(await response.text()); } return (await response.json()) as MeridiemTokenResponse; } ``` ```js [JavaScript] async function exchangeCode({ appId, code, redirectUri, codeVerifier }) { const response = await fetch("https://meridiem.markwhen.com/oauth/token", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ grant_type: "authorization_code", client_id: appId, redirect_uri: redirectUri, code, code_verifier: codeVerifier, }), }); if (!response.ok) { throw new Error(await response.text()); } return response.json(); } ``` ```python [Python] import requests def exchange_code(app_id, code, redirect_uri, code_verifier): response = requests.post( "https://meridiem.markwhen.com/oauth/token", json={ "grant_type": "authorization_code", "client_id": app_id, "redirect_uri": redirect_uri, "code": code, "code_verifier": code_verifier, }, timeout=20, ) response.raise_for_status() return response.json() ``` ```java [Java] HttpClient client = HttpClient.newHttpClient(); String body = """ { "grant_type": "authorization_code", "client_id": "%s", "redirect_uri": "%s", "code": "%s", "code_verifier": "%s" } """.formatted(appId, redirectUri, code, codeVerifier); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://meridiem.markwhen.com/oauth/token")) .header("content-type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() < 200 || response.statusCode() >= 300) { throw new RuntimeException(response.body()); } ``` ::: ## Refresh and retry once When an API request returns `401` or `405`, refresh the access token and retry the original request once. ::: code-group ```ts [TypeScript] async function refreshMeridiemToken(refreshToken: string) { const response = await fetch("https://meridiem.markwhen.com/oauth/token", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ grant_type: "refresh_token", refresh_token: refreshToken, }), }); if (!response.ok) { throw new Error(await response.text()); } return response.json(); } async function meridiemFetch(path: string, options: RequestInit = {}) { let response = await fetch(`https://meridiem.markwhen.com${path}`, { ...options, headers: { ...options.headers, authorization: `Bearer ${tokens.access_token}`, }, }); if (response.status === 401 || response.status === 405) { tokens = await refreshMeridiemToken(tokens.refresh_token); response = await fetch(`https://meridiem.markwhen.com${path}`, { ...options, headers: { ...options.headers, authorization: `Bearer ${tokens.access_token}`, }, }); } return response; } ``` ```python [Python] def refresh_meridiem_token(refresh_token): response = requests.post( "https://meridiem.markwhen.com/oauth/token", json={ "grant_type": "refresh_token", "refresh_token": refresh_token, }, timeout=20, ) response.raise_for_status() return response.json() ``` ::: Always store the new `refresh_token` returned by the refresh call. Refresh tokens rotate. ## Status codes | Status | Usually means | | ------ | ------------- | | `400` | Missing parameter, unsupported response type, invalid PKCE verifier, expired/reused authorization code, or redirect URI mismatch | | `401` | Missing token, missing email claim, expired refresh token, or not enough write permission in some endpoints | | `403` | Token exists but app/user is not allowed to perform the action | | `405` | Access token is expired and should be refreshed | | `406` | Version mismatch while writing a document | | `409` | Creating a document that already exists | When debugging, log the response body too. Meridiem often includes an `error` or `error_description`. ## Scope and document access checklist If your app can list documents but cannot read or write one document, check these in order: 1. Did the user grant `docs.read:*`, `docs.write:*`, or the document-specific scope? 2. Are you using the document name/path in the URL? 3. Does the document still exist under that user? 4. Is the current user the owner, an editor, or a viewer for that document? 5. For writes, does the user have edit access, not just view access? Remember that OAuth scopes and document sharing are both enforced. The app needs permission, and the user needs access. ## Document path vs doc\_id The document API URL uses the user-facing document name/path: ```txt /api/v1/docs/:user/doc/:docName ``` List documents first and keep both values: ```ts const { docs } = await meridiemFetch("/api/v1/docs").then((r) => r.json()); for (const doc of docs) { console.log(doc.path); // username/docName console.log(doc.doc_id); // immutable storage identity } ``` Use `doc.path` when building user-facing links. Use `doc_id` when you need stable identity across renames. ## Supabase or OIDC integrations If your auth provider supports generic OIDC, configure Meridiem as the provider: | Setting | Value | | ------- | ----- | | Authorization URL | `https://meridiem.markwhen.com/authorize` | | Token URL | `https://meridiem.markwhen.com/oauth/token` | | Userinfo URL | `https://meridiem.markwhen.com/oauth/userinfo` | | Scopes | `openid docs.read:*` or the scopes your app needs | Remark.ing uses this style of integration: it asks Supabase to start OAuth, redirects through Meridiem, stores the returned provider tokens, and refreshes Meridiem tokens when API calls expire. ## Agent debugging prompt ```txt Debug this Meridiem OAuth integration. Check: - The registered redirect URI exactly matches the authorize and token exchange redirect_uri. - The state value is generated before authorization and verified on callback. - The code_verifier is the original verifier for the code_challenge sent to /authorize. - The authorization code is exchanged only once. - The app stores the newest access_token and refresh_token. - API calls retry once after refreshing on 401 or 405. - The requested scopes include the operation being attempted. - The document URL uses :user/doc/:docName, not an arbitrary doc_id unless that is also the document path. - The user has document-level view or edit access. Return the failing request, response status, response body, likely cause, and smallest code change. ``` --- --- url: /meridiem/api/api.md --- # Meridiem API Base URL: `https://meridiem.markwhen.com` Most `/api/v1/*` endpoints require an OAuth access token. See the [OAuth quickstart](/meridiem/api/oauth) for the authorization flow. Aggregate query endpoints use app credentials with Basic auth instead. Most document endpoints use a user-facing document name or path in the URL: ```txt /api/v1/docs/:user/doc/:docName ``` Responses include the immutable `doc_id` when you need a stable identity. ## Get user \<template #description> Get user info (email and username) for the user that is using your app. #### Returns ```ts { "email": string | undefined, "username": string | undefined } ``` | JSON Field | Type | Notes | | ---------- | --------------------- | ----- | | `email` | `string \| undefined` | | | `username` | `string \| undefined` | | #### Required scopes | Required scope | | -------------- | | `openid` | #### Headers | Header | Value | | --------------- | ---------------------------------- | | `Authorization` | `Bearer {your app's access_token}` | #### Query Parameters *None* #### Body *None* #### Examples ::: code-group ```js [JS] const accessToken = ""; // Your app's access token const result = await fetch("https://meridiem.markwhen.com/api/v1/user", { headers: { authorization: `Bearer ${accessToken}`, }, }); if (result.ok) { const userInfo = await result.json(); console.log(userInfo.email); console.log(userInfo.username); } ``` ::: ## List documents \<template #description> List user's documents. Note that this will return the list of documents that the user has granted your app to access, not necessarily all the user's documents. #### Returns ```ts { "docs": { "uid": string, "doc_id": string, "updated_at": string | null, "header": any, "path": string, "pathEncoded": string }[] } ``` | JSON Field | Type | Notes | | ------------- | ---------------- | ----------------------------------------------------- | | `uid` | `string` | User ID of the document owner | | `doc_id` | `string` | Document ID | | `updated_at` | `string \| null` | Timestamp when the document was last updated | | `header` | `any` | Document header metadata | | `path` | `string` | Path to the document in the format `username/docName` | | `pathEncoded` | `string` | Encoded path for URLs | #### Required scopes | Required scope | | -------------------------------------- | | `docs.read:*` or `docs.read:{docName}` | #### Headers | Header | Value | | --------------- | ---------------------------------- | | `Authorization` | `Bearer {your app's access_token}` | #### Query Parameters *None* #### Body *None* #### Examples ::: code-group ```js [JS] const accessToken = ""; // Your app's access token const result = await fetch("https://meridiem.markwhen.com/api/v1/docs", { headers: { authorization: `Bearer ${accessToken}`, }, }); if (result.ok) { const { docs } = await result.json(); console.log(docs); } ``` ::: ## Get document \<TryIt method="GET" path="/api/v1/docs/:user/doc/:docName" scope="docs.read:\*" :path-params="\[ { name: 'user', label: 'User', placeholder: 'alice' }, { name: 'docName', label: 'Document', placeholder: 'journal' } ]" > \<template #description> Get the content of a specific document. #### Returns The document content and parsed markwhen: ```ts { version: number; text: string; parsed: any; } ``` #### Required scopes | Required scope | | -------------------------------------- | | `docs.read:*` or `docs.read:{docName}` | #### Headers | Header | Value | | --------------- | ---------------------------------- | | `Authorization` | `Bearer {your app's access_token}` | #### Query Parameters *None* #### Body *None* #### Examples ::: code-group ```js [JS] const accessToken = ""; // Your app's access token const username = "username"; // Document owner's username const docName = "journal"; // Document name/path const result = await fetch( `https://meridiem.markwhen.com/api/v1/docs/${username}/doc/${docName}`, { headers: { authorization: `Bearer ${accessToken}`, }, }, ); if (result.ok) { const { text, parsed, version } = await result.json(); console.log(text, parsed, version); } ``` ::: ## Create new document ### `POST /api/v1/docs/:user/doc/:docName` Create a new document with the specified name/path. Meridiem generates and returns the immutable `doc_id`. #### Returns Status code 202 if successful. #### Required scopes | Required scope | | -------------- | | `docs.write:*` | #### Headers | Header | Value | | --------------- | ---------------------------------- | | `Authorization` | `Bearer {your app's access_token}` | | `Content-Type` | `application/json` | #### Query Parameters *None* #### Body ```ts { "text": string, "timezone": string } ``` | JSON Field | Type | Notes | | ---------- | -------- | -------------------------------------------------------------------------------- | | `text` | `string` | The initial content of the document in markwhen format | | `timezone` | `string` | The timezone to use for datetime formatting (e.g., "utc", "America/Los\_Angeles") | #### Examples ::: code-group ```js [JS] const accessToken = ""; // Your app's access token const username = "username"; // Document owner's username const docName = "garden"; // New document name/path const result = await fetch( `https://meridiem.markwhen.com/api/v1/docs/${username}/doc/${docName}`, { method: "POST", headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json", }, body: JSON.stringify({ text: "title: My New Document\n\n2023-01-01: Created a new document", timezone: "America/New_York", }), }, ); if (result.ok) { const { doc_id, name } = await result.json(); console.log("Document created successfully", doc_id, name); } ``` ::: ## Append to document ### `PATCH /api/v1/docs/:user/doc/:docName` Append content to an existing document. Note that this will add to the end of the document if the document is "sorted" from oldest to newest (top to bottom), otherwise it will add new entries to the top. Technically it just looks at the top two events (if there are any) and if the first one is newer than the second one, appends to the top of the document (after the header). It does not insert events in sorted order. In fact, this method does not assume that a new event is even being inserted necessarily. One could, for example, insert the text `Hello world` which would presumably just be added to the event description of the last event. That being said, if you do want to insert a new event, be sure that it is a [valid markwhen event format](/syntax/). To add an event that corresponds to "now," or, when this API call is made, you may optionally include a date time interpolation token in the text to be added - the server will automatically convert it to the format specified. The token format is `dt` followed by a date time format string surrounded by curly brackets: | Input string | Interpolated value | | ------------------------------------------- | ----------------------------------- | | `dt{yyyy}` | `2025` | | `this month (dt{MMMM})` | `this month (August)` | | `It's been dt{HH 'hours and' mm 'minutes'}` | `It's been 20 hours and 55 minutes` | For example, `"dt{yyyy-MM-dd}: Hello world!` will be interpolated to `2025-06-01: Hello world!` (or whatever today's date is when the request is made). [Read more about date time formatting tokens.](https://moment.github.io/luxon/#/formatting?id=table-of-tokens) #### Returns Status code 202 if successful, with a JSON response containing any new URLs added: ```ts { "added": string[] } ``` #### Required scopes | Required scope | | ---------------------------------------- | | `docs.write:*` or `docs.write:{docName}` | #### Headers | Header | Value | | --------------- | ---------------------------------- | | `Authorization` | `Bearer {your app's access_token}` | | `Content-Type` | `application/json` | #### Query Parameters *None* #### Body ```ts { "edits": { "text": string, "timezone"?: string, "from"?: number, "to"?: number, "length"?: number, "version"?: number }[] } ``` | JSON Field | Type | Notes | | ---------- | ------- | ------------------------------------------------------------------------------------------------------------ | | `edits` | `array` | One or more edits to apply. Without `from`, Meridiem appends near the top or bottom based on document order. | #### Examples ::: code-group ```js [JS] const accessToken = ""; // Your app's access token const username = "username"; // Document owner's username const docName = "journal"; // Document name/path const result = await fetch( `https://meridiem.markwhen.com/api/v1/docs/${username}/doc/${docName}`, { method: "PATCH", headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json", }, body: JSON.stringify({ edits: [ { text: "dt{yyyy-MM-dd}: New entry added via API", timezone: "America/New_York", }, ], }), }, ); if (result.ok) { const { added } = await result.json(); console.log("New URLs added:", added); } ``` ::: ## Delete document ### `DELETE /api/v1/docs/:user/doc/:docName` Delete a document. #### Returns Status code 200 if successful. #### Required scopes | Required scope | | ---------------------------------------- | | `docs.write:*` or `docs.write:{docName}` | #### Headers | Header | Value | | --------------- | ---------------------------------- | | `Authorization` | `Bearer {your app's access_token}` | #### Query Parameters *None* #### Body *None* #### Examples ::: code-group ```js [JS] const accessToken = ""; // Your app's access token const username = "username"; // Document owner's username const docName = "journal"; // Document name/path const result = await fetch( `https://meridiem.markwhen.com/api/v1/docs/${username}/doc/${docName}`, { method: "DELETE", headers: { authorization: `Bearer ${accessToken}`, }, }, ); if (result.ok) { console.log("Document deleted successfully"); } ``` ::: ## Get document metadata ### `GET /api/v1/docs/:user/doc/:docName/metadata` Get metadata for a specific document. #### Returns ```ts { "uid": string, "doc_id": string, "updated_at": string | null, "header": any, // Additional document metadata fields } ``` #### Required scopes | Required scope | | -------------------------------------- | | `docs.read:*` or `docs.read:{docName}` | #### Headers | Header | Value | | --------------- | ---------------------------------- | | `Authorization` | `Bearer {your app's access_token}` | #### Query Parameters *None* #### Body *None* #### Examples ::: code-group ```js [JS] const accessToken = ""; // Your app's access token const username = "username"; // Document owner's username const docName = "journal"; // Document name/path const result = await fetch( `https://meridiem.markwhen.com/api/v1/docs/${username}/doc/${docName}/metadata`, { headers: { authorization: `Bearer ${accessToken}`, }, }, ); if (result.ok) { const metadata = await result.json(); console.log(metadata); } ``` ::: ## Amend document header ### `PATCH /api/v1/docs/:user/doc/:docName/header` Update a document's header. #### Returns Status code 200 if successful. #### Required scopes | Required scope | | ---------------------------------------- | | `docs.write:*` or `docs.write:{docName}` | #### Headers | Header | Value | | --------------- | ---------------------------------- | | `Authorization` | `Bearer {your app's access_token}` | | `Content-Type` | `application/json` | #### Query Parameters *None* #### Body ```ts { "set"?: object, "merge"?: object } ``` | JSON Field | Type | Notes | | ---------- | -------- | ------------------------------------------- | | `set` | `object` | Replace header values with these values | | `merge` | `object` | Merge these values into the existing header | #### Examples ::: code-group ```js [JS] const accessToken = ""; // Your app's access token const username = "username"; // Document owner's username const docName = "journal"; // Document name/path const result = await fetch( `https://meridiem.markwhen.com/api/v1/docs/${username}/doc/${docName}/header`, { method: "PATCH", headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json", }, body: JSON.stringify({ merge: { title: "Updated Document Title", }, }), }, ); if (result.ok) { console.log("Document header updated successfully"); } ``` ::: ## Get all entries (events) from a document ### `POST /api/v1/docs/:user/doc/:docName/entries` Get all entries from a document, optionally filtered by properties. #### Returns ```ts [ { uid: string, doc_id: string, url: string, from_ts: number, to_ts: number, content: string, // Additional entry fields }, ]; ``` #### Required scopes | Required scope | | -------------------------------------- | | `docs.read:*` or `docs.read:{docName}` | #### Headers | Header | Value | | --------------- | ---------------------------------- | | `Authorization` | `Bearer {your app's access_token}` | #### Query Parameters *None* #### Body ```ts { "property_filter"?: object, "property_exclude"?: object } ``` #### Examples ::: code-group ```js [JS] const accessToken = ""; // Your app's access token const username = "username"; // Document owner's username const docName = "journal"; // Document name/path const result = await fetch( `https://meridiem.markwhen.com/api/v1/docs/${username}/doc/${docName}/entries`, { method: "POST", headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json", }, body: JSON.stringify({}), }, ); if (result.ok) { const entries = await result.json(); console.log(entries); } ``` ::: ## Get entry by url ### `GET /api/v1/docs/:user/doc/:docName/entries/:entry_url` Get a specific entry from a document by its URL. #### Returns ```ts { "uid": string, "doc_id": string, "url": string, "from_ts": number, "to_ts": number, "content": string, "documents": { "header": any }, "users": { "username": string } // Additional entry fields } ``` #### Required scopes | Required scope | | -------------------------------------- | | `docs.read:*` or `docs.read:{docName}` | #### Headers | Header | Value | | --------------- | ---------------------------------- | | `Authorization` | `Bearer {your app's access_token}` | #### Query Parameters *None* #### Body *None* #### Examples ::: code-group ```js [JS] const accessToken = ""; // Your app's access token const username = "username"; // Document owner's username const docName = "journal"; // Document name/path const entryUrl = "entry-url"; // Entry URL const result = await fetch( `https://meridiem.markwhen.com/api/v1/docs/${username}/doc/${docName}/entries/${entryUrl}`, { headers: { authorization: `Bearer ${accessToken}`, }, }, ); if (result.ok) { const entry = await result.json(); console.log(entry); } ``` ::: ## Aggregate queries Aggregate endpoints let server-side apps query metadata, entries, and document content across the documents the app is authorized to read. They are useful for feeds, search, dashboards, notification jobs, and apps that need to combine entries from multiple documents. For a fuller walkthrough and examples, see [Aggregate queries](/meridiem/api/aggregate-queries). Unlike the document endpoints above, aggregate endpoints authenticate with the app's `app_id` and `app_secret`: ```js const basicAuth = btoa(`${appId}:${appSecret}`); ``` Use the header: | Header | Value | | --------------- | -------------------------------------- | | `Authorization` | `Basic ${basicAuth}` | | `Content-Type` | `application/json` for `POST` requests | Aggregate queries return only resources matching the app's authorized scopes. `docs.read:*` or `docs.write:*` can read all documents a user granted to the app. Document-specific scopes like `docs.read:{docName}` or `docs.read:{doc_id}` read only matching documents. The optional `docs` field narrows the result set; it is not required for document-specific scopes to work. `docs` values use `username/docName` or `username/path/to/doc`. A bare username such as `alice` means documents owned by that user. Responses include `doc_id` for stable identity and `doc_path` for the current user-facing path when available. ### `POST /api/v1/aggregate/metadata` Query document metadata across authorized documents. #### Body ```ts { docs?: string[]; limit?: number; offset?: number; header_filter?: object[]; header_exclude?: object[]; } ``` | JSON Field | Type | Notes | | ---------------- | ---------- | ------------------------------------------------------------------------------ | | `docs` | `string[]` | Optional document/user filters, for example `["alice/journal"]` or `["alice"]` | | `limit` | `number` | Defaults to `40`; maximum `100` | | `offset` | `number` | Defaults to `0` | | `header_filter` | `object[]` | Return docs whose header contains at least one object | | `header_exclude` | `object[]` | Exclude docs whose header contains any object | #### Returns ```ts { uid: string; username: string; doc_id: string; doc_version: number; header: any; updated_at: string; doc_path: string; canonical_url?: string; }[] ``` #### Example ::: code-group ```js [JS] const basicAuth = btoa(`${appId}:${appSecret}`); const response = await fetch( "https://meridiem.markwhen.com/api/v1/aggregate/metadata", { method: "POST", headers: { authorization: `Basic ${basicAuth}`, "content-type": "application/json", }, body: JSON.stringify({ docs: ["alice"], header_filter: [{ remarking: { view: "*" } }], limit: 20, }), }, ); const docs = await response.json(); ``` ::: ### `POST /api/v1/aggregate/entries` Query entries across authorized documents. #### Body ```ts { docs?: string[]; limit?: number; offset?: number; header_filter?: object[]; header_exclude?: object[]; property_filter?: object[]; property_exclude?: object[]; merge_with?: string[]; from_ts_gte?: string; from_ts_lte?: string; } ``` | JSON Field | Type | Notes | | ------------------ | ---------- | ------------------------------------------------------------------ | | `docs` | `string[]` | Optional document/user filters | | `limit` | `number` | Defaults to `40`; maximum `100` | | `offset` | `number` | Defaults to `0` | | `header_filter` | `object[]` | Return entries from docs whose header contains at least one object | | `header_exclude` | `object[]` | Exclude entries from docs whose header contains any object | | `property_filter` | `object[]` | Return entries whose properties contain at least one object | | `property_exclude` | `object[]` | Exclude entries whose properties contain any object | | `merge_with` | `string[]` | Header path whose value names additional docs to include | | `from_ts_gte` | `string` | ISO timestamp lower bound for entry start | | `from_ts_lte` | `string` | ISO timestamp upper bound for entry start; defaults to now | #### Returns ```ts { id: number; created_at: string; from_ts: string; to_ts: string; doc_id: string; uid: string; username: string; url: string; tags: any; entry: any; properties: any; header: any; doc_path: string; canonical_url?: string; }[] ``` #### Example ::: code-group ```js [JS] const basicAuth = btoa(`${appId}:${appSecret}`); const response = await fetch( "https://meridiem.markwhen.com/api/v1/aggregate/entries", { method: "POST", headers: { authorization: `Basic ${basicAuth}`, "content-type": "application/json", }, body: JSON.stringify({ docs: ["alice/journal"], property_filter: [{ mood: "great" }], from_ts_gte: "2026-01-01T00:00:00.000Z", limit: 20, }), }, ); const entries = await response.json(); ``` ::: ### `GET /api/v1/aggregate/content` Fetch content and metadata for one authorized document by user-facing path. #### Query Parameters | Parameter | Type | Notes | | ---------- | -------- | ------------------------- | | `username` | `string` | Document owner's username | | `doc_path` | `string` | Document name/path | #### Returns ```ts { uid: string; doc_id: string; doc_path: string; doc_type: string; header: any; content: string; doc_version: number; updated_at: string; } ``` #### Example ::: code-group ```js [JS] const basicAuth = btoa(`${appId}:${appSecret}`); const params = new URLSearchParams({ username: "alice", doc_path: "journal", }); const response = await fetch( `https://meridiem.markwhen.com/api/v1/aggregate/content?${params}`, { headers: { authorization: `Basic ${basicAuth}`, }, }, ); const document = await response.json(); ``` ::: ### Path and entry helpers Aggregate also includes helper endpoints for path-based apps: | Endpoint | Purpose | | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | `GET /api/v1/aggregate/documents/resolve?username={username}&doc_path={docPath}` | Resolve a user-facing document path to `uid`, `doc_id`, and canonical path | | `GET /api/v1/aggregate/documents/canonical?uid={uid}&doc_id={doc_id}` | Get the current canonical path for a stable document identity | | `GET /api/v1/aggregate/entries/by-path?username={username}&doc_path={docPath}&entry_url={entryUrl}` | Fetch one entry by user-facing document path and entry URL | | `GET /api/v1/aggregate/entries/by-id?uid={uid}&doc_id={doc_id}&entry_url={entryUrl}` | Fetch one entry by stable document identity and entry URL | ## List media ### `GET /api/v1/media` List media files owned by the authenticated user. #### Returns ```ts { "media": [ { "name": string, "metadata": any } ] } ``` #### Required scopes | Required scope | | --------------------------------- | | `media.read:*` or `media.write:*` | #### Headers | Header | Value | | --------------- | ---------------------------------- | | `Authorization` | `Bearer {your app's access_token}` | #### Query Parameters *None* #### Body *None* #### Examples ::: code-group ```js [JS] const accessToken = ""; // Your app's access token const result = await fetch("https://meridiem.markwhen.com/api/v1/media", { headers: { authorization: `Bearer ${accessToken}`, }, }); if (result.ok) { const { media } = await result.json(); console.log(media); } ``` ::: ## Upload media ### `GET /api/v1/media/upload` Create signed upload links for media files. #### Returns ```ts { "links": string[] } ``` | JSON Field | Type | Notes | | ---------- | ---------- | ------------------ | | `links` | `string[]` | Signed upload URLs | #### Required scopes | Required scope | | --------------- | | `media.write:*` | #### Headers | Header | Value | | --------------- | ---------------------------------- | | `Authorization` | `Bearer {your app's access_token}` | #### Query Parameters | Parameter | Type | Notes | | ------------ | -------- | ------------------------------------------------------------- | | `extensions` | `string` | Comma-separated extension counts, for example `.jpg:2,.png:1` | #### Body *None* #### Examples ::: code-group ```js [JS] const accessToken = ""; // Your app's access token const result = await fetch( "https://meridiem.markwhen.com/api/v1/media/upload?extensions=.jpg:1", { headers: { authorization: `Bearer ${accessToken}`, }, }, ); if (result.ok) { const { links } = await result.json(); await fetch(links[0], { method: "PUT", headers: { "content-type": "image/jpeg", }, body: file, }); } ``` ::: ## Delete media ### `DELETE /api/v1/media` Delete media files. #### Returns Status code 200 if successful. #### Required scopes | Required scope | | --------------- | | `media.write:*` | #### Headers | Header | Value | | --------------- | ---------------------------------- | | `Authorization` | `Bearer {your app's access_token}` | | `Content-Type` | `application/json` | #### Query Parameters *None* #### Body ```ts { "files": string[] } ``` | JSON Field | Type | Notes | | ---------- | ---------- | ----------------------------- | | `files` | `string[]` | Array of file paths to delete | #### Examples ::: code-group ```js [JS] const accessToken = ""; // Your app's access token const filesToDelete = ["userId/filename1.jpg", "userId/filename2.png"]; const result = await fetch("https://meridiem.markwhen.com/api/v1/media", { method: "DELETE", headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json", }, body: JSON.stringify({ files: filesToDelete, }), }); if (result.ok) { console.log("Files deleted successfully"); } ``` ::: --- --- url: /meridiem/api/extension-conversions.md --- # Extension Conversions If you have [permission to view a document](/meridiem/sharing), you may append a supported extension to a Meridiem document link to get its transformed format. | Extension | Format | | --------- | ------------------------------ | | `.mw` | Raw `text/markwhen` file | | `.json` | Parsed `json` | | `.ics` | [iCal](https://icalendar.org/) | For example, since [meridiem.markwhen.com/example](https://meridiem.markwhen.com/example) is [publicly viewable](/meridiem/sharing#publicly-viewable), we can get its raw text and parsed json: | `https://meridiem.markwhen.com/example.mw` | `https://meridiem.markwhen.com/example.json` | | --------------------------------------------------------- | ----------------------------------------------------------- | | | | ## Calendar The `.ics` extension is useful for subscribing to your markwhen events from your calendar. For example, in Google Calendar you can add a calendar by url: ![](/images/gcal1.png) ![](/images/gcal2.png) ::: tip Note Only [publicly viewable](/meridiem/sharing) documents can be added to another calendar application via an `.ics` url. ::: --- --- url: /meridiem/api/sms-email.md --- # SMS & Email Meridiem supports appending to markwhen documents via texting and email. Generally speaking, you can text or email `${user_name}/${doc_name}@bot.markwhen.com` and a timestamped entry will be appended to that document with the content of the message. If your username was `ester` and your document was `wichita` you would send email to `ester/wichita@bot.markwhen.com` (or `ester.wichita@bot.markwhen.com`, the separator between username and email can be either `.` or `/`.). **This only works for shared/cloud markwhen documents.** Here's how it works: 1. You must sign up to Meridiem and choose a username. 2. The email address that you used to sign in to Meridiem is automatically conferred edit permissions for appending via email. You may immediately send email from that email address to your docs to append to them. If this is all you need, you can stop here. 3. If you want others (other email addresses or phone numbers) to also be able to append to documents, there are a few ways you can give them permission: * Go to `Settings` in Meridiem. Under `Accounts` and `Editor emails and phone numbers`, add the phone numbers or email addresses that you want to give access to **all** your documents. * If you only want to give access on a per-document basis, [add their email or phone number in the `meridiem.edit` field of a specific document](/meridiem/sharing): ```mw meridiem: edit: - 2128675309 - franklin@gtav.com ``` ## Creating new documents This method can also be used to create new documents. Again, email or text **from an allowlisted** number or email address to a nonexistent document that is under your username and it will be created for you. ## Example Let's say your username is `tricia` and you have a document in meridiem that has been saved (aka shared) as `kitchen`, and it looks like this: ::: code-group ```mw [tricia/kitchen] --- title: Kitchen renovation planning --- 2024-05-05: got the plans approved 2024-06-04: met with contractor ``` ::: At `2024-09-08 09:10` you text or email `tricia/kitchen@bot.markwhen.com` from an allowlisted number or email address with the message `the kitchen is done!`. Meridiem recieves the message, parses it, and adds it to your document: ::: code-group ```mw [tricia/kitchen] --- title: Kitchen renovation planning --- 2024-05-05: got the plans approved 2024-06-04: met with contractor 2024-09-08 09:10: the kitchen is done! ``` ::: ::: info Top or bottom Personally, I like to write my markwhen documents with the most recent entries at the top, unlike Tricia. If Meridiem "notices" that your documents are sorted newest-first, it will add new entries to the top. Otherwise they will be added at the end of the document. Meridiem will **not** attempt to insert new entries in the middle of a document in sorted order - it will only add at the top or bottom. ::: ## Caveats * U.S. 10 digit phone numbers only. (it may work with other international numbers but it hasn't been tried). * Don't add spaces, periods, or dashes when adding phone numbers. * You will receive no email or sms response, whether it succeeds or fails. * There is a general movement to [get rid of SMS gateways](https://www.att.com/support/article/wireless/KM1061254/), which is what enables texting an email address in the first place. Unfortunate, but there isn't much I can do about that. So if you're an AT\&T customer you already cannot use this feature as they have disabled their gateway as of June 17, 2025. ### SMS/MMS SMS is a special breed of technology, let's put it that way. Historically, SMS messages have been limited to 160 characters (the reason for twitter's original length limit), though there may be some carrier-specific ways to hide that from users, and MMS will "helpfully" compress your media to the moon and back. [RCS](https://en.wikipedia.org/wiki/Rich_Communication_Services), SMS's replacement, would represent a more fruitful long-term mechanism for what is currently implemented here in SMS/MMS but is not yet set up - mostly because it's more difficult and rigid. SMS is easier to work with programmatically, which is unfortunately why it has been all but overrun with spam and so its successor is more locked down. Unfortunately RCS does not support [SMS gateways](https://en.wikipedia.org/wiki/SMS_gateway) (at least as far as I know) and so the one-email-address-per-document model descibed on this page will not work when RCS support does roll out (if ever). ## Remarking Incidentally this is a super easy way to work with [remarking](https://remark.ing) - just text your updates or posts, just like (very) old school twitter. --- --- url: /meridiem/api/webhooks.md --- # Webhooks Meridiem apps can receive webhook events when documents change. ## Configure a webhook URL Create an app in Meridiem and set a `doc_changed_webhook` URL in app settings. Webhooks are sent only for documents your app is authorized to access via `docs.read:*`, `docs.write:*`, or matching document-scoped permissions. ## Event type ### `doc.changed` Sent when a document change has been merged and persisted. #### Payload ```ts { type: "doc.changed"; uid: string; username?: string; docId: string; docName: string; version: number; requestTimestampSeconds?: number; emittedAt: string; } ``` | JSON Field | Type | Notes | | ---------- | ---- | ----- | | `type` | `"doc.changed"` | Event name | | `uid` | `string` | Document owner uid | | `username` | `string \| undefined` | Document owner username when available | | `docId` | `string` | Immutable document id | | `docName` | `string` | Current document name (falls back to `docId`) | | `version` | `number` | Merged document version | | `requestTimestampSeconds` | `number \| undefined` | Scheduled task timestamp (debounce target time) | | `emittedAt` | `string` | ISO timestamp generated when webhook is emitted | ## Signature headers Each webhook request includes HMAC headers signed with your app secret. | Header | Value | | ------ | ----- | | `x-markwhen-signature` | `v1=<hex hmac sha256>` | | `x-markwhen-timestamp` | Unix timestamp in seconds | | `x-markwhen-event-id` | `doc.changed:{uid}:{docId}:{version}` | | `x-markwhen-client-id` | Your app `client_id` | ### Signature payload format The signature is computed over this exact string: ```text ${timestamp}.${eventId}.${rawBody} ``` * `timestamp`: value of `x-markwhen-timestamp` * `eventId`: value of `x-markwhen-event-id` * `rawBody`: exact raw request body bytes ::: warning Do not parse and re-serialize JSON before verification. Verify against the raw body bytes exactly as received. ::: ## Verify signatures (Node.js) ```js import crypto from "crypto"; export function verifyMarkwhenWebhook(req, appSecret) { const signatureHeader = req.headers["x-markwhen-signature"] || ""; const timestamp = req.headers["x-markwhen-timestamp"] || ""; const eventId = req.headers["x-markwhen-event-id"] || ""; if ( typeof signatureHeader !== "string" || typeof timestamp !== "string" || typeof eventId !== "string" ) { return false; } const expectedPrefix = "v1="; if (!signatureHeader.startsWith(expectedPrefix)) { return false; } const receivedHex = signatureHeader.slice(expectedPrefix.length); const payloadToSign = `${timestamp}.${eventId}.${req.rawBody.toString("utf8")}`; const computedHex = crypto .createHmac("sha256", appSecret) .update(payloadToSign) .digest("hex"); const received = Buffer.from(receivedHex, "hex"); const computed = Buffer.from(computedHex, "hex"); if (received.length !== computed.length) { return false; } return crypto.timingSafeEqual(received, computed); } ``` ## Recommended protections 1. Reject signatures that fail verification. 2. Reject old timestamps (for example, older than 5 minutes). 3. Store recent `x-markwhen-event-id` values for replay protection. 4. Handle duplicates idempotently. ## Delivery notes * Webhook requests are sent as `POST` with `Content-Type: application/json`. * Treat deliveries as asynchronous notifications. * Design handlers to be idempotent. --- --- url: /remarking.md --- # Remark.ing [Remarking](https://remark.ing) is a twitter-like blogging site, but as an aggregation of markwhen documents. You write markwhen and each entry in it is its own "remark." Let's say Rob, Kris, and Esteban all join Remarking and create their markwhen docs: ::: code-group ```mw{1,2} [rob/my-remarks.mw] remarking: view: "*" 2025-06-01: Example for documentation 2025-04-21: Pool day πŸŠπŸΌβ€β™‚οΈ ``` ```mw [kris/events.mw] remarking: view: "*" 2025-04-30: πŸ›« here we go 2025-05-01: πŸ›¬ finally ``` ```mw [esteban/esteban.mw] remarking: view: "*" 2025-02-30: still cold 2025-07-04: πŸŽ‡ ``` ::: Remarking adds them all together behind the scenes, and you get: ::: code-group ```mw [combined.mw] 2025-07-04: πŸŽ‡ 2025-06-01: Example for documentation 2025-05-01: πŸ›¬ finally 2025-04-30: πŸ›« here we go 2025-04-21: Pool day πŸŠπŸΌβ€β™‚οΈ 2025-02-30: still cold ``` ::: ::: tip Note Remarking doesn't actually make a new markwhen document containing all others, but it is a good way to think about how it works. ::: ::: danger Important! Having the remarking view permission in your markwhen is crucial for this to work: ```mw remarking: view: "*" ``` Remarking will not overshare your content -- you must explicitly add these two lines in your header for your journal to be visible to others. ::: Which then turns each event into something like this (this is an actual embedded remark, you can interact with it): Remarking is built on top of [Meridiem](/meridiem/) and its [OAuth API](/meridiem/api/). You can edit your markwhen documents directly via Meridiem, or you can use the Remarking interface. All that being said, you do not need to concern yourself with how Remark.ing works under the hood if you don't want to. You can use it without knowing that it uses markwhen and Meridiem. ## Benefits of blogging with Markwhen * Future posts can be drafted by just giving them a date in the future * Similarly, backfill events and posts to your hearts content - add stuff from the past! * Exporting or moving your writing is as simple as copy pasting * Everything is very searchable - `ctrl`/`cmd` + `f` * Editting and deleting is super easy - you can edit all remarks directly in Meridiem ## Setup 1. Go to [remark.ing](https://remark.ing) and click on `Log in / Sign up`. 2. If you aren't already logged in to Meridiem, you'll be asked to send a login link to your email. 3. If you don't have an account, you'll be prompted to choose a username. This username works across Meridiem and Remark.ing; your document urls will correspond to Remark.ing urls. That is, if you choose the username `jo` and `meridiem.markwhen.com/jo/stuff` is linked to Remark.ing, it will be viewable at `remark.ing/jo/stuff`. 4. You'll be prompted to allow Remark.ing to access your Meridiem cloud documents. 5. You'll be redirected to Remark.ing where you can either create a new blog or tailor the settings of your existing blogs. ### Profile page ![](/images/remarking_profile.png) You can customize your profile page through Remark.ing (by clicking on the edit button at the top right of the profile page) or by adding specific entries to your markwhen documents. If you add an author name and avatar image, they will be shown next to your remarks when they appear in a feed. The following is the header for `rob/rob` to give it the appearance it has at [remark.ing/rob/rob](https://remark.ing/rob/rob). ```mw remarking: view: "*" image: https://media.markwhen.com/7mGszd2clHRHudsf0lLX4Kb1ChI3/cef3-a901-e1b7-5585.png author: name: Rob Koch avatar: https://media.markwhen.com/7mGszd2clHRHudsf0lLX4Kb1ChI3/0bc5-25f1-db6a-3706.jpg description: | mostly [markwhen](https://markwhen.com) stuff [Github](https://github.com/kochrt) ``` Indeed, editing the profile page using the Remark.ing UI merely changes these values in the header of your markwhen document. ::: warning Add a timezone Add a timezone to any markwhen document you want to use for remark.ing! ::: --- --- url: /remarking/visibility.md --- # Visibility Remark.ing is a follow-first model, there isn't (yet) a "public" feed to see what everyone is posting. Visibility settings are read from the `remarking.view` field of documents and entries/remarks. To denote that a document should be visible, set `remarking.view` to `"*"`: ```mw{2} remarking: view: "*" author: name: Bob // ... rest of header ... 2025-08-09: hello! ``` Same thing with remarks: set `remarking.view` to `"*"`: ```mw{3} 2025-08-09: remarking: view: "*" ``` If you intend for a document's remarks to be generally visible, set `remarking.view` in the document header. You do not need to set `remarking.view` on every entry if the visibility is already set on the document as a whole. Documents created via Remark.ing default to public visibility. ## Precedence In the case of conflicting visibility rules, individual entries' rules take precedence over the document's rules. For example, in the following document, ```mw{3,8} --- remarking: view: "*" --- 2025-08-03: this is private remarking: view: none ``` the entry `this is private` will not be visible to others as its `remarking.view` rule overrides the document's rule. Likewise, if we switch the document's and the entry's rules: ```mw{3,8} --- remarking: view: none --- 2025-08-03: this is public remarking: view: "*" ``` The remark is now visible, overriding the document header. ## Drafts Denote that an entry is a draft (and therefore not publicly visible) by setting `remarking.draft` to `true` on the entry: ```mw{2,3} 2026-03-12: Thoughts on the most recent things to happen remarking: draft: true I have a lot of thoughts on the most recent things but I'm not ready to share them yet 2026-03-06: Life update This post is visible, and that's a normal thing to say about a post ``` If using the Remark.ing UI, this property setting is handled for you when you click the "save" button. Drafts will not show up in anyone's feed but will show up as drafts beneath the compose area when drafting a new remark. ::: info Note Meridiem [view permissions](/meridiem/sharing.md), which are about viewing and editing in [Meridiem](/meridiem/index) specifically, are separate from view permissions set for remarking. ::: --- --- url: /remarking/remarks.md --- # Remarks Each event in a blog's corresponding markwhen document is a remarking entry (or "remark"). Whenever a cloud markwhen document is edited in Meridiem, it is wholly reparsed, its events sorted chronologically, and each event is given a unique id, [derived primarily from the content of the first line of the event](https://github.com/mark-when/parser/blob/c0e44891b0a65ee483311859ace567fdf8687cb0/src/utilities/urls.ts#L40). ```ts const disallowedCharacters = /[^A-Za-z0-9_-]/g; const linkRegex = /(?<preceding>^|\s)\[(?<title>[^\]]*)\]\((?<url>\S+\.\S+)\)/g; function urlFromString(s: string): string { return s .trim() .replaceAll(linkRegex, (orig, preceding, title) => preceding + title) .split(" ") .slice(0, 4) .map((s) => s.replaceAll(disallowedCharacters, "")) .filter((s) => !!s) .join("-"); } ``` ::: info Why not use user-defined `id`s from [event properties](/syntax/event-descriptions/)? User-defined `id`s are not necessarily unique. Furthermore this would require users to manually `id` all their events, which would be onerous. ::: So the following markwhen document would generate two remarks, with ids `A-long-time-ago` and `Hello-world`: ```mw 2025-06-21: A long time ago, in a galaxy far, far away 2023-04-09: Hello world! ``` If this markwhen document was owned by `terry` and the name of the doc was `vacation`, one could refer to a specific entry like `remark.ing/terry/vacation/A-long-time-ago`. This is how Remark.ing works and how remark urls are generated. ## Drafts and Limiting Visibility A remark may be marked as a draft with a simple addition to its [properties](/syntax/properties): ```mw{2,3} 2028-05-04: Not ready for prime time yet remarking: draft: true ``` If an entry is marked as a draft, it will not show up in yours or others' feeds. A remark can also be marked as private by setting `remarking.view` to `none`: ```mw{2,3} 2028-05-04: Not ready for prime time yet remarking: view: none ``` A draft remark and a private remark have the same visibility to others (i.e., none at all) but drafts will show up below to the remark compose area. ::: warning Note Individual remarks' visibility settings have a higher precedence than the document's settings. [Read more about remark visibility](/remarking/visibility). ::: --- --- url: /remarking/embedding.md --- # Embedding Remarks can be embedded by copying the below code and replacing the parameters with the remark you want to embed, then adding the code snippet to your site's `HTML`: ```html <blockquote data-remarking-uri="/${username}/${doc_id}/-/${remark_id}" ></blockquote> <script async src="https://embed.remark.ing/static/embed.js" charset="utf-8" ></script> ``` `username` is not optional, but `doc_id` and `remark_id` are. By just providing `username`, you'll be grabbing the latest remark across all of that person's blogs; if you provide both `username` and `doc_id`, you'll get the latest of that blog. By providing all three with the `/-/` delimiter you'll get that specific remark. To embed a full feed instead of only the latest remark, append `?feed=1` to the `data-remarking-uri`. This works for both user feeds (`/${username}`) and individual blogs (`/${username}/${doc_id}`). ```html <blockquote data-remarking-uri="/${username}/${doc_id}?feed=1"></blockquote> <script async src="https://embed.remark.ing/static/embed.js" charset="utf-8" ></script> ``` You can further tweak the iframe with optional query parameters: * `theme` β€” choose `light`, `dark`, or `system` (default) to control the color mode. * `background` β€” provide a hex color (e.g. `#ffffff`, `#0f172a`, or `f5f5f580`) to set the iframe's body background. These parameters can be combined with `feed`. For example: ```html <blockquote data-remarking-uri="/${username}/${doc_id}?feed=1&theme=dark&background=%23f8fafc" ></blockquote> <script async src="https://embed.remark.ing/static/embed.js" charset="utf-8" ></script> ``` The embedded feed paginates automatically when there are more than 20 remarks, and navigation links are included within the iframe. For example, this code snippet: ```html <blockquote data-remarking-uri="/markwhen/markwhen"></blockquote> <script async src="https://embed.remark.ing/static/embed.js" charset="utf-8" ></script> ``` Results in an inline element that looks like this: ::: tip Note Only public blogs or remarks are embeddable. ::: ## Generator Use the generator below to build an embed snippet for any publicly visible remark or feed. Paste a remark.ing URL or path, choose whether you want the latest remark or the full feed, then copy the generated HTML into your site. --- --- url: /remarking/rss.md --- # RSS RSS feeds are published at `/[user]/feed.xml` and `[user]/[doc_id]/feed.xml`. You can therefore subscribe to either a person (`/bella/feed.xml`) and all their published blogs, or an individual blog (`/bella/recipes/feed.xml`) that that person authors. --- --- url: /visualizations.md --- # Visualizations Markwhen and Meridiem are built to be extensible, primarily in that Meridiem (and other editors) do not assume what kind of visualization will be used from the parsed text. [List of visualizations](https://github.com/mark-when/visualizations) |Name|Repo|Link|By|Notes| |---|---|---|---|---| |Timeline/gantt|[mark-when/timeline](https://github.com/mark-when/timeline)|[timeline.markwhen.com](https://timeline.markwhen.com)|[markwhen](https://github.com/mark-when)|Timeline and gantt view in one| |Calendar|[mark-when/calendar](https://github.com/mark-when/calendar)|[calendar.markwhen.com](https://calendar.markwhen.com)|[markwhen](https://github.com/mark-when)|Calendar| |Map||[map.markwhen.com](https:///map.markwhen.com)|[markwhen](https://github.com/mark-when)|Map| |Resume|[mark-when/resume](https://github.com/mark-when/resume)|[resume.markwhen.com](https://resume.markwhen.com)|[markwhen](https://github.com/mark-when)|Specific syntax, see [here](https://github.com/kochrt/kochrt.github.io/blob/master/resume.mw) for an example| ## Making your own view ### View client library Views communicate with the renderer/editor via [the view client library](http://www.npmjs.com/package/@markwhen/view-client) ([github](https://github.com/mark-when/view-client)). The simplest implementation of a markwhen view would look something like the following: ```html <html> <body> <div id="container"></div> <script type="module"> import { useLpc } from "https://unpkg.com/@markwhen/view-client/dist/index.js"; const { postRequest } = useLpc({ state: (newState) => { document.getElementById("container").innerHTML = JSON.stringify(newState); }, }); postRequest("state"); </script> </body> </html> ``` This is less of a "visualization" more than it is "spitting out the entire app state into html." We request a state update, and, when we get a state update, we set the innerHTML of our only `div` to be the whole state. Useful to get started so you can actually see what data is coming through for you to work with. Let's break it down: we import `useLpc` from the `view-client` library (`LPC` like "remote procedure call" except it's local instead of remote). `useLpc` takes an object of listeners, all of which are optional: ```ts interface MessageTypes { state: State; setHoveringPath: EventPath; setDetailPath: EventPath; setText: { text: string; at?: { from: number; to: number; }; }; showInEditor: EventPath; newEvent: { dateRangeIso: DateRangeIso; granularity?: DateTimeGranularity; immediate: boolean; }; editEventDateRange: { path: EventPath; range: DateRangeIso; scale: DisplayScale; preferredInterpolationFormat: DateFormat | undefined; }; jumpToPath: { path: EventPath; }; jumpToRange: { dateRangeIso: DateRangeIso; }; } type MessageListeners = { [Property in keyof MessageTypes]?: ( event: MessageTypes[Property] ) => any; }; export function useLpc(listeners?: MessageListeners) { ... } ``` The type of each entry in the `MessageTypes` interface is the type of the parameter that can come through as a message, either to the hosting app or, more likely, from the hosting app (hosting app meaning the view container; [this](https://github.com/mark-when/markwhen)). In our simple example, we only have a listener for `state` updates. `state` updates contain information both on the state of the app (is dark mode on, if one of the events is currently selected, which page we're on, etc.) as well as the parsed markwhen document. These are contained in `state.app` and `state.markwhen` respectively. --- --- url: /visualizations/starter-template.md --- # Starter template view There is a starter repo for a vue markwhen view that you can use to scaffold your own visualization: [mark-when/vue-view-template](https://github.com/mark-when/vue-view-template) (it is only nominally `vue`-specific, if you prefer react or some other framework you will have no problems understanding what's going on). To use this repo, start by cloning it, installing dependencies, and running the dev server: ```bash > git clone git@github.com:mark-when/vue-view-template.git > cd vue-view-template > npm i > npm run dev ``` Then clone and run the [hosting app](https://github.com/mark-when/markwhen) or go to [markwhen.com](https://markwhen.com) and add the new view: ![](/images/settings.png) ![](/images/settings2.png) ![](/images/dialog.png) ![](/images/added.png) If everything has been set up successfully, you should see a new visualization that spits out the entire parser output: ![](/images/example.png) --- --- url: /visualizations/timeline.md --- # Timeline The timeline is the most popular visualization and so gets most of the attention. There are two versions - the "OG" Timeline and just `Timeline` or `Timeline 2`. This documentation is in regards to `Timeline` and not `OG Timeline`. The only real difference is that `OG Timeline` is HTML based whereas the new `Timeline` is SVG based. The original "OG" timeline can be found [here](https://github.com/mark-when/timeline). ## Configuration The timeline can be configured through the markwhen file's header. Any configuration that is specific to the timeline will be nested under a `timeline` entry in the header. Reminder that the [header](/syntax/header) is written in [yaml](https://yaml.org/). ## `ranges` In addition to panning and zooming manually, specify hard-coded ranges to quickly set the timeline to. If `ranges` are specified, the first one will be used as the default starting range when the timeline loads. ```mw{2} timeline: ranges: [3 months, 1 year] ``` ## `center` Center the starting position of the timeline. Defaults to `now` (that is, the time when the timeline was rendered). Must be an [ISO8601](https://en.wikipedia.org/wiki/ISO_8601) date. ```mw{2} timeline: center: 2000 ranges: [1 year] ``` ## Eras / Milestones Tag an event with `#era` or `#milestone` to give it a vertical highlighted background: ```mw{6} timeline: ranges: [10 years] center: 2019 2016: Harambe 2021 - 2022: Pandemic #era ``` --- --- url: /visualizations/timeline/styling.md --- # Styling The timeline visualization consists of several components that can be styled: | Component | Description | | ------------------------------------------- | ------------------------------------------------------------------ | | [Markers](#markers) | The vertical lines that represent time units (days, months, years) | | [Now Line](#now-line) | The line indicating the current time | | [Events](#events) | Individual timeline events | | [Sections](#sections) | Sections (groups of events) | ## Styling Precedence You can customize the styling of the timeline. The precendence of timeline styling is as follows: 1. Entry defition 2. Tag 3. Header definition 4. Theme (soon) The timeline has a default theme that, in lieu of other styles being provided, will be used. CSS themes will eventually be supported; those would also fall into this last category of precedence. Styles defined in the header override any theme styles. Tag definitions override header styles. Individual entry definitions (those that are defined on a specific section or event) override all others. For example, if an individual event specifies its own style, it overrides any other style declaration lower on the list (tag, header, etc). ## Markers Markers are the vertical grid lines that divide time units on the timeline. ### Basic Marker Styling ```mw timeline: style: marker: fill: transparent fill-opacity: 1 stroke: var(--color-zinc-300) stroke-width: 1 stroke-opacity: 1 stroke-dasharray: 2,3 now / 1 day: Hello, world! ``` ### Hover State Styling ```mw timeline: style: marker: hover: fill: transparent fill-opacity: 1 stroke: var(--color-zinc-300) stroke-width: 1 stroke-opacity: 1 stroke-dasharray: 100,0 now / 1 day: Hello, world! ``` ### Weekend Markers ```mw timeline: style: marker: weekend: fill: rgb(161, 161, 170) now / 1 day: Hello, world! ``` ### Dark Mode Support For all marker properties, you can specify different values for dark mode by adding `.dark` to the property name: ```mw timeline: style: marker: fill: transparent fill.dark: #333 stroke: var(--color-zinc-300) stroke.dark: var(--color-zinc-500) weekend: fill: rgb(161, 161, 170) fill.dark: rgb(113, 113, 122) now / 1 day: Hello, world! ``` ### Examples #### Simple Red Markers ```mw timeline: style: marker: stroke: red stroke-width: 2 now / 1 day: Hello, world! ``` #### Dashed Markers with Hover Effect ```mw timeline: style: marker: stroke: #555 stroke-dasharray: 4,2 stroke-opacity: 0.5 hover: stroke: #000 stroke-opacity: 1 stroke-dasharray: 0 now / 1 day: Hello, world! ``` #### Highlighting Weekends ```mw timeline: style: marker: stroke: #ddd weekend: fill: rgba(200, 220, 255, 0.3) now / 1 day: Hello, world! ``` #### Dark Mode Optimized ```mw timeline: style: marker: stroke: #333 stroke.dark: #aaa stroke-opacity: 0.4 stroke-opacity.dark: 0.6 weekend: fill: rgba(100, 100, 120, 0.2) fill.dark: rgba(70, 70, 100, 0.3) now / 1 day: Hello, world! ``` ## Now Line The Now Line is a vertical line that represents the current time on the timeline. ### Basic Now Line Styling ```mw timeline: style: nowLine: stroke: var(--color-blue-300) stroke-width: 2 stroke-opacity: 1 now / 1 day: Hello, world! ``` ### Dark Mode Support For all Now Line properties, you can specify different values for dark mode by adding `.dark` to the property name: ```mw timeline: style: nowLine: stroke: var(--color-blue-300) stroke.dark: var(--color-slate-400) stroke-width: 2 stroke-width.dark: 3 stroke-opacity: 1 stroke-opacity.dark: 0.8 now / 1 day: Hello, world! ``` ### Examples #### Bold Red Now Line ```mw timeline: style: nowLine: stroke: red stroke-width: 3 now / 1 day: Hello, world! ``` #### Subtle Now Line ```mw timeline: style: nowLine: stroke: #888 stroke-width: 1 stroke-opacity: 0.5 now / 1 day: Hello, world! ``` #### Dark Mode Optimized Now Line ```mw timeline: style: nowLine: stroke: rgba(59, 130, 246, 0.7) stroke.dark: rgba(226, 232, 240, 0.8) stroke-width: 2 stroke-opacity: 0.8 stroke-opacity.dark: 1 now / 1 day: Hello, world! ``` ## Events ### Event Title Styling ```mw timeline: style: event: title: color: var(--color-blue-800) color.dark: var(--color-blue-200) font-weight: 700 now / 1 year: Hello, world! 50% ``` ### Event Date Styling ```mw timeline: style: event: date: color: var(--color-zinc-600) color.dark: var(--color-zinc-300) font-weight: 500 font-size: 12 now / 1 year: Hello, world! 50% ``` ### Event Bar Styling ```mw timeline: style: event: bar: fill: var(--color-blue-500) fill.dark: var(--color-blue-600) stroke: var(--color-blue-600) stroke.dark: var(--color-blue-500) fill-opacity: 0.3 stroke-opacity: 0.8 drop-shadow: drop-shadow-md now / 1 year: Hello, world! 50% ``` ### Event Bar Detail State The detail state is used when an event is selected. ```mw timeline: style: event: bar: detail: fill-opacity: 0.6 fill-opacity.dark: 0.7 now / 1 year: Hello, world! 50% ``` ### Progress Indicator (Percent Bar) ```mw timeline: style: event: percentBar: fill: var(--color-green-600) fill.dark: var(--color-green-500) stroke: var(--color-green-700) stroke.dark: var(--color-green-400) fill-opacity: 0.7 stroke-opacity: 1 now / 1 year: Hello, world! 50% ``` ### Progress Indicator Hover and Detail States ```mw timeline: style: event: percentBar: hover: fill-opacity: 0.9 fill-opacity.dark: 1 detail: fill-opacity: 0.9 fill-opacity.dark: 1 now / 1 year: Hello, world! 50% ``` ### Dark Mode Support As with other components, all event styling properties support dark mode by adding `.dark` to the property name. ## Sections ### Basic Section Styling ```mw timeline: ranges: [20 days] style: group: fill: var(--color-zinc-400) fill.dark: var(--color-zinc-500) fill-opacity: 0.1 fill-opacity.dark: 0.1 stroke: var(--color-zinc-500) stroke.dark: var(--color-zinc-400) stroke-width: 1 stroke-opacity: 0.3 stroke-dasharray: 200,0 now / 1 day: Hello, world! # My Section now / 2 days: First event in section 3 days / 2 days: Second event in section ``` ### Section Text Styling You can customize how the section titles appear: ```mw timeline: ranges: [20 days] style: group: text: color: var(--color-zinc-800) color.dark: var(--color-zinc-100) fill: var(--color-zinc-300) fill.dark: var(--color-zinc-700) font-size: 0.875rem font-weight: 600 now / 1 day: Hello, world! # My Section now / 2 days: First event in section 3d / 2 days: Second event in section ``` ### Section Hover State Define how sections appear when users hover over them: ```mw timeline: ranges: [20 days] style: group: hover: fill: var(--color-zinc-400) fill.dark: var(--color-zinc-500) fill-opacity: 0.2 fill-opacity.dark: 0.2 stroke: var(--color-zinc-500) stroke.dark: var(--color-zinc-400) stroke-width: 1 stroke-opacity: 0.5 stroke-dasharray: 300,0 text: color: var(--color-zinc-900) color.dark: var(--color-zinc-50) fill: var(--color-zinc-50) fill.dark: var(--color-zinc-700) now / 1 day: Hello, world! # My Section now / 2 days: First event in section 3d / 2 days: Second event in section ``` ### Section Detail State The detail state is used when a section is selected or in focus: ```mw timeline: ranges: [20 days] style: group: detail: fill: var(--color-zinc-300) fill.dark: var(--color-zinc-500) fill-opacity: 0.2 fill-opacity.dark: 0.2 stroke: var(--color-zinc-400) stroke.dark: var(--color-zinc-300) stroke-width: 1 stroke-opacity: 1 stroke-dasharray: 400,0 text: color: var(--color-zinc-900) color.dark: var(--color-zinc-50) fill: var(--color-zinc-50) fill.dark: var(--color-zinc-700) now / 1 day: Hello, world! # My Section now / 2 days: First event in section 3d / 2 days: Second event in section ``` ### Dark Mode Support As with other components, all section styling properties support dark mode by adding `.dark` to the property name. ### Examples #### Colored Sections ```mw timeline: style: group: fill: var(--color-blue-300) fill.dark: var(--color-blue-800) fill-opacity: 0.15 fill-opacity.dark: 0.25 stroke: var(--color-blue-400) stroke.dark: var(--color-blue-600) stroke-opacity: 0.4 text: color: var(--color-blue-900) color.dark: var(--color-blue-100) font-weight: 600 now / 1 day: Hello, world! # Team Alpha now / 2 days: Research phase 3d / 3 days: Development # Team Beta 1d / 4 days: Design mockups 6d / 5 days: Implementation ``` --- --- url: /meridiem/commands.md --- # Commands Commands are custom code that you write that can be executed from the command menu or from the editor. Add and edit commands from the Settings menu. ## Command signature Commands are async functions that are called by the user (you). They are given a `context` parameter: ```ts import type { Timeline } from "@markwhen/parser"; import { EditorSelection, TransactionSpec } from "@codemirror/state"; type CommandContext = { text: string; markwhen: Timeline; editor: { selections: EditorSelection; }; }; type CommandResult = TransactionSpec | string | undefined; async function command( context: CommandContext ): CommandResult | Promise<CommandResult> { // Your command code is pasted here } ``` ::: info See the [codemirror documentation](https://codemirror.net/docs) and [markwhen parser documentation](/parser) for more information about `EditorSelection` and `TransactionSpec` from CodeMirror, and `Timelines`, respectively. ::: ### Command Context The `context` parameter contains the raw text of the current document in `context.text`. This can be useful for getting the actual text from specific ranges specified by the result of the parse, which is in `context.markwhen`. For example, you can get the actual text of an event by `substring`ing `context.text` from a range: ```ts const root = context.markwhen.events.value; // This is contrived, there may or may not be an event here // (it could be a section) const { from, to } = root[0].value.rangeInText; const eventText = context.text.substring(from, to); // Do something with `eventText`... ``` The `context` also gives the editor's current selection in `context.editor.selections`. You can use this to perform actions based on what is selected. For example, we can find out which event the cursor is in: ```js function isEventNode(node) { return !Array.isArray(node.value); } function* walk(node, path) { yield { node, path }; if (node && !isEventNode(node)) { const arr = node.value; for (let i = 0; i < arr.length; i++) { yield* walk(arr[i], [...path, i]); } } } const nodeFromStringIndex = (cursor) => { let bestSoFar = undefined; for (const { path, node } of walk(context.markwhen.events, [])) { const stringIndex = isEventNode(node) ? node.value.rangeInText.from : node.rangeInText?.from; if (stringIndex !== undefined) { if (stringIndex > cursor) { return bestSoFar; } else { bestSoFar = node; } } } }; const selectedNode = nodeFromStringIndex(context.editor.selections.main.from); // Do something with `selectedNode`... ``` ::: info Because `isEventNode`, `walk`, and `nodeFromStringIndex` are so useful, they are built in and already callable from commands you write - you do not have to rewrite them! `nodesFromSelection` is another built-in function that returns nodes that are within the editor's current selection (see below for code). ::: ### Returning a result A `CommandResult` may be a CodeMirror `TransactionSpec`, a `string`, or `undefined`. If `undefined`, no actions are taken. If a `string` is returned, **the entire document is replaced with the string**. Generally, however, you will probably want to be more precise with the changes you make to the document. For those cases you can return a [`TransactionSpec`](https://codemirror.net/docs/ref/#state.TransactionSpec). In it, you can define text to be inserted or new selections to be made: ```js // Inserts 'Hello, World!' at the beginning of the document return { changes: [ { insert: "Hello, World!", from: 0, // to: 99 // <- uncommenting this would replace everything from 0 to 99 in the current document with the inserted text }, ], }; ``` ## Accessible Libraries ### Luxon [Luxon](https://moment.github.io/luxon/#/) is a date/time library that is helpful for working with dates and times. It can be accessed in commands via `luxon`: ```js const now = luxon.DateTime.now(); ``` ## Built in functions The following functions are built in and can be accessed from any command you write. The code is here for your reference: ::: code-group ```js [isEventNode] function isEventNode(node) { return !Array.isArray(node.value); } ``` ```js [walk] function* walk(node, path) { yield { node, path }; if (node && !isEventNode(node)) { const arr = node.value; for (let i = 0; i < arr.length; i++) { yield* walk(arr[i], [...path, i]); } } } ``` ```js [nodeFromStringIndex] const nodeFromStringIndex = (cursor) => { let bestSoFar = undefined; for (const { path, node } of walk(context.markwhen.events, [])) { const stringIndex = isEventNode(node) ? node.value.rangeInText.from : node.rangeInText?.from; if (stringIndex !== undefined) { if (stringIndex > cursor) { return bestSoFar; } else { bestSoFar = node; } } } }; ``` ```js [nodesFromSelection] const nodesFromSelection = () => { const nodes = []; const selection = context.editor.selections.main; for (const { path, node } of walk(context.markwhen.events, [])) { const startIndex = isEventNode(node) ? node.value.rangeInText.from : node.rangeInText?.from; const endIndex = isEventNode(node) ? node.value.rangeInText.to : node.rangeInText?.to; if ( startIndex !== undefined && endIndex !== undefined && endIndex > selection.from && startIndex < selection.to ) { nodes.push(node); } if (startIndex > selection.to) { break; } } return nodes; }; ``` ::: ## Examples ::: code-group ```js [Hello world] return { changes: [ { insert: "Hello, world!", from: 0, }, ], }; ``` ```js [Remove vowels] const selection = context.editor.selections.main; if (selection.from === selection.to) { return; } const selectedText = context.text.substring(selection.from, selection.to); return { changes: [ { insert: selectedText.replaceAll(/[aeiou]/g, ""), from: selection.from, to: selection.to, }, ], }; ``` ```js [Check all checkboxes in event] const node = nodeFromStringIndex(context.editor.selections.main.from); if (!node || !isEventNode(node)) { return; } const { from, to } = node.value.rangeInText; const eventString = context.text.substring(from, to); const replaced = eventString .split("\n") .map((s) => { return s.replace(/^\s*- \[ ?\]/, "- [x]"); }) .join("\n"); return { changes: [ { insert: replaced, from, to, }, ], }; ``` --- --- url: /meridiem/data-storage-privacy.md --- # Data Storage There are three ways your markwhen documents can be stored if you're using Meridiem - in the browser, as a file, or in the cloud. ## Browser storage Also sometimes referred to by me and elsewhere in these docs as "local storage," because that's what it's called, this is a [feature of browsers](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) that gives a domain an amount of space with which to store user data. It is only readable and writable by that origin. That is, other websites can't read it. ### Pros * Easy to use and work with for both users and developers * Little overhead (no other requirements, can be used on mobile) ### Cons * Max storage capacity of 5mb (though this is rarely met, or at least would be difficult on Meridiem) * Less understood than regular files or even the cloud * Not as accessible outside of Meridiem ## Files Regular ol' files. Create them on your computer, read them elsewhere, open, edit, save, delete, send, etc. At the end of the day, regardless of which storage option you choose, your markwhen documents are essentially files - that's why we like them! ### Pros * Ubiquitious * Easy to work with ### Cons * More overhead than `localStorage` because ~~browsers don't trust websites~~ bad actors have ruined it for the rest of us, so websites have to ask for permission to read from and write to the file system. This is just an added burden to users, which is unfortunate. ## Cloud storage > I don't like the term cloud but that is what we've settled on and so that is the term I'll use here. The third option is storage by proxy - Meridiem can store your files, associated with your account, same as iCloud or OneDrive or Dropbox or the like. ### Pros * Multi device usage - you can access your data from any device anywhere, so long as you log in * [Sharing and live collaborative editing](/meridiem/sharing) * API access * Calendar-like features ### Cons * An internet connection is generally required * "Is my data safe?"" ### Data privacy and security I personally find it more than a little annoying when some company suffers a "data breach" as they call it and you get a canned letter and $12 from a settlement 3 years later. At the end of the day, there is an element of trust involved, and you may just not want to give it. And I understand, I'm a pretty skeptical person at this point too, and I would say use the other forms of storage. #### FAQ #### Is my data encrypted? Yes, at rest as part of Google Cloud's default encryption and in transit as everything is over HTTPS. #### Is it encrypted end to end? No - Meridiem can read your cloud data, in addition to you. #### Who is Meridiem? When do you read my cloud data and why? [I](https://github.com/kochrt) am Meridiem, I'm the developer of Markwhen, Meridiem, Remarking, the VSCode extension, the obsidian extension, the parser, the timeline, the resume view, the maps view, and the calendar view. I don't personally read your data because I don't want to, I wouldn't want others to do that to my data, and honestly it's not super straightforward to do so. If I think I need to read your data for debugging purposes I'll ask you to share it with me via the standard sharing sytanx. My trustworthiness is on the line and that's worth a lot to me. Now Meridiem as a system "reads" your data fairly regularly, to insert it into a database and to offer API access to any applications **that you have authorized** to give access (as of 2025-07 there is only one application - remarking). End to end encryption would not allow this functionality, as it would be reading nonsensical encrypted data instead of markwhen. No part of markwhen or meridiem or remarking is in the data brokerage or data selling business, and never will be. I hate that shit. Data selling is dragging the whole internet down, imho. I'll say it again though, because it bears repeating - this all takes trust on your part, and if you don't trust me, then don't give me your stuff to store. My feelings are not hurt. For those that do trust me, stewardship of your data is my responsibility and I take it seriously. --- --- url: /syntax/groups-and-sections.md --- # Groups and Sections Events can be organized into sections using markdown-style headers. Use `#` through `######` (1-6 hash marks) to create sections at different nesting levels. Sections automatically close when: * A section of the same or higher level (fewer or equal `#` marks) is encountered * The end of the document/page is reached For example, ```mw{1,7,12,16} # The 90s 1991: Desert Storm 1994: Friends premiered 05/14/1998: Series finale of Seinfeld ## The 2000s 03/2005: Premiere of The Office (US) // The 2000s section auto-closes when The 2010s starts ## The 2010s 2020: Pandemic // This starts a new top-level section # Other Events 2022: Other things happen ... ``` The number of `#` marks determines the nesting level - `#` is the outermost level, `##` is nested inside `#`, and so on up to `######`. ## Sections vs Groups By default, sections are rendered as "groups" - collapsible containers for events. You can change the visual style using the `style` property: ```mw # My Section style: section 2024: Event in section-styled container ``` When `style: section` is set, the section extends to the full width of the timeline: Read more about how sections are handled by the [parser](/parser). --- --- url: /syntax/reminders.md --- # Reminders (beta) Markwhen.com can send you email reminders about events. If you want to get reminders for all events, specify a top-level entry in your header: ``` --- title: Important meetings reminders: [1 day] --- 2023-09-08: ... ``` Alternatively you can specify reminders on specific tags, to only receive reminders about events that have that tag: ``` #work: reminder: 15 minutes ``` In this case, you will get an email about events tagged with `#work` 15 minutes before they begin. Instead of a singular duration, you may specify a list of durations, and you will get an email for each one accordingly: ``` #work: reminders: - 15 minutes - 1 hour #personal: reminders: [1 day, 7 days] ``` You may use either `reminder` or `reminders` for your syntax (just don't use both on one tag). You may get even more specific with your reminders with `beforeBegin`, `afterBegin`, `beforeEnd`, and `afterEnd` that send reminder(s) before or after an event begins or ends: ``` #work: color: red timezone: America/New_York reminders: beforeBegin: durations: [30 minutes, 15 minutes] afterBegin: durations: - 30 minutes beforeEnd: 15 minutes afterEnd: ... Sep 8 2023 9am: Suport important meeting #work ``` Note that currently reminders do not work with recurring events - only the first instance of the event will trigger any applicable reminders. ## Set a timezone! When using reminders, be sure to [set a timezone](/syntax/timezones) in your header. Reminders are set and sent based on the timezone specified in the markwhen file - if it's not set, you will not receive reminders when you expect, or at all. --- --- url: /syntax/overview.md --- # Syntax A markwhen document is a simple text file. Its content type is `text/markwhen`; though, when importing from other sources, `text/plain` works fine. A markwhen document is separated into timeline pages via a page break token (`\n_-_-_break_-_-_\n`). ## Timeline A Timeline page is composed of an optional [header](/syntax/header) and one or more [events](/syntax/events).