diff --git a/app/corpora/__init__.py b/app/corpora/__init__.py
index 34663b6939cef7e908ac5366b0a392c930f3e385..65129bf6115f198356fcae2494b5a1ebf05004a0 100644
--- a/app/corpora/__init__.py
+++ b/app/corpora/__init__.py
@@ -17,3 +17,4 @@ def before_request():
 
 
 from . import cli, cqi_over_socketio, files, followers, routes, json_routes
+from . import cqi_over_sio
diff --git a/app/corpora/cqi_over_sio/__init__.py b/app/corpora/cqi_over_sio/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..5c2131bd54453dde085fc3758f0eff5e60a02191
--- /dev/null
+++ b/app/corpora/cqi_over_sio/__init__.py
@@ -0,0 +1,109 @@
+from cqi import CQiClient
+from cqi.errors import CQiException
+from flask import session
+from flask_login import current_user
+from flask_socketio import ConnectionRefusedError
+from threading import Lock
+from app import db, hashids, socketio
+from app.decorators import socketio_login_required
+from app.models import Corpus, CorpusStatus
+
+
+'''
+This package tunnels the Corpus Query interface (CQi) protocol through
+Socket.IO (SIO) by wrapping each CQi function in a seperate SIO event.
+
+This module only handles the SIO connect/disconnect, which handles the setup
+and teardown of necessary ressources for later use. Each CQi function has a
+corresponding SIO event. The event handlers are spread across the different
+modules within this package.
+
+Basic concept:
+1. A client connects to the SIO namespace and provides the id of a corpus to be
+   analysed.
+     1.1 The analysis session counter of the corpus is incremented.
+     1.2 A CQiClient and a (Mutex) Lock belonging to it is created.
+     1.3 Wait until the CQP server is running.
+     1.4 Connect the CQiClient to the server.
+     1.5 Save the CQiClient and the Lock in the session for subsequential use.
+2. A client emits an event and may provide a single json object with necessary
+   arguments for the targeted CQi function.
+3. A SIO event handler (decorated with cqi_over_socketio) gets executed.
+     - The event handler function defines all arguments. Hence the client
+       is sent as a single json object, the decorator decomposes it to fit
+       the functions signature. This also includes type checking and proper
+       use of the lock (acquire/release) mechanism.
+4. Wait for more events
+5. The client disconnects from the SIO namespace
+     1.1 The analysis session counter of the corpus is decremented.
+     1.2 The CQiClient and (Mutex) Lock belonging to it are teared down.
+'''
+
+
+NAMESPACE = '/cqi_over_sio'
+
+
+from .cqi import *  # noqa
+
+
+@socketio.on('connect', namespace=NAMESPACE)
+@socketio_login_required
+def connect(auth):
+    # the auth variable is used in a hacky way. It contains the corpus id for
+    # which a corpus analysis session should be started.
+    corpus_id = hashids.decode(auth['corpus_id'])
+    corpus = Corpus.query.get(corpus_id)
+    if corpus is None:
+        # return {'code': 404, 'msg': 'Not Found'}
+        raise ConnectionRefusedError('Not Found')
+    if not (corpus.user == current_user
+            or current_user.is_following_corpus(corpus)
+            or current_user.is_administrator()):
+        # return {'code': 403, 'msg': 'Forbidden'}
+        raise ConnectionRefusedError('Forbidden')
+    if corpus.status not in [
+        CorpusStatus.BUILT,
+        CorpusStatus.STARTING_ANALYSIS_SESSION,
+        CorpusStatus.RUNNING_ANALYSIS_SESSION,
+        CorpusStatus.CANCELING_ANALYSIS_SESSION
+    ]:
+        # return {'code': 424, 'msg': 'Failed Dependency'}
+        raise ConnectionRefusedError('Failed Dependency')
+    if corpus.num_analysis_sessions is None:
+        corpus.num_analysis_sessions = 0
+        db.session.commit()
+    corpus.num_analysis_sessions = Corpus.num_analysis_sessions + 1
+    db.session.commit()
+    retry_counter = 20
+    while corpus.status != CorpusStatus.RUNNING_ANALYSIS_SESSION:
+        if retry_counter == 0:
+            corpus.num_analysis_sessions = Corpus.num_analysis_sessions - 1
+            db.session.commit()
+            return {'code': 408, 'msg': 'Request Timeout'}
+        socketio.sleep(3)
+        retry_counter -= 1
+        db.session.refresh(corpus)
+    cqi_client = CQiClient(f'cqpserver_{corpus_id}')
+    session['d'] = {
+        'corpus_id': corpus_id,
+        'cqi_client': cqi_client,
+        'cqi_client_lock': Lock(),
+    }
+    # return {'code': 200, 'msg': 'OK'}
+
+
+@socketio.on('disconnect', namespace=NAMESPACE)
+def disconnect():
+    if 'd' not in session:
+        return
+    session['d']['cqi_client_lock'].acquire()
+    try:
+        session['d']['cqi_client'].api.ctrl_bye()
+    except (BrokenPipeError, CQiException):
+        pass
+    session['d']['cqi_client_lock'].release()
+    corpus = Corpus.query.get(session['d']['corpus_id'])
+    corpus.num_analysis_sessions = Corpus.num_analysis_sessions - 1
+    db.session.commit()
+    session.pop('d')
+    # return {'code': 200, 'msg': 'OK'}
diff --git a/app/corpora/cqi_over_sio/cqi.py b/app/corpora/cqi_over_sio/cqi.py
new file mode 100644
index 0000000000000000000000000000000000000000..9cbdae67acf7bb86312e473a7588587c76e7b0b3
--- /dev/null
+++ b/app/corpora/cqi_over_sio/cqi.py
@@ -0,0 +1,111 @@
+from cqi import APIClient
+from cqi.errors import CQiException
+from cqi.status import CQiStatus
+from flask import session
+from inspect import signature
+from typing import Callable, Dict, List
+from app import socketio
+from app.decorators import socketio_login_required
+from . import NAMESPACE as ns
+
+
+CQI_API_FUNCTIONS: List[str] = [
+    'ask_feature_cl_2_3',
+    'ask_feature_cqi_1_0',
+    'ask_feature_cqp_2_3',
+    'cl_alg2cpos',
+    'cl_attribute_size',
+    'cl_cpos2alg',
+    'cl_cpos2id',
+    'cl_cpos2lbound',
+    'cl_cpos2rbound',
+    'cl_cpos2str',
+    'cl_cpos2struc',
+    'cl_drop_attribute',
+    'cl_id2cpos',
+    'cl_id2freq',
+    'cl_id2str',
+    'cl_idlist2cpos',
+    'cl_lexicon_size',
+    'cl_regex2id',
+    'cl_str2id',
+    'cl_struc2cpos',
+    'cl_struc2str',
+    'corpus_alignment_attributes',
+    'corpus_charset',
+    'corpus_drop_corpus',
+    'corpus_full_name',
+    'corpus_info',
+    'corpus_list_corpora',
+    'corpus_positional_attributes',
+    'corpus_properties',
+    'corpus_structural_attribute_has_values',
+    'corpus_structural_attributes',
+    'cqp_drop_subcorpus',
+    'cqp_dump_subcorpus',
+    'cqp_fdist_1',
+    'cqp_fdist_2',
+    'cqp_list_subcorpora',
+    'cqp_query',
+    'cqp_subcorpus_has_field',
+    'cqp_subcorpus_size',
+    'ctrl_bye',
+    'ctrl_connect',
+    'ctrl_last_general_error',
+    'ctrl_ping',
+    'ctrl_user_abort'
+]
+
+
+@socketio.on('cqi_client.api', namespace=ns)
+@socketio_login_required
+def cqi_over_sio(fn_data):
+    fn_name: str = fn_data['fn_name']
+    fn_args: Dict = fn_data.get('fn_args', {})
+    print(f'{fn_name}({fn_args})')
+    if 'd' not in session:
+        return {'code': 424, 'msg': 'Failed Dependency'}
+    api_client: APIClient = session['d']['cqi_client'].api
+    if fn_name not in CQI_API_FUNCTIONS:
+        return {'code': 400, 'msg': 'Bad Request'}
+    try:
+        fn: Callable = getattr(api_client, fn_name)
+    except AttributeError:
+        return {'code': 400, 'msg': 'Bad Request'}
+    for param in signature(fn).parameters.values():
+        if param.default is param.empty:
+            if param.name not in fn_args:
+                return {'code': 400, 'msg': 'Bad Request'}
+        else:
+            if param.name not in fn_args:
+                continue
+        if type(fn_args[param.name]) is not param.annotation:
+            return {'code': 400, 'msg': 'Bad Request'}
+    session['d']['cqi_client_lock'].acquire()
+    try:
+        return_value = fn(**fn_args)
+    except BrokenPipeError:
+        return_value = {
+            'code': 500,
+            'msg': 'Internal Server Error'
+        }
+    except CQiException as e:
+        return_value = {
+            'code': 502,
+            'msg': 'Bad Gateway',
+            'payload': {
+                'code': e.code,
+                'desc': e.description,
+                'msg': e.__class__.__name__
+            }
+        }
+    finally:
+        session['d']['cqi_client_lock'].release()
+    if isinstance(return_value, CQiStatus):
+        payload = {
+            'code': return_value.code,
+            'msg': return_value.__class__.__name__
+        }
+    else:
+        payload = return_value
+    return {'code': 200, 'msg': 'OK', 'payload': payload}
diff --git a/app/corpora/cqi_over_socketio/utils.py b/app/corpora/cqi_over_socketio/utils.py
index bdab8b53ee8f15fa8b6b931c99f50121cc09df81..14e71e2b0a655159585a058e69848461d678c8c4 100644
--- a/app/corpora/cqi_over_socketio/utils.py
+++ b/app/corpora/cqi_over_socketio/utils.py
@@ -49,7 +49,7 @@ def cqi_over_socketio(f):
                 'payload': {
                     'code': e.code,
                     'desc': e.description,
-                    'msg': e.name
+                    'msg': e.__class__.__name__
                 }
             }
         finally:
diff --git a/app/static/js/cqi/api/client.js b/app/static/js/cqi/api/client.js
new file mode 100644
index 0000000000000000000000000000000000000000..5cecf72c3d736226bfafa37554baa6f50de2e071
--- /dev/null
+++ b/app/static/js/cqi/api/client.js
@@ -0,0 +1,598 @@
+cqi.api.APIClient = class APIClient {
+  constructor(host, corpus_id, version = '0.1') {
+    this.host = host;
+    this.version = version;
+    this.socket = io(
+      this.host,
+      {
+        auth: {corpus_id: corpus_id},
+        transports: ['websocket'],
+        upgrade: false
+      }
+    );
+  }
+
+  /**
+   * @param {string} fn_name
+   * @param {object} [fn_args={}]
+   * @returns {Promise<cqi.status.StatusConnectOk>}
+   */
+  #request(fn_name, fn_args = {}) {
+    return new Promise((resolve, reject) => {
+      this.socket.emit('cqi_client.api', {fn_name: fn_name, fn_args: fn_args}, (response) => {
+        if (response.code === 200) {
+          resolve(response.payload);
+        }
+        if (response.code === 500) {
+          reject(new Error(`[${response.code}] ${response.msg}`));
+        }
+        if (response.code === 502) {
+          reject(new cqi.errors.lookup[response.payload.code]());
+        }
+      });
+    });
+  }
+
+  /**
+   * @param {string} username
+   * @param {string} password
+   * @returns {Promise<cqi.status.StatusConnectOk>}
+   */
+  async ctrl_connect(username, password) {
+    const fn_name = 'ctrl_connect';
+    const fn_args = {username: username, password: password};
+    let payload = await this.#request(fn_name, fn_args);
+    return new cqi.status.lookup[payload.code]();
+  }
+
+  /**
+   * @returns {Promise<cqi.status.StatusByeOk>}
+   */
+  async ctrl_bye() {
+    const fn_name = 'ctrl_bye';
+    let payload = await this.#request(fn_name);
+    return new cqi.status.lookup[payload.code]();
+  }
+
+  /**
+   * @returns {Promise<null>}
+   */
+  async ctrl_user_abort() {
+    const fn_name = 'ctrl_user_abort';
+    return await this.#request(fn_name);
+  }
+
+  /**
+   * @returns {Promise<cqi.status.StatusPingOk>}
+   */
+  async ctrl_ping() {
+    const fn_name = 'ctrl_ping';
+    let payload = await this.#request(fn_name);
+    return new cqi.status.lookup[payload.code]();
+  }
+
+  /**
+   * Full-text error message for the last general error reported
+   * by the CQi server
+   * 
+   * @returns {Promise<string>}
+   */
+  async ctrl_last_general_error() {
+    const fn_name = 'ctrl_last_general_error';
+    return await this.#request(fn_name);
+  }
+
+  /**
+   * @returns {Promise<boolean>}
+   */
+  async ask_feature_cqi_1_0() {
+    const fn_name = 'ask_feature_cqi_1_0';
+    return await this.#request(fn_name);
+  }
+
+  /**
+   * @returns {Promise<boolean>}
+   */
+  async ask_feature_cl_2_3() {
+    const fn_name = 'ask_feature_cl_2_3';
+    return await this.#request(fn_name);
+  }
+
+  /**
+   * @returns {Promise<boolean>}
+   */
+  async ask_feature_cqp_2_3() {
+    const fn_name = 'ask_feature_cqp_2_3';
+    return await this.#request(fn_name);
+  }
+
+  /**
+   * @returns {Promise<string[]>}
+   */
+  async corpus_list_corpora() {
+    const fn_name = 'corpus_list_corpora';
+    return await this.#request(fn_name);
+  }
+
+  /**
+   * @param {string} corpus 
+   * @returns {Promise<string>}
+   */
+  async corpus_charset(corpus) {
+    const fn_name = 'corpus_charset';
+    const fn_args = {corpus: corpus};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * @param {string} corpus 
+   * @returns {Promise<string[]>}
+   */
+  async corpus_properties(corpus) {
+    const fn_name = 'corpus_properties';
+    const fn_args = {corpus: corpus};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * @param {string} corpus
+   * @returns {Promise<string[]>}
+   */
+  async corpus_positional_attributes(corpus) {
+    const fn_name = 'corpus_positional_attributes';
+    const fn_args = {corpus: corpus};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * @param {string} corpus
+   * @returns {Promise<string[]>}
+   */
+  async corpus_structural_attributes(corpus) {
+    const fn_name = 'corpus_structural_attributes';
+    const fn_args = {corpus: corpus};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * @param {string} corpus
+   * @param {string} attribute
+   * @returns {Promise<boolean>}
+   */
+  async corpus_structural_attribute_has_values(corpus, attribute) {
+    const fn_name = 'corpus_structural_attribute_has_values';
+    const fn_args = {corpus: corpus, attribute: attribute};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * @param {string} corpus
+   * @returns {Promise<string[]>}
+   */
+  async corpus_alignment_attributes(corpus) {
+    const fn_name = 'corpus_alignment_attributes';
+    const fn_args = {corpus: corpus};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * the full name of <corpus> as specified in its registry entry
+   * 
+   * @param {string} corpus
+   * @returns {Promise<string>}
+   */
+  async corpus_full_name(corpus) {
+    const fn_name = 'corpus_full_name';
+    const fn_args = {corpus: corpus};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * returns the contents of the .info file of <corpus> as a list of lines
+   * 
+   * @param {string} corpus
+   * @returns {Promise<string[]>}
+   */
+  async corpus_info(corpus) {
+    const fn_name = 'corpus_info';
+    const fn_args = {corpus: corpus};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * try to unload a corpus and all its attributes from memory
+   * 
+   * @param {string} corpus
+   * @returns {Promise<cqi.status.StatusOk>}
+   */
+  async corpus_drop_corpus(corpus) {
+    const fn_name = 'corpus_drop_corpus';
+    const fn_args = {corpus: corpus};
+    let payload = await this.#request(fn_name, fn_args);
+    return new cqi.status.lookup[payload.code]();
+  }
+
+  /**
+   * returns the size of <attribute>:
+   * - number of tokens        (positional)
+   * - number of regions       (structural)
+   * - number of alignments    (alignment)
+   * 
+   * @param {string} attribute
+   * @returns {Promise<number>}
+   */
+  async cl_attribute_size(attribute) {
+    const fn_name = 'cl_attribute_size';
+    const fn_args = {attribute: attribute};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * returns the number of entries in the lexicon of a positional attribute;
+   *
+   * valid lexicon IDs range from 0 .. (lexicon_size - 1)
+   * 
+   * @param {string} attribute
+   * @returns {Promise<number>}
+   */
+  async cl_lexicon_size(attribute) {
+    const fn_name = 'cl_lexicon_size';
+    const fn_args = {attribute: attribute};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * unload attribute from memory
+   * 
+   * @param {string} attribute
+   * @returns {Promise<cqi.status.StatusOk>}
+   */
+  async cl_drop_attribute(attribute) {
+    const fn_name = 'cl_drop_attribute';
+    const fn_args = {attribute: attribute};
+    let payload = await this.#request(fn_name, fn_args);
+    return new cqi.status.lookup[payload.code]();
+  }
+
+  /**
+   * NOTE: simple (scalar) mappings are applied to lists (the returned list
+   *       has exactly the same length as the list passed as an argument)
+   */
+
+  /**
+   * returns -1 for every string in <strings> that is not found in the lexicon
+   * 
+   * @param {string} attribute
+   * @param {strings[]} string
+   * @returns {Promise<number[]>}
+   */
+  async cl_str2id(attribute, strings) {
+    const fn_name = 'cl_str2id';
+    const fn_args = {attribute: attribute, strings: strings};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * returns "" for every ID in <id> that is out of range
+   * 
+   * @param {string} attribute
+   * @param {number[]} id
+   * @returns {Promise<string[]>}
+   */
+  async cl_id2str(attribute, id) {
+    const fn_name = 'cl_id2str';
+    const fn_args = {attribute: attribute, id: id};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * returns 0 for every ID in <id> that is out of range
+   * 
+   * @param {string} attribute
+   * @param {number[]} id
+   * @returns {Promise<number[]>}
+   */
+  async cl_id2freq(attribute, id) {
+    const fn_name = 'cl_id2freq';
+    const fn_args = {attribute: attribute, id: id};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * returns -1 for every corpus position in <cpos> that is out of range
+   * 
+   * @param {string} attribute
+   * @param {number[]} cpos
+   * @returns {Promise<number[]>}
+   */
+  async cl_cpos2id(attribute, cpos) {
+    const fn_name = 'cl_cpos2id';
+    const fn_args = {attribute: attribute, cpos: cpos};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * returns "" for every corpus position in <cpos> that is out of range
+   * 
+   * @param {string} attribute
+   * @param {number[]} cpos
+   * @returns {Promise<string[]>}
+   */
+  async cl_cpos2str(attribute, cpos) {
+    const fn_name = 'cl_cpos2str';
+    const fn_args = {attribute: attribute, cpos: cpos};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * returns -1 for every corpus position not inside a structure region
+   * 
+   * @param {string} attribute
+   * @param {number[]} cpos
+   * @returns {Promise<number[]>}
+   */
+  async cl_cpos2struc(attribute, cpos) {
+    const fn_name = 'cl_cpos2struc';
+    const fn_args = {attribute: attribute, cpos: cpos};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * NOTE: temporary addition for the Euralex2000 tutorial, but should
+   * probably be included in CQi specs
+   */
+
+  /**
+   * returns left boundary of s-attribute region enclosing cpos,
+   * -1 if not in region
+   * 
+   * @param {string} attribute
+   * @param {number[]} cpos
+   * @returns {Promise<number[]>}
+   */
+  async cl_cpos2lbound(attribute, cpos) {
+    const fn_name = 'cl_cpos2lbound';
+    const fn_args = {attribute: attribute, cpos: cpos};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * returns right boundary of s-attribute region enclosing cpos,
+   * -1 if not in region
+   * 
+   * @param {string} attribute
+   * @param {number[]} cpos
+   * @returns {Promise<number[]>}
+   */
+  async cl_cpos2rbound(attribute, cpos) {
+    const fn_name = 'cl_cpos2rbound';
+    const fn_args = {attribute: attribute, cpos: cpos};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * returns -1 for every corpus position not inside an alignment
+   * 
+   * @param {string} attribute
+   * @param {number[]} cpos
+   * @returns {Promise<number[]>}
+   */
+  async cl_cpos2alg(attribute, cpos) {
+    const fn_name = 'cl_cpos2alg';
+    const fn_args = {attribute: attribute, cpos: cpos};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * returns annotated string values of structure regions in <strucs>;
+   * "" if out of range
+   *
+   * check corpus_structural_attribute_has_values(<attribute>) first
+   * 
+   * @param {string} attribute
+   * @param {number[]} strucs
+   * @returns {Promise<string[]>}
+   */
+  async cl_struc2str(attribute, strucs) {
+    const fn_name = 'cl_struc2str';
+    const fn_args = {attribute: attribute, strucs: strucs};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * NOTE: the following mappings take a single argument and return multiple
+   * values, including lists of arbitrary size
+   */
+
+  /**
+   * returns all corpus positions where the given token occurs
+   * 
+   * @param {string} attribute
+   * @param {number} id
+   * @returns {Promise<number[]>}
+   */
+  async cl_id2cpos(attribute, id) {
+    const fn_name = 'cl_id2cpos';
+    const fn_args = {attribute: attribute, id: id};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * returns all corpus positions where one of the tokens in <id_list> occurs;
+   * the returned list is sorted as a whole, not per token id
+   * 
+   * @param {string} attribute
+   * @param {number[]} id_list
+   * @returns {Promise<number[]>}
+   */
+  async cl_idlist2cpos(attribute, id_list) {
+    const fn_name = 'cl_idlist2cpos';
+    const fn_args = {attribute: attribute, id_list: id_list};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * returns lexicon IDs of all tokens that match <regex>;
+   * the returned list may be empty (size 0);
+   * 
+   * @param {string} attribute
+   * @param {string} regex
+   * @returns {Promise<number[]>}
+   */
+  async cl_regex2id(attribute, regex) {
+    const fn_name = 'cl_regex2id';
+    const fn_args = {attribute: attribute, regex: regex};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * returns start and end corpus positions of structure region <struc>
+   * 
+   * @param {string} attribute
+   * @param {number} struc
+   * @returns {Promise<[number, number]>}
+   */
+  async cl_struc2cpos(attribute, struc) {
+    const fn_name = 'cl_struc2cpos';
+    const fn_args = {attribute: attribute, struc: struc};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * returns (src_start, src_end, target_start, target_end)
+   * 
+   * @param {string} attribute
+   * @param {number} alg
+   * @returns {Promise<[number, number, number, number]>}
+   */
+  async alg2cpos(attribute, alg) {
+    const fn_name = 'alg2cpos';
+    const fn_args = {attribute: attribute, alg: alg};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * <query> must include the ';' character terminating the query.
+   * 
+   * @param {string} mother_corpus
+   * @param {string} subcorpus_name
+   * @param {string} query
+   * @returns {Promise<cqi.status.StatusOk>}
+   */
+  async cqp_query(mother_corpus, subcorpus_name, query) {
+    const fn_name = 'cqp_query';
+    const fn_args = {mother_corpus: mother_corpus, subcorpus_name: subcorpus_name, query: query};
+    let payload = await this.#request(fn_name, fn_args);
+    return new cqi.status.lookup[payload.code]();
+  }
+
+  /**
+   * @param {string} corpus
+   * @returns {Promise<string[]>}
+   */
+  async cqp_list_subcorpora(corpus) {
+    const fn_name = 'cqp_list_subcorpora';
+    const fn_args = {corpus: corpus};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * @param {string} subcorpus
+   * @returns {Promise<number>}
+   */
+  async cqp_subcorpus_size(subcorpus) {
+    const fn_name = 'cqp_subcorpus_size';
+    const fn_args = {subcorpus: subcorpus};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * @param {string} subcorpus
+   * @param {number} field
+   * @returns {Promise<boolean>}
+   */
+  async cqp_subcorpus_has_field(subcorpus, field) {
+    const fn_name = 'cqp_subcorpus_has_field';
+    const fn_args = {subcorpus: subcorpus, field: field};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * Dump the values of <field> for match ranges <first> .. <last>
+   * in <subcorpus>. <field> is one of the CQI_CONST_FIELD_* constants.
+   * 
+   * @param {string} subcorpus
+   * @param {number} field
+   * @param {number} first
+   * @param {number} last
+   * @returns {Promise<number[]>}
+   */
+  async cqp_dump_subcorpus(subcorpus, field, first, last) {
+    const fn_name = 'cqp_dump_subcorpus';
+    const fn_args = {subcorpus: subcorpus, field: field, first: first, last: last};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * delete a subcorpus from memory
+   * 
+   * @param {string} subcorpus
+   * @returns {Promise<cqi.status.StatusOk>}
+   */
+  async cqp_drop_subcorpus(subcorpus) {
+    const fn_name = 'cqp_drop_subcorpus';
+    const fn_args = {subcorpus: subcorpus};
+    let payload = await this.#request(fn_name, fn_args);
+    return new cqi.status.lookup[payload.code]();
+  }
+
+  /**
+   * NOTE: The following two functions are temporarily included for the
+   * Euralex 2000 tutorial demo
+   */
+
+  /**
+   * frequency distribution of single tokens
+   *
+   * returns <n> (id, frequency) pairs flattened into a list of size 2*<n>
+   * field is one of
+   * - CQI_CONST_FIELD_MATCH
+   * - CQI_CONST_FIELD_TARGET
+   * - CQI_CONST_FIELD_KEYWORD
+   *
+   * NB: pairs are sorted by frequency desc.
+   * 
+   * @param {string} subcorpus
+   * @param {number} cutoff
+   * @param {number} field
+   * @param {string} attribute
+   * @returns {Promise<number[]>}
+   */
+  async cqp_fdist_1(subcorpus, cutoff, field, attribute) {
+    const fn_name = 'cqp_fdist_1';
+    const fn_args = {subcorpus: subcorpus, cutoff: cutoff, field: field, attribute: attribute};
+    return await this.#request(fn_name, fn_args);
+  }
+
+  /**
+   * frequency distribution of pairs of tokens
+   *
+   * returns <n> (id1, id2, frequency) pairs flattened into a list of
+   * size 3*<n>
+   *
+   * NB: triples are sorted by frequency desc.
+   * 
+   * @param {string} subcorpus
+   * @param {number} cutoff
+   * @param {number} field1
+   * @param {string} attribute1
+   * @param {number} field2
+   * @param {string} attribute2
+   * @returns {Promise<number[]>}
+   */
+  async cqp_fdist_2(subcorpus, cutoff, field1, attribute1, field2, attribute2) {
+    const fn_name = 'cqp_fdist_2';
+    const fn_args = {subcorpus: subcorpus, cutoff: cutoff, field1: field1, attribute1: attribute1, field2: field2, attribute2: attribute2};
+    return await this.#request(fn_name, fn_args);
+  }
+};
diff --git a/app/static/js/cqi/api/package.js b/app/static/js/cqi/api/package.js
new file mode 100644
index 0000000000000000000000000000000000000000..fb42389b4cc454d5966447381990c2bc4fe4718d
--- /dev/null
+++ b/app/static/js/cqi/api/package.js
@@ -0,0 +1 @@
+cqi.api = {};
diff --git a/app/static/js/cqi/client.js b/app/static/js/cqi/client.js
new file mode 100644
index 0000000000000000000000000000000000000000..37a9c2b6af56a3011f0c962d36c8d63a7cdb82d3
--- /dev/null
+++ b/app/static/js/cqi/client.js
@@ -0,0 +1,57 @@
+cqi.CQiClient = class CQiClient {
+  /**
+   * @param {string} host
+   * @param {string} corpusId
+   * @param {string} [version=0.1] version
+   */
+  constructor(host, corpusId, version = '0.1') {
+     /** @type {cqi.api.APIClient} */
+    this.api = new cqi.api.APIClient(host, corpusId, version);
+  }
+
+  /**
+   * @returns {cqi.models.corpora.CorpusCollection}
+   */
+  get corpora() {
+    return new cqi.models.corpora.CorpusCollection(this);
+  }
+
+  /**
+   * @returns {Promise<cqi.status.StatusByeOk>}
+   */
+  async bye() {
+    return await this.api.ctrl_bye();
+  }
+
+  /**
+   * @param {string} username
+   * @param {string} password
+   * @returns {Promise<cqi.status.StatusConnectOk>}
+   */
+  async connect(username, password) {
+    return await this.api.ctrl_connect(username, password);
+  }
+
+  /**
+   * @returns {Promise<cqi.status.StatusPingOk>}
+   */
+  async ping() {
+    return await this.api.ctrl_ping();
+  }
+
+  /**
+   * @returns {Promise<null>}
+   */
+  async userAbort() {
+    return await this.api.ctrl_user_abort();
+  }
+
+  /**
+   * Alias for "bye" method
+   * 
+   * @returns {Promise<cqi.status.StatusByeOk>}
+   */
+  async disconnect() {
+    return await this.api.ctrl_bye();
+  }
+};
diff --git a/app/static/js/cqi/errors.js b/app/static/js/cqi/errors.js
new file mode 100644
index 0000000000000000000000000000000000000000..c7011eb7c7250ae64d1cddd81470d877fbacf0c3
--- /dev/null
+++ b/app/static/js/cqi/errors.js
@@ -0,0 +1,185 @@
+cqi.errors = {};
+
+
+/**
+ * A base class from which all other errors inherit.
+ * If you want to catch all errors that the CQi package might throw,
+ * catch this base error.
+ */
+cqi.errors.CQiError = class CQiError extends Error {
+  constructor(message) {
+    super(message);
+    this.code = undefined;
+    this.description = undefined;
+  }
+};
+
+
+cqi.errors.Error = class Error extends cqi.errors.CQiError {
+  constructor(message) {
+    super(message);
+    this.code = 2;
+  }
+};
+
+
+cqi.errors.ErrorGeneralError = class ErrorGeneralError extends cqi.errors.Error {
+  constructor(message) {
+    super(message);
+    this.code = 513;
+  }
+};
+
+
+cqi.errors.ErrorConnectRefused = class ErrorConnectRefused extends cqi.errors.Error {
+  constructor(message) {
+    super(message);
+    this.code = 514;
+  }
+};
+
+
+cqi.errors.ErrorUserAbort = class ErrorUserAbort extends cqi.errors.Error {
+  constructor(message) {
+    super(message);
+    this.code = 515;
+  }
+};
+
+
+cqi.errors.ErrorSyntaxError = class ErrorSyntaxError extends cqi.errors.Error {
+  constructor(message) {
+    super(message);
+    this.code = 516;
+  }
+};
+
+
+cqi.errors.CLError = class Error extends cqi.errors.CQiError {
+  constructor(message) {
+    super(message);
+    this.code = 4;
+  }
+};
+
+
+cqi.errors.CLErrorNoSuchAttribute = class CLErrorNoSuchAttribute extends cqi.errors.CLError {
+  constructor(message) {
+    super(message);
+    this.code = 1025;
+    this.description = "CQi server couldn't open attribute";
+  }
+};
+
+
+cqi.errors.CLErrorWrongAttributeType = class CLErrorWrongAttributeType extends cqi.errors.CLError {
+  constructor(message) {
+    super(message);
+    this.code = 1026;
+  }
+};
+
+
+cqi.errors.CLErrorOutOfRange = class CLErrorOutOfRange extends cqi.errors.CLError {
+  constructor(message) {
+    super(message);
+    this.code = 1027;
+  }
+};
+
+
+cqi.errors.CLErrorRegex = class CLErrorRegex extends cqi.errors.CLError {
+  constructor(message) {
+    super(message);
+    this.code = 1028;
+  }
+};
+
+
+cqi.errors.CLErrorCorpusAccess = class CLErrorCorpusAccess extends cqi.errors.CLError {
+  constructor(message) {
+    super(message);
+    this.code = 1029;
+  }
+};
+
+
+cqi.errors.CLErrorOutOfMemory = class CLErrorOutOfMemory extends cqi.errors.CLError {
+  constructor(message) {
+    super(message);
+    this.code = 1030;
+    this.description = 'CQi server has run out of memory; try discarding some other corpora and/or subcorpora';
+  }
+};
+
+
+cqi.errors.CLErrorInternal = class CLErrorInternal extends cqi.errors.CLError {
+  constructor(message) {
+    super(message);
+    this.code = 1031;
+    this.description = "The classical 'please contact technical support' error";
+  }
+};
+
+
+cqi.errors.CQPError = class Error extends cqi.errors.CQiError {
+  constructor(message) {
+    super(message);
+    this.code = 5;
+  }
+};
+
+
+cqi.errors.CQPErrorGeneral = class CQPErrorGeneral extends cqi.errors.CQPError {
+  constructor(message) {
+    super(message);
+    this.code = 1281;
+  }
+};
+
+
+cqi.errors.CQPErrorNoSuchCorpus = class CQPErrorNoSuchCorpus extends cqi.errors.CQPError {
+  constructor(message) {
+    super(message);
+    this.code = 1282;
+  }
+};
+
+
+cqi.errors.CQPErrorInvalidField = class CQPErrorInvalidField extends cqi.errors.CQPError {
+  constructor(message) {
+    super(message);
+    this.code = 1283;
+  }
+};
+
+
+cqi.errors.CQPErrorOutOfRange = class CQPErrorOutOfRange extends cqi.errors.CQPError {
+  constructor(message) {
+    super(message);
+    this.code = 1284;
+    this.description = 'A number is out of range';
+  }
+};
+
+
+cqi.errors.lookup = {
+  2: cqi.errors.Error,
+  513: cqi.errors.ErrorGeneralError,
+  514: cqi.errors.ErrorConnectRefused,
+  515: cqi.errors.ErrorUserAbort,
+  516: cqi.errors.ErrorSyntaxError,
+  4: cqi.errors.CLError,
+  1025: cqi.errors.CLErrorNoSuchAttribute,
+  1026: cqi.errors.CLErrorWrongAttributeType,
+  1027: cqi.errors.CLErrorOutOfRange,
+  1028: cqi.errors.CLErrorRegex,
+  1029: cqi.errors.CLErrorCorpusAccess,
+  1030: cqi.errors.CLErrorOutOfMemory,
+  1031: cqi.errors.CLErrorInternal,
+  5: cqi.errors.CQPError,
+  1281: cqi.errors.CQPErrorGeneral,
+  1282: cqi.errors.CQPErrorNoSuchCorpus,
+  1283: cqi.errors.CQPErrorInvalidField,
+  1284: cqi.errors.CQPErrorOutOfRange
+};
diff --git a/app/static/js/cqi/models/attributes.js b/app/static/js/cqi/models/attributes.js
new file mode 100644
index 0000000000000000000000000000000000000000..8a0b987c21e10426837afb61b7dd57bfab1a30f9
--- /dev/null
+++ b/app/static/js/cqi/models/attributes.js
@@ -0,0 +1,289 @@
+cqi.models.attributes = {};
+
+
+cqi.models.attributes.Attribute = class Attribute extends cqi.models.resource.Model {
+  /**
+   * @returns {string}
+   */
+  get apiName() {
+    return this.attrs.api_name;
+  }
+
+  /**
+   * @returns {string}
+   */
+  get name() {
+    return this.attrs.name;
+  }
+
+  /**
+   * @returns {number}
+   */
+  get size() {
+    return this.attrs.size;
+  }
+
+  /**
+   * @returns {Promise<cqi.status.StatusOk>}
+   */
+  async drop() {
+    return await this.client.api.cl_drop_attribute(this.apiName);
+  }
+};
+
+
+cqi.models.attributes.AttributeCollection = class AttributeCollection extends cqi.models.resource.Collection {
+   /** @type{typeof cqi.models.attributes.Attribute} */
+  static model = cqi.models.attributes.Attribute;
+
+  /**
+   * @param {cqi.CQiClient} client
+   * @param {cqi.models.corpora.Corpus} corpus
+   */
+  constructor(client, corpus) {
+    super(client);
+     /** @type {cqi.models.corpora.Corpus} */
+    this.corpus = corpus;
+  }
+
+  /**
+   * @param {string} attributeName
+   * @returns {Promise<object>}
+   */
+  async _get(attributeName) {
+     /** @type{string} */
+    let apiName = `${this.corpus.apiName}.${attributeName}`;
+    return {
+      api_name: apiName,
+      name: attributeName,
+      size: await this.client.api.cl_attribute_size(apiName)
+    }
+  }
+
+  /**
+   * @param {string} attributeName
+   * @returns {Promise<cqi.models.attributes.Attribute>}
+   */
+  async get(attributeName) {
+    return this.prepareModel(await this._get(attributeName));
+  }
+};
+
+
+cqi.models.attributes.AlignmentAttribute = class AlignmentAttribute extends cqi.models.attributes.Attribute {
+  /**
+   * @param {number} id 
+   * @returns {Promise<[number, number, number, number]>}
+   */
+  async cposById(id) {
+    return await this.client.api.cl_alg2cpos(this.apiName, id);
+  }
+
+  /**
+   * @param {number[]} cposList
+   * @returns {Promise<number[]>}
+   */
+  async idsByCpos(cposList) {
+    return await this.client.api.cl_cpos2alg(this.apiName, cposList);
+  }
+};
+
+
+cqi.models.attributes.AlignmentAttributeCollection = class AlignmentAttributeCollection extends cqi.models.attributes.AttributeCollection {
+   /** @type{typeof cqi.models.attributes.AlignmentAttribute} */
+  static model = cqi.models.attributes.AlignmentAttribute;
+
+  /**
+   * @returns {Promise<cqi.models.attributes.AlignmentAttribute[]>}
+   */
+  async list() {
+     /** @type {string[]} */
+     let alignmentAttributeNames = await this.client.api.corpus_alignment_attributes(this.corpus.apiName);
+     /** @type {cqi.models.attributes.AlignmentAttribute[]} */
+    let alignmentAttributes = [];
+    for (let alignmentAttributeName of alignmentAttributeNames) {
+      alignmentAttributes.push(await this.get(alignmentAttributeName));
+    }
+    return alignmentAttributes;
+  }
+};
+
+
+cqi.models.attributes.PositionalAttribute = class PositionalAttribute extends cqi.models.attributes.Attribute {
+  /**
+   * @returns {number}
+   */
+  get lexiconSize() {
+    return this.attrs.lexicon_size;
+  }
+
+  /**
+   * @param {number} id
+   * @returns {Promise<number[]>}
+   */
+  async cposById(id) {
+    return await this.client.api.cl_id2cpos(this.apiName, id);
+  }
+
+  /**
+   * @param {number[]} idList
+   * @returns {Promise<number[]>}
+   */
+  async cposByIds(idList) {
+    return await this.client.api.cl_idlist2cpos(this.apiName, idList);
+  }
+
+  /**
+   * @param {number[]} idList
+   * @returns {Promise<number[]>}
+   */
+  async freqsByIds(idList) {
+    return await this.client.api.cl_id2freq(this.apiName, idList);
+  }
+
+  /**
+   * @param {number[]} cposList
+   * @returns {Promise<number[]>}
+   */
+  async idsByCpos(cposList) {
+    return await this.client.api.cl_cpos2id(this.apiName, cposList);
+  }
+
+  /**
+   * @param {string} regex
+   * @returns {Promise<number[]>}
+   */
+  async idsByRegex(regex) {
+    return await this.client.api.cl_regex2id(this.apiName, regex);
+  }
+
+  /**
+   * @param {string[]} valueList
+   * @returns {Promise<number[]>}
+   */
+  async idsByValues(valueList) {
+    return await this.client.api.cl_str2id(this.apiName, valueList);
+  }
+
+  /**
+   * @param {number[]} cposList
+   * @returns {Promise<string[]>}
+   */
+  async valuesByCpos(cposList) {
+    return await this.client.api.cl_cpos2str(this.apiName, cposList);
+  }
+
+  /**
+   * @param {number[]} idList
+   * @returns {Promise<string[]>}
+   */
+  async valuesByIds(idList) {
+    return await this.client.api.cl_id2str(this.apiName, idList);
+  }
+};
+
+
+cqi.models.attributes.PositionalAttributeCollection = class PositionalAttributeCollection extends cqi.models.attributes.AttributeCollection {
+   /** @type{typeof cqi.models.attributes.PositionalAttribute} */
+  static model = cqi.models.attributes.PositionalAttribute;
+
+  /**
+   * @param {string} positionalAttributeName
+   * @returns {Promise<object>}
+   */
+  async _get(positionalAttributeName) {
+    let positionalAttribute = await super._get(positionalAttributeName);
+    positionalAttribute.lexicon_size = await this.client.api.cl_lexicon_size(positionalAttribute.api_name);
+    return positionalAttribute;
+  }
+
+  /**
+   * @returns {Promise<cqi.models.attributes.PositionalAttribute[]>}
+   */
+  async list() {
+    let positionalAttributeNames = await this.client.api.corpus_positional_attributes(this.corpus.apiName);
+    let positionalAttributes = [];
+    for (let positionalAttributeName of positionalAttributeNames) {
+      positionalAttributes.push(await this.get(positionalAttributeName));
+    }
+    return positionalAttributes;
+  }
+};
+
+
+cqi.models.attributes.StructuralAttribute = class StructuralAttribute extends cqi.models.attributes.Attribute {
+  /**
+   * @returns {boolean}
+   */
+  get hasValues() {
+    return this.attrs.has_values;
+  }
+
+  /**
+   * @param {number} id
+   * @returns {Promise<[number, number]>}
+   */
+  async cposById(id) {
+    return await this.client.api.cl_struc2cpos(this.apiName, id);
+  }
+
+  /**
+   * @param {number[]} cposList
+   * @returns {Promise<number[]>}
+   */
+  async idsByCpos(cposList) {
+    return await this.client.api.cl_cpos2struc(this.apiName, cposList);
+  }
+
+  /**
+   * @param {number[]} cposList
+   * @returns {Promise<number[]>}
+   */
+  async lboundByCpos(cposList) {
+    return await this.client.api.cl_cpos2lbound(this.apiName, cposList);
+  }
+
+  /**
+   * @param {number[]} cposList
+   * @returns {Promise<number[]>}
+   */
+  async rboundByCpos(cposList) {
+    return await this.client.api.cl_cpos2rbound(this.apiName, cposList);
+  }
+
+  /**
+   * @param {number[]} idList
+   * @returns {Promise<string[]>}
+   */
+  async valuesByIds(idList) {
+    return await this.client.api.cl_struc2str(this.apiName, idList);
+  }
+};
+
+
+cqi.models.attributes.StructuralAttributeCollection = class StructuralAttributeCollection extends cqi.models.attributes.AttributeCollection {
+   /** @type{typeof cqi.models.attributes.StructuralAttribute} */
+  static model = cqi.models.attributes.StructuralAttribute;
+
+  /**
+   * @param {string} structuralAttributeName
+   * @returns {Promise<object>}
+   */
+  async _get(structuralAttributeName) {
+    let structuralAttribute = await super._get(structuralAttributeName);
+    structuralAttribute.has_values = await this.client.api.cl_has_values(structuralAttribute.api_name);
+    return structuralAttribute;
+  }
+
+  /**
+   * @returns {Promise<cqi.models.attributes.StructuralAttribute[]>}
+   */
+  async list() {
+    let structuralAttributeNames = await this.client.api.corpus_structural_attributes(this.corpus.apiName);
+    let structuralAttributes = [];
+    for (let structuralAttributeName of structuralAttributeNames) {
+      structuralAttributes.push(await this.get(structuralAttributeName));
+    }
+    return structuralAttributes;
+  }
+};
diff --git a/app/static/js/cqi/models/corpora.js b/app/static/js/cqi/models/corpora.js
new file mode 100644
index 0000000000000000000000000000000000000000..9af467d09db2fc996c3399f8e8b96b309362ca51
--- /dev/null
+++ b/app/static/js/cqi/models/corpora.js
@@ -0,0 +1,127 @@
+cqi.models.corpora = {};
+
+
+cqi.models.corpora.Corpus = class Corpus extends cqi.models.resource.Model {
+  /**
+   * @returns {string}
+   */
+  get apiName() {
+    return this.attrs.api_name;
+  }
+
+  /**
+   * @returns {string}
+   */
+  get name() {
+    return this.attrs.name;
+  }
+
+  /**
+   * @returns {number}
+   */
+  get size() {
+    return this.attrs.size;
+  }
+
+  /**
+   * @returns {string}
+   */
+  get charset() {
+    return this.attrs.charset;
+  }
+
+  /**
+   * @returns {string[]}
+   */
+  get properties() {
+    return this.attrs?.properties;
+  }
+
+  /**
+   * @returns {cqi.models.attributes.AlignmentAttributeCollection}
+   */
+  get alignment_attributes() {
+    return new cqi.models.attributes.AlignmentAttributeCollection(this.client, this);
+  }
+
+  /**
+   * @returns {cqi.models.attributes.PositionalAttributeCollection}
+   */
+  get positional_attributes() {
+    return new cqi.models.attributes.PositionalAttributeCollection(this.client, this);
+  }
+
+  /**
+   * @returns {cqi.models.attributes.StructuralAttributeCollection}
+   */
+  get structural_attributes() {
+    return new cqi.models.attributes.StructuralAttributeCollection(this.client, this);
+  }
+
+  /**
+   * @returns {cqi.models.subcorpora.SubcorpusCollection}
+   */
+  get subcorpora() {
+    return new cqi.models.subcorpora.SubcorpusCollection(this.client, this);
+  }
+
+  /**
+   * @returns {Promise<cqi.status.StatusOk>}
+   */
+  async drop() {
+    return await this.client.api.corpus_drop_corpus(this.apiName);
+  }
+
+  /**
+   * @param {string} subcorpusName
+   * @param {string} query
+   * @returns {Promise<cqi.status.StatusOk>}
+   */
+  async query(subcorpusName, query) {
+    return await this.client.api.cqp_query(this.apiName, subcorpusName, query);
+  }
+};
+
+
+cqi.models.corpora.CorpusCollection = class CorpusCollection extends cqi.models.resource.Collection {
+   /** @type {typeof cqi.models.corpora.Corpus} */
+  static model = cqi.models.corpora.Corpus;
+
+  /**
+   * @param {string} corpusName
+   * @returns {Promise<object>}
+   */
+  async _get(corpusName) {
+    return {
+      api_name: corpusName,
+      charset: await this.client.api.corpus_charset(corpusName),
+      // full_name: await this.client.api.corpus_full_name(api_name),
+      // info: await this.client.api.corpus_info(api_name),
+      name: corpusName,
+      properties: await this.client.api.corpus_properties(corpusName),
+      size: await this.client.api.cl_attribute_size(`${corpusName}.word`)
+    }
+  }
+
+  /**
+   * @param {string} corpusName
+   * @returns {Promise<cqi.models.corpora.Corpus>}
+   */
+  async get(corpusName) {
+    return this.prepareModel(await this._get(corpusName));
+  }
+
+  /**
+   * @returns {Promise<cqi.models.corpora.Corpus[]>}
+   */
+  async list() {
+     /** @type {string[]} */
+    let corpusNames = await this.client.api.corpus_list_corpora();
+     /** @type {cqi.models.corpora.Corpus[]} */
+    let corpora = [];
+    for (let corpusName of corpusNames) {
+      corpora.push(await this.get(corpusName));
+    }
+    return corpora;
+  }
+};
diff --git a/app/static/js/cqi/models/package.js b/app/static/js/cqi/models/package.js
new file mode 100644
index 0000000000000000000000000000000000000000..4973862f6400ffb628007d69705f02e52e628af6
--- /dev/null
+++ b/app/static/js/cqi/models/package.js
@@ -0,0 +1 @@
+cqi.models = {};
diff --git a/app/static/js/cqi/models/resource.js b/app/static/js/cqi/models/resource.js
new file mode 100644
index 0000000000000000000000000000000000000000..9d3afde3ac86edef4f4fd08739d6a31725272ee3
--- /dev/null
+++ b/app/static/js/cqi/models/resource.js
@@ -0,0 +1,90 @@
+cqi.models.resource = {};
+
+
+/**
+ * A base class for representing a single object on the server.
+ */
+cqi.models.resource.Model = class Model {
+  /**
+   * @param {object} attrs
+   * @param {cqi.CQiClient} client
+   * @param {cqi.models.resource.Collection} collection
+   */
+  constructor(attrs, client, collection) {
+     /**
+      * A client pointing at the server that this object is on.
+      *
+      * @type {cqi.CQiClient}
+      */
+    this.client = client;
+     /**
+      * The collection that this model is part of.
+      *
+      * @type {cqi.models.resource.Collection}
+      */
+    this.collection = collection;
+     /**
+      * The raw representation of this object from the API
+      *
+      * @type {object} 
+      */
+    this.attrs = attrs;
+  }
+
+  /**
+   * @returns {string}
+   */
+  get apiName() {
+    throw new Error('Not implemented');
+  }
+
+  /**
+   * @returns {Promise<void>}
+   */
+  async reload() {
+    this.attrs = await this.collection.get(this.apiName).attrs;
+  }
+};
+
+
+/**
+ * A base class for representing all objects of a particular type on the server.
+ */
+cqi.models.resource.Collection = class Collection {
+   /** 
+    * The type of object this collection represents, set by subclasses
+    * 
+    * @type {typeof cqi.models.resource.Model}
+    */
+  static model;
+
+  /**
+   * @param {cqi.CQiClient} client
+   */
+  constructor(client) {
+     /**
+      * A client pointing at the server that this object is on.
+      *
+      * @type {cqi.CQiClient}
+      */
+     this.client = client;
+  }
+
+  async list() {
+    throw new Error('Not implemented');
+  }
+
+  async get() {
+    throw new Error('Not implemented');
+  }
+
+  /**
+   * Create a model from a set of attributes.
+   * 
+   * @param {object} attrs
+   * @returns {cqi.models.resource.Model}
+   */
+  prepareModel(attrs) {
+    return new this.constructor.model(attrs, this.client, this);
+  }
+};
diff --git a/app/static/js/cqi/models/subcorpora.js b/app/static/js/cqi/models/subcorpora.js
new file mode 100644
index 0000000000000000000000000000000000000000..0890fc76e6ecc6774a2061a5016fbb119988e009
--- /dev/null
+++ b/app/static/js/cqi/models/subcorpora.js
@@ -0,0 +1,155 @@
+cqi.models.subcorpora = {};
+
+
+cqi.models.subcorpora.Subcorpus = class Subcorpus extends cqi.models.resource.Model {
+  /**
+   * @returns {string}
+   */
+  get apiName() {
+    return this.attrs.api_name;
+  }
+
+  /**
+   * @returns {object}
+   */
+  get fields() {
+    return this.attrs.fields;
+  }
+
+  /**
+   * @returns {string}
+   */
+  get name() {
+    return this.attrs.name;
+  }
+
+  /**
+   * @returns {number}
+   */
+  get size() {
+    return this.attrs.size;
+  }
+
+  /**
+   * @returns {Promise<cqi.status.StatusOk>}
+   */
+  async drop() {
+    return await this.client.api.cqp_drop_subcorpus(this.apiName);
+  }
+
+  /**
+   * @param {number} field
+   * @param {number} first
+   * @param {number} last
+   * @returns {Promise<number[]>}
+   */
+  async dump(field, first, last) {
+    return await this.client.api.cqp_dump_subcorpus(
+      this.apiName,
+      field,
+      first,
+      last
+    );
+  }
+
+  /**
+   * @param {number} cutoff
+   * @param {number} field
+   * @param {cqi.models.attributes.PositionalAttribute} attribute
+   * @returns {Promise<number[]>}
+   */
+  async fdist1(cutoff, field, attribute) {
+    return await this.client.api.cqp_fdist_1(
+      this.apiName,
+      cutoff,
+      field,
+      attribute.apiName
+    );
+  }
+
+  /**
+   * @param {number} cutoff
+   * @param {number} field1
+   * @param {cqi.models.attributes.PositionalAttribute} attribute1
+   * @param {number} field2
+   * @param {cqi.models.attributes.PositionalAttribute} attribute2
+   * @returns {Promise<number[]>}
+   */ 
+  async fdist2(cutoff, field1, attribute1, field2, attribute2) {
+    return await this.client.api.cqp_fdist_2(
+      this.apiName,
+      cutoff,
+      field1,
+      attribute1.apiName,
+      field2,
+      attribute2.apiName
+    );
+  }
+};
+
+
+cqi.models.subcorpora.SubcorpusCollection = class SubcorpusCollection extends cqi.models.resource.Collection {
+   /** @type {typeof cqi.models.subcorpora.Subcorpus} */
+  static model = cqi.models.subcorpora.Subcorpus;
+
+  /**
+   * @param {cqi.CQiClient} client
+   * @param {cqi.models.corpora.Corpus} corpus
+   */
+  constructor(client, corpus) {
+    super(client);
+     /** @type {cqi.models.corpora.Corpus} */
+    this.corpus = corpus;
+  }
+
+  /**
+   * @param {string} subcorpusName
+   * @returns {Promise<object>}
+   */
+  async _get(subcorpusName) {
+     /** @type {string} */
+    let apiName = `${this.corpus.apiName}:${subcorpusName}`;
+     /** @type {object} */
+    let fields = {};
+    if (await this.client.api.cqp_subcorpus_has_field(apiName, cqi.CONST_FIELD_MATCH)) {
+      fields.match = cqi.CONST_FIELD_MATCH;
+    }
+    if (await this.client.api.cqp_subcorpus_has_field(apiName, cqi.CONST_FIELD_MATCHEND)) {
+      fields.matchend = cqi.CONST_FIELD_MATCHEND
+    }
+    if (await this.client.api.cqp_subcorpus_has_field(apiName, cqi.CONST_FIELD_TARGET)) {
+      fields.target = cqi.CONST_FIELD_TARGET
+    }
+    if (await this.client.api.cqp_subcorpus_has_field(apiName, cqi.CONST_FIELD_KEYWORD)) {
+      fields.keyword = cqi.CONST_FIELD_KEYWORD
+    }
+    return {
+      api_name: apiName,
+      fields: fields,
+      name: subcorpusName,
+      size: await this.client.api.cqp_subcorpus_size(apiName)
+    }
+  }
+
+  /**
+   * @param {string} subcorpusName
+   * @returns {Promise<cqi.models.subcorpora.Subcorpus>}
+   */
+  async get(subcorpusName) {
+    return this.prepareModel(await this._get(subcorpusName));
+  }
+
+  /**
+   * @returns {Promise<cqi.models.subcorpora.Subcorpus[]>}
+   */
+  async list() {
+     /** @type {string[]} */
+    let subcorpusNames = await this.client.api.cqp_list_subcorpora(this.corpus.apiName);
+     /** @type {cqi.models.subcorpora.Subcorpus[]} */
+    let subcorpora = [];
+    for (let subcorpusName of subcorpusNames) {
+      subcorpora.push(await this.get(subcorpusName));
+    }
+    return subcorpora;
+  }
+};
diff --git a/app/static/js/cqi/package.js b/app/static/js/cqi/package.js
new file mode 100644
index 0000000000000000000000000000000000000000..1558b3081a91a759a85f7e367213d2aa0aeda1f3
--- /dev/null
+++ b/app/static/js/cqi/package.js
@@ -0,0 +1,6 @@
+var cqi = {};
+
+cqi.CONST_FIELD_KEYWORD = 9;
+cqi.CONST_FIELD_MATCH = 16;
+cqi.CONST_FIELD_MATCHEND = 17;
+cqi.CONST_FIELD_TARGET = 0;
diff --git a/app/static/js/cqi/status.js b/app/static/js/cqi/status.js
new file mode 100644
index 0000000000000000000000000000000000000000..0782ee26e29b190f02921575a8f84c927589fd57
--- /dev/null
+++ b/app/static/js/cqi/status.js
@@ -0,0 +1,51 @@
+cqi.status = {};
+
+
+/**
+ * A base class from which all other status inherit.
+ */
+cqi.status.CQiStatus = class CQiStatus {
+  constructor() {
+    this.code = undefined;
+  }
+};
+
+
+cqi.status.StatusOk = class StatusOk extends cqi.status.CQiStatus {
+  constructor() {
+    super();
+    this.code = 257;
+  }
+};
+
+
+cqi.status.StatusConnectOk = class StatusConnectOk extends cqi.status.CQiStatus {
+  constructor() {
+    super();
+    this.code = 258;
+  }
+};
+
+
+cqi.status.StatusByeOk = class StatusByeOk extends cqi.status.CQiStatus {
+  constructor() {
+    super();
+    this.code = 259;
+  }
+};
+
+
+cqi.status.StatusPingOk = class StatusPingOk extends cqi.status.CQiStatus {
+  constructor() {
+    super();
+    this.code = 260;
+  }
+};
+
+
+cqi.status.lookup = {
+  257: cqi.status.StatusOk,
+  258: cqi.status.StatusConnectOk,
+  259: cqi.status.StatusByeOk,
+  260: cqi.status.StatusPingOk
+};
diff --git a/app/templates/_scripts.html.j2 b/app/templates/_scripts.html.j2
index 78ebd9f71fdf1b0008bab4d2bddfaaf5b4cb1e4a..04728ce0a4c43df03d4c8c8086016162917e531f 100644
--- a/app/templates/_scripts.html.j2
+++ b/app/templates/_scripts.html.j2
@@ -3,6 +3,23 @@
 <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.5.4/socket.io.min.js" integrity="sha512-HTENHrkQ/P0NGDFd5nk6ibVtCkcM7jhr2c7GyvXp5O+4X6O5cQO9AhqFzM+MdeBivsX7Hoys2J7pp2wdgMpCvw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
 <script src="https://cdnjs.cloudflare.com/ajax/libs/plotly.js/2.24.2/plotly.min.js" integrity="sha512-dAXqGCq94D0kgLSPnfvd/pZpCMoJQpGj2S2XQmFQ9Ay1+96kbjss02ISEh+TBNXMggGg/1qoMcOHcxg+Op/Jmw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
 
+{%- assets
+  filters='rjsmin',
+  output='gen/cqi.%(version)s.js',
+  'js/cqi/package.js',
+  'js/cqi/errors.js',
+  'js/cqi/status.js',
+  'js/cqi/api/package.js',
+  'js/cqi/api/client.js',
+  'js/cqi/models/package.js',
+  'js/cqi/models/resource.js',
+  'js/cqi/models/attributes.js',
+  'js/cqi/models/subcorpora.js',
+  'js/cqi/models/corpora.js',
+  'js/cqi/client.js'
+%}
+<script src="{{ ASSET_URL }}"></script>
+{%- endassets %}
 {%- assets
   filters='rjsmin',
   output='gen/app.%(version)s.js',