Skip to content

Rose for Agents (r4a)

Prototype (IX-5025)

Every r4a-mcp tool requires a signed-in buyer (Buyer identity). Both connectors (mcp.userose.ai, mcp-test.userose.ai) serve signed-in buyers. There are no rate limits or spend caps per buyer on the general profile yet. The ChatGPT research profile has process-local request limits. The only vendor with a working buying funnel is a fake one, Ibanly, which is live at ibanly.app. See What is not built.

What it is

Rose for Websites sells to people visiting a client's site. Rose for Agents sells to AI assistants (Claude, ChatGPT, Cursor, Codex) that are doing a buyer's task: research a category, compare vendors, try a product, sign up, pay, start using it.

r4a is one MCP connector, hosted by Rose, that the buyer installs once. Through it the assistant reaches every Rose customer. A customer provides no tools of its own: r4a exposes the same tools for every vendor and calls the customer's systems on its behalf.

Brand chat (backend/apps/mcp) Rose for Agents (backend/apps/r4a_mcp) WebMCP
Who is working the user talks TO a brand the assistant works on the user's task an AI browser on the client's page
The vendor's agent is the speaker, relayed word for word a source the assistant interprets a source, for that one site
Tools ask_brand_agent, compare_vendors ask_vendor + the buying tools ask_sales_agent
Output ChatGPT card or verbatim text plain data with a provenance note plain text

The two hosted connectors give the model opposite instructions (relay verbatim versus interpret and attribute), which is why r4a is its own app and not a mode of brand chat.

ChatGPT research profile

The implementation also serves /chatgpt/mcp on the same application, with a separate four-tool surface: ask_vendor, find_alternatives, compare_vendor_capabilities, and get_vendor_setup. ChatGPT receives source data it can interpret and an inline MCP Apps evidence card using Rose's blue/neutral colors. Buying tools, subscription plans/prices, account creation and credential delivery are excluded from this profile. The general /mcp tools retain their existing contract.

Named-vendor answers come from the vendor's Rose agent and remain vendor claims. Alternatives cover Rose customers only. Capability comparisons and setup documentation come from Rose-maintained web-search/scraping research sheets, with original sources and dates; unknown facts remain unknown, and filtered vendors have explicit exclusion reasons. The card sends follow-ups through the host chat instead of adding another composer.

The existing buyer OAuth protects both routes with the same token audience. The research resource advertises /.well-known/oauth-protected-resource/chatgpt/mcp; sessions cannot cross profiles, buyers or OAuth clients. Research continuations are opaque, scoped to one buyer/vendor and expire after one inactive hour.

The profile bounds requests to 12/minute, 100/rolling day and 2 concurrent calls per buyer, per process. These limits reset on restart and multiply across workers. Public rollout still requires a distributed budget, live ChatGPT sign-in/rendering and reviewer access verification, and explicit submission. Conservative screening of requests, responses and links reduces out-of-scope output; it does not guarantee every language or arbitrary vendor response is safe. No directory listing or live deployment is implied by the implementation. See the connector README for local and live E2E commands.

Actors

Actor Runs where Role
Buyer their own machine gives the task, approves paid checkouts, owns the account
Assistant Claude, ChatGPT, Cursor… plans, calls r4a tools, interprets answers
r4a-mcp Rose infrastructure the connector: instructions, tools, request signing
Buyer sign-in r4a-buyers Supabase project + id.userose.ai the buyer's Rose for Agents account; OAuth server for assistant hosts
Website agent Rose infrastructure (search API) the vendor's existing Rose agent; answers ask_vendor
Vendor endpoint the customer's infrastructure creates accounts, takes payment, issues credentials

Because r4a is hosted by Rose, anything only the customer's system can do (create an account, issue a credential) is an outbound call from r4a to an endpoint the customer hosts. Evaluating and listing offers need no such call.

The flow

A one-time sign-in, then three phases. Only evaluation needs nothing from the customer's own systems. Labels are shortened to stay legible: every tool call also carries the brand, and vendor paths are the full ones listed in The contract. The assistant may skip the proxied "use" step and call the vendor directly with the credentials it received.

0. Connect and sign in, once

The buyer installs the connector once and signs in once. After that every tool call carries the buyer's token, so r4a knows who is buying without the assistant ever typing an email.

sequenceDiagram autonumber actor B as Buyer participant H as Assistant host participant R as r4a-mcp participant I as Rose sign-in (id.userose.ai) B->>H: add the r4a connector H->>R: first request, no token R-->>H: 401 + where to sign in H->>I: register, start OAuth (PKCE) I->>B: sign in with Google B-->>I: allow this assistant I-->>H: access token H->>R: requests with the token R->>R: verify token, read buyer identity R-->>H: tools available

The assistant host (Claude, ChatGPT, Cursor) runs this flow by itself: it is the standard MCP authorization flow, triggered by the 401. It is also why sign-in has to cover the whole connector: a server that answers some tools anonymously never sends the 401 that starts the login.

1. Evaluate: the vendor's agent as a source

sequenceDiagram autonumber actor B as Buyer participant A as Assistant participant R as r4a-mcp participant W as Website agent B->>A: task A->>R: ask_vendor(vendor, question) R->>R: resolve vendor R->>W: query W-->>R: answer, sources R-->>A: data + provenance A->>A: interpret, compare A->>R: get_offers(brand) R-->>A: offers, trial, FAQs

2. Try and sign up: r4a calls the customer

sequenceDiagram autonumber participant A as Assistant participant R as r4a-mcp participant V as Vendor endpoint Note over R,V: every request is signed by r4a for the signed-in buyer A->>R: try_product(brand, input) R->>V: POST /trial V-->>R: sample output R-->>A: mode "trial" + output A->>A: judge the real output A->>R: provision_account(brand) R->>V: POST /accounts (buyer, verified email) V-->>R: the buyer's account_id R-->>A: provisioned

3. Pay, get access, use

sequenceDiagram autonumber actor B as Buyer participant A as Assistant participant R as r4a-mcp participant V as Vendor endpoint A->>R: checkout(offer) R->>V: POST …/checkout V-->>R: 402 + amount R-->>A: payment_required A->>B: approve 18.75 EUR? B-->>A: yes A->>R: checkout(offer, token) R->>V: POST …/checkout V-->>R: active + invoice R-->>A: active A->>R: get_access(account) R->>V: GET …/access V-->>R: api_key, plan R-->>A: credentials Note over A,V: use is proxied today A->>R: try_product(input, account) R->>V: POST …/use V-->>R: full output R-->>A: live output A-->>B: result + receipt

A customer on the none backend stops after phase 1: provision_account answers human_signup_required with the customer's sign-up link.

Tools

Every tool is exposed by r4a. The customer column is what a vendor has to supply.

Step Tool Logic supplied by The customer provides Status
Evaluate ask_vendor r4a, through the vendor's Rose website agent nothing (comes with Rose for Websites) real
Alternatives find_alternatives r4a, from competitor lists and identity descriptions its competitor list and description real
Compare (non-customers) compare_offers r4a, from offer sheets kept by Rose staff nothing phase 0 (IX-5364), one niche
Offers get_offers r4a, from the vendor spec data, no code built (spec is a file)
Assess assess customer handler, optional POST /assess built for Rose (IX-5714)
Trial try_product customer handler POST /trial built against the fake vendor
Sign up provision_account customer handler POST /accounts built; owned by the signed-in buyer
Pay checkout customer handler POST /accounts/{id}/checkout built with fake tokens; no Stripe
Access get_access customer handler; for a sheet vendor without account_id, its offer sheet GET /accounts/{id}/access built
Use try_product + account_id customer handler, proxied POST /accounts/{id}/use built

ask_vendor returns vendor, domain, answer, sources, suggested_follow_ups, conversation_id, agent_id, agent_name and a provenance line. The connector tells the assistant to ask one precise question per call, reuse the conversation and agent IDs for follow-ups, compare vendors, answer in its own words and attribute claims.

Start with only the vendor and question. When that vendor has several registered workspace agents, both profiles return named agents with opaque agent_id values and instructions for the next call. For example:

{
  "agents": [
    {"agent_id": "<returned-fr-id>", "name": "PayFit FR"},
    {"agent_id": "<returned-uk-id>", "name": "PayFit UK"}
  ],
  "note": "Call ask_vendor again with one returned agent_id and the same question. Choose the agent that matches the buyer's stated country, product or business context; if that is unclear, ask which of these agents they mean. Do not infer the agent from the question's language. Reuse the selected agent_id on follow-ups."
}

If the buyer has said their company is in France, retry with the returned French ID: ask_vendor(vendor="PayFit", question="…", agent_id="<returned-fr-id>"). Otherwise ask which named agent they need. An English question can concern France; a French question can concern the UK. Answer language never selects the workspace. The same rule covers workspaces split by product or business unit.

A single available agent is selected automatically. Answers include the selected ID and name; reuse the ID for follow-ups and start a fresh conversation when switching agents. Research continuation handles are bound to that workspace as well as the buyer and vendor.

The production ask_vendor input market_id remains a deprecated alias for agent_id during the client transition. If both are supplied they must agree; conflicting IDs are rejected before contacting an agent. New clients use agents, agent_id and agent_name in results. Retire the input alias only after checking existing client usage. Historical SQL names remain transitional aliases until deployed callers have migrated; do not drop them in a rolling release. The workspace function owns the catalogue implementation; legacy SQL names delegate to it and keep their previous return shape and role restrictions. The canonical entrypoints do not depend on those compatibility names. Before merging to develop, an operator must apply 20261030112452_ix5888_workspace_agent_catalogue.sql to the shared production database: merging automatically deploys the test connector, which reads that database too. Both the connector and search API require the new workspace RPCs. Catalogue lookup failures return an error without contacting a default agent.

The connector reads live workspaces and their roots through the private, read-only brand_agent_workspaces function. The ID is workspaces.id, not a separate agent registry. Each selection is checked against the vendor's current catalogue before any agent call. Unknown, disabled or foreign IDs return choices; no agent is called. Root aliases are deduplicated per workspace. Vendors without explicit path routing keep the existing agent for the resolved host; sibling host agents are not offered as new choices in legacy mode. With explicit routing, an enabled path also requires its host default to be enabled. Routing URLs stay internal to the connector.

The Ask Rose link (ask.userose.ai/<domain>) offers the same choices through the service-role wrapper public.agent_workspaces. Pass the selected agent_id as agent=<agent_id>, or open its ready-made link. JSON choices use agents; markdown asks “which agent?”. The deployed market query parameter remains a deprecated alias for agent; conflicting choices return HTTP 400. During rollout generated links and the Worker carry both matching names, so either the older Worker or older API still receives the selection. A follow-up with c keeps its agent; lang sets only the answer language. The connector and Ask Rose each own their small selection code, with disambiguation and isolation checked in each suite.

A path workspace only answers in its corpus environment. For example, a French workspace ready in production is deliberately unavailable on the shared test API. To test a new corpus without changing customer data, follow isolated test onboarding and point the connector's ROSE_API_BASE at the search API using that same preview database and retrieval namespace. Wiring a local CLI to a preview does not change the deployed apitest service's target.

find_alternatives(tool, aliases, need) lists live Rose customers (not video or test clients) that are alternatives to a tool or fit the user's need, in two passes. Each carries agent_buyable: true when it has an http vendor spec, so the assistant can sign up, buy and use it through the buying tools; the assistant tells the user when only the alternative can be adopted by an agent end to end. It asks each relevant one with ask_vendor how it compares. The result says it is Rose's network, not a neutral ranking, and the instructions tell the model to say so.

  • Competitor list (matched_by: competitor_list). Customers whose competitors.names contains the tool. Names match on a key that ignores case, accents, spaces, punctuation and a trailing domain suffix, then by trigram similarity ≥ 0.7 (the band brand resolution uses), so "iban api" finds a customer that lists "IBANAPI". String matching cannot know that "AWS" is "Amazon Web Services", so the assistant passes aliases from its own knowledge; a customer matches if it lists any of the names.
  • Similar need (matched_by: similar_need). Customers ranked by cosine similarity between the need and <name>: <identity description>, embedded with OpenAI text-embedding-3-small on the EU endpoint: top 5 at similarity ≥ 0.3, after the competitor matches. They are candidates the assistant judges from their descriptions (right matches scored 0.32 to 0.65 in calibration; unrelated clients 0.2 to 0.37). Embeddings live in private.client_description_embeddings and refresh on read: each need search embeds the profiles whose text changed, in the same OpenAI call as the need, and private.store_alternative_embeddings only accepts a vector whose hash matches the current text. A client without an identity description cannot match this way. r4a mounts the EU-restricted OPENAI_API_KEY_PROCESSING_EU as OPENAI_API_KEY; without it, need matching is skipped and logged.

Offer sheets: vendors that are not customers

compare_offers(niche, unit, monthly_volume, …) compares vendors that are not Rose customers from sheets Rose staff fill by hand from their public pages (r4a_mcp/offers/<niche>/<vendor>.json, validated by r4a_mcp/offers.py). The only niche today is web-search-scraping (15 vendors). Prices are normalized to US dollars per month at the buyer's volume, in searches or pages; each row names its cheapest plan, the free tier, compliance and capability facts, source_url, as_of and official: false, and vendors that fail a constraint come back in excluded with the reason. A fact the sheet does not hold is unknown, never "no". get_access(vendor) without account_id returns how to start with a sheet vendor: its official MCP server with the install for the buyer's host (from MCP clientInfo.name: a shell-quoted command for Claude Code and Codex, a connector URL for claude.ai and ChatGPT, an mcpServers entry otherwise), SDKs and sign-up link. get_offers(vendor) returns the whole sheet: every plan with its notes, facts and connect fields. no_rose_agent and find_alternatives point to these tools when the name has a sheet. Plan and results: docs/plans/2026-10-01-ix-5364-r4a-phase-0.md.

Benchmark. bench/questions.json holds 39 buyer questions (pricing, features, getting started, comparisons, listings, which vendor does what) with gold facts. just bench base and just bench rose ask Codex each question, alone (live web search) or with this connector; just bench-judge grades both arms against the gold and compares them blind (Claude judge, both orders), and bench/transcript.py writes the answers side by side. Results: Codex alone vs Codex + Rose. When both arms contradict a gold fact, check the vendor's page: it is how sheet errors were found.

Codex needs an instruction to use the connector. Codex loads MCP tools on demand, so without a user instruction it answers from web search and never calls Rose. The buyer adds the snippet in bench/AGENTS.rose.md to ~/.codex/AGENTS.md when installing the connector.

To update a sheet, edit the JSON from the vendor's pricing page and set as_of to the day you read it; just test validates the file.

Usage

Super Admin → AI agents → Rose for Agents shows tagged vendor questions and the five buying tools (get_offers, try_product, provision_account, get_access, checkout) by vendor and connector profile, for the selected period and environment. Questions count stored user turns; buying steps count calls that reached a vendor, including repeated trial/access checks, rather than completed purchases. Historical mcp-server questions with user_typed cannot be separated from the brand-chat connector and are not relabelled or included as R4A questions. This staff usage report includes tooling and staff calls; the client-facing traffic Sankey below excludes those agent families.

Each executed tool call logs one JSON line, jsonPayload.message="r4a_tool_call". General /mcp events have no profile field and include tool, buyer_id, client (the host), the vendor names asked (vendor, brand, tool_asked, aliases, vendors), the compare_offers constraints, the resolved domain, outcome and latency_ms. Free text (questions, task context, need, product input) is never logged. outcome is ok, not_on_rose (no Rose customer has that name), error, needs_confirmation, sign_in_required, exception, or the vendor's status or mode (provisioned, active, trial, live, …).

Research events set profile="chatgpt" and include only tool, outcome and latency_ms, with error_type, http_status and sanitized error_stack locations on failures. They omit buyer IDs, vendor arguments, exception messages and free text. Research outcomes are answer, alternatives, comparison, setup, confirmation, error or sign_in_required. Schema/transport rejection before tool execution is not a research usage event. Keep the profiles separate when counting calls or buyers.

q='resource.type="cloud_run_revision" AND resource.labels.service_name="r4a-mcp-production" AND jsonPayload.message="r4a_tool_call" AND NOT jsonPayload.profile:*'
calls() { gcloud logging read "$q $1" --project=inboundx --freshness=7d --format=json; }
calls '' | jq -r '.[].jsonPayload.tool' | sort | uniq -c | sort -rn                  # calls per tool
calls '' | jq -r '.[].jsonPayload.buyer_id // empty' | sort -u | wc -l                 # unique authenticated buyers
calls '' | jq -r '.[].jsonPayload.domain // empty' | sort | uniq -c | sort -rn        # Rose vendors asked
calls 'AND jsonPayload.outcome="not_on_rose"' \
  | jq -r '.[].jsonPayload | .vendor // .brand' | sort | uniq -c | sort -rn             # names not on Rose

q_research='resource.type="cloud_run_revision" AND resource.labels.service_name="r4a-mcp-production" AND jsonPayload.message="r4a_tool_call" AND jsonPayload.profile="chatgpt"'
gcloud logging read "$q_research" --project=inboundx --freshness=7d --format=json \
  | jq -r '.[].jsonPayload | [.tool, .outcome] | @tsv' | sort | uniq -c | sort -rn

Agent traffic per client

Analytics → Rose for Agents (IX-5774) shows a client's AI agent traffic for the page's period and environment: a Sankey from agent to surface to the furthest step reached, totals per surface, and the AI engines that crawled its FAQ pages. The tab appears when the client is listed on Rose for Agents, and always for staff. Only the surfaces the client turned on come back from the database (backoffice.r4a_agent_surfaces): FAQ pages and the crawler list with the geo feature, WebMCP with webmcp, the Ask Rose link with agent_http, Rose for Agents and Rose in ChatGPT with the listing; the brand-chat connector has no switch. The data comes from backoffice.r4a_agent_traffic, which joins two sources:

  • Conversations (questions asked): Ask Rose, WebMCP, the brand-chat connector and R4A. R4A turns carry their own message_source (r4a:ask_vendor, r4a:research for the ChatGPT app), so they are no longer mixed with the brand-chat connector's user_typed.
  • private.agent_touchpoints (reaches without a question turn), written through private.record_r4a_activity by r4a-mcp after each buying tool, one row per vendor domain the result names (find_alternatives and compare_offers rows are shown), as rose_mcp, off the call path; the ChatGPT app the same way. The search API uses record_agent_touchpoints for an Ask Rose page fetch without a question; the GEO pages Worker uses it for a page served to an AI user agent (user_fetch, search_index or training, declared user agents only; at most 60 a minute per client, since anyone can claim one). No raw buyer identity, question, answer or tool input is stored.

Unknown domains are dropped at write time. Tooling (curl, python, node) and Rose's own offer drafter are left out of the view. WebMCP sessions count as reached only when the browser identifies as an agent (chatgpt_agent, google_agent): plain Chrome exposes the tools too.

Buyer conversation timeline

In Conversations, staff select AI agents (MCP / R4A) alongside the environment to see connector conversations. R4A questions have an R4A badge; the five buying steps appear as activity rows beside the question and answer. The ChatGPT research profile is labelled separately.

The connector hashes the verified buyer ID together with vendor domain, environment and profile. Only that opaque key is stored, in private analytics tables. It links each buying step to the buyer's most recently recorded vendor question in that scope; it does not identify which assistant thread initiated a buying call when several threads share the same buyer and vendor. Chat session IDs and memory behavior stay unchanged. Steps before the first question appear in aggregate usage immediately and join its timeline once the question is recorded. A buyer who never asks a vendor question has aggregate activity only. Historical anonymous steps cannot be linked retrospectively. Once linked, steps keep their original conversation even if its save is delayed or the conversation is removed. If a question never saves, those steps remain in usage totals only; they are not moved into another buyer timeline. An activity-query failure shows a retry notice while the conversation remains visible.

Question links can precede the search API's asynchronous conversation save. The timeline reader returns events only after finding the actual R4A conversation, with matching workspace and environment and the viewer's workspace access. Analytics recording is best effort and runs outside the buyer's call path; a failure logs a warning.

Buyer identity

Every tool requires a signed-in buyer. Buyers have a Rose for Agents account, a buyer-only identity separate from Rose customers (backoffice users): they sign in with Google, and the account is created on first sign-in.

flowchart LR H[Assistant host] -- OAuth 2.1 + PKCE --> A[r4a-buyers<br/>Supabase Auth] B[Buyer] -- Google sign-in, consent --> S[id.userose.ai] S --> A H -- bearer token --> R[r4a-mcp] R -- JWKS --> A R -- signed, X-R4A-Buyer --> V[Vendor] R -- vendors used --> D[(buyers.vendors)] S -- profile, vendors --> D
Where What
r4a-buyers Supabase project buyer sign-in (auth.users, Google), the OAuth 2.1 server hosts register with, buyers.profiles (optional phone), buyers.vendors (vendors used, account id)
id.userose.ai /oauth/consent: sign in and allow an assistant; /account: email, phone, vendors used. Card, credits and vendor approval are shown greyed out, not built
r4a-mcp OAuth resource server: /mcp answers 401 with the discovery metadata (/.well-known/oauth-protected-resource/mcp) until the request carries a valid buyer token
Rose project (shared) brand lookup, vendor question conversations and private agent touchpoints / opaque conversation links

Which tokens r4a accepts. Signed by the project's asymmetric key (public JWKS, so r4a holds no secret), issued by its Auth server, unexpired, aud = r4a-mcp, issued to an OAuth client (client_id), with a verified email. The project's access token hook (buyers.access_token_hook) sets aud and email_verified on tokens issued to OAuth clients only; a browser session token is refused. An MCP session is bound to the buyer who opened it: another buyer's token on the same session is answered as an unknown session. Without its identity configuration the hosted app refuses to start. Over stdio (a local assistant, never exposed to buyers) the buyer comes from R4A_DEV_BUYER_ID / R4A_DEV_BUYER_EMAIL, and every tool refuses without them.

Vendor accounts belong to the buyer. provision_account takes no email: r4a sends the buyer's id (X-R4A-Buyer, signed) and verified email. The vendor keys the account by buyer, so the same buyer always gets the same account back (200) and an email change never moves it. Checkout, access and use on an account the buyer does not own return 403. Accounts created on Ibanly's website belong to no buyer and can never be used through r4a.

Why sign-in is required. Without it, anyone who knows the connector URL can create accounts at a customer in someone else's name, make the customer send email to arbitrary addresses, and spend Rose's LLM budget through ask_vendor. With it, every action has an accountable owner, rate limits and spend caps can be per buyer, and the customer receives a lead it can trust.

Existing-account takeover. Returning an existing account id for an unverified email let a caller retrieve its API key through get_access, use it or change its plan. Ownership by signed buyer closes it: an email never names an account, and an account id is useless to anyone but its owner. Ibanly's website signup still rejects existing emails with 409 without returning an id or key.

How much proof. Identity is not one thing; the level required can depend on what is being bought.

Level Proof Good for
1. Verified email a code sent to the address, or a social or SSO login evaluation, trials, free plans
2. Work identity verified email on a company domain (no free-mail), optionally matching the company the buyer claims to act for paid self-serve plans
3. Verified organisation company SSO, or a DNS record proving control of the domain high-value or contractual purchases

A customer states the minimum level it accepts in its spec; r4a refuses the buying tools below it and tells the assistant what the buyer has to do.

How the customer gets it. r4a, not the customer, authenticates the buyer. Every contract request carries the buyer id in X-R4A-Buyer, covered by the signature that proves the call comes from r4a; POST /accounts also carries the verified email. The customer builds no agent authentication of its own. Levels 2 and 3 are not built: every buyer today is level 1 (Google-verified email).

Next vendors. Ibanly website-agent onboarding is IX-5120. Rose for Websites sign-up and trial: Rose itself as a vendor.

Customers and the vendor contract

A customer is a Rose workspace listed on Rose for Agents, plus a backend. Support comes in levels. Levels 1 and 2 are independent add-ons to level 0.

Level Backend Customer effort The assistant can Status
Reachable not listed none: any Rose customer ask_vendor; the buying tools send the buyer to its website (resume_landing_url or https://<site>) live
0. Listed none Plans & prices and the listing switch (staff today) see offers, get a sign-up link live
1. Declarative trial none + trial trial URL, key and JSON template try_product: r4a calls the customer's existing public API from the template planned
2. Connected payment none + Stripe connect a Stripe account checkout: r4a creates the session on the customer's account planned
3. Full contract http host the contract, verifying r4a's signature try, sign up, pay, get credentials, use, with no human live: Ibanly; Rose for Websites (userose.ai, no payment)
4. Agent-native http + policy state the minimum buyer identity, spend caps, metered use buy on its own within the buyer's limits designed, not built

Levels 1 and 2 remove customer code from the trial and payment steps. Account creation and access stay at level 3: no one else can do them for the customer.

Rose itself as a vendor

The admin API hosts the contract for Rose for Websites (ixadmin_api/routes/r4a.py, IX-5524, plan docs/plans/2026-10-12-ix-5524-rose-r4a-signup-trial.md). The userose.ai listing points at the production admin API (backend=http, base_url https://admin-api-production-jsenzwq6tq-od.a.run.app/r4a, IX-5522). The test r4a service reads the same listing, so a sign-up from it creates a real client in production Supabase.

  • provision_account("Rose", input={website_url, company_name}) runs the wizard's claim for the buyer's verified email (same email-domain gate and domain_taken), with Ask Rose (agent_http) on. One client per buyer and r4a environment (integrations.r4a_accounts); the backoffice user is linked by email at the buyer's first Google sign-in.
  • try_product without an account answers that the trial needs one. With the account_id, it answers 409 with the crawl progress until the onboarding crawl succeeded, then asks the new agent through the public Ask Rose, up to 10 questions, with no key. A question that gets no answer is not counted. Every trial question leaves from the admin API's IP, and Ask Rose allows 10 per minute per IP: past that, buyers are told to retry in a minute.
  • get_access returns the crawl status, trial usage, backoffice and Ask Rose links, and the ask-rose-website install skill. checkout sends the buyer to the backoffice: no plan is sold through agents yet.
  • The "Try it with your agent" button of the /rose-for-agents page (IX-5520) copies the prompt that runs this flow from a coding agent: questions about Rose for Agents first, then connector, sign-in, provision_account, the 10 trial questions (each answered first without Rose, from the agent's own knowledge and the web, then by Ask Rose, side by side) and get_access. The prompt is website/public/rose-for-agents/try-prompt.txt.
  • assess("Rose", {domain}) is the agent sign-up diagnostic (IX-5714, plan docs/plans/2026-10-22-ix-5714-r4a-assess-rose.md, ixadmin_api/r4a_assess.py): a score out of 5 on Ask, Sign up, Get a key, Use, Claim, stopping at the first failure, each failed step with its evidence, lost sale, fix and the code_checks the agent runs in the user's repository. Rose receives only the domain. The live checks read llms.txt, auth.md, the RFC 9728 and RFC 8414 metadata and the resource's unauthenticated 401; they are the admin API's one exception to "never fetch a client-supplied URL": HTTPS on 443, every hop pinned to a public address, no redirect for the one POST, and only on the domain or its auth.md host. 30 per buyer an hour. Stateless: the before/after is two runs. Rose never lists itself in find_alternatives, and no offer sheet compares it. GET|POST /r4a/ answers the 401 challenge, so Rose's own resource passes Use.
  • Signature: checked against r4a's published keys like any vendor, with no secret. R4A_VENDOR_HOST is the admin API's public host, the one the listing's base_url names: r4a signs that host, and Rose refuses a call signed for any other. Unset, every call gets 503. On the test admin API, the write routes are refused against shared Supabase, like onboarding; /r4a/assess and /r4a/, which write nothing, are allowed.

auth.md: any agent, without the connector

https://userose.ai/auth.md redirects (302) to the production admin API's /auth.md (IX-5476, plan docs/plans/2026-10-13-ix-5476-auth-md.md). The admin API serves the file with its own environment's URLs, so the file and the endpoints always agree. It follows the user-claimed flow of auth.md v0.6 (ixadmin_api/routes/agent_auth.py).

  • Two ways to register; anonymous is refused because the trial needs a verified work email. Personal mailboxes are refused either way.
    • service_auth: the agent sends the user's work email, and the person claims (below).
    • identity_assertion: an ID-JAG from an issuer in TRUSTED_ISSUERS (WorkOS's agent issuer, https://auth.workos.bot, for now) registers at once, with no claim page. The buyer is derived from issuer + subject. If the email already has a backoffice account, the person confirms once (401 interaction_required), and later ID-JAGs for that subject act as them; the claim page names the provider first. The token must carry client_id and an auth_time under an hour old (auth.md v0.5+). Adding an issuer is one entry in that dict.
  • Claim.
    • The person opens verification_uri, the backoffice's /agent-claim page.
    • They sign in with Google or the Rose email code. The page is outside AuthGuard, because a new person has no backoffice row yet.
    • They type the agent's 6-digit code, and the signed-in email must equal login_hint.
    • The agent polls /oauth2/token with the claim grant. Tokens are delivered once.
  • Tokens. All tokens are opaque and stored as SHA-256 hashes in integrations.agent_registrations:
    • an identity assertion, valid 24 h;
    • one current access token, valid 1 h, renewed with the jwt-bearer grant;
    • /oauth2/revoke kills either.
  • The access token is the r4a contract's buyer.
    • routes/r4a.py accepts Authorization: Bearer next to r4a's signature.
    • The buyer is the claimer's Supabase user id (or the issuer-derived id), in the admin API's environment (production or test). The email comes from the claim, never the body.
    • Sign-up, trial questions and access then behave exactly as through r4a.
    • A 401 on /r4a/* carries WWW-Authenticate with the protected-resource metadata URL.
  • Limits.

    • Registration: 10 per hour per IP.
    • Codes: 10 minutes, 5 wrong tries per attempt. The agent asks /agent/identity/claim for a new code.
    • Registrations: 24 hours.
    • The claim page names the backoffice of the admin API's environment (app, appstaging or apptest); BACKOFFICE_BASE_URL overrides it locally.
  • Usage. Super Admin → AI agent usage → auth.md (/superadmin/webmcp?channel=authmd) shows three tables, read through staff-only functions:

    • Registrations per path (email claim, trusted issuer, confirmed once) and issuer, from registration to claim, tokens delivered and Rose sign-up.
    • The companies signed up, with their registration path and issuer (get_agent_auth_signups).
    • Refused registrations by failed check, with the issuer and agent app the token claimed, unverified (integrations.agent_auth_refusals). An untrusted issuer that keeps trying is the next candidate for TRUSTED_ISSUERS.
    • Staging's admin API records under test.

Ibanly's /auth.md follows the same v0.6 layout, but its claim is Rose's broker (local opt-in identity federation). An agent with no key and no connector finds it from the API's 401 challenge, llms.txt and the home page. https://ibanly.app/agents/trial-prompt.txt (copied from the home page and /agents) names only the site and works with or without the connector: run it from a Claude Code without MCP to check that discovery, then claim within the trial's 15 minutes.

Test with a plain agent. On an isolated stack (local admin API on a .context Supabase copy, BACKOFFICE_BASE_URL pointing at the local backoffice), run a Claude Code with no MCP servers, given only the local auth.md URL:

claude -p "My work email is <you@domain>… follow http://127.0.0.1:<port>/auth.md exactly…" \
  --strict-mcp-config --mcp-config '{"mcpServers":{}}' --allowedTools 'Bash(curl:*)' 'Bash(sleep:*)' \
  --output-format stream-json --verbose > agent-run.jsonl
  • Take the user_code from the registration's tool result. The first code in the stream is auth.md's example (123456), not the real one.
  • Claim as the person on /agent-claim.
  • The admin API needs IX_ENVIRONMENT=test or production. Any other value makes the onboarding claim fail with "invalid onboarding ingestion command".

Test Ask Rose discovery from a page. To measure whether an agent calls ask.userose.ai without being told (IX-5851), serve the website the way production does. From website/, run pnpm build, then pnpm exec wrangler dev -c dist/server/wrangler.json --port <port>; a plain static server skips the Worker's Markdown answer to agents. Expose it with cloudflared tunnel --protocol http2 --url http://127.0.0.1:<port> (QUIC often fails), then run each question 3 times from an empty directory outside the repository, so the agent loads no CLAUDE.md that already describes Ask Rose:

claude -p "Rose … its website is <tunnel-url> . <question>" \
  --strict-mcp-config --mcp-config '{"mcpServers":{}}' --setting-sources project \
  --allowedTools 'WebFetch' 'Bash(curl:*)' --disallowedTools WebSearch \
  --output-format stream-json --verbose > run.jsonl
  • Count the runs whose tool calls reach ask.userose.ai. Without the empty MCP config and --setting-sources project, the operator's rose connector and settings leak in, and runs mistake the endpoint for the connector.
  • Read the WebFetch results too: a summary without the endpoint is a discovery miss; a summary with it is the agent choosing not to call it.
  • Some runs distrust the tunnel's domain. Rerun against production after the deploy.

What a vendor sells: shared plans & prices

get_offers answers from settings shared across agents, so a price is maintained once:

Field Source Edited in the backoffice
plans knowledge.pricing_plans.plans, one row per workspace Sales Guidelines → Plans & prices
about Brand & tone description (identity.description) Config Studio → Brand & tone
trial, signup_url, backend integrations.r4a_vendor_specs (staff wiring) rose-r4a wire

A plan says once what it is: its name, what an amount is per (unit, e.g. "user per month"), what it includes, and the public page its prices come from (source_url, returned to the assistant). Its prices vary by volume tier (measured in tier_label, e.g. "Emails / month") and billing period (monthly, quarterly, yearly, once); each has an amount_eur (0 means free) and an id, which is what checkout sends as offer_id. A fee charged on top of other plans (e.g. PayFit's monthly base fee by company size) is its own plan, named in the includes of the plans it applies to. Plans & prices sits next to the Pricing tab: Pricing says how agents may talk about prices, Plans & prices says what they are. Neither is chat retrieval knowledge (config.knowledge_faqs) or GEO content (geo.faqs). Other buying questions (terms, security, limits) are answered live by the client's Rose agent through ask_vendor.

Serving. get_offers(brand) resolves the brand name with resolve_brand, as ask_vendor does, then calls private.r4a_vendor_spec(site_name). That function maps the resolved domain to its workspace (sites.config_key) and returns something only when the workspace is listed: suggestions are never served. It runs as the rose_mcp role, which can execute nothing else.

Suggestions. rose-r4a suggest <domain> asks the client's own Rose agent two pricing questions in one conversation and extracts grouped plans with an LLM (the r4a_spec_drafter route: strict structured output on a direct EU deployment). A deterministic guard keeps a price only if:

  • its plan's source_url is a page the agent cited (a citation card or a link in the answer; host and path, ignoring www. and a trailing slash);
  • the answer citing that page writes the amount next to a euro sign or word ("€39", "39 €", "1 200 euros"); a free plan needs the word "free". A number elsewhere in the answer ("10 cards") never backs a price.

A plan left without prices is dropped. A re-run reuses the ids of the published plans and prices it describes again (a plan by id or name, a price by tier and billing period, or as a plan's only price), so a price id checkout sends to the vendor is never renamed; anything new gets an id from its content. It writes suggested_plans only. No published euro price ("contact sales", another currency) suggests nothing. The questions land in the client's conversation history, labelled with the R4A drafter MCP client.

Editing. Sales Guidelines → Plans & prices shows one read-only card per plan: what it includes, its source page, and its prices as a grid (tiers as rows, billing periods as columns). Suggestions sit where they apply: a changed amount shows old → new in its cell, a new price or tier is highlighted, a removed one struck through, each with Apply / Ignore in the plan's card; a new plan appears as a dashed card, and Apply all takes every suggestion. Edit opens one plan in a side panel. Save publishes, like any other setting; there is no separate approval. Save refuses a plan without a name, an http(s) source page or a price, a negative amount, an unknown billing period, and duplicate plan or price ids. Editing never changes an existing price id: it is what checkout sends to the vendor. Removing a plan's last price keeps the plan, so a pending replacement price can still land. Saving keeps exactly the suggestions still pending, so applied and ignored ones do not return, unless a newer suggestion run replaced them meanwhile.

Listing. Config Studio → Rose for Agents turns the listing on or off and shows what AI agents see: the Brand & tone description, the plans & prices, and how buyers sign up (website link, or buyable through Rose with the environments that have a signing secret).

Access (IX-5910). Every backoffice user sees the page. Staff turn on Enabled for this client (integrations.r4a_vendor_specs.client_enabled, r4a_set_client_enabled); the client's users then change everything on it: the listing, WebMCP, the agent link, plans & prices (also the Sales Guidelines tab), trial protection and the ChatGPT resume page. Until then they read it and see "Ask your account manager to enable it". The backoffice RPCs (get_pricing_plans, save_pricing_plans, r4a_get_listing, r4a_set_listed, r4a_get/set_trial_protection) and the client_configs_agent_channels policy enforce this themselves; OAuth connector tokens stay refused. The page is closed to path-rooted workspaces, which brand resolution and the drafter do not address yet.

Staff commands (backend/, poetry run rose-r4a … --env production). As in the other Rose CLIs, --env selects the Supabase credentials. The default, development, fails with "Secret 'SUPABASE_SERVICE_ROLE_KEY' not found":

Command Does
suggest <domain> [--api staging] asks the agent, extracts and verifies, stores suggestions
show <domain> listing, wiring, which signing secrets exist (never their values), plans, pending suggestions
wire <domain> --backend http --base-url … [--signup-url …] [--listed/--not-listed] sets the staff wiring
set-secret <domain> --environment test\|production stores a pre-IX-5534 HMAC secret in Vault; removed once the signing rollout ends

The contract

Paths are relative to the vendor's base_url. r4a reads base_url from the listing, never from the request.

Method and path Request Success Other
POST /trial {input} 200 {output, note} 422 bad input
POST /assess (optional) {input} 200 the vendor's advice on the buyer's case 404: r4a answers "offers no assessment", 422
POST /accounts {email, input?} 201 {account_id, next?} new, 200 {account_id, next?} the buyer's existing account 422
POST /accounts/{id}/checkout {offer_id, payment_token?} 200 {status, offer, amount_eur, invoice} 402 {status, amount_eur}, 403 another buyer's, 404, 422
GET /accounts/{id}/access 200 {api_key, offer, usage} 409 no active plan, 403, 404
POST /accounts/{id}/use {input} 200 {output, usage} 409, 403, 404, 422

input is what the vendor needs to set the account up (Rose: website_url, company_name); provision_account(brand, input?) passes it, and a vendor that needs none ignores it. next, when present, replaces r4a's default next step in the tool result.

r4a maps a 402 to a payment_required tool result, any other non-success to {"error": …} using the vendor's detail, and a transport failure to an error naming the vendor. Accounts live at the customer; r4a only records which vendors each buyer used (buyers.vendors) for their account page. It never returns base_url or the backend to the assistant.

Request signing

The customer's endpoints are reachable from the internet, so they must only accept r4a. Each r4a environment (test, production) signs every contract call with one Ed25519 key held in Cloud KMS (r4a-test / r4a-production in key ring r4a, europe-west9). The key cannot be exported: only that environment's runtime identity (r4a-mcp-<env>) may sign with it, and KMS audit logs record every signature. Each service publishes its public keys at /.well-known/r4a-keys.json (a JWKS):

r4a environment Key directory
production https://mcp.userose.ai/.well-known/r4a-keys.json
test https://mcp-test.userose.ai/.well-known/r4a-keys.json

The kid starts with the environment and the KMS key version (production-1, test-2). No customer issues, stores or rotates a secret (IX-5534, plan docs/plans/2026-10-12-ix-5534-r4a-public-key-signing.md).

The signature is an HTTP Message Signature (RFC 9421), so a customer can use an existing library. Every call carries:

X-R4A-Buyer: <buyer UUID>
Content-Digest: sha-256=:<base64 SHA-256 of the raw body>:
Signature-Input: r4a=("@method" "@authority" "@path" "content-digest" "x-r4a-buyer");created=<unix s>;expires=<created + 300>;keyid="<kid>";alg="ed25519";tag="r4a-contract"
Signature: r4a=:<base64 Ed25519 signature>:
sequenceDiagram participant R as r4a-mcp participant K as Cloud KMS (r4a-<env>) participant V as Vendor endpoint R->>R: check path is a contract endpoint, buyer is a UUID R->>R: url = listed base_url + path; signature base over method, host, path, digest, buyer R->>K: asymmetricSign(signature base) K-->>R: Ed25519 signature R->>V: request + X-R4A-Buyer, Content-Digest, Signature-Input, Signature V->>V: kid in r4a's directory (cached 1 h, refetched once on an unknown kid) alt wrong tag or unknown kid, older than 300 s, bad digest, buyer not a UUID, or base over the vendor's OWN host does not verify V-->>R: 401 else valid V-->>R: handler result, for environment + buyer end

What a vendor checks, in order: tag is r4a-contract; kid is in the directory of the environment its prefix names; created is within 300 s and expires has not passed; Content-Digest matches the raw body; X-R4A-Buyer is a UUID; the Ed25519 signature verifies over a signature base built with its own host as @authority. That last point matters most: one r4a key signs for every vendor, so a valid r4a signature alone only proves the call comes from r4a. A call r4a signed for another vendor carries a valid signature for that vendor's host, and fails here.

What each rule prevents:

Rule Prevents
Private key in KMS, not exportable; cloudkms.signer for r4a-mcp-<env> on its own key only a leaked r4a image, log or backup exposing the key; one environment signing as the other
r4a signs only the contract endpoints, at the listing's base_url, for a buyer UUID from the verified token using r4a as a signing oracle for other paths or hosts
The vendor builds @authority from its own host replaying a call r4a signed for another vendor
Method, path, body digest and buyer are signed; 300 s window replaying a captured request on another endpoint, body or buyer, or later
The kid's directory names the environment; a directory only lists its own environment's kids a test-signed call acting on a production account
No signature → no call; no key → r4a refuses to call (fail closed) a misconfigured side sending or accepting unsigned calls

A vendor that serves both environments treats the environment of the key that verified as the caller's: Ibanly names accounts buyer:<env>:<id> and checks that environment when it opens one. Rotation: create a new KMS key version; instances sign with the newest enabled version from their next start, and vendors fetch it on the first unknown kid. Keep the old version enabled (so listed) for a month, then disable it. A compromised r4a process could sign for any listed vendor, as it could already ask the database signer before.

Rollout. r4a has signed with Ed25519 only since 2026-10-14, and Ibanly no longer accepts the previous HMAC (X-R4A-Timestamp, X-R4A-Signature) since IX-5634. What remains to remove: private.r4a_sign, the secret tables, signer roles, Vault entries and rose-r4a set-secret.

Local development only: R4A_SIGNING_KEY (the hex seed of a published Ed25519 dev key, RFC 8032's first test key, kid test-local) makes r4a sign with it instead of KMS, and lets R4A_VENDOR_URL_<SLUG> point a vendor at a local address. Local vendors trust that key through R4A_KEYS (an inline JWKS) instead of the published directories. No deployed directory lists it, and no deployed service sets either variable.

REST API or SDK?

The REST contract remains the integration boundary. The TypeScript vendor SDK (inboundx/rose-vendor-sdk, public, Apache-2.0) implements signature verification against r4a's published keys, routing for the six operations, mandatory account-authorization callbacks, Handover proof/revocation verification, and agent access (createAgentHandler, IX-5638): /auth.md, /.well-known/*, /agent/* (registration, claim codes, resume), /oauth2/* (token exchange, revocation) and the /claim page. Vendors install a release tag from GitHub (npm builds it on install); it is not on the npm registry yet.

Customer setup: Vendor SDK setup. Public client URL after deployment: https://app.userose.ai/documentation/rose-for-agents/sdk-setup.html.

Vendors implement their product handlers and persistence. The SDK does not create a customer database or perform checkout. With createAgentHandler, it runs the anonymous trial/claim/resume lifecycle on the vendor's accounts (AgentAccounts) and a store that applies each change in one transaction (AgentStore, applyAgentChange). A vendor that uses createHandoverVerifier alone must consume replay identifiers and apply revocation cutoffs in its own claim/revocation transactions. Ibanly installs the SDK as a vendor does, from a release tag (github:inboundx/rose-vendor-sdk#v<version> in backend/apps/r4a_ibanly/package.json; npm builds it on install), and supplies only the product handlers.

What a vendor adds for Rose

Ibanly is built like a standard vendor (Clerk sign-in, dashboard-issued API keys) with everything Rose-specific in one module, src/rose/; src/core-only.ts runs Ibanly without it (IX-5637). The vendor SDK runs agent access itself (createAgentHandler, IX-5638), on the vendor's accounts and a store that applies each change in one transaction. Measured on 2026-10-20:

What Ibanly alone (src/core/) Rose module (src/rose/)
Source 459 lines 277 lines in 4 files: contract handlers 119, SDK configuration 73, store 44, account and key hooks 41
Routes site, /dashboard, /api/* /r4a/* (6 signed operations); with agent access, the SDK's /agent/*, /oauth2/*, /.well-known/*, /auth.md, /claim
Configuration one Durable Object, one rate limiter, Clerk's two public values one more Durable Object, one rate limiter, AGENT_CONFIG, secrets AGENT_PRIVATE_KEY and AGENT_ADMIN_SECRET
Dependencies jose @userose/vendor-sdk

Before the SDK ran agent access, the module was 1,002 lines in 7 files, most of them the auth.md lifecycle and its concurrency handling. A vendor's account object needs only what a standard SaaS has: create an account, issue a key that can expire or allow a limited number of calls, revoke it, record usage. Swapping Clerk for an own email magic link left src/rose/ unchanged.

Trial mode (IX-5638)

A vendor declares how its trial runs with mode in the SDK, published at agent_auth.trial_mode in its /.well-known/oauth-authorization-server. try_product reads it for a vendor with a federation entry (federation/production.json), on every call without an account_id, and falls back to the signed /trial when the metadata is missing, unreachable or names another mode.

mode (backoffice label) try_product
contract (default; Sample result) The signed POST /trial, as in the contract below
auth-provider (Trial key) Rose signs a 5-minute ES256 identity token for the buyer (AssertionBroker.identity_token: the buyer's id as sub, their verified email, the vendor's audience), posts it to the vendor's /oauth2/token with the jwt-bearer grant, and returns trial_started with the trial key and the vendor's mcp_url (or api_url). The assistant reads each tool's input from the vendor's MCP tools/list
catalogue (Rose-run trial) Not available yet: treated as contract (IX-5621 first: per-buyer spend caps)

The vendor lists Rose's broker (issuer and the keys at its /jwks) as an accepted issuer.

Ibanly runs contract in every deployed environment. Setting "trialMode": "auth-provider" in its AGENT_CONFIG (backend/apps/r4a_ibanly/src/rose/index.ts) makes it a reference auth-provider vendor that trusts Rose's broker; the tests set it per test and never in wrangler.jsonc.

How to test it, from the cheapest level:

  1. SDK: npm ci, npm test, node scripts/smoke-package.mjs in rose-vendor-sdk. The exchange, its limits, revocation, and sign-up after a trial.
  2. Ibanly with the real SDK: just check in backend/apps/r4a_ibanly. test/rose/auth-provider.test.ts signs a Rose ID token with the test broker key, exchanges it, uses the trial key on Ibanly's MCP until its limit, then signs up through the signed /r4a contract and revokes.
  3. try_product routing: from backend/, PYTHONPATH=apps/r4a_mcp .venv/bin/python -m pytest apps/r4a_mcp/tests apps/api/admin/tests/test_r4a_trial_mode_route.py. A scripted vendor stands in for Ibanly.
  4. Connector to Ibanly, locally: just e2e-trial-mode in backend/apps/r4a_mcp (no Docker, database or hosted service). It generates disposable keys, starts the Ibanly Worker with trialMode: auth-provider trusting a local broker key, and runs the real connector in-process as a buyer: try_product returns trial_started with Ibanly's mcp_url, the trial key lists and calls validate_iban up to its limit, the same buyer gets the same trial, another buyer gets their own, then provision_account, checkout and get_access keep the account and end the trial key. It is not in just e2e: that run needs Ibanly on the signed /trial for its other cases.

Staff see the published mode, read-only, in Config Studio → Rose for Agents → Agent access → Trial mode. The backoffice calls the Admin API (GET /admin/r4a/trial-mode), which asks the connector (GET /identity/trial-mode?domain=), which reads the vendor's metadata. A vendor that publishes no mode, is not connected, or cannot be reached shows "Not set": Rose trials a connected vendor through the contract. Only a failed Admin API request shows an error with a retry.

In Config Studio → Rose for Agents → Agent access → Proof of work, select Off (default), Low, Medium or High, then save. This is a per-workspace staff setting; saving it does not publish the workspace's listing. It applies only to new anonymous trial registrations at vendors using a compatible SDK with trialProtection: "rose". Older SDKs do not enforce it. Existing accounts, claims and service-auth registrations retain their existing authorization requirements.

Low, Medium and High request total HashWX difficulty of 250,000, 1,000,000 and 3,000,000 respectively, split over four puzzles. These are computational budgets, not promised durations. An agent solves the challenge itself; no human interaction is required. This adds cost to trial farming but does not establish a unique user: a fresh valid solution can still obtain another trial. Keep product usage caps and registration rate limits.

The SDK reads GET /identity/trial-policy/{vendor} from its configured broker. The broker resolves the exact configured vendor domain to its workspace, and returns level, audience and environment. The SDK checks both audience and environment; unavailable, malformed or redirected policy responses fail closed with HTTP 503. The broker coalesces requests, caps database connections at two per instance and caches results for two seconds, including failures; saved presets take effect within that window, and failed refreshes never reuse expired policy. An unknown configured workspace returns 503; an existing workspace without a saved policy defaults Off. Each lookup has a four-second overall deadline, including vendor-lock and connection-slot waits; cancellation aborts open connections instead of adding a graceful-close delay. The private SQL helper is executable only by the broker's rose_mcp role. The backoffice getter and setter require a staff browser session.

Without a proof, POST /agent/identity with type: "anonymous" returns HTTP 428 and error: "proof_of_work_required". The proof_of_work object contains a signed token, format: 2, four HashWX challenges and an expiration timestamp in milliseconds. The token is a purpose-specific ES256 JWT scoped to the vendor origin, environment and current preset, valid for five minutes. The vendor's auth.md explains the retry and links /agent/proof-of-work/solver, a Node 22+ solver using pinned capjs-core@0.1.5. Retry registration with:

{"type":"anonymous","proof_of_work":{"token":"<challenge token>","solutions":[{"nonce":"<decimal>"},{"nonce":"<decimal>"},{"nonce":"<decimal>"},{"nonce":"<decimal>"}]}}

Verification consumes the proof atomically before creating an account. Reuse, including simultaneous retries, returns HTTP 409. Invalid, expired or superseded proofs receive a fresh challenge. Changing the preset invalidates outstanding proofs from the previous preset. If account creation fails after consumption, the agent must request and solve another challenge.

A verified Rose ID is exempt: the connector signs the registration body, including a fresh registration_nonce, using the existing HTTP Message Signature contract. The vendor verifies Rose's key, signed destination, buyer and environment, and atomically consumes the nonce. An unsigned rose_id field or buyer header cannot grant this exemption. Registration does not itself complete the account claim.

Rollout order: apply the additive policy migration and deploy the broker endpoint before enabling trialProtection: "rose" at a vendor; then select a preset in the backoffice. SDK v0.4.0 and later implement this contract; Ibanly and the development submodule are pinned to v0.5.0. Fixed SDK presets ("off", "low", "medium", "high") support isolated verification or vendors that manage their own policy. Keep test fixtures explicitly Off when they do not run a policy broker.

Change the vendor SDK

The vendor-sdk/ Git submodule records the SDK source revision associated with this server branch. Initialize it from the repository root:

git submodule update --init --recursive vendor-sdk

Read vendor-sdk/AGENTS.md before editing. The submodule starts at the recorded commit in detached HEAD state; create a feature branch there before making new SDK changes, or use a separate SDK workspace. Commit and push SDK changes to its own repository first, then check out that commit in vendor-sdk/ and commit the updated gitlink in the server branch. A server PR must reference a remotely fetchable SDK commit and link its SDK diff. After switching server branches or pulling, rerun the update command to select the matching SDK source; commit or stash SDK edits first. Avoid git submodule update --remote, which selects the upstream branch instead of the server's recorded revision.

Ibanly still installs the released SDK tag in its package manifest. The submodule neither changes that dependency nor publishes a release. To exercise the pinned source locally:

cd vendor-sdk
npm ci
npm test
node scripts/smoke-package.mjs

The smoke run packs the SDK, installs it in a temporary consumer app, checks exported TypeScript declarations and exercises signed account operations and Handover over HTTP. Its sample database demonstrates claim continuity, credential rotation, replay rejection and a persisted revocation cutoff; it is local evidence, not a customer rollout.

  • SDK only (a fix, a new option): change and verify the SDK, update the server's submodule pin, then review and merge the SDK changes in its repository. Release only when authorized, following its AGENTS.md. Point Ibanly at the new tag (npm install github:inboundx/rose-vendor-sdk#v<version> in backend/apps/r4a_ibanly), run just check there and update the server PR, including its submodule pin to the final SDK release commit (which may differ after a squash or rebase). Nothing bumps it automatically: Dependabot does not cover Ibanly.
  • Contract change (r4a, the SDK and Ibanly): change r4a so it accepts old and new behavior, then change and verify the SDK. From backend/apps/r4a_ibanly, try the submodule with npm install --no-save ../../../vendor-sdk; never commit a local dependency path. Run just check in Ibanly and just e2e in backend/apps/r4a_mcp. A dependency-install recipe can restore the released package, so verify the installed SDK with npm ls @userose/vendor-sdk before testing the feature: it must point to ../../../vendor-sdk. An invalid label and nonzero exit are expected for this local override because the manifest still requests the release tag. Release only after explicit authorization, then replace the local install with the new tag and update the submodule pin to that release commit.
  • The client guide follows the SDK: Vendor SDK setup uses the README's snippets and names the archive version; update it with each release.
  • Link the SDK PR to the ticket too. Linear closes a ticket when its linked server PR merges, so a ticket linked only to the server PR reads Done while the SDK PR is still open.

Run it locally

One-time setup: cd backend/apps/r4a_ibanly && just install, then start the local buyer project with cd backend/apps/r4a_buyers/supabase && just dev (its own Supabase stack on 554xx ports, next to the main one).

cd backend/apps/r4a_mcp && just e2e-auth          # real OAuth tokens, two buyers, Ibanly: ownership end to end
cd backend/apps/r4a_mcp && just e2e               # every tool over stdio, as a fresh dev buyer
cd backend/apps/r4a_buyers && just dev            # the buyer site on :8790 (email link locally, Mailpit :55424)
cd backend/apps/r4a_mcp && just up                # Ibanly :8788 + r4a-mcp :8766, signing in on the local stack
claude mcp add --transport http r4a http://127.0.0.1:8766/mcp

Expected: just e2e-auth prints a ✔ per check and ends with OK; any ✖ exits non-zero. An unsigned curl to :8788/r4a/trial and a tokenless curl to :8766/mcp return 401, and http://127.0.0.1:8788/ is the demo site. Both e2e recipes take the Ibanly port as an argument when :8788 is taken.

Every tool needs ROSE_MCP_DB_DSN (brand lookup and listed vendors; the recipes read it from Secret Manager). ask_vendor also needs IX_API_KEY (and ROSE_API_BASE to target staging). The recipes sign Ibanly calls with the published dev key, which the local Worker trusts (R4A_KEYS in its .dev.vars), so they need no KMS access.

Tests:

cd backend && poetry run pytest apps/r4a_mcp/tests -q     # the connector and token checks, against a scripted vendor
cd backend/apps/r4a_ibanly && just check                  # the vendor, inside the Workers runtime

The vendor is a separate codebase on another stack (a TypeScript Cloudflare Worker) that shares nothing with r4a. That a second stack implements the contract from the REST description alone is the evidence behind REST API or SDK?. Three things keep the two sides from drifting:

  • a shared signature test vector (dev key, kid test-vector, one request, its signature base and signature), asserted byte for byte in apps/r4a_mcp/tests/test_commerce.py, apps/r4a_ibanly/test/rose/contract.test.ts, apps/api/admin/tests/test_r4a_route.py and the SDK repository's test/vendor.test.mjs;
  • the connector's tests pin what r4a sends and how it maps each vendor status;
  • just e2e runs the real connector against the real Worker.

Point the connector at a local vendor with R4A_VENDOR_URL_IBANLY and R4A_SIGNING_KEY. Deployed Ibanly (https://ibanly.app/r4a) is reached with the environment's KMS key.

Rose as a vendor, on an isolated database

Rose's own contract (the admin API, routes/r4a.py) needs a listed userose.ai workspace, which only exists on a database you may write to. Never wire it on shared Supabase. just signup-loop in backend/apps/r4a_mcp runs the whole agent sign-up loop on your branch's code and ends by deleting the client it created (IX-5572, signup_loop.py):

just target isolated                                  # once, from the repository root: preview branch (pushes the branch name)
cd backend/apps/r4a_mcp && just signup-loop           # each iteration, about 10 minutes
cd backend/apps/r4a_mcp && just signup-loop --agent   # also lets `claude -p` run the published try-it prompt first
cd backend/apps/r4a_mcp && just signup-loop --agent codex   # the same with `codex exec`
cd backend/apps/r4a_mcp && just signup-loop-down      # when done: purge, unwire, delete the preview branch

Each run:

  1. Seeds Rose's listing on the branch (base_url = http://127.0.0.1:8080/r4a); re-seeding is a no-op.
  2. Starts just dev-knowledge-stack test with Rose's vendor settings (R4A_KEYS = the dev-key JWKS, R4A_VENDOR_HOST=127.0.0.1:8080, ASK_ROSE_BASE_URL=http://127.0.0.1:8789) and the Ask Rose Worker in front of its search API. Ports 8080–8082, 3002 and 8789 must be free.
  3. Drives the connector over stdio (just run, dev key) as a new dev buyer on the test site (niak.net, 18 pages; --site for another one that is not a client on the branch): get_offers, sign-up with {website_url, company_name}, "still reading" right away, the crawl (about 5 minutes), 10 answers, the 11th refused, get_access, sign-up again returns the same account, Ask Rose answers. Other dev buyers check that a personal mailbox and an email on another domain are refused, and that another buyer gets 403 on the account.
  4. Deletes the client from the branch, then stops the stack.

With --agent, step 3 starts with a headless claude -p (--agent codex: codex exec, Rose tools pre-approved, never checkout) that runs website/public/rose-for-agents/try-prompt.txt as published, in a throwaway project whose README.md names the test site, with every mcp__rose__* tool except checkout. A headless run cannot answer the prompt's questions, so a short preamble answers them (ready, this is the website repository, all 10 trial questions approved); the prompt text is unchanged. The run passes when the agent provisions the account and gets all 10 answers; the loop then checks the rest from that account. Read agent.jsonl to judge the answers: they must come from the site's own content, and the agent must stop at the quota. Three such runs are the done-when of IX-5521.

It prints ✔/✖ per check and exits non-zero on any failure. Calls, logs and the agent's transcript are under .context/signup-loop/<time>/.

Production, on request only. just signup-loop production runs the same checks, minus the dev-buyer ones, through mcp.userose.ai as the buyer signed in to Claude Code's rose server (renew with /mcp → rose → Authenticate). It creates a real production client and deletes it with rose-tenant delete-client once the dry-run lists only the test site. It tests the deployed service, not the branch: run it after a release that touches r4a or the admin API's /r4a routes. A @userose.ai buyer skips the email-domain gate, which only the local run checks.

Local opt-in identity federation and claimable access

The Ibanly POC adds a separate Rose assertion broker at /identity, browser consent at the buyer site's /agent/authorize, and an independent vendor adapter. It is disabled unless explicitly configured. Assistant OAuth still verifies aud = r4a-mcp; browser sessions and vendor ID-JAGs cannot substitute for assistant tokens. Supabase authenticates the person; the broker uses its own ES256 key and rechecks the live user and signed authentication method/time before issuing a five-minute vendor-targeted assertion.

Hosted (IX-5617), production only. r4a-mcp-production serves the broker at https://mcp.userose.ai/identity from the committed backend/apps/r4a_mcp/federation/production.json (public values only: issuer, buyer project, origin, vendor URLs). It signs with the Cloud KMS P-256 key r4a-identity-production (deploy.sh setup production creates it; deploy.sh passes its name as R4A_IDENTITY_KMS_KEY) and publishes every enabled version at /identity/.well-known/jwks.json (kid identity-production-<n>). Without KMS the JWKS and every signing route answer 503. The consent page on id.userose.ai/agent/authorize uses this broker; the test service has none, since one buyer project and one buyer origin serve both environments. A new vendor needs an entry in that file and its claim origin in the site's CSP connect-src (r4a_buyers/site/_headers). Vendors pin the public keys: a key rotation needs each vendor to update its trust config.

Ibanly publishes /auth.md, OAuth discovery and its public JWKS. Its top-level identity requests support anonymous, service_auth with a required email login_hint, and identity_assertion with assertion type urn:ietf:params:oauth:token-type:id-jag. The vendor signs the registration assertion, exchanged using the form-encoded grant urn:ietf:params:oauth:grant-type:jwt-bearer. The returned opaque token is Ibanly's API key. Pre-created claim ceremonies, /agent/identity/claim/complete with a registration/code-bound Rose assertion, and key delivery are advertised POC extensions to the pinned auth.md reference; a prose file alone supplies neither trust nor account provisioning.

Anonymous access lasts 15 minutes and allows three validations, with no checkout or private data. service_auth withholds access until claim. The agent keeps its secret claim_token; the person receives only the vendor claim URL and six-digit code, signs in and explicitly authorizes identity sharing and free demo access. Claim rotates the trial key while preserving the account ID and usage. Polling the claim grant urn:workos:agent-auth:grant-type:claim every five seconds resumes with a replacement key; the original trial key/assertion cannot upgrade. Resume expires 15 minutes after claim, and claimed credentials expire within one hour. Fresh browser consent with retained original registration/code reauthorizes the same account. There is one active delegation per account.

A per-buyer Durable Object maps owners to resources across claim. Existing signed contract calls use that mapping; signed GET /r4a/account looks up the buyer's account without credentials. Configured get_access(vendor) first reuses owned access, then creates an allowlisted anonymous registration only when no account exists. Conflicts fail explicitly instead of merging by email. Vendor trust pins the broker issuer, public keys, client, environment and source allowlist. Signed revocation events and the buyer's Revoke button invalidate access. Signed broker revocation persists an authentication-time cutoff at the vendor, including when no account exists yet: an unused assertion or a newly issued assertion backed by older authentication cannot restore access. Reauthorization after a signed event requires authentication strictly newer than that cutoff. Direct opaque-key revocation retains the existing fresh-consent renewal path. Ibanly's own sign-up is Clerk's (verified email), so it offers no unverified way around trial restrictions.

For the local stack, install the vendor and start the isolated buyer Supabase project as above, then generate disposable keys/configuration from backend/:

poetry run python apps/r4a_mcp/federation_dev.py

The helper prints separate vendor, broker and buyer-site commands. Files stay under .context/ix-5477/local/ with private permissions; no application .env is edited. Regenerating configuration rotates both local signing keys and invalidates outstanding assertions. The broker also needs ROSE_MCP_DB_DSN for the existing read-only brand lookup, as described above. With all three services running, exercise actual HTTP exchanges:

poetry run python apps/r4a_mcp/federation_e2e.py

This script only accepts the isolated buyer stack on 127.0.0.1:55421, creates verified email/password fixtures, deletes those users afterwards, and records redacted evidence under .context/ix-5477/. It proves exchange, cross-session claim, preserved usage, credential rotation, replay rejection, existing-account conflict and revocation. It does not prove a real Google or company SSO connection. Google policy checks use signed unit fixtures; real Google credentials, a pilot enterprise source and hosted issuer/key custody remain implementation gates.

Turn on the hosted broker

Once per environment, in this order. Step 1 is an owner action: CI's deployer cannot create keys or change permissions, so the release never does it.

  1. just setup production in backend/apps/r4a_mcp/ (runs deploy.sh setup): creates the KMS key r4a-identity-production and lets r4a-mcp-production sign with it. Check it with gcloud kms keys get-iam-policy r4a-identity-production --keyring=r4a --location=europe-west9.
  2. Prove KMS signing with that identity: level 3 below, with r4a-mcp-production and r4a-identity-production / identity-production (the grants already exist, skip them).
  3. Each vendor trusts the broker: its issuer https://mcp.userose.ai/identity and its public keys, the JWKS line step 2's job prints (once released, also /identity/.well-known/jwks.json) (Ibanly: AGENT_CONFIG in wrangler.jsonc, plus its AGENT_PRIVATE_KEY and AGENT_ADMIN_SECRET Worker secrets, set once by hand).
  4. Release (merge to main): r4a-mcp with the broker, Ibanly, then the buyer site.
  5. Level 4 below, in production.

Set up a vendor for Handover

What Rose staff do with a client (vendor) that wants claimable accounts, once the broker is on. Ibanly is the worked example (backend/apps/r4a_ibanly). The client-facing summary is Bring Handover to your product. A vendor whose product is an MCP follows the same steps plus MCP vendors below.

On the vendor's side (they do it; we pair with them). Their coding agent can do steps 1 to 3 from the prompt https://userose.ai/rose-for-agents/handover-prompt.txt (website/public/rose-for-agents/handover-prompt.txt), which reads the public SDK guide:

  1. Serve the agent-auth contract with the vendor SDK (createAgentHandler from inboundx/rose-vendor-sdk, see r4a_ibanly/src/rose/index.ts): /auth.md, /agent/identity, /agent/identity/claim/complete, /agent/events, /oauth2/token, /.well-known/*, /claim, with CORS for https://id.userose.ai. It maps a claim onto their own account and API keys.
  2. Generate their own P-256 signing key. The private key stays a secret on their side (Ibanly: the AGENT_PRIVATE_KEY Worker secret, plus AGENT_ADMIN_SECRET for its read-only console); its public JWK goes in their config as vendorJwks.
  3. Trust Rose's broker, all public values (Ibanly: AGENT_CONFIG in wrangler.jsonc): issuer (their origin), environment: "production", brokerIssuer: "https://mcp.userose.ai/identity", brokerClient: "rose-id", brokerJwks (the JWKS that federation_kms_check.py prints, or https://mcp.userose.ai/identity/.well-known/jwks.json), sources: ["google"], consentUrl: "https://id.userose.ai/agent/authorize", browserOrigin: "https://id.userose.ai", vendor (their id in the broker config) and trialLimit.

On Rose's side (one PR):

  1. Add the vendor to backend/apps/r4a_mcp/federation/production.json: name; domain equal to its R4A listing's domain (get_access matches on it); audience = their issuer; identity_url, claim_url and events_url under it; sources; environment: "production"; mcp_url when the product is an MCP server; auto_claim: false when the vendor wants a person to confirm every new account. Then provision_account starts the claimable trial (the same answer as get_access without an account) instead of creating the account: nothing exists in the buyer's name until the person claims it. By default (auto_claim: true), a signed-in buyer's account is created with no browser step.
  2. Add their origin to connect-src in backend/apps/r4a_buyers/site/_headers: the consent page posts the claim to it from the browser.
  3. The vendor must already be a listed http vendor (integrations.r4a_vendor_specs).
  4. Release, then run the journey with federation_hosted_e2e.py https://<vendor> (level 5 below; swap the IBAN call for one of the vendor's product calls).
MCP vendors: claimable MCP accounts

An agent uses the vendor's MCP before anyone signs in, and keeps the account once the person claims it (IX-5660; Ibanly's MCP at https://ibanly.app/mcp is the worked example):

  1. get_access(<vendor>) registers an anonymous trial at the vendor and, because the vendor's federation/production.json entry has mcp_url, exchanges its assertion itself: it returns status: "trial_started", mcp_url, a trial access_token (short-lived, a few calls) and the claim block. The agent calls the MCP directly with Authorization: Bearer (connected as an MCP server, or one JSON-RPC message per POST): trial calls never go through r4a.
  2. The person opens the claim link and signs in with Rose ID; the agent polls token_url with the claim grant and gets a replacement token for the same account and usage. The trial token stops.
  3. Revoke (the person's Revoke on Rose ID, or /oauth2/revoke) stops the token at once.
  4. Afterwards the person can connect the MCP with the vendor's normal OAuth sign-in and reach the same account: the vendor links the email the claim verified (AgentAccounts.claimed) to its own sign-in.

On the vendor's side, the SDK (v0.3.0+) needs: issueKey returning a token the MCP accepts, product.mcpUrl, and authorizationServer (its own OAuth metadata and token handler) when its OAuth server shares the auth.md origin. Ibanly's own pieces are core, not Rose code: src/core/mcp.ts (Streamable HTTP, stateless, tool validate_iban, protected-resource metadata at /.well-known/oauth-protected-resource/mcp) and src/core/oauth.ts (authorization code + PKCE, stateless client registration, Clerk sign-in page, access token = an API key named after the client). Sign-in reaches a claimed account only when Ibanly's Clerk session token carries the email claim (Clerk: Sessions, customize session token, {"email": "{{user.primary_email_address}}"}).

A broker key rotation changes brokerJwks. The broker signs with the newest enabled KMS version as soon as an instance restarts, so every vendor must trust the new public key before that version is created (or while it is disabled); remove the old key from their config only after it is disabled.

Vendors also ask Rose itself, through ask_vendor("Rose", …), which answers from the userose.ai knowledge base, not from these docs. When the vendor-facing guide changes (client/rose-for-agents.md and its subpages), update its "Rose for Agents" FAQs to match (rose-config faq list|update|add --site userose.ai, a production write the agent syncs within 10 minutes), then check the answer to a setup question with ask_vendor("Rose", …).

Test the Handover claim end to end

Run all five levels for a change to the broker (federation.py, signing.py), the consent page (r4a_buyers/site/agent/), a vendor's claim, a vendor's MCP or OAuth sign-in, or get_access for a Handover vendor. Each one catches what the previous cannot. Levels 1 to 3 run before merging, on branch code, with nothing deployed.

  1. Protocol, local. Start the buyer stack (just dev in r4a_buyers/supabase/), run federation_dev.py, then start the three services it prints: Ibanly with r4a_ibanly/node_modules/.bin/wrangler dev --config <out>/wrangler.json --persist-to <out>/state, the buyer site with just dev in r4a_buyers/, and the broker with the buyer env (eval "$(just env)" in r4a_buyers/supabase/), R4A_FEDERATION_CONFIG=<out>/broker.json and PYTHONPATH=apps/r4a_mcp .venv/bin/python -m uvicorn r4a_mcp.asgi:app --port 8766 from backend/ (no ROSE_MCP_DB_DSN needed). Then federation_e2e.py: HTTP only, no browser.
  2. Journey with the real consent page, local. federation_hosted_e2e.py http://127.0.0.1:8788 plays the agent and prints a claim link and code. Open the link in a browser (claude-in-chrome), sign in with the email link: type the address (a filled value is not submitted), fetch the newest link from Mailpit (curl :55424/api/v1/messages, then /api/v1/message/<ID>), open it in the same browser, and wait a minute between two sign-in emails. Type the code, allow, then click Revoke. The script checks resume, preserved usage, the old key's 401 and revocation.
  3. The agent journey, local. A real Claude Code, connected only to the local r4a, goes from get_access to revocation through every component, with the person's steps in a browser: The agent journey, local below. This level found a get_access that never named the vendor's MCP (IX-5660).
  4. KMS signing on Cloud Run, test. Only Cloud Run's identity can sign, so run federation_kms_check.py as a one-off job with the branch image. Grant r4a-mcp-test@inboundx.iam.gserviceaccount.com cloudkms.signer, cloudkms.viewer and cloudkms.publicKeyViewer on r4a-identity-test, build and push the image under a throwaway tag (r4a-mcp/r4a-mcp-test:<ticket>-check, never latest), then:

    gcloud run jobs create r4a-identity-<ticket>-check --region=europe-west9 \
        --image=europe-west9-docker.pkg.dev/inboundx/r4a-mcp/r4a-mcp-test:<ticket>-check \
        --service-account=r4a-mcp-test@inboundx.iam.gserviceaccount.com --max-retries=0 \
        --command=python --args=/app/apps/r4a_mcp/federation_kms_check.py,projects/inboundx/locations/europe-west9/keyRings/r4a/cryptoKeys/r4a-identity-test,identity-test
    gcloud run jobs execute r4a-identity-<ticket>-check --region=europe-west9 --wait
    

    Read the ✔ lines in its logs (gcloud logging read on the job's execution, textPayload); the JWKS line is parsed into jsonPayload (filter jsonPayload.keys:*). A run with a key the identity cannot use must stop at ✖ JWKS served from KMS (HTTP 503). Delete the job and the image tag, and remove the three grants.

  5. Production, with a person. After a release, federation_hosted_e2e.py (defaults to ibanly.app). A non-staff person signs in with Google on id.userose.ai themselves; an agent never enters their password or grants OAuth consent. Attach the redacted evidence from .context/handover-e2e/ to the ticket.

The agent journey, local

Level 3 above, step by step (claude-in-chrome plays the person's browser).

  1. Keep the level 1 stack running, but restart Ibanly with a new --persist-to directory, so the buyer has no account there yet (a claim onto an owned account is a conflict, by design).
  2. Give the agent a buyer: from backend/, with the buyer env, PYTHONPATH=apps/r4a_mcp .venv/bin/python apps/r4a_mcp/federation_agent_config.py <email> .context/<ticket>/r4a-mcp.json. Use the email of your Clerk user on the local Ibanly, so step 6 can check its sign-in.
  3. From an empty directory outside the repository (no AGENTS.md steering the agent):

    claude -p "I need to check IBANs and have no Ibanly account. Ask Rose (r4a-local) for access \
    to Ibanly with get_access, then validate GB82 WEST 1234 5698 7654 32 through Ibanly's MCP \
    server. If I need to do something to keep the account, tell me exactly what." \
      --strict-mcp-config --mcp-config <repo>/.context/<ticket>/r4a-mcp.json \
      --allowedTools "mcp__r4a-local" "Bash(curl:*)" --output-format json > run.json
    

    Note session_id. Read the tool calls in its transcript (~/.claude/projects/<dir>/<session_id>.jsonl), not only its answer: get_access returns trial_started with mcp_url, then tools/call validate_iban on the MCP reports usage 1.

    federation_dev.py sets auto_claim: false for the local Ibanly, so also run the journey with an agent only asked to sign up (IX-5690): "Sign me up for Ibanly through Rose (r4a-local), then validate … through Ibanly's MCP server. …". Its provision_account must return trial_started (no POST /r4a/accounts), and steps 4 and 5 resume it on the same account.

  4. Open the claim link it reports, sign in with the email link (as in level 2), type its code and allow. Leave this tab open: its Revoke button exists only right after the claim.

  5. claude -p --resume <session_id> "Done: I claimed it. Switch to the permanent token, check the trial token no longer works, then validate another IBAN through the MCP." (same flags). Expect: the claim grant, the trial token 401 invalid_token, usage 2 on the same account.
  6. In another tab, the vendor's own sign-in reaches the claimed account: Ibanly's /dashboard shows its account id and usage 2; then connect an MCP client with OAuth (claude mcp add --transport http ibanly-local http://127.0.0.1:8788/mcp, /mcp, Authenticate, Allow on Ibanly's consent page) and validate: usage 3. Needs the email claim in Ibanly's Clerk session token.
  7. Click Revoke in the claim tab, resume the agent once more: its token answers 401. Write the redacted evidence to .context/<ticket>/.

Stop every local service you started and just stop the buyer stack when done.

A second local proof uses an actual Keycloak realm and the installed Supabase Auth v2.197.0 runtime. Stop the separately started broker/vendor first; leave the isolated buyer Supabase project running. From backend/:

poetry run python apps/r4a_mcp/federation_oidc_e2e.py

Docker and the existing vendor dependencies are prerequisites. This script refuses busy ports 8800, 55425, 8766 and 8788, generates disposable credentials, starts its own reference IdP, Auth sidecar, broker and vendor, and removes its users and sidecars afterwards. The Auth sidecar shares only the local buyer database; the original Auth container is unchanged. An experimental provider linking-domain setting prevents automatic same-email merging for this exercise; it is not a hosted configuration recipe. Only the explicit Supabase manual-link flow, authenticated with both credentials, retains the original buyer UUID and vendor account. The proof records retained usage, key rotation, last-identity protection, unlink and rejection of cached assertions in .context/ix-5477/second-source.md.

The broker opts into oauth_sources.keycloak with a pinned issuer, jwks_url, audience and client_id. This mapping must match the operator-configured Keycloak realm in that buyer project. Authorize requests carry provider_proof: {"source": "keycloak", "access_token": "<upstream token>"} alongside the Supabase browser Bearer token and consent. The broker verifies both signatures, the upstream client/issuer/audience, verified email and original auth_time, then binds the upstream subject to the live server-owned identity (id is the provider subject; identity_id is the connection UUID). It uses the older of the two authentication times. It never infers a provider from editable metadata or sign-in timestamps. A linked OAuth session without its independently verified proof is refused. The vendor still receives a Rose-signed assertion with the canonical buyer UUID; its source allowlist must separately accept keycloak. Upstream credentials stay out of vendor assertions. This HTTP-driver proof rewrites Docker’s front-channel host alias and applies loopback cookie handling; it does not verify browser DNS or the consent UI for Keycloak. The current buyer UI does not offer a Keycloak chooser/link/unlink flow.

With identity_management_url configured for the same buyer project’s Auth API, POST /identity/unlink accepts {"provider": "keycloak", "consent": true} from the exact buyer origin and a fresh authenticated browser session (plus provider proof when needed). It refuses removal of the last identity, sends signed revocation to every configured vendor before deleting the connection, and retains the connection if a vendor fails. A repeated removal is safe. Only this broker-managed removal performs that coordination; direct Supabase identity deletion and upstream account-deletion events are not wired. Both source verification and unlink remain disabled without their opt-in configuration.

The local Ibanly /agents page exposes a sample trial when discovery is enabled. Its /backoffice/ is an operator-secret-protected, read-only lookup by account ID, showing trial/claimed/expired/revoked, expiry and identity source. It keeps no browser credentials in persistent storage and provides no provider-management UI or global account listing. Account expiry is current state, not a retained audit history; an unclaimed registration's account stays, with an expired trial key. Minimal funnel/lifecycle events are local log lines; no hosted metrics, secrets, auth settings or deployment are provisioned by this POC.

Use the deployed connectors

Both deployed services (r4a-mcp-production, r4a-mcp-test) are public at the Cloud Run level and answer only signed-in buyers. Add https://mcp.userose.ai/mcp (test: https://mcp-test.userose.ai/mcp) in Claude under Customize → Connectors → Add → Add custom connector, and Claude runs the sign-in on id.userose.ai. Each public name is a pass-through Cloudflare Worker in backend/apps/r4a_mcp/edge/. A service needs the hosted r4a-buyers project, its secrets and the site (backend/apps/r4a_buyers/README.md); without them it does not start.

Buyer-facing setup instructions live in two places: Ibanly's backend/apps/r4a_ibanly/public/agents/install-prompt.txt and the website's website/public/connect-your-agent/instructions.md (linked from /connect-your-agent). Buyers register by adding the connector and signing in; there is no signup form or waitlist. When the connector URL, the sign-in or the recommended permissions change, update both.

The test service sends ask_vendor to the non-production search API (apitest.userose.ai). A client with workspace routing (explicit_routing_enabled) has one corpus, and it is served only in the environment of its last ingestion (sites.corpus_environment). In any other environment, chat fails with workspace corpus is not ready in this environment, and ask_vendor gets a generic answer with no facts. Ibanly's corpus is in production, so its buyers use the production connector, mcp.userose.ai.

MVP test by the Rose team: every path

Before launch, the Rose team tests the connector the way a buyer and a vendor meet it. Two surfaces stay out of search until then: /connect-your-agent (buyers) and /rose-for-agents (vendors) on the website, linked from the footer but noindex. The client guide client/rose-for-agents.md and its subpages are published at app.userose.ai/documentation. Connect with the prompt on /connect-your-agent, then run one prompt per path.

In every row the buyer is the same: a Rose team member with the connector installed and signed in (no tool answers otherwise). What changes is the vendor the buyer asks about, and how far that vendor is set up on Rose. A Rose customer is a company whose website runs a Rose agent. Every path must end with a next step the assistant can give the buyer; a path that stops at an error with no next step is a bug.

Vendor the buyer asks about Vendor level Ask your assistant Expected answer
Ibanly: Rose customer, connected, asked by name 3. Full contract path 1: the prompt from ibanly.app offers, trial, account, free plan, key, metered use
Ibanly, found while the assistant considers a competitor (IBANAPI) 3. Full contract path 2: "Find an alternative to IBANAPI" with the buyer's constraints, never naming Ibanly find_alternatives lists Ibanly (competitor_list, agent_buyable: true); the assistant asks it, checks offers and a trial, says it comes from Rose's network, and warns that Ibanly is a demo
Ibanly, found through a competitor, then bought 3. Full contract path 2's test variant: "a demo vendor is fine", free plan discovery as above, then account, Free plan, key and a live validation, the demo mentioned once
Ibanly, found without being asked (IBANAPI hits its quota) 3. Full contract path 3: a plain fix task, with the CLAUDE.md line the assistant calls find_alternatives on its own and reports Ibanly
Rose: Rose customer, connected 3. Full contract "Sign my company up for Rose with our website, try it, then give me the install links." get_offers: can_provision: true, no plans and a note to ask the agent for prices; provision_account with website_url and company_name: an account_id (a real client); try_product: "still reading" during the crawl, then answers from the site, 10 questions; get_access: backoffice, Ask Rose and install links
Skello: Rose customer, not listed Reachable "Sign me up for Skello." human_signup_required with Skello's website and a note to use ask_vendor
Salesforce: not a Rose customer (no Rose agent) none "What does Salesforce cost? Sign me up." every tool: "No Rose agent for 'Salesforce'", with own research or find_alternatives as the next step
A name Rose is not sure about any "Sign me up for Iban." needs_confirmation with the candidates (here Ibanly); the assistant asks which one

Every human_signup_required answer has the same shape (brand, domain, status, can_provision, signup_url, note). just e2e checks one representative vendor per path, not the ones in the table: PayFit and Mooncard for a Rose customer that is not listed, a made-up name for no Rose agent. Its Ibanly journey buys the paid usage offer with a test token; just e2e-auth checks out the Free plan with real buyer tokens, and the unsure name is covered by the unit tests only. Paths 2 and 3 also test what the assistant decides, which no automated case covers: run them by hand. Report a failure on the R4A project with the transcript.

Test it as a buyer

This runs the Ibanly journey the way a buyer meets it: a Claude Code with no MCP servers, the production connector, and the prompts published on ibanly.app. It tests the deployed service, not your branch. For your branch, use Run it locally. Production gets main only through a release, so a connector change reaches this test after that release.

Claude Code without MCP. A buyer has no MCP servers. Keep your own Claude Code and login, but turn off claude.ai connectors (one of them is named "claude.ai Rose") and your user MCP servers for a dedicated project:

export ENABLE_CLAUDEAI_MCP_SERVERS=false
mkdir -p ~/ibanly-buyer
cd ~/ibanly-buyer
claude mcp list        # your user servers, if any
claude                 # once: /mcp → each server listed → Disable (only for this project)
claude mcp list        # expected: every server "⊘ Disabled for this project", or none

Set the variable in every terminal you use for the test, including after a restart. A rose added later is not disabled, so it loads. To re-test the install step, run claude mcp remove rose -s user; to re-test the sign-in step, claude mcp logout rose. Your ~/.claude/CLAUDE.md still loads, which a buyer would not have. For a fully clean Claude, set CLAUDE_CONFIG_DIR to an empty directory and /login there once. A headless claude -p can't /login: log in once interactively with that CLAUDE_CONFIG_DIR, then reuse it headless. Without it, the run loads your own CLAUDE.md and session hooks, which bias the agent's choices. To check that an agent finds a vendor's auth.md on its own, give it a task that needs the vendor's API, not just its result: told only to "validate this IBAN with Ibanly", it checked the IBAN offline and never visited the vendor. Give the project a reason to need IBAN validation, for example a README.md saying that members enter the IBAN their monthly fee is debited from by SEPA Direct Debit.

Path 1: the prompt from ibanly.app. Copy "test it with your AI" on ibanly.app, or run curl -s https://ibanly.app/agents/evaluate-prompt.txt | pbcopy, and paste it. Claude adds the connector and stops. Restart claude, run /mcp, choose rose → Authenticate, sign in with Google on id.userose.ai and allow the assistant, then paste the prompt again. Claude cannot run claude mcp login itself: inside Claude Code the command has no terminal. Passes when:

  • claude mcp list shows rose … ✔ Connected, and after the restart Claude goes straight to the setup;
  • get_offers lists Free (0 EUR), Usage (18.75 EUR) and Team (96.50 EUR);
  • try_product returns mode: "trial" and valid: true for a valid IBAN;
  • provision_account returns an account_id (existing on a re-run), Claude asks you before checkout, and the Free plan becomes active with an invoice for 0 EUR;
  • get_access returns an ibn_… key and api_request, and try_product with the account_id returns mode: "live" and usage counting up;
  • the code change calls api_url with {"input": {"iban": …}}, and the key comes from an environment variable, never from a file.

This creates a permanent account for your buyer on the live Ibanly Worker. Nothing is paid. The "install" prompt on /agents (install-prompt.txt) covers the same setup without the code change.

Path 2: found as an alternative. With rose installed and signed in, ask as a buyer would, naming the competitor and never Ibanly:

We pay IBANAPI to check the IBANs freelancers enter before each payout batch. It's getting expensive, and our DPO wants IBANs processed in the EU and never stored by the provider. We need a hosted API (our payout service can't ship bank data libraries). Find an alternative we could switch to.

Passes when Claude calls find_alternatives(tool="IBANAPI") on its own, and the result lists Ibanly with matched_by: "competitor_list" and agent_buyable: true. Claude should then ask Ibanly how it compares (ask_vendor), check its prices and output with get_offers and try_product, and say the suggestion comes from Rose's network. Ibanly's description says it is a demo with simulated paid plans, so for real payouts Claude rightly warns instead of switching: that is a pass. Keep the constraints in the prompt. Asked on cost alone, Claude rightly prefers a free checksum library, because that is all Ibanly does.

To test discovery and buying in one run, tell Claude a demo is fine and the Free plan is enough, still without naming Ibanly:

We're testing how you buy software through the Rose connector, so a demo vendor is fine. We pay IBANAPI to check the IBANs freelancers enter before each payout batch, about 60 a month. Our DPO wants IBANs processed in the EU and never stored by the provider, and we need a hosted API. Find an alternative we could switch to. If one can be set up through Rose, set it up on its free plan and validate FR76 3000 6000 0112 3456 7890 189 with it. Ask me before any paid checkout.

Passes when Claude goes from find_alternatives to Ibanly's Free plan and a live try_product with the account_id, mentioning the demo once without stopping.

Path 3: Claude thinks of it itself. The buyer never asks for an alternative. Claude checks on its own when a coding task runs into the cost or limits of a paid service, but only if the buyer's CLAUDE.md asks it to. The install and "test it" prompts offer to add that line to ~/.claude/CLAUDE.md when they install the connector. For the test, put it in the project's CLAUDE.md:

When a task adds usage of, renews, or runs into the cost or limits of a paid third-party
service, check alternatives with the Rose connector (find_alternatives) before settling on a
fix, and tell me what you found.

Use a project that already uses IBANAPI: a README.md saying it pays freelancers by SEPA in a weekly batch, checks each IBAN with IBANAPI, and that the DPO wants EU processing with nothing stored; src/iban.ts calling api.ibanapi.com; and src/onboarding.ts calling it at onboarding. Then give a plain task:

Since we added the IBAN check to onboarding, IBANAPI answers 429 (quota exceeded) in the middle of the weekly payout batch, so the batch stops. Fix it.

Passes when Claude calls find_alternatives(tool="IBANAPI") without being asked, reports Ibanly as an EU, no-storage, agent-buyable alternative from Rose's network, and says why it did or did not adopt it. Expect "not adopted": the fix only needs a checksum, which Claude writes locally. Without the CLAUDE.md line, Claude fixes the code and never calls Rose.

Headless, for an agent. The sign-in needs a person once, because claude mcp login needs a terminal and the buyer clicks allow. After path 1 (or claude mcp add and claude mcp login rose in ~/ibanly-buyer), a run keeps its transcript:

cd ~/ibanly-buyer
ENABLE_CLAUDEAI_MCP_SERVERS=false claude -p "$(curl -s https://ibanly.app/agents/evaluate-prompt.txt)" \
  --allowedTools "mcp__rose__ask_vendor mcp__rose__find_alternatives mcp__rose__get_offers mcp__rose__try_product Read Glob Grep" \
  --output-format stream-json --verbose > path1.jsonl

Your own ask rule on mcp__rose__checkout still applies in a headless run, which cannot answer it, so path 1 stops before the free plan. To run it unattended, add --setting-sources project and add mcp__rose__provision_account, mcp__rose__get_access and mcp__rose__checkout to --allowedTools: that flag also drops the allows from your settings.

The first line of path1.jsonl ("subtype":"init") lists the MCP servers the run loaded: only rose is connected. checkout is not allowed, so the run can never pay. Each tool_use line is one connector call; the last result line holds the answer. To check a change to the connector's instructions or tool results before it is deployed, add --strict-mcp-config --mcp-config '{"mcpServers":{"rose":{"command":"just","args":["--justfile","<repo>/backend/apps/r4a_mcp/justfile","run"]}}}' to use the local stdio server. Buying tools then need the Ibanly Worker on :8788 (just dev in backend/apps/r4a_ibanly). The assistant follows a rule placed in the tool result it is reading (for example the find_alternatives note) more reliably than the same rule in the connector's instructions, so check a steering change with a few runs.

Check every client's surfaces

The r4a-surfaces-check Cloud Run job (IX-5850, backend/apps/jobs/r4a_surfaces_check/) checks every workspace with the Ask Rose link on, each Monday at 06:00 UTC, and writes a tree per workspace to integrations.r4a_surface_checks:

  • Ask Rose link: ask.userose.ai/<domain> answers as the right company, and its robots.txt lets agents fetching for a person (ChatGPT-User, Claude-User, Perplexity-User) open it.
  • Website discovery: the published verify-ask-rose-setup.sh (llms.txt, footer link, webagents.md, <head> tags, CSP).
  • Claude Code and Codex, no connector (Sonnet and gpt-6.1-sol, medium effort; Codex in a fresh home with live web search and no apps): asked a pricing question with growing hints (company name, then its domain, then /llms.txt, then the prompt the footer's "Ask Rose" button copies, website/public/rose-for-agents/ask-prompt.txt), each must open ask.userose.ai/<domain>. The tree gives the first level that does; every level before it fails, and so does a link that opened with an error, so an agent's line is green only when it finds Ask Rose from the company name alone. An agent run that fails itself (sign-in, quota, timeout) stops at that hint as "did not run: ": it says nothing about the website, so the backoffice rating leaves that agent out.
  • Claude Code + Rose connector: the same hints, with r4a-mcp over stdio as the fixed dev buyer (no sign-in; the image installs it in /opt/r4a). A hint passes when ask_vendor answers for the domain. Only the read tools are allowed (ask_vendor, get_offers, compare_offers, find_alternatives): the check never starts a trial or a purchase. The connector reads ROSE_MCP_DB_DSN, IX_ADMIN_API_KEY and OPENAI_API_KEY_PROCESSING_EU from Secret Manager. It never runs while a Codex row does: Codex's read-only shell could read those secrets on a client page's instruction.
  • Claude Code + Rose skill, Codex + Rose skill: the same hints with the buyer skill (website/public/rose-for-agents/rose/SKILL.md) installed and no connector, as a buyer who added only the skill. The image copies it from the rose_for_agents build context.
  • OpenAI API (web search): the same hints sent to the Responses API with the web_search tool (job's OPENAI_API_KEY); a hint passes when it opens the Ask Rose link. The closest to ChatGPT a job can run, and an upper bound: it fetches live, while ChatGPT accounts (and Codex signed in with one) open only pages OpenAI already holds, so never an Ask Rose URL.
  • Anthropic API (web search and fetch): the same hints sent to the Messages API with the web_search and web_fetch tools (job's ANTHROPIC_API_KEY, Sonnet 5.5). web_fetch opens only URLs already in the conversation and swaps others for one it knows: asked for a new question, it returns the landing page or an example link's answer. A hint passes only when the page it got carries the question it asked, which in practice means a link Ask Rose lists.

Staff see the latest tree on the workspace's Config Studio → Rose for Agents page, under WebMCP and the Agent HTTP endpoint, with Run checks now, which starts the job for that workspace through the admin API (/admin/r4a/surface-checks). Production only: the table is in the shared Supabase. The job reads ANTHROPIC_API_KEY (Claude Code) and OPENAI_API_KEY (Codex) from rose-backend-env-production, and a client's failing check is a result, not a failed execution.

These calls are real: they appear in the client's agent traffic and count toward its daily cap. A 429 is the Ask Rose Worker's per-IP limit, retried after a minute.

Locally, just surfaces-check in backend/apps/r4a_mcp prints the same tree and writes nothing (--site <domain> for one client, --agents claude or codex for one agent, --no-agent for none); the report and transcripts land in .context/surfaces-check/<time>/, and it exits 1 on any ✖.

What is not built

  • Hosted buyer sign-in. Built and proven on the local stack (IX-5119); the hosted r4a-buyers project, its Google client and the id.userose.ai deployment are owner actions listed in backend/apps/r4a_buyers/README.md. Levels 2 and 3 of Buyer identity, payment card, credits and vendor approval are not built.
  • Buyer-facing connector rollout. The deployment workflow sends develop to r4a-mcp-test and main to r4a-mcp-production (prerequisites and staff verification in backend/apps/r4a_mcp/README.md). Each service runs as its own r4a-mcp-<env> identity (just setup <env>). Both resolve brand names against the production client list (ROSE_MCP_DB_DSN, brand lookup only), since the Supabase develop branch holds no clients. Schema migration promotion is tracked in IX-5148. Both are public at the Cloud Run level and mount R4A_BUYERS_URL and R4A_BUYERS_SECRET_KEY.
  • Outbound-call safety. Staff set base_url today. Once customers enter it themselves, r4a needs a public-host allow-list (no private addresses), timeouts and response size caps.
  • Abuse controls. No rate limits or spend caps per buyer.
  • Customer self-service. Clients edit plans & prices and the listing once staff enable Rose for Agents for them; a "Suggest from website" button in the backoffice needs a backend endpoint to run the drafter.
  • Scheduled suggestions. Re-run rose-r4a suggest after a knowledge refresh; nothing triggers it.
  • Other agents on the shared plans. Only Rose for Agents reads Plans & prices; the website agent still answers pricing from its knowledge and the Pricing guidance.
  • Cross-surface journeys and influenced revenue. R4A questions and buying steps are measurable in Usage and the buyer timeline. Attribution across other surfaces and influenced revenue are not built.
  • Rose for Websites beyond sign-up and trial: paid plans through agents and the widget snippet in get_access. Real payment for any vendor.

Strategy

Strategy and market analysis live in the private Rose staff vault. Open its Home page and follow the Rose for Agents links for the strategy, access and identity options, and the Versine comparison. This page remains the reference for current behavior, the vendor contract, and testing.

Code map

Path What
backend/apps/r4a_mcp/r4a_mcp/server.py connector, instructions, ask_vendor
backend/apps/r4a_mcp/r4a_mcp/commerce.py buying tools, vendor spec model, database spec store, which calls r4a signs
backend/apps/r4a_mcp/r4a_mcp/signing.py RFC 9421 signature, KMS and local dev signers; served as /.well-known/r4a-keys.json by asgi.py
backend/apps/api/admin/ixadmin_api/routes/r4a.py Rose for Websites as a vendor: the contract, sign-up through the wizard's claim, trial through Ask Rose
backend/apps/r4a_mcp/r4a_mcp/offers.py, r4a_mcp/offers/ offer sheets of non-customer vendors, compare_offers ranking, get_access install commands
backend/apps/r4a_mcp/r4a_mcp/identity.py buyer token verification, the signed-in buyer of a call, vendor-use log
backend/apps/r4a_mcp/auth_e2e.py buyer sign-in end to end on the local stack (just e2e-auth)
backend/apps/r4a_mcp/edge/ mcp.userose.ai / mcp-test.userose.ai: pass-through Worker in front of Cloud Run
backend/apps/r4a_buyers/ the r4a-buyers Supabase project (supabase/, schema buyers) and the id.userose.ai site (site/, a static Cloudflare Worker)
backend/apps/cli/cli/cli_r4a.py rose-r4a: plan suggestions (questions, extraction, citation guard), wiring, secrets
frontend/client-backoffice/src/pages/GuidelinesPlansPage.tsx Sales Guidelines → Plans & prices; suggestions logic in domain/pricingPlans.ts
frontend/client-backoffice/src/pages/RoseForAgentsPage.tsx Config Studio → Rose for Agents: listing switch and what AI agents see
supabase/migrations/20260928091556_ix5245_r4a_vendor_specs.sql knowledge.pricing_plans, listing and secret tables, r4a_vendor_spec, r4a_sign, signer roles, backoffice RPCs
backend/apps/r4a_ibanly/ Ibanly, the demo vendor for ibanly.app: fake company, real IBAN validation. One Cloudflare Worker (TypeScript): Ibanly itself in src/core/ (site, Clerk sign-in, dashboard keys, Durable Object accounts) and everything it adds for Rose in src/rose/ (the contract, auth.md, claim). Reference implementation of the contract. Live at ibanly.app
docs/plans/2026-09-16-ix-5025-rose-for-agents.md plan and decisions
docs/plans/2026-09-24-ix-5119-buyer-sign-in.md buyer sign-in plan and decisions