The Stone Maps API: Enabling Unique Experiences Across Every Core Workflow
#stonemaps#devlog#api#build
David OlssonStone Maps is a journaling app. But underneath the journal, every action is an API call. When you write an entry, that's POST /api/posts. When the Emissary responds, that's the sparse prompt system calling POST /api/conversations/[id]/messages on your behalf. When the map loads pins, that's GET /api/map/posts with a bounding box.
The API is not a secondary interface for developers — it's the primary interface, with the browser sitting on top of it. Everything the app can do, you can do from code.
This matters because the four core workflows of Stone Maps — pairing, journaling, conversation, and discovery — each become richer when they're programmable. This article documents that surface and shows what opens up.
Authentication
All API calls require a Bearer token. Generate one from Settings → API Tokens. The token is shown once — copy it immediately.
curl https://stnmps.com/api/users/me \
-H "Authorization: Bearer YOUR_TOKEN"
Tokens are hashed server-side (SHA-256). The raw token is never stored — only the hash. If you lose it, revoke and regenerate. There's no limit on the number of tokens per account; create one per integration.
Revoke a token:
curl -X DELETE https://stnmps.com/api/tokens \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "id": "token-uuid-here" }'
All endpoints return 401 for missing or invalid tokens, 429 with Retry-After headers when rate-limited, and 400 with a structured details array for validation failures.
Workflow 1: Pairing and Stone Identity
The pairing workflow establishes who you are in Stone Maps. Before any journaling can happen, a self-pair must exist — a record that binds your account to a specific stone.
Fetch your self-pairs
GET /api/self-pairs
{
"selfPairs": [
{
"id": "uuid",
"stoneId": "uuid",
"stoneName": "Dusk",
"createdAt": "2026-01-15T09:00:00Z",
"agentState": {
"conversationCount": 12,
"themes": ["light", "returning", "winter"]
}
}
]
}
agentState.themes is the Emissary's running model of what you've been noticing. It accumulates slowly. Reading it programmatically lets you build your own interface around it — a sidebar that surfaces your recurring themes, a yearly review that tracks how they evolve.
Fetch stone details
GET /api/stones/:id
{
"id": "uuid",
"kind": "stone",
"qrCode": "stone-Silver-Birch-07",
"stoneTraits": ["patient", "coastal", "attentive"],
"genesisTraits": {
"placeLikeHome": "the smell of rain on hot stone",
"qualityToHold": "patience",
"deepTime": "the chalk cliffs at the coast",
"wonderPlace": "the old forest north of the town",
"intention": "to notice more than I act"
},
"activatedAt": "2026-01-15T09:00:00Z",
"genesisCompletedAt": "2026-01-15T09:12:00Z"
}
What opens up: The genesis traits are a portrait of who you were when you started. Reading them back after months of journaling is its own kind of experience. An integration could surface them as a yearly "who you were when you began" prompt — comparing genesis intention against the themes the Emissary has tracked since.
Workflow 2: Journaling
Creating and retrieving posts is the core data operation. Every journal entry is a post.
Create a post
POST /api/posts
Content-Type: application/json
{
"text": "The light through the window is the same light it always was.",
"visibility": "private",
"location": { "lat": 51.5074, "lng": -0.1278 },
"capturedAt": "2026-04-17T07:30:00Z",
"contentType": "text"
}
{
"id": "uuid",
"text": "The light through the window is the same light it always was.",
"visibility": "private",
"location": { "lat": 51.5074, "lng": -0.1278 },
"createdAt": "2026-04-17T07:30:00Z"
}
capturedAt vs createdAt: capturedAt is when the moment happened; createdAt is when you posted it. If you're journaling retroactively — describing something from two days ago — you can pass the actual moment as capturedAt. The journal displays capturedAt as the primary timestamp. This distinction matters for honest place-records.
Visibility options: private (only you), team (visible to team members), pair (future use), public (appears on the map). Default is private.
Team and campaign scoping:
{
"text": "Third sighting of the same heron at the same bend.",
"teamId": "uuid",
"campaignId": "uuid",
"visibility": "team",
"location": { "lat": 51.4923, "lng": -0.1832 }
}
Fetch your journal
GET /api/posts?limit=20&offset=0
Posts are returned newest-first. Combine limit and offset for pagination. The full text, location, media URLs, and tags are all returned per post.
Search:
GET /api/posts?q=light&limit=10
Full-text search across post content. Useful for building a personal concordance — every time you wrote about light, or water, or the quality of silence.
Attach media
Media upload is a two-step process: request a presigned upload URL, then PUT directly to R2.
POST /api/media/upload-url
{
"fileName": "IMG_0042.jpg",
"contentType": "image/jpeg",
"postId": "post-uuid"
}
{
"uploadUrl": "https://r2.cloudflarestorage.com/...(presigned)",
"key": "posts/user-id/post-id/1713340800000-IMG_0042.jpg",
"publicUrl": "/api/r2/posts/user-id/post-id/1713340800000-IMG_0042.jpg"
}
Upload directly:
PUT <uploadUrl>
Content-Type: image/jpeg
< ./photo.jpg
Store the publicUrl — it's what you pass to POST /api/posts as a media reference, and what the app uses to render the image.
What opens up: The journaling API enables a class of integrations that would otherwise require the browser. A CLI journaling tool that accepts text from stdin and creates a post. A script that imports a folder of dated photos as posts, using EXIF timestamps as capturedAt and GPS coordinates as location. A Morning Pages habit that pipes the first 750 words you type each day into your Stone Maps journal. The data model is permissive enough for all of these.
Workflow 3: Emissary Conversation
The conversation API is the most interactive surface. It lets you initiate and continue Emissary dialogues programmatically.
Start a conversation
POST /api/conversations
{
"location": { "lat": 51.5074, "lng": -0.1278 }
}
location is optional but changes the Emissary's context. A conversation started at a specific coordinate has the possibility of that place shaping the exchange. A conversation started without coordinates is more abstract.
{
"id": "conversation-uuid",
"stoneId": "stone-uuid",
"selfPairId": "self-pair-uuid",
"messageCount": 0,
"startedAt": "2026-04-17T08:00:00Z",
"lastMessageAt": "2026-04-17T08:00:00Z"
}
Send a message
POST /api/conversations/:id/messages
{
"content": "I keep coming back to the same stretch of river.",
"senderType": "user"
}
The Emissary processes the message against your full context — genesis traits, stone traits, conversation history, recent posts — and responds. The response is returned in the same call.
Fetch conversation history
GET /api/conversations/:id/messages?limit=50
Returns messages in chronological order, each tagged with senderType: 'user' | 'emissary' and contextMetadata for automated messages.
What opens up: The conversation API is what makes the Emissary composable.
The most interesting use case: combining it with the MCP endpoint. An external AI assistant (Claude Desktop, for instance) can call start_conversation with real coordinates from your phone, then forward your spoken words as messages. The Emissary responds inside Stone Maps. The transcript is saved to your journal. The conversation happened in a place, even if you were typing it somewhere else.
Another: a daily reflection script. At 9pm each day, a cron job calls POST /api/conversations without location, then sends a message with the day's journal entries concatenated. The Emissary responds with a reflection. The response is written back to the journal as a private post tagged with that day's date. Over time, you have a record of the Emissary's observations, one per day, anchored to what you actually wrote.
Workflow 4: Map Discovery
The map API transforms place-discovery from a visual experience into a queryable one.
Nearby posts
GET /api/map/nearby?lat=51.5074&lng=-0.1278&radiusMeters=500&limit=20
Returns public posts within the radius, newest-first. Each post includes location, userId (stone name, not real name), text, and createdAt.
Bounding box
GET /api/map/posts?north=51.52&south=51.49&east=-0.10&west=-0.15
The same query the map UI uses on every pan and zoom. Results capped at 500.
Filter by team or campaign:
GET /api/map/posts?north=51.52&south=51.49&east=-0.10&west=-0.15&teamId=uuid&campaignId=uuid
What opens up: The map API makes it possible to build experiences that don't exist in the app UI.
A location history export: script that fetches all your posts with coordinates and generates a GeoJSON file, which you can drop into any map tool (QGIS, Google My Maps, Felt) for your own analysis.
A place-density report: for a team campaign, a script that aggregates all posts by grid square and identifies the most journaled locations. Where did people keep returning to?
A "what have others noticed near me" CLI tool. You're at a location; you run stonemaps nearby and get a feed of what other Stone Maps users have written within walking distance. No browser required.
The MCP Layer
Above the REST API sits the MCP endpoint. It exposes 17 curated tools — a higher-level interface designed for AI assistant use:
https://stnmps.com/api/mcp/mcp
Configure it in any MCP-compatible client with your Bearer token. The MCP layer handles tool discovery, parameter validation, and result formatting. You get the same data as the REST API, but framed as tools an AI can call fluently.
The combination is powerful: an AI assistant that can read your journal (get_journal_posts), understand your stone (get_self_pairs, get_stone_details), start a conversation with the Emissary (start_conversation), and find what others have noticed nearby (get_nearby_posts) — all in a single session, with the AI synthesizing across them.
Rate Limits
All endpoints are rate-limited. Headers on every response tell you where you stand:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 23
Retry-After: 23
When you hit the limit, the response is 429 Too Many Requests with Retry-After in seconds. Respect it; backing off and retrying is the right pattern.
Current limits by endpoint category:
| Endpoint | Max requests | Window |
|---|---|---|
POST /api/posts | 30 | 60s |
POST /api/conversations | 20 | 60s |
GET /api/map/* | 60 | 60s |
Most GET endpoints | 60 | 60s |
What the API Is Optimized For
Stone Maps is not a high-throughput API. It is not designed for real-time sync or websocket subscriptions. It is designed for:
- Deliberate writes — a post created once from a place that mattered
- Thoughtful reads — fetching your journal to reflect on it, not to display a live stream
- Contextual AI — starting a conversation with place and history as context
- Spatial queries — finding what others have noticed near where you are
If you're building something fast and interactive, the API will feel constrained. If you're building something slow and intentional — a weekly reflection script, a field documentation tool, a personal geography of everywhere you've been — it fits exactly.
The philosophy of slow over fast extends to the API. That's not an accident.