in inTouch Docs
Get your key
Capability

📚 Vocabulary stable

A language-learning capability that is entirely AI-driven. Read the user's profile, push a set of exercises, read back exactly what they got right and wrong — then push a sharper set.

The app downloads the whole set including its solutions and grades on-device, so the user can practise offline. Results are queued locally and uploaded when they are back online. A push replaces the active set completely; the answer history survives.

The daily loop

The app has no settings screen for this capability, and neither does the server. What is being learned — language, level, how many questions — lives in your prompt file on your own machine. The server only holds today's set, the answers and the streak. Once a day the assistant runs three moves:

1. GET  /vocab/profile   is today's set already there? (status only — no stored profile)
2. GET  /vocab/history   what did they get wrong, which types are weak, how many tries
3. POST /vocab/push      today's set, carrying language + level, all five types

Raising or lowering the level is a local decision: the assistant edits the level in its own prompt file and the next push carries the new one. Nothing to store server-side.

Step 1 makes the whole thing safe to run as often as you like: if active_set.set_date is already today, the run stops before it writes anything. That is what makes a laptop schedule work — it may fire at breakfast, at lunch, or twice.

Not interested in the plumbing? Skip to the lazy path and let an assistant build all of it for you.

What lives where

Two places, and the split is deliberate:

On the serverIn your prompt file
Every answer you ever gave: the question, what you typed, right or wrong, how many attempts it took, plus the streak and the active set. Language, level, questions per day — and the learned: lines the assistant writes after reading your results.
Durable. Readable any time with your key. Survives a reinstall, a new phone and a new laptop. Yours. A plain text file you can read, correct or delete — open it in any editor.

The rule the assistant follows: raw events on the server, conclusions in the file. It never copies the history into the file — it re-reads the history and rewrites the conclusions, so a wrong assumption is one deleted line away from being gone.

The lazy path: hand the whole setup to your AI

Everything on the rest of this page is something an assistant can do for you. Copy this, paste it into Claude Code or Codex CLI, replace the key, and answer the questions it asks. It reads the docs itself and comes back when your first set is in the app and tomorrow's is scheduled.

Set up my daily inTouch vocabulary training, end to end.

Read these two pages first — they are plain text, written for you, and contain
everything you need:
  https://docs.in-touch.cc/vocabulary.txt
  https://docs.in-touch.cc/llms.txt
On the vocabulary page, follow the section "self-setup" — it is the job description.

My inTouch API key: itk_PASTE_YOUR_KEY_HERE

Ask me what I want to learn, then do the rest yourself: write the prompt file that
holds my language and level, push a first set, and install the daily automation for
my operating system. Show me what you are going to create before you create it, and
never print my key.
What it does with that. It asks four short questions (language, level, questions per day, language of the explanations), writes them into ~/intouch/vocab-prompt.md, pushes a first set into the app, adds a guard script and registers a scheduler that fits your OS — launchd, systemd, Task Scheduler or a GitHub Actions cron. Finally it runs the script once so you can see the guard refuse a second push.

The prompt stays short on purpose: the instructions live in vocabulary.txt under self-setup, so they improve without you ever copying a new prompt. What you paste is a pointer, not a program.

What it needs — and where it stops. An assistant with web access and a terminal: Claude Code and Codex CLI both qualify, and Codex additionally needs network access allowed in its sandbox. A browser chat (claude.ai, ChatGPT) can push a first set through its own tools, but it cannot write a prompt file or install a scheduler on your machine — it will tell you so and stop there. And your key is a credential: paste it into a tool you trust, and rotate it in the app if you ever regret it.

The manual path: set it up yourself

Three things: a key, a prompt file, and something that runs it every day. This is also the reference for what the lazy path builds — worth a skim before you let it write files on your machine.

1 · Get your key

In the app: Settings → App & Media → inTouch API, then Generate key. It starts with itk_ and is the only credential involved. Keep it in an environment variable rather than inside the prompt file — see Authentication.

2 · Pick your AI and agree what to learn

Both CLIs work the same way here: one interactive conversation to settle language, level and volume, then the same prompt on a schedule. Nobody types any of it into the app, and nothing about it is stored on the server.

◈

Claude Code

npm install -g @anthropic-ai/claude-code
claude            # first run: sign in

Then have the setup conversation once — this is where language, level and volume get decided, and where you can change your mind later:

export INTOUCH_API_KEY="itk_YOUR_KEY"
claude "Read https://docs.in-touch.cc/vocabulary.txt. I want to learn
Spanish, level A2, 8 questions a day, explanations in German. Write that into
~/intouch/vocab-prompt.md, then push a first set with my key from INTOUCH_API_KEY."

For the daily run, the same CLI in headless mode. It needs permission to reach the API without asking, which is what the --allowedTools flag grants:

claude -p "$(cat ~/intouch/vocab-prompt.md)" --allowedTools "Bash(curl:*)"
◇

Codex CLI

npm install -g @openai/codex
codex             # first run: sign in

The same setup conversation:

export INTOUCH_API_KEY="itk_YOUR_KEY"
codex "Read https://docs.in-touch.cc/vocabulary.txt. I want to learn
Spanish, level A2, 8 questions a day, explanations in German. Write that into
~/intouch/vocab-prompt.md, then push a first set with my key from INTOUCH_API_KEY."

And the daily run, non-interactive:

codex exec --full-auto "$(cat ~/intouch/vocab-prompt.md)"
Codex sandboxes network access by default. The run does nothing but HTTPS calls, so it has to be allowed: set network_access = true under [sandbox_workspace_write] in ~/.codex/config.toml, or pass the full-access sandbox flag. Flag names differ between versions — check codex exec --help once, then never again.

3 · Save the daily prompt

Put this in ~/intouch/vocab-prompt.md. This file is the only place your language, level and volume exist — the server never sees them. It is deliberately tool-agnostic: the same file works for Claude Code, Codex, a cloud runner, or a chat window on a bad day.

You keep my inTouch vocabulary training up to date. Run this once a day and do not ask me
anything — decide on your own and report in one line at the end.

WHAT I AM LEARNING  (edit these lines, they live here and nowhere else)
  language:            es
  my language:         de
  level:               A2
  questions per day:   8
  learned:             (you keep these lines; one short conclusion each)
  last read:           (cursor from the history; you overwrite it every run)

API base:  https://api.in-touch.cc
Auth:      header "X-API-Key" with the value of the INTOUCH_API_KEY environment variable
Reference: https://docs.in-touch.cc/vocabulary.txt

STEP 1 - IS TODAY ALREADY DONE?
  GET /api/addons/vocab/profile   (status only: active_set, streak, supported types)
  If active_set.set_date is today (the API works in UTC), stop: today's set already exists.
  Never push twice on the same day.

STEP 2 - READ HOW I DID  (only what is new)
  GET /api/addons/vocab/history?since=<last read>&limit=200
    - no "last read" yet? Leave since out for the first run.
    - keep calling with the returned cursor while has_more is true.
    - write the final cursor into "last read:" at the top of this file.
  Then, for the standing picture, one call without since:
  GET /api/addons/vocab/history?only=wrong&limit=50
  Read summary.first_try_rate and summary.by_type[].accuracy (both PERCENT, 0-100),
  summary.avg_attempts (tries per question) and hardest[] (worst 10, newest first).
  items[] carries every single answer: the question, what I typed, right or wrong,
  and how many attempts it took.

STEP 3 - DECIDE THE LEVEL (only once summary.total is at least 30)
  first_try_rate >= 85 and avg_attempts <= 1.2 -> one level up   (A1 A2 B1 B2 C1)
  first_try_rate <= 40                         -> one level down (never below A1)
  otherwise                                    -> keep it
  If it changes, EDIT THE "level:" LINE AT THE TOP OF THIS FILE and say so in your
  summary. There is no server-side profile — the file is the source of truth.

STEP 4 - BUILD TODAY'S SET (the "questions per day" number from the top of this file)
  - Around 40 % revisits what I got wrong or needed several attempts for: take it from
    hardest[] and the only=wrong items, but rephrase it. I should not recognise the wording.
  - Let the "learned:" lines above steer the mix — that is what they are for.
  - The rest is new material for the current level, reusing vocabulary I already know.
  - Use all five question types, and over-weight the types with the lowest accuracy.
  - Every question carries its solution and a short explanation in my native language.
  - translate and fill_gap: list every reasonable variant in "accepted". Matching ignores
    case, accents and punctuation, but it does not know synonyms.

STEP 5 - WRITE DOWN WHAT YOU LEARNED
  Update the "learned:" lines at the top of this file: what keeps failing, which
  question types are too easy, words that need a third pass. Short conclusions,
  not a copy of the history — the history stays on the server and you can always
  read it again. Drop lines that no longer hold; I can edit them myself too.

STEP 6 - PUSH IT
  POST /api/addons/vocab/push  with {"language": "<language>", "level": "<level>",
                                     "questions": [ ... ]}
  language is REQUIRED on every push - the app reads it from the set to label the card.
    translate        { prompt, accepted[], direction?, hint?, explanation? }
    multiple_choice  { prompt, options[>=2], correct_index, explanation? }
    fill_gap         { sentence (contains ___), accepted[], word_bank[]?, explanation? }
    match_pairs      { pairs[>=2 of {left, right}], explanation? }
    sentence_order   { solution[>=2], tokens[] (same words, shuffled), explanation? }
  The push replaces the active set completely; my answer history survives it.

STEP 7 - VERIFY
  The response reports accepted, rejected and errors[] per index. If anything was rejected,
  fix those questions and push the corrected FULL set again (a push replaces, it never appends).
  Final line: date, level, number of questions, and what changed compared to yesterday.

4 · Wrap it in a script

The script exists for one reason: ask the API whether today is already done before waking a model. A skipped run then costs one HTTPS request instead of a conversation.

#!/usr/bin/env bash
# ~/intouch/vocab.sh
set -euo pipefail

export INTOUCH_API_KEY="itk_YOUR_KEY"
BASE="https://api.in-touch.cc"
TMP="$(mktemp)"; trap 'rm -f "$TMP"' EXIT

today="$(date -u +%F)"                      # the API works in UTC

# No network yet (the machine just woke up) is not an error. A rejected key is:
# it has to fail loudly, or the schedule quietly does nothing for weeks.
code="$(curl -sS -o "$TMP" -w '%{http_code}' \
        -H "X-API-Key: $INTOUCH_API_KEY" \
        "$BASE/api/addons/vocab/profile")" || {
  echo "inTouch: API not reachable, leaving it for the next run"; exit 0
}
case "$code" in
  200)     ;;
  401|403) echo "inTouch: key rejected ($code) - fix it in this script"; exit 1 ;;
  *)       echo "inTouch: API returned $code, trying again next run"; exit 0 ;;
esac

if [ "$(jq -r '.active_set.set_date // ""' "$TMP")" = "$today" ]; then
  echo "inTouch: set for $today already exists"
  exit 0
fi

claude -p "$(cat "$HOME/intouch/vocab-prompt.md")" --allowedTools "Bash(curl:*)"
# Codex:  codex exec --full-auto "$(cat "$HOME/intouch/vocab-prompt.md")"
chmod +x ~/intouch/vocab.sh && ~/intouch/vocab.sh    # run it once by hand first

Needs jq. Without it, grep the response for the date instead — the profile payload carries no other YYYY-MM-DD field.

Run it every day

Four ways, depending on what you have. They all execute the same script.

SetupFires while your computer is offNeeds a terminalBest for
VPS or always-on machineYesYesThe clean answer, if you have one
Laptop or desktop scheduleNo — it catches up on the next startYesAlmost everyone
GitHub ActionsYesNoNo server, no CLI on your machine
By handNoYes (or a chat window)Trying it out
A laptop schedule is not worse than a cron on a server — it is just later. Every scheduler below is told to catch up on a missed run, and the guard in step 1 makes a late or repeated run harmless. Practical effect: you open the lid, and a fresh set is there by the time you reach for your phone.
⏱

launchd agent

StartCalendarInterval is the important bit: if the Mac was asleep at 07:00, launchd runs the job shortly after it wakes. RunAtLoad adds a run at login.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>              <string>com.intouch.vocab</string>
  <key>ProgramArguments</key>   <array>
    <string>/bin/bash</string>
    <string>/Users/YOU/intouch/vocab.sh</string>
  </array>
  <key>StartCalendarInterval</key>
  <dict><key>Hour</key><integer>7</integer><key>Minute</key><integer>0</integer></dict>
  <key>RunAtLoad</key>          <true/>
  <key>StandardOutPath</key>    <string>/tmp/intouch-vocab.log</string>
  <key>StandardErrorPath</key>  <string>/tmp/intouch-vocab.log</string>
</dict>
</plist>
launchctl load -w ~/Library/LaunchAgents/com.intouch.vocab.plist
tail -f /tmp/intouch-vocab.log     # watch the first run
▸

systemd timer or cron

Persistent=true is the catch-up: a timer that was missed while the machine was off runs right after the next boot.

# ~/.config/systemd/user/intouch-vocab.service
[Unit]
Description=inTouch vocabulary set

[Service]
Type=oneshot
ExecStart=%h/intouch/vocab.sh

# ~/.config/systemd/user/intouch-vocab.timer
[Unit]
Description=Daily inTouch vocabulary set

[Timer]
OnCalendar=*-*-* 07:00:00
Persistent=true

[Install]
WantedBy=timers.target
systemctl --user daemon-reload
systemctl --user enable --now intouch-vocab.timer
systemctl --user list-timers intouch-vocab.timer

On a VPS, add loginctl enable-linger $USER so the timer runs without a login session. If you would rather keep it simple there, a plain cron line does the job because the machine is always on:

0 6 * * * /home/YOU/intouch/vocab.sh >> /home/YOU/intouch/vocab.log 2>&1
▪

Task Scheduler

-StartWhenAvailable is the catch-up flag, -WakeToRun is optional and only helps a machine that sleeps rather than shuts down.

$action  = New-ScheduledTaskAction -Execute "powershell.exe" `
             -Argument "-File C:\intouch\vocab.ps1"
$trigger = New-ScheduledTaskTrigger -Daily -At 7am
$set     = New-ScheduledTaskSettingsSet -StartWhenAvailable -WakeToRun

Register-ScheduledTask -TaskName "inTouch Vocab" -Action $action `
  -Trigger $trigger -Settings $set

The PowerShell twin of the guard script:

# C:\intouch\vocab.ps1
$env:INTOUCH_API_KEY = "itk_YOUR_KEY"
$base  = "https://api.in-touch.cc"
$today = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd")

$state = Invoke-RestMethod "$base/api/addons/vocab/profile" `
           -Headers @{ "X-API-Key" = $env:INTOUCH_API_KEY }
if ($state.active_set.set_date -eq $today) { exit 0 }

claude -p (Get-Content "$HOME\intouch\vocab-prompt.md" -Raw) --allowedTools "Bash(curl:*)"

Both CLIs are happiest inside WSL. If you run them there, use the Linux tab and let Task Scheduler call wsl -e ~/intouch/vocab.sh.

◇

GitHub Actions

No server, no CLI, no computer that has to be awake. Put the prompt file in a private repository and add two secrets: INTOUCH_API_KEY and the model key your CLI signs in with.

# .github/workflows/vocab.yml
name: inTouch vocabulary
on:
  schedule:
    - cron: "0 5 * * *"      # 05:00 UTC
  workflow_dispatch:          # and a manual button

jobs:
  push-set:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm install -g @anthropic-ai/claude-code
      - run: claude -p "$(cat vocab-prompt.md)" --allowedTools "Bash(curl:*)"
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          INTOUCH_API_KEY:   ${{ secrets.INTOUCH_API_KEY }}
Two quirks worth knowing. Scheduled workflows can start a few minutes late under load, and GitHub disables the schedule after 60 days without repository activity — the workflow_dispatch button is your reminder that it is still alive. Neither matters much here: a set that lands at 05:07 is still today's set.

Or just do it by hand

No scheduler at all is a legitimate answer, especially on day one. Paste the prompt into Claude Code or Codex whenever you feel like practising — or into a normal chat window together with your key and the link to this page. The guard in step 1 means you can do that five times a day without ever damaging a set.

Check what is live

GET /api/addons/vocab/profile X-API-Key

Answers one question: is today's set already in the app? The path name is historical — there is no stored profile. Language, level and volume live in your prompt file; the server keeps only the active set, the answers and the streak.

curl "https://api.in-touch.cc/api/addons/vocab/profile" \
  -H "X-API-Key: itk_YOUR_KEY"
{
  "success": true,
  "supported_types": ["translate","multiple_choice","fill_gap","match_pairs","sentence_order"],
  "active_set": { "id": "vs_42_1753400000000", "language": "es", "level": "A2",
                  "set_date": "2026-07-25", "question_count": 8 },
  "streak": 6
}

"active_set": null means nothing has ever been pushed. Compare active_set.set_date against today (UTC) before every push — that single comparison is the whole idempotency story.

Why there is no profile endpoint any more. A stored profile was a second source of truth next to the prompt file, and the two could drift: the server would claim A2 while the file had long moved to B1. Now the level exists once, in the file, and every push carries it. Less to store, nothing to reconcile.

Push an exercise set

POST /api/addons/vocab/push X-API-Key
FieldTypeDescription
questions[]array1–50 questions, see types below
languagestringLanguage code from your prompt file, e.g. es. The app labels the feed card with it
levelstringA1–C1, defaults to A1
set_datestringOptional YYYY-MM-DD, defaults to today
set_idstringOptional own identifier; generated if omitted

Question types

Every question object needs a type; the remaining fields are its payload. Invalid questions are reported per index in errors[] — the rest is still accepted.

TypePayload
translate prompt*, accepted* (string[]), direction, hint, explanation
multiple_choice prompt*, options* (≥2), correct_index*, explanation
fill_gap sentence* (must contain ___), accepted* (string[]), word_bank (string[]), explanation
match_pairs pairs* (≥2 × { left, right }), explanation
sentence_order solution* (string[], ≥2), tokens (same words, shuffled), explanation

* required.

Free-text answers are matched leniently. translate and fill_gap compare against accepted ignoring case, accents, punctuation and repeated spaces — but not synonyms. List every reasonable variant. Adding a word_bank to fill_gap turns it into a tap-to-answer grammar drill, which is the natural way to test conjugation.

Example

curl -X POST https://api.in-touch.cc/api/addons/vocab/push \
  -H "X-API-Key: itk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "language": "es",
    "level": "A2",
    "questions": [
      { "type": "translate", "prompt": "der Schlüssel", "direction": "Deutsch → Spanisch",
        "accepted": ["la llave", "llave"], "explanation": "llave ist feminin." },
      { "type": "multiple_choice", "prompt": "¿Cómo ___ (tú)?",
        "options": ["estás", "está", "estoy"], "correct_index": 0 },
      { "type": "fill_gap", "sentence": "Ayer ___ al mercado.",
        "accepted": ["fui"], "word_bank": ["fui", "voy", "iré"],
        "explanation": "Indefinido, 1. Person Singular von ir." },
      { "type": "match_pairs",
        "pairs": [ { "left": "el pan", "right": "das Brot" },
                   { "left": "la leche", "right": "die Milch" } ] },
      { "type": "sentence_order",
        "solution": ["Mañana", "voy", "a", "la", "playa"],
        "tokens": ["playa", "voy", "Mañana", "la", "a"] }
    ]
  }'
{ "success": true, "set_id": "vs_42_1753400000000", "language": "es", "level": "A2",
  "set_date": "2026-07-25", "accepted": 5, "rejected": 0, "errors": [] }

Read results

GET /api/addons/vocab/history X-API-Key

Every answered question with the user's actual answer and whether it was correct — including sets you already replaced.

QueryDescription
onlywrong · correct · all (default)
limit1–500, default 100
sinceCursor from a previous response — returns only what was answered since then, oldest first
Reading only what changed. Store the cursor from the response and pass it back as since next time. A question answered again after practise again reappears, because its timestamp moves — which is what you want, the attempt count changed. Keep calling while has_more is true. Nothing is deleted; you are simply not re-reading it.
curl "https://api.in-touch.cc/api/addons/vocab/history?only=wrong&limit=50" \
  -H "X-API-Key: itk_YOUR_KEY"
{
  "success": true,
  "streak": 6,
  "summary": {
    "total": 96, "correct": 71, "accuracy": 74,
    "first_try": 58, "first_try_rate": 60, "avg_attempts": 1.34,
    "by_type": {
      "translate": { "total": 40, "correct": 25, "accuracy": 63,
                     "first_try": 19, "avg_attempts": 1.52 }
    }
  },
  "hardest": [
    { "question_id": 512, "type": "translate", "attempts": 4, "correct": true,
      "question": { "prompt": "der Schlüssel", "accepted": ["la llave"] } }
  ],
  "items": [
    { "question_id": 512, "set_date": "2026-07-24", "type": "translate",
      "question": { "prompt": "der Schlüssel", "accepted": ["la llave"] },
      "user_answer": "la clave", "correct": false, "attempts": 4,
      "answered_at": "2026-07-24T18:22:10Z" }
  ]
}

Use only=wrong to build the next set around what they keep missing, and summary.by_type to see which exercise formats are too easy.

Attempts — the signal behind the accuracy

The app has a practise again button: the user can reset the round and answer every question of the set once more. attempts counts how many tries a question needed until it was first answered correctly.

FieldMeaning
items[].attempts1 = right first time. Higher means it took that many goes.
summary.first_try / first_try_rateHow many answers landed on the first attempt, absolute and in percent.
summary.avg_attemptsAverage number of tries per question, overall and per type.
hardest[]Up to 10 questions with the most attempts, newest first — the most direct "teach this again" list.
Accuracy alone hides effort. A question answered correctly on the third attempt counts as correct: true — only attempts reveals that the word is not settled yet. Weight hardest and first_try_rate more heavily than raw accuracy when choosing what to repeat.

The counter stops once a question has been answered correctly, so practising a solved question again never worsens its score. It never decreases either — a late offline batch cannot overwrite a higher count.

Offline behaviour and replacement

Because the app holds the whole set locally, timing matters in two ways:

SituationBehaviour
You push while the user is offlineThey keep working on the old set; the new one arrives when they reconnect
You push a new set_idPending results are uploaded first, then the old set and its local progress are discarded
You push twice on the same dayThe second set replaces the first; a day already completed stays counted for the streak
The user answers offlineGraded on-device, queued, uploaded on next connection — history catches up afterwards
The user taps practise againEvery question of the set reopens; attempts keeps counting, the streak is untouched
Do not push a fresh set every few minutes. Each push wipes the set the user is currently working through. One set per day, or when history shows the previous one is finished, is the intended rhythm.

Streaks

A day counts once every question of that day's set has been answered — right or wrong. The streak is the number of consecutive such days. A day still in progress does not break it, so a user who has not practised yet today keeps yesterday's streak until midnight (UTC).

Errors

StatusMeaning
401Missing X-API-Key header
403Invalid API key
400No valid question, unknown type, or missing language
500Server error
inTouch API · https://api.in-touch.cc · keep your key private.
# intouch api
base    https://api.in-touch.cc
auth    X-API-Key: itk_<64 hex>   one key per user, no expiry, no scopes
docs    https://docs.in-touch.cc
format  every response is JSON and carries "success"

# vocabulary         status: stable
An entirely AI-driven language-learning capability. The app has NO settings
screen for it: language, level and daily volume are agreed with the user in
conversation and written through the API.

The app downloads the whole set INCLUDING its solutions and grades on-device, so
the user can practise offline. Results are queued locally and uploaded on the
next connection. A push REPLACES the active set completely; history survives.

# the daily loop
1. GET  /api/addons/vocab/profile   status only: is today's set already there?
2. GET  /api/addons/vocab/history   what went wrong, which types are weak,
                                    how many tries did it take
3. POST /api/addons/vocab/push      today's set, carrying language + level:
                                    recycled mistakes + new material, 5 types

THERE IS NO SERVER-SIDE PROFILE. Language, level and questions-per-day live in
the agent's own prompt file on the user's machine. Raising the level means
editing that file; the next push carries the new value. The app reads language
and level from the active set.

IDEMPOTENCY GUARD - this is the important one:
if active_set.set_date is already today (UTC), STOP before writing anything.
Never push twice on the same day. That guard is what makes a laptop schedule
safe: it may fire at breakfast, at lunch, or twice.

# endpoints
GET  /api/addons/vocab/profile    X-API-Key    status: active set, streak
POST /api/addons/vocab/push       X-API-Key    replace the active set
GET  /api/addons/vocab/history    X-API-Key    read every answer

# GET /api/addons/vocab/profile          (historical path name, status only)
curl "https://api.in-touch.cc/api/addons/vocab/profile" -H "X-API-Key: itk_YOUR_KEY"

200 {
  "success": true,
  "supported_types": ["translate","multiple_choice","fill_gap",
                      "match_pairs","sentence_order"],
  "active_set": { "id": "vs_42_1753400000000", "language": "es", "level": "A2",
                  "set_date": "2026-07-25", "question_count": 8 },
  "streak": 6
}

"active_set": null  -> nothing has ever been pushed for this user.
Compare active_set.set_date with today (UTC) before every push. There is no
profile field: what to teach comes from your prompt file, not from the server.

# POST /api/addons/vocab/push
  questions[]   array    REQUIRED   1-50 questions, see types below
  language      string   REQUIRED   e.g. "es" - from your prompt file; the app
                                    labels the feed card with it
  level         string              A1-C1, defaults to A1
  set_date      string              YYYY-MM-DD, defaults to today
  set_id        string              own identifier; generated if omitted

# question types
Every question object needs "type"; the remaining fields are its payload.
* = required. Invalid questions are reported per index in errors[]; the rest
is still accepted.

translate         prompt*, accepted*(string[]), direction, hint, explanation
multiple_choice   prompt*, options*(>=2), correct_index*, explanation
fill_gap          sentence*(must contain ___), accepted*(string[]),
                  word_bank(string[]), explanation
match_pairs       pairs*(>=2 of {left, right}), explanation
sentence_order    solution*(string[], >=2), tokens(same words, shuffled),
                  explanation

Free-text answers are matched LENIENTLY: translate and fill_gap compare against
"accepted" ignoring case, accents, punctuation and repeated spaces - but NOT
synonyms. List every reasonable variant in "accepted".
A word_bank on fill_gap turns it into a tap-to-answer grammar drill, which is
the natural way to test conjugation.

# push example
curl -X POST https://api.in-touch.cc/api/addons/vocab/push \
  -H "X-API-Key: itk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "language": "es",
    "level": "A2",
    "questions": [
      { "type": "translate", "prompt": "der Schlüssel",
        "direction": "Deutsch → Spanisch",
        "accepted": ["la llave","llave"], "explanation": "llave ist feminin." },
      { "type": "multiple_choice", "prompt": "¿Cómo ___ (tú)?",
        "options": ["estás","está","estoy"], "correct_index": 0 },
      { "type": "fill_gap", "sentence": "Ayer ___ al mercado.",
        "accepted": ["fui"], "word_bank": ["fui","voy","iré"],
        "explanation": "Indefinido, 1. Person Singular von ir." },
      { "type": "match_pairs",
        "pairs": [ { "left": "el pan",   "right": "das Brot" },
                   { "left": "la leche", "right": "die Milch" } ] },
      { "type": "sentence_order",
        "solution": ["Mañana","voy","a","la","playa"],
        "tokens":   ["playa","voy","Mañana","la","a"] }
    ]
  }'

200 { "success": true, "set_id": "vs_42_1753400000000", "language": "es",
      "level": "A2", "set_date": "2026-07-25",
      "accepted": 5, "rejected": 0, "errors": [] }

# GET /api/addons/vocab/history
The durable memory of this capability: every answer ever given, with the question
it belonged to, what the user typed, right or wrong, and how many attempts it
took. Survives set replacement, reinstall and a new machine.
query:
  only    wrong | correct | all   (default all)
  limit   1-500, default 100
  since   cursor from a previous response: only what was answered after it,
          oldest first. Opaque - hand back exactly the string you got.
          A question answered again after "practise again" reappears: its
          timestamp moved, and so did its attempt count.

curl "https://api.in-touch.cc/api/addons/vocab/history?only=wrong&limit=50" \
  -H "X-API-Key: itk_YOUR_KEY"

200 {
  "success": true,
  "streak": 6,
  "summary": {
    "total": 96, "correct": 71, "accuracy": 74,
    "first_try": 58, "first_try_rate": 60, "avg_attempts": 1.34,
    "by_type": {
      "translate": { "total": 40, "correct": 25, "accuracy": 63,
                     "first_try": 19, "avg_attempts": 1.52 }
    }
  },
  "hardest": [
    { "question_id": 512, "type": "translate", "attempts": 4, "correct": true,
      "question": { "prompt": "der Schlüssel", "accepted": ["la llave"] } }
  ],
  "items": [
    { "question_id": 512, "set_date": "2026-07-24", "type": "translate",
      "question": { "prompt": "der Schlüssel", "accepted": ["la llave"] },
      "user_answer": "la clave", "correct": false, "attempts": 4,
      "answered_at": "2026-07-24T18:22:10Z" }
  ]
}

accuracy and first_try_rate are PERCENT (0-100). avg_attempts is tries per
question. hardest[] holds up to 10 questions with the most attempts, newest
first - the most direct "teach this again" list.

# attempts - the signal behind the accuracy
The app has a "practise again" button that reopens every question of the set.
attempts counts how many tries a question needed UNTIL IT WAS FIRST ANSWERED
CORRECTLY.

  items[].attempts               1 = right first time; higher = that many goes
  summary.first_try(_rate)       answers that landed on the first attempt
  summary.avg_attempts           average tries per question, overall / per type
  hardest[]                      worst 10 by attempts, newest first

A question answered correctly on the third attempt still counts as
correct: true - only attempts reveals the word is not settled. Weight hardest[]
and first_try_rate more heavily than raw accuracy when choosing what to repeat.
The counter stops once a question was answered correctly and never decreases, so
a late offline batch cannot overwrite a higher count.

# what lives where
server (durable, readable with the key, survives everything):
  every answer: question snapshot, the user's input, correct/wrong, attempts,
  the date - plus the streak and the active set
local prompt file ~/intouch/vocab-prompt.md (the user owns and can edit it):
  language, level, questions per day, the "learned:" conclusions you write after
  reading the history, and "last read:" (your history cursor)

# reading only what changed
Store "cursor" from the response, pass it back as ?since= next time, and keep
calling with the newest cursor while has_more is true. Nothing is deleted; you
are simply not re-reading years of answers every morning. Without since you get
the current picture, newest first - use that for the standing summary.
Rule: raw events belong on the server, conclusions belong in the file. Never
copy the whole history into the file; re-read it and rewrite the conclusions.

# level decision (suggested policy, only once summary.total >= 30)
first_try_rate >= 85 and avg_attempts <= 1.2   -> one level up (A1 A2 B1 B2 C1)
first_try_rate <= 40                           -> one level down (never below A1)
otherwise                                      -> keep it
Apply it by editing the level in YOUR OWN prompt file and sending the new value
with the next push. Nothing to store server-side.

# building a good set
- Around 40% revisits what went wrong or needed several attempts: take it from
  hardest[] and only=wrong, but REPHRASE it - the user should not recognise
  the wording.
- The rest is new material for the current level, reusing known vocabulary.
- Use all five types, over-weighting the types with the lowest accuracy.
- Every question carries its solution and a short explanation in the user's
  native language.
- Verify the response: if anything was rejected, fix those questions and push
  the corrected FULL set again (a push replaces, it never appends).

# offline behaviour and replacement
push while the user is offline   they keep the old set; the new one arrives
                                 when they reconnect
push a new set_id                pending results upload first, then the old set
                                 and its local progress are discarded
push twice on the same day       the second set replaces the first; a day
                                 already completed stays counted for the streak
user answers offline             graded on-device, queued, uploaded on the next
                                 connection - history catches up afterwards
user taps "practise again"       every question reopens; attempts keeps
                                 counting, the streak is untouched

DO NOT push a fresh set every few minutes: each push wipes the set the user is
currently working through. One set per day, or when history shows the previous
one is finished, is the intended rhythm.

# streaks
A day counts once EVERY question of that day's set has been answered, right or
wrong. The streak is the number of consecutive such days. A day still in
progress does not break it, so a user who has not practised yet today keeps
yesterday's streak until midnight UTC.

# automation
The human page has ready-made setups for Claude Code and Codex CLI plus
schedulers for macOS (launchd), Linux/VPS (systemd timer, cron), Windows
(Task Scheduler) and GitHub Actions:
https://docs.in-touch.cc/vocabulary#automate
The pattern in one line: a guard script asks GET /vocab/profile whether
active_set.set_date is today and only wakes the model if it is not.

# self-setup - read this when a user asks you to "set up my vocabulary training"
They pasted a short prompt and their key; the whole job is below. Work through it
in order and do not invent extra steps, files, services or repositories.

0. THE KEY IS A CREDENTIAL. Never print it, never echo it into a log, never put it
   in the prompt file. It belongs in the guard script or an environment variable.

1. ASK - one message, four questions, with defaults so a one-word answer is enough:
   target language, level (A1-C1, default A2), questions per day (3-50, default 8),
   language for explanations (default: the user's own language).
   If the user already answered all of that in their prompt, skip the questions.

2. WRITE THE PROMPT FILE - ~/intouch/vocab-prompt.md
   This file IS the profile; the server stores none. Put the four answers in a
   header block ("language", "my language", "level", "questions per day") plus an
   empty "learned:" line, and below them the daily loop, the level decision policy
   and "building a good set" from this page, so a future run needs nothing but
   this file. Add a "last read:" line for the history cursor. Every later run
   reads only what is new (?since=<last read>), refreshes the "learned:" lines
   and overwrites the cursor - that is how the file adapts without re-reading
   everything. Show it to the user before writing it.

3. PUSH A FIRST SET
   Skip if active_set.set_date is already today (UTC).
   Send "language" and "level" from the header block with the push - language is
   required. Build the set per "building a good set" and "question types" above.
   There is no history yet, so this first set is plain new material for the level.
   Read the response: fix and re-push the FULL set if anything was rejected.

4. INSTALL THE DAILY AUTOMATION
   Show the user the file paths and the schedule BEFORE creating anything, then:
   a) the prompt file from step 2 is already in place.
   b) ~/intouch/vocab.sh (chmod +x) - the guard:
      - read GET /api/addons/vocab/profile
      - API unreachable -> exit 0 (a machine that just woke has no network yet)
      - active_set.set_date == today (UTC, date -u +%F) -> exit 0
      - otherwise run your own CLI headless with the prompt file
   c) the scheduler that fits the user's OS - always one that CATCHES UP after a
      missed run, because a laptop is asleep at 07:00:
      macOS    LaunchAgent ~/Library/LaunchAgents/com.intouch.vocab.plist,
               StartCalendarInterval 07:00 + RunAtLoad, launchctl load -w
      Linux    systemd user timer, OnCalendar=*-*-* 07:00:00, Persistent=true,
               systemctl --user enable --now; on a VPS also loginctl enable-linger
      Windows  Register-ScheduledTask with -StartWhenAvailable, or WSL + the Linux
               path if the CLI lives there
      no local shell -> a GitHub Actions workflow on a daily cron instead
   The exact, tested snippets for all four are on https://docs.in-touch.cc/vocabulary#automate.

5. VERIFY - run the guard script once by hand. It must report that today's set
   already exists, because step 3 created it. That proves the guard works, which
   is the part that keeps a repeated run harmless.

6. HAND OVER - name the two files, the scheduler entry and the next run time, and
   tell the user that changes ("harder", "switch to Italian", "more questions")
   are four lines at the top of ~/intouch/vocab-prompt.md - by talking to you
   again, or in any text editor. Neither the app nor the server has a setting.

If you cannot run shell commands (browser chat, no terminal): do step 1, push a
first set (step 3), then tell the user plainly that the prompt file and the daily
schedule need Claude Code, Codex CLI or a cloud runner, and point them at
https://docs.in-touch.cc/vocabulary#automate.

# self-setup: working snippets
Adapt paths and the CLI line; the shape is what matters.

--- ~/intouch/vocab.sh -------------------------------------------------------
#!/usr/bin/env bash
set -euo pipefail

export INTOUCH_API_KEY="itk_THE_USERS_KEY"
BASE="https://api.in-touch.cc"
TMP="$(mktemp)"; trap 'rm -f "$TMP"' EXIT

today="$(date -u +%F)"

# No network yet (the machine just woke up) is not an error. A rejected key is:
# it has to fail loudly, or the schedule quietly does nothing for weeks.
code="$(curl -sS -o "$TMP" -w '%{http_code}' \
        -H "X-API-Key: $INTOUCH_API_KEY" \
        "$BASE/api/addons/vocab/profile")" || {
  echo "inTouch: API not reachable, leaving it for the next run"; exit 0
}
case "$code" in
  200)     ;;
  401|403) echo "inTouch: key rejected ($code) - fix it in this script"; exit 1 ;;
  *)       echo "inTouch: API returned $code, trying again next run"; exit 0 ;;
esac

if [ "$(jq -r '.active_set.set_date // ""' "$TMP")" = "$today" ]; then
  echo "inTouch: set for $today already exists"
  exit 0
fi

# your own CLI, headless, allowed to reach the network. For example:
#   claude -p "$(cat "$HOME/intouch/vocab-prompt.md")" --allowedTools "Bash(curl:*)"
#   codex exec --full-auto "$(cat "$HOME/intouch/vocab-prompt.md")"
------------------------------------------------------------------------------

--- macOS: ~/Library/LaunchAgents/com.intouch.vocab.plist --------------------
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>              <string>com.intouch.vocab</string>
  <key>ProgramArguments</key>   <array>
    <string>/bin/bash</string>
    <string>/Users/YOU/intouch/vocab.sh</string>
  </array>
  <key>StartCalendarInterval</key>
  <dict><key>Hour</key><integer>7</integer><key>Minute</key><integer>0</integer></dict>
  <key>RunAtLoad</key>          <true/>
  <key>StandardOutPath</key>    <string>/tmp/intouch-vocab.log</string>
  <key>StandardErrorPath</key>  <string>/tmp/intouch-vocab.log</string>
</dict>
</plist>

launchctl load -w ~/Library/LaunchAgents/com.intouch.vocab.plist
StartCalendarInterval catches up after sleep; RunAtLoad adds a run at login.
------------------------------------------------------------------------------

--- Linux / VPS: systemd user timer ------------------------------------------
# ~/.config/systemd/user/intouch-vocab.service
[Unit]
Description=inTouch vocabulary set
[Service]
Type=oneshot
ExecStart=%h/intouch/vocab.sh

# ~/.config/systemd/user/intouch-vocab.timer
[Unit]
Description=Daily inTouch vocabulary set
[Timer]
OnCalendar=*-*-* 07:00:00
Persistent=true
[Install]
WantedBy=timers.target

systemctl --user daemon-reload
systemctl --user enable --now intouch-vocab.timer
# VPS without a login session: loginctl enable-linger $USER
Persistent=true is the catch-up after a missed run.
------------------------------------------------------------------------------

--- Windows: Task Scheduler --------------------------------------------------
$action  = New-ScheduledTaskAction -Execute "powershell.exe" `
             -Argument "-File C:\intouch\vocab.ps1"
$trigger = New-ScheduledTaskTrigger -Daily -At 7am
$set     = New-ScheduledTaskSettingsSet -StartWhenAvailable -WakeToRun
Register-ScheduledTask -TaskName "inTouch Vocab" -Action $action `
  -Trigger $trigger -Settings $set

-StartWhenAvailable is the catch-up. If the CLI lives in WSL, keep the bash
script and let the task call:  wsl -e ~/intouch/vocab.sh
------------------------------------------------------------------------------

--- No local machine: GitHub Actions -----------------------------------------
# .github/workflows/vocab.yml   (private repo, secrets for both keys)
name: inTouch Vocab
on:
  schedule:
    - cron: "0 5 * * *"
  workflow_dispatch:
jobs:
  push-set:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm install -g @anthropic-ai/claude-code
      - run: claude -p "$(cat vocab-prompt.md)" --allowedTools "Bash(curl:*)"
        env:
          ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
          INTOUCH_API_KEY:   \${{ secrets.INTOUCH_API_KEY }}

Scheduled runs can start a few minutes late, and GitHub disables the schedule
after 60 days without repository activity.
------------------------------------------------------------------------------

# errors
401  missing X-API-Key header
403  invalid API key
400  no valid question, unknown type, or missing language
500  server error

# pages
/                  overview, capability list
/quickstart        get a key, first request
/authentication    key format, rotation, failure modes
/conventions       envelope, status codes, limits, dates
/recipes           recipes capability
/vocabulary        vocabulary capability
/roadmap           capability anatomy, what is planned
every page is also plain text: append .txt  (e.g. https://docs.in-touch.cc/vocabulary.txt)
all pages at once: /llms.txt (index) and /llms-full.txt (everything)

# for agents
- Use only the endpoints listed here; do not invent paths, fields or values.
- Check "success" in the body, not just the HTTP status.
- Error strings are German and developer-facing; branch on the status code.
- Batches are accepted partially: read "rejected" and "errors[].index".
- Dates are UTC. Calendar days are YYYY-MM-DD, timestamps are ISO 8601.
- The API key is a credential: never log it, never pass it to a third party.
- Ask the user before writing content into their app for the first time.