-
-
Confirm card for mid-day debt with integer amount (HITL capture)
-
Closing plan and Confirm & Send Statements (HITL gate)
-
Device-owned close ritual: localDay, Drive backup, aging
-
Collections Desk rank split: 5 PDFs and 2 text-only
-
Inbox with MIME PDF for Top 5 (Proof of Action)
-
-
Cloud Run service daftar-closing-agent in Console (GCP deploy) | Logs Explorer: SMTP 250 accept and Message-ID
-
Architecture diagram (One-glance stack)
-
Architecture HUD: Cloud Run, gemini-3.5-flash (Observability)
Judge glance
Taskmaster. Gemini 3.5 Flash (Vertex AI) + Google ADK on Cloud Run. After one merchant confirm, the device finishes the day: Drift ledger → Drive backup → FIFO aging → ranked Gmail (PDF Top 5). The model plans; the phone commits. SMTP 250 is Gmail accept, not mailbox-delivered.
Inspiration
Shops that still run on a paper دفتر close the day by hand. Balances live in the merchant’s head. Collections wait until someone remembers who owes. That close-the-day chore is the friction this project exists to remove.
I built Daftar Closing Agent (وكيل إغلاق الدفتر) for the Taskmaster track of All Things Agentic: not a chatbot that talks about the ledger, but an agent that plans the day’s close and then takes action — backup, aging, ranked Gmail — after one merchant confirm.
The product is Arabic-first (RTL ar default, en second). Built-in money is YER, SAR, and USD. That is a paper-ledger shop problem, not a pan-regional coverage claim.
The long-running task is the shop’s business day, persisted on the phone. Each Cloud Run /run is fast on purpose. Cloud Run may scale to zero. That is the design, not a compromise: idle always-on agents are a poor fit for a merchant who is offline most of the afternoon.
What it does
Mid-day. The merchant speaks or types a capture (Mohamed owes 500 sugar). Gemini 3.5 Flash on Cloud Run (Google ADK) emits a structured proposal. The device shows a Confirm card. Money hits Drift only after the merchant taps confirm.
Close-the-day. One plan (propose_closing_plan). One tap: Confirm & Send Statements. The device then runs a deterministic ritual:
- Drift
localDaysummary (closing totals come from the ledger, not from the model) - Google Drive backup upload
- FIFO aging rank of overdue accounts
- Collections Desk preview
POST /v1/email/send-batch→smtp.gmail.com
Send set (device-ranked, not model-picked): up to 20 overdue contacts with a valid email. MIME PDF statements on the ranked Top 5. The remainder get a fixed text-only reminder (Appendix C.2). Gemini does not choose recipients and does not author each email.
Confirm without sending is first-class: the ritual still runs; send-batch is never called.
Proof of Action. Inbox PDF on a Top-5 contact + Cloud Logging daftar.agent.email with SMTP 250 and a unique Message-ID. SMTP 250 means Gmail accepted the message. It is not mailbox-delivered, not a read receipt, and not a DSN. Gmail SMTP has no delivery webhook — this project does not fake one.
How I built it
Custom FastAPI on Cloud Run (daftar-closing-agent, us-central1) hosts ADK POST /run (web=False) plus sibling routes POST /v1/email/send-batch and POST /v1/tts. No second agent runtime. No webhook service.
| Layer | Choice | Why |
|---|---|---|
| Planning | One ADK LlmAgent named closing_agent, model gemini-3.5-flash on Vertex AI |
Contest-mandatory Gemini 3.5+; one hop for intent-dependent mid-day capture |
| Tools | Eight frozen FunctionTools — proposals only, no send-email tool | Catalog freeze tests; Gemini must not move money or mail |
| Source of truth | Flutter + Drift (SQLite) on device | Survives airplane mode and Cloud Run scale-to-zero |
| Money | amountMinor: int only |
No double / num on the wire or in Drift |
| Outreach | Sibling FastAPI + Gmail SMTP | Consent is the device button, not a tool call |
| Secrets | Secret Manager file mount /secrets/gmail-smtp-app-password |
Never in Flutter, git, or chat |
| Auth | Cloud Run IAM + ID tokens with custom OAuth audiences | No allUsers |
| Voice out | Cloud TTS Chirp 3 HD Enceladus (ar-XA / en-US) on POST /v1/tts |
Sibling route, not an ADK tool; flutter_tts is the offline fail-soft |
Official Architectural Discipline (30% of the score — also how Best Architectural Design is awarded; it is not a track you select):
- Decouple — Cloud Run plans; Drift commits; SMTP is a sibling route, not a FunctionTool.
- State — Drift + device
ClosingAgentPhase; scopeddaftarContext(the ledger is not dumped into Gemini). No vector database — a debt ledger is a financial source of truth, not a RAG corpus. - Credentials — ID-token custom audiences; Gmail App Password in Secret Manager only.
- Failures — confirm-gate lock per
proposalId; Drive skip does not abort close; per-row SMTPfailed/skipped; Cloud Run down leaves the ledger intact. Durable send key is devicebatchId/ Drift.
I did not ship SequentialAgent or a multi-agent graph. Mid-day capture is one intent-dependent hop (parallel propose_*). The close ritual is device-deterministic after one plan confirm. Taskmaster scores a complete workflow, not an org chart of sub-agents.
Data sources. Merchant-entered ledger data on device (contacts, transactions, integer balances). Demo path: onboarding Try with Demo Store (7 USD overdue contacts). No third-party credit bureau, no scraped inboxes, no vector index of the books.
Disclosure. The offline Drift ledger, Drive Auth V2 backup, and Khazna UI shell are pre-existing substrate, disclosed in docs/CONTEST_DISCLOSURE.md. The agentic closing workflow (ADK + Gemini 3.5 + Cloud Run + HITL + Gmail SMTP + Architecture HUD) was built during the Submission Period (3–31 Aug 2026). Hybrid E (wa.me drafts) is a contest-period leftover, not substrate and not the filmed climax.
Challenges I ran into
HITL vs “no human intervention.” Taskmaster scoring asks whether a multi-step workflow completes without babysitting the agent. Money and outreach still need a merchant. The honest split: Gemini proposes; the merchant confirms once; after Confirm & Send Statements the device ritual and SMTP dispatch run without further prompts. Confirm-without-sending is the same ritual with mail skipped.
Scale-to-zero vs send idempotency. Cloud Run min instances = 0. A process-local idempotency map on the service is wiped when the revision sleeps. The durable send key therefore lives on the device (batchId / Drift), not in Cloud Run memory.
Integer money across locales. Spoken 500 is 500 minor units in YER (0 decimal places) and 50_000 cents in the USD demo fixture. A single ×100 bug would silently 100× every Yemeni debt. Tools refuse floats.
Vertex routing. gemini-3.5-flash 404s on a narrow regional Vertex endpoint. The service pins GOOGLE_GENAI_USE_VERTEXAI=TRUE and GOOGLE_CLOUD_LOCATION=global.
SMTP honesty. It is tempting to narrate 250 as “delivered.” I did not. The inbox screenshot is Proof of Action; the log line is accept + Message-ID.
Custom audiences. Cloud Run invokers use Google ID tokens whose audience is the OAuth client IDs, not “the service URL.” Getting that wrong looks fine in a happy-path demo and fails for real Android/iOS clients.
What I learned
- The agent should be a clerk who proposes, not a cashier who writes the books. Confirm-before-commit is the architecture, not a UI flourish.
- For an offline merchant, the phone is the system of record. Cloud is a planner and a mail worker that may disappear.
- Frozen tools are cheaper to reason about than a growing toolbox. Eight FunctionTools, catalog-freeze tests, no ninth “just send it” tool.
- Christina Lin’s pre-submission check-in (25 Aug 2026): fast
/runis not a penalty. What counts is whether the user’s task spans the day — and whether the demo shows the work, not a chatbot recap.
Try it / docs
Repo. Private GitHub; shared with testing@devpost.com and cloudhackathons@google.com (Rules §6). Open the code repo field on this form (not the public blob links). Paths below are in that clone.
- Spin-up:
README.md - Architecture:
docs/architecture/contest_architecture.png(same file as the required Devpost upload) · captions indocs/architecture/contest_architecture.md - Film script:
docs/contest_demo.md - Cloud Run: IAM-authenticated (
roles/run.invoker). Unauthenticated GET is 403. Use the Hosted project URL field on this form; do not treat it as a public demo.
Log in or sign up for Devpost to join the conversation.