Today NavTorah reads each column with its own network, on the phone (that's told in How NavTorah knows which column the sefer is open at). The first version, in September 2026, didn't have that network. The app took the photo, shrank it to 1,800 pixels on the long side and sent it to the server, which passed it to Claude Opus 5 through the Anthropic API. Claude transcribed the lines, and from there everything went on as it does now, with the same search among the words of the Torah.
What we asked of Claude
From the start, the idea was for the model only to read. The position and the confidence are decided by the search, which can be tested and measured without spending anything. So what we asked of Claude was quite narrow:
- Transcribe the first 14 lines, letters only, without nikkud, cantillation or punctuation.
- Keep the final letter forms and the spaces between words.
- Write
?for each letter that can't be read. - Count every visible line, even the ones it doesn't transcribe.
- Read the ink and don't fill in from memory. If the parchment says something other than what's expected, write what the parchment says.
The answer came in a fixed format (structured output, with a JSON schema). It held the lines, which ones were doubtful, where there were blank spaces and how many lines were visible. This is the call, shortened.
const response = await client.messages.parse({
model: "claude-opus-5",
max_tokens: 8000,
system: systemPrompt(14),
output_config: { effort: "low", format: zodOutputFormat(OcrResultSchema) },
messages: [
{
role: "user",
content: [
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data } },
{ type: "text", text: "Transcribe the first 14 lines of this Sefer Torah column and count the visible lines." },
],
},
],
});
if (response.stop_reason === "refusal") throw new OcrError("refusal", "The model refused to transcribe the image.");
if (response.stop_reason === "max_tokens") throw new OcrError("truncated", "The transcription was cut off by the token limit.");
const parsed = response.parsed_output; // { lines, uncertainLines, gapBeforeLines, lineCountVisible }
if (!parsed) throw new OcrError("bad-format", "The model did not return a transcription in the expected format.");The full code is still in tools/eval, where we use it to compare both ways of reading on the same photos.
Does it read or recite?
Claude knows the Torah, so we had to check that it read what was written and not what it remembers. To test it, we made a column with six words swapped for other real Hebrew words. Claude transcribed all six as written, and none went back to the original text. When the image cut off the start of the lines, it transcribed only what was visible.
How it did
It read very well. We tried how much the model could think (the effort parameter) and how many lines it transcribed, on scanned columns of Elihu Shannon's sefer, which follows the standard layout and is published on Wikimedia Commons. All of them were placed correctly.
| Configuration | Time per photo | Letter error rate |
|---|---|---|
| Opus 5, full column, medium effort | 52 s | 0.19% |
| Opus 5, 14 lines, low effort | 15 s | 0.38% |
| Sonnet 5, 14 lines, low effort | 12 s | 0.60% |
14 lines were enough to place the column, and transcribing less cut the time and the cost a lot. With Opus 5, low effort and 14 lines, each photo cost about 3 US cents. On 78 scanned images from 4 sifrei it never gave a wrong position, and in the tests on real sifrei each scan took 16 or 17 seconds.
Why did we change it?
The MVP worked, but it had three problems for use in a congregation:
- Time: 16 seconds with the phone pointed at the sefer is a lot, especially if you have to scan more than once.
- Cost: every photo cost money, so the app couldn't be opened to anyone.
- The photo: it had to leave the phone and go through a server.
With our own network, in the tests on a sefer each scan took 1 or 2 seconds. It costs nothing per scan and the photo doesn't leave the phone. In exchange, it reads quite a bit worse, with many more wrong letters or ?. Since the search is designed for readings with mistakes, it still gets to the right column. How we trained that network is in another post.