Photo Storybook - API

A photo in, a story out. From your own code.

Turn a photo into an original short story

Photo Storybook takes one photo and writes an original story inspired by it, in the genre, age band and length you choose, split into picture-book pages written to be read aloud. It says what it saw in the photo, uses those things in the story, and for children adds questions to talk about and words to know.

Each story is one call to the SkillSafe App API: a flat JSON object plus the photo's file id in over HTTPS, JSON Lines out. The app reads the story 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.

StatusTypical error.codeMeaning
400validation_errorThe body failed validation (a field of the wrong type, an object where a scalar belongs); details names the field.
401unauthorizedInvalid or expired app session. Get a new token (step 1).
402payment_requiredInsufficient credits: the balance is below min_credits for this run. Top up, or sign in for a personal token.
403forbiddenThe 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.
404not_foundUnknown app (a misspelt slug in POST /guest), or an unknown job id, or a job that belongs to another token.
409conflictThe Idempotency-Key was already used with a different body.
429rate_limitedRate 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, story. Always send "task": "story". The mode field picks a new story, a rewrite or the next chapter.

taskWhat it doesReply lines
storyLook at the attached photo and write an original story from it for the chosen genre, age and length.seen, title, 3-12 page, optional moral, 0-3 talk, 0-3 word, end. When the photo cannot carry a story: seen (usable false), ask, end.

Input fields

Every field is a scalar except $files. Send them all; use an empty string when one does not apply.

FieldTypeMeaning
taskstringstory.
photostringattached when $files carries a photo. Without one the reply is an ask.
detailsstringNames to use and things to include or avoid, or empty. The app keeps up to 400 characters (start and end, the middle replaced by a marker).
genrestringbedtime | adventure | funny | fairy | mystery | fable | space | spooky.
agestring3-5 | 6-8 | 9-12 | 13+.
minutesnumber1, 3 or 5 - the read-aloud length.
target_wordsnumberminutes x 130.
pagesstring3-4, 6-8 or 9-12 for 1, 3 or 5 minutes.
modestringnew | rewrite (apply change_request to previous_story) | next (chapter chapter of previous_story).
previous_storystringOptional: the earlier story's title and pages as plain text, up to 3,000 characters.
change_requeststringOptional: what should change, up to 300 characters.
chapternumberOptional, with mode: next: 2 or more.
retry_notestringOptional: a correction after a malformed reply, sent once on a new Idempotency-Key attempt.
$filesarrayOne 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. 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 re-encodes the photo in the browser, which also strips its location data.

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')

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 (a stray code fence, for one).

kFields
seensummary (one sentence about the photo), things (3-6 visible things), people (how many are visible), usable (false when the photo cannot carry a story).
titletext.
pagespot: which part of the photo the page's picture shows - center, top, bottom, left, right, top-left, top-right, bottom-left, bottom-right; text (plain sentences written to be read aloud).
moraltext - the lesson; always present for fable.
talktext - a question to ask a child afterwards.
wordword, means - a word from the story and a child-friendly meaning.
asktext - only when usable is false: what kind of photo works.

Check the reply against what you asked for: the pages should total about target_words, most of things should appear in the pages, and every name in details should be used. Stories are AI-generated - read them before reading them to a young child.

Step 1 - A tiny client, and a token

POST /guest

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":"photo-storybook"}, 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":"photo-storybook"}' | 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": "photo-storybook"}, 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: "photo-storybook" }, {}, 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": "photo-storybook"}, 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 PhotoStorybook {
    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", "photo-storybook"), 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: "photo-storybook" })["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" => "photo-storybook"])["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).
PhotoStorybook.Token ??= (await PhotoStorybook.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "photo-storybook" })).GetProperty("token").GetString();

// ... steps 2-5 go here ...

static class PhotoStorybook
{
    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?

GET /me

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 PhotoStorybook.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("subject_id")}: {me.GetProperty("credits")} credits");

Step 3 - Price a story for free

POST /estimate

/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 bedtime story for ages 3-5. Every field is a scalar; the photo rides in "$files".
jq -n --arg id "$FILE_ID" '{
  task: "story", photo: "attached", details: "The hero is Maya, 6.",
  genre: "bedtime", age: "3-5", minutes: 3, target_words: 390,
  pages: "6-8", mode: "new", "$files": [$id]
}' > story.json

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -d @story.json | jq '.data | {hold_credits, min_credits, model_alias, input_checked, warnings}'
story_input = {
    "task": "story", "photo": "attached", "details": "The hero is Maya, 6.",
    "genre": "bedtime", "age": "3-5", "pages": "6-8", "mode": "new",
    "minutes": 3, "target_words": 3 * 130,
    "$files": [file_id],  # from POST /files - see "Sending a photo"
}
est = api("POST", "/estimate", story_input)
print(est["hold_credits"], est["min_credits"], est["model_alias"], est.get("warnings"))
const storyInput = {
  task: "story", photo: "attached", details: "The hero is Maya, 6.",
  genre: "bedtime", age: "3-5", pages: "6-8", mode: "new",
  minutes: 3, target_words: 3 * 130,
  $files: [fileId], // from POST /files - see "Sending a photo"
};
const est = await api("POST", "/estimate", storyInput);
console.log(est.hold_credits, est.min_credits, est.model_alias, est.warnings);
// at package level:
func storyInput() map[string]any {
	return map[string]any{
		"task": "story", "photo": "attached", "details": "The hero is Maya, 6.",
		"genre": "bedtime", "age": "3-5", "pages": "6-8", "mode": "new",
		"minutes": 3, "target_words": 3 * 130,
		"$files": []string{fileID}, // from POST /files - see "Sending a photo"
	}
}

// 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", storyInput(), nil, &est); err != nil {
	log.Fatal(err)
}
fmt.Println(est.Hold, est.Min, est.Alias, est.Warnings)
static ObjectNode storyInput() {
    ObjectNode in = JSON.createObjectNode();
    in.put("task", "story");
    in.put("photo", "attached");
    in.put("details", "The hero is Maya, 6.");
    in.put("genre", "bedtime");
    in.put("age", "3-5");
    in.put("minutes", 3);
    in.put("target_words", 3 * 130);
    in.put("pages", "6-8");
    in.put("mode", "new");
    in.putArray("$files").add(fileId); // from POST /files - see "Sending a photo"
    return in;
}

// in main():
ensureToken();
JsonNode est = api("POST", "/estimate", storyInput(), Map.of());
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model_alias"));
story_input = {
  task: "story", photo: "attached", details: "The hero is Maya, 6.",
  genre: "bedtime", age: "3-5", pages: "6-8", mode: "new",
  minutes: 3, target_words: 3 * 130,
  "$files" => [file_id] # from POST /files - see "Sending a photo"
}
est = api("POST", "/estimate", story_input)
puts est.values_at("hold_credits", "min_credits", "model_alias").inspect
$storyInput = [
    "task" => "story", "photo" => "attached", "details" => "The hero is Maya, 6.",
    "genre" => "bedtime", "age" => "3-5", "pages" => "6-8", "mode" => "new",
    "minutes" => 3, "target_words" => 3 * 130,
    '$files' => [$fileId], // from POST /files - see "Sending a photo"
];
$est = api("POST", "/estimate", $storyInput);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model_alias"], PHP_EOL;
var storyInput = new Dictionary<string, object>
{
    ["task"] = "story", ["photo"] = "attached", ["details"] = "The hero is Maya, 6.",
    ["genre"] = "bedtime", ["age"] = "3-5", ["pages"] = "6-8", ["mode"] = "new",
    ["minutes"] = 3, ["target_words"] = 3 * 130,
    ["$files"] = new[] { fileId }, // from POST /files - see "Sending a photo"
};
var est = await PhotoStorybook.ApiAsync(HttpMethod.Post, "/estimate", storyInput);
Console.WriteLine($"{est.GetProperty("hold_credits")} {est.GetProperty("min_credits")} {est.GetProperty("model_alias")}");

Step 4 - Write the story

POST /run   GET /jobs/{job_id}

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 . story.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="photo-storybook-story-$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 == "seen") | "In the photo: \(.summary)"' reply.jsonl
jq -r 'select(.k == "title" or .k == "page") | .text' 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"photo-storybook-{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(story_input)
job = api("POST", "/run", body, {"Idempotency-Key": idem_key("story", 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"])
seen = next(r for r in rows if r["k"] == "seen")
print("In the photo:", seen["summary"])
for r in rows:
    if r["k"] in ("title", "page"):
        print(r["text"])
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 `photo-storybook-${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(storyInput);
let job = await api("POST", "/run", body, { "Idempotency-Key": idemKey("story", 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 seen = rows.find((r) => r.k === "seen");
console.log(job.charged_credits, seen.summary);
for (const r of rows.filter((r) => r.k === "title" || r.k === "page")) console.log(r.text);
// 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("photo-storybook-%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(storyInput())
var j job
if err := call("POST", "/run", json.RawMessage(body), map[string]string{"Idempotency-Key": idemKey("story", 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 "title":
		fmt.Println(r["text"])
	case "page":
		fmt.Printf("[%v] %v\n", r["spot"], r["text"])
	}
}
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 "photo-storybook-" + 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(storyInput());
JsonNode job = api("POST", "/run", body, Map.of("Idempotency-Key", idemKey("story", 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("title")) System.out.println(r.get("text").asText());
    if (k.equals("page")) System.out.println("[" + r.get("spot").asText() + "] " + r.get("text").asText());
}
require "digest"

def idem_key(task, body, attempt = 1)
  "photo-storybook-#{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 = story_input.to_json
job = api("POST", "/run", body, { "Idempotency-Key" => idem_key("story", body) })
until %w[succeeded failed].include?(job["status"])
  sleep 2
  job = api("GET", "/jobs/#{job["job_id"]}")
end
rows = jsonl(job.dig("output", "output"))
seen = rows.find { |r| r["k"] == "seen" }
puts "In the photo: #{seen["summary"]}"
rows.select { |r| %w[title page].include?(r["k"]) }.each { |r| puts r["text"] }
function idem_key(string $task, string $body, int $attempt = 1): string {
    return "photo-storybook-$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($storyInput, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$job = api("POST", "/run", $body, ["Idempotency-Key" => idem_key("story", $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"] === "title") echo $r["text"], PHP_EOL;
    if ($r["k"] === "page") echo "[{$r["spot"]}] {$r["text"]}", 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 $"photo-storybook-{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(storyInput);
var job = await PhotoStorybook.ApiAsync(HttpMethod.Post, "/run", body, new() { ["Idempotency-Key"] = IdemKey("story", body) });
while (job.GetProperty("status").GetString() is not ("succeeded" or "failed"))
{
    await Task.Delay(2000);
    job = await PhotoStorybook.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 == "title") Console.WriteLine(r.GetProperty("text"));
    if (k == "page") Console.WriteLine($"[{r.GetProperty("spot")}] {r.GetProperty("text")}");
}

Step 5 - Stream a second story

POST /run-stream

The same photo as a 1-minute funny story, 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 photo again as a 1-minute funny story - a second run, streamed.
jq -c '. + {genre: "funny", minutes: 1, target_words: 130, pages: "3-4"}' story.json > funny.json
BODY=$(cat funny.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: photo-storybook-story-$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 == "page") | .text' 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")

funny = dict(story_input, genre="funny", minutes=1, target_words=130, pages="3-4")
body = json.dumps(funny)
done = run_stream(body, idem_key("story", body))
for r in jsonl(done["output"]["output"]):
    if r["k"] == "page":
        print(r["text"])
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 funny = { ...storyInput, genre: "funny", minutes: 1, target_words: 130, pages: "3-4" };
const body2 = JSON.stringify(funny);
const done = await runStream(body2, idemKey("story", body2));
for (const r of jsonl(done.output.output)) if (r.k === "page") console.log(r.text);
// 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 := storyInput()
in["genre"], in["minutes"], in["target_words"], in["pages"] = "funny", 1, 130, "3-4"
body2, _ := json.Marshal(in)
done, err := runStream(body2, idemKey("story", body2, 1))
if err != nil {
	log.Fatal(err)
}
for _, r := range jsonl(done.Output.Output) {
	if r["k"] == "page" {
		fmt.Printf("[%v] %v\n", r["spot"], r["text"])
	}
}
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 funny = storyInput();
funny.put("genre", "funny");
funny.put("minutes", 1);
funny.put("target_words", 130);
funny.put("pages", "3-4");
String body2 = JSON.writeValueAsString(funny);
JsonNode done = runStream(body2, idemKey("story", body2, 1));
for (JsonNode r : jsonl(done.path("output").path("output").asText()))
    if (r.get("k").asText().equals("page")) System.out.println(r.get("text").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

funny = story_input.merge(genre: "funny", minutes: 1, target_words: 130, pages: "3-4")
body2 = funny.to_json
done = run_stream(body2, idem_key("story", body2))
jsonl(done.dig("output", "output")).each { |r| puts r["text"] if r["k"] == "page" }
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");
}

$funny = array_merge($storyInput, ["genre" => "funny", "minutes" => 1, "target_words" => 130, "pages" => "3-4"]);
$body2 = json_encode($funny, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$done = run_stream($body2, idem_key("story", $body2));
foreach (jsonl($done["output"]["output"] ?? "") as $r)
    if ($r["k"] === "page") echo $r["text"], PHP_EOL;
static async Task<JsonElement> RunStream(string body, string key)
{
    var req = new HttpRequestMessage(HttpMethod.Post, PhotoStorybook.Api + "/run-stream")
    { Content = new StringContent(body, Encoding.UTF8, "application/json") };
    req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", PhotoStorybook.Token);
    req.Headers.Add("Idempotency-Key", key);
    var res = await PhotoStorybook.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 funny = new Dictionary<string, object>(storyInput) { ["genre"] = "funny", ["minutes"] = 1, ["target_words"] = 130, ["pages"] = "3-4" };
var body2 = JsonSerializer.Serialize(funny);
var done = await RunStream(body2, IdemKey("story", body2));
foreach (var r in Jsonl(done.GetProperty("output").GetProperty("output").GetString()!))
    if (r.GetProperty("k").GetString() == "page") Console.WriteLine(r.GetProperty("text"));

Worked examples

Real replies from the app's free examples.

A bedtime story for ages 3-5, 3 minutes

```
{"k":"seen","summary":"A small hedgehog with brown and white spines stands in green grass next to a curled brown leaf.","things":["hedgehog","spiky brown and white fur","green grass","a dry brown leaf","a pink nose","black eyes"],"people":0,"usable":true}
{"k":"title","text":"Pip the Sleepy Hedgehog"}
{"k":"page","spot":"center","text":"Once upon a time, there was a little hedgehog named Pip. Pip had a soft white nose. Pip had tiny black eyes. Pip had hundreds of little brown spikes on his back. Pip lived in a green, green garden. Every single evening, Pip went for a slow, slow walk. Tonight, Pip felt a little bit sleepy already."}
{"k":"page","spot":"bottom","text":"Pip walked slowly through the soft green grass. Swish, swish, went the grass under his little paws. Snuffle, snuffle, went Pip's twitchy nose. He sniffed here. He sniffed there. He sniffed everywhere. Pip loved his cosy, quiet garden home."}
{"k":"page","spot":"top","text":"Pip looked left. Pip looked right. The tall grass waved gently around him. A little beetle scuttled by. A soft moth fluttered past. Pip was not looking for his friends tonight. Pip was looking for something special."}
{"k":"page","spot":"bottom-left","text":"Then Pip found it. A little brown leaf, curled and dry. Crinkle, crinkle, said the leaf. Pip touched it gently with his small paw. \"Hello, soft leaf,\" said Pip. \"You will be my pillow tonight.\" Pip smiled a sleepy smile."}
{"k":"page","spot":"top","text":"Above the garden, the sky turned soft and pink. The warm sun slipped slowly down, down, down. A gentle breeze moved through the grass. Shh, shh, whispered the wind, soft as a lullaby. It was almost time for sleep."}
{"k":"page","spot":"right","text":"Pip's little black eyes blinked slowly. Blink. Blink. His soft pink nose gave one last twitch. Pip gave a great big yawn, wide and long. He stretched his tiny paws out, then tucked them back in. Pip was ready to rest."}
{"k":"page","spot":"top-right","text":"Pip curled his spiky back into a tight little ball. Round and round, soft and small. His brown spikes made a cosy, prickly blanket all his own. His warm white tummy tucked in safe and snug. Curl up, Pip. Curl up tight, little hedgehog."}
{"k":"page","spot":"bottom-left","text":"The little brown leaf rested close beside him. The soft green grass tucked around him like a blanket. The whole garden grew quiet and still. Goodnight, sleepy Pip. Goodnight, little hedgehog. Sleep softly now, until the morning light."}
{"k":"talk","text":"What did Pip find in the soft green grass?"}
{"k":"talk","text":"Can you curl up into a tiny ball like Pip?"}
{"k":"end"}
```

A fable with a lesson for ages 6-8

{"k":"seen","summary":"A small snowman stands alone in a foggy, snow-covered field beside a bare tree and a chain-link baseball cage.","things":["a small snowman","a bare tree","thick fog","a chain-link baseball cage","a snow-covered field"],"people":0,"usable":true}
{"k":"title","text":"Pip the Snowman and the Foggy Giant"}
{"k":"page","spot":"bottom-left","text":"Pip the snowman stood alone in a wide, snowy field. Someone had patted his middle round and smooth. Two twigs stuck out for arms. A crooked stick hat sat on his head. Pip liked his quiet spot, most days."}
{"k":"page","spot":"top-left","text":"One grey morning, a thick fog crept over the field. It wrapped around the old bare tree beside him. The tree's branches stretched out like long, bony fingers. Even the ball field's tall fence vanished into the grey. Pip had never seen fog so thick before. It made everything look strange and new."}
{"k":"page","spot":"left","text":"Through the swirling grey, Pip spotted a tall shape. It had a criss-cross pattern, like silver teeth. His twig arms trembled, even though snowmen cannot really feel afraid. \"A giant!\" Pip gasped. \"A giant with a mouth made of string, waiting to gobble up the whole snowy field!\""}
{"k":"page","spot":"bottom","text":"Pip wanted to run far away. But snowmen have no legs, only one round lump for feet. He wiggled. He wobbled. He leaned so hard he nearly tipped right over. Still, he did not move one single inch."}
{"k":"page","spot":"center","text":"Just then, a little brown sparrow landed on his twig arm. Her feathers were puffed up round and warm. \"What is wrong, Pip?\" she chirped softly. Pip pointed a shaky stick toward the fog. \"A giant,\" he whispered. \"With teeth made of silver string, right over there.\""}
{"k":"page","spot":"left","text":"The sparrow gave a tiny laugh and flew off to look closer. She swooped right through the criss-cross lines, then circled back again. \"It is only a baseball cage,\" she said. \"Empty, quiet, and not one bit hungry.\""}
{"k":"page","spot":"right","text":"Pip felt silly, then he felt glad, all at once. The fog was only fog. The giant was only an old fence with holes. He had been scared of shapes he could not see clearly. Pip laughed a little snowman laugh at himself."}
{"k":"page","spot":"bottom-left","text":"From then on, whenever shapes looked scary in the mist, Pip did not worry right away. He asked his sparrow friend to take a closer look first. The fog drifted off, and the field grew calm and bright. Pip stood tall, happy, and brave."}
{"k":"moral","text":"Things that scare us in the fog often turn out to be friendly once we look closer."}
{"k":"talk","text":"Have you ever seen something scary in the fog or dark that turned out to be friendly up close?"}
{"k":"talk","text":"What would you say to Pip to help him feel brave next time?"}
{"k":"word","word":"criss-cross","means":"lines that cross over each other, like an X shape"}
{"k":"word","word":"trembled","means":"shook a little, often from being scared or cold"}
{"k":"end"}

A photo too dark to use - an ask instead of a story

{"k":"seen","summary":"The photo is almost completely black, with no shapes, colours or objects visible.","things":["darkness","faint shadows","no visible shapes"],"people":0,"usable":false}
{"k":"ask","text":"This photo is too dark for me to see anything to build a story around. Could you try again in a brighter room or with a flash, maybe of a favourite toy, pet or cosy bedtime spot?"}
{"k":"end"}