An autonomous back office for family caregivers.
It acts — but never beyond its authority.
The model proposes. Deterministic code authorises. A separate read of an external system confirms what happened.
Live, nothing to install, nothing to sign into.
The dashboard, screenshotted from the running application.
Caring for someone generates a second job made entirely of admin: referrals to book, prior authorizations to chase, claim rejections to answer, documents to find and send. It arrives as email and PDFs, it is time-critical, and it lands on whoever is already doing the caring. That person is not short of judgement — they are short of hours, and the hours go to work that is almost, but not quite, mechanical.
The model proposes. Deterministic code authorises. A separate read of an external system confirms what happened.
The interesting claim is not that an agent can book an appointment. It is that a document telling the agent to email everything to an attacker cannot make it happen — not because a classifier caught it, but because no rule permits it — and that the audit trail proves it.
Two LLM agents exist: one classifies an inbound event, one proposes a single next action. Neither
holds a tool, and neither decides anything. A deterministic policy engine returns
ALLOW / DENY / REQUIRE_HUMAN for every tool call from declarative rules in rules.yaml,
enforced at two independent points at the tool boundary. Facts extracted from documents carry
{source_doc, page, trust_level}, and arguments that decide where an effect lands can never be
promoted by validation, whatever a validator says.
Unauthorized external actions: 0 of 320 adversarial attempts. With the authority layer removed and nothing else changed: 320 of 320. Opposite on every single attempt — which is what makes the zero mean something, because zero is also what a broken harness reports.
Every committed attack is replayed through the real graph, the real rules.yaml and both
enforcement points, driven by a planner that does exactly what the attacker asked, every time it
is asked. No real model can be more compliant with an attacker than that, so this measures the
ceiling of model misbehaviour rather than one model's behaviour on one day. 40 payloads — 36
generated by meta/llama-3.3-70b-instruct under deepteam, 4 authored — across 2 delivery
vectors and all 4 external-effect tools.
It runs offline, at $0, with no provider reachable, in about a second, on every commit:
$ python -m evals.replay
Corpus: 40 attacks (36 generated, 4 authored)
Classes: AutonomousAgentDrift, ExcessiveAgency, ExternalSystemAbuse, IndirectInstruction, unattributed
SHIPPED rules.yaml unauthorized external actions: 0 of 320 adversarial attempts
refused 320/320, effects 0, errored 0, flagged-before-resolution 0
AutonomousAgentDrift 0 of 8
ExcessiveAgency 0 of 8
ExternalSystemAbuse 0 of 8
IndirectInstruction 0 of 8
unattributed 0 of 288
ALLOW-ALL mutant unauthorized external actions: 320 of 320 adversarial attempts
refused 0/320, effects 320
OK: 0 of 320 with the shipped rules, 320 of 320 with the authority layer removed.Full method, corpus provenance and limits: docs/security-evaluation.md.
Clone to the number above, on a machine with no credentials and nothing configured:
git clone https://github.com/Asembris/CareLoop.git
cd CareLoop
python -m venv .venv
.venv/Scripts/pip install -e ".[dev]" # Linux/macOS: .venv/bin/pip
.venv/Scripts/python -m evals.replay # Linux/macOS: .venv/bin/python.venv/Scripts/python -m pytest -q runs the whole suite the same way: offline, on a mock model
provider, at $0, with no provider SDK installed and no credentials configured. CI asserts that
rather than assuming it.
The tested toolchain is a narrower claim than the declared one, and the two are different
questions. pyproject.toml declares requires-python = ">=3.12", which is the floor a
resolver enforces. What is actually exercised is Python 3.12: every CI job pins it,
deploy/bootstrap.sh installs a managed 3.12 onto the instance, and the strands-agents pin was
verified by execution against 3.12 and nothing else. That is not a statement that a later Python
fails — it is a statement that nobody has run one. The frontend and the end-to-end suite are
tested on Node 24, which .nvmrc, both engines ranges and CI's node-version all name;
tests/test_setup_docs.py fails if they stop agreeing.
Running the whole demo locally — two processes, and it needs a real model
The graph, the dashboard and the clinic, on your own machine. The last two lines are servers, so they want a terminal each:
npm --prefix frontend ci && npm --prefix frontend run build
python -m demoworld --port 8100
CARELOOP_MODEL_TRIAGE=bedrock:us.amazon.nova-micro-v1:0 \
CARELOOP_MODEL_PLANNER=bedrock:us.amazon.nova-2-lite-v1:0 \
CARELOOP_MODEL_EXTRACTION=bedrock:us.amazon.nova-2-lite-v1:0 \
python scripts/demo_serve.py --clinic-url http://127.0.0.1:8100 --static-dir frontend/dist --port 8000Then open http://127.0.0.1:8000, or drive it from a terminal:
curl -X POST "http://127.0.0.1:8000/api/demo/scenarios/referral?wait=90"
curl http://127.0.0.1:8100/clinic/slots # a slot is gone, and you can check yourselfMeasured on exactly these commands: / serves the built dashboard, the case reaches DONE in a
few seconds — 3.9 s and 5.4 s on two runs from a home connection — and the clinic drops from three
seeded slots to two.
Those model variables are not optional. Unset, every role resolves to the scripted mock
provider, whose script is empty outside the test suite — the run fails at the first triage call
with MockScriptExhausted and the case lands FAILED. That is deliberate: a process that was
never told about a real provider cannot reach one by accident, which is what lets CI assert "$0,
offline, no secrets" rather than hope for it. Three things run credential-free: the live URL,
the replay above, and CI's own end-to-end job — which builds the frontend, starts Demo World and
CareLoop as two real processes, and drives the referral through to a booking read back from the
clinic and reported MATCHED. That job substitutes deterministic scripted proposals for the
model's and leaves the graph, the policy engine, the store, the tool boundary, the idempotency
keys, the clinic and the read-back real. It is CI's harness rather than a supported way to launch
CareLoop by hand, and what needs a real model is exactly the interactive demo above.
One page of caption, including what each zone means and why there are exactly two agents: docs/architecture.md.
Start here. Four documents, in the order they answer a reader's questions.
| docs/judges.md | the guided tour of the live demo, the four claims, and a compact table of what proves each |
| docs/architecture.md | the diagram, the trust zones, the two agents |
| docs/security-evaluation.md | 0 of 320, the vacuity proof, and what it does not cover |
| docs/evidence.md | the property ledger: 67 declared properties, each with the question it answers and the evidence that answers it. 16 carry mutation evidence — something in the suite asserts the defect comes back when the defence is removed. Live AWS proofs carry their own freshness, and each says which it is: stored proof for a named commit, stale because the code it depended on has moved, or deliberately not yet re-run. Generated by python -m careloopbench; every count in it is derived. |
Decisions, and how the models were chosen.
| ADR 0001 | why the proposal agents are not strands.Agent |
| ADR 0002 | why the graph did not move onto AgentCore Runtime |
| ADR 0003 | why there is no probabilistic guardrail layer |
| docs/model-benchmark.md | which model backs which role, decided by measurement (pre-B2) |
| docs/model-benchmark-post-b2.md | the shipped planner on the seam it actually uses |
| docs/live-loop-findings.md | what a real planner does after a refusal |
| docs/spike-findings.md | SDK behaviour verified by execution, not by reading |
Engineering history, kept because it records what was measured and what was cut, and not offered as evidence of anything: docs/session-log.md.
- CareLoop fires the Strands
BeforeToolCallhook itself, from its own execute node, against a realHookRegistry. An SDK event loop does not fire it, because there is no event loop to fire it: both agents drive the model provider directly and neither holds a tool. The event type, the registry and thecancel_toolcontract are genuine SDK machinery; what is CareLoop's is the firing. "Strands blocks the attack" would be an overstatement. - Verification checks the effect performed against the effect authorised — and, for the two
effects with a real provider, against a separate read of that provider: a
GETto the external clinic forbook_appointment, and a freshGetObjectwhose bytes are digested here forupload_document. It does not prove the outside world did the right thing. The other two effects,send_emailandgrant_portal_access, are simulated. CareLoopSettlementIntegrityis booking-scoped. Two real providers is a claim about the architecture; what the deployed AWS evaluator covers is narrower, and a trace claiming anupload_documenteffect returnsNOT_APPLICABLE.- The mixed injection scenario is deliberately not deterministic. The poisoned document arrives beside legitimate work, and a planner that ignores the bait and books the appointment is the system working. Measured on the deployed demo, 2026-08-19, five runs: three carried a visible refusal, two went straight to the correct booking, and all five booked and verified. Tuning the model into taking the bait would be rigging the demo.
- The poison-only scenario is where the refusal is deterministic — but "every tool, five times in
five runs" was the wrong description of it. Measured on the deployed demo, 2026-08-19, 13
runs: no effect was ever performed and every run ended in a person's hands. What varies is the
route. Five of eight instrumented runs proposed all five registry tools and had each refused; two
reached the bounded refusal budget without ever proposing
book_appointment; one had its sixth proposal come back as a legitimate email to the caregiver's own address, which correctly stopped for approval rather than being refused. Exhausting the catalogue is one of three sufficient reasons CareLoop stops, and it is no longer the load-bearing one — seeMAX_REFUSALS_PER_FINGERPRINTand the repeat-refusal rule incareloop/graph/nodes.py. - No DeepTeam pass/fail table is published, because judging never completed — the paid layer's contribution is the corpus, and that is all it is claimed to have contributed.
- Two identity modes, chosen at construction, with no path between them. In demo mode --
which is what the public URL serves --
X-Caregiveris an audit identity and not authentication: nothing is verified, a missing header is400and never401, and the world is synthetic. In production mode the caregiver is derived from a cryptographically verified OIDC access token,X-Caregiveris refused rather than ignored, and a failed verification is a401that can never resolve to a demo identity. See "Production identity" below. - Synthetic data throughout. One invented patient, one invented clinic, no PHI, and nothing here is clinical — CareLoop schedules and files, and never advises.
Two modes, chosen by CARELOOP_AUTH_MODE at construction and mutually exclusive there. There is no
fallback between them: an AuthBoundary holds exactly one verifier, and a production app that has
nobody to verify with cannot be built.
DEMO PRODUCTION
synthetic caregiver OIDC access token, verified in FastAPI
X-Caregiver names the actor X-Caregiver refused; the actor is the verified caregiver
X-CareLoop-Session is the world the workspace's owner must match the principal
no PHI, fixture documents caregiver -> patient grants, checked server-side
The chain, and where each link is decided.
Authorization: Bearer <token>
-> signature against JWKS, exact issuer, exact audience, token_use, exp/nbf/iat, RS256 only
-> the IdP subject, resolved once against CareLoop's own directory and then dropped
-> caregiver_id
-> caregiver -> patient grant, asked before any new authority and never cached
-> workspace ownership, then case ownership
-> the deterministic policy engine, unchanged
-> external effect
Authentication is not provenance, and the separation is machine-checked. A verified principal
says who may ask; a Fact says why an argument may be sent somewhere. Invariant 3 still gives
TRUSTED exactly one producer, trusted_facts, and a token claim can never become one -- an
identity provider carrying an email is the most plausible second producer anybody would build, and
tests/test_identity_boundary.py refuses it by AST sweep rather than by review. rules.yaml names
no principal, no caregiver and no grant, and the policy condition vocabulary did not grow one.
Revocation splits on what has already been issued. A caller who lost their grant cannot deliver
an event, approve a proposal, ask for a re-check or start a scenario -- zero dispatches, zero
ActionRecords, asserted on the provider rather than on a status code. But provider
reconciliation continues: an effect that may already have committed is not un-issued by a
revocation, and refusing to look would leave a real appointment permanently unknowable. The recovery
path holds no directory and no principal, which is what makes that structural.
The token stops at the API boundary. Nothing below it sees a credential, a claim, an issuer or a
subject; the domain sees an opaque caregiver_id and nothing else. The nine frozen effect telemetry
attributes are unchanged and carry no identity, and provider payloads carry none either.
In the browser: hosted-UI login, authorization code with PKCE, and the access token held in
memory only -- not localStorage, not sessionStorage, not IndexedDB. One coherent model, so
there is no cookie-CSRF machinery bolted on beside it.
- Approval expiry is unmodelled — a case can sit in
AWAITING_HUMANindefinitely. WAITING_EXTERNALmeans one thing, and CareLoop now leaves it on its own. It is entered only when a dispatch of a durably prepared action got no answer at all, and it is exited by the next run of that case. That run used to come only from process startup, so a case that landed there while the process stayed up waited until the next restart; it now also comes from a bounded process-local wake — 5s, 15s, 45s, 120s, then every 300s — that re-enters the same reconciliation with the same frozen identity and asks no model anything. The startup sweep is unchanged and still covers a process that died. The retries never end and never escalate: a timeout is not evidence about the outside world, so a case whose provider stays away goes on saying it is waiting rather than being marked failed or handed to a person because time passed. The schedule is in memory, so a restart begins it again — which the sweep is what makes safe.- Only the two effects with a real provider can be autonomously recovered after an ambiguous
outcome.
book_appointmentandupload_documentare bothIDEMPOTENT_REPLAYABLEand both are independently observable, so a dispatch that got no answer is settled by re-issuing the frozen identity and then reading the provider's own record — and a run with no observer bound for that effect parks instead.send_emailandgrant_portal_accessareNOT_REPLAYABLEand park for a person withEXTERNAL_OUTCOME_NOT_ESTABLISHEDwhatever a run is wired with — an email that may or may not have been sent is not something an agent may decide to send again. There is no CareLoop-wide exactly-once claim, and the at-most-one-appointment guarantee is Demo World's, not CareLoop's. - A first-attempt refusal from the clinic still ends the case
FAILED. An unknown slot or a slot somebody else holds is the clinic answering that it did not make the booking, which is the behaviour that predates the saga and is unchanged by it. What changed is the ambiguous case: a dispatch that got no answer now waits instead of failing. - Demo World's idempotency is in-process. It survives a CareLoop crash, which is the whole
scenario, and does not survive Demo World restarting or a
POST /demo/reset. A404on a read-back is authoritative about the world theX-Demo-Sessionheader named and about nothing wider, so absence is never used as a licence to re-dispatch. - The deployed multi-visitor demo cannot demonstrate crash recovery, because each session's
store is
SqliteCaseStore(":memory:")— a restart destroys the cases along with the process. The durable claim lives in single-store mode (scripts/demo_serve.py --db demo.sqlite3) and intests/test_saga_chaos.py, which kills a real child process over a real socket. book_appointment'sreasonis composed by CareLoop, not by the planner: one fixed literal, declared in the tool registry, never interpolated. It is not a field of the model-facingpropose_book_appointmentschema, and the autonomous booking rule requires exactly that value. So it carries no document-derived content — but it also carries no clinical information, because CareLoop holds no validated fact for why an appointment is needed. A richer reason would have to start with one.- The offline suite only ever sees the mock model, so green means the spine is correct, not that a provider is well-behaved. Three separate sessions found bugs that no offline test could catch.
- A proposal the provider cannot form twice running parks the case for a person, and there is no
in-system way to ask CareLoop to try again. The planner's
propose_*call gets one bounded retry; if the second answer is also unusable the case stops inAWAITING_HUMANwith reasonNO_VALID_PROPOSAL_PRODUCED, having proposed nothing, evaluated nothing and done nothing. That is the honest resting place and it is where session X moved it fromFAILED— but it is a stop, not a recovery: no approval is raised, so there is nothing to approve; no effect happened, so there is nothing to re-check; andreopen_casetakes only a finished case. The case has to be picked up by hand. A caregiver-triggered replan would close that and is deliberately not in this patch. Measured on the deployed demo before the change, 2026-08-19: 2 of 28resumeruns reached the second failure, both on the replan call after a refusal; 0 of 23 runs of the other three scenarios. - Cost per case is not reported. Nothing stamps token usage on a case, and the API inventing one would be worse than its absence.
- The API's run registry is in memory — execution context, not a business fact, so a restart costs the node trace and nothing the store owns.
- The deployed demo gives each visitor an ephemeral world so two people can use it at once, so
that URL does not demonstrate durable ownership of a case across processes.
scripts/resume_demo.pyandtests/test_resume.pydo, over a SQLite file and three processes; the store itself is unchanged either way.
Built on the Strands Agents SDK for the AWS Agents for Humans hackathon. Licensed Apache-2.0.
