Spenser - a developer's companion
1.0.0 - STU1
Spenser - a developer's companion, published by Zeora. This guide is not an authorized publication; it is the continuous build for version 1.0.0 built by the FHIR (HL7® FHIR® Standard) CI Build. This version is based on the current content of https://github.com/costateixeira/spenser/ and changes regularly. See the Directory of published versions
This page documents every interface a Spenser unit exposes: the FHIR ones, the
private HTTP API it uses for its own configuration, and the interfaces that are
not HTTP at all. It is the reference behind the Using Spenser
page, and the source of truth is the CapabilityStatement each unit publishes at
its own /metadata.
A unit is reachable on the local network as http://spenser.local, which it
advertises over mDNS, or at whatever address its router hands it.
Spenser is a FHIR server and a FHIR client at the same time, and the difference matters when deciding where to send something.
Both roles are declared in its CapabilityStatement.
Following the HL7 Clinical Orders Workflow guidance, a MedicationRequest sitting on a server is an authorization, not a command. Spenser fulfils it only when one of two things is true:
meta.tag
http://terminology.hl7.org/CodeSystem/common-tags#actionable.
See Order made actionable by a tag.requested points at the request in
Task.focus.
See Task asking for an order to be fulfilled,
which makes this plain order
actionable.These are not two modes of the device. Spenser looks for both on every check
and acts on whichever turns up. When an order arrives by both routes at once the
Task takes precedence, and the order is still dispensed exactly once. A unit can
be narrowed to one route with the orderMode setting, which is useful for
demonstrating the difference and for little else.
Who closes the order out depends on which route was used:
| route | what Spenser sets | what it leaves alone |
|---|---|---|
| tag, no Task | the MedicationRequest to completed |
|
| a Task | the Task to completed, with the dispense in Task.output |
the MedicationRequest, which stays active |
The second row is deliberate. With a Task coordinating the work, closing the request is the placer's job, not the filler's, and a completed Task is the placer's cue to do it.
Served by the device itself.
| Interaction | Meaning |
|---|---|
GET /metadata |
CapabilityStatement for this unit, declaring both roles |
POST /MedicationRequest |
dispense now, answering with the MedicationDispense |
GET /MedicationDispense |
searchset of recent dispenses, newest first, with an OperationOutcome about the last check |
GET /MedicationDispense/{id} |
read one of them |
GET /InventoryReport |
current stock, one listing per lane |
POST /InventoryReport |
set stock from a report |
POST /MedicationRequest answers 200 with a completed
MedicationDispense when
a piece came out, and 201 with a declined one when the lane is empty. R5
removed MedicationDispense.statusReason, so a refusal carries its reason in
notPerformedReason, which is a CodeableReference. See
the declined example.
GET /MedicationDispense is how you ask a unit what it has been doing. Besides
the dispenses themselves, the Bundle carries one entry with search.mode set to
outcome: an OperationOutcome whose severity, issue.diagnostics and
issue.details.text describe the last check of the order server, including
which routes the unit is accepting and how long ago it looked.
Stock is reported as an
InventoryReport, and can be set the same
way, either as a snapshot or as a difference.
Point a unit at a server, and on every check it runs two searches and writes back what happened:
GET {server}/Task?status=requested&_include=Task:focus&_count=5
GET {server}/MedicationRequest?_tag=http://terminology.hl7.org/CodeSystem/common-tags|actionable&status=active&intent=instance-order&_count=5
POST {server}/MedicationDispense
PUT {server}/Task/{id}
PUT {server}/MedicationRequest/{id}
The _include matters: Spenser resolves Task.focus from the entries of that
one Bundle rather than fetching each order separately, so a Task whose focus is
not returned with it is reported as a problem and skipped.
_count=5 matters when testing. Only the first five results of each search are
considered per check, so with a longer queue a given order may not be picked up
on a particular check.
Both http:// and https:// work. Certificates are not validated, so treat the
link as you would any other unauthenticated local traffic.
The IG carries these as formal examples:
The smallest useful order, placed on the order server:
{
"resourceType": "MedicationRequest",
"meta": {
"tag": [
{ "system": "http://terminology.hl7.org/CodeSystem/common-tags",
"code": "actionable" }
]
},
"status": "active",
"intent": "instance-order",
"medication": {
"concept": {
"coding": [
{ "system": "http://costateixeira.github.io/spenser/CodeSystem/SpenserMeds",
"code": "chocolate-dark",
"display": "Dark Chocolate" }
]
}
},
"subject": { "reference": "Patient/123" },
"dosageInstruction": [ { "text": "One bite" } ]
}
The same body without meta.tag is the negative case: Spenser must leave it
alone. Drop the tag, add a Task pointing at it, and it becomes actionable again
by the other route.
Medications come from the
SpenserMeds code system: chocolate-dark and
chocolate-milk, one piece per order. R5 puts the code in
medication.concept; a directly posted order may also use the R4
medicationCodeableConcept spelling, which this firmware still accepts.
Everything below is the unit's own administrative API. It is plain JSON or text, it is not FHIR, and it is not meant for exchanging clinical data. It exists to configure a unit, to drive it during a demo, and to serve the built-in dashboard.
| Group | Endpoints |
|---|---|
| Orders | /checkOrders queues a check and returns 202, /orders reports the last check for the dashboard, /forgetOrders clears the list of orders already dealt with |
| Settings | /settings, /setSettings, /resetSettings |
| Stock and hardware | /inventory, /setInventory, /resetInventory, /setServos, /flashServo1, /flashServo2, /setColor, /battery, /reset |
| Wi-Fi and identity | /, /scan, /cfg, /save, /wifi-settings, /wifi-reset, /wifi-reboot-config, /getMac, /setMac, /clearMac |
| Captive portal | /generate_204, /hotspot-detect.html, /fwlink, /ncsi.txt, /connecttest.txt |
/setSettings accepts any of serverUrl, orderMode with values both, tag
or task, orderQuery, taskQuery, pollEnabled, pollSeconds between 5 and
3600, writeBack, giveUpOnStockEmpty, and the servo parameters. It answers
with the full settings document.
A unit checks its order server every pollSeconds, 30 by default, while
pollEnabled is on. /checkOrders forces a check immediately. It returns
before the work is done, because the HTTP work happens in the main loop and
never inside a request handler, so a client should wait for the check to finish
rather than assume it has.
giveUpOnStockEmpty decides what happens to an order that cannot be filled.
Off, the order is reported once as out of stock and left open, so refilling the
lane fulfils it on a later check. On, it is reported once and abandoned, and a
coordinating Task is set to failed.
Anything not matched falls back to a static file from the device filesystem,
which is how the dashboard is served. OPTIONS returns 200 for CORS
preflight.
spenser with an HTTP service on port
80, so http://spenser.local works without knowing its address.Each unit serves a guide written for AI agents at
http://spenser.local/llms.txt. It describes every endpoint, the exact
shapes accepted and produced, worked examples, a test that exercises the whole
loop, and the mistakes that catch people out. Point an agent at that URL and it
can write examples, tests or a client without guessing, against the unit in
front of it rather than against documentation that may have drifted.
It is discoverable from FHIR: the CapabilityStatement at /metadata names it in
implementation.description, so an agent that starts at the FHIR entry point
finds it.
Two things pair well with it. The AGENTS.md file in the source repository
covers the repository itself, how to build and flash, where the routes live, and
how to run the tests. The Bruno collection under bruno/ is an executable
version of this page: it is split into the order server on one side and the
device on the other, and every scenario asserts on FHIR rather than on prose.