System architecture · for engineering review · v2.0 · September 2026

Company Brain Architecture

A permission-aware AI platform over a company's own business data. Employees ask in the tool they already use; agents answer, retrieve, execute and collaborate; work gets done through connected systems, a browser and a computer. Written to be built from.

AudienceCTO and engineering team. No prior context assumed.
DeploymentSingle tenant, client-hosted, one Linux box.
Scale50–300 staff · ~3,000 questions/day.
LanguagePython. FastAPI, LangGraph, Playwright.
Part I · Orientation

01What we are building

A Company Brain answers questions and performs work over a company's own operational data, while enforcing that each person sees only what they are entitled to see — decided per request and per field, not per document.

It installs on the client's own server. One deployment serves one company. Departments are filters within it, not separate systems, so adding the ninth department costs configuration rather than a rebuild.

Everything is plural except one thing. Many agents, skills, channels, departments, models, connectors. One gate that decides who may see what and who may do what.

That is the architectural thesis. An agent roster, a visual builder, agents delegating to one another, a browser driving a client's accounting system — each is a permission problem before it is a feature problem. One decision point is what stops those features fighting each other.

What makes it different from a chatbot over documents

02The full architecture

Every layer, top to bottom. Read this once before anything else; the rest of the document expands each band.

PEOPLE Super Admin · Department Admin · Member · Auditor · Connector Admin · Approver · Partner CHANNELS Lark · Slack · Teams · WhatsApp · Telegram · Web widget · Email · REST API · Scheduler one adapter contract · identity binding · per-channel policy · rich card rendering THE GATE identify → entitle → screen → cache → route → project tool catalogue E(principal) resolved here · ent_hash computed · channel and assurance intersected no model has run yet · a tool the caller cannot use is absent, not refused OWNED CODE — never delegated to a library or a vendor LANES FAST · no model <1.5s · local only ANSWER · in-process <8s · 1 live fetch TASK · durable minutes · survives restart ORCHESTRATOR single loop (default) · fan-out gate · parallel workers · schema-validated merge the model proposes subtasks; arithmetic decides whether to parallelise delegation narrows capabilities only · depth 2 · concurrency 8 AGENTS Global (Super Admin) Department Personal from Templates · or built in Verz Studio · audience ≠ authority a global agent still redacts per the person asking CAPABILITIES Skills (SKILL.md) Tools Surfaces Automations Leash rung per (agent × target × scope): Shadow → Assisted → Autonomous KNOWLEDGE Row plane typed tools · compiled columns Document plane (RAG) hybrid: FTS + pgvector Entity registry the cross-system join key Assets → parsing → chunking → embedding · scope filter INSIDE the query MEMORY Session · Persistent · Adaptive — each tagged with the capability set that formed it re-checked at read · promotion to a wider scope always needs a human MODELS tier classifier (deterministic) → priority chain → pinned deployment → provider fallback on a closed error set · breaker on fail ratio · residency as a hard rule answer cache keyed on ent_hash · plan cache · prompt-prefix cache REDACTION recursive field-level walk over every typed result · untagged data dropped the last line of defence, and the only one that catches a bug in the layers above DATA PostgreSQL + pgvector · Valkey (cache, queue) · object store (assets, recordings) LOCAL tier only · plus the 12-field projection · never connector payloads CONNECTORS MCP server REST / OpenAPI Database (read-only) Custom adapter all four normalise to the same entity-tagged typed contract SOURCES Laravel/MySQL · Freshdesk · Lark Base · Lark Wiki · Xero · HubSpot · Drive · websites READ ONLY · one direction · never written to OBSERVABILITY Metadata ledger — Postgres who, when, capability, scope, rung, tokens, fields removed no content · years Payload store — Langfuse prompts, tool results, page text 30 days · separate role masked client-side, before egress Audit — hash-chained every grant, leash and merge Eval gate — promptfoo golden corpus + permission canaries, run through the gate CONSOLE Operate · Govern · Report live runs · model matrix connector health · coverage people · scopes · leashes skills review · audit gaps · usage · canaries Verz Studio form composer · rehearsal procedure canvas · publish gate PLATFORM Keycloak — identity, SSO OpenBao — secrets, leases Traefik via Coolify — TLS gVisor + mitmproxy — sandbox Docling — document parsing Presidio — PII detection pgBackRest — backup Ansible — N-install deploy 16 versioned extension points plug in without a core release
Thirteen bands, two of them owned outright. The gate at the top and the redactor near the bottom are the only layers that cannot be delegated to a library or a vendor — everything between them is replaceable. The right rail spans the whole stack because observability, the console and the platform services are cross-cutting rather than a step in the flow. Note the direction of the bottom arrow: sources are read, never written.

03The Python stack

Decided: Python. It is the better fit here, and the reason is not preference — it is that four of the components this system depends on most are Python-native, and a TypeScript build would have needed a separate Python sidecar container to run them anyway.

Choosing Python removes a container rather than adding one. Parsing, PII detection, entity resolution and evaluation stop being a sidecar and become imports.
The stack · all verified on GitHub, 4 September 2026
LayerChoiceLicenceHealth
Agent loop, both lanesLangGraphMIT41.0k stars, pushed 3 Sep
Typed agent contractsPydantic AIMIT19.7k stars, pushed 4 Sep
API and console backendFastAPI + UvicornMIT / BSD-3102k stars, pushed 1 Sep
Console frontendReact + Vite + React FlowMIT
Job queueProcrastinate (Postgres-native)MIT1.4k stars, pushed 31 Aug
Durable executionHatchet, or Temporal at scaleMIT7.9k / 1.2k, both pushed 4 Sep
Browser automationPlaywright for PythonApache-2.015.0k stars, pushed 3 Sep
Browser agent referencebrowser-useMIT112k stars, pushed 4 Sep
Document parsingDoclingMIT66.0k stars, pushed 4 Sep
PII detectionPresidioMIT10.7k stars, pushed 31 Aug
Entity resolutionSplink (offline calibration)MIT2.4k stars, pushed 3 Sep
ORM and migrationsSQLAlchemy + AlembicMIT4.4k stars, pushed 3 Sep
Packaging and toolinguvApache-2.089.4k stars, pushed 4 Sep
Testingpytest + promptfoo for evalsMIT

What Python changes, and what it does not

Changes. No sidecar — Docling, Presidio, Splink and the eval harness are imports in the same process family. Pydantic becomes the entity-tagged result envelope, which is a genuinely better fit than a TypeScript equivalent because the redaction walker operates on validated models rather than plain objects. And LangGraph is available natively, with its checkpointer and interrupt primitives.

Does not change. The gate, the redaction walker, the tool registry, the leash, the three lanes, the data tiers, entity resolution, the org model, and every diagram in this document. Those are language-independent by design, which is why this decision could safely be deferred until now.

The one thing to budget explicitly

LangGraph's library is MIT, but langgraph-api — the server providing persistence, task queues and crash recovery — is Elastic-2.0 and needs a commercial key in production. We do not use it. The open-source Postgres checkpointer saves state; what it lacks is something to re-drive a thread after a crash. That driver is ours to build, roughly a week's work, and it sits behind the same adapter seam described in §20 so Hatchet or Temporal can replace it without touching the graphs.

One import note: Uvicorn's repository moved from the encode organisation to Kludex/uvicorn. Same package, but pin it and know where upstream lives.

04Core concepts

No term is defined using another undefined term. Read this before any later section.

Primitives · never granted directly
TermDefinitionRepresentation
PrincipalAn authenticated identity — person, service or partner.uuid from Keycloak
ScopeA predicate over rows. This is what a department actually is.jsonb {"department":"web"}
CapabilityAn atomic permission. Authorisation is set membership over these.read:client.contract_value
Entitlement setComputed per request: every (capability, scope) pair held now. Its hash keys the cache.E(principal) → ent_hash
The nouns people work with
TermDefinitionKey property
KnowledgeHuman-authored, approved content with an owner, verified date and review date.Distinguished from Memory by authorship.
AssetAn uploaded file — PDF, document, image — parsed and indexed into Knowledge.We are the source. Contrast with a Connector.
SkillA procedure with no side effects. A SKILL.md folder.Importable from GitHub, URL or upload. Not executable until reviewed.
ToolAn executable function with typed input and output.The only grantable thing.
ConnectorA deployment unit: transport, credential, lifecycle.Never granted — granting it would grant everything behind it.
SurfaceA browser or desktop session the agent drives. Output is pixels and DOM.Untyped, so a weaker, separately named guarantee.
MemorySystem-authored observation. Three kinds.Never authoritative over the database.
AgentA named configuration: persona, knowledge, skills, tools, model tier, leash.A lens, never an identity.
TemplateA signed, versioned, immutable agent definition.Every agent is an instance of one.
AutomationA saved task with a trigger: schedule, event or webhook.Resolves its owner's live entitlements at each run.
LeashThe autonomy rung on (agent, target, scope).A database row, never code structure.
Part II · Flow and permissions

05The request lifecycle

  1. Arrive. The channel adapter normalises and dedupes the platform event.
  2. Identify. Channel identity maps to a principal. An unbound identity gets nothing.
  3. Entitle. Resolve E(principal), cached 60 seconds, invalidated on any grant write. Compute ent_hash.
  4. Intersect the channel. What this channel may carry, and how assured this identity is.
  5. Screen. An injection classifier scores the input. It never permits or blocks.
  6. Cache. Key = question + ent_hash + agent config hash + policy epoch.
  7. Route. FAST, ANSWER or TASK, by deterministic rules.
  8. Choose the agent. Channel binding, then rules, then a cheap classifier. The user does not pick.
  9. Project the catalogue. agent grants ∩ E. A tool the caller cannot use is absent, so there is nothing to jailbreak toward.
  10. Loop. The model reasons, calls tools, loads skills, retrieves knowledge, consults memory.
  11. Check the leash before every call. Shadow simulates; Assisted renders the real artefact for approval; Autonomous proceeds.
  12. Redact. Walk the typed result depth-first; delete every field not in the caller's mask; drop anything untagged.
  13. Trace the post-redaction payload only.
  14. Answer, with citations carrying freshness and a trace reference.

Steps 1 to 9 all happen before any model runs. The result contract that makes step 12 possible:

{"@entity":"client","@id":"c_123","hours_remaining":12,"contract_value":48000,
 "tickets":[{"@entity":"ticket","@id":"t_9","subject":"…","internal_note":"…"}]}

06Company, departments, users

Company → Departments → Users, exactly as you described. Agents exist at three visibility levels.

LevelCreated byVisible toExample
GlobalSuper AdminEveryone in the companyInternal Helpdesk, Site Health Sentinel
DepartmentDepartment AdminMembers of that departmentTicket Triage for Maintenance
PersonalAny memberOnly its creator, unless publishedA designer's own brief-writing agent
Audience is not authority. Visibility decides who can find and invoke an agent. Entitlement decides what it returns, and that is always the calling user's, never the agent's or its creator's.

This is the single most important rule in the permission model, and it is worth knowing that two shipping products do the opposite. Notion's Custom Agents documentation states an agent "responds using the agent's own access, not the permissions of the person who triggered it". Dust states an agent "inherits the access right to the data that has been used… independently of who uses the agent".

Under our model a Super Admin can publish a company-global agent that reaches finance tools, and a junior invoking it still sees only what that junior is entitled to. That makes cross-department agents safe to publish globally, which the creator-inherits model forfeits. It is a competitive advantage as much as hygiene.

Formally: E_run(caller, agent) = E(caller) ∩ agent_ceiling. The agent id is not an argument to the entitlement resolver, not in the cache key, and not visible to the redactor. The ceiling can only shrink an already-computed set.

07Roles and permissions

Two separate tables answering two separate questions. Roles govern the platform — publish, install, appoint, read audit metadata — and are a compiled constant, not editable rows. Entitlements govern data as (capability, scope) pairs. No role implies a capability, including Super Admin.

Six platform roles · every one has zero data capability by default
RoleScopedExists toTypical count
Super AdmincompanyOwn the platform: publish global agents, change the catalogue, confirm nominations, disable principals2–4
Department AdminrequiredRun one department: approve its publications, grant within its scope, adopt orphaned agents, lower leashes1 per dept
MemberAsk questions, build personal agents, own their delegationseveryone
AuditorRead the metadata plane end to end, including Super Admin activity. Never the content.1–2
Connector AdminInstall connectors, bind and rotate credential references1–2, deliberately not the Super Admins
ApproverrequiredApprove Assisted-rung actions, within their own entitlementper dept

Deliberately not roles

ConceptModelled instead asWhy
Contractor / externalan attribute + mandatory expirySame platform verbs, different lifecycle. A role would duplicate the whole table.
Installing partner (you)a principal kind with break-glass sessionsZero standing entitlement, its own immutable audit chain. Structural, not permissional.
Knowledge owner / curatora per-object stewardship relationA global Curator means one person curating everything — wrong and unstaffable.
Buildernothing — building is unrestrictedGate publication, not building. That is the governance moment.
Service accounta standing delegation resolving the owner's live entitlementsA service principal with its own grants is union authority, which is the classic escalation.
Deputya time-boxed role grant, max 30 days, depth 1Covers annual leave without an unbounded appointment chain.

Three planes of visibility

Oversight and data access are different things, and separating them is what lets a Super Admin do their job without becoming a universal reader.

PlaneGoverned byExample
ExistenceroleThe Payroll knowledge base exists, has 412 documents, is owned by HR, was synced at 14:02, and produced 3 refusals today.
ConfigurationroleAgent instructions, tool grants, leash rungs, connector endpoints. Credential values are visible to nobody, ever.
Contententitlement onlyDocument bodies and field values. A Super Admin sees none of it without a grant.

A Super Admin can grant themselves a capability — refusing that would just push people to the database — but doing so writes a loud audit event and notifies the object's steward. The Auditor role exists so that action is visible to someone who cannot perform it.

The mover case is where permission systems actually leak. When someone changes department: entitlements re-resolve on the next request automatically; personal agents follow them; their delegations are re-evaluated and any that would now exceed their entitlements are suspended, not silently narrowed; cached answers keyed on their old ent_hash become unreachable by construction; and memories tagged with the old capability set stop being replayed.

Part III · Data

08Data: what we store

Confirming your question directly: we do not sync connector data. With one deliberate, bounded exception, described below, because pure federation is arithmetically impossible.

TierContainsStored?
LocalIdentity, capabilities, audit, leash state, agent and template config, uploaded knowledge and assets, memory, the entity registryyes — we are the source
ProjectedRecord ids, join keys, status enums, timestamps, display labels ≤120 chars, and the source's visibility predicate. Max 12 fields per entity type.a pointer, not the payload
FederatedTicket bodies, conversations, invoice lines, contracts, CRM notes, attachments, custom fields, email, phone, address, NRIC, bank details, salarynever

The projection is roughly 40 MB at your scale, against tens of gigabytes for a mirror. Email, phone, NRIC, bank details and salary are on a permanent deny list with no exception.

Why the projection has to exist

Source ceilings · verified 4 September 2026
SourcePer minutePer dayConsequence
Xero605,000 per tenantShared with every other integration the client runs.
Freshdesk100 / 400 / 700Per account. Ticket listing capped harder still.
Lark Base100 fixedTheir docs state it cannot be raised. 1.67 calls/second for the whole tenant, permanently.

A realistic question touches 5–20 records. Federating everything means 15,000–60,000 calls a day against a Xero ceiling of 5,000. Separately, Freshdesk's search returns at most 300 records ever, so "which clients have an open P1 older than five days" has no live answer at any speed. Headers must be local; bodies never are.

Never store a resolved ACL. Store the source's visibility predicate and evaluate it against the live entitlement set. When someone changes department, the next query returns a different row set with zero writes and zero invalidation. Microsoft's own connector docs admit their incremental crawls do not update permissions at all; Glean's full crawls run on 28-day cycles. This is where the category fails and we do not.

The rule for a new field: if the fast lane must filter, sort or count on it and the source will tell us when it changes, project it. Otherwise fetch it live. No change signal means no projection — that clause is what stops the projection becoming a mirror.

09Entity resolution

You identified the hardest problem in the design. "ABC" in Xero, "ABC Pte Ltd" in the maintenance portal, "ABC Pte" in HubSpot. The same for people and projects, and one client may legitimately have several names.

This is not a nice-to-have: the entity registry is the join key that makes federation possible at all. Without it the system cannot know which Freshdesk company, which Xero contact and which Laravel row are one organisation, so it cannot fetch the right record live. It is why the registry sits in the LOCAL tier.

A cascade, strongest evidence first

StageSignalStrengthAction
1 · Hard identifierUEN, tax id, web domain, verified phonedecisiveAuto-link. A shared UEN is essentially never coincidence.
2 · Normalised namecase, punctuation and suffixes stripped: Pte Ltd, Sdn Bhd, Inc, LLC, LimitedmoderateAuto-link only with corroboration from a second field.
3 · Fuzzy similaritytrigram similarity, edit distanceweakAbove the upper threshold and corroborated, link. Otherwise queue.
4 · Human reviewthe ambiguous banddeferredA person confirms, with the evidence shown as a weighted list.

The scoring is Fellegi-Sunter: each field contributes a weight reflecting how reliably it agrees on true matches versus how often it agrees by coincidence. That gives two thresholds rather than one, and the band between them is the review queue — it falls out of the model rather than being bolted on. It also gives the reviewer their explanation for free: "same web domain, strong; similar name, weak; different phone, negative".

Three specifics that matter for Singapore. A free-mail domain like gmail.com must be blocklisted, not merely weighted low, because it agrees constantly by coincidence. Phonetic matchers such as Soundex are English-centric and documented as unreliable on multibyte text, so they are actively harmful on romanised Chinese, Malay and Tamil names — use edit distance and the Daitch-Mokotoff variant instead, weighted low. And trigram similarity is unstable on very short strings, so anything under four normalised characters goes straight to review.

Merges must be reversible, unlike every CRM

Salesforce cannot undo a merge. HubSpot states plainly that records cannot be unmerged. Both destroy the pre-image, which is why a small industry exists to recover from it.

We can do better cheaply, because we never own the source records — the registry is a mapping layer over immutable source ids, so a merge moves pointers only. Every merge writes the complete prior state of the affected links and aliases into an append-only decision row first. That single column turns unmerge from impossible into an update. A canonical id, once issued, resolves forever through a forwarding pointer, so an old memory citation never dangles.

Guardrails, and what happens when it cannot decide

The resolver holds hashed join keys rather than full records — roughly 7 MB of non-reversible keys for 60,000 links, against 80 MB of live personal data if we synced contacts. Tooling: Splink (MIT, actively maintained) run offline as a weekly job to calibrate the weights, with the weights exported as a small table and the same arithmetic evaluated online in plain SQL using pg_trgm and fuzzystrmatch. That keeps a Python ML dependency out of the request path and keeps online scoring around a millisecond.

10Knowledge and RAG

Two retrieval planes over one Postgres.

The row plane reaches business records through typed tools whose column projection is compiled from the caller's capabilities before the query runs, so a hidden field is never in the SQL and never crosses the socket. No model ever writes SQL — an LLM authoring queries against a business database defeats any column policy.

The document plane is hybrid search over knowledge and assets: Postgres full-text and pgvector fused by rank, with the scope filter inside the query so out-of-scope rows are absent rather than ranked lower. Assets flow through parsing (Docling, layout-aware so tables in PDF contracts survive), chunking, embedding, and indexing.

pgvector applies a filter after the index scan. A narrow-scope user silently gets almost no results unless iterative scan is enabled.

This sits directly on the scope primitive and fails quietly — the answer is just thin and nobody files a bug. Either enable hnsw.iterative_scan explicitly, or at these corpus sizes use a partial index per scope, which removes the failure mode instead of tuning around it.

Citations for a row cite the record and the field — "Acme, hours remaining, read 14:02" — because a link to a record does not say which cell the number came from. Every knowledge row carries verified-by, verified-at and review-by, surfaced as a badge, with a scheduled job opening re-verification tasks.

Who can reach which knowledge

Everything uploaded — SOPs, price lists, package definitions, brand guidelines, contracts, meeting notes — lands in one knowledge layer. There is no second store per department. What differs is the scope predicate attached to it, and that is what decides who reaches it.

Knowledge visibility mirrors agent visibility
LevelSet byReachable byTypical
CompanySuper Admin, or a Department Admin proposing and a Super Admin approvingEveryone, subject to field-level redactionHR policy, brand guidelines, the standard price list, escalation rules
DepartmentDepartment AdminThat department's scopeThe Web team's deployment SOP, Maintenance's triage rules, Finance's package costings
PersonalThe uploaderOnly them, until publishedWorking notes, a draft proposal, a client call transcript
Uploading is not publishing. Knowledge added by a department stays in that department's scope by default, and widening it is a deliberate act with an owner and a review date attached.

Without that rule every upload silently widens the company's exposure, and within a year nobody can say why the whole company can read a client contract.

One correction worth making precisely

It is right that the company-level brain holds all the knowledge — there is one store and one index. It is not right that a company-level agent has access to all of it. Access is never a property of the agent; it is always the intersection with the person asking, per §6.

So a global "Company Assistant" published to all 126 staff is safe precisely because it has no standing authority. Someone in Web asking it about a Finance SOP gets nothing, or gets the parts they are entitled to, with the rest shown as locked fields. The same agent asked the same question by the Finance Director returns the full answer. One agent, one knowledge layer, different answers, and no configuration needed to make that true.

Two consequences worth designing for. Pricing and packages are a field-level problem, not a document-level one — a price list usually carries cost and margin columns beside the sell price, and the whole company needs the sell price. Classify those columns rather than restricting the document, or you will end up with three near-identical price lists and two of them stale. And knowledge inherits the mover case: when someone changes department, the department knowledge they could read stops resolving on their next request, with no reindex and no cache purge, because the scope predicate is evaluated live.

Part IV · Subsystems

11Agents and templates

Templates are how every agent exists — a hand-built one is an instance of the blank template. One code path, one config document, one upgrade story. A template is signed, versioned and immutable; an installed agent is a pinned reference plus an overlay, with per-path ownership tracking who last set each field.

A manifest declares required capabilities; it never grants them. Five paths are sealed by a database constraint: capabilities, leash ceiling, egress, connector auth, and the surface flag. The worst a malicious template can do is ask for a capability nobody holds, and then its tools are simply absent.

Install proves it works. The wizard runs the template's golden set through the real gate twice — once as the installing admin, once as a deliberately low-privilege fixture user — catching both installed-but-dead and works-for-the-admin-only. Missing connectors do not block install; the agent pins to Shadow with an amber badge, and an empty catalogue raises a hard error rather than letting the model answer from general knowledge.

The web agency catalogue

Role templatesOperational templates
Business Analyst · Pre-Sales · Project Manager · UI Designer · UX Designer · HTML Developer · WordPress Developer · Laravel Developer · Shopify Developer · Content Uploader · Tester Internal Helpdesk · Site Health Sentinel · Support Ticket Agent · Project Status Reporter · Capacity and Hours Analyst · Quote and Proposal Drafter · AR and Renewal Chaser · Accountant Agent · SEO Agent · SEM Agent · SMM Agent · Knowledge Gap Curator

Ship four on day one — Internal Helpdesk, Site Health Sentinel, Support Ticket Agent and Project Status Reporter — because they have the cheapest connectors and produce visibly correct answers immediately. The role templates land in stage 2 once their skills exist.

Three carry a manifest-level Shadow pin regardless of promotion criteria. AR and Renewal Chaser and Accountant Agent because they touch money, and the Accountant additionally because reconciliation errors are found weeks later rather than immediately. SEM Agent because ad spend is the one place an autonomous mistake bills you by the hour — it reads and drafts, and a human commits every budget change. SEO and SMM are read-and-draft by default too, with publishing as a separate grant.

Agents that learn. Learning writes to a reviewable delta queue — typed, evidenced, marked machine-authored — so what an agent taught itself stays permanently separable from what a client configured. A department lead approves or rejects. Nothing changes silently.

12Skills, tools and connectors

SkillToolConnector
IsA procedureAn executable functionA deployment unit
FormatSKILL.md foldertyped definitiontransport + credential
GrantableNo — loaded by an agentYes, the only oneNever
ImportGitHub, URL, uploadFrom a connectorInstalled and configured

Connectors support four transports, all normalising to the same entity-tagged typed contract, so the transport is invisible above the connector layer:

Two properties enforced regardless of transport: scope at connect (one Drive folder, not the whole Drive) and read-only by default, with write as a separate deliberate grant shown at connect time. Every tool declares whose identity it runs as — the requester's by default. Third-party servers are pinned by a hash of their full manifest including tool descriptions, so a silent redefinition fails closed on reconnect.

Skills import in a non-executable state until a named reviewer approves. Progressive disclosure keeps cost down: the agent sees names and descriptions, loading full instructions on demand. A skill's scripts run through one tool, run_skill_script, carrying the sandbox, the leash and output redaction like any other.

13Memory

KindHoldsVisible toLifetime
SessionThe current conversation and working statethe askerdies with the thread
PersistentFacts about people and entities — the identity mappings in §9, preferences, recurring contextby scopeuntil changed
AdaptiveProcedural learning from outcomes: what worked on cases like thisthe agent's scopedecays unless reinforced

Build the entity registry first — deterministic, no model, and most of what staff mean by "it remembers". Two invariants: every memory carries the capability set that formed it and is re-checked at read time, so a fact learned by someone who could see contract values is never replayed to someone who cannot; and nothing is promoted to a wider scope without a human. Memory is never authoritative over the database.

Adopt no memory product. Mem0, Letta and Graphiti all partition by id and call it access control, and most drag in a second stateful database. Nothing third-party sits between the capability set and the row.

How an agent actually learns

Not by fine-tuning, and not by writing to itself. But most learning is automatic. An earlier draft of this document required a human for everything, which is wrong: an approval queue nobody has time to work means no learning happens at all, and the control gets bypassed rather than followed.

Learning that narrows, personalises or re-ranks is automatic. Learning that widens, publishes or changes behaviour needs a human. Those are the only two categories.
Four tiers · only the last one asks anybody anything
TierWhatWhy it is safe
0 · silentSession and working memoryDies with the thread. Nothing persists.
1 · automatic
no human
User preferences · retrieval boosts and demotions · entity links above threshold with hard-identifier corroboration · every negative signalBlast radius is one person or one ranking. A demotion can only narrow what is seen, so it is always safe to apply immediately. An entity link corroborated by a matching domain and registration number is not a guess.
2 · earns it
no human
Fast-path rules · procedural shortcuts using tools the agent already holdsShadow first. The rule is computed alongside the real answer and not used. After it agrees with reality enough times, it promotes itself and you are told. The evidence is the approval.
3 · gated
human
Widening scope from personal to department to company · changing company knowledge · raising a leash · entity merges crossing a money boundary · anything adding a capability or toolThese are the only changes that can disclose something to someone new, or make an agent do something it could not do before.

Notify, do not ask. Tiers 1 and 2 produce a weekly digest — "here are the eleven things the Maintenance agent learned, and what each was based on" — with one-click undo on any of them. Reading a digest and reversing one item is a fraction of the work of approving eleven, and it keeps the oversight.

The review queue has a budget. If tier 3 exceeds a handful of items a week, the thresholds are wrong and the fix is tuning them, not asking humans to work harder. A queue that grows is a design signal, and the console charts it.

Nothing here relies on someone being diligent, because the decay, contradiction and source-truth rules below mean a bad automatic item corrects itself without anyone acting.

What is observed. Explicit thumbs-up feedback is close to dead in internal tools, so the signals are implicit and captured from day one — which makes this a schema decision in stage 1, not a feature to add later:

SignalMeans
The same question re-asked in different words within minutesThe first answer failed. The strongest negative signal available.
The answer copied outIt was useful.
A follow-up that contradictsIt was wrong, and the correction is in the follow-up.
Escalated to a humanOut of the agent's competence, and the human's resolution is the label.
A human took over an Assisted actionThe leash is set too long for that action.
A ticket reopened within N daysThe resolution was wrong, discovered late.
An approval rejected with a reasonThe best-labelled data in the system, and the rarest.

What gets learned is three different things, and separating them is what keeps this safe:

  1. Entity facts. That a client name, a domain, a Laravel row and a helpdesk company are one organisation. Deterministic, highest value, and it lands in the entity registry rather than in memory. Most of what staff mean by "it learned" is this.
  2. Procedure. "When someone asks about hosting expiry, check domain expiry too — they are almost always asked together." This becomes a proposed diff against a SKILL.md file, reviewed and versioned like code, not a hidden weight.
  3. Preference. "This person wants figures, not prose." User-scoped, never shared.
The system never learns a field value. It learns patterns and pointers. Learning a value would be caching federated data through the back door, which §8 forbids.

Does learning go away?

Yes, four ways, and this is deliberate — a system that only accumulates becomes confidently wrong about a company that has moved on.

Promoted knowledge also carries a review-by date, with a scheduled job opening re-verification tasks. A review date nothing reads is documentation, not a control — which is the same mistake as a circuit breaker with no scheduled caller.

Where a person sees and controls it: every agent has a Memory tab listing what it has learned, with the evidence, the confidence, the date and a delete button. A department admin sees the same for their department. Nothing is learned that a person cannot read back in plain language, and anything deleted there is gone from retrieval immediately.

14Multi-agent execution

A large task splits into subtasks, runs across agents in parallel, and merges into one answer. The discipline is in deciding when.

The model proposes; arithmetic decides. A deterministic partitioner builds a dependency graph — node weight is estimated cost, edge weight is shared context a split would duplicate — and a gate decides in pure arithmetic whether parallel beats serial. Fail any condition and the run collapses to a single loop, which is the default rather than the exception. Published measurements put naive fan-out at 1.4–1.6x cost for noise-level gains, and your own prior system measured 2.15x.

Every subtask carries a contract — objective, output schema, tool hints, scope predicate. A result that does not validate is a failed step, not a merge input. Necessary here because the redactor only works on typed data, so "return prose" is not a permitted subtask contract.

Delegation only narrows. A child's grant is computed in SQL as parent ∩ agent ∩ subtask, and a database trigger rejects any delegation row that is not a subset of its parent. The orchestrator has no verb for granting. Depth is capped at two; concurrency at eight per run.

Cross-department work falls out of the scope model: if the asker holds both scopes it is one plan with two filters; if they hold one, they get what they are entitled to plus a stated reason for the gap and a request-access route.

15Browser and computer use

This is a core capability, not an optional extra. Agents navigate, log in, read the screen, fill forms, take multi-step decisions, verify the result and report back — on the many client systems that expose no usable API. Python strengthens this rather than compromising it: Playwright's Python binding is first-class and identical in API to the Node one, and browser-use, the most-starred browser-agent project in existence at 112,000 stars, is Python-native. The credential-injection pattern we adopt in §15 comes from reading its source.

Browser agents clear about 70% on reading and the best reach 46.6% on writing. A browser write is an attempt, not an action, and every one is verified out of band before it is reported as done.

That asymmetry between reading and writing is the reason for the staging, and it also offers a way to have the capability sooner. Read-only browsing can land in stage 4 or 5 — checking a client portal, reading a dashboard no API exposes, pulling a status page — because at roughly 70% success a failed read costs a retry and nothing else. Writes stay in stage 6 behind the full envelope, enforcer and verifier, because a wrongly-reported success corrupts a client's records. If browser work is a priority for you, splitting it that way gets the useful half early without shipping the dangerous half unguarded.

Three planes. A control plane holds the plan, the capability set and the target registry, and never reads a byte of page content — the component that can widen permissions cannot see the text that would ask it to. An untrusted runner in an ephemeral sandboxed container reads the page and holds no authority. A deterministic guard between them contains no model at all: policy compiled at container start and immutable after, an egress proxy the container cannot route around, credential leases, and a hash-chained audit.

The human approves an Envelope once — the plan and its declared writes, rendered as a readable diff in a Lark card — not step by step, which trains people to click through. Re-interruption happens only on envelope breach.

Four supporting mechanisms: the agent reads the accessibility tree, not screenshots (cheaper, less brittle, and a screenshot of a login page can contain the credential while an accessibility node cannot); credentials resolve by reference at the execution boundary, bound to the current origin, never entering a prompt or a log; egress is default-deny to the envelope's origins; and success is never the agent's own claim — a verifier builds its rubric from the goal before seeing the trajectory.

One rule removes most of the risk: the skill loader refuses to register a browser skill for any domain that has a registered API tool, unless the folder carries an explicit exception with a named approver and an expiry. Never attach the agent to a staff member's own logged-in browser profile.

16Channels

Lark, Slack, Teams, WhatsApp, Telegram, a web widget, email and the API — one brain behind all of them. A gateway terminates each platform's wire protocol and does nothing else: no entitlements, no redaction, no policy.

Identity binds outward only. A nonce minted inside an authenticated web session is presented on the new channel. Never a code sent to a number that asked for one.

The group-chat problem is solved by moving when redaction runs. Keep the answer typed and unredacted briefly, then run the redactor at render time under each viewer's own entitlements. Every room gets a room envelope computed at the floor of everyone present, plus a per-viewer body delivered by whatever private mechanism the platform supports — Lark ephemeral cards, Slack ephemeral posts, Teams per-user refresh, and finally a link into the web app where the gate re-runs under the viewer's own session.

Async task results re-run the entitlement computation at send time. A task that takes twenty minutes outlives the world it was authorised in. Per-channel policy decides which sensitivity classes a channel may carry at all.

17Models and routing

Tier, then an ordered chain, then a pinned deployment. Tier classification is deterministic — tool count, estimated context, lane, explicit user request, and whether the scope touches data with a residency rule. Never a model call, because that adds a round trip to every request and would let text inside a retrieved document influence which jurisdiction handles it.

Priority and fallback matrix · editable in the console
TierChainFallback fires on
smalldefault → next model, same provider → next model → next providerA closed set only: connection error, timeout, 429, 5xx, context exceeded. Never "the answer looked weak".

Open question: this set originally also carried content-policy refusal. The implementation excludes it, on the grounds that a refusal is a property of the request rather than of the provider's health, so the next rung reproduces it at full cost, and a rung that does answer has shopped for a "yes". Awaiting a decision; see Needs Rupash.
mainsame shape, heavier default
heavysame shape, heaviest default

Each attempt appends a row, so the executed chain is reconstructable from one join. The breaker is a fail ratio over a window, not failures per minute — at 0.1 requests per second a per-minute threshold is nearly unreachable and a dead provider stays in rotation for an hour. A background prober keeps an idle-but-broken provider visible. Cooldown backs off with jitter. Alert on fallback depth, not final failure.

Residency is a hard rule: a scope carrying a residency constraint cannot route outside it, and the chain skips those rungs rather than degrading quietly.

18Builder and console

Verz Studio lets a department admin or member create agents without code. Agent authoring is a form composer — identity, persona, knowledge, skills, tools, leash, tests — because control flow is model-driven at run time and the catalogue is projected per request, so an edge someone draws is a promise the runtime would have to break.

The canvas survives where it earns its place: a read-only trace graph of what actually happened on a completed run, which is what people usually want a canvas for, and a bounded procedure canvas that authors a SKILL.md with a small node set, scope predicates as the only conditional grammar, and no code node.

Rehearsal runs a draft against the real gate, leash and redactor, with tool calls replaying recorded fixtures so a test never fires a real side effect. Publish is gated by system-authored permission canaries; author-written tests stay advisory so the gate cannot decay into a rubber stamp. Widening grants demotes to Shadow and needs a second approver; editing instructions does not.

What open source the builder actually uses

A fair challenge, and the answer is that we use a great deal of it — at the library level rather than adopting a whole builder product. All verified 4 September 2026.

NeedLibraryLicenceHealth
Canvas: trace graph and procedure editorReact Flow (xyflow)MIT38.3k stars, pushed 2 Sep
Forms generated from JSON Schemareact-jsonschema-formApache-2.015.9k stars, pushed 3 Sep
Tables, filtering, the console gridsTanStack TableMIT28.4k stars, pushed 31 Aug
Graph state and checkpointing behind the canvasLangGraphMIT41.0k stars, pushed 3 Sep

The install wizard and every template configuration screen are generated from the manifest's JSON Schema rather than hand-built, which is why adding a template field costs no frontend work. That is react-jsonschema-form doing the job an entire in-house form framework would otherwise do.

Langflow versus building the composer: the full comparison

Langflow is MIT, 154,000 stars and pushed daily. The question deserves a real answer rather than a preference, because adopting it would visibly save frontend work.

Effort, honestly estimated
Adopt LangflowWeeksBuild the composerWeeks
Install and run it<1Form composer, generated from the template JSON Schema3–4
Reconcile its own user model with Keycloak1–2Read-only trace graph on React Flow1–2
Disable code components and prove no bypass1+Rehearsal, publish gate, permission canaries2–3
Wrap execution so every tool call still passes our gate3–4Bounded procedure canvas (deferrable)3–4
Build the publish gate and canaries anyway — Langflow has none2–3
Total8–10Total, MVP 6–910–13
Langflow is much faster to a demo and not faster to a safe product. The thing it saves — a canvas — is not the hard part. The hard part is the permission-aware composer and the publish gate, and it supplies neither.

The disqualifying detail, in their own words

This is not caution on my part. Langflow's own security documentation states:

For a product whose entire value is field-level permission enforcement, adopting a builder whose vendor says its access controls are not security controls is not a trade-off — it is a contradiction. A department admin who can write Python in a flow reads the database directly and the gate never runs. Note also that its unauthenticated remote-code-execution flaw in 2025 was actively exploited in the wild, which is what that architecture makes possible.

Disabling code components is possible, but it removes the feature that makes Langflow worth adopting, and leaves you operating a large application for its canvas alone.

The comparison that actually matters
Langflow as the agent builderOur composer
Time to a demodaysweeks
Time to something safe to sell8–10 weeks6–9 weeks to MVP
Permission fitcode nodes bypass the gate entirelynative — it only writes grants and leashes
Ops cost per install+1 app, +1 datastore, +1 upgrade treadmill, forever, across every clientnone
Who can actually use itTechnical staff. Canvases consistently end up used by developers.Non-technical, because it is a form with sensible defaults
Flexibilitydraw anythingbounded — this is a real limitation
Scalability at our loadFineFine. Neither is a bottleneck at 0.1 req/s.

The honest concession: our composer is less flexible. Someone who wants a genuinely custom graph cannot draw one, and the bounded procedure canvas only partly covers that. If a client's requirement is "let our people draw arbitrary automations", that requirement is real and the answer is the hybrid below rather than pretending the form covers it.

Where a canvas does belong

Deterministic automation — a trigger, then steps, with no model deciding control flow — is exactly what a canvas is good at, and it carries none of the objections above because the flow calls our tools through the API and the gate still runs on every call.

So ship it as an optional container, sandboxed with allowlisted egress and no database credentials, on the extension-point list so it is a configuration choice rather than a fork. For that job Activepieces (MIT core) fits better than Langflow, and n8n is available where a client already runs one.

The division that results: model-driven agent behaviour is composed in a form, because a drawn edge would be a lie about what the runtime does when the catalogue is projected per request. Deterministic automation is drawn on a canvas, because there the drawing is the truth.

Console, thirteen screens

GroupScreens
OperateLive runs · Model matrix and provider health · Connector health · Knowledge coverage and staleness · Queue and automations
GovernPeople and grants · Scopes and departments · Agents and leash state with promotion history · Skills and templates with review queue · Audit
ReportQuestions and gaps · Usage and cost · Quality and canaries

The console is an ordinary client of the gate: its reads are tool calls with required capabilities, its rows are entity-tagged so the redactor applies to reports as to answers, and a report's audience is intersected with the caller's scope, never unioned. Super Admin filters by department and user; Department Admin sees the same shape bounded to their scope.

Two stores split by classification — a metadata ledger in Postgres with long retention and no content, and a payload store for thirty days behind a separate role with an audit row written before every read. Because the console holds no payloads of its own, observability cannot become a bypass around redaction. Honest counting is enforced in the type system: every request declares its traffic class at ingress with no default, so a new channel cannot compile without saying what kind of traffic it produces.

Three surfaces, not one console

The console is not the whole product, and a screen inventory written late is a screen inventory that gets built twice. There are three authored surfaces plus the chat channels, and they differ by who signs in rather than by what they can reach — entitlement decides that identically in all four.

SurfaceWhoWhat it is forScreens
Admin consoleSuper Admin, Department AdminGovernance and inventory: every agent, skill, knowledge item, connector, grant and learning in scope13
Agent workspaceWhoever may open a given agentAssembly: one agent, and what is attached to it7 tabs
Member applicationEvery member, signed in on the webWhat the system holds about you, and your approvals9
ChannelsEveryoneAsking, answering, approving in Lark and WhatsApp

The two admin roles share one console build with a scope filter rather than two products. A Department Admin's console is the Super Admin's console with the scope chip pinned and the cross-department rows absent — which is a query predicate, not a second frontend. Knowledge and Learning appear in both: a Department Admin sees their own department's rows, the Super Admin sees every row in the company, because coverage across departments cannot be judged from inside one of them.

One distinction the console must hold and most products collapse: a Super Admin sees the complete inventory by role — every item, its owner, scope, freshness and audience. Opening the contents of another department's document is a separate capability, granted by default in a single-tenant install, revocable, and written to the audit ledger on every use. Existence, configuration and content are three planes of visibility, and role governs the first two while entitlement governs the third.

An agent is a container, not a configuration row

The member application and the agent workspace were both missing from the first console specification, and the second omission is the more expensive one. An agent composes eleven things, each stored as a reference with a pinned version rather than an inline copy, so a fix reaches every instance when someone promotes the version and a repository push on its own changes nothing that is live:

AttachedStored asWho may change it
Persona and roleversioned textowner
Knowledgea scope predicate, never a document listowner, within their own scope
Connectorsreferences; ungranted ones show as requesteddepartment admin
Skillspinned versions from the approved library onlyowner, after review
Channelsreferences, intersected with the ceilingdepartment admin
Model policyprimary and fallback chainsuper admin
Ceilingthe widest entitlement any run may reachdepartment admin
Leasha rung per target and scopedepartment admin; money boundaries need two
Memoryreadable text with a diff per revisionowner; the subject may delete their own
Automationsowned by the agent, run on a named principalowner
Artifactsappend-only, carrying the producing runnobody edits; supersede or archive

Availability and ceiling are two blocks that must never merge in the interface. Availability answers who can find and invoke this agent; the ceiling answers what any run of it may reach. Adding a department to Availability lets forty more people find the agent and gives them no data they could not already reach, because a run still resolves to E(caller) ∩ ceiling. Notion and Dust both ship the opposite — the creator's access travels with the agent — and the reason it is worth building the harder way is that theirs turns every shared assistant into an access-escalation path. The workspace therefore carries a preview-as-a-person control that runs the real gate rather than an estimator, because a preview with its own logic is the component most likely to lie.

Artifacts: what the agent produced

Knowledge and assets are inputs. Until now there was nowhere for output to live — every generated report, deck, export and image had no home in the model, which is a gap AnyGen's interface makes obvious by having solved it. An artifact is not a sixth governed noun, because nobody is granted one; it is an object class with a single rule. An artifact inherits the entitlement of the run that produced it. Field-level redaction is applied at production time rather than at read time, so an artifact can never contain more than its caller could see, and re-downloading re-checks the requester rather than trusting the original link. Retention follows the classification of the most sensitive input that fed it. Artifacts are superseded and archived, never edited, so provenance stays intact.

Part V · Delivery

19Feature list

Asking and answering

  • Natural-language questions over company data
  • Answers from live business records, not just documents
  • Field-level redaction per person
  • Citations to record and field, with freshness
  • Four distinct "I don't know" states
  • Fast lane: instant answers with no model call
  • Streaming with named progress steps
  • Conversation history and search
  • Escalation to a human with full context
  • Request-access route when refused

Agents

  • Global, department and personal agents
  • Pre-built template catalogue, install in minutes
  • Role templates for every agency discipline
  • Custom agents via the form composer
  • Procedure canvas for authored steps
  • Assign skills, tools, connectors, knowledge
  • Per-agent model tier and instructions
  • Save any agent as a private template
  • Template versioning with opt-in upgrade
  • Agents that learn, via a reviewable queue
  • Rehearsal against real gate with mocked tools
  • Publish gate with permission canaries

Skills and tools

  • SKILL.md folder format
  • Import from GitHub, URL or upload
  • Review queue before anything executes
  • Skill versioning and diffs
  • Progressive disclosure to control cost
  • Sandboxed skill scripts
  • Tool registry with a closed naming grammar
  • Per-agent tool assignment

Connectors

  • MCP, REST/OpenAPI, database and custom transports
  • Scope at connect: one folder, one pipeline
  • Read-only by default, write as a separate grant
  • OAuth, token and key credential handling
  • Credential values never visible to anyone
  • Connector health dashboard
  • Manifest pinning against silent redefinition
  • Live federation, minimal projection

Knowledge and assets

  • Upload documents, PDFs, images
  • Layout-aware parsing, tables survive
  • Extract from links
  • One knowledge layer for the whole company
  • Company, department and personal visibility
  • Uploading never publishes — widening is deliberate
  • Field-level classification within a document, so a price list can share sell prices while hiding cost and margin
  • SOPs, price lists, packages, brand guidelines
  • Owner, verified date and review date per item
  • Verification badge and re-verification nags
  • Hybrid search: full-text plus vectors
  • Per-scope indexing
  • Coverage and staleness reporting
  • Knowledge gap detection from unanswered questions

Entity resolution

  • One canonical entity across all systems
  • Multiple aliases per client, company and project
  • Identifier, name and fuzzy match cascade
  • Human review queue with weighted evidence
  • Reversible merge and unmerge
  • Blocked values and per-identifier caps
  • Never guesses when unresolved

Memory

  • Session, persistent and adaptive memory
  • Capability-tagged, re-checked at read
  • Human-gated promotion between scopes
  • Inspect, edit and delete any memory
  • Implicit learning signals captured from day one

Automation and autonomy

  • Scheduled, event and webhook triggers
  • Named outcome templates, not a blank canvas
  • Durable execution surviving restarts
  • Multi-agent task decomposition, parallel
  • Shadow, Assisted and Autonomous rungs
  • Asymmetric promotion, automatic demotion
  • Approval cards with the real artefact
  • Per-tool and platform-wide kill switches
  • Outbound webhooks to client systems

Browser and computer use

  • Navigate, log in, read screen, fill forms
  • Multi-step tasks with decision-making
  • Sandboxed, ephemeral, egress-allowlisted
  • Credentials never seen by the model
  • Envelope approval before any write
  • Out-of-band verification of results
  • Full session recording for audit

Channels

  • Lark, Slack, Teams, WhatsApp, Telegram
  • Web console and embeddable widget
  • Email and REST API
  • One brain behind every channel
  • Identity binding across platforms
  • Group-chat safe rendering per viewer
  • Rich cards, approvals, artefacts
  • Per-channel sensitivity policy

Models

  • Multiple providers and tiers
  • Deterministic tier classification
  • Priority and fallback matrix, editable
  • Circuit breaker with health probing
  • Per-department budgets and alerts
  • Residency as a hard routing rule
  • Bring your own keys
  • Local model support where required
  • Answer, plan and prompt-prefix caching

Administration

  • Company, departments, teams and users
  • Six platform roles plus stewardship
  • Roles and entitlements as separate models
  • SSO via SAML and OIDC
  • Joiner, mover and leaver handling
  • Time-boxed deputies and delegation
  • Full activity view, filterable by department and user
  • Hash-chained audit log
  • Retention, export, deletion and legal hold
  • Usage and cost reporting
  • Redaction-rate and canary reporting
  • Install, upgrade, backup and restore tooling

20Technology decisions

By layer, so every band in §2 has a named component. Health verified 4 September 2026.

The full stack
LayerChoiceLicenceWhy
ChannelsOwn adapters, harvesting RAGFlow's Go channel codeApache-2.0Tested Lark, WhatsApp, Telegram, Teams adapters already exist and are liftable.
Gate and redactionOursThe product. Never delegated. ~2,000 lines.
OrchestratorLangGraph + Pydantic AIMITA library, not a service, so the tool loop and permission checks stay ours. See §3.
Durable executionHatchet (Postgres-backed), or Temporal at scaleApache-2.0 / MITBehind our own adapter so the choice is reversible in one file.
ConnectorsMCP SDK + our REST, DB and custom adaptersMITFour transports, one typed contract.
RAG / retrievalPostgreSQL FTS + pgvector, fusedPostgreSQLNo second datastore. Scope filter inside the query.
Document parsingDocling, Tika fallbackMITLayout-aware; tables in PDF contracts survive.
EmbeddingsQwen3-Embedding-0.6B, localApache-2.0Off the hot path; keeps document content out of a third party.
Entity resolutionSplink offline + pg_trgm / fuzzystrmatch onlineMIT / PostgreSQLCalibrate weekly, evaluate in SQL at ~1ms. No ML dependency in the request path.
MemoryOurs, on PostgresNothing third-party between the capability set and the row.
CacheValkey + PostgresBSD-3Redis relicensed. Entitlement-keyed, never semantic.
Model gatewayLiteLLM SDKMIT coreDriver only, never the routing authority and never the proxy. Pin ≥1.83.10.
Routing and matrixOurs, config in PostgresDeterministic tier classification; residency as a hard rule.
Visual builderReact + Vite + React Flow, our composerMITForm-first for agents; canvas for traces and procedures.
BrowserPlaywright + playwright-mcpApache-2.0Accessibility-tree-first. Healthiest maintenance signal in the evaluation.
SandboxgVisor, Kata where KVM existsApache-2.0No /dev/kvm needed, which many SME VPS lack.
Egress controlmitmproxyMITSole route out of the runner namespace.
GuardrailsPresidio + GLiNER, signal onlyMITNever an authorisation boundary — its own FAQ disclaims recall.
IdentityKeycloakApache-2.0SAML and OIDC to the client's IdP. Zitadel relicensed to AGPL.
SecretsOpenBaoMPL-2.0Leases scoped to (agent, action, scope, run).
TracingLangfuse — mask client-sideMIT coreSix services with mandatory ClickHouse. Its docs state events land in blob storage before masking, so never rely on server-side masking.
Evaluationpromptfoo, driven through our gateMITSo an eval proves something about redaction, not about a prompt.
Object storageSeaweedFSApache-2.0Assets and session recordings. MinIO is archived.
DatabasePostgreSQL 18 + pgvector + PgBouncerPostgreSQLOne store for rows, documents, vectors, queue, audit and memory.
BackuppgBackRest + resticMIT / BSD-2Test the restore, not the backup.
Proxy and TLSTraefik, via CoolifyMIT / Apache-2.0Coolify configures it from the deployed resources and issues certificates. Caddy is the fallback where Coolify is not used.
DeploymentCoolify + Docker ComposeApache-2.0Already in place. Compose files are the source of truth; Ansible only where Coolify is absent.

Rejected: LLM Guard, MinIO, Daytona and Flowise (all archived, all still widely recommended); Redis and Zitadel and ParadeDB (licence); E2B (needs a cloud account plus Nomad and Consul for ten concurrent requests); Mem0, Letta and Graphiti (partition by id and call it access control); Onyx, Dust and AnythingLLM as a base (all enforce at document level — retrofitting field-level redaction is a rewrite wearing a fork's clothes).

On LangGraph specifically: the library is MIT and excellent, but langgraph-api — the server providing persistence, queues and crash recovery — is Elastic-2.0 and needs a commercial key in production. If you choose Python and want LangGraph, budget building that driver over the open-source checkpointer, and keep the tool registry in one place both the graph and the redactor read.

21Infrastructure

Small · 50 staffMedium · 150 staffLarge · 300 staff
Questions/day~300–1,000~1,500–3,000~3,000–6,000
Machine8 vCPU · 32 GB · 500 GB NVMe16 vCPU · 64 GB · 1 TB2 boxes: app 16/64, db 16/64/2 TB
shared_buffers8 GB16 GB24 GB
work_mem16 MB32 MB32 MB
PgBouncer pool152030
Worker concurrency4612
Browser memory cap8 GB · ~20 contexts8 GB · ~2016 GB · ~40
Disk growth/year~15 GB~40 GB~90 GB
Backup target~120 GB~350 GB~750 GB

Two Postgres pools, not one. PgBouncer in transaction mode for the app, and a separate session-mode pool for the job worker, because transaction pooling breaks the LISTEN/NOTIFY the worker depends on. Set autovacuum_work_mem explicitly or autovacuum workers inherit maintenance_work_mem and multiply it.

Latency budget. Fast lane under 1.5s, realistically 5–15ms. Answer lane under 8s with one live fetch on an 800ms timeout. Singapore to a source API is 200–600ms before their processing. Provider SDK defaults left alone can be a 10-minute timeout with 2 retries — override to 25s and 1 retry on the answer lane.

What breaks first, in order: document parsing memory during bulk ingestion, Postgres connections, then tracing. None of them is request throughput.

Deployment profiles. Lite is one box for a 50-person client: Postgres, app, worker, Caddy, Keycloak, ledger only. Full adds tracing, payload store, object storage, browser runner and automation. Same code, different compose file.

22Making it faster and more flexible

Additions, not removals. Ordered by impact against effort at your current volume.

Do now
ImprovementWhy it pays
Measure before optimisingThe predecessor's own figures do not reconcile (2.4 + 33.7 ≠ 31.2), and output tokens were never measured. Instrument the thinking-versus-output split, TTFT, per-stage timings and 429 counts in week one. Every number below is a row update afterwards, not a release.
Static tool prefix, projected catalogueDeclare the byte-identical tool union in the cached prefix and emit only the entitled subset after it. The provider cache is then shared across all 126 people because the cached bytes contain no per-user data. That is a security property as well as a cost one, and it is expensive to retrofit.
Grow the fast laneThe only measured sub-second win available. New fast-path rules must be data rows, not code, so a department admin can add one. Measure its share excluding machine traffic.
Collapse guard checkpointsThree serial screens cost roughly 700ms — the largest fixed item in the answer budget. At your load the scanner runs at about 2.5% utilisation, so this is a latency problem, not a throughput one. Collapse the checkpoints; do not buy GPUs.
Plan cacheStores tool ids, never results. A cached plan is a hint filtered through catalogue projection at execution, never an authorisation. This is the round-collapse mechanism.
Named progress stepsPerceived latency tracks visible progress, not elapsed time. Label steps in the user's language, and remember step labels are part of the security surface — "reading finance ledger" leaks the ledger's existence.
Do when triggered · premature before 10x
ImprovementTrigger
Answer cacheOnly once source epochs and a policy epoch exist. Without the policy epoch, tightening a field policy leaves a window where a cached answer keeps disclosing a field that was just revoked.
Read replica for the consoleWhen console queries start affecting answer latency. Not before.
Ledger partitioningAround 10x current volume.
Materialised views for reportsWhen a report takes over two seconds.
Local model hostingA contractual residency requirement, never cost or speed.

Two caches are forbidden by construction. Semantic answer caching, because similarity is not entitlement equality. And any cache keyed without the entitlement hash.

Flexibility comes from four seams: sixteen versioned extension points so a new connector or channel ships without a core release; every vendor behind our own adapter so swapping costs one file; configuration as data in Postgres rather than code, so tuning is not a deploy; and deployment profiles so the same code runs lite or full.

The honest ceiling. Tool and database time was 2.4s against a 27.9s median — 8% of the budget. At a 6s target it becomes 40%. Every model-side lever above stops mattering at that point, and what attacks it is the projection, the fast lane and parallel tool execution. That is the real limit of this design, and it arrives long before request throughput does.

23Hosting, delivery and recovery

Any VPS. Not AWS specifically.

This is Docker Compose on Linux. Hetzner, DigitalOcean, Vultr, Linode, OVH, a local Singapore provider or the client's own hardware all work, and none is meaningfully better than another for this workload. AWS works too if the client is already there, but it is more expensive for the same specification and the client-hosted premise means you would not be using the managed services that justify its price.

Three things matter more than the provider:

Cloudflare: useful, not required

Caddy handles TLS automatically, so nothing is blocked without Cloudflare. It earns its place in exactly one case and is unnecessary in another.

SituationVerdict
Public web widget on a client's marketing siteuse it DDoS protection, WAF and origin-IP hiding on the only unauthenticated surface in the system.
Internal only — Lark plus the console behind a VPNskip it Nothing is publicly reachable, so there is nothing to shield.
Any deployment, if you want no inbound ports at allCloudflare Tunnel The box makes an outbound connection and accepts no inbound traffic. A genuinely strong posture for a server you support remotely, and worth defaulting to.

One consequence to document rather than discover: with Cloudflare in front, TLS terminates at their edge. For a client with a data-residency clause, confirm that is acceptable before enabling it.

You already have Coolify, so use it

Coolify is Apache-2.0, actively maintained, and covers a real slice of this. It changes three decisions in this document.

Coolify gives youEffect on the design
Traefik with automatic TLSCaddy is no longer needed. Coolify configures Traefik from the deployed resources and issues certificates itself. One fewer thing to run and one fewer thing to explain.
Docker Compose as the source of truthExactly our packaging. The lite and full profiles become two compose files Coolify deploys, with no wrapper.
Scheduled database backups to S3, cron-configurableCovers the backup half of §23. It does not cover restore verification, which stays ours.
Per-resource environment variablesFine for configuration. Not for runtime secrets — connector credentials and browser session material stay in OpenBao, because those need per-run leases and an audit trail, not an env var.
Multi-server managementThe two-box upgrade path in §25 becomes a Coolify configuration rather than a migration.

Two caveats worth knowing before you rely on it. Coolify's own documentation advises against running it on the same server as the workloads it manages, because heavy application load can starve the control panel — for your own install that is a judgement call, and for client installs it is an argument for Coolify managing them remotely rather than sitting on each box. And budget its footprint: roughly 2 GB for a single application, 4 GB alongside databases, which comes off the figures in §21.

GitHub to server, pull not push

One private repository. GitHub Actions runs lint, type checks, tests, a migration check and the permission canaries, then builds and signs a container image and publishes it to a registry.

The server pulls; GitHub never pushes. No inbound access to the client's box, and no production credential ever stored in GitHub.

That single choice removes the two worst outcomes in a multi-client deployment pipeline: a compromised CI account reaching every client's server, and a client's secrets sitting in a repository. Each install runs a small agent that polls the registry for the tag it is pinned to and applies the update with drain.

ConcernHow it works
Per-client versionsEach install pins a release tag. Client A can sit on 1.4 while client B takes 1.6, and an urgent fix ships to one without forcing it on all.
ConfigurationOne Ansible variables file per install, in a separate private repo. Never in the application repo.
SecretsMinted per install, held in that install's OpenBao, never copied between clients and never in git.
MigrationsRun before the new image takes traffic, and must remain backward-compatible for one release so rollback is real rather than theoretical.
RollbackRe-pin the previous tag. Because migrations are backward-compatible for one version, this is a minute rather than an incident.

The install checklist exists because a copied deployment silently carries four things: platform project files, temporary CLI state, git remotes, and scheduled jobs. Every one of those has caused a production incident in this codebase's predecessor. They are checked on every install, not remembered.

Backup and recovery as a module, not a cron job

WhatToolSchedule
DatabasepgBackRestContinuous WAL archiving · daily incremental · weekly full
Object store and configresticDaily, deduplicated
Destinationoff-host, alwaysA backup on the same disk is not a backup
Encryptionat rest, client-held keyWe can restore it for them; we cannot read it without them
Retention30 daily, 12 weekly, 12 monthly

The part everyone skips, and the reason this is a module: a weekly job restores the most recent backup into a scratch container, runs migrations, executes a smoke query and a permission canary, records the elapsed time as the measured RTO, then destroys the container. A backup that has never been restored is a hope, and the elapsed time is the only honest answer to "how long would recovery take".

The console's recovery panel shows last backup, last verified restore, measured RTO against the profile's target, and offers a one-click drill. It alerts when backup age exceeds the RPO for that profile, or when a verification fails — which is a louder alarm than a failed backup, because it means the backups you have may not work.

24Quotas, budgets and cost control

A user can build an agent that fans out, calls six tools, retrieves forty chunks and runs on the heaviest model. Nothing in the design so far stops them running it two hundred times. Budgets and concurrency are the same subsystem, so they are specified together.

The budget hierarchy

LevelSet byTypical control
CompanySuper AdminMonthly token and currency ceiling. A hard stop, and an alert at 70 and 90 per cent.
DepartmentSuper Admin, spent by the Department AdminA share of the company budget. Departments cannot borrow from each other without an explicit transfer.
UserDepartment AdminA daily and monthly allowance, with a fair-share cap so one person cannot drain the department in an afternoon.
AgentWhoever publishes itA per-run ceiling and a per-day ceiling. This is the control that matters most, because an agent is where cost is designed in.

Every level is a row, editable in the console, versioned and audited. None is a constant in code.

Enforcement happens twice, before and after

Before: the router estimates a request's cost from its lane, tier, expected tool count and retrieval size. If the estimate exceeds any remaining budget in the chain, the request is refused or degraded before a token is spent. After: actual usage is recorded against principal, department, agent, model and lane. Estimates are compared to actuals and the estimator is corrected, because a cost control based on a bad estimate is theatre.

On breach, degrade rather than fail wherever it is safe: drop to a cheaper model tier, then disable fan-out, then queue the task lane, then refuse with a message naming which budget was hit and who can raise it. A hard refusal on the first breach makes people distrust the system; silent degradation without telling them makes them distrust the answers. Say which one happened.

The expensive-agent problem, specifically

An agent's cost is designed in when it is published, not when it is run, so that is where the control belongs. The publish gate in §18 shows a measured cost per run from the rehearsal fixtures — not an estimate — and publishing an agent whose median run exceeds the department's per-run ceiling requires the Department Admin's approval. Fan-out is a separate grant: an agent may not spawn parallel workers unless explicitly permitted, because that is where a single question becomes fifteen model calls.

What the console shows

Because the client holds their own provider keys, these numbers are theirs and reconcilable against the provider's own invoice. That is a genuine advantage over any credit-based hosted product, and worth stating in a proposal.

25Capacity, failure and SLOs

An independent review made a fair criticism of an earlier draft: it was stronger on the security control plane than on operational resilience. That is accurate. This section closes it, and most of what follows is adopted from that review.

Admission control

The design had per-run limits — eight concurrent subtasks, depth two — and no global limit. Twenty users each running eight subtasks is a hundred and sixty concurrent operations against a machine sized for ten. Every resource gets a global budget, enforced centrally before work starts, not by each subsystem independently consuming until Postgres or memory gives out.

Budgeted globallyUnder pressure
concurrent model calls · concurrent source calls per connector · browser sessions · long-running tasks · document jobs · embedding jobs · tokens per minuteFAST still runs · ANSWER queues briefly · TASK queues · browser queues separately · ingestion throttles · reports deprioritise

All of these are configuration rows, not constants. Three workload classes share the database with separate pools and budgets: interactive (the request path), background (tasks and agents), batch (ingestion, re-indexing, reporting). Six classes would be over-engineering at this size; one is what causes an ingestion run to make the chat slow.

Idempotency, and the problem this design did not previously answer

What happens if the process dies after the external system accepted a write, but before we recorded that it succeeded?

This was a genuine gap, and it matters most exactly where the system is most ambitious — browser writes and connector side effects. Retrying blindly repeats the action; not retrying loses it. The protocol's own recent revision removed message redelivery, so connector-side idempotency is mandatory rather than optional.

Every side-effecting operation carries an idempotency key and moves through explicit states: PENDING → SENT → UNKNOWN → VERIFYING → SUCCEEDED | FAILED. UNKNOWN is the important one, because it is the honest state after a crash, and it resolves by verification against the target system rather than by retry. Every connector must therefore implement a read-back that can answer "did operation X land?". A connector that cannot is restricted to read-only tools, and that restriction is declared in its manifest rather than discovered later.

One critical path, not one live fetch

The earlier constraint of "at most one live fetch per question" was too rigid — some questions genuinely need the CRM, the helpdesk and the ledger. The correct formulation is one critical path with parallel I/O: independent source calls fan out concurrently and the latency budget covers the slowest, not the sum. The per-source and global call budgets still apply, so parallelism cannot be used to evade the rate ceilings in §8. The dependency graph already built for multi-agent work is reused here for connector calls.

Per-connector rate limiting

A token bucket, circuit breaker and retry policy sit in front of every external system, owned by the connector layer rather than by callers. Twenty agent runs must not independently decide to call the same API twenty times. Per connector we track requests per second and minute, concurrency, 429 rate, latency and error rate — and the ceilings from §8 are configured here, not remembered.

Browser scheduling

Browser work is the most expensive subsystem, so it gets its own scheduler: global concurrency, memory and CPU limits, per-domain limits so one misbehaving automation cannot occupy every slot, per-agent limits, and a global kill switch.

Service levels, to be validated in stage 1

MetricTargetMetricTarget
FAST p50 / p95<100ms / <500msPermission decision p95<50ms
ANSWER p50 / p95<4s / <8sRedaction p95<20ms
TASK acknowledgement<1sConnector timeout5s default
Successful request rate>99.5%Unintended disclosurezero tolerated

Every request records: request and trace id, principal, agent version, policy epoch, entitlement hash, lane, model, provider, time to first token, input and output tokens, tool count and latency, connector, cache hit or miss, redaction count, fallback count, retry count, final status. This is mandatory production telemetry, not optimisation instrumentation — the numbers above are provisional until stage 1 measures them, because the predecessor system's own figures did not reconcile.

Recovery objectives, stated per profile

ProfileRPORTOMeans
Lite≤ 24h≤ 8hNightly backup, off-host copy, documented rebuild
Full≤ 4h≤ 2hWAL archiving, object-store replication, rehearsed restore
HA≤ 15m≤ 30mStreaming replica, second box, tested failover

"Backups work" tells a client nothing about resilience. These numbers do, they belong in the proposal, and backup frequency follows from them rather than the reverse. The single box remains the default and remains a large failure domain — the mitigation is that the two-box split is designed now and built when triggered: application and browser on one, database and object store on the other, with no application change required.

Failure-mode matrix and chaos testing

Before implementation goes far, the team produces a failure-mode matrix: for every component, what failure looks like, the user impact, the recovery path, and the data risk. Every operation is then classified as safe to retry, unsafe to retry, retry only after verification, or requires a human.

The tests that matter are not the happy paths. Kill Postgres, the model provider, a connector mid-call, the browser, the worker mid-task. Return malformed JSON and 429s. Fill the disk. Change a policy during an active task; disable a user during one. And the decisive one: kill the worker immediately after every side-effect boundary and verify the system recovers without repeating the action. That is how durable execution is proven rather than assumed.

26Build plan

StageShipsDone when
1 · Gate
5–7 wks
Schema and row-level security, Keycloak, capability tables, tool registry, catalogue projection, redaction walker, row plane, entity registry, one Lark channel, audit, backup with a tested restore, measurement layerTwenty real questions from five real people, answered correctly, permission decision explainable on each, one restore drill completed
2 · Execution kernel
and agents
5–6 wks
Kernel first: job model, idempotency keys and operation states, durable checkpoints, cancellation, timeouts, admission control, per-connector rate limiting, budget tables. Then agents: template model, four day-one templates, install wizard with golden sets, skills import with review, form composer, leash tableA department admin installs an agent and it answers correctly in minutes — and a worker killed mid-task recovers without repeating the action
3 · Trust
3–4 wks
Ledger and payload split, Operate screens, permission canaries, caches, model matrix and breaker, fast laneA model can be swapped without fear; you know where time and money go
4 · Breadth
4–6 wks
Document plane, more connectors, knowledge centre, second department, second and third channels, Govern and Report screens, joiner-mover-leaver, role templatesTwo departments in real use across two channels
5 · Doing
4–6 wks
Automations, approval cards, multi-agent decomposition, Shadow and Assisted only. Plus read-only browsing: the sandboxed runner, egress proxy and credential leases, restricted to navigation and extractionThe brain does work; and it can read a client portal that has no API
6 · Hands
4–6 wks
Browser writes: envelope compiler, enforcer, out-of-band verifier, session recording, approval diffs. The runner already exists from stage 5One real write against one target, behind Assisted
7 · Judgement
on trigger
Confidence scoring, Autonomous rung, circuit breaker with a scheduled caller, memory promotionOnly after stages 1–6 generate real outcome labels

Roughly seven months, usable at the end of every stage. Three sequencing rules worth defending in review:

27Open questions

  1. Durable execution. Hatchet is the default; Temporal if scale demands it. The LangGraph re-drive driver is ours to build. Confirm before stage 5.
  2. Not yet designed: multi-language retrieval including Chinese lexical search and Singapore NRIC/FIN recognisers; accessibility, particularly screen-reader-safe streaming; mobile and PWA; conversation export and eDiscovery; retention and DSAR mechanics; sensitive-topic interception for HR and grievance; API versioning policy.
  3. Not measured: the lane split and output tokens per answer. Instrument in stage 1, size in stage 3.
  4. Per client: whether their VPS exposes /dev/kvm, which decides what sandbox isolation can be promised.