Overview
What Crumb is, how these docs are organized, and where to start.
Crumb is an open format for travel itineraries — a plain-text file that turns a list of places into an interactive map and timeline. It's designed around progressive detail: a bare list of city names is already a valid crumb, and you add dates, stays, transport, and activities as your plans firm up. The format is the product; everything here documents it.
A crumb is just YAML with a small, friendly vocabulary, so you can write one by hand, have an AI generate one, or build your own tool around it.
How these docs are organized
- Format Specification — the complete, authoritative definition: every field, type, and rule, with examples.
- AI Authoring Guide — how to have an AI draft your crumb from a plain-language description, then open the result in the editor.
- Embedding — drop a crumb's interactive map into your own site or blog as a single self-contained embed.
- Parser Reference — how a parser turns crumb text into a resolved document, pass by pass.
- Data Model — the exact TypeScript shape a parser outputs, which every viewer reads.
Where to start
Just want to make one? You don't need to read the spec. Open the live editor and start typing, or follow the AI Authoring Guide to have an AI write the crumb from a plain-language description of your trip.
Putting a trip on your website? Go straight to Embedding. The map is self-contained — no server, build step, or API key.
Building a tool that reads or displays crumbs? Start with the Format Specification to learn the vocabulary, then use the Parser Reference and Data Model to implement or consume a parser. There's a reference implementation in TypeScript to build on.
The shape of a crumb
The simplest crumb is an ordered list of places:
itinerary:
- Tokyo
- Kyoto
- Osaka
The same trip, with detail added as it becomes known:
trip:
name: Japan in Autumn
note: Two weeks, Tokyo down to Hiroshima.
itinerary:
- place: Tokyo
arrives: Nov 3
duration: 4 nights
- transport: train
to: Kyoto
duration: 2h 15m
- place: Kyoto
duration: 3 nights
Both are valid. That range — from a sketch to a fully timed schedule, in one format — is the whole idea. The Format Specification covers everything you can express.
Format Specification
The complete, authoritative definition of the format — every field, type, and rule.
Leave a trail.
An open format for travel itineraries. Plan trips in plain text. Share the pieces.
Introduction
Crumb is a human-writable format for describing travel itineraries. It is designed to be readable without any tools, editable in any text editor, and structured enough that tools can display it as a map, a timeline, or a day-by-day planner.
The core idea is progressive detail. You can write as little or as much as you know. A list of city names is a valid crumb. So is a fully timed, day-by-day schedule with bookings and coordinates. Both live in the same format — you just add fields as your plans become more concrete.
A crumb can represent a full trip, a single city, a favourite weekend route, or just a handful of restaurant picks. Crumbs are designed to be shared, remixed, and assembled into something new — the way a recipe is adapted each time someone makes it their own.
Crumb files use the .crumb extension and are valid YAML documents.
A Quick Look
Here are two examples that show the range of what Crumb looks like in practice.
The simplest possible crumb
Just a list of places, in order.
itinerary:
- Tokyo
- Kyoto
- Osaka
- Hiroshima
A more detailed crumb
The same trip, progressively enriched — each place and transport leg adds a little more detail than the last.
trip:
name: Japan in 2 Weeks
author: Ana Yamamoto
tags: [asia, food, temples, city]
note: A two-week circuit through Japan's most iconic cities. Best visited in autumn.
itinerary:
- place: Tokyo
duration: 5 nights
- transport: train
- place: Kyoto
duration: 3 nights
plan:
- Fushimi Inari
- activity: Nishiki Market
priority: must
- activity: Arashiyama Bamboo Grove
priority: maybe
- transport: train
duration: 15m
- place: Osaka
duration: 2 nights
plan:
- stay: Namba Hotel
arrives: 3pm
departs: morning
- activity: Dotonbori
priority: must
tags: [landmark, food, nightlife]
- activity: Osaka Castle
priority: must
tags: [landmark, history]
- activity: Shinsekai
priority: maybe
tags: [landmark, food]
- transport: train
to: Hiroshima
departs: 2026-09-15T09:00
- place: Hiroshima
arrives: 2026-09-15
departs: 2026-09-18
duration: 3 nights
plan:
- stay: Hiroshima Garden Hotel
arrives: 2026-09-15
departs: 2026-09-18
info:
website: https://www.example.com
reference: HGH-220
note: Ask for a room facing the garden.
- activity: Peace Memorial Park
priority: must
tags: [landmark, history]
location: Peace Memorial Park, Hiroshima
note: Allow a **full morning**. The museum is deeply moving.
- day:
plan:
- Mitaki-dera Temple
- Hiroshima Museum of Art
- day:
plan:
- Itsukushima Shrine
- Mt. Misen
- day: Peace and history
time: 2026-09-18
plan:
- activity: Peace Memorial Museum
time: 9am
duration: 2h
note: Book tickets in advance.
- activity: Atomic Bomb Dome
time: 11am
duration: 1h
location:
lat: 34.3955
lng: 132.4530
- activity: Okonomi-mura
time: midday
duration: 1h
note: Try the **Hiroshima-style** okonomiyaki — layered, not mixed.
- transport: train
from: Hiroshima
to: Tokyo
departs: 2026-09-18T17:00+09:00
arrives: 2026-09-18T19:30+09:00
- transport: flight
from:
address: 2-6-5 Hanedakuko, Ota City, Tokyo
lat: 35.5494
lng: 139.7798
to:
address: London Heathrow Airport
lat: 51.4700
lng: -0.4543
departs: 2026-09-18T23:00+09:00
arrives: 2026-09-19T06:00+01:00
info:
operator: JAL
reference: JL44-8821
gate: 114
terminal: International Terminal
How the Format Works
Three rules shape everything in Crumb.
1. Each item in a list declares its kind.
A crumb is built from lists — the itinerary, and the plan inside each place. Every list has a default kind. An item written as a bare string is one of that default kind, with a name and nothing else. To add fields, write a mapping whose key names the kind: the key's value is the item's name (or, for transport, its mode; for a group, its title), and the item's other fields are sibling keys in the same mapping. The kinds, and their default per list, are given in Itinerary: Places and Itinerary: Transport.
2. Dates, times, and durations are flexible.
Write 2026-09-15 or just September. Write 9am or afternoon. Write 2h or at least half day. A wide range of formats are valid, from exact timestamps to loose human expressions.
3. Everything is optional. A bare place name is valid. An activity with just a name is valid. You add fields as your plans take shape. The only required part of any item is its kind and name.
Note: Names must be valid YAML strings. If a name could be read as something else by YAML — for example a number like
2026, or the wordnull— wrap it in quotes:place: "2026",activity: "null".
A crumb can mix any level of detail freely. One place can have confirmed dates and a booking reference; another in the same itinerary can be just a name. Dates, durations, and locations are all independent — a place can have an exact arrival date and a rough duration, or a specific hotel location and no dates at all. The right level of precision for any field is the level that reflects what's actually known. An approximate value like
early Octobersignals an estimate; a missing field signals that the information isn't available.
Every block has a fixed set of fields, listed in that block's field table. The single exception is
info, which holds custom key-value pairs of your choosing. Anything that is not a listed field belongs underinfo— see MetadataList.
Top-level Fields
A Crumb document has two top-level keys: trip and itinerary.
trip
Optional metadata about the trip.
trip:
name: Japan in 2 Weeks
author: Ana Yamamoto
duration: 2 weeks
tags: [asia, food, city]
note: A two-week circuit through Japan's most iconic cities.
| Field | Type | Description |
|---|---|---|
name |
string | Title of the trip |
author |
string | Name or handle of the person who wrote this crumb |
duration |
Duration | Total length of the trip. |
tags |
list | Keywords describing the trip style and focus |
info |
MetadataList | Supplementary key-value details (e.g. booking platform, guide website, trip code) |
note |
Text | Free-text description of the trip |
When a crumb describes a single place — a city guide, a weekend —
trip-levelnote,tags, andinfoare the natural home for the document's metadata; the lone place can stay bare rather than repeating them.
itinerary
An ordered list of items. Order implies chronological sequence. The default kind is place; the other kind is transport.
itinerary:
- Tokyo
- transport: train
to: Kyoto
- Kyoto
Itinerary: Places
A place is the default kind in the itinerary. A bare string is a place with a name and nothing else. To add fields, write a mapping keyed place, whose value is the name, with the place's fields as sibling keys.
# Minimal
- Tokyo
# With fields
- place: Tokyo
duration: 5 nights
tags: [city, food]
note: Base yourself in Shinjuku.
The place fields are siblings of the place key, at the same indentation — they are not nested underneath it.
A place's stays, activities, and day-plans all live in its plan — a single ordered list, described below.
- place: Tokyo
arrives: 2026-09-10
departs: 2026-09-15
duration: 5 nights
location: Tokyo, Japan
tags: [city, food, culture]
plan:
- stay: Shinjuku Granbell Hotel
arrives: 2026-09-10
departs: 2026-09-15
- activity: Senso-ji Temple
priority: must
tags: [temple, landmark]
info:
guide: https://www.gotokyo.org
note: Get a **Suica card** on arrival.
| Field | Type | Description |
|---|---|---|
arrives |
Moment | When you arrive at this place |
departs |
Moment | When you leave this place |
duration |
Duration | How long you are spending here |
location |
Geolocation | Geographic reference for this place |
tags |
list | Keywords |
plan |
list | What happens here — see Plan |
info |
MetadataList | Supplementary key-value details |
note |
Text | Free-text description or tips |
Plan
A place's plan is one ordered list holding everything that happens there. Its default kind is activity; a bare string is an activity. The other kinds — accommodation and activity groups — announce themselves with a keyword key:
| Key | Kind | |
|---|---|---|
(bare string) / activity |
an activity | the default |
stay |
accommodation | see Stay |
day / week / group |
an activity group | see Activity groups |
- place: Kyoto
plan:
- Fushimi Inari # bare string — an activity
- activity: Nishiki Market # an activity with fields
priority: must
- stay: Gion Hatanaka Ryokan # accommodation
duration: 3 nights
- day: Temple hopping # an activity group
time: 2026-09-16
plan:
- Kinkaku-ji
- Ryoan-ji
Because the list is ordered, stays and activities can be interleaved in the sequence they actually happen — useful when you change hotels mid-visit, or want a stay to sit between the activities around it.
An activity placed directly in the plan is loose — a thing to do at the place, not tied to a specific day. Placing an activity inside a day or week group schedules it to that day or week. An activity is normally listed once: either loose in the plan, or within a single group.
Stay
A stay is accommodation within a place's plan. Its value is the property name; fields are siblings. Multiple stays are supported for when you leave and return, or switch hotels mid-visit.
# Minimal
- place: Kyoto
plan:
- stay: Gion Hatanaka Ryokan
# With fields
- place: Kyoto
plan:
- stay: Gion Hatanaka Ryokan
arrives: 2026-09-15
departs: 2026-09-18
duration: 3 nights
location: Nijo, Gion, Kyoto
tags: [ryokan]
info:
website: https://www.hatanaka.jp
reference: ABC123
note: Traditional **kaiseki** dinner included.
| Field | Type | Description |
|---|---|---|
arrives |
Moment | Arrival date or time |
departs |
Moment | Departure date or time |
duration |
Duration | Length of stay |
location |
Geolocation | Property address or coordinates |
tags |
list | Keywords |
info |
MetadataList | Supplementary key-value details |
note |
Text | Free-text notes |
Activities
An activity is the default kind in a plan. A bare string is an activity with a name and nothing else. To add fields, write a mapping keyed activity, whose value is the name, with fields as siblings.
A bare activity name is enough for anything worth noting but not yet planned in detail. Add
mustfor definite priorities,maybefor things that depend on time or mood. Fields liketimeanddurationsuit activities where scheduling actually matters — leave them out for anything more loosely planned.
- activity: Senso-ji Temple
priority: must
tags: [temple, landmark]
time: 8am
duration: 1h30m
location: Asakusa, Tokyo
info:
tripadvisor: https://www.tripadvisor.com/example
note: |
Arrive before **8am** to avoid crowds.
The side streets around the temple are worth exploring too.
| Field | Type | Description |
|---|---|---|
priority |
enum | How important this activity is: must, maybe |
tags |
list | Activity type and practical keywords |
time |
Moment | When to go |
duration |
Duration | How long it takes |
location |
Geolocation | If different from the parent place |
info |
MetadataList | Supplementary key-value details |
note |
Text | Free-text tips or description |
Activity groups
An activity group collects activities into a named unit — useful for day-by-day planning. A group is keyed by its kind, and its value is an optional title. Its activities go in a nested plan. Activity groups cannot be nested, and a group's plan holds only activities.
Valid group kinds:
| Kind | Meaning |
|---|---|
day |
A single day. |
week |
A single week. |
group |
An unscheduled group — a themed collection, alternatives, or ideas. Its contents are not part of the itinerary's chronological sequence. |
# A day — title omitted
- day:
plan:
- Fushimi Inari
- Kinkaku-ji
# A day — with title and time
- day: Temple hopping
time: 2026-09-16
plan:
- activity: Fushimi Inari
time: 8am
- activity: Kinkaku-ji
time: 11am
# A week
- week: First week in Tokyo
plan:
- Senso-ji Temple
- teamLab Planets
- Shibuya Crossing
# An unscheduled group — for themed ideas or alternatives
- group: Rainy day alternatives
plan:
- teamLab Planets
- Mori Art Museum
- Shibuya shopping
When a day or week group has no explicit time, it begins the day or week immediately following the previous group, starting from the place's arrival date. An explicit time on any group resets the sequence from that point. group groups are never part of this sequence.
| Field | Type | Description |
|---|---|---|
| (value) | string | The group's title. Optional. |
time |
Moment | When this group takes place. Defaults to next day for day groups and next week for week groups when omitted. |
duration |
Duration | How long this group spans. Optional on all group kinds. |
plan |
list | Bare or detailed activities. No nested groups, no stays. |
Itinerary: Transport
A transport leg connects two places. It is a mapping keyed transport, whose value is the mode. The surrounding places in the list determine the departure and arrival points — you only need to be explicit when the actual point differs from the place name, such as a specific airport.
# Mode only — no fields needed
- transport: train
# With fields
- transport: train
to: Kyoto
# With full detail
- transport: flight
from: Tokyo Haneda
to: Osaka Kansai
departs: 2026-09-12T07:30+09:00
arrives: 2026-09-12T08:45+09:00
info:
operator: ANA
reference: NH425
note: Check in at least **2 hours** before departure.
The available transport modes are:
train, flight, bus, car, ferry, walk, bike, other
Use other when the mode doesn't fit any of the above.
A bare string in the itinerary is always a place. A transport leg always uses the
transportkey — so- trainis a place named "train", while- transport: trainis a transport leg.
| Field | Type | Description |
|---|---|---|
from |
Geolocation | Departure point. When absent, the nearest preceding place in the itinerary is used. |
to |
Geolocation | Arrival point. When absent, the nearest following place in the itinerary is used. |
departs |
Moment | Departure time |
arrives |
Moment | Arrival time |
duration |
Duration | Journey time. Computed from departs and arrives when both carry a UTC offset. |
info |
MetadataList | Supplementary key-value details |
note |
Text | Free-text notes |
Tip: For flights and cross-timezone transport, include a UTC offset in
departsandarrives(e.g.2026-06-01T10:00+09:00). When both carry an offset,durationis computed from the elapsed UTC time, correctly accounting for timezone differences.
Field Types
Crumb uses a small set of named types, referenced consistently in every field table throughout this document.
Primitive types
| Type | Description | Example |
|---|---|---|
string |
Plain text | place: Tokyo |
number |
Decimal number | lat: 34.9671 |
list |
YAML list of strings, flow or block syntax | tags: [food, city] |
enum |
String with a fixed set of valid values | priority: must |
Special field types
These types have their own grammar, defined in the Field Reference section below.
| Type | Description |
|---|---|
Duration |
A duration string — 2h30m, 3 nights, around 2 hours. |
MetadataList |
A map of custom key-value pairs for supplementary details. |
Geolocation |
A place name, address, or a block with optional address, lat, lng. |
Text |
Free-text string supporting CommonMark markdown. Supports multiline content. |
Moment |
Any temporal expression — exact, relative, or a natural language label. |
Field Reference
This section describes the grammar for each special field type.
Duration
How long something takes or lasts. Accepts shorthand, plain English, named spans, and any of these with a modifier expressing uncertainty.
Used on: places, stays, activities, activity groups, transport legs.
Exact values suit confirmed or well-known durations. Approximate, minimum, or range forms suit estimates —
around 3 nightsor2-3 hoursis more accurate than a precise value that isn't actually known. Named spans likeall dayorhalf daywork when an activity fills a period without a specific hour count. Leave the field out when the length is unknown.
Shorthand
Compact unit codes. Compound forms are accepted.
duration: 30m
duration: 2h
duration: 2h30m
duration: 3d
duration: 2w
duration: 3n # nights
Units: h (hours), m (minutes), d (days), w (weeks), n (nights)
Plain English
duration: 30 minutes
duration: 2 hours
duration: 2 hours 30 minutes
duration: 3 days
duration: 2 weeks
duration: 3 nights
Units: minute, minutes, hour, hours, day, days, night, nights, week, weeks
Named spans
Fixed labels for durations that are better described by name than by quantity. Each has an approximate equivalent that tools can use for scheduling.
| Value | Estimate |
|---|---|
all day |
10 hours |
half day |
5 hours |
overnight |
1 night |
duration: all day
duration: half day
duration: overnight
Approximate
The author has a reasonable estimate but not a fixed value.
# Numeric
duration: around 2h
duration: around 30 minutes
duration: around 3 nights
# Named span
duration: around all day
duration: around overnight
Minimum
A lower bound — the activity or stay takes at least this long.
# Numeric
duration: at least 1h
duration: at least 3 nights
duration: at least 2 days
# Named span
duration: at least half day
duration: at least overnight
Range
A bounded estimate. Both to and - are accepted as separators and produce identical output.
# Numeric with "to"
duration: 2 to 3 hours
duration: 1 to 2 weeks
duration: 2h to 3h
# Numeric with hyphen
duration: 2-3 hours
duration: 1-2 weeks
duration: 2h-3h
# Named span range
duration: half day to all day
MetadataList
A map of custom key-value pairs for supplementary details. Each key is a user-defined string label. Each value is a string or number — numbers are common for travel metadata such as flight numbers, confirmation codes, and gate numbers.
info is the only block that accepts arbitrary keys. Every other block has a fixed set of fields; anything custom belongs here.
info:
website: https://www.kikunoi.jp
tripadvisor: https://www.tripadvisor.com/example
reservation: KIK-882
dress-code: Smart casual
Used on: trip, places, stays, activities, transport legs.
Geolocation
A geographic reference. Write it as a plain string or as a block with any combination of the fields below.
A plain string is enough for any named location — a city, a neighbourhood, a landmark. Include coordinates only when a specific map pin matters beyond what a name provides. Use
location: nonewhen no geographic reference applies. Leave the field out when location isn't relevant.
# Plain string — a name or address
location: Fushimi Inari Taisha, Kyoto
# Block
location:
address: 68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto
lat: 34.9671
lng: 135.7727
# Opt out of geocoding
location: none
| Field | Type | Description |
|---|---|---|
address |
string | Street address |
lat |
number | Latitude in decimal degrees |
lng |
number | Longitude in decimal degrees |
A plain-string Geolocation carries the location's name or address directly; the surrounding item already supplies a name for display. The block form is for a precise address or coordinates.
An item's name is a label, not a geographic reference: a descriptive name (Dotonbori at night) does not locate a place. Add a location — a plain place-name string is enough (location: Dotonbori) — to give the item a geographic reference; coordinates are for when an exact point is required.
Geolocation used on: places, stays, activities.
from and to on transport legs follow the same grammar.
location: none
location: none marks a place, stay, or activity as having no geographic coordinates. Coordinate lookup and map placement are outside the scope of the Crumb format. Useful for unnamed waypoints, intentionally abstract places, or privacy.
- place: Somewhere private
location: none
duration: 2 nights
location: none opts out of geographic reference. location: null is not valid.
Text
Free-text string supporting CommonMark markdown. Use YAML's literal block scalar | for multiline content.
# Single line
note: Go **early** to avoid crowds. See the [official site](https://www.example.com).
# Multiline
note: |
Go **early** to avoid crowds.
The upper shrine is less visited and worth the extra hike.
Used on: trip, places, stays, activities, transport legs.
Moment
A temporal expression — from a precise machine datetime to a loose human label. Machine formats follow ISO 8601. Human formats are English, culturally neutral, and kept to a minimal vocabulary.
Used on: time on activities and activity groups, arrives/departs on places, stays, and transport legs.
Machine formats suit confirmed dates and times. Human month-year or approximate forms suit plans still taking shape —
September 2026,early October, orfall 2026is more honest than a specific date that isn't actually known. Named periods likemorningorafternoonwork for time-of-day when the exact time doesn't matter. Leave the field out entirely when timing is unknown.
Machine date
ISO 8601 date. Always an absolute date.
arrives: 2026-09-15
Machine time
ISO 8601 24-hour time. Assumed local to the nearest place context.
time: 09:15
time: 14:30
Machine datetime
ISO 8601 datetime. Without a UTC offset, local time is assumed.
departs: 2026-09-15T09:15
departs: 2026-09-18T23:00+09:00
arrives: 2026-09-19T06:00+01:00
Human date — with year
Month-name formats. Both day-before-month and month-before-day are accepted. Abbreviations and ordinal suffixes (st, nd, rd, th) are accepted. Always an absolute date.
arrives: September 15, 2026
arrives: 15 September 2026
arrives: Sep 15, 2026
arrives: 15th of September 2026
arrives: September 15th, 2026
Human date — without year
Same formats as above, without the year. Assumed to be the current or next upcoming occurrence of that date.
arrives: September 15
arrives: 15 September
arrives: Sep 15
arrives: 15th of September
Human date — month and year
For itineraries where no specific day is known yet.
arrives: September 2026
arrives: Sep 2026
Human date — month only
For early-stage planning when only the month is known.
arrives: September
arrives: Sep
Human date — approximate
For planning when you know roughly when something will happen but not a specific date. Year may be omitted — when it is, the current or next upcoming occurrence is assumed, the same rule as "Human date — without year".
arrives: early October 2026
arrives: mid March 2026
arrives: late October
arrives: sometime in October 2026
arrives: around October 15, 2026
arrives: around 15 October
Seasons are also valid approximate dates. Year is required for season forms.
arrives: spring 2026
arrives: summer 2026
arrives: fall 2026
arrives: autumn 2026
arrives: winter 2026
All recognized approximate forms:
| Form | Example |
|---|---|
early [Month] [Year?] |
early October 2026, early October |
mid [Month] [Year?] |
mid March 2026, mid-March |
late [Month] [Year?] |
late October 2026, late October |
sometime in [Month] [Year?] |
sometime in October 2026 |
around [Month] [Day][, Year?] |
around October 15, 2026, around October 15 |
around [Day] [Month] [Year?] |
around 15 October 2026 |
spring [Year] |
spring 2026 |
summer [Year] |
summer 2026 |
fall [Year] / autumn [Year] |
fall 2026, autumn 2026 |
winter [Year] |
winter 2026 |
Human time
12-hour clock. Case-insensitive. Space between number and am/pm is optional.
time: 9am
time: 3pm
time: 9:15am
time: 3:30pm
time: 9:00 AM
time: 11:30 PM
Named period
A fixed vocabulary of time-of-day labels.
| Value | Refers to |
|---|---|
early morning |
5am – 8am |
morning |
8am – noon |
midday |
11am – 1pm |
afternoon |
noon – 5pm |
late afternoon |
4pm – 7pm |
evening |
6pm – 10pm |
night |
9pm – midnight |
late night |
midnight – 5am |
midnight |
around midnight |
time: early morning
time: morning
time: midday
time: afternoon
time: late afternoon
time: evening
time: night
time: late night
time: midnight
Relative
Position within a place stay, relative to the place's arrival date. When no arrival date is set, relative values describe position within the stay without resolving to a calendar date.
# Ordinal day — two equivalent forms. Day 1 / 1st day = arrives date.
time: Day 1
time: 1st day
time: Day 3
time: 3rd day
# Ordinal week — two equivalent forms. Week 1 / 1st week = arrives date.
time: Week 1
time: 1st week
time: Week 2
time: 2nd week
# Named anchors
time: first day # same as Day 1
time: last day # final day of place stay
# Weekday — next occurrence on or after arrives date, inclusive
time: Monday
time: Friday
time: Saturday
# Sequence stepper — for day and week groups
time: next day # the next day in this place, starting from arrival
time: next week # the next week in this place, starting from arrival
next day and next week are the natural way to sequence day and week groups. Each group starts the day or week after the previous one. You rarely need to write them — they are the default when no time is given on a day or week group.
Combinations
Any date form and any time or named period can be combined using at. A weekday followed directly by a named period does not require at.
# Machine date + human time
time: 2026-09-15 at 9am
# Machine date + named period
time: 2026-09-15 at morning
# Human date + human time
time: September 15 at 9am
time: 15th of September at 3pm
# Human date + named period
time: September 15 at morning
time: September 15, 2026 at late afternoon
# Weekday + human time
time: Monday at 9am
time: Friday at 3pm
# Weekday + named period — no "at" needed
time: Monday morning
time: Friday evening
time: Saturday night
For tool builders
- Parser Reference — parsing pipeline, field resolution rules, worked example
- Output Data Model — CrumbDocument TypeScript interfaces
- Authoring Guide — compact format reference for AI systems generating crumbs
AI Authoring Guide
How to have an AI draft your crumb from a plain-language description, then open it in the editor.
A crumb is just plain text with a small, friendly vocabulary, so an AI is very good at writing one. Describe your trip in plain language and a chatbot can draft the whole itinerary as valid Crumb — it's the fastest way to a first version. Let the model rough out the structure, then open the result in the live editor to refine it.
Generate your crumb
Use the ready-made prompt below. It points the model at the format spec, has it ask a few questions about your trip, then write the crumb for you. Copy it into a new chat, or launch it straight into ChatGPT or Claude — and if the model can't read the linked spec, hand it the file with Download the guide.
Read the AI guide for the Crumb itinerary format here: https://raw.githubusercontent.com/renancamm/crumb/main/spec/crumb-for-ai.md (if you can't access the link, ask me to upload the spec instead).
Then ask me 3-5 short questions about the trip I'm planning, covering things like destination or region, rough dates or duration, and the overall vibe or focus I'm going for.
Once I've answered, write a valid .crumb file for the trip following the spec exactly. Output it as a fenced YAML code block so I can copy it.
The model will ask a handful of questions, then reply with a fenced YAML block. You don't need to read it closely yet — the next step puts it on a map.
Bring it into the editor
Copy the YAML the model produced, open the live editor, and paste it in. The map and timeline render as you type, so you can immediately see the route, the days, and anything the model got wrong — then fix it by hand or ask the model to revise. When you're happy, save the file straight out of the editor.
Tips
- Say what's known, skip the rest. A bare list of cities is already a valid crumb; add dates, stays, and activities as the plan firms up. The model doesn't need to invent detail you don't have.
- Loose values are fine. "September", "morning", "around 2h" are all valid — match precision to what you actually know.
- Always check it in the editor. Models occasionally invent fields or misplace indentation; the editor's live lint and map make those obvious at a glance.
- Iterate in the chat. Once the model has the format in context, follow-ups like "add a day trip to Pisa" or "fly home from Rome instead" keep producing valid Crumb.
For the complete vocabulary, see the Format Specification.
Embedding
Put a crumb's interactive map on your own site or blog, with nothing to set up.
A crumb's interactive map is self-contained: it is a single page that runs
entirely in the browser, with no server, build step, or API key. To put one on
your own site or blog, you embed embed.html in an <iframe> and hand it a
crumb. This page covers every way to do that.
The easiest path is the live editor: open your crumb, choose Embed, and copy the snippet it generates. The rest of this page explains what that snippet does and the other options, so you can wire an embed by hand.
The generated snippet
The editor's Embed button produces an <iframe> plus a tiny script that
hands the crumb to the embed once it is ready:
<iframe src="https://your-host/embed.html" width="100%" height="480" loading="lazy" style="border:0;border-radius:12px"></iframe>
<script>
(function(){var f=document.currentScript.previousElementSibling,c=/* your crumb, as a JSON string */;
window.addEventListener("message",function(e){if(e.source===f.contentWindow&&e.data&&e.data.type==="crumb:ready")
f.contentWindow.postMessage({type:"crumb:load",crumb:c},"*");});})();
</script>
The crumb's text is baked into the snippet as a string, so the embed has no file
to fetch — paste it into any HTML page and it works. The handshake (crumb:ready
→ crumb:load) is what makes this robust for lazy iframes; see
How the handshake works below.
How to give the embed a crumb
embed.html is generic and content-agnostic — it ships no itinerary of its own.
You give it one in one of two ways.
By URL — ?src=
Point the embed at a hosted .crumb file and it fetches it:
<iframe src="embed.html?src=https://your-host/trips/japan.crumb"
width="100%" height="480" style="border:0"></iframe>
An optional &geo= parameter points at a baked geocode cache (a .geo.json
file) so known places resolve with zero network requests:
<iframe src="embed.html?src=https://your-host/japan.crumb&geo=https://your-host/japan.geo.json"></iframe>
Use this when the crumb already lives at a stable URL. The trade-off is the extra fetch (and that the file must be reachable and CORS-permitted from the host page).
Inline — postMessage
Send the crumb to the embed as data, with no file to host. This is what the
generated snippet uses. After the iframe loads, post a crumb:load message:
const frame = document.querySelector("iframe")
frame.contentWindow.postMessage({
type: "crumb:load",
crumb: "trip:\n name: My trip\nitinerary:\n - place: Lisbon", // the .crumb text
geo: { "Lisbon": { lat: 38.72, lng: -9.14 } } // optional baked cache
}, "*")
The same message also swaps the crumb at runtime — post it again with a
different crumb and the map re-renders in place, no reload. (The landing page
uses exactly this to flip its hero map between detail levels.)
How the handshake works
An iframe may finish loading before or after the host page is ready to talk to
it — especially with loading="lazy". To make timing irrelevant, an embed that
has no ?src and no baked-in data announces itself to its parent:
embed ── postMessage({ type: "crumb:ready" }) ──▶ host
host ── postMessage({ type: "crumb:load", crumb, geo }) ──▶ embed
So instead of racing the iframe's load event, you wait for its crumb:ready
and reply with the data. That is the whole of the generated snippet's script.
The card variant — ?card
Add ?card to get a compact map-plus-legend card instead of the full map UI:
<iframe src="embed.html?card&src=https://your-host/japan.crumb"
width="100%" height="200" style="border:0"></iframe>
The card shows the map alongside a small trip header (name + note) and the
overview as a legend — good for a gallery of trips or an inline preview. ?card
combines with either delivery method (?src= or inline postMessage).
Sizing and styling
The embed fills its iframe, so size it from the host page:
| Attribute | Notes |
|---|---|
width / height |
Set on the <iframe>. width="100%" with a fixed height (e.g. 480) is a good default; cards are shorter. |
loading="lazy" |
Defers offscreen embeds. The handshake makes this safe. |
style="border:0;border-radius:12px" |
The embed has no border of its own; round and frame it from the host. |
allow="fullscreen" |
Optional — lets the embed's expand control go fullscreen. |
The embed follows the viewer's own light/dark theme (it honours the host's
prefers-color-scheme); the map tiles stay light in both.
What you are not shipping
There is no Crumb runtime to install on your site and no account to create. The
embed is a static page that parses and renders the crumb in the visitor's
browser. Geocoding (turning place names into map pins) happens lazily,
browser-side, against the public Nominatim service — or not at all if you supply
a baked geo cache. If you would rather render a crumb yourself instead of
embedding this viewer, see the Parser Reference and
Data Model.
Parser Reference
How a parser turns crumb text into a resolved document, pass by pass.
This document is for tool builders. It defines the complete parsing pipeline for a Crumb document — from raw YAML to a fully resolved CrumbDocument value.
Parsing runs in three passes. Pass 1 reads the raw YAML and classifies every node into a typed tree without interpreting any field values. Pass 2 resolves each field value into its output type. Pass 3 fills in everything implied by the structure — inferred dates, assembled groups, and resolved contradictions. Each pass takes the output of the previous one as its input.
Parsers are forgiving throughout. Invalid or unrecognised values are stored as-is rather than causing errors. The only hard failure is a YAML parse error at the document level — everything else degrades gracefully.
The TypeScript implementation lives in src/parser/:
pass1-classify.ts— implements Pass 1pass2-resolve.ts— implements Pass 2pass3-infer.ts— implements Pass 3
Pass 1 — Structure
Input: raw YAML. Output: RawCrumbDocument.
1.1 Document validation
- A valid Crumb document is a YAML file whose root is a mapping.
- The root mapping must contain at least one of the keys
triporitinerary. - If neither key is present, the document is invalid and parsing stops.
- If YAML parsing fails entirely, parsing stops.
- An
itineraryvalue that is an empty list is valid. - Unknown root-level keys are ignored.
1.2 Itinerary items
Each item in itinerary is either a bare string or a single-key mapping. Classify as follows:
- A bare string whose value exactly matches a transport keyword in lowercase →
RawTransportLegwith no fields. - A single-key mapping whose key exactly matches a transport keyword in lowercase →
RawTransportLegwith fields from the mapping value. - Any other bare string →
RawPlacewith that string asnameand no fields. - Any other single-key mapping →
RawPlacewith the key asnameand fields from the mapping value. - Items that are not a string or single-key mapping are ignored.
Transport keywords (case-sensitive, lowercase only): train, flight, bus, car, ferry, walk, bike, transport. A capitalised form such as Train is a place name, not a transport leg.
YAML string note: Place names must be valid YAML strings. YAML parses bare values such as null, true, false, yes, no, on, off, and plain numbers as non-string scalars — these will not be classified as places. Quote them when needed: - "null", - "2026". Items that fail to parse as a string or single-key mapping are silently ignored.
1.3 Place fields
Recognised fields on a RawPlace node: arrives, departs, duration, location, tags, stay, activities, info, note. All other keys are ignored.
staymust be a YAML list. A non-list value is ignored andstayis treated as absent.activitiesmust be a YAML list. A non-list value is ignored andactivitiesis treated as absent.tagsandinfomust be YAML lists. Non-list values are stored as-is and resolved in Pass 2.
1.4 Transport fields
Recognised fields on a RawTransportLeg node: from, to, departs, arrives, duration, info, note. All other keys are ignored.
1.5 Activity items
Each item in activities is either a bare string or a single-key mapping. Classify as follows:
- A bare string →
RawActivitywith that string asnameand no other fields. - A single-key mapping whose key exactly matches an activity group keyword →
RawActivityGroupwith that keyword askind. - A single-key mapping whose key does not match an activity group keyword →
RawActivitywith the key asnameand fields from the mapping value. - If the value of a detailed activity mapping is not itself a mapping, treat it as a bare
RawActivitywith just the name. - Items that are not a string or single-key mapping are ignored.
Activity group keywords (case-sensitive, lowercase only): day, week, plan.
1.6 Activity group fields
A RawActivityGroup node is produced in one of two forms:
Shorthand form — the mapping value is a YAML list.
- Treat the list directly as
items. Each item is classified as an activity item per 1.5, except that nestedRawActivityGroupitems are ignored.
Detailed form — the mapping value is a YAML mapping.
- Recognised fields:
title,time,duration,items. itemsmust be a YAML list. A non-list value is ignored anditemsis treated as an empty list.- Each item in
itemsis classified as an activity item per 1.5, except that nestedRawActivityGroupitems are ignored. - All other keys are ignored.
If the mapping value is neither a list nor a mapping, the group has no items and no fields.
1.7 Stay items
Each item in stay is either a bare string or a single-key mapping. Classify as follows:
- A bare string →
RawStaywith that string asnameand no other fields. - A single-key mapping →
RawStaywith the key asnameand fields from the mapping value. - Items that are not a string or single-key mapping are ignored.
Recognised fields on a RawStay node: arrives, departs, duration, location, tags, info, note. All other keys are ignored.
Pass 2 — Field resolution
Input: RawCrumbDocument. Output: resolved node tree — same structure as RawCrumbDocument but with all RawMoment, RawDuration, and RawGeolocation fields replaced by their resolved counterparts. priority is narrowed to Priority or omitted. tags is validated to string[] or absent. info is validated to MetadataItem[]. note is validated to string or absent. trip.duration is resolved from a raw string to ResolvedDuration using the same rules as all other duration fields.
Every field value is resolved independently. Resolution never inspects neighbouring nodes — that is the job of Pass 3. The original string is always preserved in label on ResolvedMoment and in label on ResolvedDuration, regardless of whether resolution succeeds.
Valid input forms for all field types are defined in crumb-spec.md. This section defines how each form is classified and what output type it produces.
2.1 Moment → ResolvedMoment
Resolve each Moment string into a ResolvedMoment. The string is parsed into an optional date part, an optional time part, and a label preserving the original. All three can coexist independently.
Combination forms (X at Y, weekday + named period): Split on at first. The left side is resolved as the date part; the right side as the time part. A weekday followed directly by a named period (no at) is split on the first named period keyword.
Date part — DateRef:
| Input form | Precision | Notes |
|---|---|---|
YYYY-MM-DD |
absolute | |
YYYY-MM-DDTHH:MM |
absolute | Date extracted; time resolved separately |
YYYY-MM-DDTHH:MM±HH:MM |
absolute | Date extracted; UTC offset stored on time part |
| Month-name + day + year | absolute | Normalised to YYYY-MM-DD. Full and abbreviated month names accepted. Day-before-month and month-before-day both valid. Ordinal suffixes (st, nd, rd, th) accepted and stripped. |
| Month-name + day (no year) | relative | Resolved against current year at parse time in Pass 3; rolls forward if date has passed. Stored as-is in value. |
| Month-name + year (no day) | relative | Month precision only. Stored as-is in value. |
| Month-name only | relative | No day or year. Stored as-is in value. |
early [Month] [Year?] |
approximate | estimate = 5th of that month. Year inferred if omitted: current year if month is upcoming, next year if passed. |
mid [Month] [Year?] / middle of [Month] [Year?] |
approximate | estimate = 15th of that month. Same year inference rule. |
late [Month] [Year?] |
approximate | estimate = 25th of that month. Same year inference rule. |
sometime in [Month] [Year?] |
approximate | estimate = 15th of that month. Same year inference rule. |
around [Month] [Day][, Year?] / around [Day] [Month] [Year?] |
approximate | estimate = that calendar date. Ordinal suffixes accepted and stripped. Same year inference rule. |
spring [Year] |
approximate | estimate = Apr 1 of that year. Year required. |
summer [Year] |
approximate | estimate = Jul 1 of that year. Year required. |
fall [Year] / autumn [Year] |
approximate | estimate = Oct 1 of that year. Year required. |
winter [Year] |
approximate | estimate = Jan 1 of year+1 (e.g. winter 2026 → 2027-01-01). Year required. |
Day N / Nth day (N a positive integer) |
relative | Both forms equivalent. Day 0 / 0th day invalid — treated as unrecognised. |
Week N / Nth week (N a positive integer) |
relative | Both forms equivalent. Week 0 invalid. |
first day |
relative | Equivalent to Day 1. |
last day |
relative | Requires place departs to resolve in Pass 3. Stored as-is otherwise. |
next day |
relative | Scope-aware stepper. Resolved in Pass 3 step 3.5. |
next week |
relative | Scope-aware stepper. Resolved in Pass 3 step 3.5. |
Monday through Sunday |
relative | Resolved to next occurrence on or after place arrives in Pass 3 step 3.5. |
| Unrecognised | — | date absent; string stored in label only. |
Time part — TimeOfDay:
| Input form | Precision | Notes |
|---|---|---|
HH:MM |
exact | 24-hour. Normalised to "HH:MM" string. |
H:MMam, Ham, H:MM AM, HAM and variations |
exact | 12-hour. Normalised to 24-hour. Case-insensitive. Space before am/pm optional. |
| Named period | loose | Normalised to canonical LoosePeriod value; estimate assigned. |
| Unrecognised | — | time absent. |
Named period canonical values and sort estimates:
| Input | LoosePeriod value |
Estimate |
|---|---|---|
early morning |
"early morning" |
06:00 |
morning |
"morning" |
09:00 |
midday |
"midday" |
12:00 |
afternoon |
"afternoon" |
14:30 |
late afternoon |
"late afternoon" |
17:00 |
evening |
"evening" |
19:30 |
night |
"night" |
22:00 |
late night |
"late night" |
02:00 |
midnight |
"midnight" |
23:59 |
No synonyms are accepted. Any value not exactly matching a named period is unrecognised and falls through to the unrecognised case.
2.2 Duration → ResolvedDuration
Parse each Duration string and classify into one of the output types below. label always preserves the original string.
Numeric values (N, M must be positive numbers; zero and negative are invalid → unknown):
| Input form | Output type |
|---|---|
Nh, Nm, NhNm, Nd, Nw, Nn |
exact |
N unit / N unit M unit |
exact |
around N unit, around Nh etc. |
approximate |
at least N unit, at least Nh etc. |
minimum |
N to M unit, Nh to Mh, N-M unit, Nh-Mh |
range |
Valid shorthand units: h (hours), m (minutes), d (days), w (weeks), n (nights).
Valid plain English units: minute, minutes, hour, hours, day, days, night, nights, week, weeks.
For range, N must be less than M. If N ≥ M, treat as unknown.
Named spans:
| Input form | Output type | span value |
Estimate |
|---|---|---|---|
all day |
named |
"all day" |
{ value: 10, unit: "hours" } |
half day |
named |
"half day" |
{ value: 5, unit: "hours" } |
overnight |
named |
"overnight" |
{ value: 1, unit: "nights" } |
around <span> |
named-approximate |
as above | as above |
at least <span> |
named-minimum |
as above | as above |
<span> to <span>, <span>-<span> |
named-range |
min, max |
minEstimate and maxEstimate from each span |
For named-range, min and max must be different spans. If the same span appears on both sides, treat as unknown.
Fallback:
Anything not matching the above → { type: "unknown", label: <original string> }.
2.3 Geolocation → ResolvedGeolocation
- The plain string
"none"→{ label: "none", geocodingDisabled: true }. Signals to renderers that this location should not be geocoded. - Any other plain string value →
{ label: <original string> }. No other fields set. - A mapping value must contain at least one of
name,address,lat, orlng. An empty mapping is ignored andlocationtreated as absent. latandlngare only valid as a pair. If one is present without the other, both are discarded.latmust be between −90 and 90 inclusive.lngmust be between −180 and 180 inclusive. Out-of-range values cause the coordinate pair to be discarded.labelis derived in order of preference:name→address→"lat,lng"string.
from and to on RawTransportLeg nodes follow the same resolution rules.
2.4 priority → Priority
"must"→Priority.must"maybe"→Priority.maybe- Any other value, or absent → field omitted from output.
2.5 MetadataList → MetadataItem[]
Each item in the info list must be a single-key mapping where the key is a non-empty string and the value is a string or number.
- The key must be a non-empty string.
- The value must be a string or number. Values of any other type are ignored.
- Items that do not meet these requirements are ignored.
- Valid items are emitted as
{ key: string, value: string | number }.
2.6 tags → string[]
tagsmust be a YAML list. A non-list value is ignored andtagsis treated as absent.- Each item must be a string. Non-string items are ignored.
- If all items are invalid,
tagsis treated as absent.
2.7 note → string
notemust be a string. A non-string value is ignored andnoteis treated as absent.- The value is stored as-is with no transformation. CommonMark markdown within the string is not parsed — that is a renderer concern.
Pass 3 — Inference
Input: resolved node tree. Output: CrumbDocument.
Pass 3 runs six steps in order. Each step may produce information that a later step depends on.
3.1 UngroupedActivities assembly
For each resolved Place in the itinerary:
- Collect all resolved Activity nodes that appear directly in
activitiesand are not inside any resolved ActivityGroup. - If at least one exists, wrap them in an
UngroupedActivitiesnode, preserving source order. - Insert
UngroupedActivitiesas the first item inPlace.activities. - Append all resolved ActivityGroup nodes from
activities, in source order, asActivityGroupnodes inPlace.activities. - If no standalone activities exist,
UngroupedActivitiesis not emitted andPlace.activitiesbegins with the firstActivityGroupin source order.
3.2 Transport endpoint inference
For each TransportLeg in the itinerary:
- If
fromis absent, scan backward through the itinerary for the nearest precedingPlace. If found, setfromto{ label: place.name }. - If
tois absent, scan forward through the itinerary for the nearest followingPlace. If found, settoto{ label: place.name }. - If no preceding or following
Placeexists, the field remains absent. - An explicitly authored
fromortois never overwritten.
3.3 Group time injection
For each Place, walk its activities array and inject a default time value on any day or week group that has no explicit time field.
- A
daygroup with notime→ settimeto{ date: { precision: "relative", value: "next day" }, label: "next day" }. - A
weekgroup with notime→ settimeto{ date: { precision: "relative", value: "next week" }, label: "next week" }. - Groups that already have a
timefield are never overwritten.
plan groups and UngroupedActivities are not affected.
After this step, every day and week group in the document is guaranteed to have a time field.
3.4 Anchor propagation
Anchor propagation gives every ResolvedMoment in the document a resolved date context where one can be determined.
Anchor sources and precedence (highest to lowest):
| Precedence | Source |
|---|---|
"transit" |
TransportLeg.departs / TransportLeg.arrives |
"place" |
Place.arrives / Place.departs |
"stay" |
Stay.arrives / Stay.departs |
"explicit" |
Any ActivityGroup.time with an explicitly authored or injected date |
"inferred" |
Duration arithmetic |
A higher-precedence anchor never overwrites one set by a higher source. Within the same precedence level, the nearest source wins.
Propagation direction: Propagation is forward-only. Once an absolute date is established at any point in the itinerary, it propagates forward to subsequent nodes. Each Place.duration and TransportLeg.duration advances the running date estimate. No backward propagation is performed.
Duration arithmetic for propagation:
Calendar-day conversion rules:
N nights= N calendar days (5 nights from Oct 10 → departs Oct 15).N days= N calendar days.N weeks= N × 7 calendar days.hoursandminutesdo not advance the calendar date anchor — they are ignored for propagation.overnight(named span) = 1 calendar day advance.all dayandhalf day(named spans) = hours only, no date advance.
Qualified durations:
approximate(e.g.around 3 nights): use the stated value as-is, same as exact.minimum(e.g.at least 3 nights): use the stated value as the estimate.range(e.g.2 to 3 nights): use the max value as the estimate.unknown: duration is ignored; no propagation.
Transport leg duration contributes to forward propagation using the same rules (hours/minutes are ignored, days/nights advance the date).
trip.duration as time budget: If trip.duration is authored and at least one explicit Place.arrives exists in the itinerary, the total is used as a budget for the even-distribution phase. When the last place has no explicit departs, a virtual end date is computed as firstExplicitArrives + trip.duration and used to bound the distribution window. Duration-less places within that window receive an even share of the remaining budget.
plan groups: plan groups and their activities do not participate in anchor propagation. Any time field on a plan group or its activities is stored as-is and receives no anchor.
Anchor fields:
anchor.date(YYYY-MM-DD) is set whenever a calendar date can be resolved.anchor.offset(1-based ordinal from itinerary start) is set in relative-only itineraries where no calendar date exists anywhere. Once any absolute date is established,offset-based anchors are promoted todateanchors where reachable.- At least one of
dateoroffsetis always present on anyAnchor.
When an anchor is set:
- When
date.precisionis"relative". - When
dateis entirely absent (e.g. a time-onlyResolvedMomentsuch as"morning"on an activity inside a resolved day group). - Never when
date.precisionis"absolute"— the date is already explicit.
When no anchor can be set: If no anchor of any kind is reachable through forward propagation, the ResolvedMoment carries no anchor. This is valid and expected for fully isolated relative values.
3.5 Relative date resolution
This step walks every ResolvedMoment in the document that has a date.precision of "relative" and attempts to resolve it to a calendar date, stored in anchor.date. The DateRef itself is never modified — the resolved date is always placed on the anchor.
approximate DateRef values are not processed in this step — their estimate is already a resolved calendar date assigned in Pass 2. They carry no anchor.
Resolution depends on what relative form was authored or injected:
Month-name + day without year (e.g. September 15)
- Resolved using the current year at parse time.
- If the resulting date has already passed within the current year, advance to the following year.
- Precedence:
"explicit".
Month-name + year without day (e.g. September 2026)
- Resolved to the first day of that month.
- Precedence:
"explicit".
Month-name only (e.g. September)
- Resolved to the first day of that month in the current year at parse time; rolls forward to the following year if the month has passed.
- Precedence:
"explicit".
Day N / Nth day / first day
- Resolved relative to the parent place's
arrivesdate.Day 1=arrives,Day 2=arrives + 1 day.first dayis equivalent toDay 1. - If no
arrivesdate is available, attempt to derive one from an adjacent transport leg or anchor propagation. - If no date context is available, stored as a display-only label and no
anchor.dateis set. - Precedence:
"explicit".
Week N / Nth week
- Resolved relative to the parent place's
arrivesdate.Week 1=arrives,Week 2=arrives + 7 days. - Same fallback and precedence rules as day ordinals.
last day
- Resolved to the parent place's
departsdate. - If no
departsdate is available, stored as a display-only label and noanchor.dateis set. - Precedence:
"explicit".
next day / next week
- Scan backward through the parent place's
activitiesarray for the nearest precedingActivityGroupthat has a resolvedanchor.date.plangroups andUngroupedActivitiesare skipped. - If a preceding anchored group is found:
next day= that group'sanchor.date+ 1 day;next week= + 7 days. Precedence:"explicit". - If no preceding anchored group exists (this is the first
day/weekgroup in the place): resolve against the placearrivesdate directly.next day=arrives;next week=arrives. Precedence:"place".- Note: For the first group,
next dayresolves to thearrivesdate — that is, Day 1 of the stay. "Next" means "the next available day slot in the sequence starting from arrival," not "the day after arrival." Subsequent groups advance by one day or one week from the previous group.
- Note: For the first group,
- If no place date context is available, stored as a display-only label and no
anchor.dateis set.
Monday through Sunday
- Resolved to the next occurrence of that weekday on or after the parent place's
arrivesdate. - If
arrivesitself falls on the named weekday, that date is used. - If no place date context is available, stored as a display-only label and no
anchor.dateis set. - Precedence:
"explicit".
Combined weekday + named period (e.g. Monday morning)
- The date part is resolved using the weekday rule above.
- The time part was already resolved to a
TimeOfDayin Pass 2. - Both results are combined into the single
ResolvedMoment.
Activity anchoring within groups:
- Each
Activityinside adayorweekgroup that has a resolved anchor inherits the group's date as its own anchor when the activity'stimehas no date or has a relative date. - Activities with an absolute
timeare not affected. - Activities inside a
plangroup are not affected by this rule — they receive no anchor from the group.
3.6 Trip duration inference
After anchor propagation (step 3.4), if trip.duration was not authored, the parser attempts to compute and set it:
- From absolute itinerary span: find the first resolved
Place.arrivesand last resolvedPlace.departsin the itinerary. Compute the total days between them. If all contributing anchors were user-authored, settrip.durationasexact; if any were inferred, set it asapproximate(i.e. prefixed with~in the label). - From place duration sum (fallback when no absolute dates exist): sum
placeDays()across all places. If the total is positive, settrip.durationas anapproximateduration in days.
If neither strategy yields a positive value, trip.duration remains absent. An already-authored trip.duration is never overwritten.
3.7 Contradiction resolution
arrives/departs vs duration on the same node:
- If both are present on a
PlaceorStay,arrivesanddepartstake precedence. durationis only used for sequencing when no explicit dates are present.
Place dates vs Stay dates:
- A
Placeand itsStayitems describe complementary scopes: place dates define the overall visit window; stay dates define the accommodation window. - For anchor propagation purposes,
placeoutranksstay. When resolving anchors for activities and groups within a place, place dates are preferred over stay dates.
Worked Example
This section walks a small crumb document through all three passes and shows the final CrumbDocument output. Use it as a reference implementation test case.
Source
itinerary:
- Kyoto:
arrives: 2026-10-12
departs: 2026-10-14
stay:
- Gion Guesthouse:
arrives: 2026-10-12
departs: 2026-10-14
activities:
- Nishiki Market:
priority: must
time: morning
- day:
title: Temple day
items:
- Fushimi Inari:
time: 8am
duration: 2h
- Kinkaku-ji:
time: 11am
duration: 1h
- day:
title: Arashiyama
items:
- Bamboo Grove:
time: morning
- Tenryu-ji:
priority: must
time: afternoon
- train:
departs: 2026-10-14T10:00
- Osaka:
arrives: 2026-10-14
duration: 2 nights
What each pass does to it
Pass 1 classifies every node. Kyoto and Osaka become RawPlace nodes. train matches a transport keyword and becomes a RawTransportLeg. Nishiki Market becomes a RawActivity. The two day mappings become RawActivityGroup nodes with kind: "day".
Pass 2 resolves all field values. ISO dates become absolute DateRef values. morning and afternoon become loose TimeOfDay values with estimates. 8am and 11am become exact TimeOfDay values. must becomes Priority. 2h and 1h become exact ResolvedDuration values. 2 nights becomes an exact ResolvedDuration.
Pass 3 runs six steps:
- 3.1 —
Nishiki Marketis standalone; it is wrapped inUngroupedActivitiesand placed first inKyoto.activities. The twodaygroups follow in source order. - 3.2 — The train has no
fromorto; both are inferred from neighbouring places:from: { label: "Kyoto" },to: { label: "Osaka" }. - 3.3 — Neither
daygroup has atime;next dayis injected on both. - 3.4 — Anchor propagation gives every relative or date-absent
ResolvedMomenta date from context.Kyoto.arrives(2026-10-12) propagates as a"place"anchor to the ungrouped activity and activities inside the first group. - 3.5 — The first
daygroup's injectednext dayhas no preceding anchored group, so it resolves to the placearrivesdate: 2026-10-12. The seconddaygroup'snext dayfinds the first group as its predecessor and resolves to 2026-10-13. Activities inside each group inherit their group's resolved date.
Final output
{
"itinerary": [
{
"type": "place",
"name": "Kyoto",
"arrives": {
"date": { "precision": "absolute", "value": "2026-10-12" },
"label": "2026-10-12"
},
"departs": {
"date": { "precision": "absolute", "value": "2026-10-14" },
"label": "2026-10-14"
},
"stay": [
{
"name": "Gion Guesthouse",
"arrives": {
"date": { "precision": "absolute", "value": "2026-10-12" },
"label": "2026-10-12"
},
"departs": {
"date": { "precision": "absolute", "value": "2026-10-14" },
"label": "2026-10-14"
}
}
],
"activities": [
{
"type": "ungrouped",
"items": [
{
"name": "Nishiki Market",
"priority": "must",
"time": {
"time": { "precision": "loose", "value": "morning", "estimate": "09:00" },
"anchor": { "date": "2026-10-12", "precedence": "place" },
"label": "morning"
}
}
]
},
{
"type": "group",
"kind": "day",
"title": "Temple day",
"time": {
"date": { "precision": "relative", "value": "next day" },
"anchor": { "date": "2026-10-12", "precedence": "place" },
"label": "next day"
},
"items": [
{
"name": "Fushimi Inari",
"time": {
"time": { "precision": "exact", "value": "08:00" },
"anchor": { "date": "2026-10-12", "precedence": "explicit" },
"label": "8am"
},
"duration": { "type": "exact", "value": 2, "unit": "hours", "label": "2h" }
},
{
"name": "Kinkaku-ji",
"time": {
"time": { "precision": "exact", "value": "11:00" },
"anchor": { "date": "2026-10-12", "precedence": "explicit" },
"label": "11am"
},
"duration": { "type": "exact", "value": 1, "unit": "hours", "label": "1h" }
}
]
},
{
"type": "group",
"kind": "day",
"title": "Arashiyama",
"time": {
"date": { "precision": "relative", "value": "next day" },
"anchor": { "date": "2026-10-13", "precedence": "explicit" },
"label": "next day"
},
"items": [
{
"name": "Bamboo Grove",
"time": {
"time": { "precision": "loose", "value": "morning", "estimate": "09:00" },
"anchor": { "date": "2026-10-13", "precedence": "explicit" },
"label": "morning"
}
},
{
"name": "Tenryu-ji",
"priority": "must",
"time": {
"time": { "precision": "loose", "value": "afternoon", "estimate": "14:30" },
"anchor": { "date": "2026-10-13", "precedence": "explicit" },
"label": "afternoon"
}
}
]
}
]
},
{
"type": "transport",
"mode": "train",
"from": { "label": "Kyoto" },
"to": { "label": "Osaka" },
"departs": {
"date": { "precision": "absolute", "value": "2026-10-14" },
"time": { "precision": "exact", "value": "10:00" },
"label": "2026-10-14T10:00"
}
},
{
"type": "place",
"name": "Osaka",
"arrives": {
"date": { "precision": "absolute", "value": "2026-10-14" },
"label": "2026-10-14"
},
"duration": { "type": "exact", "value": 2, "unit": "nights", "label": "2 nights" },
"activities": []
}
]
}
Key transformations to verify
| What to check | Expected result |
|---|---|
Nishiki Market wrapping |
Inside UngroupedActivities, first in Kyoto.activities |
Train from |
{ "label": "Kyoto" } — inferred from preceding place |
Train to |
{ "label": "Osaka" } — inferred from following place |
Temple day group time.anchor.date |
"2026-10-12" — first next day resolves to arrival |
Arashiyama group time.anchor.date |
"2026-10-13" — second next day chains from previous |
Fushimi Inari time anchor |
"2026-10-12" — inherited from group |
Bamboo Grove time anchor |
"2026-10-13" — inherited from group |
Osaka.activities |
[] — empty array, never absent |
Raw Data Model
The raw data model is the output of Pass 1. It mirrors the source structure exactly — all field values are preserved as raw strings. No interpretation has been applied. Pass 2 takes this as input and resolves field types. Pass 3 takes the resolved tree and produces the final CrumbDocument.
// ─── Raw type aliases ─────────────────────────────────────────────────────────
//
// Raw field values are unresolved strings, exactly as authored.
// Pass 2 transforms these into their resolved counterparts.
type RawMoment = string
type RawDuration = string // shorthand, plain English, named span, or modified form
// ─── RawGeolocation ──────────────────────────────────────────────────────────
//
// Mirrors the two authoring forms: plain string or block with named fields.
// lat/lng are numbers because YAML parses them as numbers natively.
// The special string "none" opts out of geocoding — preserved as-is for Pass 2.
type RawGeolocation =
| string // includes "none"
| { name?: string; address?: string; lat?: number; lng?: number }
// ─── RawActivity ─────────────────────────────────────────────────────────────
//
// type: "activity" is a Pass 1 discriminator. It does not appear on the
// final Activity interface — it exists only to distinguish RawActivity
// from RawActivityGroup in the RawActivityItem union.
//
// priority is a raw string here. Pass 2 narrows it to Priority or omits it.
interface RawActivity {
type: "activity"
name: string
priority?: string
tags?: string[]
time?: RawMoment
duration?: RawDuration
location?: RawGeolocation
info?: MetadataItem[]
note?: string
}
// ─── RawActivityGroup ────────────────────────────────────────────────────────
//
// kind is already typed as GroupKind — classification happens in Pass 1.
// activities contains the items list, already classified by Pass 1.
interface RawActivityGroup {
type: "group"
kind: GroupKind
title?: string
time?: RawMoment
duration?: RawDuration
items: RawActivity[]
}
type RawActivityItem = RawActivity | RawActivityGroup
// ─── RawStay ─────────────────────────────────────────────────────────────────
interface RawStay {
name: string
arrives?: RawMoment
departs?: RawMoment
duration?: RawDuration
location?: RawGeolocation
tags?: string[]
info?: MetadataItem[]
note?: string
}
// ─── RawPlace ────────────────────────────────────────────────────────────────
//
// activities is a flat list of RawActivity and RawActivityGroup items in
// source order. UngroupedActivities does not exist at this stage —
// it is assembled in Pass 3 step 3.1 from the standalone RawActivity items.
interface RawPlace {
type: "place"
name: string
arrives?: RawMoment
departs?: RawMoment
duration?: RawDuration
location?: RawGeolocation
tags?: string[]
stay?: RawStay[]
activities: RawActivityItem[]
info?: MetadataItem[]
note?: string
}
// ─── RawTransportLeg ─────────────────────────────────────────────────────────
//
// from/to are raw at this stage — endpoint inference happens in Pass 3 step 3.2.
interface RawTransportLeg {
type: "transport"
mode: TransportMode
from?: RawGeolocation
to?: RawGeolocation
departs?: RawMoment
arrives?: RawMoment
duration?: RawDuration
info?: MetadataItem[]
note?: string
}
type RawItineraryItem = RawPlace | RawTransportLeg
// ─── RawCrumbDocument ────────────────────────────────────────────────────────
//
// trip uses TripMeta at this stage; Pass 2 resolves trip.duration (raw string → ResolvedDuration).
// All other trip fields are strings or string arrays and pass through unchanged.
// itinerary is always an array; empty if no itinerary key is present in source.
interface RawCrumbDocument {
trip?: TripMeta
itinerary: RawItineraryItem[]
}
Data Model
The TypeScript shape a parser outputs — the contract every viewer reads.
The output data model is the contract between the parser and any tool that consumes a Crumb document. A fully parsed document is a CrumbDocument value. The parser has resolved all authored date expressions, assembled activity groups, and inferred transport endpoints and activity anchors where possible. Optional fields may still be absent — consuming tools should treat all optional fields as nullable.
The model is defined as TypeScript interfaces. The TypeScript files in src/types/ are canonical. If this document diverges from those files, the TypeScript files win.
// ─── Primitives ──────────────────────────────────────────────────────────────
type TransportMode =
| "train" | "flight" | "bus" | "car"
| "ferry" | "walk" | "bike" | "transport"
type GroupKind = "day" | "week" | "plan"
type Priority = "must" | "maybe"
type DurationUnit = "minutes" | "hours" | "days" | "nights" | "weeks"
// ─── Anchor ──────────────────────────────────────────────────────────────────
//
// A parser-inferred date context attached to a ResolvedMoment.
// Never authored — discard to round-trip back to source.
//
// Set whenever a date can be inferred from context — not only when
// date.precision is "relative", but also when date is absent entirely
// (e.g. a time-only value like "morning" inside a resolved day group).
// The only case that never carries an anchor is date.precision "absolute".
//
// date: YYYY-MM-DD — set when a calendar date can be resolved.
// offset: 1-based ordinal day from the start of the itinerary — set in
// relative-only itineraries where no calendar date is available.
// At least one of date or offset is always present.
//
// precedence records which source provided the anchor. Higher-precedence
// anchors always win in a conflict:
// "transit" — transport leg departs/arrives (highest)
// "place" — place arrives/departs
// "stay" — stay arrives/departs
// "explicit" — activity group with authored or injected date
// "inferred" — duration arithmetic (lowest)
interface Anchor {
date?: string // YYYY-MM-DD
offset?: number // 1-based day ordinal from itinerary start
precedence: "transit" | "place" | "stay" | "explicit" | "inferred"
}
// ─── LoosePeriod ─────────────────────────────────────────────────────────────
//
// Canonical named period values. No synonyms are accepted — input must
// exactly match one of these values.
//
// estimate is an HH:MM string used for chronological sorting. Loose and
// exact times share the same coordinate space — compare estimate against
// an exact HH:MM value to order them on the same day.
// All estimates are on the same anchor date — no day-crossing is applied.
type LoosePeriod =
| "early morning" // estimate 06:00
| "morning" // estimate 09:00
| "midday" // estimate 12:00
| "afternoon" // estimate 14:30
| "late afternoon" // estimate 17:00
| "evening" // estimate 19:30
| "night" // estimate 22:00
| "late night" // estimate 02:00
| "midnight" // estimate 23:59
// ─── TimeOfDay ───────────────────────────────────────────────────────────────
//
// exact: normalised 24h clock time. Any authored format (9am, 9:00 AM,
// 09:00) normalises to "HH:MM". Use value directly for sorting.
// utcOffset is present only when the authored string carried an explicit
// UTC offset (e.g. "2026-06-01T10:00+09:00" → utcOffset "+09:00").
// "Z" is normalised to "+00:00". Use utcOffset for cross-timezone
// arithmetic (e.g. flight duration); display value as local time.
//
// loose: a canonical LoosePeriod with a parser-assigned estimate for
// sorting. The original label is preserved in ResolvedMoment.label.
type TimeOfDay =
| { precision: "exact"; value: string; utcOffset?: string }
| { precision: "loose"; value: LoosePeriod; estimate: string }
// ─── DateRef ─────────────────────────────────────────────────────────────────
//
// absolute: a resolved calendar date. Always "YYYY-MM-DD".
//
// approximate: a parser-assigned midpoint calendar date for fuzzy human
// expressions ("early October 2026", "fall 2026", "around March 15").
// estimate is always "YYYY-MM-DD" — use it for calendar arithmetic only.
// The original authored text is preserved in ResolvedMoment.label.
// Never carries an Anchor — estimate is already a resolved calendar date.
//
// relative: the authored value preserved exactly — "Day 1", "1st day",
// "first day", "last day", "next day", "next week", "Monday",
// "Week 2", "September 15" (year-less), "September 2026"
// (month+year), "September" (month-only), etc.
// Never collapsed into an absolute date. An Anchor may accompany
// a relative DateRef on ResolvedMoment when a date can be inferred.
type DateRef =
| { precision: "absolute"; value: string }
| { precision: "approximate"; estimate: string }
| { precision: "relative"; value: string }
// ─── ResolvedMoment ──────────────────────────────────────────────────────────
//
// date and time are independent and both optional. Any combination is valid:
//
// date only "2026-09-15" → { date: absolute }
// human date with year "September 15, 2026" → { date: absolute }
// human date no year "September 15" → { date: relative }
// month and year "September 2026" → { date: relative }
// month only "September" → { date: relative }
// time only "9am" → { time: exact "09:00" }
// loose time only "morning" → { time: loose "morning" }
// date + exact time "2026-09-15T09:00" → { date: absolute, time: exact }
// human date + time "September 15 at 9am" → { date: absolute, time: exact }
// date + loose time "Monday morning" → { date: relative, time: loose }
// relative only "Day 1" → { date: relative }
//
// anchor: set whenever a date can be inferred from context. Present when
// date.precision is "relative" OR when date is absent entirely.
// Never present when date.precision is "absolute" or "approximate"
// (both already carry a resolved calendar date).
// Parser-inferred — never authored. Discard to round-trip back to source.
//
// label: the original input string, always preserved. Sufficient on its own
// to reconstruct the crumb. Use for display or round-tripping.
interface ResolvedMoment {
date?: DateRef
time?: TimeOfDay
anchor?: Anchor
label: string
}
// ─── NamedSpan ───────────────────────────────────────────────────────────────
type NamedSpan = "all day" | "half day" | "overnight"
// ─── DurationEstimate ────────────────────────────────────────────────────────
//
// Parser-assigned numeric estimate for a named span.
// Used internally for anchoring, ordering, and timeline estimation.
// Never displayed to the user.
//
// "all day" → { value: 10, unit: "hours" }
// "half day" → { value: 5, unit: "hours" }
// "overnight"→ { value: 1, unit: "nights" }
interface DurationEstimate {
value: number
unit: DurationUnit
}
// ─── ResolvedDuration ────────────────────────────────────────────────────────
//
// Discriminated union. "unknown" is the fallback for unrecognised strings —
// label is always preserved so a renderer can still display the original.
type ResolvedDuration =
| { type: "exact"; value: number; unit: DurationUnit; label: string }
| { type: "approximate"; value: number; unit: DurationUnit; label: string }
| { type: "minimum"; value: number; unit: DurationUnit; label: string }
| { type: "range"; min: number; max: number; unit: DurationUnit; label: string }
| { type: "named"; span: NamedSpan; estimate: DurationEstimate; label: string }
| { type: "named-approximate"; span: NamedSpan; estimate: DurationEstimate; label: string }
| { type: "named-minimum"; span: NamedSpan; estimate: DurationEstimate; label: string }
| { type: "named-range"; min: NamedSpan; max: NamedSpan; minEstimate: DurationEstimate; maxEstimate: DurationEstimate; label: string }
| { type: "unknown"; label: string }
// ─── ResolvedGeolocation ─────────────────────────────────────────────────────
//
// label: safe display string in all cases. Set to the original plain
// string when written in string form; otherwise name ?? address ?? coords.
//
// geocodingDisabled: true when the author wrote `location: none`. Renderers must
// not attempt to geocode this location. Absent otherwise.
//
// lat/lng: always present as a pair or not at all.
interface ResolvedGeolocation {
label: string
geocodingDisabled?: true
name?: string
address?: string
lat?: number
lng?: number
}
// ─── MetadataItem ────────────────────────────────────────────────────────────
interface MetadataItem {
key: string
value: string | number
}
// ─── Activity ────────────────────────────────────────────────────────────────
//
// type: "activity" — discriminator consistent with all other output types.
// priority: only present when explicitly set by the author.
interface Activity {
type: "activity"
name: string
priority?: Priority
tags?: string[]
time?: ResolvedMoment
duration?: ResolvedDuration
location?: ResolvedGeolocation
info?: MetadataItem[]
note?: string
}
// ─── UngroupedActivities ─────────────────────────────────────────────────────
//
// Wraps all standalone activities for a place into a single container.
// Only emitted when at least one standalone activity exists.
// Always the first item in Place.activities when present.
interface UngroupedActivities {
type: "ungrouped"
items: Activity[]
}
// ─── ActivityGroup ───────────────────────────────────────────────────────────
//
// kind "day": default time "next day" injected by Pass 3 step 3.3 when
// no time is authored. duration present only when explicitly
// authored.
// kind "week": default time "next week" injected by Pass 3 step 3.3 when
// no time is authored. duration present only when explicitly
// authored.
// kind "plan": not affected by time injection. Does not participate in
// next day/next week sequencing.
// duration present only when explicitly authored.
interface ActivityGroup {
type: "group"
kind: GroupKind
title?: string
time?: ResolvedMoment
duration?: ResolvedDuration
items: Activity[]
}
type ActivityItem = UngroupedActivities | ActivityGroup
// ─── Stay ────────────────────────────────────────────────────────────────────
interface Stay {
name: string
arrives?: ResolvedMoment
departs?: ResolvedMoment
duration?: ResolvedDuration
location?: ResolvedGeolocation
tags?: string[]
info?: MetadataItem[]
note?: string
}
// ─── Place ───────────────────────────────────────────────────────────────────
//
// activities: always an array, possibly empty. UngroupedActivities, if present,
// is always first. Remaining groups follow in source order.
//
// When arrives/departs and duration are both present, arrives/departs
// take precedence. duration is only used when no explicit dates exist.
//
// When a stay also carries arrives/departs, the place dates define the
// overall visit window and the stay dates define the accommodation window.
interface Place {
type: "place"
name: string
arrives?: ResolvedMoment
departs?: ResolvedMoment
duration?: ResolvedDuration
location?: ResolvedGeolocation
tags?: string[]
stay?: Stay[]
activities: ActivityItem[]
info?: MetadataItem[]
note?: string
}
// ─── TransportLeg ────────────────────────────────────────────────────────────
//
// from/to: inferred from neighbouring places in the itinerary when omitted.
// Absent when inference is not possible.
interface TransportLeg {
type: "transport"
mode: TransportMode
from?: ResolvedGeolocation
to?: ResolvedGeolocation
departs?: ResolvedMoment
arrives?: ResolvedMoment
duration?: ResolvedDuration
info?: MetadataItem[]
note?: string
}
// ─── Document root ───────────────────────────────────────────────────────────
interface ResolvedTripMeta {
name?: string
author?: string
duration?: ResolvedDuration
tags?: string[]
info?: MetadataItem[]
note?: string
}
type ItineraryItem = Place | TransportLeg
interface CrumbDocument {
trip?: ResolvedTripMeta
itinerary: ItineraryItem[] // always an array; empty if no itinerary key present
}