Try it before you read anything

https://d2ew2t4uldglcr.cloudfront.net. No account, no key, no login.

The page opens with a number it recomputes while you are looking at it:

4 of 46 answer deadlines in this corpus are ones that counting seven days by hand gets wrong. Three roll off a weekend under O.C.G.A. 1-3-1(d)(3). One is controlled by a summons stating a different date, which under O.C.G.A. 44-7-51(b) is the date that binds the tenant.

/api/stats runs the real engine on every request and returns both lists in full, so you can count the rows yourself rather than take my word for it. Nothing there is cached.

curl -s https://d2ew2t4uldglcr.cloudfront.net/api/stats

That command needs no key and no account. It returns both lists in full, so the 3 weekend rolls plus the 1 summons-controlled case add up to 4 in front of you.

A numbered three-minute walk of every live surface: https://d2ew2t4uldglcr.cloudfront.net/judge

Then press Sweep the queue. A real agent run starts on Amazon Bedrock AgentCore, reads 48 cases, and stops in front of you asking for a decision.

The problem, and who has it

In Georgia, a tenant served with a dispossessory (eviction) filing has seven days to answer. Miss it and the court issues a writ of possession. There is no grace period and no second notice.

The Atlanta Volunteer Lawyers Foundation reports nearly 40,000 evictions filed in Fulton County each year, with fewer than 2 percent of tenants represented (avlf.org, 2025). In completed 2015 Fulton County dispossessory cases, 54 percent of tenants never answered at all (Federal Reserve Bank of Atlanta, CED Discussion Paper 04-16, 2016). Most tenants who lose an eviction case do not lose an argument. They lose a clock.

Eviction-defense clinics are walk-in operations. A tenant arrives with a stack of paper, an intake volunteer writes notes, and somewhere in that queue are the two or three people whose deadline is tomorrow. The supervising attorney is one person with a finite day.

A walk-in clinic cannot watch every clock. That sentence is the whole product.

What the attorney actually gets

Here is what is waiting at 7am on a Tuesday, before anyone opens a laptop:

A ranked queue, worst first, capped at the number of interrupts that clinic said it can absorb today. Not 48 cases. Two.

Per case: the computed answer deadline, the days remaining, the full statutory trace showing how that date was reached, and one plain sentence saying why this case outranked the others.

A flag, never a guess, wherever the calendar is ambiguous. December 31 2026 comes back as "the statute does not move this, and the courthouse is closed", addressed to a human.

A packet memo, drafted only after approval, and only for the cases that were approved.

A notification that names a count and never a case, on the lock screen of the one person allowed to decide, while they are standing in a hallway at the courthouse.

What they do not get is a system that decided for them. The run ends in a question.

What it does

Instanter reads the intake queue, computes every answer deadline directly from the statute, ranks the queue by how close each case is to a default judgment, rations attorney interrupts to the capacity a clinic actually has, and then stops and asks a licensed attorney before it commits anything.

It runs itself at 7am on weekdays, in the court's own timezone, whether or not anybody opens the page.

How it uses Strands Agents

The agent is a strands.multiagent.GraphBuilder graph of three nodes: analyst → writer →(conditional)→ drafter

analyst perceives intake notes into typed fields. Tools: list_cases_with_notes, submit_case_observations, get_ranked_queue. writer explains why a case outranked the others, then asks. Tools: submit_escalation_rationale, commit_escalations. drafter writes the packet memo. Tool: write_packet_memo.

The edge into drafter is a real conditional edge, not decoration: its predicate is attorney_action == "approved" and committed_case_ids is not empty. A run that was fully deferred, or that had nothing to approve, never reaches that node at all.

The rest of the SDK surface, and every one of these is carrying weight:

strands.hooks (BeforeToolCallEvent, AfterToolCallEvent, HookProvider, HookRegistry) for the attorney interrupt and the audit trail. event.interrupt() and strands.types.interrupt.InterruptResponseContent for the human-in-the-loop pause and resume. GraphBuilder.set_session_manager with an S3 session manager, so an interrupted run's state survives the compute going away. strands.models.BedrockModel on Amazon Nova. SequentialToolExecutor, because these tools mutate shared run state and must not interleave. Structured output through Pydantic models, so a malformed tool call is rejected with a correction rather than silently coerced.

The design decision the whole thing rests on

The deadline math never touches the model.

A statutory deadline has no judgment call in it. O.C.G.A. 44-7-51(b) gives seven days from actual service. O.C.G.A. 1-3-1(d)(3) makes the day of service day zero and rolls a terminal day off weekends and legal holidays. An LLM computing that would be slower, unauditable, and wrong some fraction of the time, and in this domain "some fraction" means a family loses a home.

So the engine is plain Python with a full per-case trace, and the model does the two things it is genuinely good at here: reading unstructured intake notes into typed fields, and explaining in plain language why one case outranked the others for an attorney's scarce attention.

The hard part turned out to be the deterministic half. Georgia's legal holidays and the Fulton County courthouse closure calendar disagree on three dates in 2026. Two are state holidays when the courthouse is open. The third is the dangerous one: a tenant served December 24 has a day-seven deadline of December 31, the statute does not move it, and the building is locked. The engine computes the date, raises a flag saying a human must decide, and refuses to guess when the clerk reopens if its calendar data does not cover it.

Every ambiguity becomes a flag for a human. Never a silent resolution.

The interrupt is the product

An agent that always says yes has not been tested.

If a tired attorney types "ok sure" or presses return on an empty box and the run treats that as authorization, the human-in-the-loop step exists in the architecture diagram and nowhere else.

So the approval parser is deliberately strict. Only an exact approval approves: approve, approved, approve all. A deferral must be explicit and carry a reason. Everything else returns invalid.

You can try this on the live site. There is a third button next to Approve and Defer that sends a deliberate typo. That is not an oversight, it is the demo. Send it and the run reports FAILURE, because nobody actually decided.

Two more guards sit around that one.

The approval is bound to what was shown. A digest is recorded when the cases are presented and verified when the approval arrives. If the queue or a rationale changed in between, the approval is refused with STATE CHANGED DURING APPROVAL rather than applied to content the human never saw. When nobody decides, silence is not safe. These are the cases closest to a default judgment, so dropping them would be the worse failure. A deterministic floor commits them for later human review under explicit floor authority, and the run still reports failure. The work is preserved. The authority is not forged.

How it is built on AWS EventBridge Scheduler (7am weekdays, America/New_York) ─┐ a browser with no credentials ─┤ ↓ CloudFront ─ default ─→ S3 (console, private, OAC) └ /api/* ─→ Lambda Function URL ─→ Bedrock AgentCore Runtime └ Strands Graph, Nova, S3 sessions DynamoDB holds every run; a sparse index on status answers "what is still owed a decision"

InvokeAgentRuntime accepts only IAM SigV4 or an OAuth bearer token, so a browser cannot reach the agent at all. A Lambda holds the credential and invokes it server-side, and CloudFront injects a shared secret header the function requires, so a caller who finds the Function URL directly gets a 403.

The scheduled sweep and visitor traffic have separate daily spend caps, because they defend against different things: one stops a stranger draining model spend, the other stops a retried schedule firing in a loop. Sharing a counter would mean a busy day of visitors silently cancels the clinic's morning sweep, which is the one run that has to happen.

The audit trail lands in S3 Object Lock, Compliance mode, 30 days: sweeps, custom instanter.* spans, and every attorney decision. A decision that cannot be durably recorded says so on its own response rather than pretending. The run receipt you watch in the console is built from those spans: instanter.compute_deadline forty-eight times, instanter.triage_queue, instanter.attorney_interrupt, stop.

Where the human answers

The operator console is where someone watches the queue. The phone app is the attorney's decision surface, and deliberately not a second console: it exists for the escalation that fires while the one person allowed to decide is in a hallway at the courthouse. Same public door, no second backend.

iOS: public TestFlight link Android: signed APK on the repo's releases

At the interrupt, the iOS app also starts a count-only Live Activity (TestFlight build 14): the lock screen says how many cases wait, never which. Web Push does the same job for the console: a ping only when a sweep actually stops for an attorney, naming no case.

The door also reads paper. Photograph a summons and Nova Pro transcribes only what is printed; the engine computes the statutory last day. A date the model invented is a refusal, and a summons-stated date is never silently dropped, because under O.C.G.A. 44-7-51(b) it is the date that binds the tenant.

Challenges

Adversarial review found my fail-open paths, repeatedly. I ran a second model against my own code before anything shipped, and the loop does not stop at a declared finish, it stops at a clean round. Round one found three real defects in a suite that was already green, including a case where a summons-stated date could flow into a result looking like a finalized deadline after the computation had refused. Round two then found defects in the fixes, including the classic Python trap that isinstance(True, int) is true, so a boolean could become a one-day window. Twenty-four rounds in total produced seventy-one verified defects.

A guard that cannot fail is not a guard. My first version of the approval digest computed the digest at approval time instead of verifying one recorded at presentation time. It was comparing the state to itself and could never fail. Only writing the failing case first exposed it.

A test suite that never runs is the same as no tests. The console had fourteen tests and no CI job touched that directory, so a broken console would have merged under a green check. Fixing it meant proving the new job from a clean clone AND proving it could fail, including that a vanished suite exits non-zero rather than reporting a green run of zero tests.

Reserved concurrency, which I could not use. I planned it as the abuse control. This account's total Lambda concurrency limit is 10, not the 1000 I assumed, and reserving any of it drops unreserved concurrency below the required minimum and fails the deploy outright. The cap had to move to spend instead.

React Native's SafeAreaView is a silent no-op on Android. The header drew straight through the status bar. iOS looked perfect for exactly the reason the bug was invisible. No test could catch a component that does nothing on one platform; installing the binary and looking at it could.

What I learned

Read your own pitch as though a judge wrote it, then check whether the product does that.

The README said "a walk-in clinic cannot watch every clock" for weeks while the agent only ran when somebody pressed a button, which is precisely the thing the pitch says nobody has time to do. The product's central claim was the one thing it did not do. I did not find that by reading the code. I found it by grepping my shipped source for which services were actually wired and noticing that the scheduler was in the plan and nowhere else.

A green suite next to dead wiring is the most convincing wrong answer a system can give you. Almost every real defect this month came from making the thing happen and looking at it: firing the schedule by hand instead of waiting for 7am, installing the APK instead of trusting the build, screenshotting a form before pressing submit instead of trusting the tool that said it had filled it.

Rigor is not scored as rigor. It is scored as trust in the claim it supports. I cut a lot of numbers that measured whether the system was correct and kept the one that measures what a user gets.

What's next

Real intake data with a real clinic, under a real agreement. Everything demonstrated here runs on synthetic case records precisely because the alternative would be handling other people's eviction filings without one.

Scope, stated plainly. Instanter states operative facts and computes statutory deadlines for review by a licensed attorney. It does not give legal advice, does not file anything, and does not represent anyone. Every case record in the demonstration data is synthetic and labelled as such inside the product. The statute, the court calendar, and every computation are real. Built solo. Apache-2.0.

Built With

Share this project:

Updates