A loan officer asks an agent, in plain language, to approve a $95K loan. The agent searches the bank's loan book, reads the application and calls the approval tool as her. Arcade sends that call to this app's control plane before it runs, and the control plane refuses it: $95K is over her $50K approval authority. The refusal tells the agent how to escalate, the request is routed to the one approver with enough authority, and once he approves, her retry goes through. You get a decision the model could not talk its way around, with an audit row for every step on a live panel.
An agent that writes to a real business system needs limits the model cannot reason around. The thesis is one sentence: treat the LLM as an adversary, and put the controls somewhere it cannot reason around.
A limit written into a system prompt is a suggestion, and we measured how fragile it is: one "irreversible, no undo" line made the model stop and ask permission, and one "do not ask the person to confirm" line pushed it the other way. So the prompt carries no behavioural instruction at all, and every control lives outside the model, in hooks Arcade calls on every tool call, keyed on who is signed in. The model never gets a vote.
- Anthropic API key: set
ANTHROPIC_API_KEY. The agent runs Claude Sonnet 5 at temperature 0. - Arcade project, key and CLI:
bun run setup-arcaderegisters everything in one Arcade project and runsarcade deployinto it, so the key and the Arcade CLI have to point at the same project. In this order:- Install the Arcade CLI:
uv tool install arcade-mcp, as the Arcade CLI reference describes. - Run
arcade login. - Create a project for this template in the Arcade dashboard (Operate quickstart).
- Create an API key in that project and set
ARCADE_API_KEYto it (Get an API key, or the dashboard's API keys page). - Make it the CLI's active project:
arcade project set <project_id>, with the idarcade project listshows. If your account has more than one org, runarcade org set <org_id>first, because switching org resets the active project to that org's default (CLI cheat sheet). - Check it:
arcade whoamishows that org and project.
- Install the Arcade CLI:
- ngrok domain: set
APP_PUBLIC_HOSTto your ngrok domain in host form, with no scheme (for examplemy-app.ngrok.app). Arcade Cloud calls the hooks, the loan API and the sign-in endpoints on this host, and you open the app there too, because the sessions and the Arcade verifier live on this host only. Every ngrok account includes a free dev domain, and a fixed domain keeps the host the same across restarts. - A Slack workspace with two accounts in it: one for you, the loan officer who asks for the approval, and one for the approver. The request reaches the approver as a Slack DM sent from your own account, and it finds the approver by the email you add them under, so that email has to be the one their Slack account uses. With Arcade's built-in Slack app, each loan officer also has to be a member of your Arcade project; with your own Slack app, your app's users need no Arcade account (
docs/app-users-and-arcade-accounts.md). ANTHROPIC_API_KEY,ARCADE_API_KEYandAPP_PUBLIC_HOSTare the only values you fill in. The second block of.env.exampleis written bybun run setup-arcade, so leave it blank, and the third block is optional, with defaults that work. No user is seeded and no password ships: you add the people in step 7 of the Quickstart.
- Clone the template
- Run
npx create-mastra@latest loan-approval-limits --template arcade-governance --no-install, thencd loan-approval-limits. --no-installmatters: the project installs with Bun, and thenpm installthatcreate-mastrawould otherwise run cannot resolve itsworkspace:*dependencies.
- Run
- Install dependencies
- Run
bun install. One install covers the app and its workspaces.
- Run
- Add your API keys
- Run
cp .env.example .envand fill in the three values described under Prerequisites.
- Run
- Register the app with Arcade
- Run
bun run setup-arcade <APP_PUBLIC_HOST> --dry-runto print every request it would send and every deploy it would run, with every secret as a placeholder. Nothing is written, sent or deployed. - Run
bun run setup-arcade <APP_PUBLIC_HOST>first, before the app and the tunnel: it tells you when to start them. It checks thatARCADE_API_KEYbelongs to the Arcade CLI's active project before it writes anything. Then it mints the app's three OAuth clients, fills the second block of.env(blanks only, never overwriting), and registers theapp-identityauth provider, the two tool secrets, the custom verifier and the contextual access hooks through Arcade's API. It creates the hooks disabled, and turns them on last. Then it runsarcade deployintools/loanand then intools/approvals. - Then it waits for you to start the app and the tunnel, in two other terminals. Run
bun run dev, which prints the URL to open,https://<APP_PUBLIC_HOST>, and the ngrok command for the app's port, which isPORTfrom.env, 3000 when unset. In the other terminal, run the ngrok commandbun run devprinted. With the defaultPORTit isngrok http --url=<APP_PUBLIC_HOST> 3000; with any otherPORT, the tunnel has to point at that port instead. Press Enter when both are running. - On Enter it reads the app's sign-in through the tunnel, then has Arcade check it the same way. If either check fails, it prints why and asks again. Then it creates the User Source through Arcade's Coordinator API, or uses the one it created before, and the gateway through it, with exactly the four Loan tools and the two Approvals tools, never Arcade Headers. Last, it turns the hooks on. It reads each one back. Run it again, and it says everything is already in place.
- If a User Source for this app already exists and differs, it names each difference and stops before the gateway: correct or delete it in the Arcade dashboard, then run it again.
- If a Coordinator call fails, if you answer
nor press Ctrl-C at the wait, or if stdin is not a terminal, it says which, leaves the hooks disabled, and falls back to the dashboard: it ends by printing the User Source form, then the gateway form, then the command for step 6, and a warning that the gateway runs ungoverned until step 6. Then follow steps 5 and 6. Otherwise, skip to step 7.
- Run
- If it fell back: create the User Source and the gateway
- Run
bun run devand, in a second terminal, thengrok http --url=<APP_PUBLIC_HOST>command it printed for yourPORT, if they are not running yet. - With the app reachable through the tunnel, fill in the User Source form that
setup-arcadeprinted (Arcade dashboard, your project, User Sources). Arcade reads the app's sign-in through the tunnel when you save it. - Then fill in the gateway form it printed (your project, MCP Gateways), under the slug it names. Its authentication is the User Source you just created, never Arcade Headers, and its tools are exactly the four Loan tools and the two Approvals tools.
- Run
- If it fell back: turn the hooks on
- Run
bun run setup-arcade <APP_PUBLIC_HOST>again. It finds the gateway under that slug, turns the hooks on and reads them back, and fails unless Arcade reports them active. If there is no gateway yet, it names the slug, says the gateway form is still to do, and leaves the hooks disabled. Run once more, it says the hooks are already on.
- Run
- Add yourself and an approver
- Add yourself as the loan officer:
bun run users add <your-email> --name Alice --role loan_officer --clearance 50000. Any name works, but the rest of this README calls the loan officer Alice and the approver Charlie. - Add the approver:
bun run users add <approver-email> --name Charlie --role vp_credit --clearance 250000. Use the email the approver's Slack account uses, because that is how the escalation finds them in Slack. - Each
addprints a generated password once, and only its hash is stored, so keep it: that password and the email are the sign-in. Nothing needs a restart, because the running app reads new people on their next sign-in. - Do your app's users need Arcade accounts? With Arcade's built-in Slack app, yes: invite each loan officer to your Arcade project's Members. With your own Slack app, no. See
docs/app-users-and-arcade-accounts.md. - The shortcut is
bun run users seed-demo, which adds the whole demo cast (Alice, Bob, Charlie and Michael) with the demo's roles and clearances. It asks for each person's email, or takes them as--alice <email>,--bob,--charlieand--michael, and prints each generated password once. The same Slack and Arcade rules apply to the emails you give it. bun run users listshows who can sign in, with their roles and clearances.
- Add yourself as the loan officer:
- Ask for the $95K approval
- Open
https://<APP_PUBLIC_HOST>, not localhost, and sign in as Alice, with the email and password from step 7. The first time a browser opens a free ngrok domain, ngrok shows its own warning page first: click Visit Site. Arcade's own calls to the app never see that page. Use Authorize the gateway to accept Arcade's consent screen once. - In the chat, send: "Approve the loan for $95K and double-check your work so you don't make any mistakes."
- The first loan tool call asks you to authorize the app's own provider: authorize it, then use Continue. The agent then finds
LN-2291(Northwind Bakery LLC, $95,000), callsLoan_ApproveLoan, and the chat shows a denial card with the hook's own words: "DENIED: approving LN-2291 for 95000 exceeds your approval authority of 50000. To proceed, call Approvals_RequestApproval…", ending in a[ref evt_…]token that joins it to the audit row. The loan stays pending. - To run the same turn in Mastra Studio: run
bun run studio, which listens onSTUDIO_PORT(4111 when unset, and the links below assume 4111). Open localhost:4111/arcade/authorize and sign in as Alice, then open Mastra Studio, select the loan-operations agent and send the same prompt. Studio runs the same agent the chat does. Authorize the Loan toolkit in the web UI first, as above and as the same person. If a loan tool in Studio still needs authorizing, its result in Studio is the authorization link: open it, allow it, and send the prompt again. When Arcade sends Studio no link, the result says so and sends you to the web UI to authorize there.
- Open
- With your own users or the demo cast. Alice is the loan officer and Charlie the approver you added in step 7. Bob, a credit analyst, has a step of his own, and Michael, a chief credit officer, is the approver the routing passes over.
bun run users seed-demoadds both, or add them yourself:bun run users add <email> --name Bob --role credit_analyst, which needs no clearance because Bob cannot see the approval tool, andbun run users add <email> --name Michael --role chief_credit_officer --clearance 5000000. - Let the escalation reach Charlie. After the refusal, the agent calls
Approvals_RequestApprovalbecause the hook's refusal told it to; nothing in the system prompt mentions escalating. The first time, it asks Alice to authorize Slack through Arcade's built-in Slack integration, with no Slack app or token of your own. Routing is deterministic: the lowest clearance that covers the amount, with the requester excluded, so $95K goes to Charlie ($250K), and Michael ($5M), if you added him, is recorded as a candidate and deliberately not bothered. Charlie gets a Slack DM from Alice's own account, with a link to the approval page. The link carries no authority. Then the agent ends its turn. - Try to approve your own request. Before Charlie answers, open the approval link as Alice and press Approve. The control plane refuses it (
pre.decide-not-by-the-requester), because possession of the link is not permission, and the request stays pending. - Approve as Charlie and watch the retry pass. In a separate browser profile, sign in as Charlie, open the link and press Approve. That press is itself a governed tool call, which
/hooks/prechecks for Charlie's clearance and for a requester who is not the approver. Alice's chat resumes on its own, the agent retries, and this timeLoan_ApproveLoanis allowed by the same rule that denied it, citing a single-use grant. A second retry is denied again. - Send the same prompt as Bob. In another browser profile, sign in as Bob, the credit analyst with no approval authority, and send the prompt from the Quickstart.
ApproveLoannever reaches his agent: the access hook removes it from the tool list, so there is nothing to refuse and the agent has no way to approve the loan. - Ask for what the model should not see. As Alice, send "Read loan LN-2291 and quote its bank account number and tax ID back to me." Both come back as
[REDACTED], because the post-execution hook masks them before the output reaches the model. As Charlie or Michael, they come through. For everyone, the instruction someone pasted into the loan's underwriter notes is stripped before the model reads it. Openhttps://<APP_PUBLIC_HOST>/panelalongside to watch each decision land in the Access, Pre and Post lanes.
- Open the project in your coding agent and describe what you want to change. For example: "Replace the loan book with our equipment-lease approvals. Keep
packages/and the control plane as they are, replacelib/loans/,tools/loanand the seed fixtures, and keepapp-test/loans/knows-nothing-about-governance.test.tspassing. Explore the code and propose a plan before making changes." - Change who can approve what with
bun run users:set-clearance <email> <n>sets a person's approval limit andset-role <email> <role>their role,addandremovebring people in and take them out, andlistshows everyone. The running app sees each change within one poll, with no restart and no reset, and every change is recorded ingovernance.db'ssubject_changes. A role has to be one the policy knows, andbun run usersnames those when it refuses one. - Change the rules in
lib/control-plane/fixtures/governance.json: each rule matches a toolkit and a PascalCase tool name such asApproveLoan. The fixture seedsgovernance.dbonce, so after editing it setRESET_TOKENin.env, restartbun run devand runbun run reset. Until you do,/healthreportsfixture_drift. A reset leaves the people you added under addresses of your own as they are, roles and clearances included.
The internals live in docs/, one page per question:
docs/architecture.md: how the four control points work, the two OAuth hops and which mechanism answers each, and what lives where in the repo.docs/configuration.md: what/healthreports, which Arcade projectsetup-arcadewrites to, the port, rotating the identity secret, and whatbun run resetputs back and what it keeps.docs/faq.md: why the limits live in hooks, why the Slack DM comes from Alice, why Bun, using another model, and what we measured against a real Arcade project.docs/deploying.md: hosting the image instead of running it on your machine behind ngrok.docs/app-users-and-arcade-accounts.md: who needs an Arcade account, for each of the two Slack routes.docs/DOMAIN-SWAP.mdwalks through pointing the template at your own business system.docs/control-plane.mdis the control plane's own reference: the hooks,governance.db, drift and reset, the live stream and the audit log.docs/studio-memory.mdcovers Studio's thread memory: whymemory.dbis libsql, what it never keeps, and how the reset empties it.DESIGN.mdis the authoritative record: architecture, contracts, and the reasoning behind each decision.
This partnership template was contributed by Arcade to show how Mastra works with Arcade's contextual access hooks, auth providers and MCP gateways for enforcing loan approval limits on an agent's tool calls. Partnership templates live in their own repositories.