Into the VLE without an integration project
Zayeed's server is an LTI 1.3 tool. A teacher places the activity, a student clicks it, and the mark goes back to the gradebook. There is no bespoke connector to commission and no nightly CSV to reconcile.
LTI 1.3
- Deep Linking — the teacher picks a quiz from their own library while placing the activity, rather than pasting an opaque ID.
- Launch — the student's click lands them in that quiz, already signed in.
- Assignment and Grade Services — the mark is written back to the gradebook.
- Offline-safe grading — marking still happens on the student's device, so a grade earned with no network is queued and delivered when the network returns.
# point the server at a signing key and the platforms it trusts export ZAYEED_TOOL_URL=https://exams.example.edu # where the platform reaches the tool export ZAYEED_APP_URL=https://app.example.edu # where a launched student lands export ZAYEED_LTI_KEY=./lti-private.pem # generated on first use if absent export ZAYEED_LTI_CONFIG=./lti-platforms.json # issuer, client id, auth + token URLs
/lti/config describes the tool for a platform administrator and /lti/jwks publishes its public key. The private key never leaves the server, and the gradebook credential never reaches a device.
QTI 3.0
Import and export both run through a dedicated package. Where a construct does not map cleanly in either direction, the item carries a note saying so. Silent loss during an interchange is worse than a refusal, because nobody finds it until the paper has already been sat.
Packs
The .mcq container is the interchange format when there is no network at all: items, media, rules, question sets and the on-screen keyboard layout in one file, encrypted and signed, with a lossless bridge to and from the database. It is specified rather than merely shipped — the specification and its test vectors are published, so a reader can be written from them alone — and every shell still opens the .mcqx ZIP it replaced and the legacy MCQPro banks that came before that, deciding which by reading the bytes.
The same container carries a sync bundle: the changes one device has that another has not seen, rather than a snapshot. Hand it over on a memory stick and it merges into whatever the other machine has done since; apply it twice and the second time changes nothing.
Slides and meetings
Five panels open a live room from the questions you were going to ask anyway: a PowerPoint taskpane, a Google Slides sidebar, a Microsoft Teams tab, a Zoom app and a Webex embedded app. There is no second set of questions to author and nothing to keep in step with the first — the questions go up as text and the relay decides what a question is, so one deck becomes the same questions whichever host it is presented from.
Two of the five read the deck themselves. The three meeting clients cannot: none of Teams, Zoom or Webex will show an app the screen being shared, which is the right design and not an oversight, so those three take the questions as pasted text — through one panel, not three. Three copies of it would be three panels that answered the same question three ways within a term.
A slide becomes a question when it has options under it. Mark the right one with a trailing *, a tick or (correct), or state it in the notes as answer: b. points:, seconds:, rating 1-5, nps, ranking, open and skip are read from the same place. Before anybody is asked anything the panel lists, slide by slide, what it read, what it guessed and what it skipped — and a slide with no key is a poll rather than a defect. A room only runs as a scored quiz when every question it read carries a key, whatever the panel asks for: a leaderboard built from two of nine questions is a leaderboard about two questions.
Two limits belong to the hosts rather than to us, and the panels say so where you would hit them:
- PowerPoint does not let an add-in read speaker notes. A key written there stays unread, so it is marked on the slide or typed into the panel's key box — one box per slide.
- Teams, Zoom and Webex do not let an app see the screen being shared. No app can, in any of the three; their panels therefore take the questions as pasted text, one block per question. Zoom and Webex are each asked for exactly one thing beyond that — the meeting’s title, as a default in one text box — and no Zoom OAuth scope is requested at all: who answered is recorded from who joined the room, never taken from a participant list.
- Google Slides can read speaker notes, and does — nothing has to be marked on the slide and nothing has to be retyped.
All five talk to your relay: the address you give the panel is your institution's server, and the deck, the answers and the names never pass through us. Install steps and the manifests are on the add-ins page. None of the five is published in a vendor store — not the Office store, the Teams catalogue, the Zoom Marketplace or Webex App Hub — so each is created against your own account from the manifest published beside it, which is ten minutes of an administrator’s time and is also what keeps all of this on your server rather than ours.
The API, and provisioning
Everything a person can do through a screen, an institution can do through /v1: 77 documented operations across packs, questions, results, sittings, live-room results, webhooks, proctoring, scheduling, the member directory and the audit log. The OpenAPI document at /v1/openapi.json is not written alongside the server — the router is built from it, and a documented operation with no handler stops the server at startup. That is the only way a published surface stays true: it cannot drift from the code, because it is the code.
- Tokens look like
zy_<id>_<secret>. The secret is shown once and stored only as a hash. Each token carries its scopes, its expiry and when it was last used, and can be revoked on its own. - Scopes are a closed list of seventeen, each with the sentence a screen should show. An unknown scope is refused when the token is made, not ignored when it is used.
- Write implies read, for the same noun. Being allowed to overwrite a pack you may not look at is not a safety feature.
- There is no
*scope. It is the one that never fails in staging, and a token that can do everything is indistinguishable from the session it replaced. - A session is not a token. A signed-in person's browser session cannot call a scoped operation; the human surfaces have their own routes. A stolen session is not an API key.
- Another tenant's object answers 404, never 403. A 403 confirms the thing exists, which is the half of the answer nobody is entitled to.
# issue a token that can only read results, then use it curl -X POST https://exams.example.edu/v1/tokens \ -H 'authorization: Bearer '$SESSION \ -d '{"name":"results export","scopes":["results:read"]}' # => {"token":"zy_7f3a_...","shownOnce":true} curl https://exams.example.edu/v1/results?windowId=win_summer \ -H 'authorization: Bearer zy_7f3a_...'
Single sign-on
SAML 2.0 and OpenID Connect, because single sign-on is the one thing an institution will not replace to buy an examination system. An identity team pastes one URL — /saml/metadata — into their provider, and the certificate that is allowed to assert who somebody is comes from configuration here. It is never taken from the KeyInfo in the document being checked, because a document that nominates its own verifying key is only asking to be signed by whoever is holding it.
- Sign-in starts here, never at the provider. Every response is bound to an
AuthnRequestthis server minted and will spend exactly once, so an assertion captured in flight is worth one use to whoever captured it and none to anybody else. Identity-provider-initiated sign-in is refused with a sentence saying why: an unsolicited assertion has nothing to be bound to. - The signed element and the element read are the same object. This is the whole of signature wrapping — an attacker keeps a genuinely signed assertion in the document so the signature verifies, and puts their own name somewhere else for the verifier to read. Exactly one assertion and exactly one signature are allowed, the reference must name the element the signature sits on, and the digest is recomputed over the exclusive canonicalisation of that element before any public-key work is done. The attempts are named test cases, not a paragraph of intention.
- The reader is hostile on purpose. No document type declarations, no entity expansion, no external references, no duplicate attributes, SHA-1 refused by name. A lenient XML parser is a signature bypass when the document is evidence rather than content.
- No Single Logout, and it is stated rather than half-built. SLO depends on every service in a federation being reachable at the moment somebody logs out, and a logout flow that silently does not log out is worse than one that was never offered. Sessions are revoked here and they expire.
- A provider can be pinned to its own domains. Federations often let an institution assert any address it likes;
allowedDomainsis how one of them stops being able to name somebody at another.
SCIM 2.0
An institution does not want to provision people into Zayeed. It wants to provision them once, into whatever it already runs — Entra, Okta, Google Workspace — and have everything it bought follow. So /scim/v2 speaks Users, Groups and the discovery documents a provider expects, and the connector holds one scope: directory:write.
DELETEdeactivates. Every provider sendsDELETE /Users/{id}when somebody leaves. Deleting the row would delete the person who sat an examination and leave the attempt naming an identifier nothing resolves — soactivegoes to false, the row stays, and the provider gets the 204 it is waiting for.- An unknown
PATCHpath is recorded, not refused. The specification says 400; a 400 does not drop one unmapped attribute, it fails the whole operation, the provider retries, and provisioning stops for the entire tenant. What was ignored goes into the audit line instead, where an administrator can act on it. PUTis not a blanking replace. A provider sends only the attributes it maps; treated as a true replace it would reset a teacher's locale, display name and role every night.- One tenant per token. Every route reads the organisation from the token rather than the request.
/v1/audit. An administrator asking after the fact is the only person in a position to act, and "the system did it" is not an answer they can use.Self-hosting
The server runs on Node against SQLite by default, or Postgres by setting DATABASE_URL. Both are generated from one schema definition and pinned by a parity test, so self-hosting is the same product rather than a reduced edition. For air-gapped estates, the sync server and the shells never require an outbound connection to us.
Still deciding?
The fastest way to judge a marker is to try to break it. Run a paper, answer it badly on purpose, and read the reason next to every mark.