KeeperHub / KeeperHub/keeperhub
Permission Cards For agent actions
- Dominant language
- TypeScript
- Stars
- 24
- Forks
- 93
- Avg merge
- 1d 4h
- Merged PRs (30d)
- 253
Description
## What this is
Generative UI for KeeperHub. Today a tool call answers with JSON and the person reads it. With this
change the answer arrives as a small interactive card they can check, correct, and act on. Nothing
that changes anything runs until they press the button. Cards refresh their own reads; that is all
they do unattended.
## What it looks like
Connect KeeperHub to Claude Desktop today, ask it to run a workflow, and you get this:
```json
{ "executionId": "n3364uzl2s6aram5v558c", "status": "running",
"hint": "This confirms the workflow was triggered, not that it completed..." }
```
To learn what happened you type another tool call. More JSON. If the model picked the wrong
workflow, you find out afterwards.
With this change, asking about that workflow renders this in the transcript:
```
┌──────────────────────────────────────────────┐
│ Daily rebalance Enabled │
├──────────────────────────────────────────────┤
│ 1. Read vault balance Base │
│ 2. Swap USDC to ETH Base │
│ 3. Notify Slack │
│ │
│ Pays from Org wallet 0x8b3...9e01 │
│ │
│ [ Run once ] │
└──────────────────────────────────────────────┘
```
Press Run once. The card calls `get_execution` on a timer and redraws itself until the chain
confirms, ending on the transaction hash and an explorer link.
The same idea in the app. Someone types "send the treasury 500 USDC on Base". The amount is theirs,
but the destination is a name the model has to resolve, so the form comes back filled in and still
editable:
```
┌──────────────────────────────────────────────┐
│ Send USDC Base │
├──────────────────────────────────────────────┤
│ Amount [ 500 ] USDC │
│ To [ Treasury (2023) ▾] │
│ 0x1f9...c4a2 · never paid │
│ 2 entries match "treasury" │
│ From Org wallet 0x8b3...9e01 │
│ Fee about $0.02 │
│ │
│ [ Cancel ] [ Send ] │
└──────────────────────────────────────────────┘
```
The name was ambiguous and the model picked wrong. What catches that is not a warning, it is the
card showing the resolution: the label it landed on, that two entries matched, and that this
particular address has never been paid. The person opens the picker and takes the other one. Fixing
it is a click, not typing 42 characters.
Worth being precise about why it is built that way. "You have not paid this address before" on its
own is a weak control: it fires on every legitimate first payment to a new supplier, so it cannot
tell a wrong treasury from a new vendor, and a warning that common gets clicked through. It earns
its place only next to the ambiguity, where it says which of two candidates is the unusual one. So
the card shows the resolution, and the novelty line is a qualifier on it rather than the safeguard.
Three plainer cards round it out. Any other read renders its fields as a titled list, nesting one
level and linking out rather than dumping JSON. A validation result lists its errors and warnings.
A running execution shows its status.
In the app the same cards appear inline in the conversation, on their own page and in a panel beside
the builder:
```
┌──────────────┬────────────────────────────────┐
│ Hub │ Copilot │
│ Workflows ├────────────────────────────────┤
│ ▸Copilot │ you send the treasury 500 │
│ Analytics │ USDC on Base │
│ Activity │ │
│ Settings │ ┌──────────────────────────┐ │
│ │ │ Send USDC Base │ │
│ │ │ the same card, inline │ │
│ │ └──────────────────────────┘ │
│ │ │
│ │ [ ask something... ] │
└──────────────┴────────────────────────────────┘
```
The unhappy paths. Cancel closes the card and tells the model it was declined, so the conversation
carries on rather than dangling. A failed execution renders your own error text on the card, not a
stack trace. If the workflow changed between review and confirm, the route refuses and the card
offers to re-read and show what moved.
**Where the cards render, and where they do not.**
The MCP Apps extension is supported today by Claude on web and desktop, Goose, VS Code and ChatGPT.
The host draws the card in a sandboxed iframe, so a terminal client has nowhere to put one. Anyone
driving KeeperHub from a terminal, Claude Code included, keeps exactly today's text output with
nothing removed. That is not a limitation we are working around, it is how the extension is
designed: hosts that do not implement it ignore the metadata and behave as they do now.
The protocol announcement shows a working example rendered in Claude and in VS Code:
https://blog.modelcontextprotocol.io/posts/2026-01-26-mcp-apps/
That is the feature. The rest is how it fits your codebase.
## Reason
There is no way today to review a specific KeeperHub action before it runs. Agent users read JSON
and type the next call; nothing is clickable or correctable. App users have no conversational path
at all: there is no chat interface anywhere in the app, and the one AI route,
`app/api/ai/generate/route.ts`, is single-prompt workflow generation that is off in staging and
production, with its own end-to-end block skipped at
`tests/e2e/vitest/api-key-auth.test.ts:454-458` as no longer used by the frontend.
What told us to expect otherwise was KeeperHub itself. `components/welcome/previews.tsx` shows a
chat window where an agent drives KeeperHub and a person watches, and the API docs are written for
agents. The product already describes this shape. It just is not there for the person.
Checked on `staging` at `2df88ce`.
## Scope
**In.**
1. **Cards for MCP hosts that support the MCP Apps extension** (`io.modelcontextprotocol/ui`). Each
card is one HTML file with its styles and script inline and no external fetches. The host renders
it in a sandboxed iframe and passes clicks back over the extension's message channel. A card
draws the tool result it is handed and may re-read through the channel to refresh itself, under
the rule below.
2. **Each read tool's listing gains a `_meta` field** pointing at its card. Tool results, behaviour
and annotations are untouched, and hosts without the extension ignore the field by spec. With the
cards flag off, nothing is attached and every byte on the wire is what it is today. The filter in
item 3 is deliberately not behind that flag; why, and why it changes nothing today, is below.
3. **One change to an authorization boundary**, so that cards cannot reach anonymous callers.
Detailed below, with its tests.
4. **A chat page and a builder panel in the app**, plus a session-authenticated route that runs what
a person approved there. That route is the riskiest item here and has its own section below. The
transfer card reads your existing address book to resolve a name and to show whether an address
has been paid before; both are reads, through the routes that already serve them.
5. **Usage limits on that new route**, counted per organisation in Postgres. Not a fix to your
existing limiter, which is a separate concern. Just this: our route adds model calls, and
`app/api/ai/_lib/rate-limit.ts` notes in its own comment that counting in memory multiplies the
effective limit by the replica count, so we are not counting ours that way.
Two flags, both shipping off: one for the cards, one for the chat.
**Out.**
- No new MCP tools.
- No changes to signing, or to how a session or an API key is verified.
- No changes to `/api/ai/generate` or its flag.
- No MCP SDK version change, so the SDK 2.0 migration tracked in #1901 stays untouched.
We are not going to hide behind that second bullet. Verifying a credential is untouched; what a
credential is permitted to do is not. This change widens it twice, and both belong in your review
rather than in a reassurance list.
**The riskiest thing we are proposing is a new way to move money.**
Your direct-execution routes under `app/api/execute/` deliberately accept only OAuth and API keys.
A person approving a transfer in the app has neither, so this needs a route that accepts a browser
session and can move funds. That is a new authenticated path to value movement, and it is the
single item here we would most want you to push back on.
How we would contain it. The route resolves its caller with `getDualAuthContext`, the helper your
workflow routes already use, so the origin check, the step-up MFA state, the deactivated-org check
and the membership check all apply. It refuses anonymous accounts, as
`app/api/workflow/[workflowId]/execute/route.ts` already does. It then calls your existing execution
services rather than reimplementing them, so spend caps, the stablecoin cap, the circuit breaker,
concurrency, idempotency and receipt verification are all reached on the same code path an API key
reaches them on. It is behind a flag that ships off. And it writes an audit event naming the person
who confirmed, which the API-key path cannot do today.
We would rather propose it, show the containment, and let you judge it than bury it.
**How a card runs something, when cards attach to read tools.**
The card hangs off a read tool because a read tool returns the data it draws, `get_workflow` for
example. The Run button does not make that read tool execute. It calls `execute_workflow`, which
already exists and which you already annotate destructive, over the extension's message channel.
Say the honest version of that, though. No tool definition changes and no tool is added, but a card
on `get_workflow` puts an execution trigger one click away from a read. Someone asked to look at a
workflow and the result can now start it. The annotation is not what holds that line; these two
things are:
- **What a card calls on its own: reads yes, writes never.** A status card polls `get_execution`
unattended, because refreshing a read is what it is for. Nothing you annotate destructive is
called without a person pressing something. Be clear about what kind of guarantee that is: it is
a discipline in our own card code, which is script in a sandboxed iframe, not something the
server enforces. The boundary that does not depend on our code is the one you already have. A
card can only ask for what the connected credential is allowed to do, `withScopeCheck` still runs,
and `execute_workflow` still requires write scope. Our rule keeps a well-behaved card honest; your
scopes are what stop a misbehaving one.
- **The Run call carries an idempotency key**, using the `idempotency_key` argument
`execute_workflow` already accepts, so no tool definition changes. A re-render replays the
original execution instead of starting a second. If the card's host storage is cleared it does not
guess: it lists recent executions and asks which is yours.
This rule governs the MCP cards. It is not what protects the in-app money path, which is the
session route above and its own gates. Two different surfaces, two different guards; neither one
covers the other.
**On the authorization filter.**
`publicToolRegistrar` in `lib/mcp/server.ts` keeps non-public tools off anonymous `/mcp/public` by
matching on `.tool`. Cards register through `registerTool` and `registerResource`, which it does not
match on, so we extend it to cover those too, and register card resources inside the authenticated
branch. Both, not one. Its test asserts anonymous callers get zero cards and zero card resources.
This one is not flag-gated, deliberately. With no cards registered it filters calls that do not
occur, so behaviour is unchanged today and correct the moment the flag goes on.
## Plan
A proposal, not a commitment. Replace it if a different shape suits you.
1. **Cards.** Attach the `_meta` through a wrapper at `createMcpServer`, so no call site changes.
Extend `publicToolRegistrar`. Serve the card HTML as MCP resources to authenticated callers. One
shared card for any read result, plus the workflow, validation and execution-status cards above.
2. **In-app chat.** The page and the builder panel. New tables for conversations, messages and
proposed actions, in their own schema file with a generated migration. The model comes from your
existing `getAIModel` helper, lifted out of the generate route so both callers share it. The
session-authenticated confirm route lands here. A workflow proposal carries a fingerprint of the
definition the person reviewed, and the route refuses if the workflow changed underneath them.
3. **Usage limits** on that route, per organisation, in Postgres.
4. **Tests.** Anonymous callers get zero cards, authenticated callers get one per read tool. Flag on
and off. Snapshot tests proving results are unchanged for hosts without the extension. Unit tests
for the card data shaping and the limiter.
**Three things we would rather settle here than on the pull request.**
- **Two new dependencies.** `@modelcontextprotocol/ext-apps` pinned at `1.7.5`, the release that
works with the MCP SDK 1.x line you are on. Version 2.0.0 needs the split SDK 2 packages, so we
are not proposing it. And `@ai-sdk/react` at `2.0.256`, the binding that matches your `ai` 5.x.
- **New tables.** Chat history has to live somewhere. Migration generated and committed with its
snapshot and journal, per `AGENTS.md`.
- **Who pays for model calls.** The feature ships off, and a self-hosted operator supplies their own
key through the existing environment variables. We would rather name that than assume it.
**Packaging.** These are two features. They share the function that turns a tool result into the
values a card shows, and a small table in code mapping each operation to whether it needs a confirm
step. We would like to send them as one pull request, since they are one thing to a user and we are
building this for the Agent Economy hackathon, which closes on 18 September. Your rule is to split
what can ship separately, and these can. Tell us which you prefer and we will do that.
Contributor guide
Research direction
Start by reading lib/mcp/server.ts and app/api/workflow/[workflowId]/execute/route.ts, then inspect app/api/ai/_lib/rate-limit.ts and the skipped test at tests/e2e/vitest/api-key-auth.test.ts:454-458. Done means the scoped MCP cards, authorization filtering, session-authenticated app path, and Postgres usage limits are implemented with the stated flags, guards, and tests.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- postgres, typescript
- Domain
- api, authorization, backend, blockchain, databases, frontend, payments, security
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100