diff --git a/Manifest.toml b/Manifest.toml
index cf255d7a031755ab63cb7eef5552258efa33f301..af56fc636a1f1a66302a588746ca80d003f3e614 100644
--- a/Manifest.toml
+++ b/Manifest.toml
@@ -1,8 +1,8 @@
 # This file is machine-generated - editing it directly is not advised
 
-julia_version = "1.10.8"
+julia_version = "1.10.9"
 manifest_format = "2.0"
-project_hash = "029a32b6e21d0f2c52e6dccd0a009e9681bdff18"
+project_hash = "51a3effe31474c33a1cea491c41e13148b8635d0"
 
 [[deps.AbstractFFTs]]
 deps = ["LinearAlgebra"]
@@ -503,10 +503,10 @@ uuid = "0234f1f7-429e-5d53-9886-15a909be8d59"
 version = "1.14.2+1"
 
 [[deps.HTTP]]
-deps = ["Base64", "CodecZlib", "ConcurrentUtilities", "Dates", "ExceptionUnwrapping", "Logging", "LoggingExtras", "MbedTLS", "NetworkOptions", "OpenSSL", "Random", "SimpleBufferStream", "Sockets", "URIs", "UUIDs"]
-git-tree-sha1 = "1336e07ba2eb75614c99496501a8f4b233e9fafe"
+deps = ["Base64", "CodecZlib", "ConcurrentUtilities", "Dates", "ExceptionUnwrapping", "Logging", "LoggingExtras", "MbedTLS", "NetworkOptions", "OpenSSL", "PrecompileTools", "Random", "SimpleBufferStream", "Sockets", "URIs", "UUIDs"]
+git-tree-sha1 = "c67b33b085f6e2faf8bf79a61962e7339a81129c"
 uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3"
-version = "1.10.10"
+version = "1.10.15"
 
 [[deps.HarfBuzz_jll]]
 deps = ["Artifacts", "Cairo_jll", "Fontconfig_jll", "FreeType2_jll", "Glib_jll", "Graphite2_jll", "JLLWrappers", "Libdl", "Libffi_jll"]
@@ -848,6 +848,11 @@ git-tree-sha1 = "1d2dd9b186742b0f317f2530ddcbf00eebb18e96"
 uuid = "23992714-dd62-5051-b70f-ba57cb901cac"
 version = "0.10.7"
 
+[[deps.MIMEs]]
+git-tree-sha1 = "1833212fd6f580c20d4291da9c1b4e8a655b128e"
+uuid = "6c6e2e6c-3030-632d-7369-2d6c69616d65"
+version = "1.0.0"
+
 [[deps.MKL_jll]]
 deps = ["Artifacts", "IntelOpenMP_jll", "JLLWrappers", "LazyArtifacts", "Libdl", "oneTBB_jll"]
 git-tree-sha1 = "f046ccd0c6db2832a9f639e2c669c6fe867e5f4f"
@@ -1025,7 +1030,7 @@ version = "3.2.4+0"
 [[deps.OpenLibm_jll]]
 deps = ["Artifacts", "Libdl"]
 uuid = "05823500-19ac-5b8b-9628-191a04bc5112"
-version = "0.8.1+2"
+version = "0.8.1+4"
 
 [[deps.OpenMPI_jll]]
 deps = ["Artifacts", "CompilerSupportLibraries_jll", "Hwloc_jll", "JLLWrappers", "LazyArtifacts", "Libdl", "MPIPreferences", "TOML", "Zlib_jll"]
@@ -1062,6 +1067,12 @@ git-tree-sha1 = "dfdf5519f235516220579f949664f1bf44e741c5"
 uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d"
 version = "1.6.3"
 
+[[deps.Oxygen]]
+deps = ["DataStructures", "Dates", "HTTP", "JSON3", "MIMEs", "Reexport", "RelocatableFolders", "Requires", "Sockets", "Statistics", "StructTypes"]
+git-tree-sha1 = "2ad010b0de6172faf1d09ed5e0837eb0b7355bd8"
+uuid = "df9a0d86-3283-4920-82dc-4555fc0d1d8b"
+version = "1.5.16"
+
 [[deps.PCRE2_jll]]
 deps = ["Artifacts", "Libdl"]
 uuid = "efcefdf7-47ab-520b-bdef-62a2eaa19f15"
diff --git a/Project.toml b/Project.toml
index 60848ef53384d300254e240b3260af26052804df..4707dabd64ff184de9b7d7c20e66c91d37e4f4e1 100644
--- a/Project.toml
+++ b/Project.toml
@@ -9,6 +9,8 @@ Colors = "5ae59095-9a9b-59fe-a467-6f913c188581"
 Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
 DocOpt = "968ba79b-81e4-546f-ab3a-2eecfa62a9db"
 Format = "1fa38f19-a742-5d3f-a2b9-30dd87b9d5f8"
+HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
+JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
 LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
 Logging = "56ddb016-857b-54e1-b83d-db4d58db5568"
 Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a"
@@ -18,6 +20,7 @@ NyxWidgets = "c288fd06-43d3-4b04-8307-797133353e2e"
 Observables = "510215fc-4207-5dde-b226-833fc4488ee2"
 ObservationPolicies = "6317928a-6b1a-42e8-b853-b8e2fc3e9ca3"
 OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d"
+Oxygen = "df9a0d86-3283-4920-82dc-4555fc0d1d8b"
 PlanarLarvae = "c2615984-ef14-4d40-b148-916c85b43307"
 Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
 StaticArrays = "90137ffa-7385-5640-81b9-e52037218182"
diff --git a/README.md b/README.md
index 1b9a1b286b9ae0235f27bf794435e79375c3edfd..9996377e17efce0fab5afc88c784e9a5affc5836 100644
--- a/README.md
+++ b/README.md
@@ -213,19 +213,6 @@ The `--browser` argument may open a new tab in your web browser, but this featur
 
 The first time the application is loaded, it may take a while for a window in your web browser to open, and the data to be plotted.
 
-### From the *Julia* interpreter
-
-As an alternative to the *larvatagger* script or command, in the *LarvaTagger* directory created above, launch the *Julia* interpreter:
-```
-julia --project=.
-```
-In the interpreter, to launch the editor, type:
-```
-julia> using LarvaTagger; display(larvaeditor("path/to/data/file"))
-```
-
-To exit the interpreter, type `exit()` or press Ctrl+D.
-
 ### macOS
 
 On some computers (typically macOS computers), the 2D larva view may show up twice as small as expected.
diff --git a/src/LarvaTagger.jl b/src/LarvaTagger.jl
index 00d344cc349bbf9d23082cd6168c350f5e7cb788..5c96f8ecf48b8737b3f44c59f0dea6a957975ceb 100644
--- a/src/LarvaTagger.jl
+++ b/src/LarvaTagger.jl
@@ -32,6 +32,8 @@ include("players.jl")
 include("controllers.jl")
 include("Taggers.jl")
 using .Taggers
+include("REST/REST.jl")
+using .REST
 include("files.jl")
 include("backends.jl")
 include("cli_base.jl")
diff --git a/src/REST/Client.jl b/src/REST/Client.jl
new file mode 100644
index 0000000000000000000000000000000000000000..61814f43a12209c35a7e6ea2576db718e79a5b67
--- /dev/null
+++ b/src/REST/Client.jl
@@ -0,0 +1,225 @@
+module Client
+
+using PlanarLarvae.Datasets
+import ..Taggers: Taggers, Tagger
+import HTTP: HTTP
+using JSON3
+using OrderedCollections: OrderedDict
+using Observables
+
+export RemoteTagger, LTBackend, connect, listmodels, active_model_instance
+
+mutable struct RemoteTagger
+    endpoint::AbstractString
+    token::AbstractString # empty if not connected
+    backend::AbstractString
+    model_instance::AbstractString
+    output_filenames::Dict{String, String} # renaming rules
+end
+
+function RemoteTagger(endpoint, backend, model_instance)
+    RemoteTagger(endpoint, "", backend, model_instance, Dict{String, String}())
+end
+
+connected(tagger) = !isempty(tagger.token)
+
+function connect!(tagger)
+    tagger.token = gettoken(tagger)
+    return tagger
+end
+
+function gettoken(tagger)
+    if isempty(tagger.token)
+        resp = HTTP.get("$(tagger.endpoint)/get-token/$(tagger.backend)/$(tagger.model_instance)")
+        transcode(String, resp.body)
+    else
+        tagger.token
+    end
+end
+
+function url(tagger::RemoteTagger, switch)
+    token = tagger.token
+    @assert !isnothing(token)
+    return "$(tagger.endpoint)/$switch/$(tagger.backend)/$(tagger.model_instance)/$token"
+end
+
+function Base.close(tagger::RemoteTagger)
+    HTTP.get(url(tagger, "close"))
+    tagger.token = ""
+end
+
+function listfiles(tagger::RemoteTagger, srcdir::String)
+    resp = HTTP.get("$(url(tagger, "list-files"))/$srcdir")
+    @info "listfiles" transcode(String, resp.body)
+    String[]
+end
+
+function Taggers.pull(tagger::RemoteTagger, destdir::String)
+    cmd = url(tagger, "pull-file")
+    # destdir can be empty on macOS
+    destdir = isempty(destdir) ? pwd() : realpath(destdir) # strip end slash
+    destfiles = String[]
+    for filename in listfiles(tagger, "processed")
+        resp = HTTP.get("$cmd/$filename")
+        destfile = joinpath(destdir, filename)
+        open(destfile, "w") do f
+            write(f, resp.body)
+        end
+        push!(destfiles, destfile)
+    end
+    return destfiles
+end
+
+function Taggers.resetdata(tagger::RemoteTagger, dir=nothing)
+    query = url(tagger, "reset-data")
+    if !isnothing(dir)
+        query = "$query/$dir"
+    end
+    HTTP.get(query)
+end
+
+function Taggers.pushfile(tagger::RemoteTagger, src, dst)
+    @assert dst == basename(src)
+    Taggers.pushfile(tagger, src)
+end
+
+function Taggers.pushfile(tagger::RemoteTagger, src)
+    request = url(tagger, "push-file")
+    @info "dummy push-file" src
+end
+
+function Taggers.push(tagger::RemoteTagger, file::String, metadata; clean=true)
+    clean ? Taggers.resetdata(tagger) : Taggers.resetdata(tagger, "raw")
+    Taggers.push(tagger, file)
+    if !isnothing(metadata)
+        mktempdir() do dir
+            metadatafile = joinpath(dir, "metadata")
+            Datasets.to_json_file(metadatafile, metadata)
+            Taggers.pushfile(tagger, metadatafile)
+        end
+    end
+end
+
+Taggers.run(tagger::RemoteTagger, switch) = HTTP.get(url(tagger, switch); retry=false)
+
+Taggers.predict(tagger::RemoteTagger) = Taggers.run(tagger, "predict")
+
+Taggers.embed(tagger::RemoteTagger) = Taggers.run(tagger, "embed")
+
+
+struct LTBackend
+    endpoint
+    metadata
+    taggers
+    active_tagging_backend
+    active_tagger
+end
+
+function LTBackend(endpoint)
+    metadata = Dict{String, OrderedDict{Symbol, Union{String, Dict{String, OrderedDict{Symbol, String}}}}}()
+    taggers = OrderedDict{String, OrderedDict{String, RemoteTagger}}()
+    active_tagging_backend = Observable{Union{Nothing, String}}(nothing)
+    active_tagger = Observable{Union{Nothing, RemoteTagger}}(nothing)
+    on(active_tagging_backend) do tagging_backend
+        if isnothing(tagging_backend)
+            active_tagger[] = nothing
+        elseif isnothing(active_tagger[]) || active_tagger[].backend != tagging_backend
+            active_tagger[] = first(values(taggers[tagging_backend]))
+        else
+            notify(active_tagger)
+        end
+    end
+    on(active_tagger) do tagger
+        if !isnothing(tagger)
+            @info "Tagger selected" backend=tagger.backend instance=tagger.model_instance
+            if tagger.backend != active_tagging_backend[]
+                active_tagging_backend[] = tagger.backend
+            end
+        end
+    end
+    LTBackend(endpoint, metadata, taggers, active_tagging_backend, active_tagger)
+end
+
+function active_model_instance(back::LTBackend)
+    obs = Observable{Union{Nothing, String}}(nothing)
+    active_tagger = back.active_tagger
+    on(active_tagger) do tagger
+        obs[] = isnothing(tagger) ? nothing : tagger.model_instance
+    end
+    on(obs) do model_instance
+        tagger = active_tagger[]
+        if model_instance != tagger.model_instance
+            active_tagger[] = back.taggers[tagger.backend][model_instance]
+        end
+    end
+    return obs
+end
+
+function connect(back::LTBackend; refresh_rate=0.5, preselect_tagger=false)
+    while true
+        resp = HTTP.get("$(back.endpoint)/status")
+        resp = transcode(String, resp.body)
+        if resp == "up"
+            break
+        else
+            sleep(refresh_rate)
+        end
+    end
+    listtaggers(back)
+    #@info "listtaggers" back.taggers back.metadata
+    if preselect_tagger
+        back.active_tagging_backend[] = first(collect(keys(back.taggers)))
+    end
+end
+
+function simpleconvert(json::JSON3.Object)
+    OrderedDict(key => simpleconvert(val) for (key, val) in pairs(json))
+end
+
+simpleconvert(json::JSON3.Array) = simpleconvert.(json)
+
+simpleconvert(val) = val
+
+function listtaggers(back::LTBackend)
+    endpoint = back.endpoint
+    resp = HTTP.get("$(endpoint)/list-taggers")
+    resp = transcode(String, resp.body)
+    json = JSON3.read(resp)
+    @assert json isa JSON3.Array
+    taggers = simpleconvert(json)
+    for tagger in taggers
+        tagging_backend = tagger[:name]
+        back.metadata[tagging_backend] = OrderedDict(
+            key => (key === :models ? Dict(model[:name] => OrderedDict(
+                key′=> val′ for (key′, val′) in pairs(model) if key′!== :name)
+                for model in val) : val)
+            for (key, val) in pairs(tagger) if key !== :name)
+        back.taggers[tagging_backend] = OrderedDict(
+            model[:name] => RemoteTagger(endpoint, tagging_backend, model[:name])
+            for model in tagger[:models])
+    end
+    return taggers
+end
+
+listmodels(back::LTBackend) = listmodels(back, Val(false))
+
+listmodels(back::LTBackend, ::Val{false}) = collect(keys(back.taggers))
+
+function listmodels(back::LTBackend, ::Val{true})
+    map(back.active_tagging_backend) do tagging_backend
+        isnothing(tagging_backend) ? String[] : collect(keys(back.taggers[tagging_backend]))
+    end
+end
+
+function Taggers.predict(back::LTBackend, file::String; metadata=nothing)
+    tagger = back.active_tagger[]
+    isnothing(tagger) && throw("no active tagger")
+    connected(tagger) || connect!(tagger)
+    Taggers.push(tagger, file, metadata)
+    Taggers.predict(tagger)
+    labelfile = Taggers.pull(tagger, dirname(file))
+    @assert length(labelfile) == 1
+    return labelfile[1]
+end
+
+end
diff --git a/src/REST/Model.jl b/src/REST/Model.jl
new file mode 100644
index 0000000000000000000000000000000000000000..781ce7ab71a807fd8c48344d98bf8c329139b3ba
--- /dev/null
+++ b/src/REST/Model.jl
@@ -0,0 +1,224 @@
+module Model
+
+import ..Taggers: Taggers, Tagger
+import HTTP: HTTP
+import JSON3
+using OrderedCollections: OrderedDict
+
+export LTBackend, gettoken, resetdata, listfiles, pushfile, pullfile, listtaggers, predict,
+       embed
+
+# pure part of the Server module (no global state)
+
+struct LTBackend
+    root
+    tokens
+    lock
+end
+
+function LTBackend()
+    root = Ref{AbstractString}("")
+    tokens = Dict{String, Dict{String, Dict{String, Float64}}}()
+    lock = ReentrantLock()
+    LTBackend(root, tokens, lock)
+end
+
+Base.lock(f::Function, backend::LTBackend) = lock(f, backend.lock)
+
+Base.isready(backend::LTBackend) = isdir(backend.root[])
+
+get!(dict::AbstractDict{K, V}, key::K) where {K, V} = Base.get!(dict, key, V())
+
+function get!(dict::AbstractDict{K, D}, key1::K, key2, keys...) where {K, D}
+    get!(get!(dict, key1), key2, keys...)
+end
+
+function gettagger(lt_backend, tagging_backend_dir, model_instance)
+    @assert isready(lt_backend)
+    tagging_backend_path = joinpath(lt_backend.root[], tagging_backend_dir)
+    @assert Taggers.isbackend(tagging_backend_path)
+    lock(lt_backend) do
+        tagger = Taggers.isolate(Tagger(tagging_backend_path, model_instance))
+        token = tagger.sandbox
+        tokens = get!(lt_backend.tokens, tagging_backend_dir, model_instance)
+        @assert token ∉ keys(tokens)
+        tokens[token] = time()
+        return tagger
+    end
+end
+
+function gettagger(lt_backend, tagging_backend_dir, model_instance, token)
+    @assert isready(lt_backend)
+    lock(lt_backend) do
+        tokens = lt_backend.tokens
+        @assert tagging_backend_dir in keys(tokens)
+        tokens = lt_backend.tokens[tagging_backend_dir]
+        @assert model_instance in keys(tokens)
+        tokens = tokens[model_instance]
+        @assert token in keys(tokens)
+    end
+    tagging_backend_path = joinpath(lt_backend.root[], tagging_backend_dir)
+    tagger = Tagger(tagging_backend_path, model_instance, token)
+    return tagger
+end
+
+##
+
+function gettoken(lt_backend, backend_dir, model_instance)
+    tagger = gettagger(lt_backend, backend_dir, model_instance)
+    return tagger.sandbox
+end
+
+function Base.close(lt_backend::LTBackend, backend_dir, model_instance, token)
+    tagger = gettagger(lt_backend, backend_dir, model_instance, token)
+    Taggers.removedata(tagger)
+    lock(lt_backend) do
+        pop!(lt_backend.tokens[backend_dir][model_instance], token)
+    end
+    nothing
+end
+
+function resetdata(lt_backend, backend_dir, model_instance, token, datadir=nothing)
+    tagger = gettagger(lt_backend, backend_dir, model_instance, token)
+    if isnothing(datadir)
+        Taggers.resetdata(tagger)
+    else
+        Taggers.resetdata(tagger, datadir)
+    end
+    nothing
+end
+
+function listfiles(lt_backend, backend_dir, model_instance, token, data_dir)
+    tagger = gettagger(lt_backend, backend_dir, model_instance, token)
+    dir = Taggers.datadir(tagger, data_dir)
+    ls = []
+    for (parent, _, files) in walkdir(dir; follow_symlinks=true)
+        if parent == dir
+            append!(ls, files)
+        else
+            parent = relpath(parent, dir)
+            for file in files
+                push!(ls, joinpath(parent, file))
+            end
+        end
+    end
+    isempty(ls) ? "[]" : "[\"" * join(ls, "\", \"") * "\"]"
+end
+
+"""
+    pushfile(::LTBackend, ::HTTP.Request, backend_dir, model_instance, token)
+
+Handle `push-file` queries, *i.e.* receive file.
+"""
+function pushfile(lt_backend, request, backend_dir, model_instance, token)
+    tagger = gettagger(lt_backend, backend_dir, model_instance, token)
+    @assert request.method == "POST"
+    body = request.body
+    @assert body isa Vector{UInt8}
+    k = findfirst(c -> c in (0x0d, 0x0a), body) - 1
+    filesep = body[1:k]
+    linesep = if body[k+2] == 0x0a
+        @assert body[k+1] == 0x0d
+        body[k+1:k+2]
+    else
+        body[k+1]
+    end
+    filesep = vcat(linesep, filesep)
+    n = length(filesep)
+    dk = length(linesep)
+    bodies = Vector{UInt8}[]
+    while k < length(body)
+        i, j = 1, k + dk + 1
+        for outer k in j:length(body)
+            if body[k] == filesep[i]
+                i += 1
+                if n < i
+                    push!(bodies, body[j:k-n])
+                    break
+                end
+            else
+                i = 1
+            end
+        end
+    end
+    linesep = transcode(String, linesep)
+    for body in bodies
+        k = HTTP.Parsers.find_end_of_header(body)
+        @assert 0 < k
+        rawheader = transcode(String, body[1:k-2dk])
+        content = body[k+1:end]
+        @debug "push-file" rawheader
+        header = Dict{Symbol, AbstractString}()
+        for line in split(rawheader, linesep)
+            if isempty(header)
+                for pair in split(line, "; ")
+                    parts = split(pair, '"')
+                    if length(parts) == 1
+                        key, val = split(pair, ": ")
+                        header[Symbol(key)] = val
+                    else
+                        key = parts[1]
+                        @assert endswith(key, '=')
+                        key = Symbol(key[1:end-1])
+                        val = join(parts[2:end-1], '"')
+                        header[key] = val
+                    end
+                end
+            else
+                @assert startswith(line, "Content-Type: ")
+                key, val = split(line, ": ")
+                header[Symbol(key)] = val
+            end
+        end
+        @info "push-file" header length(content)
+        filename = header[:filename]
+        dest = joinpath(Taggers.datadir(tagger, "raw"), filename)
+        open(dest, "w") do f
+            write(f, content)
+        end
+    end
+end
+
+function pullfile(lt_backend, backend_dir, model_instance, token, filename)
+    tagger = gettagger(lt_backend, backend_dir, model_instance, token)
+    src = joinpath(Taggers.datadir(tagger, "processed"), filename)
+    header = Dict("Content-Disposition" => "form-data")
+    body = open(src)
+    return HTTP.Response(200, header, body)
+end
+
+function listtaggers(lt_backend)
+    inventory = Vector{OrderedDict{String, Any}}()
+    backends_dir = lt_backend.root[]
+    for tagging_backend in readdir(backends_dir)
+        tagging_backend_path = joinpath(backends_dir, tagging_backend)
+        Taggers.isbackend(tagging_backend_path) || continue
+        models_dir = joinpath(tagging_backend_path, "models")
+        models = [OrderedDict(
+            "name" => dir,
+            "description" => "",
+            "homepage" => "",
+        ) for dir in readdir(models_dir) if isdir(joinpath(models_dir, dir))]
+        push!(inventory, OrderedDict(
+            "name" => tagging_backend,
+            "description" => "",
+            "homepage" => "",
+            "models" => models,
+        ))
+    end
+    return JSON3.write(inventory)
+end
+
+function predict(lt_backend, backend_dir, model_instance, token)
+    tagger = gettagger(lt_backend, backend_dir, model_instance, token)
+    # blocking; should we run async and expose a token-specific status api call?
+    Taggers.predict(tagger; skip_make_dataset=true)
+end
+
+function embed(lt_backend, backend_dir, model_instance, token)
+    tagger = gettagger(lt_backend, backend_dir, model_instance, token)
+    # blocking, like predict
+    Taggers.embed(tagger; skip_make_dataset=true)
+end
+
+end
diff --git a/src/REST/REST.jl b/src/REST/REST.jl
new file mode 100644
index 0000000000000000000000000000000000000000..e4465b9d98c337db5c2c52c5c7aa6e7394c87a06
--- /dev/null
+++ b/src/REST/REST.jl
@@ -0,0 +1,8 @@
+module REST
+
+using ..Taggers
+
+include("Server.jl")
+include("Client.jl")
+
+end
diff --git a/src/REST/Server.jl b/src/REST/Server.jl
new file mode 100644
index 0000000000000000000000000000000000000000..4bd2bf57fe951ea4acd3d8f00a1b6b403b06bdca
--- /dev/null
+++ b/src/REST/Server.jl
@@ -0,0 +1,130 @@
+module Server
+
+using Oxygen; @oxidise
+import ..Taggers
+
+include("Model.jl")
+using .Model
+
+export run_backend
+
+function run_backend(backend::LTBackend; async=false, port=9285, kwargs...)
+    @assert isready(backend)
+    serve(; async=async, port=port, kwargs...)
+end
+
+# the Oxygen module has global state; as a consequence, the server must also have global
+# state
+const lt_backend = LTBackend()
+
+function run_backend(root::AbstractString; kwargs...)
+    lt_backend.root[] = root
+    run_backend(; kwargs...)
+end
+
+run_backend(; kwargs...) = run_backend(lt_backend; kwargs...)
+
+
+@get "/status" function(request)
+    return "up"
+end
+
+
+@get "/get-token/{backend_dir}/{model_instance}" function(
+        request,
+        backend_dir::String,
+        model_instance::String,
+    )
+    gettoken(lt_backend, backend_dir, model_instance)
+end
+
+
+@get "/close/{backend_dir}/{model_instance}/{token}" function(
+        request,
+        backend_dir::String,
+        model_instance::String,
+        token::String,
+    )
+    close(lt_backend, backend_dir, model_instance, token)
+end
+
+
+@get "/reset-data/{backend_dir}/{model_instance}/{token}" function(
+        request,
+        backend_dir::String,
+        model_instance::String,
+        token::String,
+    )
+    resetdata(lt_backend, backend_dir, model_instance, token)
+end
+
+
+@get "/reset-data/{backend_dir}/{model_instance}/{token}/{data_dir}" function(
+        request,
+        backend_dir::String,
+        model_instance::String,
+        token::String,
+        data_dir::String,
+    )
+    resetdata(lt_backend, backend_dir, model_instance, token, data_dir)
+end
+
+
+@get "/list-files/{backend_dir}/{model_instance}/{token}/{data_dir}" function(
+        request,
+        backend_dir::String,
+        model_instance::String,
+        token::String,
+        data_dir::String,
+    )
+    listfiles(lt_backend, backend_dir, model_instance, token, data_dir)
+end
+
+
+@post "/push-file/{backend_dir}/{model_instance}/{token}" function(
+        request,
+        backend_dir::String,
+        model_instance::String,
+        token::String,
+    )
+    pushfile(lt_backend, request, backend_dir, model_instance, token)
+end
+
+
+@get "/pull-file/{backend_dir}/{model_instance}/{token}/{filename}" function(
+        request,
+        backend_dir::String,
+        model_instance::String,
+        token::String,
+        filename::String,
+    )
+    pullfile(lt_backend, backend_dir, model_instance, token, filename)
+end
+
+
+@get "/list-taggers" function(request)
+    listtaggers(lt_backend)
+end
+
+
+@get "/predict/{backend_dir}/{model_instance}/{token}" function(
+        request,
+        backend_dir::String,
+        model_instance::String,
+        token::String,
+    )
+    predict(lt_backend, backend_dir, model_instance, token)
+end
+
+
+@get "/embed/{backend_dir}/{model_instance}/{token}" function(
+        request,
+        backend_dir::String,
+        model_instance::String,
+        token::String,
+    )
+    embed(lt_backend, backend_dir, model_instance, token)
+end
+
+
+end
diff --git a/src/Taggers.jl b/src/Taggers.jl
index b66ef40de5e133bc456ed95eedb44ec27a99e826..e7864cca49f69ca21cfcb13371bec6d006b39733 100644
--- a/src/Taggers.jl
+++ b/src/Taggers.jl
@@ -11,14 +11,15 @@ struct Tagger
     output_filenames::Dict{String, String}
 end
 
-function Tagger(backend_dir::String, model_instance::String)
-    Tagger(backend_dir, model_instance, nothing, Dict{String, String}())
+function Tagger(backend_dir::String, model_instance::String,
+        sandbox::Union{Nothing, String}=nothing)
+    Tagger(backend_dir, model_instance, sandbox, Dict{String, String}())
 end
 Tagger(backend_dir, model_instance) = Tagger(string(backend_dir), string(model_instance))
 
 function isolate(tagger)
     rawdatadir = joinpath(tagger.backend_dir, "data", "raw")
-    mkdir(rawdatadir)
+    isdir(rawdatadir) || mkpath(rawdatadir)
     rawdatadir = mktempdir(rawdatadir; cleanup=false)
     Tagger(tagger.backend_dir, tagger.model_instance, basename(rawdatadir),
            tagger.output_filenames)
@@ -50,15 +51,15 @@ tagging_backend_command(tagger::Tagger) = tagging_backend_command(tagger.backend
 
 modeldir(tagger::Tagger) = joinpath(tagger.backend_dir, "models", tagger.model_instance)
 
-datadir(tagger::Tagger, stage::String) = joinpath(tagger.backend_dir, "data", stage,
+datadir(tagger::Tagger, stage) = joinpath(tagger.backend_dir, "data", stage,
         something(tagger.sandbox, tagger.model_instance))
 
-function reset(tagger::Tagger)
+function reset(tagger)
     resetmodel(tagger)
     resetdata(tagger)
 end
 
-function reset(dir::String)
+function reset(dir::AbstractString)
     try
         rm(dir; recursive=true)
     catch
@@ -69,13 +70,13 @@ end
 
 resetmodel(tagger::Tagger) = reset(modeldir(tagger))
 
-function resetdata(tagger::Tagger)
+function resetdata(tagger)
     for dir in ("raw", "interim", "processed")
         resetdata(tagger, dir)
     end
 end
 
-resetdata(tagger::Tagger, dir::String) = reset(datadir(tagger, dir))
+resetdata(tagger::Tagger, dir) = reset(datadir(tagger, dir))
 
 function removedata(tagger::Tagger)
     for dir in ("raw", "interim", "processed")
@@ -166,6 +167,72 @@ function push(tagger::Tagger, inputdata::String)
     return destination
 end
 
+## new implementation for push
+
+function pushfile(tagger, src, dst)
+    backend_name = basename(realpath(tagger.backend_dir))
+    @info "Pushing file to backend" backend=backend_name instance=tagger.model_instance src dst
+    src = normpath(src)
+    dst = normpath(joinpath(datadir(tagger, "raw"), dst))
+    if dst != src
+        dstdir = dirname(dst)
+        mkpath(dstdir)
+        open(src, "r") do f
+            open(dst, "w") do g
+                write(g, read(f))
+            end
+        end
+    end
+    return dst
+end
+
+pushfile(tagger, src) = pushfile(tagger, src, basename(src))
+
+function pushdir(tagger, src, dst=nothing)
+    raw = datadir(tagger, "raw")
+    dst = isnothing(dst) ? raw : joinpath(raw, dst)
+    symlink(src, dst)
+    return dst
+end
+
+function push(tagger, inputdata::AbstractString)
+    destination = nothing
+    if occursin('*', inputdata)
+        repository = Dataloaders.Repository(inputdata)
+        for file in Formats.find_associated_files(Dataloaders.files(repository))
+            srcfile = file.source
+            dstfile = relpath(srcfile, repository.root)
+            pushfile(tagger, srcfile, dstfile)
+        end
+    elseif isdir(inputdata)
+        srcdir = realpath(inputdata) # strip the end slashes
+        resetdata(tagger, "raw")
+        pushdir(tagger, srcdir)
+    elseif endswith(inputdata, ".txt")
+        files_by_dir = Dict{String, Vector{String}}()
+        for file in readlines(inputdata)
+            parent = dirname(file)
+            push!(get!(files_by_dir, parent, String[]), abspath(file))
+        end
+        for (dir, files) in pairs(files_by_dir)
+            for file in Formats.find_associated_files(files)
+                srcfile = file.source
+                dstfile = joinpath(dir, basename(srcfile))
+                pushfile(tagger, srcfile, dstfile)
+            end
+        end
+    else
+        for file in Formats.find_associated_files(abspath(inputdata))
+            srcfile = file.source
+            dstfile = pushfile(tagger, srcfile)
+            if isnothing(destination)
+                destination = dstfile
+            end
+        end
+    end
+    return destination
+end
+
 function pull(tagger::Tagger, dest_dir::String)
     proc_data_dir = datadir(tagger, "processed")
     isdir(proc_data_dir) || throw("no processed data directory found")
diff --git a/src/backends.jl b/src/backends.jl
index 41f5c9ba385b52d7e1765bdeda93b66e4a61e19e..d734ad19d81921c70ee3f8bef25a36e324d78ebb 100644
--- a/src/backends.jl
+++ b/src/backends.jl
@@ -46,26 +46,40 @@ Backends(controller, location) = Backends(controller, string(location))
 
 function getbackends(controller, location=nothing)
     controller = gethub(controller)
-    try
-        return controller[:backends]
-    catch
-        backends = Backends(controller, location)
-        Observables.notify(backends.active_backend)
-        controller[:backends] = backends
-        return backends
+    if haskey(controller, :backends)
+        controller[:backends]
+    else
+        if !isnothing(location) && startswith(location, "http://")
+            back = REST.Client.LTBackend(location)
+            REST.Client.connect(back; preselect_tagger=true)
+            controller[:backends] = back
+        else
+            backends = Backends(controller, location)
+            Observables.notify(backends.active_backend)
+            controller[:backends] = backends
+        end
     end
 end
 
-function Taggers.push(model::Backends, file::String; clean=true, metadata=true)
+get_active_backend(backends::Backends) = backends.active_backend
+get_model_instances(backends::Backends) = backends.model_instances
+get_model_instance(backends::Backends) = backends.model_instance
+get_backend_names(backends::Backends) = backends.backends
+
+get_active_backend(back::REST.Client.LTBackend) = back.active_tagging_backend
+get_model_instances(back::REST.Client.LTBackend) = REST.Client.listmodels(back, Val(true))
+get_model_instance(back::REST.Client.LTBackend) = REST.Client.active_model_instance(back)
+get_backend_names(back::REST.Client.LTBackend) = REST.Client.listmodels(back)
+
+function Taggers.push(model::Backends, file::String; clean=true, metadata=nothing)
     tagger = gettagger(model)
     clean ? resetdata(tagger) : resetdata(tagger, "raw")
     dest_file = Taggers.push(tagger, file)
-    if metadata
+    if !isnothing(metadata)
         # save the metadata to file, so that the backend can reproduce them in the output
         # file for predicted labels
         dest_file = joinpath(dirname(dest_file), "metadata")
-        metadata = Observables.to_value(getmetadatatable(model.controller))
-        PlanarLarvae.Datasets.to_json_file(dest_file, asdict(metadata))
+        PlanarLarvae.Datasets.to_json_file(dest_file, metadata)
     end
 end
 
@@ -76,25 +90,32 @@ function Taggers.pull(model::Backends, destdir::String)
     return Taggers.pull(tagger, destdir)
 end
 
-function Taggers.predict(model::Backends)
+function Taggers.predict(model::Backends, file::String; metadata=nothing)
     isnothing(model.model_instance[]) && throw("no model selected")
     backend_dir = joinpath(model.location, model.active_backend[])
     model_instance = model.model_instance[]
     isnothing(model_instance) && throw("no model instance selected")
-    turn_load_animation_on(model.controller)
+    tagger = Tagger(backend_dir, model_instance)
+    #
+    Taggers.push(model, file; metadata=metadata)
+    # TODO: make the skip_make_dataset option discoverable in the backend
+    predict(tagger; skip_make_dataset=true)
+    labelfile = Taggers.pull(model, dirname(file))
+    @assert length(labelfile) == 1
+    return labelfile[1]
+end
+
+function Taggers.predict(controller::ControllerHub, back)
+    inputfile = controller[:input][]
+    isnothing(inputfile) && throw("no loaded files")
+    @assert ispath(inputfile)
+    metadata = asdict(Observables.to_value(getmetadatatable(controller)))
+    turn_load_animation_on(controller)
     try
-        # TODO: make the skip_make_dataset option discoverable in the backend
-        predict(Tagger(backend_dir, model_instance); skip_make_dataset=true)
+        resultingfile = predict(back, inputfile; metadata=metadata)
+        tryopenfile(controller, resultingfile; reload=true)
     catch
-        turn_load_animation_off(model.controller)
+        turn_load_animation_off(controller)
         rethrow()
     end
 end
-
-function Taggers.predict(model::Backends, file::String)
-    Taggers.push(model, file)
-    predict(model)
-    labelfile = Taggers.pull(model, dirname(file))
-    @assert length(labelfile) == 1
-    tryopenfile(model.controller, labelfile[1]; reload=true)
-end
diff --git a/src/cli_open.jl b/src/cli_open.jl
index a1d46b678e4e4faf1b2790a1202297a6b354ca0e..73210d872b0635b0e4dc2b5eaf3a3db82d05fb62 100644
--- a/src/cli_open.jl
+++ b/src/cli_open.jl
@@ -54,7 +54,7 @@ function main(args=ARGS; exit_on_error=false)
     infile = parsed_args["<file-path>"]
     if isempty(infile)
         infile = nothing
-    elseif !isfile(infile)
+    elseif !(startswith(infile, "http://") || isfile(infile))
         if isdir(infile)
             dataroot = infile
             infile = nothing
diff --git a/src/editor.jl b/src/editor.jl
index ad40fb617e22a23c656a06f82595d154bbd3fd28..4891530cfc2c5c955bf8eefcf387aaeb51210f3c 100644
--- a/src/editor.jl
+++ b/src/editor.jl
@@ -43,6 +43,11 @@ function larvaeditor(path=nothing;
         enable_delete=false,
         kwargs...)
 
+    if path isa AbstractString && startswith(path, "http://")
+        backend_directory = path
+        path = nothing
+    end
+
     # to (re-)load a file, the app is reloaded with the filepath as sole information
     # from previous session
     T = Ref{Union{Nothing, String}}
diff --git a/src/larvatagger.css b/src/larvatagger.css
index 013b8a4acf974db08bbde37249d883491a754cab..943cd7710e8fa7d6f91366849f2c80e12919287f 100644
--- a/src/larvatagger.css
+++ b/src/larvatagger.css
@@ -146,7 +146,8 @@ table {
 }
 
 button:disabled {
-	--text-opacity: 0.5;
+	/* --text-opacity: 0.5; */
+	opacity: 0.5;
 	pointer-events: none;
 }
 
diff --git a/src/wgl.jl b/src/wgl.jl
index fb4640d76c361210a95344674d35ec4f27d736bf..6674dc505cfd75b82be44d16a658101addeb99e2 100644
--- a/src/wgl.jl
+++ b/src/wgl.jl
@@ -979,33 +979,39 @@ function addtosavequeue!(c::LarvaFilter, id)
 end
 
 struct BackendMenu
-    backends::Backends
+    backends
+    models
     send2backend::NyxWidgets.Button
     dom_id
 end
 
 function backendmenu(controller, location)
+    controller = gethub(controller)
     backends = getbackends(controller, location)
-    disabled = map(isempty, backends.model_instances)
+    models = get_model_instances(backends)
+    disabled = if isnothing(controller[:input][])
+        true
+    else
+        map(isempty, models)
+    end
     send2backend = NyxWidgets.Button("Autotag"; disabled=disabled)
     on(send2backend) do _
-        predict(backends, controller[:input][])
+        predict(controller, backends)
     end
-    BackendMenu(backends, send2backend, dom_id())
+    BackendMenu(backends, models, send2backend, dom_id())
 end
 
 function lowerdom(bs::BackendMenu)
-    models = bs.backends.model_instances
-    model_instance = bs.backends.model_instance
+    model_instance = get_model_instance(bs.backends)
     update_model_instance = js"(evt)=>{ $model_instance.notify(evt.srcElement.value); }"
-    select_dom = DOM.select(DOM.option(model; value=model) for model in models[];
+    select_dom = DOM.select(DOM.option(model; value=model) for model in bs.models[];
                             onchange=update_model_instance,
                             class=css_button)
-    active_backend = bs.backends.active_backend
+    active_backend = get_active_backend(bs.backends)
     update_active_backend = js"(evt)=>{ $active_backend.notify(evt.srcElement.value); }"
     return DOM.div(DOM.label("Backend"),
                    DOM.select(DOM.option(backend; value=backend)
-                              for backend in bs.backends.backends;
+                              for backend in get_backend_names(bs.backends);
                               onchange=update_active_backend,
                               class=css_button),
                    DOM.label("Model instance"),
@@ -1015,7 +1021,7 @@ function lowerdom(bs::BackendMenu)
 end
 function Bonito.jsrender(session::Session, bs::BackendMenu)
     node = lowerdom(session, bs)
-    onjs(session, bs.backends.model_instances, js"""(options) => {
+    onjs(session, bs.models, js"""(options) => {
          select = document.querySelectorAll($(dom_selector(bs)) + ' > select')[1];
          LarvaTagger.updateSelectOptions(select, options);
     }""")
diff --git a/test/rest_client.sh b/test/rest_client.sh
new file mode 100755
index 0000000000000000000000000000000000000000..23daccbe2ad9c9a1b30ac5c572791b87b9690f7b
--- /dev/null
+++ b/test/rest_client.sh
@@ -0,0 +1,32 @@
+#!/bin/bash
+
+set -m
+
+this_script=$(realpath "${BASH_SOURCE[0]}")
+larvatagger_jl_project_root=$(dirname "$(dirname "${this_script}")")
+larvatagger_project_root=$(dirname "${larvatagger_jl_project_root}")
+
+tagging_backend=MaggotUBA
+tagging_model=20230311
+lt_backend_port=9285
+
+# pre-requisites are similar to rest_server.sh
+
+# pre-compile
+julia +1.10 --project="${larvatagger_jl_project_root}" -q -e "using LarvaTagger"
+
+# run and background the backend server
+JULIA_PROJECT="${larvatagger_project_root}/TaggingBackends" \
+  julia +1.10 --project="${larvatagger_jl_project_root}" -i \
+  -e "using LarvaTagger.REST.Server; run_backend(\"${larvatagger_project_root}\"; port=${lt_backend_port})" &
+lt_backend_pid=$!
+
+# run the frontend server
+JULIA="julia +1.10" ${larvatagger_jl_project_root}/scripts/larvatagger-gui.jl http://localhost:${lt_backend_port}
+
+# scenarii:
+# * click on "Autotag" prior to loading any files (if not disabled, as with a bug in v0.19)
+# * load a tracking data file, and then click on "Autotag"
+
+kill $lt_backend_pid
+wait $lt_backend_pid
diff --git a/test/rest_server.sh b/test/rest_server.sh
new file mode 100755
index 0000000000000000000000000000000000000000..6de171f82297bbda75c4a929589cd40ced7285f5
--- /dev/null
+++ b/test/rest_server.sh
@@ -0,0 +1,177 @@
+#!/bin/bash
+
+set -m
+
+this_script=$(realpath "${BASH_SOURCE[0]}")
+larvatagger_jl_project_root=$(dirname "$(dirname "${this_script}")")
+larvatagger_project_root=$(dirname "${larvatagger_jl_project_root}")
+
+tagging_backend=MaggotUBA
+tagging_model=20230311
+lt_backend_port=9285
+
+# assumed directory structure:
+# the MaggotUBA-adapter, TaggingBackends, NyxArtefacts and LarvaTagger.jl projects are siblings in the ${larvatagger_project_root} directory;
+# the MaggotUBA-adapter project is available both as directory MaggotUBA-adapter and link MaggotUBA (${tagging_backend});
+# the MaggotUBA-adapter tagging backend contains two trained models (or model instances): 20230311 and 20230311-0;
+# the NyxArtefacts project is available as directory Artefacts;
+# MaggotUBA is installed with JULIA_PROJECT pointing at the TaggingBackends directory
+
+[ -d "${larvatagger_project_root}/${tagging_backend}/models/${tagging_model}" ] || exit "tagging backend or model not found"
+
+if [ -z "`julia +1.10 -v 2>/dev/null`" ]; then
+  juliaup add 1.10
+fi
+
+# run and background the backend server
+JULIA_PROJECT="${larvatagger_project_root}/TaggingBackends" \
+  julia +1.10 --project="${larvatagger_jl_project_root}" -iq \
+  -e "using LarvaTagger.REST.Server; run_backend(\"${larvatagger_project_root}\"; port=${lt_backend_port})" &
+lt_backend_pid=$!
+
+# wait for the server to be ready
+while [ "`curl -s http://localhost:${lt_backend_port}/status`" != "up" ]; do
+  sleep 1
+done
+echo "status: success"
+
+# get a session token for a tagging backend
+token=`curl -sS http://localhost:${lt_backend_port}/get-token/${tagging_backend}/${tagging_model}`
+
+if [ "${token:0:3}" = "jl_" ]; then
+  echo "get-token: success"
+else
+  echo "get-token: failure"
+  echo "expected: string starting with \"jl_\""
+  echo "got: $token"
+fi
+
+# send one text file, one binary file
+curl -sS -F "f=@${larvatagger_jl_project_root}/README.md" -F "g=@${larvatagger_project_root}/Artefacts/PlanarLarvae/trx_small.tgz" http://localhost:${lt_backend_port}/push-file/${tagging_backend}/${tagging_model}/${token} 1>/dev/null
+
+generated_files=(`ls "${larvatagger_project_root}/${tagging_backend}/data/raw/${token}"`)
+
+expected_files=("README.md" "trx_small.tgz")
+
+if [ ${#generated_files[@]} -eq ${#expected_files[@]} -a \
+  "${generated_files[0]}" = "${expected_files[0]}" -a \
+  "${generated_files[1]}" = "${expected_files[1]}" ]; then
+  echo "push-file: success"
+else
+  echo "push-file: failure"
+fi
+
+# discover the sent files
+discovered_files=`curl -sS http://localhost:${lt_backend_port}/list-files/${tagging_backend}/${tagging_model}/${token}/raw`
+
+expected_files='["README.md", "trx_small.tgz"]'
+
+if [ "$discovered_files" = "$expected_files" ]; then
+  echo "list-files: success"
+else
+  echo "list-files: failure"
+  echo "expected: $expected_files"
+  echo "got: $discovered_files"
+fi
+
+# make file in processed data dir
+test_filename=label.json
+
+mkdir -p "${larvatagger_project_root}/${tagging_backend}/data/processed/${token}"
+cat <<EOF >"${larvatagger_project_root}/${tagging_backend}/data/processed/${token}/${test_filename}"
+{
+}
+EOF
+
+# retrieve file from processed data dir
+retrieved_content=`curl -sS http://localhost:${lt_backend_port}/pull-file/${tagging_backend}/${tagging_model}/${token}/${test_filename}`
+
+expected_content="{
+}"
+
+if [ "$retrieved_content" = "$expected_content" ]; then
+  echo "pull-file: success"
+else
+  echo "pull-file: failure"
+  echo "expected file content:"
+  echo "$expected_content"
+  echo "got:"
+  echo "$retrieved_content"
+fi
+
+# request file deletion
+curl -sS http://localhost:${lt_backend_port}/reset-data/${tagging_backend}/${tagging_model}/${token}/raw 1>/dev/null
+
+stored_files=`ls "${larvatagger_project_root}/${tagging_backend}/data/raw/${token}/"`
+if [ -z "$stored_files" ]; then
+  echo "reset-data: success"
+else
+  echo "reset-data: failure"
+  echo "expected empty list"
+  echo "got:"
+  echo "$stored_files"
+fi
+
+# discover the backend(s) and their models
+discovered_backends=`curl -sS http://localhost:${lt_backend_port}/list-backends`
+
+backend_metadata() {
+  local desc1=
+  local link1=
+  local desc2=
+  local link2=
+  local desc3=
+  local link3=
+  echo "{\"name\":\"$1\",\"description\":\"$desc1\",\"homepage\":\"$link1\",\"models\":[{\"name\":\"20230311\",\"description\":\"$desc2\",\"homepage\":\"$link2\"},{\"name\":\"20230311-0\",\"description\":\"$desc3\",\"homepage\":\"$link3\"}]}"
+}
+expected_backends="[`backend_metadata $tagging_backend`,`backend_metadata ${tagging_backend}-adapter`]"
+
+if [ "$discovered_backends" = "$expected_backends" ]; then
+  echo "list-backends: success"
+else
+  echo "list-backends: failure"
+  echo "expected:"
+  echo "$expected_backends"
+  echo "got:"
+  echo "$discovered_backends"
+  # further diagnose
+  model_instances=(`ls "${larvatagger_project_root}/${tagging_backend}/models"`)
+  if ! [ ${#model_instances[@]} -eq 2 -a "${model_instances[0]}" = "20230311" -a  "${model_instances[1]}" = "20230311-0" ]; then
+    echo "tagging backend is not adequately set up"
+  fi
+fi
+
+cp "${larvatagger_jl_project_root}"/20140805_101522@FCF_attP2_1500062@UAS_TNT_2_0003@t5@p_8_45s1x30s0s#p_8_105s10x2s10s#n#n@100.* "${larvatagger_project_root}/${tagging_backend}/data/raw/${token}/"
+
+# if python does not find TaggingBackends, refresh LT install:
+#(cd ../MaggotUBA-adapter && (cd ../TaggingBackends && PYTHON="`poetry env info`/bin/python" julia +1.10 --project=. -e 'using Pkg; Pkg.instantiate()'))
+#(cd ../MaggotUBA-adapter && JULIA_PROJECT="`realpath ../TaggingBackends`" poetry install)
+
+# run the predict function of the tagging backend
+curl -sS http://localhost:${lt_backend_port}/predict/${tagging_backend}/${tagging_model}/${token} 1>/dev/null
+
+generated_files=(`ls "${larvatagger_project_root}/${tagging_backend}/data/processed/${token}/"`)
+
+if [ ${#generated_files[@]} = 1 -a "${generated_files[0]}" = "predicted.label" ]; then
+  echo "predict: success"
+else
+  echo "predict: failure"
+  echo "expected: predicted.label"
+  echo "got:"
+  echo "$generated_files"
+fi
+
+# clear session data
+curl -sS http://localhost:${lt_backend_port}/close/${tagging_backend}/${tagging_model}/${token} 1>/dev/null
+
+if ! [ -d "${larvatagger_project_root}/${tagging_backend}/data/raw/${token}" ] && \
+  ! [ -d "${larvatagger_project_root}/${tagging_backend}/data/interim/${token}" ] && \
+  ! [ -d "${larvatagger_project_root}/${tagging_backend}/data/processed/${token}" ]; then
+  echo "close: success"
+else
+  echo "close: failure"
+fi
+
+echo "tests complete; terminating the backend (a stack trace will follow)"
+kill $lt_backend_pid
+wait $lt_backend_pid