DEV Community

Aarish mansur
Aarish mansur

Posted on AI-assisted

My college ERP keeps logging me out. Sloth Clerk checks my 75% attendance at 7:30 AM and asks my phone before it clicks Submit

Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady Submission 🦥

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady

What I Built

If you study at an Indian college, you know the number 75. Fall below 75% attendance in a subject and you can be stopped from writing the exam. So every few days I do the same ritual: open the college ERP, type my ID and password, wait, click Attendance, scroll through 6 subjects, do mental maths ("if I bunk Friday's lab, am I still safe?"), then check Notices in case some circular dropped overnight, then Marks. Somewhere in the middle the portal logs me out and I start again.

My classmates do the exact same thing. My friend Chirag found out about a submission deadline from the WhatsApp group two days late, because nobody checks the Notices tab.

So I built Sloth Clerk, a small, opt-in agent that does the ritual for me:

  • Every morning at 07:30 IST it logs into the ERP, reads attendance, notices and marks, and saves a snapshot.
  • It compares today's snapshot with the last one and only tells me when something actually changed: a new notice, a new mark, or an attendance percentage that moved.
  • For attendance it does the bunk maths for me: "Attendance in Analysis of Algorithms is 58.0%. You can miss 0 more classes while staying at 75%." (That one hurt.)
  • If I ever want it to do something on the portal, like submit a leave application or pending feedback, it never clicks Submit on its own. It sends an approval request to my phone through Auth0, and the form is only submitted after I tap approve.

It supports up to five accounts, stores portal passwords encrypted, and comes with a mock college ERP so anyone can try it without touching a real portal.

The sloth part: I set it up once and stop opening the portal. It wakes up, does the boring clicking, and only bothers me when there's something worth reading.

Demo

Code

Sloth Clerk

Sloth Clerk is a small, opt-in college ERP agent for calmer mornings. It checks attendance, notices and marks with Stagehand and hosted Gemma through Google AI Studio, records snapshots and only sends an update when the data changes. It never submits a form unless Auth0 CIBA returns an approved request and the backend passes that request ID to the worker. Inference runs remotely; model weights are not downloaded locally.

Demo first. Use the included mock ERP at http://mockportal:4000 when running with Compose. Do not connect a real college portal until you have permission to automate it.

Architecture

flowchart LR
    UI[SvelteKit dashboard] -->|Bearer session| API[Go API + 7:30 IST scheduler]
    API --> DB[(PostgreSQL)]
    API -->|encrypted portal credentials| API
    API -->|POST /check and approved POST /act| WORKER[Node worker]
    WORKER --> STAGEHAND[Stagehand browser agent]
    STAGEHAND --> BB[Browserbase remote Chrome]
    STAGEHAND --> LOCAL[Local Chromium for mock mode]
    WORKER -->|DOM + screenshot extraction|
…

How I Built It

Architecture

flowchart LR
    UI[SvelteKit dashboard] -->|Bearer session| API[Go API + 7:30 IST scheduler]
    API --> DB[(PostgreSQL)]
    API -->|POST /check and approved POST /act| WORKER[Node worker]
    WORKER --> STAGEHAND[Stagehand browser agent]
    STAGEHAND --> BB[Browserbase remote Chrome]
    STAGEHAND --> LOCAL[Local Chromium for mock mode]
    WORKER -->|DOM + screenshot extraction| GOOGLE[Google AI Studio: Gemma 4 open-weight model]
    API -->|CIBA request / token poll| AUTH0[Auth0 phone approval]
    API --> TG[Telegram, optional]
    WORKER --> MOCK[Mock college ERP]

There are four pieces:

  • Go API (backend/): auth, encrypted portal credentials (AES-GCM), the daily scheduler pinned to Asia/Kolkata, snapshot diffing, and the Auth0 approval flow. Postgres is accessed through sqlc-generated queries.
  • Node worker (worker/): runs Stagehand, logs in, navigates, and extracts.
  • SvelteKit dashboard (web/): onboarding, change feed, run history, approvals and Lazy Payoff numbers.
  • Mock ERP (mockportal/, Go): a fake college portal with cookie sessions that expire after 20 minutes by default (MOCK_SESSION_TTL), plus an endpoint to force-expire sessions. That way I could test the logout-in-the-middle problem on purpose.

To try it locally with the included mock ERP:

cp .env.example .env   # fill in secrets + GEMINI_API_KEY
docker compose up --build -d
# open http://localhost:3000, add portal http://mockportal:4000
# username: student  password: sloth123
⚽ Live Match: aarishmansur/my college erp keeps logging me out sloth clerk checks my attendance at am and asks my jl - Info Live Streaming 2026 ⚽ Live Match: aarishmansur/my college erp keeps logging me out sloth clerk checks my attendance at am and asks my jl - Info Live Streaming 2026

The open model: Gemma 4, hosted, plugged straight into Stagehand

I didn't want to download model weights on my laptop, so Sloth Clerk calls Gemma 4 (gemma-4-26b-a4b-it) hosted on Google AI Studio. To be clear, inference is remote; nothing runs on-device. The model ID comes from LLM_MODEL, and you can set an optional LLM_FALLBACK_MODEL.

Stagehand lets you give it your own generate function, so I wrote a small hosted generator on top of the Vercel AI SDK's Google provider and passed it into Stagehand:

// worker/src/browser.ts
const metrics = newMetrics();
const stagehand = await Stagehand.create({
  browser,
  model: { generate: makeHostedGenerator(metrics) },
  systemPrompt:
    "You are Sloth Clerk, a careful browser agent. Use the visible ERP page only. Never submit a form unless explicitly instructed to act on an approved request.",
  cache: false,
});
⚽ Live Match: aarishmansur/my college erp keeps logging me out sloth clerk checks my attendance at am and asks my jl - Info Live Streaming 2026 ⚽ Live Match: aarishmansur/my college erp keeps logging me out sloth clerk checks my attendance at am and asks my jl - Info Live Streaming 2026

Inside the generator, every call is timed and its tokens counted, so each run stores model latency, input/output tokens and an estimated cost:

// worker/src/inference.ts
const google = createGoogleGenerativeAI({ apiKey: config.geminiApiKey });
const started = Date.now();
const result = await generateText({
  model: google(modelName),
  system,
  messages: toModelMessages(params.messages),
  temperature: params.temperature ?? 0.1,
  abortSignal: AbortSignal.timeout(config.llmTimeoutMs),
});
const elapsed = Date.now() - started;
metrics.calls += 1;
metrics.modelMs += elapsed;
metrics.inputTokens += result.usage.inputTokens ?? 0;
metrics.outputTokens += result.usage.outputTokens ?? 0;
⚽ Live Match: aarishmansur/my college erp keeps logging me out sloth clerk checks my attendance at am and asks my jl - Info Live Streaming 2026 ⚽ Live Match: aarishmansur/my college erp keeps logging me out sloth clerk checks my attendance at am and asks my jl - Info Live Streaming 2026

If the primary model throws or returns JSON that doesn't match the schema, the generator retries once with the fallback model. There's a test for exactly that case (worker/test/inference.test.ts).

Reading the ERP with Stagehand extract + a schema

Each section has a plain-English prompt and a Zod schema. Gemma gets the page (with a screenshot), and Stagehand validates the result against the schema:

// worker/src/types.ts
export const attendanceRowSchema = z.object({
  subject: z.string(),
  attended: z.number().int(),
  total: z.number().int(),
  percent: z.number(),
});
export const attendanceExtractSchema = z.object({
  attendance: z.array(attendanceRowSchema),
});
⚽ Live Match: aarishmansur/my college erp keeps logging me out sloth clerk checks my attendance at am and asks my jl - Info Live Streaming 2026 ⚽ Live Match: aarishmansur/my college erp keeps logging me out sloth clerk checks my attendance at am and asks my jl - Info Live Streaming 2026
// worker/src/check-ai.ts
const extracted = await stagehand.extract(PROMPTS[section], SCHEMAS[section], {
  screenshot: true,
  timeout: config.extractTimeoutMs,
});
⚽ Live Match: aarishmansur/my college erp keeps logging me out sloth clerk checks my attendance at am and asks my jl - Info Live Streaming 2026 ⚽ Live Match: aarishmansur/my college erp keeps logging me out sloth clerk checks my attendance at am and asks my jl - Info Live Streaming 2026

The attendance prompt is deliberately boring: "Extract the student's attendance table. For every subject return the subject name, number of classes attended, total number of classes, and attendance percentage as a number (not a string). Include every row." The system prompt adds: "Only return details visible on the page; do not guess."

Navigation uses stagehand.act("open the attendance page") first. If that lands on the login screen (session expired), the worker logs in again, counts a re-login, and retries. If natural-language navigation still fails, it falls back to the known path, like /attendance.

The 75% maths

The Go backend diffs the new snapshot against the previous one. For attendance it computes how many more classes you can skip and stay at or above 75%:

// backend/internal/app/checks.go
skips := int(math.Floor(number(row["attended"])/.75 - number(row["total"])))
if skips < 0 {
    skips = 0
}
text = fmt.Sprintf("Attendance in %v is %.1f%%. You can miss %d more classes while staying at 75%%.", row["subject"], pct, skips)
⚽ Live Match: aarishmansur/my college erp keeps logging me out sloth clerk checks my attendance at am and asks my jl - Info Live Streaming 2026 ⚽ Live Match: aarishmansur/my college erp keeps logging me out sloth clerk checks my attendance at am and asks my jl - Info Live Streaming 2026

The Auth0 approval gate (the part I care about most)

An agent that can click around my college portal shouldn't be able to submit things on its own. So state-changing actions (only leave and feedback are allowed) go through Auth0 CIBA (Client-Initiated Backchannel Authentication). The backend asks Auth0 to push an approval to my phone, with a binding message like "Sloth Clerk: submit leave form 2026-10-28 to 2026-10-29":

// backend/internal/app/approvals.go
form := url.Values{
    "client_id":        {a.cfg.Auth0ClientID},
    "client_secret":    {a.cfg.Auth0ClientSecret},
    "scope":            {"openid"},
    "login_hint":       {email},
    "binding_message":  {truncateRunes("Sloth Clerk: "+actionText, 100)},
    "requested_expiry": {"300"},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Auth0Domain+"/bc-authorize", strings.NewReader(form.Encode()))
⚽ Live Match: aarishmansur/my college erp keeps logging me out sloth clerk checks my attendance at am and asks my jl - Info Live Streaming 2026 ⚽ Live Match: aarishmansur/my college erp keeps logging me out sloth clerk checks my attendance at am and asks my jl - Info Live Streaming 2026

It then polls Auth0's token endpoint every 5 seconds for up to 5 minutes. Only a successful token response moves the approval to approved, through a conditional update that also checks the Auth0 request ID:

tag, err := a.db.Exec(context.Background(), `UPDATE approvals SET status='approved',decided_at=now()
    WHERE id=$1 AND status='pending' AND auth0_req_id=$2 AND length(auth0_req_id)>0`, approvalID, authReqID)
if tag.RowsAffected() == 1 {
    go a.executeApproved(approvalID)
}
⚽ Live Match: aarishmansur/my college erp keeps logging me out sloth clerk checks my attendance at am and asks my jl - Info Live Streaming 2026 ⚽ Live Match: aarishmansur/my college erp keeps logging me out sloth clerk checks my attendance at am and asks my jl - Info Live Streaming 2026

Denied or expired requests are recorded and nothing is submitted. Pending polls are also persisted and resumed if the API restarts. On the worker side there's a second lock. The only function that can click a submit button refuses to run without both IDs:

// worker/src/actions.ts
export async function runApprovedAction(req: ActRequest): Promise<ActResult> {
  if (!req.approvalId.trim() || !req.auth0RequestId.trim()) {
    throw new Error("refusing to run a state-changing action without an approved Auth0 request ID");
  }
  ...
}
⚽ Live Match: aarishmansur/my college erp keeps logging me out sloth clerk checks my attendance at am and asks my jl - Info Live Streaming 2026 ⚽ Live Match: aarishmansur/my college erp keeps logging me out sloth clerk checks my attendance at am and asks my jl - Info Live Streaming 2026

After submitting, the worker reads the page and only reports success if the ERP actually shows a confirmation message. There's deliberately no "approve" button in the dashboard: if Auth0 isn't configured, actions just stay blocked.

Browserbase

The browser runtime is switchable with BROWSER_PROVIDER. With browserbase, Stagehand launches a Browserbase remote Chrome session, which is the mode meant for a real ERP on the internet. With local, it uses Chromium inside the worker container, which the Docker Compose mock portal needs, because a cloud browser can't reach http://mockportal:4000 inside my Docker network.

// worker/src/browser.ts
if (provider === "browserbase") {
  browser = await browserbase.launch({
    apiKey: config.browserbaseApiKey,
    ...(config.browserbaseProjectId ? { projectId: config.browserbaseProjectId } : {}),
  });
} else {
  browser = await localBrowser.launch({ headless: config.headless, viewport: { width: 1280, height: 900 } });
}
⚽ Live Match: aarishmansur/my college erp keeps logging me out sloth clerk checks my attendance at am and asks my jl - Info Live Streaming 2026 ⚽ Live Match: aarishmansur/my college erp keeps logging me out sloth clerk checks my attendance at am and asks my jl - Info Live Streaming 2026

This week I ran 4 Browserbase sessions against a publicly hosted copy of the mock ERP. Each one logged in, read all three sections, and closed cleanly, and the session replay made it easy to see exactly where act clicked.

What didn't work

These are the rough edges I had to work around:

  • Gemma doesn't always return clean JSON. Sometimes it wraps the answer in Markdown code fences. The generator strips them and slices out the first {...} before parsing, then validates with the schema. When that still fails, it tries the fallback model. That happened in 3 of 54 extracts.
  • AI extraction sometimes fails, so there's a non-AI safety net. If extract throws or returns zero rows for a section, the worker logs a warning and reads the table straight from the DOM. That's honest but a bit humbling: for a well-structured page, plain selectors still work. 1 run needed the DOM fallback.
  • Natural-language navigation isn't always enough. stagehand.act("open the attendance page") can miss, so every section also has a fixed fallback URL.
  • Session expiry mid-run. The portal logging out between sections was the problem that started this whole project, so re-login detection runs after every navigation and the count is saved per run.
  • Browserbase vs Docker networking. A cloud browser can't see a container-only mock portal, which is why the default local setup uses local Chromium.
  • The "minutes saved" number is an estimate, not a measurement. The dashboard multiplies runs by 3 minutes per check, my own guess at what one manual portal visit costs.
  • Auth0 CIBA setup took longer than the code. Getting the tenant, the Guardian push on my phone, and the right grant type enabled took most of an evening before the first approval came through.

Why Does Open Innovation Matter?

I want to be accurate here: Sloth Clerk does not run the model on my laptop. It calls Gemma 4 hosted on Google AI Studio. So the case for "open" isn't "offline". It's these things:

  • Open weights mean the model isn't a black box I'm locked into. Gemma is an open-weight model. The same gemma-4-26b-a4b-it I call today through AI Studio can be served by other providers or self-hosted later if my college or a classmate wants their data to stay elsewhere. The model ID is just an environment variable.
  • The agent harness is open source. Stagehand is open source, so I could replace its model layer with my own generate function, add timing and token accounting, and force a "do not guess" system prompt. I couldn't do that with a closed browser-agent product.
  • The provider is swappable. The worker talks to the model through one small file (inference.ts) using the Vercel AI SDK, with an optional fallback model. Changing providers means changing that file and two env vars, not rewriting the agent.
  • Cost is visible. Every run stores input/output tokens, latency and an estimated cost. This week, 18 runs used about 410,000 input and 9,800 output tokens, and it cost me $0 on the free tier.
  • Everything else is open too. The whole project is MIT-licensed: Go, Postgres, SvelteKit and a mock ERP anyone can run.

The honest trade-off: page content (attendance, notices, marks) is sent to a hosted model. That's why the project is opt-in, capped at five accounts, encrypts portal passwords, and tells you to use the mock portal until you have permission to automate a real one.

The Lazy Payoff

The dashboard's Lazy Payoff cards come straight from /api/metrics. Here's this week:

  • Portal logins avoided: 18 (one per completed check)
  • Time returned: 54 minutes (estimated at 3 minutes per check; see above)
  • Notices caught: 5
  • Re-logins the agent handled for me: 7
  • Approvals requested / approved / denied: 3 / 2 / 1
  • Model usage: 54 calls, about 410,000 input + 9,800 output tokens, about 96 seconds of total model time, $0
  • Extraction success: attendance 18/18, notices 17/18, marks 18/18 (DOM fallback needed once)

Chirag and I used it for 3 days. His reaction after the first morning message: "bhai ab main portal kholunga hi nahi."

Prize Categories

  • Best Use of Browserbase: Stagehand drives the ERP end to end (login, natural-language navigation, schema-validated extraction, approved form submission) and launches on Browserbase remote Chrome via BROWSER_PROVIDER=browserbase.
  • Best Use of Auth0 for AI Agents: every state-changing action needs Auth0 CIBA phone approval. Approval comes only from a successful Auth0 token poll, persisted polls resume after restart, and the worker refuses to act without the Auth0 request ID.
  • Best Use of Gemma: Gemma 4 (gemma-4-26b-a4b-it, hosted on Google AI Studio) is the model behind every Stagehand act and extract call.

Top comments (0)