External hosting
Most tools are hosted by Voshi: the builder writes browser-oriented Javascript, and Voshi hosts everything. In contrast, an externally hosted tool runs on your own server instead. Voshi still handles the LMS, the placement, the students, and the gradebook, but it hands each launch to your server over HTTPS, and your server does the rest.
This section is the contract for that: the endpoints you build, what arrives at them, how to verify it, and the REST APIs for storage and grades. If you are building a hosted tool, none of it applies to you — everything in it is done for you by the Voshi API.
External hosting is an exception
Hosting is not a choice on the New Tool form. Voshi hosts tools because a tool that runs on Voshi's infrastructure is one Voshi can vouch for to the schools that adopt it: it is reviewed before it reaches students, it stores student work where Voshi can see how it is kept, and it cannot reach out to anywhere else. A server Voshi does not run can give none of that for free, so external hosting is granted case by case, where a tool genuinely needs it:
- A backend of your own — an existing product, a database with its own schema, server-side logic.
- Grading that must not be readable off the tool's source, computed and published from your server.
- A catalog of locations that changes dynamically — locations generated per placement, or served from your own data.
- Grading outside the launch — a background job, an instructor's roster view, scoring work hours after the student left.
Everything else — identity, storage, files, grades during the launch — a hosted tool already has, with no server to run.
An externally hosted tool comes with increased review and periodic checks. Voshi reviews it more closely before it goes live, and checks on it periodically afterwards, to confirm that it keeps meeting the security, privacy, and other expectations the educational space places on anything that handles student work. Expect to be asked about where data lives, who can reach it, and how the server is kept patched, and expect those questions to come around again.
To request it, contact the MyEducator team with what the tool is and why it needs its own server. If it is approved, your tool is set up as external, and the differences below apply.
Hosted or external
| Voshi Hosted | External | |
|---|---|---|
| Where your code runs | Voshi's CDN, in the learner's browser | Your own server |
| How you build it | AI Builder, or upload a folder | However you like |
| Locations | Recorded in the dashboard (the Locations tab, kept in step by the AI Builder) | Served by your Locations URL on demand |
| Course setup | Automatic | Your Provision URL is called |
| Launches | Your element is constructed with the Voshi API | A signed launch_data JWT is posted to your Callback URL |
| Storage and grades | api.storage, api.submitGrade() | REST calls with the launch's token, or your API key |
| API key | None | One, shown once |
| Versions | One per publish, each reviewed | One, standing for the tool; its visibility is what is reviewed |
| Dashboard | AI Builder, Versions | Routes, Status |
The three endpoints you build
Voshi reaches your tool at three URLs. All three must be public HTTPS, and all three receive a form POST with a single signed launch_data field that you verify the same way.
| URL | Called when | Docs |
|---|---|---|
| Callback URL | Someone opens your tool from a course. Arrives through their browser. | Receiving launches |
| Provision URL | A course, or a location in it, needs setting up in your tool. Server to server. | Receiving provision requests |
| Locations URL | An instructor is placing your tool and Voshi needs your list of destinations. Server to server. | Serving your locations |
Only the Callback URL is strictly required to receive a launch — but a tool without a Locations URL can't be placed in a course, and a tool without a Provision URL can't finish course setup, so a live tool needs all three.
Setting up in the dashboard
Once the MyEducator team has set your tool up as external, it appears under Tools in the dashboard with a Routes page in place of the AI Builder and a Status page in place of Versions.
On the Settings tab, press Rotate Key. The new key is shown exactly once — copy it now and store it securely, like you would a password. Its one job is letting your server publish grades with no launch session.
Open the Routes page and fill in Callback URL, Provisioning URL, and Locations URL. They're blank to begin with on purpose — the server they point at usually doesn't exist yet.
You can edit the name, description, and logo on the Settings tab, and the three URLs on Routes, at any time. Changes apply immediately — including to placements instructors have already made. See Managing your tool.
Endpoint URL rules
Each of the three URLs must be an absolute https:// URL (no http://, no relative paths); that is checked when you save it. Each must also point at a publicly-routable host, which is checked every time Voshi calls it: a URL that resolves to a private, loopback, or link-local address is refused at that moment, and the instructor or launch sees the error. Your local machine can't receive launches; use a tunnel with a public HTTPS hostname or a deployed environment during development.
A minimal external tool
When someone opens your tool, Voshi sends their browser to your Callback URL with a form POST containing a single field, launch_data — a signed JWT carrying everything about the launch. Verify it, start your own session, and render.
- Python (Flask)
- Node (Express)
# pip install flask pyjwt cryptography
from flask import Flask, request, session
import jwt
from jwt import PyJWKClient
JWKS_URL = "https://api.link.voshi.com/lti13/v1/jwks"
jwks = PyJWKClient(JWKS_URL) # fetches and caches Voshi's public keys by kid
app = Flask(__name__)
app.secret_key = "replace-with-a-real-secret"
@app.post("/launch_receiver")
def launch():
token = request.form["launch_data"]
# Verify the signature and expiry — never trust the claims before this.
signing_key = jwks.get_signing_key_from_jwt(token)
data = jwt.decode(token, signing_key.key, algorithms=["RS256"])
# Trade the one-time launch token for your own session.
session["voshi_user_id"] = data["user"]["id"]
session["voshi_member_id"] = data["user"]["member"] # this person in this course
session["groups"] = data["user"]["groups"] # e.g. ["instructor"]
session["course_id"] = data["context"]
session["grade_url"] = data["grade"]["submit"] # None if this placement can't be graded
location = data["location"]
if location["type"] == "assessment":
return f"Quiz time! You launched: {location['label']}"
return f"Welcome to {location['label']}"
// npm install express jose express-session
import express from 'express'
import session from 'express-session'
import { createRemoteJWKSet, jwtVerify } from 'jose'
const JWKS_URL = 'https://api.link.voshi.com/lti13/v1/jwks'
const jwks = createRemoteJWKSet(new URL(JWKS_URL)) // caches keys by kid
const app = express()
app.use(express.urlencoded({ extended: false }))
app.use(session({ secret: 'replace-with-a-real-secret', resave: false, saveUninitialized: false }))
app.post('/launch_receiver', async (req, res) => {
// Verify the signature and expiry — never trust the claims before this.
const { payload: data } = await jwtVerify(req.body.launch_data, jwks, {
algorithms: ['RS256'],
})
// Trade the one-time launch token for your own session.
req.session.voshiUserId = data.user.id
req.session.voshiMemberId = data.user.member // this person in this course
req.session.groups = data.user.groups // e.g. ['instructor']
req.session.courseId = data.context
req.session.gradeUrl = data.grade.submit // null if this placement can't be graded
const location = data.location
if (location.type === 'assessment') {
res.send(`Quiz time! You launched: ${location.label}`)
} else {
res.send(`Welcome to ${location.label}`)
}
})
app.listen(3000)
See Verifying the launch JWT for the full security checklist — every endpoint below verifies launch_data the same way.
Instructors can only place destinations your tool tells Voshi about. When one is placing content, Voshi POSTs a signed launch_data to your Locations URL and shows them what you return, keyed by an external ID of your own choosing:
- Python (Flask)
- Node (Express)
@app.post("/voshi/locations")
def voshi_locations():
verify_launch_data(request.form["launch_data"]) # same verification as a launch
return {"locations": {
"ch3_quiz": {
"type": "assessment", # the only type that can be graded
"label": "Chapter 3 Quiz",
"description": "20 questions on normal distributions.",
"points": 50, # gradebook column value
},
"syllabus": {"type": "content", "label": "Course Syllabus"},
}}
app.post('/voshi/locations', async (req, res) => {
await verifyLaunchData(req.body.launch_data) // same verification as a launch
res.json({ locations: {
ch3_quiz: {
type: 'assessment', // the only type that can be graded
label: 'Chapter 3 Quiz',
description: '20 questions on normal distributions.',
points: 50, // gradebook column value
},
syllabus: { type: 'content', label: 'Course Syllabus' },
}})
})
Before the first launch of a course reaches you, Voshi asks your tool to set that course up — and the same for each location placed in it. Answer 204 immediately, then call the URLs from the provision claim when you're done:
- Python (Flask)
- Node (Express)
@app.post("/voshi/provision")
def voshi_provision():
data = verify_launch_data(request.form["launch_data"])
queue_setup(data) # do the work in the background
return "", 204 # answer first
def queue_setup(data):
headers = {"Authorization": f"Bearer {data['api']['token']}"}
# The course first — a location cannot be provisioned before its course.
if "context" in data["provision"]:
requests.put(data["provision"]["context"], headers=headers).raise_for_status()
if "location" in data["provision"]:
requests.put(data["provision"]["location"], headers=headers).raise_for_status()
app.post('/voshi/provision', async (req, res) => {
const data = await verifyLaunchData(req.body.launch_data)
res.status(204).end() // answer first
queueSetup(data) // do the work in the background
})
async function queueSetup(data) {
const headers = { Authorization: `Bearer ${data.api.token}` }
// The course first — a location cannot be provisioned before its course.
if (data.provision.context) {
await fetch(data.provision.context, { method: 'PUT', headers })
}
if (data.provision.location) {
await fetch(data.provision.location, { method: 'PUT', headers })
}
}
Until those calls land, launches for that course wait. See How provisioning works.
Every launch carries a grade.submit URL — or null, when that placement has no gradebook column. POST the score there; your API key authorizes it with no launch session:
- Python
- Node
import requests
grade_url = session["grade_url"]
if grade_url: # None when this placement can't be graded
resp = requests.post(
grade_url,
headers={"Authorization": "Bearer ltiaas_mytoolid_mysecret"},
json={
"score": 0.85, # a fraction from 0.0 to 1.0
"comment": "Nice work!",
},
)
resp.raise_for_status()
attempt = resp.json()
print(attempt["status"]) # "success" if it reached the gradebook
const gradeUrl = req.session.gradeUrl
if (gradeUrl) { // null when this placement can't be graded
const resp = await fetch(gradeUrl, {
method: 'POST',
headers: {
Authorization: 'Bearer ltiaas_mytoolid_mysecret',
'Content-Type': 'application/json',
},
body: JSON.stringify({
score: 0.85, // a fraction from 0.0 to 1.0
comment: 'Nice work!',
}),
})
const attempt = await resp.json()
console.log(attempt.status) // "success" if it reached the gradebook
}
A rejected score comes back 200 with status: "failed" and the reason in data.error — check it. See Grade passback.
Optional: the Voshi API client on a Node server
Everything above calls the REST APIs directly, which works from any language. If your server runs on Node, there is a shortcut: the same Voshi API client a hosted tool is handed runs on a server too, and wraps the storage, files, and grade calls for you. It is one dependency-free ES module:
https://api.link.voshi.com/static/ltiaas/scripts/voshi-api.1.86ed96c5584e.js
Download it into your project (Node does not import from a URL; Deno can) and construct one from the launch data after you have verified the JWT:
import { VoshiApi, VoshiPermissionError } from './voshi-api.1.js'
app.post('/launch_receiver', async (req, res) => {
const data = await readLaunchData(req.body.launch_data) // verified claims
req.session.launch = data // keep the claims, not the instance
const api = new VoshiApi(data)
const saved = await api.storage.memberLocation.get() // {} on a first visit
res.send(render(data.location, saved))
})
app.post('/finish', async (req, res) => {
const api = new VoshiApi(req.session.launch)
try {
if (api.canSubmitGrade) {
await api.submitGrade({ score: 0.85, maxScore: 10, comment: 'Nice work!' })
}
res.sendStatus(204)
} catch (err) {
if (err instanceof VoshiPermissionError) res.sendStatus(403)
else throw err
}
})
What you get is what the API section documents — api.user, api.isStaff, api.location, api.storage.<scope>.get / set / update / patch, api.storage.<scope>.files, api.submitGrade(), and the thrown error classes — minus api.assets, which only the hosted launch page provides. Three things to know on a server:
- It acts as the launching user, with the launch's token. It cannot use your API key, so it is for work during the launch: a score published while the student is still in the tool, their work read and saved as they go. Grading hours later is still a
POSTto the storedgrade.submitURL with your key, as above. - Construct it per request from the stored claims. The launch token ends after 12 hours idle; a
VoshiAuthenticationErrormeans the user needs a fresh launch. - Node 18 or later, for the global
fetch,Blob, andFormData. The file name carries a content hash and changes when the client changes, so pin the copy you downloaded and update it between semesters.
Coming soon. The client will be published to the npm registry, so a Node server can npm install it and take updates the normal way instead of downloading the file. A Python version of the client is also forthcoming, with the same storage, files, and grade methods for a Flask or Django server. Until then, the download above and the raw REST calls on this page are the two ways in.
Testing your tool
The dashboard's Sandbox tab launches your tool in a trial course of its own, before any LMS is involved and before the tool is visible to anyone else. These are real launches: Voshi plays the part of the LMS, your Locations URL is asked what you offer through the ordinary picker, your Provision URL is called and waited on, your Callback URL receives a properly signed launch_data, real storage rows are created, and the Sandbox has a gradebook of its own that your grade submissions land in.
- New Link opens your tool's content picker, served from your Locations URL, and what you pick becomes a test link.
- Launch any test link as a student or as an instructor. Each role is a separate person in the trial course with its own data.
- Reset on a test link throws away everything stored for that link, so the next launch provisions it from scratch — the way to re-test your Provision URL.
- The Sandbox's Launches and Grades tabs show what Voshi recorded.
A trial launch signs you into the browser as the test student or instructor, not as yourself. Open launches in a new window, and expect to sign back into the dashboard afterwards.
Going live
An externally hosted tool has no versions to publish — your server is the one version there is — but it is reviewed and found the same way a hosted tool's versions are, through a single standing version whose visibility you set on the Status page:
| Visibility | Meaning |
|---|---|
| Private | Where every tool starts. Only courses you teach yourself can add it, so you can pilot it with your own students. |
| Unlisted | Instructors add it with a sharing code. Not listed in the Voshi catalog. |
| Public | Listed in the Voshi catalog, where any instructor can find it. |
Change Status to unlisted or public sends the tool for review; it shows as Pending review until an administrator approves it, and cannot launch anywhere but your own courses meanwhile. Once approved it is available to instructors. The path, then:
- Build your three endpoints and your grade flow against these docs.
- Exercise them from the Sandbox tab, and in a real course of your own if you have one (a private tool can be added to a course you teach, with a code pinned to pilot).
- On Status, change the visibility to unlisted or public, with notes for the reviewer.
- After approval, test from a real LMS course — place a location, launch as an instructor and as a student, and verify grades land in the gradebook.
The review is where the increased scrutiny of external hosting lands: expect questions about your server, and expect to be asked again from time to time after approval.
The tool itself also has a Status on the Settings tab: Active, Archived (you put it away — it does not launch or take placements, and you can unarchive it), or Suspended (Voshi disabled it, which only Voshi can lift).
Your API key
Only an externally hosted tool has one. It has the form:
ltiaas_<tool-id>_<secret>
- It's shown once, when it is issued. Voshi stores only a hash of it and cannot show or recover it — the dashboard displays a hint (
ltiaas_…last4) so you can tell which key is live. - Keep it server-side only.
- Its one use is publishing a grade from your own server, long after the launch session is gone. It does not configure your tool, and it is not a general-purpose credential.
- It is not involved in launch verification — launches are verified against Voshi's public keys. Nor is it what provisioning calls use; those authenticate with the request's own
api.token.
Rotating or replacing your API key
Rotate the key with Rotate Key on the Settings tab. A new key is issued and shown once.
The old key stops working immediately — there is no overlap window. Every deployed instance of your tool still using it starts getting 401 the moment you rotate, so roll the new key out promptly, or accept a gap in server-side grading.
Rotation is also the only recovery for a lost key, and the right response to a leaked one.
Where to next
Every field in the launch JWT, explained.
Persist state per course, placement, and student — no database required.
Course setup, the two levels, and what students see until it's done.
The destinations instructors place, and how your tool declares them.