Skip to content

Driving textweaver from another program (JSON-RPC)

tw serve --stdio runs textweaver as a server controlled by another program. That program, the client, starts tw serve as a child process, writes requests to its standard input, and reads responses from its standard output. Through the protocol a client can open a document, navigate it by sentence, heading, line, or any other textweaver unit, have it read aloud with textweaver's voices, invoke any keyboard action by name, and follow playback word by word. This reference is intended for developers of editor plugins, web front ends, and other tools that want textweaver's reading engine without reimplementing it. The protocol is JSON-RPC 2.0, and the aspects that matter here are described where they arise.

Each message in the examples appears in its own code block, preceded by a sentence identifying it.

Before you start

  • tw must be installed; tw --version confirms it, and the install guide covers installation.
  • The protocol is JSON-RPC 2.0, and every message is a single JSON object. No JSON-RPC library is required: any language that can start a process and write and read lines is sufficient.
  • Positions are zero-based character offsets from the start of the document, counted in Unicode characters (code points), as Python counts string indexes. JavaScript and VS Code count UTF-16 code units, so clients written for them must convert.
  • Every example was produced by running the real tw program with --no-speech, so nothing was spoken aloud.

Start the server

This starts the server and speaks, like the terminal reader:

tw serve --stdio

The server prints nothing intended for a person. It waits for JSON messages on standard input and writes only JSON messages on standard output; it is meant to be started by a program, not typed at.

--stdio is required. Without it, tw serve stops at once with an error and exit code 1:

tw serve

This produces "Error: tw serve needs a transport; use --stdio". There is no network or socket mode; a client that needs a long-running server keeps the child process open.

Stay silent with --no-speech

tw serve --stdio --no-speech

Nothing is spoken, but the client still receives every announcement and playback change as a message and can display them or forward them to a screen reader. Use this option when the client has a voice of its own, as an editor used with a screen reader does. See Speech: who does the talking.

Choose a speech engine with --backend

tw serve --stdio --backend sapi

The value is a speech engine id, such as eci (ETI-Eloquence), sapi, dectalk, or null. The engines available on the current machine are listed by:

tw backends

Without --backend, the server uses the [speech] backend setting, normally auto. If the named engine is unavailable, the server falls back to the automatic choice and informs the client with the announcement "Speech engine sapi is not available; using …", naming the engine it selected. --backend has no effect together with --no-speech.

Keep settings in another folder with --home

tw serve --stdio --home "C:\textweaver-test"

Settings, reading positions, bookmarks, and notes are read from and saved under this folder instead of the usual locations, so that testing a client never alters real reading positions. Setting the TEXTWEAVER_HOME environment variable to a folder has the same effect.

Open a document at start with --open

tw serve --stdio --open notes.md

The document is open before the first request arrives, and the client receives "Opened" and the title as an announcement. If it cannot be opened, the server keeps running with no document and sends an announcement beginning "Could not open". A document can always be opened later with the open method.

How messages travel

One message per line

The simplest framing is one JSON object per line, terminated by a newline. The server responds in the same way, and blank lines are ignored.

Content-Length framing, like the Language Server Protocol

A client may instead put a header before each message, the way language servers do:

Content-Length: 42

{"jsonrpc":"2.0","id":2,"method":"status"}

The header line ends with a carriage return and line feed, and a blank line (another carriage return and line feed) precedes the message. The length is the size of the message in bytes, in UTF-8, and the header name is case-insensitive. Content-Length must come first: header lines after it, such as Content-Type, are skipped, but a line before it is read as a message of its own and fails.

The server determines the framing of each incoming message: a line beginning with Content-Length: introduces a framed message, and any other line is a complete message. It responds, and sends its own notifications, in the framing of the last message received; before the first message it uses one message per line. Framed responses carry only a Content-Length header.

A client should choose one framing and keep to it. A framed response is not followed by a newline, so switching in mid-session can place the next line-delimited response on the same line.

What every message looks like

Every message is a JSON-RPC 2.0 object:

  • jsonrpc must be the string "2.0". A message without it is refused.
  • method is the name of the method, such as open.
  • params is an object with the method's parameters. Leave it out when a method has none.
  • id is a number or string chosen by the client. The response carries the same id so that the two can be matched. A message without an id is a notification: the server executes it but sends no response.

A response contains either result (success) or error (failure), never both. Requests are answered in the order received.

A message may also be a JSON array of requests, a batch. The server handles the requests in order but sends the responses individually, as separate messages rather than as one array.

Server messages that have method and no id are notifications. They may arrive at any time: before the response to the request that caused them, and between requests while speech continues. A client must therefore be prepared to read them whenever it reads.

Speech: who does the talking

Without --no-speech, the server speaks exactly as the terminal reader does, with the same settings, engine selection, and voices. It reads the document aloud and also speaks its own announcements, such as "Opened Cell biology." The client never receives audio.

Every announcement is also sent to the client as an announcement notification carrying its text and priority. A client therefore has two modes of operation:

  • Let textweaver speak. The client displays the announcements or ignores them.
  • Start the server with --no-speech. The client receives the announcements and passes them to its own voice or to the user's screen reader. This is the appropriate choice for screen reader users, who would otherwise hear two voices at once.

The server does not follow the reader's accessibility mode ([accessibility] mode; see Using textweaver with a screen reader). It always speaks for itself unless started with --no-speech, so a client gets the mode it chose regardless of how the terminal reader was last configured.

With --no-speech, reading takes no time. A request to read yields "reading", one position notification, and "stopped" almost immediately.

Methods

The server has 29 methods, all listed by initialize. Parameters marked optional may be omitted.

initialize: say hello and learn the version

Parameters: none.

The result is an object with:

  • server: always "textweaver".
  • version: the textweaver version, such as "0.1.0-beta.1".
  • protocol: the protocol version, a number, currently 1.
  • methods: the names of every method.
  • notifications: the names of every notification.

Calling initialize first is not required; every method works without it.

open: open a document

Parameters:

  • path (string, required): what to open, in any form tw open accepts: a file, a file inside an archive (book.zip!chapter.pdf), or a web address. A relative path is resolved against the folder in which the server was started.

The result is an object with:

  • document: facts about the document (see below).
  • position: where reading starts, in the same shape as the result of the position method. If the document was read before, this is the saved position, and the announcement says "Resumed at" and a percentage.

The document object has:

  • title: the document's title. For the Markdown example below it is the first heading. For a plain text file it is the file name.
  • path: the full path, or null.
  • format: the format, such as "markdown".
  • length: the number of characters in its text.
  • lines: the number of lines.
  • editing: true while edit mode is on.
  • dirty: true when there are unsaved edits.

If edit mode is on when open is called, textweaver leaves edit mode first. The result then contains document and effects instead of document and position, so the client should call position afterwards.

Errors: -32602 when path is missing, and -32002 when the file does not exist, is a folder, or cannot be read.

status: everything at once

Parameters: none. It works with or without a document.

The result is an object with:

  • mode: the reader's mode, such as "Browse", "Edit", "Speech Cursor", "Find", "Go to", or "Command".
  • playback: "reading", "paused", or "stopped".
  • document: the same object open returns, or null when no document is open.
  • position: the same object position returns, or null.
  • rate: the reading rate in words per minute.
  • backend: the speech engine id, or "silent" with --no-speech.
  • status: the last status message, identical to the text on the terminal reader's status line. It is empty initially.
  • pending: the yes-or-no question waiting for an answer, or null. See action.

position: where the cursor is

Parameters: none. It needs an open document.

The result is an object with:

  • char: the character offset of the reading position.
  • line: the line number, starting at 1.
  • column: the column, starting at 1.
  • percent: how far through the document, from 0 to 100, rounded down.
  • word: the word at the position, as {start, end, text}, or null when the position is not inside a word (on a space, for example).

It needs an open document. Give one of these parameters:

  • action (string): the id of a movement action, such as next_sentence, previous_paragraph, next_heading, skip_next_heading, or next_bookmark. Actions from four groups of the keyboard reference are accepted: Navigation, Speech Cursor, Search, and Bookmarks and notes.
  • goto (string): a destination. It accepts a line number ("12" or "line 12"), a percentage ("50%" or "50 percent"), a character offset ("char 120"), "start" (also "top" or "beginning"), or "end" (also "bottom"). Headings ("heading 2") are not accepted here.

When both are given, goto is used.

The result is the new position, in the same shape as the result of the position method. The move is also announced, usually by speaking the text at the destination.

Errors: -32602 "Not a navigation action" for an action from another group, and "Not a go-to target" for a goto value it cannot understand.

Some actions in these groups open a prompt or a list, for example find and list_bookmarks. Through navigate, the client receives only the announcement; for these, use the action method, which returns the prompt or list in its result.

read: read aloud

It needs an open document. Parameters:

  • what (string, optional): what to read. The default is cursor.
  • cursor: read on from the cursor until you stop it.
  • document: read from the start of the document.
  • paragraph: read on from the start of the current paragraph (the replay_paragraph action).
  • sentence, line, word, character: read just that one unit at the cursor, without moving.
  • selection: read the selected text. With nothing selected you hear "No selection."
  • from (number, optional): a character offset to move to before reading.

The result is {playback}, the playback state immediately after the request: "reading", "paused", or "stopped". For a short unit it may already be "stopped". playback and position notifications follow as reading proceeds.

Errors: -32602 "Cannot read" and the word you gave, for anything not in the list above.

pause, resume, and stop

Parameters: none. The result of each is {playback}.

  • pause pauses reading and has no effect unless reading is in progress.
  • resume continues after a pause and has no effect unless reading is paused.
  • stop stops reading.

pause and resume need an open document. stop works without one.

search: find text

It needs an open document. Parameters:

  • pattern (string, required): the text to find.
  • regex (true or false, optional): treat the pattern as a regular expression. The default is false.

Matching follows the same rules as Find in the reader; in testing, a lowercase pattern also matched a capitalized word.

The result is an object with:

  • matches: every match, each {start, end, line} (character offsets and a line number starting at 1).
  • current: the index in matches of the match the cursor moved to, or null.

The cursor moves to the first match at or after its position, wrapping to the top when necessary, and the announcement is, for example, "Match 1 of 2: The nucleus".

text: get the document's text

It needs an open document. Parameters:

  • start (number, optional): the first character offset. The default is 0.
  • end (number, optional): the offset just after the last character. The default is the end of the document.

Offsets past the end are treated as the end, and an end before start yields empty text. The result is {text, start, end}. The text is the canonical text that tw text prints and that textweaver reads aloud.

outline, notes, highlights, and info: read what the reader knows

These four methods are read-only: they change nothing and speak nothing. Each requires an open document and takes no parameters. Offsets are characters in the canonical text, as for text, and line counts from 1.

  • outline returns {items, pages}. Each item is {level, text, char, line}: a heading, its level from 1 to 6, its text on one line, and where it starts. In a paged document without headings, such as a PDF, the items are its pages, each with level 0, and pages is true.
  • notes returns {notes}, in document order. Each note is {id, start, end, line, anchor, text, tags, color}: anchor is the noted text, text is what the user wrote, tags the words written as #tag, and color a color name or null.
  • highlights returns {highlights}, in document order. Each is {id, start, end, line, color, text}, where text is the highlighted text and color a color name such as "yellow".
  • info returns facts about the document: title, path, format, author, language (each may be null), and the counts chars, words, lines, headings, notes, highlights, and bookmarks. reading_minutes is the time to read it aloud at rate, the speech rate in words per minute.

For example, the outline of the document in the example session:

{"jsonrpc": "2.0", "id": 9, "method": "outline"}
{
  "id": 9,
  "jsonrpc": "2.0",
  "result": {
    "items": [
      {"char": 0, "level": 1, "line": 1, "text": "Cell biology"},
      {"char": 90, "level": 2, "line": 5, "text": "The nucleus"}
    ],
    "pages": false
  }
}

And in Python, with the call function from the minimal client, a study list of every note:

for note in call("notes")["notes"]:
    print(f"Line {note['line']}: {note['anchor']}: {note['text']}")

Errors: -32001 when no document is open.

insert: type text at the cursor in edit mode

It requires an open document and edit mode. This is the means by which a client, such as a dictation front end, enters text as though typed at the keyboard: the insertion is a single undo step and is echoed as typing is. Parameters:

  • text (string, required): the text to insert at the cursor.

The result is {status, effects, position}, the same as action, plus the new position.

Errors: -32602 when text is missing, and -32004 "insert needs edit mode; run the toggle_edit_mode action first" when edit mode is off. Turn on edit mode first with action and toggle_edit_mode.

action: run any command by name

This method lets a client perform anything a key can do. Parameters:

  • id (string, required): any action id from the keyboard reference, such as add_bookmark, go_to, toggle_edit_mode, save, or quit. It can also be one of the notes commands: add_note, list_notes, next_note, previous_note, toggle_highlight, list_highlights, rename_bookmark, delete_bookmark, or note_links.
  • confirm (true or false, optional): the answer to give if the action asks a yes-or-no question first, as quit and delete_note do. true answers yes and false answers no.

The result is an object with:

  • status: the status message after the action, the same text as its announcement.
  • effects: what the action asked the client to show. Each effect is {type, params}:
  • prompt, with params {label, purpose}: a question that needs typed text, such as a Find or Go to box. label is what to show or say. purpose says what the answer is for, in lowercase with underscores; the list of purposes is under Prompt purposes. Answer it with answer.
  • list, with params {title, items}: a list to choose from, such as bookmarks or notes. items is a list of strings. Pick one with choose.
  • quit, with empty params: the app quit. The server stops after sending this answer.
  • pending: when the action asked a yes-or-no question and you did not give confirm, this is {action, question}, for example {"action": "quit", "question": "Quit textweaver? y or n"}. Otherwise it is null. Answer later with another action call that has confirm, or with cancel for no.

The notes commands return status and effects but no pending.

Prompt purposes

A prompt's purpose is one of these 31 purposes. The names are fixed: within protocol version 1 a purpose may be added but none is renamed or removed. A client should treat an unknown purpose as an ordinary text question, presenting the label and sending what the user types.

  • find: the text to find.
  • go_to: a line, a percentage, start, or end.
  • open: the document to open.
  • command_palette: the name of a command to run.
  • save_as: the file to save to; empty accepts the name in the label.
  • export_as: the file to export the document to; empty accepts the name in the label, the document's own name and folder with the format's extension.
  • table_size: a table size such as 3 by 2 (columns by rows).
  • image_path: the path of an image to insert.
  • replace_find: the text to replace (the replacement is asked next).
  • replace_with: the replacement for every match.
  • note_text: the text of a new note.
  • edit_note: the new text of a note; empty keeps it.
  • rename_bookmark: the new name of a bookmark; empty keeps it.
  • export_settings: the file to export settings to.
  • import_settings: the settings file to import.
  • citation_locator: the page for a citation being inserted; empty inserts none.
  • reference_identifier: a DOI or ISBN to look up and add.
  • import_references: a BibTeX, RIS, or CSL-JSON file to import.
  • template_title: the title of a new document from a template.
  • define_word: a word to define.
  • profile_name: the name of a new settings profile.
  • rename_profile: the new name of a settings profile; empty keeps it.
  • import_profiles: a profile export to import.
  • export_profiles: the file to export the profiles to.
  • setting_value: a new value for the setting chosen in the settings list; empty keeps it.
  • sync_computer_name: this computer's name for sync; empty keeps the one suggested.
  • document_details: one field of the document details form (title, author, DOI, or ISBN). prompt_key with tab moves between the fields, and answer saves them all.
  • comment_reply: a reply to the comment chosen in the changes list.
  • comment_text: the text of a new comment on the selection or the sentence at the cursor.
  • import_cards: an Anki package (.apkg) whose cards to add to the open document's cards.
  • github_token: a GitHub token for a private components source; empty skips. It is secret: text in the prompt state is one star per character, and a client should mask what it shows and never log it.

The reader writes files in the background (saves, bookmarks, notes, positions). The server waits for those writes before responding, so when an action such as save or add_bookmark returns, the file is on disk.

Errors: -32602 "No action" and the id, for an id that does not exist, and "confirm must be true or false".

answer, choose, and cancel: reply to a prompt or list

  • answer takes text (string, required): the text typed into the open prompt. For a Go to prompt, for example, "5" goes to line 5.
  • choose takes index (number, required): the item of the open list to pick, starting at 0.
  • cancel takes no parameters. It closes the open prompt or list, or answers no to a waiting yes-or-no question. You hear "Cancelled."

Each returns {status, effects}, the same as action without pending.

list_state and list_key: move through a list as the reader does

textweaver itself keeps the displayed list and its focused item, identically for the terminal reader, the app, and a client. These methods let a client move through a list with the reader's own keys and receive the same "item, 2 of 5" announcements.

  • list_state takes no parameters. It returns the list shown as {title, items, selected, filter}, or null when no list is shown. selected counts from 0; filter is the text typed so far in a list that filters as you type (the outline, the citation picker, the settings), else null.
  • list_key takes key (string, required): up, down, page_up, page_down, home, end, left, right, enter, escape, backspace, delete, rename, introduce (the list's introduction again), details (the Say Status key: in the file browser, a preview of the focused row), the file browser's choose_here, sort, and show_all, or one character. A character filters a list that filters, chooses by a list's own letter (s, d, c in Save, Discard, Cancel), or moves to the next item starting with it; a space marks an item (a favorite voice). left and right change a value in the settings list. It returns {status, effects, list}, where list is the list after the key, as list_state gives it.

prompt_state and prompt_key: type into a prompt

  • prompt_state takes no parameters. It returns the prompt open as {label, purpose, text, caret}, or null. caret counts characters from the start of text.
  • prompt_key takes either key (string): one character, or backspace, delete, left, right, home, end, up and down (earlier answers to the same prompt, or command palette matches), tab (completes a command name or a file path, or moves to the next field of the edit details form), shift_tab (the form's previous field), kill_to_start, kill_to_end, delete_word_back, enter, or escape; or text (string): the whole text of the prompt at once. It returns {status, effects, prompt}.

answer still works: it answers the open prompt with the text you give.

settings_schema, get_setting, and set_setting: change settings

  • settings_schema takes no parameters. It returns every setting in settings.toml, in order, as an array of objects with path (such as "speech.rate"), section, label, help, kind, default, and internal (true for the few that textweaver keeps for itself). By kind:
  • "toggle": on or off.
  • "number": with min, max, step, and unit (such as "words per minute").
  • "choice": with choices, a list of {value, label}, and open (true when other values may be typed, such as a theme of your own).
  • "text": with optional (true when it may be unset, with null).
  • "list": a list of texts, such as library folders.
  • "table": names and values, such as pronunciations; edit these in settings.toml.
  • get_setting takes path (string, required). It returns {path, value, spoken}: the value as JSON, and as it reads aloud ("265 words per minute").
  • set_setting takes path (string, required) and value (any JSON; null puts the default back). The value is checked against the setting's type; a number outside its range is set to the nearest value in range. The change takes effect at once (the voice, the theme, the keys) and is saved before the answer. It returns {path, value, spoken}.

Errors: -32602 "There is no setting" and the path, or a sentence saying why the value does not fit.

shutdown and exit: finish

  • shutdown takes no parameters and returns null. It saves the reading position and settings. After it, every request except exit fails with error -32003.
  • exit takes no parameters. Send it as a notification (with no id). The server stops and closes its output. If you did not call shutdown first, exit saves anyway. If you send exit with an id, you get a null result first.

Closing the server's standard input also stops it, and saves the position too. So a client that is killed or crashes does not lose the reader's place.

The server does not poll on a timer. The speech engine wakes it as each word is spoken, so position notifications are sent immediately; otherwise it checks for work about four times a second.

Notifications from the server

These arrive without an id:

  • announcement, with {text, priority}: something the app said, identical to what a screen reader user of the terminal reader would hear. priority is "polite" or "assertive"; assertive announcements are urgent, such as errors, and should interrupt.
  • playback, with {state}: reading started ("reading"), paused ("paused"), or ended ("stopped").
  • position, with {start, end, line}: the word being spoken at that moment, as character offsets and a line number starting at 1, suitable for highlighting or scrolling. One is sent for each word the speech engine reports.

initialize also lists prompt, list, and quit as notifications. In this version the server does not send them independently; they arrive only inside the effects of the results of action, answer, choose, and cancel, as described under action.

Errors

An error response has this form:

{"error":{"code":-32001,"message":"No document is open; call open first"},"id":3,"jsonrpc":"2.0"}

The codes JSON-RPC defines:

  • -32700, parse error: the message is not JSON. The message starts "Parse error:" and the id is null.
  • -32600, invalid request: the message is not a JSON-RPC 2.0 request, for example jsonrpc is missing. The message is "Not a JSON-RPC 2.0 request". An empty batch ([]) gives "Empty batch".
  • -32601, method not found: "No method" and the name you sent.
  • -32602, invalid parameters: a parameter is missing or has the wrong type or value. The message says which, for example "path is required and must be a string" or "index must be a number".

The server's own codes:

  • -32001, no document: the method needs an open document. "No document is open; call open first".
  • -32002, open failed: the document could not be opened. The message names the file and the reason.
  • -32003, shut down: a request arrived after shutdown. "The server is shutting down; send exit".
  • -32004, not editing: insert was called outside edit mode. "insert needs edit mode; run the toggle_edit_mode action first".

Versions

initialize reports protocol, currently 1. The design record, ADR-0015, sets the compatibility rule: adding methods, notifications, parameters, or result fields keeps version 1, so a client must ignore fields it does not recognize, while renaming or removing anything, or changing the meaning of anything, raises the version. A client should check protocol on connecting and refuse to continue if the version is unknown.

The server emits the fields of each object in alphabetical order; clients must not depend on field order.

A complete example session

This is a real session. The client started the server with this command:

tw serve --stdio --no-speech

The document was a small Markdown file, notes.md:

# Cell biology

Cells are the smallest units of life. Every living thing is made of cells.

## The nucleus

The nucleus holds the cell's DNA. It controls growth and division.

Each message was sent on one line. Responses are shown here with line breaks added for readability, and the actual folder of the file is replaced with C:\Users\you\Documents.

Say hello

The client asks for the server's version.

{"jsonrpc": "2.0", "id": 1, "method": "initialize"}

The server answers with its name, version, protocol, and the lists of methods and notifications.

{
  "id": 1,
  "jsonrpc": "2.0",
  "result": {
    "methods": ["initialize", "open", "status", "position", "navigate", "read", "pause", "resume", "stop", "search", "text", "outline", "notes", "highlights", "info", "insert", "action", "answer", "choose", "cancel", "list_state", "list_key", "prompt_state", "prompt_key", "settings_schema", "get_setting", "set_setting", "shutdown", "exit"],
    "notifications": ["position", "playback", "announcement", "prompt", "list", "quit"],
    "protocol": 1,
    "server": "textweaver",
    "version": "0.1.0-beta.1"
  }
}

Open the document

The client opens notes.md. Backslashes in a Windows path are doubled inside JSON.

{"jsonrpc": "2.0", "id": 2, "method": "open", "params": {"path": "C:\\Users\\you\\Documents\\notes.md"}}

First comes the announcement, as a notification.

{"jsonrpc": "2.0", "method": "announcement", "params": {"priority": "polite", "text": "Opened Cell biology."}}

Then the answer, with the document and the starting position, the word "Cell" at the very start.

{
  "id": 2,
  "jsonrpc": "2.0",
  "result": {
    "document": {
      "dirty": false,
      "editing": false,
      "format": "markdown",
      "length": 169,
      "lines": 7,
      "path": "C:\\Users\\you\\Documents\\notes.md",
      "title": "Cell biology"
    },
    "position": {
      "char": 0,
      "column": 1,
      "line": 1,
      "percent": 0,
      "word": {"end": 4, "start": 0, "text": "Cell"}
    }
  }
}

Move to the next sentence

The client moves by one sentence.

{"jsonrpc": "2.0", "id": 3, "method": "navigate", "params": {"action": "next_sentence"}}

The server announces the sentence it arrived at.

{"jsonrpc": "2.0", "method": "announcement", "params": {"priority": "polite", "text": "Cells are the smallest units of life."}}

The answer is the new position: line 3, character 14, the word "Cells".

{
  "id": 3,
  "jsonrpc": "2.0",
  "result": {
    "char": 14,
    "column": 1,
    "line": 3,
    "percent": 8,
    "word": {"end": 19, "start": 14, "text": "Cells"}
  }
}

Read that sentence aloud

The client asks for the sentence at the cursor to be read.

{"jsonrpc": "2.0", "id": 4, "method": "read", "params": {"what": "sentence"}}

Reading starts.

{"jsonrpc": "2.0", "method": "playback", "params": {"state": "reading"}}

The answer says reading is going on.

{"id": 4, "jsonrpc": "2.0", "result": {"playback": "reading"}}

A position notification says which word is being spoken. With a real voice there is one for every word. With --no-speech there is only this one.

{"jsonrpc": "2.0", "method": "position", "params": {"end": 19, "line": 3, "start": 14}}

Reading ends.

{"jsonrpc": "2.0", "method": "playback", "params": {"state": "stopped"}}

Ask where the cursor is

The client asks for the position.

{"jsonrpc": "2.0", "id": 5, "method": "position"}

Reading one sentence did not move the cursor.

{
  "id": 5,
  "jsonrpc": "2.0",
  "result": {
    "char": 14,
    "column": 1,
    "line": 3,
    "percent": 8,
    "word": {"end": 19, "start": 14, "text": "Cells"}
  }
}

Run an action: add a bookmark

The client runs the add_bookmark action, the same as pressing m in browse mode in the terminal reader.

{"jsonrpc": "2.0", "id": 6, "method": "action", "params": {"id": "add_bookmark"}}

The announcement.

{"jsonrpc": "2.0", "method": "announcement", "params": {"priority": "polite", "text": "Bookmark mark1 set at 8 percent."}}

The answer: no effects and no question waiting.

{"id": 6, "jsonrpc": "2.0", "result": {"effects": [], "pending": null, "status": "Bookmark mark1 set at 8 percent."}}

Run an action that asks for text: go to

The client runs go_to, which opens a prompt.

{"jsonrpc": "2.0", "id": 7, "method": "action", "params": {"id": "go_to"}}

The prompt's label is announced.

{"jsonrpc": "2.0", "method": "announcement", "params": {"priority": "polite", "text": "Go to line, percent, start, or end"}}

The answer carries the prompt as an effect.

{
  "id": 7,
  "jsonrpc": "2.0",
  "result": {
    "effects": [
      {"params": {"label": "Go to line, percent, start, or end", "purpose": "go_to"}, "type": "prompt"}
    ],
    "pending": null,
    "status": "Go to line, percent, start, or end"
  }
}

The client answers the prompt with line 5.

{"jsonrpc": "2.0", "id": 8, "method": "answer", "params": {"text": "5"}}

The server announces where it went.

{"jsonrpc": "2.0", "method": "announcement", "params": {"priority": "polite", "text": "53 percent, line 5: The nucleus"}}

The answer.

{"id": 8, "jsonrpc": "2.0", "result": {"effects": [], "status": "53 percent, line 5: The nucleus"}}

Finish

The client asks the server to save and get ready to stop.

{"jsonrpc": "2.0", "id": 9, "method": "shutdown"}

The answer is null.

{"id": 9, "jsonrpc": "2.0", "result": null}

The client sends exit as a notification. There is no answer. The server closes its output and ends with exit code 0.

{"jsonrpc": "2.0", "method": "exit"}

The next time this document is opened, the announcement is "Opened Cell biology. Resumed at" and the saved percentage.

A minimal client in Python

This client uses only Python's standard library. It starts the server without speech, opens the named file, moves to the next sentence, prints every announcement, and shuts down cleanly. It reads one line at a time and skips notifications until the response to its request arrives.

import json
import subprocess
import sys

# Start the server as a child process. Use "tw" if it is on your PATH.
server = subprocess.Popen(
    ["tw", "serve", "--stdio", "--no-speech"],
    stdin=subprocess.PIPE,
    stdout=subprocess.PIPE,
    text=True,
    encoding="utf-8",
)
next_id = 0


def call(method, params=None):
    """Send one request and return its result, printing announcements."""
    global next_id
    next_id += 1
    request = {"jsonrpc": "2.0", "id": next_id, "method": method}
    if params is not None:
        request["params"] = params
    server.stdin.write(json.dumps(request) + "\n")
    server.stdin.flush()
    while True:
        line = server.stdout.readline()
        if not line:
            raise RuntimeError("the server stopped")
        message = json.loads(line)
        if message.get("method") == "announcement":
            print("Announcement:", message["params"]["text"])
        if message.get("id") == next_id:
            if "error" in message:
                raise RuntimeError(message["error"]["message"])
            return message["result"]


info = call("initialize")
print("Connected to", info["server"], info["version"], "protocol", info["protocol"])
opened = call("open", {"path": sys.argv[1]})
print("Opened", opened["document"]["title"], "with", opened["document"]["lines"], "lines")
position = call("navigate", {"action": "next_sentence"})
print("Now at line", position["line"], "column", position["column"])
call("shutdown")
server.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "exit"}) + "\n")
server.stdin.flush()
server.wait()

Save it as client.py and run it with a document:

python client.py notes.md

With the example document, opened for the first time, it prints:

Connected to textweaver 0.1.0-beta.1 protocol 1
Announcement: Opened Cell biology.
Opened Cell biology with 7 lines
Announcement: Cells are the smallest units of life.
Now at line 3 column 1

This client reads only while it waits for a response. A production client, such as an editor plugin, should read the server's output continuously, on its own thread or in an event loop, because position and playback notifications keep arriving while speech continues.

If something goes wrong

  • tw serve stops at once with "needs a transport". Add --stdio.
  • Nothing comes back. Check that each message ends with a newline, or has a correct Content-Length header, and that your client flushes its output after writing. Check that the message has "jsonrpc": "2.0". A message without an id is a notification and never gets an answer.
  • Error -32001, "No document is open". Call open first, or start the server with --open.
  • Error -32002 when opening. The path is wrong or the file cannot be read. A relative path is taken from the folder the server was started in, so send a full path when you can.
  • Error -32602, "Not a navigation action". navigate takes only movement, Speech Cursor, search, and bookmark actions. Use action for everything else.
  • Error -32003. You already called shutdown. Send exit and start a new server.
  • The user hears two voices. Both textweaver and the user's screen reader are speaking. Start the server with --no-speech and pass the announcements to the screen reader. See the screen reader guide.
  • A prompt or list opened, but the client was not told. Actions run through navigate or read do not return their effects. Run them with action, then use answer, choose, or cancel. cancel always closes whatever is open.
  • Highlighting is off by a few characters in JavaScript or VS Code. Positions count Unicode characters, and those tools count UTF-16 units. Convert before highlighting.
  • No speech, and an announcement says the speech engine is not available. Run tw backends to see which engines work on this computer, and see the troubleshooting guide.
  • You need more detail. The server writes nothing but protocol messages to standard output, and nothing to standard error. Problems are written to the log file, textweaver.log, in the state folder (with --home, that is the data\state folder inside it). Set the TEXTWEAVER_LOG environment variable to debug before starting the server to log more.

See also