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.
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¶
2. Try and sign up: r4a calls the customer¶
3. Pay, get access, use¶
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 whosecompetitors.namescontains 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 passesaliasesfrom 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 OpenAItext-embedding-3-smallon 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 inprivate.client_description_embeddingsand refresh on read: each need search embeds the profiles whose text changed, in the same OpenAI call as the need, andprivate.store_alternative_embeddingsonly accepts a vector whose hash matches the current text. A client without an identity description cannot match this way. r4a mounts the EU-restrictedOPENAI_API_KEY_PROCESSING_EUasOPENAI_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:researchfor the ChatGPT app), so they are no longer mixed with the brand-chat connector'suser_typed. private.agent_touchpoints(reaches without a question turn), written throughprivate.record_r4a_activityby r4a-mcp after each buying tool, one row per vendor domain the result names (find_alternativesandcompare_offersrows areshown), asrose_mcp, off the call path; the ChatGPT app the same way. The search API usesrecord_agent_touchpointsfor 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_indexortraining, 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.
| 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 anddomain_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_productwithout an account answers that the trial needs one. With theaccount_id, it answers409with 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_accessreturns the crawl status, trial usage, backoffice and Ask Rose links, and theask-rose-websiteinstall skill.checkoutsends the buyer to the backoffice: no plan is sold through agents yet.- The "Try it with your agent" button of the
/rose-for-agentspage (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) andget_access. The prompt iswebsite/public/rose-for-agents/try-prompt.txt. assess("Rose", {domain})is the agent sign-up diagnostic (IX-5714, plandocs/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 thecode_checksthe agent runs in the user's repository. Rose receives only the domain. The live checks readllms.txt,auth.md, the RFC 9728 and RFC 8414 metadata and the resource's unauthenticated401; 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 onePOST, and only on the domain or itsauth.mdhost. 30 per buyer an hour. Stateless: the before/after is two runs. Rose never lists itself infind_alternatives, and no offer sheet compares it.GET|POST /r4a/answers the401challenge, so Rose's own resource passes Use.- Signature: checked against r4a's published keys like any vendor, with no secret.
R4A_VENDOR_HOSTis the admin API's public host, the one the listing'sbase_urlnames: r4a signs that host, and Rose refuses a call signed for any other. Unset, every call gets503. On the test admin API, the write routes are refused against shared Supabase, like onboarding;/r4a/assessand/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;
anonymousis 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 inTRUSTED_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 carryclient_idand anauth_timeunder 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-claimpage. - 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/tokenwith the claim grant. Tokens are delivered once.
- The person opens
- 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/revokekills either.
- The access token is the r4a contract's buyer.
routes/r4a.pyacceptsAuthorization: Bearernext to r4a's signature.- The buyer is the claimer's Supabase user id (or the issuer-derived id), in the admin API's environment (
productionortest). 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/*carriesWWW-Authenticatewith 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/claimfor a new code. - Registrations: 24 hours.
- The claim page names the backoffice of the admin API's environment (
app,appstagingorapptest);BACKOFFICE_BASE_URLoverrides 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 forTRUSTED_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_codefrom 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=testorproduction. 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'sroseconnector 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_urlis a page the agent cited (a citation card or a link in the answer; host and path, ignoringwww.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>:
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:
- SDK:
npm ci,npm test,node scripts/smoke-package.mjsinrose-vendor-sdk. The exchange, its limits, revocation, and sign-up after a trial. - Ibanly with the real SDK:
just checkinbackend/apps/r4a_ibanly.test/rose/auth-provider.test.tssigns 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/r4acontract and revokes. try_productrouting: frombackend/,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.- Connector to Ibanly, locally:
just e2e-trial-modeinbackend/apps/r4a_mcp(no Docker, database or hosted service). It generates disposable keys, starts the Ibanly Worker withtrialMode: auth-providertrusting a local broker key, and runs the real connector in-process as a buyer:try_productreturnstrial_startedwith Ibanly'smcp_url, the trial key lists and callsvalidate_ibanup to its limit, the same buyer gets the same trial, another buyer gets their own, thenprovision_account,checkoutandget_accesskeep the account and end the trial key. It is not injust e2e: that run needs Ibanly on the signed/trialfor 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:
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:
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>inbackend/apps/r4a_ibanly), runjust checkthere 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 withnpm install --no-save ../../../vendor-sdk; never commit a local dependency path. Runjust checkin Ibanly andjust e2einbackend/apps/r4a_mcp. A dependency-install recipe can restore the released package, so verify the installed SDK withnpm ls @userose/vendor-sdkbefore testing the feature: it must point to../../../vendor-sdk. Aninvalidlabel 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 inapps/r4a_mcp/tests/test_commerce.py,apps/r4a_ibanly/test/rose/contract.test.ts,apps/api/admin/tests/test_r4a_route.pyand the SDK repository'stest/vendor.test.mjs; - the connector's tests pin what r4a sends and how it maps each vendor status;
just e2eruns 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:
- Seeds Rose's listing on the branch (
base_url = http://127.0.0.1:8080/r4a); re-seeding is a no-op. - Starts
just dev-knowledge-stack testwith 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. - Drives the connector over stdio (
just run, dev key) as a new dev buyer on the test site (niak.net, 18 pages;--sitefor 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 gets403on the account. - 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/:
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:
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.
just setup productioninbackend/apps/r4a_mcp/(runsdeploy.sh setup): creates the KMS keyr4a-identity-productionand letsr4a-mcp-productionsign with it. Check it withgcloud kms keys get-iam-policy r4a-identity-production --keyring=r4a --location=europe-west9.- Prove KMS signing with that identity: level 3 below, with
r4a-mcp-productionandr4a-identity-production/identity-production(the grants already exist, skip them). - Each vendor trusts the broker: its issuer
https://mcp.userose.ai/identityand its public keys, the JWKS line step 2's job prints (once released, also/identity/.well-known/jwks.json) (Ibanly:AGENT_CONFIGinwrangler.jsonc, plus itsAGENT_PRIVATE_KEYandAGENT_ADMIN_SECRETWorker secrets, set once by hand). - Release (merge to
main): r4a-mcp with the broker, Ibanly, then the buyer site. - 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:
- Serve the agent-auth contract with the vendor SDK (
createAgentHandlerfrominboundx/rose-vendor-sdk, seer4a_ibanly/src/rose/index.ts):/auth.md,/agent/identity,/agent/identity/claim/complete,/agent/events,/oauth2/token,/.well-known/*,/claim, with CORS forhttps://id.userose.ai. It maps a claim onto their own account and API keys. - Generate their own P-256 signing key. The private key stays a secret on their side (Ibanly:
the
AGENT_PRIVATE_KEYWorker secret, plusAGENT_ADMIN_SECRETfor its read-only console); its public JWK goes in their config asvendorJwks. - Trust Rose's broker, all public values (Ibanly:
AGENT_CONFIGinwrangler.jsonc):issuer(their origin),environment: "production",brokerIssuer: "https://mcp.userose.ai/identity",brokerClient: "rose-id",brokerJwks(the JWKS thatfederation_kms_check.pyprints, orhttps://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) andtrialLimit.
On Rose's side (one PR):
- Add the vendor to
backend/apps/r4a_mcp/federation/production.json:name;domainequal to its R4A listing's domain (get_accessmatches on it);audience= theirissuer;identity_url,claim_urlandevents_urlunder it;sources;environment: "production";mcp_urlwhen the product is an MCP server;auto_claim: falsewhen the vendor wants a person to confirm every new account. Thenprovision_accountstarts the claimable trial (the same answer asget_accesswithout 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. - Add their origin to
connect-srcinbackend/apps/r4a_buyers/site/_headers: the consent page posts the claim to it from the browser. - The vendor must already be a listed
httpvendor (integrations.r4a_vendor_specs). - 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):
get_access(<vendor>)registers an anonymous trial at the vendor and, because the vendor'sfederation/production.jsonentry hasmcp_url, exchanges its assertion itself: it returnsstatus: "trial_started",mcp_url, a trialaccess_token(short-lived, a few calls) and the claim block. The agent calls the MCP directly withAuthorization: Bearer(connected as an MCP server, or one JSON-RPC message per POST): trial calls never go through r4a.- The person opens the claim link and signs in with Rose ID; the agent polls
token_urlwith the claim grant and gets a replacement token for the same account and usage. The trial token stops. - Revoke (the person's Revoke on Rose ID, or
/oauth2/revoke) stops the token at once. - 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.
- Protocol, local. Start the buyer stack (
just devinr4a_buyers/supabase/), runfederation_dev.py, then start the three services it prints: Ibanly withr4a_ibanly/node_modules/.bin/wrangler dev --config <out>/wrangler.json --persist-to <out>/state, the buyer site withjust devinr4a_buyers/, and the broker with the buyer env (eval "$(just env)"inr4a_buyers/supabase/),R4A_FEDERATION_CONFIG=<out>/broker.jsonandPYTHONPATH=apps/r4a_mcp .venv/bin/python -m uvicorn r4a_mcp.asgi:app --port 8766frombackend/(noROSE_MCP_DB_DSNneeded). Thenfederation_e2e.py: HTTP only, no browser. - Journey with the real consent page, local.
federation_hosted_e2e.py http://127.0.0.1:8788plays 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. - The agent journey, local. A real Claude Code, connected only to the local r4a, goes from
get_accessto revocation through every component, with the person's steps in a browser: The agent journey, local below. This level found aget_accessthat never named the vendor's MCP (IX-5660). -
KMS signing on Cloud Run, test. Only Cloud Run's identity can sign, so run
federation_kms_check.pyas a one-off job with the branch image. Grantr4a-mcp-test@inboundx.iam.gserviceaccount.comcloudkms.signer,cloudkms.viewerandcloudkms.publicKeyVieweronr4a-identity-test, build and push the image under a throwaway tag (r4a-mcp/r4a-mcp-test:<ticket>-check, neverlatest), 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 --waitRead the
✔lines in its logs (gcloud logging readon the job's execution,textPayload); the JWKS line is parsed intojsonPayload(filterjsonPayload.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. -
Production, with a person. After a release,
federation_hosted_e2e.py(defaults toibanly.app). A non-staff person signs in with Google onid.userose.aithemselves; 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).
- Keep the level 1 stack running, but restart Ibanly with a new
--persist-todirectory, so the buyer has no account there yet (a claim onto an owned account is a conflict, by design). - 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. -
From an empty directory outside the repository (no
AGENTS.mdsteering 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.jsonNote
session_id. Read the tool calls in its transcript (~/.claude/projects/<dir>/<session_id>.jsonl), not only its answer:get_accessreturnstrial_startedwithmcp_url, thentools/call validate_ibanon the MCP reports usage 1.federation_dev.pysetsauto_claim: falsefor 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. …". Itsprovision_accountmust returntrial_started(noPOST /r4a/accounts), and steps 4 and 5 resume it on the same account. -
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.
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 token401 invalid_token, usage 2 on the same account.- In another tab, the vendor's own sign-in reaches the claimed account: Ibanly's
/dashboardshows 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 theemailclaim in Ibanly's Clerk session token. - 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/:
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 listshowsrose … ✔ Connected, and after the restart Claude goes straight to the setup;get_offerslists Free (0 EUR), Usage (18.75 EUR) and Team (96.50 EUR);try_productreturnsmode: "trial"andvalid: truefor a valid IBAN;provision_accountreturns anaccount_id(existingon a re-run), Claude asks you beforecheckout, and the Free plan becomesactivewith an invoice for 0 EUR;get_accessreturns anibn_…key andapi_request, andtry_productwith theaccount_idreturnsmode: "live"andusagecounting up;- the code change calls
api_urlwith{"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 itsrobots.txtlets 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 openask.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 whenask_vendoranswers 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 readsROSE_MCP_DB_DSN,IX_ADMIN_API_KEYandOPENAI_API_KEY_PROCESSING_EUfrom 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 therose_for_agentsbuild context. - OpenAI API (web search): the same hints sent to the Responses API with the
web_searchtool (job'sOPENAI_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_searchandweb_fetchtools (job'sANTHROPIC_API_KEY, Sonnet 5.5).web_fetchopens 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-buyersproject, its Google client and theid.userose.aideployment are owner actions listed inbackend/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
developtor4a-mcp-testandmaintor4a-mcp-production(prerequisites and staff verification inbackend/apps/r4a_mcp/README.md). Each service runs as its ownr4a-mcp-<env>identity (just setup <env>). Both resolve brand names against the production client list (ROSE_MCP_DB_DSN, brand lookup only), since the Supabasedevelopbranch holds no clients. Schema migration promotion is tracked in IX-5148. Both are public at the Cloud Run level and mountR4A_BUYERS_URLandR4A_BUYERS_SECRET_KEY. - Outbound-call safety. Staff set
base_urltoday. 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 suggestafter 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 |