
IntentLinks
Shareable links that give AI agents bounded, revocable spending power — enforced by Sui, not us.
Demo
About
IntentLink is a link that carries a permission. You write what an AI agent may do with your money in plain English — how much, how often, where, for how long. You get back a shareable link with a QR code. Whoever opens it proves they're a real human, and then plugs in whatever agent they like. The limits aren't enforced by our code, our server, or the agent's good behaviour. They're Move asserts on Sui — nineteen of them, on every single spend. The problem Handing an agent money today means handing over the keys and hoping. "Only spend $50 a day" lives in a prompt. The API key does everything. You find out afterwards, and stopping it means rotating credentials. IntentLink is a valet key instead. The car starts, the boot stays shut — and it isn't a promise, it's the shape of the key. How Sui enforces it Four Move objects. A Vault holds the funds. A Capsule is the permission — key only, no store, so it can't be transferred, sold or wrapped. Access is by field, not ownership: the issuer may revoke, pause or tighten; the recipient may pause or hand back; only the holder address may execute. Every spend passes nineteen asserts: who's asking (3), is it alive (6), recurrence (1), soft caps (3), hard caps (2), scope (3), funds (1). An approved escalation lifts the soft caps and only those — nothing lifts the hard cap, not a permit, not the issuer at 3am, not a compromised backend. The centrepiece is a hot potato. begin_execute hands out the coin and an ExecTicket — a struct with no abilities at all. It can't be stored, copied or discarded, and only settle can destroy it, after checking the proceeds landed on the beneficiary fixed at mint. A transaction holding that ticket is structurally incapable of finishing unless the money goes to the right address. The agent isn't trusted not to skim; a transaction where it skims cannot be built. And because the swap is an ordinary call between those two, any venue works and we're coupled to none. Also on Sui: zkLogin and sponsored transactions, built ourselves — our own salt derivation, prover client and gas station with a reserved coin pool. Users sign in with Google and pay no gas. 43 Move tests, one per assert. How ENS publishes it Every capability is a subname under intentlink.eth, issued programmatically through an ENSv2 PermissionedRegistry. Its text records carry the vault, the capsule, the chain, the status, and il:policy — a hash of the terms. That same hash is written into the Sui object in the same flow. Before acting, the agent resolves the ENS name and halts if the two chains disagree. The recipient's page shows "Verified" only when the hashes match and our stored document matches the capsule field by field — a check on our own server, not just on the chain. Names are ERC-1155, so the tokenId is parsed from the TransferSingle mint event. Records go through a subtree resolver in a multicall, so a name is never half-updated. Reads use ENSIP-10 resolve(). We grant ourselves exactly ROLE_SET_RESOLVER | ROLE_SET_RESOLVER_ADMIN per name using Enhanced Access Control — only bit 24 is documented, so the layout was recovered by probing a fresh name. How World ID vouches for it At redemption, a live human is claiming the link — not a script, not a replayed credential. The load-bearing use is escalation. An agent can ask to exceed its limit; it can never approve. approve_escalation asserts ctx.sender() == capsule.issuer, and the frontend requires a fresh World ID proof whose signal is bound to hash(capsule, amount, nonce) — so a stored credential can't be replayed and a yes to 26 can't be stretched into a yes to 260. Using World sessions, the nullifier is comparable to the one recorded at mint, proving the same human, not just the same account. Every other approval mechanism is a bearer credential — and a bearer credential can be handed to the very agent you're constraining. A live human cannot. Bring your own agent claim() always accepted any address as holder, so a capability can be handed to an agent we didn't write and have never seen. Every permission is also an MCP server. One line of config into Claude Desktop or Cursor, and that assistant gains five tools generated from the capsule: get_permission, quote, execute, request_escalation, get_history. The tool list is scoped to the capsule — that's convenience. Underneath, execute meets the same nineteen asserts — that's the guarantee, and it holds for an agent that read nothing. Verified on testnet: a delegated key holding zero SUI traded 0.01 through MCP, and the same connection asking for 5 SUI was refused with E_OVER_ACTION_CAP, nothing moved. The demo Open Claude Desktop — an AI we didn't write, on someone else's laptop — tell it to spend ten times the limit, and watch Sui refuse it. Then revoke from the dashboard and watch the agent go quiet, because it cannot continue rather than because it agreed to stop. Anyone can build an agent that spends money. We built the part that says no.
How it's made
How it's made Stack: Sui Move 2024 · Next.js 16 (App Router, Turbopack) · TypeScript · @mysten/sui v2 over gRPC · viem for Sepolia · World IDKit v4 · Tailwind v4 · OpenAI structured outputs · Vercel with a private Blob store. The contract intentlink.move is ~1,200 lines and leans on Move's linear types rather than runtime checks. Capsule is key with no store, so a permission can't be transferred or wrapped. Permit has no drop, so execute_elevated consuming it by value makes replay unrepresentable rather than merely defended against. ExecTicket has no abilities at all — the hot potato that forces settlement to the beneficiary. One assert path (check_and_charge) is shared by the transfer and swap routes so they can't drift. 43 tests, one per assert. The bug worth admitting: the per-window budget bound nothing. roll_window used window_start_ms == 0 as its "never run" sentinel — and at a genesis-epoch test clock, zero is indistinguishable from unset, so the window silently reset on every call. Switching to windows_used == 0 fixed it. The tests caught it; reading the code hadn't. Sui JSON-RPC is deprecated on public fullnodes, so everything is SuiGrpcClient over GrpcWebFetchTransport, with responses arriving as tagged unions needing unwrapping. Enoki is paid, so we built its three parts ourselves: a salt service (HMAC(secret, iss|aud|sub)), a prover client, and a gas station — a pool of reserved coins so concurrent sponsored transactions don't collide. Users sign in with Google and pay no gas. Three things bit hard: listCoins is cached and returns stale versions. Reserving a gas coin now does a fresh authoritative version read first, or you get provided version doesn't match under any concurrency. Google emits iss in two valid forms, so one person got two different Sui addresses on alternate sign-ins. A canonicalIss() normaliser fixed it. getSecretKey() returns bech32, not base64. Round-tripping produced a key that looked fine and signed nothing — the sign-in button silently did nothing. Package upgrades have two traps. An upgrade mints a new package ID for calls while types keep the original, so PACKAGE_ID and PACKAGE_LATEST are separate config. Sharper: a struct introduced by an upgrade is named after the version that declared it. Executed stayed 0x2df6…, but EscalationRequested is 0x095f…. A history query filtered on one package returns a plausible, chronological, entirely incomplete timeline — missing exactly the newest events. It now queries both and merges on digest + event index. We measured DeepBook rather than assuming: SUI_DBUSDC had zero bids on testnet, so selling returned dust. We built a minimal constant-product AMM instead. The capability layer imports no DEX at all — the swap happens between begin_execute and settle, so changing venue is a one-line change. ENS — v2 specifically, on Sepolia Not the legacy registry; the differences are the whole integration. Hierarchical registries. intentlink.eth owns its own PermissionedRegistry, and every capability is minted as a subname inside it via register(label, owner, subregistry, resolver, roleBitmap, expires). Names are issued programmatically at runtime — thousands of them — rather than one vanity registration. Names are ERC-1155 tokens in v2. The tokenId only surfaces in the TransferSingle event, so it's parsed from the receipt to manage that name's roles later. Enhanced Access Control replaces v1's single owner with a role bitmap, and only ROLE_SET_RESOLVER (bit 24) is documented. We registered a throwaway name and probed it to recover the layout: roles sit at every fourth bit (0, 4, 8 … 28), admin at +128. Each capability gets exactly ROLE_SET_RESOLVER | ROLE_SET_RESOLVER_ADMIN — write its records, delegate that, nothing else. The v2 subtree resolver addresses names by DNS wire format, not namehash: setText(bytes dnsName, string, string). That ABI wasn't published usefully, so several were recovered from deployed bytecode. Writes go through multicall so a name is never half-updated — a capability whose il:status said active while il:policy said something else would be worse than no record. Reads use ENSIP-10 resolve(). What the name is for: it publishes il:capsule, il:vault, il:chain, il:status, il:expires, and il:policy — a hash of the canonical terms, written to Ethereum and into the Sui object in the same flow. The agent resolves the name before acting and halts if the two chains disagree. We also tried to freeze a name's records after issuance and found you can't: a subtree resolver authorises from the parent, so revoking write access on one name revokes it across the subtree. Removed rather than shipped as a fake guarantee. World ID v4 is protocol-level, not widget-level. Requests must be RP-signed server-side, and verification is POST /api/v4/verify/{rp_id} on developer.world.org — not the v2 {app_id} endpoint, which cheerfully answers "Action not found" for actions that plainly exist. signal_hash comes back hashed, and we were comparing it to the plaintext signal — which rejected every genuine approval until we hashed with IDKit's own hashSignal and compared hash to hash. A v4 action allows one verification per human, with no API to raise it. So we mint a fresh action per request — which then makes nullifiers incomparable, since they're scoped to (user, app, action). The fix is World sessions: no action at all, and a session_nullifier stable for one human across every proof, which is what makes "the same human who set the limit approved raising it" checkable. Bring your own agent, over MCP Each capability is exposed as an MCP server at /api/mcp/[token] — a JSON-RPC handler implementing initialize, tools/list, tools/call. The tool list is generated from the on-chain capsule with live figures, so a connected agent can't see an out-of-scope action. Connection tokens and delegated keys are derived (HMAC(secret, "mcp"|capsuleId)) rather than stored — surviving restarts without a table that can fall out of step, with constant-time comparison. Delegated agents hold zero SUI; their transactions are sponsored, because "fund this address before your agent can act" would undo the point of pasting one line of config. Deployment The intent store began as a JSON file. On Vercel that's fatal twice: the filesystem is read-only outside /tmp, and /tmp is per-instance — so a mint wrote to one lambda and the next click read an empty disk on another. Links 404'd while the capability was live on chain. It's now a private Blob (these records hold salts and emails; an unguessable public URL is a bet, not privacy), with a 2s cache collapsing repeated reads. Then the Git integration built the repository root, which has no Next app, reported Ready in five seconds, took the alias and 404'd everything. Setting rootDirectory via the Vercel API fixed it — and we pushed an empty commit specifically to prove a push no longer takes the site down. The NL compiler Intents are written in English and compiled with OpenAI structured outputs, each clause classified enforced / advisory / refused before anything is minted. The model kept inventing objections — reciting every limitation it had been briefed on, asked for or not — so a grounding filter keeps only clauses with real word-overlap against what the person actually typed.