# FriendTrack — Map Creation Guide for LLM Agents > FriendTrack is a location-sharing app. Users share live GPS position with time-bound **Groups** and see each other on **custom uploaded Maps** (not Google Maps) — venue images calibrated to real-world GPS via **Anchor Points**. This document is written for an LLM agent that a human has asked to build a Map. Read it top to bottom, then follow the recipe. Every endpoint, field, and limit below matches the shipped API. > > **Base URL (production):** `https://api.friendtrack.net` > **Machine-readable companion:** the curated OpenAPI spec at `https://api.friendtrack.net/openapi/public.json` describes the auth + map endpoints programmatically. Prefer this file (llms.txt) for the *why* and the workflow; use the OpenAPI spec for exact request/response schemas. You are a **map-agent**: a third-party LLM that authenticates as a real FriendTrack User (via their phone) and builds Maps on their behalf. You cannot do anything else on their account — see "What your token can and cannot do". --- ## 1. What you are building (read this first) A **Map** is an image (e.g. a festival site plan, a resort piste map, a night-time town map) pinned to the real world so that a person standing at a GPS coordinate shows up at the correct pixel on the image. You make that pinning work by placing **Anchor Points**, then you draw **Areas of Interest** so the app can announce when someone enters "the Beer Garden" or "Piste 14". Your job, end to end: 1. Authenticate (OTP handshake — your human reads you an SMS code). 2. Create the Map (upload the image + confirm image rights). 3. Add **at least 3** well-spread Anchor Points. 4. Self-verify the calibration with `GET /map/{id}/project` and `GET /map/{id}/projection-error`. 5. Draw Areas of Interest (prefer disjoint areas). 6. Iterate. Note that once a Map is published it is **immutable** — you revise by creating a new **Map Version**. **The single most important warning is in section 8. Read it before you pick an image.** --- ## 2. Auth handshake (OTP → scoped token) Authentication is a one-time SMS code. There is no password. The User's phone number IS their identity. You cannot receive SMS, so **your human reads you the code**. ### Step 1 — request the code ``` POST /auth/send-otp Content-Type: application/json { "phoneNumber": "27228440", "countryCode": "+45" } ``` - `200 OK` `{ "message": "OTP sent successfully." }` — a code was texted to the phone. - `429 Too Many Requests` — OTP send is throttled (max 3 in the current window). Tell your human to wait, then retry. ### Step 2 — ask your human for the code Say something like: *"I've sent a 6-digit code by SMS to the phone ending 8440. Please read it back to me."* Do not proceed until they give it to you. ### Step 3 — verify with the map-agent surface ``` POST /auth/verify-otp Content-Type: application/json { "phoneNumber": "27228440", "countryCode": "+45", "otpCode": "123456", "surface": "map-agent", "deviceId": "my-agent-session-1" // optional; identifies this session } ``` `surface` **must** be the literal string `"map-agent"`. The response omits the refresh fields entirely: ```json { "token": "", "expiresAt": "2026-07-06T12:00:00Z", "phoneNumber": "27228440", "userId": "", "scope": "map-editing" } ``` - `401 Unauthorized` — wrong or expired code (3 attempts allowed). Ask your human to re-read it, or send a fresh code. Send the token on every subsequent request: ``` Authorization: Bearer ``` ### What your token can and cannot do - **Lifetime:** ~24 hours. **There is no refresh token.** When it expires, run the OTP handshake again (send-otp → ask human → verify-otp). Do not attempt `/auth/refresh` — it will not work for a map-agent token. - **Scope:** the token carries `scope: "map-editing"`. It is accepted **only** on the `/map/*` endpoints. - **CAN:** create/read/update/delete Maps you own, add/remove Anchor Points, create/update/delete Areas of Interest, run projection self-checks, create new Map Versions, submit a Map for public approval, browse public maps. - **CANNOT:** touch `/user/*` or `/group/*` (you get `403 Forbidden` — this is by design so an agent token can never read contacts, join groups, or move someone's location). It also cannot **approve** maps — approval is a Platform Admin action, not something the owning agent can self-grant. - **Ownership:** every Map you create is owned by the authenticated User. If you send an `ownerUserId` field on create, it must equal your own `userId`, or you get `403`. --- ## 3. Build recipe ### 3.1 Create the Map — `POST /map` (multipart/form-data) This is the **only** multipart endpoint. All others are JSON. | Form field | Type | Required | Notes | |---|---|---|---| | `image` | file | yes | The map image (PNG/JPEG). | | `name` | text | yes | Display name, e.g. `"Ischgl by Night"`. | | `width` | integer | yes | Image width in pixels. Must match the file. | | `height` | integer | yes | Image height in pixels. Must match the file. | | `imageRightsConfirmed` | boolean | yes | Must be `true`. Your confirmation that the User holds the rights to use this image. See section 8. | | `imageSource` | text | recommended | Free-text provenance/attribution shown to reviewers, e.g. `"© OpenStreetMap contributors, ODbL"`. | | `contentType` | text | optional | Overrides the uploaded file's content type. | | `ownerUserId` | text | optional | If sent, must equal your token's `userId`. Omit it and it defaults to you. | Returns `200 OK` with a `MapDTO`: `{ id, name, imageUrl, width, height, anchorPoints[], createdAt, updatedAt, ownerUserId, description, tags[], visibility, approvalStatus, sourceMapId, latestVersionMapId, icon, bounds, calibrationError, imageRightsConfirmed, imageSource, areas[] }`. **Keep the `id`** — you need it for every following call. About `imageUrl`: it is a **relative URL** — `/map/{id}/image` — not the image bytes. Map JSON stays small (no base64 payload rides along). If you actually need the image, fetch `GET /map/{id}/image` with your token; it streams the raw bytes with the correct `Content-Type` and an `ETag`. Otherwise you can ignore `imageUrl` — you already have the image you uploaded. Note: `imageRightsConfirmed` is **required** — omitting it or sending `false` fails validation with a `400` naming the field, so map creation will not proceed without it. Set it to `true` only when the User genuinely holds the rights to the image (see section 8); a reviewer sees it and `imageSource` at approval time. ### 3.2 Add Anchor Points — `POST /map/{id}/anchors` (JSON) An **Anchor Point** ties one real-world coordinate to one pixel on the image. ```json { "latitude": 46.9741, "longitude": 10.2915, "pixelX": 120.0, "pixelY": 880.0, "label": "SW piste base" } ``` Returns `200 OK` with an `AnchorPointDTO` `{ id, latitude, longitude, pixelX, pixelY, label }`. **Rules the calibration math enforces (match these or projection stays disabled):** - **Minimum 3 anchor points**, and they must be **non-collinear** (they must form a real triangle). Fewer than 3, or 3 in a line, and the Map cannot project — you will get errors from `/project` and the Map is not calibrated. (The domain description you may have seen elsewhere saying "2 minimum" is wrong; the shipped minimum is **3**.) - **4+ anchors** yields higher accuracy via a least-squares fit. **Why spread matters — this is the part agents get wrong.** An affine projection is fit from your anchors. If they are clustered in one corner or nearly in a line, the fit is well-determined *there* but extrapolates badly across the rest of the image — a small anchor error becomes a large position error far from the cluster. Spreading anchors so their triangle covers most of the map keeps error low *everywhere*. Concretely, the validator wants: - Anchors spread across **at least ~50%** of the map width AND height (it warns below ~15%). - A well-shaped triangle: smallest angle ≥ ~15° (aim 30–90°), largest ≤ ~150°. - Anchors NOT all in the same quadrant. **Ideal placement:** the four corners plus the center. Pick features you can locate precisely on both the image and the real world (path junctions, building corners, lift stations) — not blank expanses. ### 3.3 Self-verify the calibration (do this before drawing areas) **Forward-project a known coordinate** — `GET /map/{id}/project?lat={lat}&lng={lng}`: ```json { "pixelX": 121.4, "pixelY": 878.9, "estimatedErrorPixels": 3.2 } ``` Pick a landmark whose correct pixel you know, project its GPS, and check the returned pixel lands where you expect. `400 Bad Request` here means the Map is not calibrated yet (need 3+ non-collinear anchors). **Inspect the whole surface** — `GET /map/{id}/projection-error?resolution=20`: ```json { "resolution": 20, "columns": 20, "rows": 20, "cellWidth": 96.0, "cellHeight": 54.0, "maxError": 5.7, "cells": [ { "col": 0, "row": 0, "error": 2.1 }, ... ] } ``` Each cell's `error` is the estimated projection error (in pixels) at that spot. High-error cells tell you *where to add another anchor*. `resolution` must be ≥ 2 (default 20). The `MapDTO.calibrationError` field is a related single number: the mean anchor residual after fitting. **These self-checks measure anchor self-consistency only. They cannot tell you whether the image itself is geometrically truthful — see section 8.** ### 3.4 Create Areas of Interest — `POST /map/{id}/areas` (JSON) An **Area of Interest** is a named geofenced zone. When a User crosses into or out of it, the app broadcasts the transition to their Group. Shape is one of `polygon`, `circle`, `rectangle` — set `shapeType` and the matching geometry (all coordinates in **image pixels**): ```json // polygon { "name": "Beer Garden", "shapeType": "polygon", "vertices": [ {"x":400,"y":300}, {"x":520,"y":300}, {"x":520,"y":410}, {"x":400,"y":410} ], "color": "#E8A33D", "description": "Après-ski terrace", "isQuiet": false, "labelOnDemand": true } // circle { "name": "Gondola Base", "shapeType": "circle", "centerX": 210, "centerY": 640, "radius": 45 } // rectangle { "name": "Car Park", "shapeType": "rectangle", "x": 40, "y": 40, "width": 180, "height": 120 } ``` Returns an `AreaOfInterestDTO`. Other operations: `GET /map/{id}/areas`, `PUT /map/{id}/areas/{areaId}`, `DELETE /map/{id}/areas/{areaId}`, and `GET /map/{id}/area-occupancy` (live occupancy counts). - Polygons need ≥ 3 vertices. - `isQuiet` and `labelOnDemand` are explained in the glossary (section 5). Both default to `false`. ### 3.5 Iterate via new versions — `POST /map/{id}/new-version` Once a Map is **published** (approved + public) it is **immutable**: anchors, areas, and metadata are locked. Editing a published Map's areas returns `409 Conflict`. To change a published Map, create a new **Map Version** — a draft clone with all anchors and areas copied — then edit and re-submit that: ``` POST /map/{id}/new-version { "userId": "" } // optional; defaults to the map's owner ``` Returns `201 Created` `{ "newMapId": "" }`. Work on `newMapId` from there. While a Map is still a draft (not yet published) you can edit it directly — you only need a new version after it has been published. --- ## 4. Optional: submit for public discovery If your human wants the Map discoverable by all Users, submit it: `POST /map/{id}/submit`. A **Platform Admin** then approves or rejects it — you cannot self-approve. Approval requires accurate `imageRightsConfirmed`/`imageSource` (section 8). Submitted by mistake, or want to keep editing privately? `POST /map/{id}/withdraw` takes it back out of the review queue; resubmit whenever. Public browsing (`GET /map/public`, `GET /map/public/search?q=`) is anonymous and available to anyone. --- ## 5. Domain glossary (use this exact vocabulary) - **Map** — a user-uploaded image anchored to GPS. Owned by exactly one User (its uploader); ownership is permanent. Maps have **no relationship to Groups** — they are per-User. - **Anchor Point** — a GPS↔pixel calibration pair. ≥ 3 non-collinear required to enable projection; 4+ improves accuracy. - **Area of Interest** — a named geofenced zone (polygon/circle/rectangle) drawn on a Map. Entering/exiting is broadcast to the User's Group. Avoid calling it a "geofence", "zone", or "region" in anything user-facing. - **announced vs quiet** (`isQuiet`) — an **announced** Area (default, `isQuiet: false`) sends Group members a notification on enter/exit. A **quiet** Area (`isQuiet: true`) only updates presence — occupancy counts and member labels, no alert. Use quiet for dense ambient areas (e.g. individual piste segments) where the value is the live "where is she" label, not a ping. - **labelOnDemand** — if `false` (default), the Area's name label is always shown on the map. If `true`, the label appears only while someone is inside the Area, when a user taps it, or when it is the Group's Meeting Point. Set `true` when the image **already carries the area names** (e.g. a rendered venue map) so labels don't double up. - **Elevation Layer** — an optional named floor within an Area, by altitude range (barometer-detected). Label only, no floor image. Provide via the `layers` field. - **Map Version** — an immutable snapshot. Published Maps are locked; changes require a new version (draft clone → edit → re-approve). Older installed versions keep working; users opt in to upgrade. - **Current Map / Installed Map / Public Map** — how Users select and acquire maps. Not something you manage as a build agent, but the terms may appear in responses. --- ## 6. Limits table Hard server-side quotas (HTTP `409 Conflict`) and the write rate limit (HTTP `429`): | Limit | Value | Scope | Endpoint(s) affected | |---|---|---|---| | Maps per User | **20** | per User | `POST /map` | | Areas of Interest per Map | **150** | per Map | `POST /map/{id}/areas` | | Anchor Points per Map | **20** | per Map | `POST /map/{id}/anchors` | | Write requests per minute | **60** | per User | every map write (`POST`/`PUT`/`DELETE`) | ## 7. Errors and how to react Error bodies carry an actionable `Message` — read it and follow it. - **`409 Conflict` — "Quota exceeded"**: a hard ceiling was hit. Messages tell you the fix: - Maps: *"Map limit reached (20). Delete an unused map via DELETE /map/{id} or create a new version of an existing map instead."* → delete an unused Map, or version an existing one. - Areas: *"Area of interest limit reached (150) for map {id}. Delete an unused area via DELETE /map/{id}/areas/{areaId} before adding another."* - Anchors: *"Anchor point limit reached (20) for map {id}. Remove an existing anchor via DELETE /map/{id}/anchors/{anchorId} before adding another."* → 20 well-placed anchors is already plenty; prune weak ones instead of piling on more. - **`409 Conflict` — published-map edit**: message mentions "Published maps". The Map is immutable. → create a new version (section 3.5) and edit that. - **`429 Too Many Requests`**: rate limit. Body: *"Map write rate limit reached (60 requests per minute). Wait and retry after N seconds (see the Retry-After header)."* → **honor the `Retry-After` header**, sleep that many seconds, then continue. Batch/pace your writes; don't hammer. (`send-otp` also returns 429 when OTP sends are throttled — wait and retry.) - **`403 Forbidden`**: either you tried a non-map endpoint (`/user`, `/group`) with a map-agent token, sent an `ownerUserId` that isn't you, tried to approve a map, or tried to version/delete a Map you don't own. → stay within `/map/*` on Maps you own. - **`401 Unauthorized`**: token missing, invalid, or expired. → re-run the OTP handshake (section 2). Remember: no refresh token. - **`400 Bad Request`**: bad input — missing image on create, projecting before 3+ non-collinear anchors exist, collinear anchors, or `resolution < 2`. The `Message` says which. --- ## 8. Image-source policy (the make-or-break section) A FriendTrack Map only works if the **image geometry is truthful** — real-world distances and directions must be preserved (a top-down, geometrically faithful base). Get this wrong and the app confidently plots people in the wrong place. **Use only geometrically faithful sources**, for example: - OpenStreetMap-derived renders (the VenueImporter produces these). - Official venue/resort site plans that are drawn to scale. - Orthophotos / to-scale surveyed plans. **Prohibited — do not use these as a geometric base:** - **Google Maps / Google Earth / Apple Maps / other proprietary map screenshots** — both a rights violation and, when cropped/rotated, geometrically unreliable for anchoring. - **AI-generated / hallucinated map images** — a diffusion model invents plausible-looking geometry that does not correspond to the real world. It will never anchor correctly. - Artistic or "not to scale" panorama/trail-map illustrations where distances are deliberately distorted. **Attribution:** for OpenStreetMap-derived images, set `imageSource` to the ODbL attribution, e.g. `"© OpenStreetMap contributors, ODbL"`. For an official site plan, record its source/licence there too. Only set `imageRightsConfirmed: true` when the User genuinely holds the rights. Also **draw the attribution visibly on the image itself** (a small line in a corner is enough). ODbL expects attribution wherever the map is *displayed*, and `imageSource` is only shown to reviewers — the image is what Users see. If you produce the image by fetching tiles from the public `tile.openstreetmap.org` servers: a **one-off stitch of a modest area** is acceptable, but bulk tile scraping violates the OSM tile usage policy. Keep the zoom level and bounding box no larger than the Map needs, and don't re-download on every iteration — cache what you fetched. ### The warning that catches good agents out > `projection-error` and `/project` measure **anchor self-consistency** — how well the affine fit reproduces the anchors you gave it. **They do NOT measure whether the underlying image is a truthful map of the real world.** > > You can place 4 anchors on a *distorted, AI-generated, or fictional* image, achieve a beautiful sub-pixel `maxError`, and still ship a completely broken Map — because you fit a clean transform to a lie. A low error score is necessary but **not sufficient**. First guarantee the image is geometrically faithful (section 8); only then does a good score mean the Map is good. --- ## 9. Worked example — "Ischgl by Night" (reference build) A compact transcript of building a night-time town map for Ischgl. (Coordinates illustrative.) ``` # 1. Auth POST /auth/send-otp { "phoneNumber":"27228440", "countryCode":"+45" } -> 200 { "message":"OTP sent successfully." } # (agent to human) "I've texted a 6-digit code to the phone ending 8440 — read it back to me." # (human) "492013" POST /auth/verify-otp { "phoneNumber":"27228440","countryCode":"+45", "otpCode":"492013","surface":"map-agent" } -> 200 { "token":"eyJ...","expiresAt":"2026-07-06T12:00:00Z", "userId":"A1B2...","scope":"map-editing" } # Use Authorization: Bearer eyJ... from here on. # 2. Create the Map (image is an OSM-derived night render — geometrically faithful) POST /map (multipart) image= name="Ischgl by Night" width=1920 height=1080 imageRightsConfirmed=true imageSource="© OpenStreetMap contributors, ODbL" -> 200 { "id":"7f3c...","calibrationError":null, ... } # 3. Anchor Points — four corners + center of the town render POST /map/7f3c.../anchors { "latitude":46.9698,"longitude":10.2860,"pixelX":95, "pixelY":95, "label":"NW ridge" } POST /map/7f3c.../anchors { "latitude":46.9702,"longitude":10.3010,"pixelX":1830,"pixelY":110, "label":"NE gondola" } POST /map/7f3c.../anchors { "latitude":46.9648,"longitude":10.2872,"pixelX":120, "pixelY":980, "label":"SW church" } POST /map/7f3c.../anchors { "latitude":46.9651,"longitude":10.3005,"pixelX":1815,"pixelY":965, "label":"SE bridge" } POST /map/7f3c.../anchors { "latitude":46.9675,"longitude":10.2938,"pixelX":965, "pixelY":540, "label":"town centre" } # 4. Self-verify GET /map/7f3c.../project?lat=46.9675&lng=10.2938 -> 200 { "pixelX":963.7,"pixelY":541.2,"estimatedErrorPixels":1.4 } # lands on the centre — good GET /map/7f3c.../projection-error?resolution=20 -> 200 { "maxError":2.3, ... } # low & even across the surface # 5. Areas of Interest — DISJOINT zones; names already printed on the render -> labelOnDemand POST /map/7f3c.../areas { "name":"Kuhstall","shapeType":"circle","centerX":740,"centerY":610,"radius":40, "isQuiet":false,"labelOnDemand":true } # announced: ping when friends arrive POST /map/7f3c.../areas { "name":"Trofana Alm","shapeType":"rectangle","x":1180,"y":505,"width":150,"height":90, "isQuiet":false,"labelOnDemand":true } POST /map/7f3c.../areas { "name":"Main Street","shapeType":"polygon", "vertices":[{"x":300,"y":560},{"x":1500,"y":560},{"x":1500,"y":640},{"x":300,"y":640}], "isQuiet":true,"labelOnDemand":true } # quiet: ambient "who's on the strip" label # 6. Hand back the map id to the human. If later published and it needs a change: POST /map/7f3c.../new-version { "userId":"A1B2..." } -> 201 { "newMapId":"9d20..." } ``` Note how the announced venues (Kuhstall, Trofana Alm) are the places worth a notification, while the strip is quiet (ambient presence). The three areas do not overlap — see section 10. --- ## 10. Area guidance — prefer disjoint areas Design Areas so they **do not overlap**. Overlap is not a way to express "this OR that" — a User inside two overlapping Areas is simultaneously in **both**. That means: - Both Areas count them in occupancy at once. - Both **announced** Areas fire an enter notification — the Group gets two pings for one arrival. Only overlap deliberately, when the real-world spaces genuinely nest or coincide and you want both memberships. Otherwise carve the map into disjoint Areas. If two named places abut, share an edge rather than letting rectangles/circles bleed into each other. When one place sits inside another (a bar inside a building), model the inner one as an **Elevation Layer** of the outer Area rather than a second overlapping Area, where that fits. --- ## 11. Quick reference — endpoints your token may call ``` POST /auth/send-otp # start OTP (anonymous) POST /auth/verify-otp # surface:"map-agent" -> scoped token (anonymous) POST /map # create (multipart) [write] GET /map # list maps GET /map/{id} # get one map GET /map/{id}/image # stream the map image bytes (imageUrl points here) DELETE /map/{id} # delete a map you own [write] PUT /map/{id}/metadata # edit metadata (draft only) [write] POST /map/{id}/new-version # clone a published map to a draft [write] POST /map/{id}/submit # submit for Platform Admin approval POST /map/{id}/withdraw # take a submission back out of review [write] POST /map/{id}/anchors # add anchor point [write] DELETE /map/{id}/anchors/{anchorId} # remove anchor point [write] GET /map/{id}/project?lat=&lng= # forward-project a GPS coord -> pixel GET /map/{id}/projection-error?resolution= # error grid across the surface POST /map/{id}/areas # create Area of Interest [write] GET /map/{id}/areas # list areas PUT /map/{id}/areas/{areaId} # update area (draft only) [write] DELETE /map/{id}/areas/{areaId} # delete area [write] GET /map/{id}/area-occupancy # live occupancy counts GET /map/public # browse public maps (anonymous) GET /map/public/search?q= # search public maps (anonymous) ``` Endpoints marked `[write]` count against the 60-writes-per-minute limit. `/user/*` and `/group/*` return `403` for a map-agent token — do not call them.