Skip to main content
Skip to content
Iris, the agent inside Elevate

It has 76 tools, and it cannot reach a single one without passing three checks

Iris operates the ERP rather than talking about it: it admits students, marks attendance, posts fee entries and files results through a typed tool registry. This page is about the mechanism — which tools exist, how your role narrows them, what gets written to the audit log, and what a request costs in real tokens.

Counted, not estimated

Every figure below is a constant or a count in this codebase, named in the comments of this file so it can be re-derived rather than trusted.

76
tools in the registryEach one a typed entry with a schema, a required permission and a handler.
4
risk classesRead-only, reversible write, financial, destructive. The class decides the guard.
3
checks before a tool runsRole, then plan entitlement, then that the record belongs to your school.
6
model turns on an ordinary requestRaised to 20 when the wording is a bulk one. Either way a run that completes no new tool call for two turns running halts on its own.
1
audit row per changeWritten to schools/{id}/auditLogs with the before and after states.
1,000
tokens per creditBilled from the provider’s reported usage, not from an estimate.

The registry

The agent has no general access to your database

It cannot compose a query. It can only call one of 76 registered tools, each with a fixed input schema, a declared risk class and a permission it demands before it will run. The model chooses which tool to call; it does not get to choose what a tool is allowed to touch.

The four risk classes in the Iris tool registry, with the number of tools in each, whether a confirmation prompt is required, what the class permits, and an example tool with the permission it requires.
Risk classToolsAsks firstWhat the class permitsExample → permission
read_only37noneFetch and summarise. Holds no write path at all.get_fee_defaultersfees.read
reversible_write3311 of 33Create or amend a record, leaving a prior state that can be restored.admit_studentstudents.write
financial5alwaysMove money — take a payment, raise a demand bill, run payroll.collect_paymentfees.write
destructive1alwaysOverwrite current state with a previously recorded state.undo_actionsystem.write
Exactly one tool in the registry is classed destructive, and it is the undo.
Ordinary writes are only ever reversible, because the audit row holds the state that preceded them. The operation that genuinely destroys something is the one that overwrites the present with the past — so undo_action is the only call in the registry carrying that class. It is one of six that demand system.write, and it always asks before it runs.

How Iris works

An agent you can hand a task to, and audit afterwards

One run, line by line. The interesting part is not that Iris understands the request — it is the order of what happens next: a fixed set of typed tools rather than free database access, the permission a tool requires checked in code before its handler is reached and a role without that grant refused rather than served, and plain arithmetic doing the part that has to be right.

Recorded demonstration

Sample data for Demo Public School, a fictional school. Not a live session — nothing here leaves your browser. The tool names, the permission each one requires, and the order the runtime does things in are the product’s real ones.

Choose a request

Asked

“Which Class 9 students are below 75% attendance this term?”

Signed in asPrincipalprincipalgrants students · attendance · academics · system.read

Which tools a request can reach is a property of this account, resolved from the school’s own staff records rather than from anything in the wording of the question.

  1. Select
    classifyIntent("…below 75% attendance…") → attendance + core-read schemas

    The request is classified before any tool schema is sent. Keyword matching keeps the attendance family, and because the message reads as a question the core read tools are added on top so the model always has a legal way to look at data. Every other family — fees, payroll, timetable, exams, messaging — is withheld on this turn. A count is not quoted here on purpose: the registry gained and lost entries twice in one afternoon while this page was being corrected, and a hard-coded denominator would have been wrong by the time you read it.

    Narrowing is a cost decision as much as a safety one — every schema that survives is re-sent on each turn of the loop.

    services/ai/toolRegistry.ts · classifyIntent()

  2. Callacademics.read checked
    get_class_info({ className: "Class 9" })
    → { classId: "CLS-2026-4K7A", sections: ["A", "B", "C", "D"] }

    “Class 9” is resolved against this school’s own class documents rather than trusted as written, so the later calls carry a real class id instead of a string the model chose.

    The signed-in role is checked against the tool’s required permission, and the tenant scope against its arguments, before the handler runs. Neither verdict is the model’s to make.

    services/ai/agent-runtime.ts · authorizeToolCall() before executeTool()

  3. Callstudents.read checked
    search_students({ classId: "CLS-2026-4K7A", status: "active" })
    → { count: 132, students: [ … ] }

    The roster comes back filtered by class id, not class name. Records created before the class-resolution fix carry an empty name field, and matching on it would have quietly returned nothing.

    Calls issued in the same turn run concurrently, and each result is truncated to 6,000 characters before it goes back to the model.

    services/ai/agent-runtime.ts · Promise.all over toolCalls · MAX_TOOL_RESULT_CHARS

  4. Callattendance.read checked
    get_attendance({ classId: "CLS-2026-4K7A", onlyAbsent: true })
    → { date: "2026-08-19", absentCount: 11, … }

    Attendance is read from the same collection the dashboards and the biometric gateway write to. There is no separate reporting copy that can drift out of agreement with it.

    Scoped to this school for the whole run. A tenant is not a filter the model can choose to omit — it is applied to the query path.

    services/ai/authorization.ts · validateTenantScope()

  5. Compute
    calculateEligibility(records, threshold = 75)

    The shortfall itself is arithmetic, not a judgement. Present days plus late arrivals plus half-days at 0.5, over working days, against the threshold your school sets — and it returns how many consecutive days each student needs to climb back above it.

    Deterministic, so two people asking the same question on the same day get the same list. That is what makes a disputed number arguable.

    features/attendance/services/eligibilityEngine.ts

  6. Close
    loop 4 / 6 · tools 3 ok, 0 failed · writes 0

    Each turn of the loop is decided from the results of the one before it, so the number of turns depends on the request rather than being fixed in advance. The loop ends when the model stops asking for tools, when two turns in a row achieve nothing new, or at the ceiling — six turns for an everyday request like this one, twenty for a bulk or setup instruction whose dependency chain is genuinely that long. If it had answered in prose without calling a function, the runtime would discard that text and say the request did not run — a narrated success is the one failure mode worth being blunt about.

    This run wrote nothing, so there is no audit entry. Writes are what land in the audit log, with the actor, the collection, the record and the payload before and after.

    services/ai/agent-runtime.ts · MAX_LOOPS, NO_PROGRESS_LIMIT, writeAuditLog()

Returned

Fourteen students below the 75% threshold. The three furthest behind, with the recovery each would need.

StudentSectionAttendanceDays to recover
Aarav Mehta9-B68.4%14
Ishita Rao9-A71.9%9
Kabir Sen9-C73.2%6

Fourteen of the 132 active students in Class 9 are below 75% for this term. Aarav Mehta (9-B) is furthest behind at 68.4% and needs 14 consecutive present days to clear the threshold. I have not changed any records — say the word if you want me to notify their guardians.

Select · phase 1 of 6

What Iris is not. It is not autonomous — it runs only when someone asks it something, and every request has a bounded number of steps rather than looping until it finds an answer: six for an everyday question, twenty for a bulk or setup instruction, and it halts earlier than either if two steps in a row achieve nothing. It does not train on your data. If you supply your own model key it is stored sealed with AES-256-GCM per school, and usage is metered in real tokens so the cost of a request is visible rather than estimated. The three runs above either read or were refused and none of them changed anything; writes are what land in your audit log, with the user, the tool and the payload, and the only tool the registry marks destructive is the one that undoes them.

The full tool registry, role gating and cost metering

Layer one of three

The same request does different things depending on who asks

Before a tool is offered to the model, the asker's role is resolved and checked against it. A refusal is not the model declining — it is a throw from canExecuteTool that the model never gets a say in. Pick a role to see the grants it actually holds.

Runs the whole school.

Grants held (23)

  • students
  • teachers
  • staff
  • fees
  • attendance
  • exams
  • payroll
  • documents
  • id_cards
  • dashboard
  • academics
  • system
  • settings
  • library
  • transport
  • hostel
  • timetable
  • communication
  • hr
  • inventory
  • events
  • admissions
  • reports
Can run
collect_paymentneeds fees.writeThe bare `fees` grant covers every action on the module, writes included.
Refused
No tool is refused to an admin on role alone. The other two layers still apply: the plan must include the feature, and every record id must resolve inside this school.

Layer two, entitlement. Some tools also name a plan feature — payroll is one — and are withheld from a school whose plan does not include it, whatever the asker's role.

Layer three, tenancy. Any student, teacher or staff id in the arguments is read back from schools/{yourSchoolId}/… before the tool proceeds. An id belonging to another school does not resolve, so it cannot be acted on — a cross-tenant reference fails as a missing record rather than as a permission decision.

After the write

One row per change, and this is every field in it

schools/{schoolId}/auditLogs/{autoId}

{

action: "CREATE" | "UPDATE" | "BULK_CREATE" | "ROLLBACK",

targetCollection: "students",

targetDocumentId: <id of the document written>,

before: null,

after: <the document as written>,

actorId: <uid of the signed-in user>,

actorEmail: <their email>,

institutionId: <the tenant above the school>,

schoolId: <this school>,

source: "iris_agent",

createdAt: new Date().toISOString(),

timestamp: serverTimestamp(),

}

action — The four values the agent actually writes, taken from its fourteen `writeAuditLog` call sites. There is no "DELETE" among them, which this page used to list — no agent tool deletes a document, so the row never records one.

before — The state that preceded the change, serialised and capped at 2,000 characters. `null` on a create, because nothing preceded it. This field is what makes a write reversible.

after — The state as it now stands, so the row is readable on its own without joining back to the record — which may since have changed again.

actorId — The person, not the agent. Nothing is ever recorded as having been done by the AI on its own behalf, because nothing is.

schoolId — Recorded inside the row as well as being the path it is stored under. Redundant on purpose: a row exported out of its collection still says which school it belongs to.

source — A constant, and the reason an administrator can separate agent-made changes from hand-made ones in the same log and review only those.

createdAt — An ISO string, and the field the rollback actually orders by. It exists alongside `timestamp` because Firestore sorts by value TYPE before value, so a Timestamp here would put every agent row on one side of every hand-made row and break "most recent action" for undo.

timestamp — Set by the database, not by the caller, so the ordering of the log does not depend on a client clock.

Reversal reads this row rather than guessing: undo_action reads the newest fifty rows in this school’s log, keeps the ones your own account wrote, and restores the before state of the most recent of those. That fifty is the real reach of the undo, so it is stated rather than left to be discovered: if fifty rows have been logged since, yours is no longer the agent’s to reverse and it says so instead of reversing the wrong one. It also refuses to undo an undo. An admission does more than write the row, too — it reads the new document back, confirms it exists, and checks the stored name against the one it was given, so a write that did not land is reported as a failure instead of being assumed to have worked.

What a request costs

Metered on the tokens the provider actually reported

Not on an estimate of message length, which is what this used to do. The superseded function is still in the file marked deprecated, so you can see the difference for yourself.

tokens = totalTokens ÷ 1,000

work = writes × 2 + reads × 0.5

cost = ⌈(tokens + work) × speed⌉

Speed is 0.8 in fast, 1.0 in balanced, 1.5 in deep. A write is weighted four times a read because it consumes a database transaction and produces an audit row. Any turn that reached a provider costs at least one credit, and one credit is 1,000 tokens.

Worked example — arithmetic, not a measurement

4,200 tokens ÷ 1,000
4.2
1 write × 2 + 1 read × 0.5
2.5
(4.2 + 2.5) × 1.0, rounded up
7 credits

When a provider returns no usage numbers at all, the fallback counts characters at four per token and multiplies by three, deliberately over-estimating rather than under-charging invisibly.

Where it stops

Six model turns, twenty on a bulk request
`MAX_LOOPS` is 6, and 20 when the request reads as a bulk one — "set up my school" is a departments → classes → sections → subjects → fee structure chain of roughly twenty steps, and a flat six exhausted every time, handing back a description of the plan instead of the finished work. Neither ceiling is the only thing that ends a run: two consecutive turns that complete no new tool call halt it, so a stuck loop cannot spend the whole budget.
A tool call first, when a tool could answer
For a write or a data question the opening provider call is made with `tool_choice: 'required'`, so the model's first move must be to call something rather than answer from a cached context block that may be stale. A request no tool can answer — "how do I improve attendance" — opens on `'auto'` instead, because forcing a call there guarantees a wrong one. Follow-up turns are always `'auto'`.
6,000 characters per tool result
`MAX_TOOL_RESULT_CHARS = 6_000`. A larger result is truncated, and the truncation is stated inside the result the model reads — so it knows the list was cut and cannot present a partial answer as a complete one.
Older results are compacted
As the loop lengthens, earlier tool results are compacted out of the context to keep the request inside the provider’s token window. The registry is also narrowed by keyword before the first call, because re-sending every tool schema on every turn is what exhausted the window in the first place.
When nothing ran, the reply is overwritten
“I understood your request but was unable to execute it this time — no database changes were made.”

A model asked to do something it could not do will still tend to describe having done it. If tools were available and none of them executed, the runtime discards the model's answer and substitutes that sentence.

The limits

Six things this AI does not do

Listed at the same size as the capabilities, because a buyer who finds these out after signing has been sold something else.

Not autonomous

It has no schedule, no trigger and no background loop. It runs when someone asks it to, as that person, inside that person’s permissions. There is no configuration that makes it act on its own.

app/api/agents/command/route.ts

Not a predictor

The student-risk endpoint is a threshold matrix you can read in one sitting: high or critical when attendance is under 75%, or dues exceed ₹15,000, or an exam score is under 40%, or there are more than three logged incidents. There is no trained model behind it and no accuracy figure quoted anywhere, because none has been measured. The model is only allowed to summarise the metrics that were actually supplied — every field is optional precisely so a caller can say “this is not recorded” instead of having a number invented for a real child.

app/api/ai/student-risk/route.ts

Not trained on your data

Your records are sent as context for the request being answered and are not used to train anything. Nothing in this repository fine-tunes, embeds for later reuse, or retains prompts beyond the conversation they belong to.

services/ai/agent-runtime.ts

Not always your own key

Tool calling runs on Groq, which is the primary executor; a second provider, Gemini, does the reasoning pass and stands in when Groq errors. No model identifier is printed here on purpose — the four this repository used before were retired upstream inside a year, and a page that names one goes false without anyone touching it. You may bring your own provider key, and when you do it is sealed with AES-256-GCM under schools/{id}/secrets/ai_byok. But it is optional: with no key configured, requests run on the platform’s provider allocation and are metered in credits. Any page that tells you a key is required is wrong.

services/ai/providers/index.ts

Not certified

No compliance certification is claimed here — not SOC 2, not ISO 27001, not a DPDP audit. The security properties described on this page are code you can read, which is a different and lesser thing than an external attestation, and it would be dishonest to present one as the other.

this file

No customers to point at

Elevate has no schools in production yet, so there are no logos, no case studies, no “trusted by” counts and no uptime percentage on this page. When there is a real number, it will appear with a real name attached.

this file

Read the code before you believe the page

Every mechanism described here is a named file and a named function. Start free, or ask for the parts that are still on the roadmap — they are marked as such throughout.

Iris runs on the platform’s provider allocation by default; bringing your own key is optional.