📚 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 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 server | In 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.
~/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.
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)"
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.
| Setup | Fires while your computer is off | Needs a terminal | Best for |
|---|---|---|---|
| VPS or always-on machine | Yes | Yes | The clean answer, if you have one |
| Laptop or desktop schedule | No — it catches up on the next start | Yes | Almost everyone |
| GitHub Actions | Yes | No | No server, no CLI on your machine |
| By hand | No | Yes (or a chat window) | Trying it out |
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 }}
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
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.
Push an exercise set
| Field | Type | Description |
|---|---|---|
questions[] | array | 1–50 questions, see types below |
language | string | Language code from your prompt file, e.g. es. The app labels the feed card with it |
level | string | A1–C1, defaults to A1 |
set_date | string | Optional YYYY-MM-DD, defaults to today |
set_id | string | Optional 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.
| Type | Payload |
|---|---|
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.
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
Every answered question with the user's actual answer and whether it was correct — including sets you already replaced.
| Query | Description |
|---|---|
only | wrong · correct · all (default) |
limit | 1–500, default 100 |
since | Cursor from a previous response — returns only what was answered since then, oldest first |
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.
| Field | Meaning |
|---|---|
items[].attempts | 1 = right first time. Higher means it took that many goes. |
summary.first_try / first_try_rate | How many answers landed on the first attempt, absolute and in percent. |
summary.avg_attempts | Average 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. |
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:
| Situation | Behaviour |
|---|---|
| You push while the user is offline | They keep working on the old set; the new one arrives when they reconnect |
You push a new set_id | Pending results are uploaded first, then the old set and its local progress are discarded |
| You push twice on the same day | The second set replaces the first; a day already completed stays counted for the streak |
| The user answers offline | Graded on-device, queued, uploaded on next connection — history catches up afterwards |
| The user taps practise again | Every question of the set reopens; attempts keeps counting, the streak is untouched |
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
| Status | Meaning |
|---|---|
401 | Missing X-API-Key header |
403 | Invalid API key |
400 | No valid question, unknown type, or missing language |
500 | Server error |