Identify a place and get a spoken tour script
Landmark Tour takes a travel photo, a typed name, or both. It says what the place or artwork is, how sure it is and which visible clues it used, lists alternatives when it is unsure, and writes a 2, 3 or 5 minute tour script meant to be read aloud: the story, what to look at, one lesser-known detail.
Each tour is one call to the SkillSafe App API: a flat JSON object in over HTTPS, JSON Lines out. The app speaks the script with an on-device voice; from the API you get the text.
Basics
Base URL https://api.skillsafe.ai/v1/app-api. Every call except
POST /guest sends Authorization: Bearer <token>; bodies are
JSON with Content-Type: application/json. The token is bound to this app, so
there is no app or slug header: the slug appears only in the
POST /guest body.
Responses use one envelope: {"ok":true,"data":{...}} on success and
{"ok":false,"error":{"code","message","details"}} on failure.
/estimate, /run and /run-stream take
the input object itself as the body. Never wrap it as
{"input":{...}}: the server only warns unknown field 'input', and the run is still charged.
Credits: 1 credit = $0.0001. /estimate is free and creates no job;
/run and /run-stream hold up to hold_credits and
charge charged_credits when the job finishes.
| Status | Typical error.code | Meaning |
|---|---|---|
400 | validation_error | The body failed validation (a field of the wrong type, an object where a scalar belongs); details names the field. |
401 | unauthorized | Invalid or expired app session. Get a new token (step 1). |
402 | payment_required | Insufficient credits: the balance is below min_credits for this run. Top up, or sign in for a personal token. |
403 | forbidden | The token may not do this. A guest token can run metered turns only while this app sponsors guests; when it does not, sign in for a personal token. |
404 | not_found | Unknown app (a misspelt slug in POST /guest), or an unknown job id, or a job that belongs to another token. |
409 | conflict | The Idempotency-Key was already used with a different body. |
429 | rate_limited | Rate limit. Back off and retry with the same Idempotency-Key. |
5xx | - | Transient platform error. Retry with backoff and the same Idempotency-Key. |
Branch on the HTTP status and error.code, and show error.message
to a person; the exact codes and wording are the platform's and can gain detail over time.
Browsers enforce CORS for this API, so run the samples from a server, script or terminal, not from another website's frontend.
The task field first
There is one task, tour. Always send "task": "tour".
| task | What it does | Reply lines |
|---|---|---|
tour | Identify the place or artwork in the attached photo and/or hint, then write the spoken tour. | id, 0-3 alt, title, 4-8 seg, 0-4 verify, end. With confidence none: id, alt, ask, end. |
Input fields
Every field is a scalar. Send them all; use an empty string when one does not apply.
| Field | Type | Meaning |
|---|---|---|
task | string | tour. |
hint | string | A name, a city, a sign's text, or empty when a photo is attached. The app keeps up to 600 characters (start and end, the middle replaced by a marker). |
photo | string | attached when $files carries a photo, else none. |
photo_gps | string | "lat, lon" from the photo's own EXIF, or empty. The app checks the reply's coordinates against it. |
photo_taken | string | YYYY-MM-DD from EXIF, or empty. |
minutes | number | 2, 3 or 5 - the spoken length. |
target_words | number | minutes x 140. |
focus | string | general | history | art | architecture | family. |
correction | string | Optional: "it is really X" after a wrong identification. |
retry_note | string | Optional: a correction after a malformed reply, sent once on a new Idempotency-Key attempt. |
$files | array | Optional: one file id from POST /files - see below. |
Sending a photo
Upload the picture first as multipart form data (field file); the reply's
data.file.file_id goes in $files and photo becomes
attached. File ids belong to the token that uploaded them. Put the id on the
/estimate body too - it prices the picture. Downscale to about 1280 px first; the
app also re-encodes, which strips EXIF, so read the GPS before you do.
FILE_ID=$(curl -s -X POST "$API/files" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-F "file=@photo.jpg;type=image/jpeg" | jq -r '.data.file.file_id')
jq -c --arg id "$FILE_ID" '. + {photo: "attached", hint: "", "$files": [$id]}' tour.json > photo.json
Reply contract
JSON Lines: one object per line with a "k" field, the last line always
{"k":"end"}. A reply without it was cut short - keep the lines that arrived.
Ignore any line that is not a JSON object with k.
| k | Fields |
|---|---|
id | name, kind, where, lat/lon (the place's own coordinates, or null), confidence: high | medium | low | none, clues (array of visible clues), unsure (what is uncertain). |
alt | name, why - another candidate. |
title | text. |
seg | kind: intro | history | look | detail | close, heading, say (plain sentences written to be read aloud). |
verify | text - a claim worth checking on site. |
ask | text - only with confidence none: what to photograph or type next. |
Check the reply against what you know: with photo_gps, the distance to
lat/lon should be small; a non-high confidence should come with
unsure; the script should total about target_words. Identifications
and facts are AI-generated and can be wrong.
Step 1 - A tiny client, and a token
Start with a short helper that adds the auth header, sends JSON and unwraps the envelope;
every later step reuses it. For the token, open the token
page, sign in with SkillSafe and copy the token (or the shell export line).
A personal token bills metered runs to your account. Scripts with no browser at all can
mint a guest token with POST /guest and the body
{"slug":"landmark-tour"}, sent with no auth header; it answers 201 with
{token, guest_id, expires_at}. There is no app header on any later call.
export API="https://api.skillsafe.ai/v1/app-api"
export SKILLSAFE_TOKEN="YOUR_TOKEN" # "Copy shell export" on /tokens.html sets this for you
# No browser at all? Mint a guest token (this one call sends no Authorization header):
SKILLSAFE_TOKEN=$(curl -s -X POST "$API/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"landmark-tour"}' | jq -r '.data.token')
# 201 -> {"ok":true,"data":{"token":"...","guest_id":"...","expires_at":"..."}}
# Every later call looks like:
# curl -s "$API/..." -H "Authorization: Bearer $SKILLSAFE_TOKEN" [-H "Content-Type: application/json" -d @body.json]
# and jq reads fields out of the {"ok":true,"data":{...}} envelope.
import json, os, time, requests
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN") # copy it from tokens.html
def api(method, path, body=None, headers=None, auth=True):
"""Send JSON (a dict, or an already-encoded string), unwrap the envelope."""
h = {"Content-Type": "application/json", **(headers or {})}
if auth and TOKEN:
h["Authorization"] = f"Bearer {TOKEN}"
data = body if isinstance(body, str) or body is None else json.dumps(body)
res = requests.request(method, API + path, data=data, headers=h, timeout=180)
payload = res.json()
if not res.ok or payload.get("ok") is False:
err = payload.get("error", {})
raise RuntimeError(f'{res.status_code} {err.get("code")}: {err.get("message")}')
return payload["data"]
# No browser at all? Mint a guest token (no Authorization header on this call).
if not TOKEN:
TOKEN = api("POST", "/guest", {"slug": "landmark-tour"}, auth=False)["token"]
// Node 18+ (built-in fetch), run as an ES module (top-level await)
const API = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // paste it from tokens.html, or load it from your secret store
async function api(method, path, body, extraHeaders = {}, auth = true) {
const headers = { "Content-Type": "application/json", ...extraHeaders };
if (auth) headers.Authorization = `Bearer ${TOKEN}`;
const res = await fetch(API + path, {
method,
headers,
body: body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || json.ok === false) {
const e = json.error ?? {};
throw Object.assign(new Error(`${res.status} ${e.code}: ${e.message ?? res.statusText}`),
{ status: res.status, code: e.code, details: e.details });
}
return json.data;
}
// No browser at all? Mint a guest token (no Authorization header on this call).
if (TOKEN === "YOUR_TOKEN") {
({ token: TOKEN } = await api("POST", "/guest", { slug: "landmark-tour" }, {}, false));
}
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"hash/fnv"
"log"
"net/http"
"os"
"strings"
"time"
)
const API = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // copy it from tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// call sends body (any value, or json.RawMessage for pre-encoded bytes) and
// decodes the envelope's data into out.
func call(method, path string, body any, headers map[string]string, out any) error {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req, _ := http.NewRequest(method, API+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
for k, v := range headers {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env envelope
json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode >= 400 || env.Error != nil {
if env.Error != nil {
return fmt.Errorf("%d %s: %s", res.StatusCode, env.Error.Code, env.Error.Message)
}
return fmt.Errorf("HTTP %d", res.StatusCode)
}
if out == nil {
return nil
}
return json.Unmarshal(env.Data, out)
}
// No browser at all? Mint a guest token (sent while token is still empty, so no auth header).
func ensureToken() {
if token != "" {
return
}
var g struct{ Token string `json:"token"` }
if err := call("POST", "/guest", map[string]string{"slug": "landmark-tour"}, nil, &g); err != nil {
log.Fatal(err)
}
token = g.Token
}
// Java 17+, with Jackson (com.fasterxml.jackson.core:jackson-databind) for JSON.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class LandmarkTour {
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final ObjectMapper JSON = new ObjectMapper();
static final HttpClient HTTP = HttpClient.newHttpClient();
static String token = System.getenv("SKILLSAFE_TOKEN"); // copy it from tokens.html
/** body: any Jackson-serialisable value, or an already-encoded JSON String. */
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(API + path))
.header("Content-Type", "application/json")
.method(method, body == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body instanceof String s ? s : JSON.writeValueAsString(body)));
if (token != null) b.header("Authorization", "Bearer " + token);
headers.forEach(b::header);
var res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
JsonNode env = JSON.readTree(res.body());
if (res.statusCode() >= 400 || !env.path("ok").asBoolean(true))
throw new RuntimeException(res.statusCode() + " " + env.path("error").path("code").asText()
+ ": " + env.path("error").path("message").asText());
return env.get("data");
}
// No browser at all? Mint a guest token (token is null, so no auth header is sent).
static void ensureToken() throws Exception {
if (token == null) token = api("POST", "/guest", Map.of("slug", "landmark-tour"), Map.of()).get("token").asText();
}
// Steps 2-5 below go in main(); the static helpers they define sit beside api().
}
require "net/http"
require "json"
API = "https://api.skillsafe.ai/v1/app-api"
$token = ENV["SKILLSAFE_TOKEN"] # copy it from tokens.html
# body: a Hash, or an already-encoded JSON String
def api(method, path, body = nil, headers = {})
uri = URI(API + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{$token}" if $token
headers.each { |k, v| req[k] = v }
req.body = body.is_a?(String) ? body : body.to_json if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 180) { |h| h.request(req) }
payload = (JSON.parse(res.body) rescue {})
unless res.is_a?(Net::HTTPSuccess) && payload["ok"] != false
err = payload["error"] || {}
raise "#{res.code} #{err["code"]}: #{err["message"] || res.message}"
end
payload["data"]
end
# No browser at all? Mint a guest token ($token is nil, so no auth header is sent).
$token ||= api("POST", "/guest", { slug: "landmark-tour" })["token"]
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: null; // copy it from tokens.html
// $body: an array, or an already-encoded JSON string
function api(string $method, string $path, $body = null, array $headers = []): array {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $h,
CURLOPT_TIMEOUT => 180,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS,
is_string($body) ? $body : json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
$payload = json_decode(curl_exec($ch), true) ?? [];
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400 || ($payload["ok"] ?? true) === false) {
$e = $payload["error"] ?? [];
throw new Exception("$status " . ($e["code"] ?? "") . ": " . ($e["message"] ?? "HTTP error"));
}
return $payload["data"];
}
// No browser at all? Mint a guest token ($TOKEN is null, so no auth header is sent).
$TOKEN = $TOKEN ?: api("POST", "/guest", ["slug" => "landmark-tour"])["token"];
// .NET 8+, Program.cs. Top-level statements come first; the helper class goes at the end of the file.
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
// No browser at all? Mint a guest token (Token is null, so no auth header is sent).
LandmarkTour.Token ??= (await LandmarkTour.ApiAsync(HttpMethod.Post, "/guest",
new { slug = "landmark-tour" })).GetProperty("token").GetString();
// ... steps 2-5 go here ...
static class LandmarkTour
{
public const string Api = "https://api.skillsafe.ai/v1/app-api";
public static readonly HttpClient Http = new() { Timeout = TimeSpan.FromMinutes(5) };
public static string? Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN"); // copy it from tokens.html
// body: any serialisable object, or an already-encoded JSON string
public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null,
Dictionary<string, string>? headers = null)
{
var req = new HttpRequestMessage(method, Api + path);
if (Token != null) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
foreach (var (k, v) in headers ?? new()) req.Headers.Add(k, v);
if (body != null)
req.Content = new StringContent(body as string ?? JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
var json = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (!res.IsSuccessStatusCode)
{
var e = json.GetProperty("error");
throw new Exception($"{(int)res.StatusCode} {e.GetProperty("code")}: {e.GetProperty("message")}");
}
return json.GetProperty("data");
}
}
Treat a token like a password: it can spend its credits. A guest token only runs metered
turns while the app sponsors guests (a 403 otherwise); a signed-in token always can.
Step 2 - Who am I, and what is my balance?
Returns subject_type ("user" for a personal token,
"guest" for a guest token), subject_id and credits.
Branch on subject_type. A 401 here means the token is stale.
curl -s "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | jq '.data'
# {"subject_type":"guest","subject_id":"gst_...","credits":2000}
me = api("GET", "/me")
print(me["subject_type"], me["subject_id"], me["credits"], "credits",
f'(${me["credits"] * 0.0001:.2f})')
const me = await api("GET", "/me");
console.log(me.subject_type, me.subject_id, `${me.credits} credits ($${(me.credits * 0.0001).toFixed(2)})`);
// in main():
ensureToken()
var me struct {
SubjectType string `json:"subject_type"` // "user" or "guest"
SubjectID string `json:"subject_id"`
Credits int64 `json:"credits"`
}
if err := call("GET", "/me", nil, nil, &me); err != nil {
log.Fatal(err)
}
fmt.Printf("%s %s: %d credits ($%.2f)\n", me.SubjectType, me.SubjectID, me.Credits, float64(me.Credits)*0.0001)
ensureToken();
JsonNode me = api("GET", "/me", null, Map.of());
System.out.printf("%s %s: %d credits%n",
me.get("subject_type").asText(), me.get("subject_id").asText(), me.get("credits").asLong());
me = api("GET", "/me")
puts "#{me["subject_type"]} #{me["subject_id"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']} {$me['subject_id']}: {$me['credits']} credits\n";
var me = await LandmarkTour.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("subject_id")}: {me.GetProperty("credits")} credits");
Step 3 - Price a tour for free
/estimate takes the same body as a run and creates no job. It returns
hold_credits (reserved while the run is in flight), min_credits
and the model binding. A run is charged charged_credits, usually far less than
the hold. warnings names unknown or mistyped fields.
# A typed landmark, no photo. Every field is a scalar.
jq -n '{
task: "tour", hint: "Charles Bridge, Prague", photo: "none",
photo_gps: "", photo_taken: "",
minutes: 3, target_words: 420, focus: "history"
}' > tour.json
curl -s -X POST "$API/estimate" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-d @tour.json | jq '.data | {hold_credits, min_credits, model_alias, input_checked, warnings}'
tour_input = {
"task": "tour", "hint": "Charles Bridge, Prague", "photo": "none",
"photo_gps": "", "photo_taken": "",
"minutes": 3, "target_words": 3 * 140, "focus": "history",
}
est = api("POST", "/estimate", tour_input)
print(est["hold_credits"], est["min_credits"], est["model_alias"], est.get("warnings"))
const tourInput = {
task: "tour", hint: "Charles Bridge, Prague", photo: "none",
photo_gps: "", photo_taken: "",
minutes: 3, target_words: 3 * 140, focus: "history",
};
const est = await api("POST", "/estimate", tourInput);
console.log(est.hold_credits, est.min_credits, est.model_alias, est.warnings);
// at package level:
func tourInput() map[string]any {
return map[string]any{
"task": "tour", "hint": "Charles Bridge, Prague", "photo": "none",
"photo_gps": "", "photo_taken": "",
"minutes": 3, "target_words": 3 * 140, "focus": "history",
}
}
// in main():
ensureToken()
var est struct {
Hold int `json:"hold_credits"`
Min int `json:"min_credits"`
Alias string `json:"model_alias"`
Warnings []string `json:"warnings"`
}
if err := call("POST", "/estimate", tourInput(), nil, &est); err != nil {
log.Fatal(err)
}
fmt.Println(est.Hold, est.Min, est.Alias, est.Warnings)
static ObjectNode tourInput() {
ObjectNode in = JSON.createObjectNode();
in.put("task", "tour");
in.put("hint", "Charles Bridge, Prague");
in.put("photo", "none");
in.put("photo_gps", "");
in.put("photo_taken", "");
in.put("minutes", 3);
in.put("target_words", 3 * 140);
in.put("focus", "history");
return in;
}
// in main():
ensureToken();
JsonNode est = api("POST", "/estimate", tourInput(), Map.of());
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model_alias"));
tour_input = {
task: "tour", hint: "Charles Bridge, Prague", photo: "none",
photo_gps: "", photo_taken: "",
minutes: 3, target_words: 3 * 140, focus: "history"
}
est = api("POST", "/estimate", tour_input)
puts est.values_at("hold_credits", "min_credits", "model_alias").inspect
$tourInput = [
"task" => "tour", "hint" => "Charles Bridge, Prague", "photo" => "none",
"photo_gps" => "", "photo_taken" => "",
"minutes" => 3, "target_words" => 3 * 140, "focus" => "history",
];
$est = api("POST", "/estimate", $tourInput);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model_alias"], PHP_EOL;
var tourInput = new Dictionary<string, object>
{
["task"] = "tour", ["hint"] = "Charles Bridge, Prague", ["photo"] = "none",
["photo_gps"] = "", ["photo_taken"] = "",
["minutes"] = 3, ["target_words"] = 3 * 140, ["focus"] = "history",
};
var est = await LandmarkTour.ApiAsync(HttpMethod.Post, "/estimate", tourInput);
Console.WriteLine($"{est.GetProperty("hold_credits")} {est.GetProperty("min_credits")} {est.GetProperty("model_alias")}");
Step 4 - Write the tour
Send the body with an Idempotency-Key; a retry with the same key and body never
bills twice. Poll the job until it is succeeded or failed, then
read the JSON Lines from output.output.
BODY=$(jq -c . tour.json)
# The app hashes the body with FNV-1a; from a shell any stable hash of the exact body works.
HASH=$(printf %s "$BODY" | shasum | cut -c1-8)
KEY="landmark-tour-tour-$HASH-a1" # <slug>-<task>-<hash>-a<attempt>
JOB_ID=$(curl -s -X POST "$API/run" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" -d "$BODY" | jq -r '.data.job_id')
while :; do
JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
STATUS=$(echo "$JOB" | jq '.data | {status, charged_credits, truncated}'
# The reply is JSON Lines: keep every line that parses as an object with "k".
echo "$JOB" | jq -r '.data.output.output' | jq -R -c 'fromjson? | select(type == "object" and has("k"))' > reply.jsonl
jq -r 'select(.k == "id") | "\(.name) - \(.where) - confidence \(.confidence)"' reply.jsonl
jq -r 'select(.k == "seg") | "[\(.kind)] \(.heading): \(.say)"' reply.jsonl
tail -n 1 reply.jsonl # {"k":"end"} - anything else means the reply was cut short
import hashlib
def idem_key(task, body, attempt=1):
h = hashlib.sha1(body.encode()).hexdigest()[:8] # any stable hash of the exact body
return f"landmark-tour-{task}-{h}-a{attempt}"
def jsonl(text):
rows = []
for line in (text or "").splitlines():
try:
o = json.loads(line)
if isinstance(o, dict) and "k" in o:
rows.append(o)
except ValueError:
pass # prose or a code fence - ignore it
return rows
body = json.dumps(tour_input)
job = api("POST", "/run", body, {"Idempotency-Key": idem_key("tour", body)})
while job.get("status") not in ("succeeded", "failed"):
time.sleep(2)
job = api("GET", f"/jobs/{job['job_id']}")
rows = jsonl(job["output"]["output"])
ident = next(r for r in rows if r["k"] == "id")
print(ident["name"], "-", ident["where"], "- confidence", ident["confidence"])
for seg in (r for r in rows if r["k"] == "seg"):
print(f"[{seg['kind']}] {seg['heading']}: {seg['say']}")
import { createHash } from "node:crypto";
function idemKey(task, body, attempt = 1) {
const h = createHash("sha1").update(body).digest("hex").slice(0, 8); // any stable hash of the exact body
return `landmark-tour-${task}-${h}-a${attempt}`;
}
const jsonl = (text) => String(text || "").split("\n").flatMap((l) => {
try { const o = JSON.parse(l); return o && typeof o === "object" && "k" in o ? [o] : []; } catch { return []; }
});
const body = JSON.stringify(tourInput);
let job = await api("POST", "/run", body, { "Idempotency-Key": idemKey("tour", body) });
while (!["succeeded", "failed"].includes(job.status)) {
await new Promise((r) => setTimeout(r, 2000));
job = await api("GET", `/jobs/${job.job_id}`);
}
const rows = jsonl(job.output.output);
const ident = rows.find((r) => r.k === "id");
console.log(job.charged_credits, ident.name, ident.where, ident.confidence);
for (const seg of rows.filter((r) => r.k === "seg")) console.log(`[${seg.kind}] ${seg.heading}: ${seg.say}`);
// at package level:
func idemKey(task string, body []byte, attempt int) string {
h := fnv.New32a()
h.Write(body) // any stable hash of the exact body
return fmt.Sprintf("landmark-tour-%s-%08x-a%d", task, h.Sum32(), attempt)
}
func jsonl(text string) []map[string]any {
var rows []map[string]any
for _, line := range strings.Split(text, "\n") {
var o map[string]any
if json.Unmarshal([]byte(strings.TrimSpace(line)), &o) == nil && o["k"] != nil {
rows = append(rows, o)
}
}
return rows
}
type job struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Charged int `json:"charged_credits"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
// in main():
body, _ := json.Marshal(tourInput())
var j job
if err := call("POST", "/run", json.RawMessage(body), map[string]string{"Idempotency-Key": idemKey("tour", body, 1)}, &j); err != nil {
log.Fatal(err)
}
for j.Status != "succeeded" && j.Status != "failed" {
time.Sleep(2 * time.Second)
call("GET", "/jobs/"+j.JobID, nil, nil, &j)
}
for _, r := range jsonl(j.Output.Output) {
switch r["k"] {
case "id":
fmt.Println(r["name"], "-", r["where"], "- confidence", r["confidence"])
case "seg":
fmt.Printf("[%v] %v: %v\n", r["kind"], r["heading"], r["say"])
}
}
static String idemKey(String task, String body, int attempt) throws Exception {
var h = java.security.MessageDigest.getInstance("SHA-1").digest(body.getBytes(java.nio.charset.StandardCharsets.UTF_8));
return "landmark-tour-" + task + "-" + java.util.HexFormat.of().formatHex(h).substring(0, 8) + "-a" + attempt;
}
static List<JsonNode> jsonl(String text) {
var rows = new ArrayList<JsonNode>();
for (String line : text.split("\n")) {
try { JsonNode o = JSON.readTree(line); if (o != null && o.has("k")) rows.add(o); } catch (Exception ignored) { }
}
return rows;
}
// in main():
String body = JSON.writeValueAsString(tourInput());
JsonNode job = api("POST", "/run", body, Map.of("Idempotency-Key", idemKey("tour", body, 1)));
while (!job.path("status").asText().matches("succeeded|failed")) {
Thread.sleep(2000);
job = api("GET", "/jobs/" + job.get("job_id").asText(), null, Map.of());
}
for (JsonNode r : jsonl(job.path("output").path("output").asText())) {
String k = r.get("k").asText();
if (k.equals("id")) System.out.println(r.get("name").asText() + " - confidence " + r.get("confidence").asText());
if (k.equals("seg")) System.out.println("[" + r.get("kind").asText() + "] " + r.get("heading").asText() + ": " + r.get("say").asText());
}
require "digest"
def idem_key(task, body, attempt = 1)
"landmark-tour-#{task}-#{Digest::SHA1.hexdigest(body)[0, 8]}-a#{attempt}"
end
def jsonl(text)
text.to_s.lines.filter_map { |l| o = (JSON.parse(l) rescue nil); o if o.is_a?(Hash) && o.key?("k") }
end
body = tour_input.to_json
job = api("POST", "/run", body, { "Idempotency-Key" => idem_key("tour", body) })
until %w[succeeded failed].include?(job["status"])
sleep 2
job = api("GET", "/jobs/#{job["job_id"]}")
end
rows = jsonl(job.dig("output", "output"))
ident = rows.find { |r| r["k"] == "id" }
puts "#{ident["name"]} - #{ident["where"]} - confidence #{ident["confidence"]}"
rows.select { |r| r["k"] == "seg" }.each { |s| puts "[#{s["kind"]}] #{s["heading"]}: #{s["say"]}" }
function idem_key(string $task, string $body, int $attempt = 1): string {
return "landmark-tour-$task-" . substr(sha1($body), 0, 8) . "-a$attempt";
}
function jsonl(?string $text): array {
$rows = [];
foreach (explode("\n", (string)$text) as $line) {
$o = json_decode(trim($line), true);
if (is_array($o) && isset($o["k"])) $rows[] = $o;
}
return $rows;
}
$body = json_encode($tourInput, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$job = api("POST", "/run", $body, ["Idempotency-Key" => idem_key("tour", $body)]);
while (!in_array($job["status"] ?? "", ["succeeded", "failed"])) {
sleep(2);
$job = api("GET", "/jobs/" . $job["job_id"]);
}
foreach (jsonl($job["output"]["output"] ?? "") as $r) {
if ($r["k"] === "id") echo $r["name"], " - confidence ", $r["confidence"], PHP_EOL;
if ($r["k"] === "seg") echo "[{$r["kind"]}] {$r["heading"]}: {$r["say"]}", PHP_EOL;
}
static string IdemKey(string task, string body, int attempt = 1)
{
var h = Convert.ToHexString(System.Security.Cryptography.SHA1.HashData(Encoding.UTF8.GetBytes(body))).ToLowerInvariant();
return $"landmark-tour-{task}-{h[..8]}-a{attempt}";
}
static List<JsonElement> Jsonl(string text) => text.Split('\n').Select(l =>
{
try { var o = JsonDocument.Parse(l).RootElement; return o.ValueKind == JsonValueKind.Object && o.TryGetProperty("k", out _) ? (JsonElement?)o : null; }
catch (JsonException) { return null; }
}).Where(o => o != null).Select(o => o!.Value).ToList();
// top-level:
var body = JsonSerializer.Serialize(tourInput);
var job = await LandmarkTour.ApiAsync(HttpMethod.Post, "/run", body, new() { ["Idempotency-Key"] = IdemKey("tour", body) });
while (job.GetProperty("status").GetString() is not ("succeeded" or "failed"))
{
await Task.Delay(2000);
job = await LandmarkTour.ApiAsync(HttpMethod.Get, "/jobs/" + job.GetProperty("job_id").GetString());
}
foreach (var r in Jsonl(job.GetProperty("output").GetProperty("output").GetString()!))
{
var k = r.GetProperty("k").GetString();
if (k == "id") Console.WriteLine($"{r.GetProperty("name")} - confidence {r.GetProperty("confidence")}");
if (k == "seg") Console.WriteLine($"[{r.GetProperty("kind")}] {r.GetProperty("heading")}: {r.GetProperty("say")}");
}
Step 5 - Stream a second take
The same place as a 2-minute family tour, over Server-Sent Events: job, then
tick heartbeats (and delta frames for some clients), then one
done event carrying the whole reply. Read the reply from done. An
idempotent replay answers with plain JSON instead of a stream, so check the content type.
# The same place again as a 2-minute family tour - a second run, streamed.
jq -c '. + {minutes: 2, target_words: 280, focus: "family"}' tour.json > family.json
BODY=$(cat family.json)
HASH=$(printf %s "$BODY" | shasum | cut -c1-8)
curl -N -s -X POST "$API/run-stream" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: landmark-tour-tour-$HASH-a1" \
-d "$BODY" | tee stream.txt
# event: job data: {"job_id":"job_..."}
# event: tick data: {"t":4} (a browser sees ticks; deltas only from some clients)
# event: done data: {"status":"succeeded","charged_credits":...,"output":{"output":"...JSON Lines..."}}
# Parse the reply from the done event, never from the deltas.
sed -n '/^event: done/{n;s/^data: *//p;}' stream.txt | jq -r '(.job // .) | .output.output' \
| jq -R -c 'fromjson? | select(type == "object" and has("k"))' > reply2.jsonl
jq -r 'select(.k == "seg") | "[\(.kind)] \(.say)"' reply2.jsonl
def run_stream(body, key):
"""POST /run-stream and return the job from the done event (or the JSON of an idempotent replay)."""
h = {"Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}", "Idempotency-Key": key}
res = requests.post(API + "/run-stream", data=body, headers=h, stream=True, timeout=300)
if "text/event-stream" not in res.headers.get("content-type", ""):
return res.json()["data"]
event = None
for line in res.iter_lines(decode_unicode=True):
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:") and event in ("done", "error"):
data = json.loads(line[5:])
if event == "error":
raise RuntimeError(data)
return data.get("job", data)
raise RuntimeError("stream ended without a done event")
family = dict(tour_input, minutes=2, target_words=2 * 140, focus="family")
body = json.dumps(family)
done = run_stream(body, idem_key("tour", body))
for r in jsonl(done["output"]["output"]):
if r["k"] == "seg":
print(f"[{r['kind']}] {r['say']}")
async function runStream(body, key) {
const res = await fetch(API + "/run-stream", {
method: "POST", body,
headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}`, "Idempotency-Key": key },
});
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) return (await res.json()).data;
const text = await res.text(); // the reply arrives whole in the done event
for (const frame of text.split("\n\n")) {
const ev = /^event: *(.+)$/m.exec(frame)?.[1], data = /^data: *(.+)$/m.exec(frame)?.[1];
if (ev === "error") throw new Error(data);
if (ev === "done") { const d = JSON.parse(data); return d.job ?? d; }
}
throw new Error("stream ended without a done event");
}
const family = { ...tourInput, minutes: 2, target_words: 2 * 140, focus: "family" };
const body2 = JSON.stringify(family);
const done = await runStream(body2, idemKey("tour", body2));
for (const r of jsonl(done.output.output)) if (r.k === "seg") console.log(`[${r.kind}] ${r.say}`);
// at package level:
func runStream(body []byte, key string) (job, error) {
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
return job{}, err
}
defer res.Body.Close()
var j job
if !strings.Contains(res.Header.Get("Content-Type"), "text/event-stream") {
var env struct{ Data job `json:"data"` }
err = json.NewDecoder(res.Body).Decode(&env)
return env.Data, err
}
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<24)
event := ""
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, "event:") {
event = strings.TrimSpace(line[6:])
} else if strings.HasPrefix(line, "data:") && event == "done" {
err = json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &j)
return j, err
} else if strings.HasPrefix(line, "data:") && event == "error" {
return j, fmt.Errorf("stream error: %s", line[5:])
}
}
return j, fmt.Errorf("stream ended without a done event")
}
// in main(), after step 4:
in := tourInput()
in["minutes"], in["target_words"], in["focus"] = 2, 2*140, "family"
body2, _ := json.Marshal(in)
done, err := runStream(body2, idemKey("tour", body2, 1))
if err != nil {
log.Fatal(err)
}
for _, r := range jsonl(done.Output.Output) {
if r["k"] == "seg" {
fmt.Printf("[%v] %v\n", r["kind"], r["say"])
}
}
static JsonNode runStream(String body, String key) throws Exception {
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Content-Type", "application/json").header("Authorization", "Bearer " + token)
.header("Idempotency-Key", key).POST(HttpRequest.BodyPublishers.ofString(body)).build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
if (!res.headers().firstValue("content-type").orElse("").contains("text/event-stream"))
return JSON.readTree(res.body()).get("data");
String event = "";
for (String line : res.body().split("\n")) {
if (line.startsWith("event:")) event = line.substring(6).trim();
else if (line.startsWith("data:") && event.equals("done")) {
JsonNode d = JSON.readTree(line.substring(5).trim());
return d.has("job") ? d.get("job") : d;
} else if (line.startsWith("data:") && event.equals("error")) throw new RuntimeException(line);
}
throw new RuntimeException("stream ended without a done event");
}
// in main(), after step 4:
ObjectNode family = tourInput();
family.put("minutes", 2);
family.put("target_words", 2 * 140);
family.put("focus", "family");
String body2 = JSON.writeValueAsString(family);
JsonNode done = runStream(body2, idemKey("tour", body2, 1));
for (JsonNode r : jsonl(done.path("output").path("output").asText()))
if (r.get("k").asText().equals("seg")) System.out.println("[" + r.get("kind").asText() + "] " + r.get("say").asText());
def run_stream(body, key)
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
{ "Content-Type" => "application/json", "Authorization" => "Bearer #{$token}", "Idempotency-Key" => key }.each { |k, v| req[k] = v }
req.body = body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) { |h| h.request(req) }
return JSON.parse(res.body)["data"] unless res["content-type"].to_s.include?("text/event-stream")
event = nil
res.body.each_line do |line|
if line.start_with?("event:") then event = line[6..].strip
elsif line.start_with?("data:") && event == "done"
d = JSON.parse(line[5..]); return d["job"] || d
elsif line.start_with?("data:") && event == "error" then raise line
end
end
raise "stream ended without a done event"
end
family = tour_input.merge(minutes: 2, target_words: 2 * 140, focus: "family")
body2 = family.to_json
done = run_stream(body2, idem_key("tour", body2))
jsonl(done.dig("output", "output")).each { |r| puts "[#{r["kind"]}] #{r["say"]}" if r["k"] == "seg" }
function run_stream(string $body, string $key): array {
global $TOKEN;
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 300,
CURLOPT_HTTPHEADER => ["Content-Type: application/json", "Authorization: Bearer $TOKEN", "Idempotency-Key: $key"],
]);
$raw = curl_exec($ch);
$type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
if (strpos((string)$type, "text/event-stream") === false) return json_decode($raw, true)["data"];
$event = "";
foreach (explode("\n", $raw) as $line) {
if (str_starts_with($line, "event:")) $event = trim(substr($line, 6));
elseif (str_starts_with($line, "data:") && $event === "done") { $d = json_decode(trim(substr($line, 5)), true); return $d["job"] ?? $d; }
elseif (str_starts_with($line, "data:") && $event === "error") throw new Exception($line);
}
throw new Exception("stream ended without a done event");
}
$family = array_merge($tourInput, ["minutes" => 2, "target_words" => 2 * 140, "focus" => "family"]);
$body2 = json_encode($family, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$done = run_stream($body2, idem_key("tour", $body2));
foreach (jsonl($done["output"]["output"] ?? "") as $r)
if ($r["k"] === "seg") echo "[{$r["kind"]}] {$r["say"]}", PHP_EOL;
static async Task<JsonElement> RunStream(string body, string key)
{
var req = new HttpRequestMessage(HttpMethod.Post, LandmarkTour.Api + "/run-stream")
{ Content = new StringContent(body, Encoding.UTF8, "application/json") };
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", LandmarkTour.Token);
req.Headers.Add("Idempotency-Key", key);
var res = await LandmarkTour.Http.SendAsync(req);
var text = await res.Content.ReadAsStringAsync();
if (res.Content.Headers.ContentType?.MediaType != "text/event-stream")
return JsonDocument.Parse(text).RootElement.GetProperty("data");
var ev = "";
foreach (var line in text.Split('\n'))
{
if (line.StartsWith("event:")) ev = line[6..].Trim();
else if (line.StartsWith("data:") && ev == "done")
{
var d = JsonDocument.Parse(line[5..]).RootElement;
return d.TryGetProperty("job", out var j) ? j : d;
}
else if (line.StartsWith("data:") && ev == "error") throw new Exception(line);
}
throw new Exception("stream ended without a done event");
}
// top-level, after step 4:
var family = new Dictionary<string, object>(tourInput) { ["minutes"] = 2, ["target_words"] = 2 * 140, ["focus"] = "family" };
var body2 = JsonSerializer.Serialize(family);
var done = await RunStream(body2, IdemKey("tour", body2));
foreach (var r in Jsonl(done.GetProperty("output").GetProperty("output").GetString()!))
if (r.GetProperty("k").GetString() == "seg") Console.WriteLine($"[{r.GetProperty("kind")}] {r.GetProperty("say")}");
Worked examples
Real replies from the app's free examples.
A typed name, no photo - Charles Bridge, 5 minutes, history
{"k":"id","name":"Charles Bridge","kind":"landmark","where":"Prague, Czech Republic","lat":50.0865,"lon":14.4114,"confidence":"high","clues":["Hint names Charles Bridge, Prague directly","world-famous stone bridge with distinctive Gothic towers and baroque statues"],"unsure":""}
{"k":"title","text":"Charles Bridge: Stones of Prague's History"}
{"k":"seg","kind":"intro","heading":"Standing on the Vltava","say":"You are standing on Charles Bridge, the stone bridge that has linked the two halves of Prague for more than six hundred years. Below you the Vltava River flows toward the Elbe, and ahead of you the towers of the Old Town and the castle hill frame the view. This bridge was not just a crossing. For centuries it was the only way over the river here, which made it the city's main artery for trade, royal processions, and everyday life. Take a moment and feel the worn stones under your feet. Millions of footsteps have smoothed them long before yours."}
{"k":"seg","kind":"history","heading":"A King's Ambitious Project","say":"The bridge takes its name from Charles the Fourth, the Holy Roman Emperor and King of Bohemia, who ordered its construction in 1357. Prague already had an older bridge nearby that floods kept destroying, so Charles wanted something permanent. Legend says the builders mixed egg yolks into the mortar to strengthen it, and local villages were asked to send eggs for the project. Historians are not fully certain the story is true, but the bridge has certainly proven remarkably durable, surviving floods, wars, and centuries of heavy use."}
{"k":"seg","kind":"history","heading":"Builder and Design","say":"The architect was a young man named Peter Parler, who was only in his twenties when Charles entrusted him with this work and with the city's great cathedral. Parler's design used sixteen sturdy arches of sandstone blocks. For hundreds of years, this was the only bridge across the river in Prague, so everyone from merchants to coronation processions had to pass this exact spot. The bridge carried horse carts, pilgrims, and soldiers long before anyone imagined it would one day be filled with painters, musicians, and tourists."}
{"k":"seg","kind":"look","heading":"Towers and Statues","say":"Look toward the Old Town side and you will see a tall Gothic tower with intricate stone carvings, considered one of the finest civil Gothic structures in Europe. Now look along the length of the bridge itself. Lining both sides are thirty dark statues and statue groups, mostly saints, added mostly in the late 1600s and early 1700s, long after the bridge itself was built. Many of the originals have been moved to a museum and replaced with copies to protect them from weather and pollution, but their dramatic, swirling baroque style still fills the bridge with movement and emotion."}
{"k":"seg","kind":"look","heading":"The Bronze Saint","say":"Find the statue of a bearded man with a crown of stars, usually surrounded by a small crowd touching its base. This is Saint John of Nepomuk, a priest thrown from this bridge into the river in the 1300s. Tradition holds that touching the bronze plaque beside him, showing the scene of his fall, brings good luck or guarantees a return visit to Prague. Notice how the bronze has turned bright gold in a few spots from countless hands rubbing it over the years."}
{"k":"seg","kind":"detail","heading":"A Detail Most Visitors Miss","say":"Here is something easy to overlook. Near the base of several bridge towers, look closely at the stonework for small, carved marks, which were signatures left by the individual stonemasons who cut each block, a kind of medieval quality control. Also listen for the difference in sound as you cross. Horse carts and heavy wagons once crossed here daily, and the bridge was built wide and strong enough to handle that constant weight, which is part of why it has lasted so long without major rebuilding of its core structure."}
{"k":"seg","kind":"close","heading":"Before You Cross On","say":"As you finish crossing, turn around and look back at the way you came, with the twin towers rising behind you and the river curving beneath the arches. This bridge has outlasted the empire that built it and the kingdom that named it, yet it still does exactly what it always did, carrying people from one side of Prague to the other. Walk on toward the castle, and know that you are following a path worn by seven centuries of travelers before you."}
{"k":"verify","text":"The egg-yolk-in-the-mortar legend is popularly told but not confirmed by modern material analysis; treat it as folklore."}
{"k":"verify","text":"Confirm the exact count and dating of the bridge statues on site, as some have been replaced or restored at different times."}
{"k":"end"}
A photo of a generic bridge with GPS - low confidence, hedged tour
{"k":"id","name":"Old stone bridge over a dry riverbed","kind":"landmark","where":"near Myrtos, Lasithi region, Crete, Greece","lat":null,"lon":null,"confidence":"low","clues":["long stone-paved causeway or bridge deck with rounded cobbles","low mortared fieldstone parapet walls on both sides","puddles sitting in the stone paving, suggesting a seasonal riverbed or wash beneath","olive groves and a dry, scrub-covered hillside around it","a small cluster of white village buildings visible in the distance"],"unsure":"I can see this is an old stone-paved bridge or causeway crossing what looks like a seasonal streambed, but I cannot confirm its name, builder, or exact age from the photo. The GPS point sits almost exactly on Myrtos, on Crete's south coast, which fits the look of the old mule-path bridges in that area, but I can't verify this particular crossing against a plaque or record."}
{"k":"alt","name":"A restored kalderimi (traditional stone mule path) rather than a true bridge","why":"The low parapets and continuous stone paving look like the old paved footpaths that linked Cretan villages, which sometimes cross dry gullies on raised stone causeways like this one"}
{"k":"title","text":"An Old Stone Crossing Near Myrtos"}
{"k":"seg","kind":"intro","heading":"A quiet stone crossing","say":"You are standing on an old stone-paved bridge, on a route near Myrtos, on the south coast of Crete. It carries a narrow track over what is clearly a dry riverbed or wash, with rounded cobbles worn smooth underfoot. I cannot confirm its exact name or age, but its style is typical of the old stone crossings scattered across this part of the island."}
{"k":"seg","kind":"history","heading":"Paths for mules and groves","say":"For centuries, villages in this part of Crete were linked by paved mule paths called kalderimia, built by hand from local stone. Bridges like this one let farmers and travelers cross seasonal streams even after autumn rains, carrying olives, goods, and animals between the hills and the coast. Many such crossings are far older than they first appear."}
{"k":"seg","kind":"look","heading":"Walls and worn stones","say":"Look at the low parapet walls on either side, built from roughly mortared fieldstone to keep animals and carts from slipping off the edge. Beneath your feet, the paving is made of rounded river cobbles, packed tightly and smoothed by generations of footsteps, hooves, and cart wheels."}
{"k":"seg","kind":"detail","heading":"Water still passes through","say":"Notice the puddles sitting right in the stone paving. That is a clue this structure doubles as a ford or small bridge over a streambed that only runs after rain, something easy to miss unless you visit, as you have, shortly after a storm."}
{"k":"seg","kind":"close","heading":"Take in the valley","say":"Before you move on, look out over the olive groves and dry hillsides on either side. This simple crossing has quietly carried people through this valley for a very long time, long before cars reached these hills."}
{"k":"verify","text":"The official name, builder, and date of this specific bridge or causeway near Myrtos, if it has one."}
{"k":"end"}
Too generic to name - confidence none, an ask instead of a tour
{"k":"id","name":"","kind":"unknown","where":"","lat":null,"lon":null,"confidence":"none","clues":["a long stone-paved bridge or causeway with low rubble-stone parapet walls","worn cobbled surface with puddles, suggesting a seasonal riverbed or ford below","surrounding olive groves and dry scrubby hills typical of a Mediterranean climate","no signage, inscription, or distinctive architectural feature visible","power lines and a small village or farm buildings faintly visible in the distance"],"unsure":"This looks like an old stone footbridge or packhorse bridge somewhere in the Mediterranean, perhaps Greece, Crete, Cyprus, or a similar region, but there is no name, sign, or landmark feature to identify which bridge or village this is."}
{"k":"ask","text":"Could you share the town or region name, or a wider shot showing a sign, village, or any inscription near the bridge? That would let me identify it properly."}
{"k":"end"}