Kontier RI - a shared investigation workspace for humans and agents

Inspiration

Every agent demo ends the same way. Something impressive happens, and you have no idea whether to trust it.

We kept hitting this inside our own product. Kontier is a billing platform, and the question customers ask most is some version of "why did churn spike last month?" An agent can answer that. What it cannot do is convince the person who has to act on the answer - the reasoning scrolls past in a chat window, the edits appear with no author, and no colleague can check the work before it becomes true.

So we stopped asking how do we let an agent do more and started asking who reviews it, and how would they even see it.

That question has a shape. It looks like code review: a proposal, a diff, a reason per change, a second pair of eyes, and a way back. WebMCP was the missing half - a page can declare what an agent may do, in the same tab a human is already looking at. The report becomes the shared surface instead of the transcript.

What it does

Several people and their agents work one live report.

An agent reads the page's tools through WebMCP, investigates, and proposes. Nothing is applied. A human - often a different human, arriving on nothing but an invite link - reads the staged change set as a diff with a reason on every row, keeps what they agree with, drops what they do not, and approves. The result lands attributed to the agent that proposed it, not the human who clicked, as one entry and one undo step.

In our demo, ChatGPT's own browser was asked in plain English why churn spiked in March. It listed the page's tools, read the work context, queried the data, and found the cause: one plan went from €99 to €149 that month, and 40 of the 44 cancellations came from it. Then it wrote two annotations onto the report and stopped, waiting for a human.

How we built it

A pnpm monorepo, TypeScript throughout, strict everywhere.

  • packages/studio - the collaboration contract and the WebMCP layer. 40 static tools plus small bundles that mount and unmount with the state of the work. Chrome's own guide asks for exactly this: "register tools when they're useful in a certain page state, then unregister when the tool is no longer usable," with static registration as the default.
  • One schema per tool. A zod v4 schema generates the protocol inputSchema via z.toJSONSchema() and re-validates the same shape inside execute, so the contract cannot drift from the implementation.
  • packages/datasource - DuckDB-WASM. SQL runs in the tab, and registerRemoteParquet range-reads remote Parquet over HTTP, so 100 million rows are queryable without downloading them.
  • packages/workspace - one WorkspaceStore interface, local and HTTP implementations, and a 23-test conformance suite run against both.
  • apps/web - Next.js. Guest workspaces where the invite link is the credential, real OIDC through the platform's existing Keycloak, an ordered command log with server-assigned seq, presence, and cross-user approval.

Deployed as a Node container at ri.kontier.eu, on the same infrastructure as the rest of the platform.

What we learned

WebMCP rewards being specific. Chrome publishes character budgets - 500 per description, 150 per parameter, 30 per name. Ours had one tool at 888 characters, repeating a grammar its own inputSchema already expressed. Nothing enforces those budgets, which is exactly why we wrote a test that does.

Declaring nothing is worse than declaring false. Silence on a mutating tool is a missing signal, not a neutral one. All 46 tools now declare readOnlyHint explicitly - 13 reads true and untrusted-hinted, 33 writes false - pinned by a test that fails on any future tool that says nothing.

Rejected calls are the ones you cannot see. A call the host rejects on schema validation leaves no trace anywhere — no document change, no activity entry. We had excluded those from our ledger, reasoning nothing ran. Wrong: an agent sending the wrong shape is one of Chrome's named failure modes, and the ledger is the only place it can surface.

Chrome 152 wants a JSON string. executeTool(tool, {}) fails with "Failed to parse input arguments." It needs executeTool(tool, JSON.stringify(args)). We only found that by driving the real runtime.

Challenges we ran into

Two workers, one contract, five silent failures. We built the client store and server API in parallel against a written contract. Every piece passed its own tests. Then they met: /identity was never implemented, so nothing ever connected. The client PUT a shape the server rejected - and the conformance mock read the id from the body, so 79 tests passed against a server that did not exist. loadDashboard read a bare record where the server enveloped it, so documents arrived undefined and the report opened empty with no error. The workspace package re-exported its test suite, dragging vitest into the browser bundle. And peers tracked each other through the command stream alone, so a change producing no activity entry left a peer stale forever.

The bug only production had. A reviewer opening an invite link erased the proposal they came to review - a freshly joined tab publishes an empty presence before its first read. It reproduced every time against the live site and never locally, because it is a race the fast path wins. The fix is one rule: read before write. We found it while recording the demo.

Local edits that undid themselves. A poll could list dashboards before a save landed and finish after it, applying a stale document over edits made in between. A change that quietly reverts a second later is the worst thing an editor can do.

Config describing a server nobody ran. Keycloak honours wildcards in the path, not the host - https://.kontier.eu/ had always been a no-op that merely looked correct. And the realm file declared an organization scope the live server lacked, which fails the whole authorization request rather than being ignored.

We put sign-in behind a wall to keep tests passing. It broke 41 of 42 end-to-end tests in one run - exactly what a first-time visitor would have experienced. The tests were right and we were wrong.

What's next

Entitlement-scoped tools. Our API keys already carry explicit permission scopes, so the toolbelt an agent sees can be derived from what the credential allows - an agent could not even see a tool it is not authorized to call, and the server would refuse it anyway. The machinery exists; it is scoped by workflow phase today and by permission tomorrow.

Built With

Share this project:

Updates