Sandbox data source ✅
The sandbox gives you identities, grants and tokens. This page is the other half: a data source that honours those tokens, so an app or an agent has something to read under consent. It serves synthetic health data and nothing else.
It is a test fixture. It exists so you can exercise the whole loop (grant, token, read, ledger entry, revoke, refusal) against something that behaves the way a conforming data source must, before a real one is involved.
- Base URL:
https://lifecare.id/sandbox-source - Authorization server: the hosted sandbox,
https://sandbox.lifecare.id - What it holds: five synthetic personas. No person, no PHI.
- What it returns: interpreted outputs only. Scores, bands, weekly direction, dates and authored sentences. Never a raw reading.
- Two faces, one set of handlers: REST and MCP.
GET https://lifecare.id/sandbox-source/ describes the source and needs no
token. Its configured field says whether the source has a sandbox to check
tokens against; while it reads false, every data route answers 503.
What the source does on every request
It follows the resource-host contract in Data sources & connectors, with nothing cached between requests:
- Introspects the bearer token at the sandbox (
POST /v1/token/introspect), presenting the source's own client credentials, as every resource server must (RFC 7662, section 2.1). - Enforces
active,token_type: "access", the scope and the resource owner. - Records the access in the owner's ledger (
POST /v1/access/record). - Serves the persona's data, directly to you.
The record is written before the response, so an access that cannot be written
to the ledger is not served. Because step 1 runs on every request, a revoked
grant stops the very next call. What the source sends to LifeCare.ID® is
{subject_did, scope, purpose, accessor, consent_id} and never a value; the
consent_id is the one introspection returned for the token, so the ledger
entry names the grant the read was made under (left out when introspection
returned none).
Walkthrough
Everything below is copy and paste. On the hosted sandbox the two self-service calls need your developer access token (see Sandbox); a sandbox you run locally needs none.
SANDBOX=https://sandbox.lifecare.id
SOURCE=https://lifecare.id/sandbox-source
DEVACCESS=… # the token from https://app.lifecare.id/devaccess (the portal's verify page)
1. Register a sandbox client
curl -s -X POST "$SANDBOX/v1/sandbox/clients" \
-H "Authorization: Bearer $DEVACCESS" -H "Content-Type: application/json" \
-d '{"name":"Pilot agent","redirect_uris":["http://localhost:4000/callback"]}'
CLIENT_ID=sbx_… # client_id from the response
2. Mint a synthetic identity
curl -s -X POST "$SANDBOX/v1/sandbox/test-identity" \
-H "Authorization: Bearer $DEVACCESS" -H "Content-Type: application/json" -d '{}'
DID=did:web:sandbox.lifecare.id:u:… # did from the response
SESSION=lcs_… # session_token from the response
3. Grant a scope
The synthetic person grants your client the recovery and daily scopes. The
call carries the person's own session, which is the credential a person acts
with.
curl -s -X POST "$SANDBOX/v1/consent/grant" \
-H "Authorization: Bearer $SESSION" -H "Content-Type: application/json" -d '{
"subject_did":"'"$DID"'",
"grantee":{"name":"Pilot agent","id":"'"$CLIENT_ID"'"},
"scopes":["recovery","daily"],
"purpose":{"text":"agent governance pilot"},
"duration_days":1}'
CONSENT_ID=urn:lifecare:consent:… # consent_id from the response
4. Get a token
Tokens are single scope and short lived. expires_at in the response says when
to ask for another.
curl -s -X POST "$SANDBOX/v1/token" \
-H "Authorization: Bearer $SESSION" -H "Content-Type: application/json" -d '{
"subject_did":"'"$DID"'","scope":"recovery",
"purpose":"agent governance pilot","requester":"'"$CLIENT_ID"'"}'
TOKEN=lc_… # access_token from the response
5. Read synthetic data over REST
curl -s "$SOURCE/v1/source/recovery?owner=$DID" -H "Authorization: Bearer $TOKEN"
{
"fixture": true,
"notice": "Synthetic persona data from the LifeCare.ID® sandbox reference source. Not a person. Zero PHI. Test fixture.",
"resource_owner": "did:web:sandbox.lifecare.id:u:817612d13f3219b6",
"scope": "recovery",
"purpose": "agent governance pilot",
"requester": "sbx_Zy47k_NDW4E",
"consent_id": "urn:lifecare:consent:8e1f…",
"persona": { "persona_id": "p3-under-recovered", "display_name": "Under-Recovered Athlete", "selected_by": "did-hash" },
"as_of": "2026-10-01",
"access_recorded": true,
"ledger_entry_hash": "43bcec11…",
"data": {
"covered": true,
"pillar": "recovery",
"score": 0.0,
"band": "off-track",
"as_of": "2026-10-01",
"weekly": [
{ "week_index": 0, "first_day": "2026-09-02", "last_day": "2026-09-08",
"covered": true, "score": 51.0, "band": "drifting", "as_of": "2026-09-08", "direction": "first-reading" }
],
"sentence": "Recovery is off track: sleep is short and overnight recovery sits well below this persona's own baseline.",
"sleep_label": "under-rested"
}
}
The read is now in the person's ledger, attributed to
sandbox-source:synthetic-personas, and its payload carries the consent_id
the token was issued under, so the entry links back to the grant from step 2:
curl -s "$SANDBOX/v1/consent/ledger/$DID" -H "Authorization: Bearer $SESSION"
{ "event_type": "data_access",
"description": "sandbox-source:synthetic-personas read your recovery data. Purpose: agent governance pilot.",
"payload": { "scope": "recovery", "purpose": "agent governance pilot",
"accessor": "sandbox-source:synthetic-personas",
"consent_id": "urn:lifecare:consent:8e1f…" } }
6. Connect an MCP client
The MCP endpoint is https://lifecare.id/sandbox-source/mcp (streamable HTTP).
The bearer token on the transport is the same sandbox access token. Any MCP
client that can send a header will connect; for example:
{
"mcpServers": {
"lifecare-sandbox-source": {
"type": "http",
"url": "https://lifecare.id/sandbox-source/mcp",
"headers": { "Authorization": "Bearer lc_…" }
}
}
}
Or by hand:
curl -s -X POST "$SOURCE/mcp" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"read_scope","arguments":{"scope":"recovery","owner":"'"$DID"'"}}}'
The tool result carries the same document as step 5, as structuredContent and
as JSON text.
7. Revoke, and see the next call fail
curl -s -X POST "$SANDBOX/v1/consent/revoke" \
-H "Authorization: Bearer $SESSION" -H "Content-Type: application/json" \
-d '{"consent_id":"'"$CONSENT_ID"'"}'
curl -s -i "$SOURCE/v1/source/recovery?owner=$DID" -H "Authorization: Bearer $TOKEN"
HTTP/2 401
www-authenticate: Bearer error="invalid_token"
{"detail":"token inactive or unknown","error":"invalid_token"}
The MCP endpoint refuses the same token the same way, on every method.
REST reference
All data routes take Authorization: Bearer <sandbox access token> and answer
Cache-Control: no-store.
| Route | Token scope | Returns |
|---|---|---|
GET /sandbox-source/ |
none | the source's description; configured says whether it has a sandbox to check tokens against, client_credentials whether it presents its own client credentials there (introspection needs them) |
GET /sandbox-source/v1/scopes |
any | the scope vocabulary, the one scope this token reads, the owner and the persona. Records nothing |
GET /sandbox-source/v1/source/{scope}?owner={did} |
{scope} |
that scope for the token's owner |
GET /sandbox-source/v1/weekly-summary?owner={did}&week={n} |
daily |
one week across every pillar, plus the persona's headline. week is optional and defaults to the latest |
owner is required on every read and must be the DID the token was issued for.
That is the subject check a data source owes the person, and it is never skipped.
Scopes
The source follows the scope vocabulary. Each payload
carries covered and a sentence; a scope a persona has no data for says so
instead of inventing a reading.
| Scope | What comes back |
|---|---|
recovery |
the recovery band and score, weekly direction, a sleep label |
activity |
the muscle stimulus band and score from training, weekly direction, the training modality, a movement band |
cgm |
the glucose stability band and score, weekly direction, an anomaly flag. Covered for one persona only |
body_composition |
a muscle retention band and the direction of weight and lean mass. No mass, no percentage |
daily |
every pillar's current band, coverage day by day, the focus, the challenge week |
insights |
the newest derived insight per family, each a categorical label |
labs |
covered: false. No persona has laboratory results |
told_to_guardian is not served: it is what a person tells their guardian, held
by their node, and no data source holds it. A read of it answers 404
unknown_scope.
Bands are on-track, drifting and off-track; retention bands are
excellent, good and watch; directions are improving, steady,
declining and first-reading. Scores run from 0 to 100 and are derived, in the
same sense as the node's own contract: no response field holds a reading, a
mass, a percentage or a count of anything measured, and no sentence holds a
digit.
Errors
Every refusal is {"detail": "...", "error": "<code>"}. A 401 also carries
WWW-Authenticate: Bearer.
| Status | error |
When |
|---|---|---|
401 |
missing_token |
no bearer token |
401 |
invalid_token |
the sandbox says the token is not active: unknown, expired, or its grant was revoked |
403 |
wrong_token_type |
a session token, or any token that is not a data-access token |
403 |
insufficient_scope |
the token covers a different scope |
403 |
wrong_subject |
owner is not the DID the token was issued for |
400 |
owner_required |
no owner named |
404 |
unknown_scope |
the scope is not in the vocabulary |
404 |
unknown_week |
the persona has no such week |
503 |
introspection_unavailable |
the sandbox could not be asked about the token. Nothing is served |
503 |
source_not_authorized |
the sandbox refused the source's own client credentials (missing or wrong), so it could not check the token. Your token was not looked up. Nothing is served |
503 |
access_record_failed |
the access could not be written to the ledger. Nothing is served |
503 |
source_not_configured |
the source has no sandbox to check against |
MCP reference
POST https://lifecare.id/sandbox-source/mcp: streamable HTTP, stateless, JSON
responses. There is no session and no server-initiated stream, so GET answers
405.
A test fixture for agent governance pilots. This is not the member MCP surface, and its tools are not a preview of one. It exists so a gateway, a firewall or a policy layer in front of an agent has a consent-bound health data server to be tested against.
| Tool | Arguments | Token scope |
|---|---|---|
list_scopes |
none | any |
read_scope |
scope, owner |
scope |
get_weekly_summary |
owner, optional week |
daily |
- Every message is authorized.
initializeandtools/listincluded: a missing, inactive or wrong-type token is refused at the HTTP layer with the401and403shapes above, so a revoked grant ends the session at the next message. - Scope and subject refusals are tool errors. The result has
isError: trueandstructuredContentof{detail, error, status}with the same codes as REST. - Tokens are single scope, so one connection reads one scope. An agent that
needs two scopes holds two tokens and two connections.
list_scopesnames the owner and the readable scope, which is the natural first call. - There is no OAuth discovery on this endpoint. You get the token from the sandbox, as in the walkthrough, and send it as a header.
The personas
Five synthetic members, thirty days each, generated by the challenge readiness simulation that the node is tested against. Each is built to show one thing.
| Persona | What it shows |
|---|---|
p1-adherent-responder |
the healthy case: weight comes down while lean mass holds |
p2-glp1-muscle-loser |
weight comes down fast, a meaningful share of it lean, with exercise that is all cardio |
p3-under-recovered |
frequent training on short sleep: training on track, recovery off track |
p4-sparse-returner |
data that arrives, stops for a long stretch and resumes |
p5-glucose-anomaly |
glucose stability falling through the month while everything else stays flat |
Which persona you get. The source maps the resource owner's DID onto a persona: SHA-256 of the DID, the first four bytes read as a big-endian unsigned integer, modulo five, indexing the list above in order. The same identity always reads the same persona.
Choosing one. A grant whose purpose text carries persona:<id> pins that
persona, for example "purpose":{"text":"agent governance pilot persona:p2-glp1-muscle-loser"}
(and the same text as purpose on the token request). The pin lives in the
purpose the synthetic person consented to, so it cannot be changed per request.
The response's persona.selected_by says which rule applied: did-hash or
purpose.
The window is fixed. The data covers the thirty days ending on as_of. It
does not move with the calendar, and nothing new arrives.
Limits you should know about
- It is only as available as the sandbox. The source runs on the edge, but
it checks every token at the hosted sandbox. While the sandbox is unreachable
the source answers
503 introspection_unavailableand serves nothing. - Rate limits are the sandbox's. The sandbox limits token checks per calling
address, and every caller of this source arrives at the sandbox from the same
place. A burst from one tester can produce
503for another. - The reader is named by a label. The ledger entry names the source as
sandbox-source:synthetic-personas. A production source should have its own DID and recordactor_did(see Node identity). - It is not a conformance suite. It shows the behaviour a source owes; it does not certify yours. See the roadmap.
Run it locally
The source, its tests, a Compose file that runs it on your machine and an
example client that runs the whole loop are the public sandbox kit,
github.com/enbarca/lifecareid-sandbox
(Apache-2.0). The source you run checks every token at the hosted sandbox, as
the hosted source does, with a client of its own: register one with your
developer access token (step 1 of the sandbox guide) and put its
id and secret in compose/.env. There is no identity server to run: sandbox
mode is a runtime flag on the production binary, so no image of it is
published, and identity, consent and tokens stay at https://sandbox.lifecare.id.
git clone https://github.com/enbarca/lifecareid-sandbox && cd lifecareid-sandbox/compose
cp .env.example .env # SOURCE_CLIENT_ID and SOURCE_CLIENT_SECRET: the client you registered
docker compose up
# SOURCE=http://localhost:8799/sandbox-source (SANDBOX stays https://sandbox.lifecare.id)
The walkthrough above then runs unchanged with SOURCE pointed at your
machine, developer access token included; the kit's compose/README.md has it
by hand, and examples/run-loop.mjs runs every step and checks each answer
(LIFECAREID_DEVACCESS_TOKEN=... SOURCE=http://localhost:8799/sandbox-source node examples/run-loop.mjs).
The hosted source itself is a Cloudflare Pages Function; the kit's pages/
folder is that deployment, with the two project variables it reads.