Agent service (apps/agents)
Status: one working agent, no orchestration.
Mastra (Apache 2.0, TypeScript-native) runs as its own Bun service on port 4001. Today it holds exactly one agent and one tool. The architecture intends a set of persistent role-agents — IQAC, Placement, Warden, Curriculum — delegating to each other over A2A with a human-approval gate on every action. None of that orchestration exists yet.
The per-tenant design, and why it matters
The single most important design decision here is that an agent is not a process per
institution. createIqacAgent() returns one shared, stateless agent definition; institution count
does not multiply deployed services. What makes an institution's "brain" its own is tenant-scoped
context at request time, not separate deployments.
That mechanism is visible end to end:
POST /iqac-agent/ask { question }
header: x-tenant-id
↓
withTenantScope(tenantId, ...) @campus/observability — tags the Sentry scope
↓
askIqacAgent({ tenantId, question })
↓
agent.generate(`tenantId: ${tenantId}\n\n${question}`)
↓
tool: query-at-risk-evidence-gaps({ tenantId })
↓
withTenant(tenantId, tx => runTenantScopedCypher(tx, { tenantId, cypher: ... }))
Note the tool call: it goes through withTenant() and runTenantScopedCypher — the
tenant-pre-filtered graph service — rather than querying AGE directly. That is the rule the whole
architecture rests on, and it holds here with no exceptions. The agent's instructions state the
constraint in plain language too: "When you need data, call your tools with the tenantId you were
given; never answer about any other institution's data."
The example query is the multi-hop case Pillar 1 is built for:
MATCH (s:Person {tenantId: $tenantId, personType: 'student'})
MATCH (s)-[:HAS_ATTENDANCE_FLAG]->(:AttendanceRisk {tenantId: $tenantId})
MATCH (s)-[:HAS_FEE_STATUS]->(:FeeDue {tenantId: $tenantId})
MATCH (s)-[:RESIDES_IN]->(:Room)-[:HAS_FLAG]->(:HostelFlag {tenantId: $tenantId})
RETURN s
The graph nodes that query matches (AttendanceRisk, FeeDue, HostelFlag) are not written by
anything: graph-sync.ts only creates Person, org vertices, and ENROLLED_IN / TEACHES
edges. So the example query returns nothing against real data.
Authentication
This service sits outside Better Auth — it is called service-to-service, not from a browser, so
there is no session cookie to resolve. It authenticates with a shared secret instead
(@campus/internal-auth): every request must present INTERNAL_SERVICE_TOKEN as the
x-internal-token header, and the institution as a validated x-tenant-id header.
This is deliberately not the only control. /health stays public so container healthchecks work,
and everything else is mounted on a sub-app with * gated, so a route added later is protected by
construction rather than by remembering a middleware. If INTERNAL_SERVICE_TOKEN is unset the
service fails closed — 503 on every non-health request rather than serving unauthenticated
traffic, which is the failure mode a misconfigured deploy would otherwise produce.
Network placement is still required on top of this: the service must run with no published ports, on an internal network, with no route from any reverse proxy. A shared secret that is exposed to the internet is only one bug away from being no secret at all.
The route also validates the tenant before doing any work — shape-checked by the middleware, then
confirmed to exist in tenants, so an unknown id returns 404 rather than a misleadingly empty
answer — and rejects a blank question with 400.
The memory wiring is probably not doing anything
askIqacAgent passes the memory config as an option to the generate call:
return agent.generate(prompt, {
memory: { thread: `iqac-agent:${params.tenantId}`, resource: params.tenantId },
});
In Mastra, memory has to be a Memory instance configured on the Agent for thread/resource
to persist anything; passing a plain object here will not create a store, so the tenant-scoped
conversation history that TECHNICAL_ARCHITECTURE.md §6 describes ("memory is scoped per tenant via
resource, so conversation history never crosses tenants either") almost certainly does not persist
today. The retrieval side is genuinely tenant-safe, because that is enforced in the database. The
memory side is aspirational.
The fix is small: construct a Memory instance with a Postgres-backed storage adapter in
createIqacAgent(), attach it as memory, and keep passing thread/resource per request — with
resource: tenantId doing the isolation work. Verify the exact API against the installed Mastra
version rather than the docs, for the same reason the workflow service's code says to.
Not present
- A2A. No agent-to-agent delegation, no capability cards. The IQAC agent cannot ask a Curriculum agent for anything, because there is no Curriculum agent.
- MCP tool exposure. Tools are internal Mastra tools, not an MCP server.
- The governance gate. Nothing routes an agent output through human approval before it attains institutional standing. Today the only consumer is an authenticated internal caller.
- The model gateway. The agent uses
anthropic("claude-sonnet-4-5")directly. The multi-provider gateway with cost-tier fallback is a documented intention, and the source comment says so. - Evaluation, tracing beyond Sentry, RAG over documents. No
document_embeddingstable exists, so there is nothing to retrieve from beyond the graph.
To add an agent
Follow the shape that is already here and resist adding a new pattern:
- Create
src/agents/<role>-agent.tsexporting bothcreate<Role>Agent()and anask<Role>Agent({ tenantId, ... })wrapper. - Give it tools that take
tenantIdand route throughwithTenant+runTenantScopedCypher/tenantScopedVectorSearch. Never callcypher()directly — that is the single rule that keeps cross-tenant leakage structurally impossible. - Add the route to the
internalsub-app insrc/index.ts(not the outer app), so the token and tenant checks apply to it automatically. Read the tenant withgetInternalTenantId(c); never accept one from the request body. - Before shipping it, fix the memory wiring above, or document clearly that memory does not persist.
Because the deployment model is one shared agent definition evaluated per request, adding a fourth or fortieth agent does not grow the infrastructure — which is the whole point of the design, and is worth preserving as agents are added.