API and agents
Namespace Hilms\Api. Two audiences, one module: machines that grant course access after taking money, and agents that author course material as a member of staff.
HiLMS sells nothing itself. An external store takes the payment and grants access here, which is why there is no commerce anywhere in the codebase.
Authentication
Section titled “Authentication”laravel/passport 13, in two shapes:
| Shape | Grant | Token names | Used by |
|---|---|---|---|
| Machine | client credentials | nobody | A shop, a CRM, a script |
| Agent | authorization code with PKCE (S256) |
the person who approved it | An MCP client |
Passport::ignoreRoutes() drops the package’s own group, and api-routes.php registers four endpoints by hand — POST oauth/token and GET, POST, DELETE oauth/authorize — so the device, client and personal-token endpoints still answer 404. Access tokens live an hour, refresh tokens thirty days. The signing keys live in storage/app/keys and are in every backup.
CORS is published and closed: only api/* and oauth/token answer preflight, credentials are never shared, and no browser origin is allowed until one is named in CORS_ALLOWED_ORIGINS.
Every /api/v1 route ends with Hilms\Api\Http\Middleware\SetActivityCauser, so a change made through the API is recorded against the calling client rather than nobody. For an MCP token it names the person the token belongs to instead.
Scopes
Section titled “Scopes”Hilms\Api\Enums\Scope: courses:read, entitlements:read, entitlements:write and mcp:use. They are stored per client in oauth_clients.scopes (NULL means every scope, an empty array none) and enforced when the token is issued, so a token never carries more than its client was given. Each route is guarded by EnsureClientIsResourceOwner::using(<scope>) with a case of the enum, never a bare string.
mcp:use is the odd one out and Scope::forClients() keeps it apart: it is absent from the API clients checkbox list and from Passport::defaultScopes(), because an MCP token acts as a person and a machine token names nobody — a client that could ask for it would be asking to act as everybody. It is declared in our own enum rather than left to laravel/mcp to append, so the package finds it already there.
Rate limit: 120 requests a minute per token (throttle:api-client, falling back to the IP).
The versioned API
Section titled “The versioned API”Tenant isolation is the rule: a client reads and revokes only what it granted itself, so one shop never sees another shop’s grants or the ones staff made in the panel.
| Endpoint | Scope | Behaviour |
|---|---|---|
GET /api/v1/courses |
courses:read | Paginated non-deleted courses: id, slug, title, language, translation_group, translations, status, access, purchase_url, url. ?language= narrows to one of the installation’s languages and refuses any other with 422. Every url is the course’s address in the course’s own language. Drafts are included so a store can map its products before launch |
POST /api/v1/entitlements |
entitlements:write | email, name, course (id or slug), expires_at, external_reference, note, include_translations. Provisions the account when needed and grants access; with include_translations it grants every other language of the same course under the same reference and reports them as translations. 201 with a Location header when created, 200 with the existing entitlement when the reference is replayed |
GET /api/v1/entitlements |
entitlements:read | Paginated entitlements the calling client granted for an email, newest first |
GET /api/v1/entitlements/{id} |
entitlements:read | 200 for one the calling client granted, 404 for anything else |
POST /api/v1/entitlements/{id}/revoke |
entitlements:write | Optional reason and include_translations, which also takes back what this client granted for the other languages of the same course, to the same person, under the same reference; idempotent; 403 when another grantor issued it |
The conventions are worth copying for any endpoint added later: input is validated in a FormRequest with explicit allowlists (a course is named by id or slug and resolved in after()), a model route answers ->missing() with {"message": "Not found."}, output goes through a JSON resource in Http\Resources\V1, and a grant is idempotent by grantor, course and external reference.
An entitlement’s course block names the course’s id, slug, title, language and url, so a shop can tell which language it just sold; translations appears only when the call asked for it.
One entitlement covers one course, and a course is one language. include_translations exists so a shop can sell a course in every language it exists in without asking twice, and a store that would rather decide for itself lists the translations a course carries and grants each one with a reference of its own.
Paginated responses use Laravel’s resource envelope (data, links, meta). Errors are Laravel’s defaults rendered as JSON for api/*; every response carries X-RateLimit-Limit and X-RateLimit-Remaining.
Dates on the wire
Section titled “Dates on the wire”Two halves of one promise, and the reason a consumer never has to devise a way to handle a HiLMS date:
- Every datetime the API sends is UTC, ISO 8601, with six decimals and a literal
Z—2026-09-08T20:44:34.000000Z— forgranted_at,expires_atandrevoked_atalike, whatever clock the value arrived on and whatever clock the installation keeps. Parse it as ISO 8601 and it is the instant. - A datetime on the wire is UTC unless it says otherwise. An
expires_atcarrying an offset (2026-12-31T23:59:00+01:00) is read at that offset; one carrying none (2026-12-31 23:59:00) is read as UTC. The API is the same contract for every caller and has no person in front of it, so it has no other clock to reach for. A client should always send ISO 8601 with an offset —pipejesus/hilms-sdksendsDATE_ATOM, which does — and one that sends a bare wall clock is stating a UTC instant. The validation accepts either form on purpose and is not narrowed: an integration in the field must keep working.
Both are a promise rather than an accident of Carbon’s default serialisation, so EntitlementApiTest holds a real response to that format and to the instant that was stored, from a value stored out of a non-UTC input. A stray Carbon::serializeUsing(), a changed cast or a framework upgrade fails the suite instead of the consumer. Time and clocks is the rest of the story.
Consumers
Section titled “Consumers”Two repositories consume this API and are developed alongside HiLMS:
pipejesus/hilms-sdk0.2 — the PHP client (namespaceHilms\Sdk, PHP ≥ 7.4, private on GitHub, consumed as a VCS or path Composer repository). Its README is the package’s own spec; the wire contract lives inSPECS.md§13. The contract it implements: exchange the credentials once, cache the token forexpires_in - 60seconds, retry a request once after a 401, always sendAccept: application/json, use the store’s order-item id asexternal_reference, and keep the returned entitlement id for refunds. It carries a course’s language and its translations, and the?language=andinclude_translationsparameters.pipejesus/hilms-woocommerce0.2 — the WordPress plugin that grants access when an order is paid. It references entitlements aswc-{order}-{item}-c{course}and appends-g{n}after a revoke, so a retry replays the same entitlement while a re-grant after a refund creates a new one. The course picker names each course’s language, and a per-product switch grants every translation — expanded by the plugin itself, one reference per course, rather than throughinclude_translations, so each grant can be traced back to the course it opened.
Change the wire contract and both need a release; tag the SDK before bumping the plugin’s lock.
API clients in the panel
Section titled “API clients in the panel”Filament/Resources/ApiClients is an index page listed in Settings, under Integrations, through InSettings (Settings); only the administrator holds its permissions. “Add a client” asks for a name and scopes and creates a client-credentials client; the credentials are then available behind “Show the credentials”, a header action that appears while they are in memory and shows the id and the plain secret once. Row actions: edit, “New secret” (regenerates and shows it the same way) and “Revoke” (revokes the client and its tokens). Nothing is ever deleted, and an agent’s client shows as an agent and offers no new secret, because it holds none.
ApiClient extends Laravel\Passport\Client and is registered with Passport::useClientModel(), so Passport’s own factory and repository build it. It is audited on name, scopes and revoked.
The MCP authoring server
Section titled “The MCP authoring server”An agent authors courses through MCP (laravel/mcp 1.0, protocol revision 2026-07-28; a client still opening with the older initialize handshake is served in the revision it asked for, and AuthoringServerHttpTest holds discovery, a tool call with its headers and the 400 for headers that disagree), acting as the member of staff who approved it and under that person’s own policies. There is no second set of rules for agents: what an editor may do in the panel is what an agent acting as that editor may do.
Connecting
Section titled “Connecting”The agent registers itself at POST /oauth/register (dynamic client registration, which laravel/mcp serves), is told where to go by GET /.well-known/oauth-authorization-server and GET /.well-known/oauth-protected-resource, sends its owner to GET /oauth/authorize, and exchanges the code at POST /oauth/token. docs/install/mcp.md is the operator’s guide, with the Claude Code and Cursor commands.
Who may approve: the three oauth/authorize routes sit behind web, auth, Hilms\Api\Http\Middleware\CanAuthoriseAgents and Hilms\Api\Csp\AuthorizePreset. CanAuthoriseAgents admits only a user whose canAccessPanel() is true — approving an agent hands it everything that account can do, so a student is refused outright. The approval page is a contract view: what is being asked, in plain words, the host the access will be sent to, who is signed in, and two forms.
Registration is deliberately open, and config/mcp.php stays unpublished with its default redirect_domains: nothing follows from registering, because a registered client can do nothing until a member of staff approves it in a browser. The consent screen is the control, and registration is throttled to ten clients an hour per address.
Following the form: form-action is checked against the document that carried the form, and a browser applies it to the redirect the submission is answered with — here the agent’s own callback on another origin, so the front-end policy’s form-action 'self' would block the approval click silently. AuthorizePreset extends FrontendPreset and appends exactly one origin: the one named by the request’s redirect_uri, and only when the client named by client_id registered that address. It lives in api rather than access because it reads an ApiClient, and access sits below it.
The server
Section titled “The server”Hilms\Api\Mcp\AuthoringServer at POST /mcp/authoring, behind api, auth:api, throttle:api-client, CheckToken::using('mcp:use') and SetActivityCauser. It is deliberately outside the web group: an MCP client posts JSON with a bearer token and carries no session, so CSRF has nothing to check and would refuse every call. GET and DELETE on the same address answer 405, as the transport expects.
The server carries #[Instructions] telling the model what it may write — Markdown only, no images, no tables, no raw HTML, read before writing, appending is the safe way — so the guard rails are stated as well as enforced.
The tools
Section titled “The tools”Nine, and nothing else: no publishing, no media, no students, no settings.
| Tool | Does | Notes |
|---|---|---|
list-courses |
Every course with id, slug, title, language, status and the languages it is written again in | Read-only; optional search and limit |
get-course |
One course’s outline: its language, its translations, its sections in order and the lessons in each | Read-only; named by id or slug |
get-lesson |
A lesson’s title, summary and every block as its type and its text | Media addresses and quiz answer keys are left out |
create-course |
A course, always a draft | Optional summary, category by id or slug, language (one the installation speaks) and translation_of (a course whose translation group it joins; a group already holding that language is refused) |
create-section |
A section at the end of a course | |
create-lesson |
A lesson at the end of a section, optionally with a Markdown body | The body is markdown, as in the two tools below |
append-lesson-content |
Adds Markdown after what a lesson already holds | |
replace-lesson-content |
Replaces everything a lesson holds | Destructive; the old blocks go, and the library files they showed stay in the library, no longer shown by this lesson |
set-lesson-quiz |
Gives a lesson its quiz, replacing the one it had | Idempotent; the prose around it is kept |
Every tool follows the same four rules, and a new one must too:
- validate with
$request->validate()and an explicit allowlist; - authorise through the catalog policies for
Hilms\Api\Mcp\Actor; - write only through
Hilms\Catalog\Actions\{CreateCourse,CreateSection,CreateLesson}andSyncBlocks, with Markdown going throughMarkdownToBlocksand a quiz throughQuizData; - answer a refusal with
Response::error()naming the next thing to try, rather than throwing.
Hilms\Api\Mcp\Actor::for(Request) is whose hands a tool acts with: over HTTP, the user the token names, with no fallback — a request that carries no user gets none, even if the route were one day registered without auth:api.
The local stdio server is the one place with no request and nobody to ask. php artisan mcp:start hilms-authoring runs the same server over standard input, with no OAuth, and acts as the first administrator — but only after mcp:start has announced itself through Actor::actingAsAdministrator(). Whoever can run artisan on the machine already owns the installation; do not expose that server to anything you would not give a shell to. php artisan mcp:inspector hilms-authoring is the quickest way to try a tool by hand.
Revoking
Section titled “Revoking”An agent’s client is listed among the API clients, marked as an agent. Revoking it kills its tokens. Deleting the account that approved it takes its tokens, refresh tokens and authorisation codes with it: the agent acted as that person, and with the account gone it acts as nobody.
app-modules/api/tests/Feature: token issuance and scope enforcement, each endpoint with its validation and its tenant isolation, the datetime format in both directions, idempotent grants and replays, ?language= and its refusal of a language the installation does not speak, a grant and a revoke with include_translations, the error envelopes and rate-limit headers, the approval flow and who may reach it, the authorize preset’s form-action, every MCP tool including its refusals, the actor’s lack of an HTTP fallback, and the API clients resource.
HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.