A personal CRM that lives inside your chat. Built with Skybridge, secured by AuthPlane, submitted to the AuthPlane x Skybridge Speedrun Challenge.
One-liner: Your second brain, in your chat.
>> Watch the 5-minute walkthrough on YouTube
The video shows the full bring-up, the one-line OAuth wiring, the
end-to-end test, a live chat session with harsh@demo.io, and the
isolation proof by switching to maya@demo.io.
| Who am I? | Contact card (empty) |
|---|---|
![]() |
![]() |
| Contact card (with note) | Search results |
|---|---|
![]() |
![]() |
| Render Blueprint | E2E test (16 green) | Devtools |
|---|---|---|
![]() |
![]() |
![]() |
| Isolation: Maya (empty) |
|---|
![]() |
The live deployment is open. Two demo accounts, one is empty (for the isolation proof), the other has seed data.
Live URLs
- App:
https://murmur-app.onrender.com/mcp - AuthPlane AS:
https://murmur-authserver.onrender.com
Demo accounts
| Password | Has data? | |
|---|---|---|
harsh@demo.io |
speedrun-demo |
yes (1 contact, Sam Lee) |
maya@demo.io |
speedrun-demo |
no (empty) |
Three ways to test it
- Skybridge DevTools (easiest, no install) -- open
https://murmur-app.onrender.com/__/in a regular browser tab. Sign in with one of the demo accounts. Chat with the model, watch the views render, call any of the 6 tools. - Codex desktop -- Settings -> Connectors -> Add Connector.
URL:
https://murmur-app.onrender.com/mcp. Click through the OAuth flow, log in asharsh@demo.ioormaya@demo.io. - Headless e2e -- from the repo root:
Walks the full OAuth chain (DCR, PKCE, login, consent, token, tool calls) and prints 16 green checkmarks.
node scripts/e2e-oauth.mjs --headless --user harsh
Isolation test
Log in as harsh@demo.io, add a contact, log out, log in as
maya@demo.io, search for the same name -- no results. Same
database, different JWT, zero data leakage.
Heads-up: First request can take a couple of seconds if the service has been idle.
Ask Claude (or ChatGPT) to remember a person, what you talked about, and what to follow up on. Murmur stores the note privately. Every contact is private to the signed-in user -- isolation comes directly from the OAuth token AuthPlane mints, not from application code.
Example prompts (works in any MCP host):
- "Add Sam Lee -- met at the conference, working on agent identity."
- "We talked about OIDC federation and his new blog post."
- "What did I say about Sam?" -> contact card.
- "What was I working on this week?" -> recent-dashboard view.
- "Who am I?" -> verified JWT claims on screen.
- "What was I working on this week?" as Maya -> empty state. Same URL, same app, different JWT sub, zero data leakage.
Self-hosted identity. AuthPlane runs in our own Docker
container. The token-signing keys live in /data, the user
database is in the same /data volume, and the only public
endpoint is the OAuth one. Nothing leaves our infrastructure --
no Clerk, no Auth0, no third-party identity vendor.
One-line OAuth wiring. The Skybridge framework exposes
authplaneProvider as a first-class OAuth provider. Our entire
auth layer is three config strings and one constructor option:
discovery, JWKS verification, scope enforcement, and DCR
acceptance are all handled by the framework. We did not write
a JWT verifier, a middleware, or a scope checker.
Per-user isolation by token, not by filter. Every store
query in src/store.ts takes a user_key derived from the
JWT's sub claim. There is no global lookup path and no admin
override. The who-am-i tool exposes the verified claims on
screen, so the auth itself is the visible feature, not a hidden
implementation detail.
+-----------------------------------------------+
| Host (Claude / ChatGPT) |
| - DCR client (Claude/ChatGPT self-register) |
| - PKCE-S256 + ES256 JWT bearer |
| - React view sandbox (CSP-restricted iframe) |
+-----------------------+-----------------------+
| HTTPS / streamable HTTP
| Authorization: Bearer <JWT>
| /.well-known/oauth-protected-resource/mcp
v
+-----------------------------------------------------------------------+
| Murmur (Skybridge, Node 24) |
| - runs natively: npm run dev -- --plain |
| - authplaneProvider({ issuer, resource, scopes }) |
| - 6 tools: who-am-i, add-contact, add-note, search, |
| list-recent, delete-contact |
| - 6 views: identity, contact-card, search-results, |
| recent-dashboard, delete-result, note-result |
| - store: node:sqlite (keyed by JWT sub) |
+-----------------------+-----------------------------------------------+
| discovery, register, authorize, token, jwks
v
+-----------------------------------------------------------------------+
| AuthPlane (Docker: authplane/authserver:latest) |
| - :9000 public OAuth, :9001 admin API (private network only) |
| - cloudflared quick tunnel -> public trycloudflare URL |
| - Resources: murmur (uri, scopes) |
| - Users: harsh@demo.io, maya@demo.io |
| - ES256 keys auto-generated, /data bind-mounted |
+-----------------------------------------------------------------------+
The three-identical-strings contract: the advertised PRM resource
= the registered resource uri = the JWT aud. Drift = 401
(invalid_target or invalid_token).
murmur/
|-- README.md
|-- docker-compose.yml AuthPlane only (the app runs natively)
|-- .env.example
|-- start.sh / start.ps1 Bring up AuthPlane + create demo users
|-- stop.sh docker compose down
|-- reset.sh Wipe volumes + database
|-- render.yaml Render Blueprint (authserver + app, one click)
|-- authplane.Dockerfile 1-line wrapper around authplane/authserver:latest
|-- scripts/
| |-- e2e-oauth.mjs Headless OAuth 2.1 E2E (PRM <- DCR <- PKCE <- token <- MCP)
| |-- seed.mjs Seed demo data for harsh@demo.io
| `-- seed-maya.mjs Leave maya's ledger empty (for the isolation demo)
`-- murmur-app/ The Skybridge MCP App (runs natively)
|-- package.json
|-- tsconfig.json
|-- .npmrc keeps devDeps at install time
|-- vite.config.ts
`-- src/
|-- server.ts authplaneProvider + 6 tools
|-- store.ts node:sqlite
|-- helpers.ts generateHelpers<AppType>()
`-- views/ contact-card, search-results, recent-dashboard
identity, note-result, delete-result
| Tool | Scope | View | Purpose |
|---|---|---|---|
who-am-i |
(any sign-in) | identity |
Render the verified JWT claims. |
add-contact |
contacts:write |
contact-card |
Create a person; render their profile. |
add-note |
contacts:write |
contact-card |
Append a note; view re-renders with it on top. |
search-contacts |
contacts:read |
search-results |
Free-text search over name + context + notes. |
list-recent |
contacts:read |
recent-dashboard |
Last N contacts touched, with last snippet. |
delete-contact |
contacts:write |
delete-result |
Remove a contact (and its notes). |
All six tools are declared with _meta: { "openai/widgetAccessible": true }
so any MCP host can mount the corresponding view inline.
Prereqs: Docker, Node 24, npm.
cp .env.example .env
# macOS / Linux
./start.sh
# Windows (PowerShell)
.\start.ps1This generates secrets, brings up the authserver container on
http://localhost:9000, creates two demo users, and prints the
cheat sheet.
cd murmur-app
npm install
AUTHPLANE_ISSUER=http://localhost:9000 \
SERVER_URL=http://localhost:3000/mcp \
npm run dev -- --plainOpen the printed dev URL in Claude (Customize <- Connectors) or
ChatGPT (Profile <- Apps <- Developer mode). Log in with one of the
demo users created by start.sh / start.ps1.
The repo ships a render.yaml that provisions both services. Render
dashboard <- New <- Blueprint <- pick the repo. It will create:
murmur-authserver-- the AuthPlane authorization server (Docker, upstreamauthplane/authserver:latest, persistent disk at/data).murmur-app-- the Skybridge MCP App (Node, nativenpm run start).
After the first deploy, copy each service's public URL and update the other service's env vars so the two URLs line up:
murmur-authserver<-AUTHPLANE_SERVER_ISSUER= its own URL,AUTHPLANE_RESOURCE_URI=https://<app>/mcpmurmur-app<-AUTHPLANE_ISSUER= the authserver URL,SERVER_URL=https://<app>/mcp
Then Manual Deploy on both. Free tier sleeps after 15 min of inactivity -- the first OAuth handshake takes ~30s to wake the service.
The live deployment backing this submission:
- App:
https://murmur-app.onrender.com/mcp - AS:
https://murmur-authserver.onrender.com
node scripts/e2e-oauth.mjs --headless --user harshThe script walks PRM discovery <- AS metadata <- DCR <- PKCE-S256 <- login <- consent <- token exchange <- scope-gated tool calls. Sixteen green checkmarks means the OAuth stack is wired correctly.
Per-user isolation: log in as maya, search "Sam" <- no results
(harsh's contacts aren't visible). Isolation comes from the JWT
sub claim, not from a WHERE user_id = ? filter we wrote.
The full e2e log against the deployed stack:
=== Murmur x AuthPlane scripted OAuth flow ===
AS: https://murmur-authserver.onrender.com
RS: https://murmur-app.onrender.com/mcp
Login as: harsh@demo.io (headless)
[OK] 1. DCR -- client registered
[OK] 2. PKCE -- S256 challenge ready
[OK] 3. authorize <- 303 (login redirect)
[OK] 4. login <- 303 (authorize with auth code)
[OK] 5. consent approved <- 303
[OK] 6. Authorization code captured
[OK] 7. Token exchange -- JWT issued
sub=egVrTTqmX6U6kO_wKqZTZA aud=https://murmur-app.onrender.com/mcp
scope=contacts:read contacts:write
[OK] 8. MCP initialize -- murmur v0.1.0
[OK] 9. tools/list -- 6 tools returned
[OK] 10. add-contact -- Added "Sam Lee"
[OK] 11. add-note -- Noted on Sam Lee
[OK] 12. search-contacts -- Found 1 contact
[OK] 13. list-recent -- 1 most recent
[OK] 14. delete-contact -- Deleted
[OK] 15. list-recent (after delete) -- No contacts yet
[OK] 16. who-am-i -- Signed in as Harsh via OAuth client
The Skybridge framework exposes authplaneProvider as a first-class
OAuth provider. The whole integration is three lines:
oauth: await authplaneProvider<MurmurClaims>({
issuer: ISSUER,
resource: RESOURCE,
scopes: ["contacts:read", "contacts:write"],
}),What that one constructor does internally:
- Discovery -- fetches
/.well-known/oauth-protected-resource/mcpand/.well-known/oauth-authorization-serverto learn the authserver URL and its advertised features. - JWKS caching -- pulls
/.well-known/jwks.json, caches the public key, rotates onkidmiss. - Bearer middleware -- on every
/mcprequest, validates the JWT against the cached JWKS, checksiss,aud,exp,nbf, and that the grantedscopeincludes the tool's required scope. - DCR acceptance -- accepts any client that registers via
POST /oauth/register(no pre-provisioning needed for hosts like Claude and ChatGPT). - Per-tool scope gating -- when a tool is declared with
auth: { scopes: ["contacts:write"] }, the framework rejects invocations whose token doesn't carry that scope.
The who-am-i tool is intentionally scope-less (any signed-in user
can call it), so it serves as a "logged in?" probe the host can call
to confirm the connection is alive.
Every store query in src/store.ts takes a user_key parameter
that's extracted from extra.authInfo.extra.subject in the tool
handler. The sub claim comes from the JWT the authserver mints.
There is no global lookup, no admin override, no "show me
everything" path.
async ({ name, context }, extra) => {
const userKey = subjectOf(extra); // <- JWT.sub, never trusted from input
const c = dbAddContact(userKey, name, context);
...
}Even if Maya's chat somehow requested Harsh's contact id, the
SQLite WHERE user_key = ? clause would return zero rows because
Harsh's user_key != Maya's user_key. The token literally cannot
represent Harsh's data -- that's the security property the OAuth
spec was designed for, and the property the demo makes visible.
What worked great:
authplaneProvideris genuinely a one-liner. Discovery, JWKS caching, and scope enforcement happened automatically once the three config strings were correct.- The AuthPlane admin API on port 9001 made user creation
scriptable. We created both demo users with two
curlcalls and a singleadmin API keyenv var. - The Skybridge devtools UI mounted against the deployed app at
https://murmur-app.onrender.com/__/(it's normally localhost-only). That was a happy accident that gave us an extra demo beat in the video.
What was hard:
- The three-identical-strings contract (advertised
resource= registereduri= JWTaud) is unforgiving. One trailing whitespace and you get a 401 with no useful error message. We spent more time debugging env-var typos than writing the app. - The AuthPlane upstream image is distroless -- no shell, no apk,
no
bash. We tried to add a user-seed entrypoint to the Docker image and the container failed to start withexec: "/bin/sh": stat /bin/sh: no such file or directory. Workaround: SSH into a sibling service and hit the admin API on the private network. - Render's free web-service model exposes only one public port. The admin API (port 9001) is on the private network only, so we couldn't have a one-command "deploy + seed users" flow. The e2e script + a small SSH bootstrap is the cleanest workaround for free-tier deployments.
- Codex desktop renders MCP App views inside a cross-origin
iframe (
web-sandbox.oaiusercontent.com) with a CSP ofscript-src 'self' 'unsafe-inline' 'unsafe-eval'. Skybridge's view JS is served from the MCP origin, soscript-src 'self'resolves tooaiusercontent.comand blocks the view bundle. Tool calls work end-to-end (16/16 e2e green) but the view body renders empty. Same behavior in dev, prod build, and Vercel deploy. Filed in AuthPlane #7. Workaround for the demo: Skybridge's devtools UI mounted against the deployed Render URL (https://murmur-app.onrender.com/__/), which is same-origin to the view bundle so the CSP allows it. - ChatGPT (
chatgpt.com) does not currently support custom MCP servers for Free or Plus personal accounts -- per the OpenAI help center, full MCP is "only available to Business and Enterprise/Edu users." Pro accounts can connect but with read-only restrictions. That narrows the Speedrun Challenge rule 4 ("Connect it in Claude or ChatGPT") to Claude.ai for Free/Plus submitters. We confirmed Claude.ai custom connectors accept the PRM + DCR flow; for the video we used Codex + devtools for the actual rendering surface. - Codex desktop caches the registered client and the issued
access token across logins, so the "switch user to prove
isolation" beat of our demo required forcing a fresh OAuth
handshake. The Disconnect button in Codex's connector UI
cleared the token, but the cached DCR client still matched
the next login and skipped the consent screen. The reliable
reset was to delete the client on the authserver with
DELETE /admin/clients/<id>?force=true(private network only), then re-add the connector in Codex. Worth documenting if you want submitters to demo per-user isolation cleanly.
MIT.







