atlas

Agentic Mini apps on TheGraph -aka dune analytics on steroids

atlas

Created At

ETHGlobal Lisbon 2026

Winner of

ENS

ENS - Best ENS Integration for AI Agents

The Graph

The Graph - Best Use of Composable or Standardized Graph Products 2nd place

Project Description

Atlas turns a question about onchain activity into a mini app: a live interface generated from real Graph data, that can also hold a wallet and act inside a policy you set.

A mini app is not a dashboard. It watches live data, renders whatever interface fits the shape of that data, and — if you let it — takes an action when a condition breaks. Every published one gets an ENS name, an address, and an onchain identity, thereby making ENS names as self contained agentic mini-apps themselves.

The Graph had everything it needed to be the consumer analytics layer of crypto — more chains, more protocols, real time, decentralized — but it only ever shipped for developers. Atlas is the consumer surface it never built. Because the interface is generated per question, an app exists the moment you ask for it, including for the long tail nobody would ever hand-build.

The pipeline: a question becomes an app

Atlas pipeline: question to mini app

The important move is at the Resolver: it picks a schema family (lending-cdp, dex-amm-extended, bridge, perp-futures, …), never a single subgraph id. One question then fires the same query shape at every healthy deployment of that family across every network we have one on, and merges the results. A question nobody anticipated still resolves, because the schema is shared even when the protocol is new to us.

A representative run:

18 sources queried → 13 healthy, 5 dead skipped by health check → 74 rows in 2.8s → $0.0014

That ~28% dead rate is real, which is why sources are health-checked at generation time and frozen into the app's manifest. All 16 of 16 seed mini apps in the registry are built by this exact pipeline — resolve, health-check, fan out, compose — not written by hand. Re-measuring every one of them costs $0.0084 total.

Three tiers, and depth encodes agency

analytics  ──────────────  monitoring  ──────────────  autonomous
read-only                  watches + alerts            holds a wallet, acts
sits flush                 lifts, live-blue rim        stands proud, orange rim

The tier is not decoration. An autonomous app always renders its policy strip, its kill switch and its trade log — you can see what a thing is allowed to do by looking at it. The shipped theme is skeuomorphic (Dieter Rams / Braun), and a single data-skin attribute re-expresses the whole system in a different material with no component changes, no catalog changes, no round trip to the agent. That theme swap is the proof that the generated UI is data, not code.

The agent cannot inject code

The model emits A v0.9.1 — a declarative document plus a data model. The client holds the approved component catalog and the document may only reference components by name. For a generated interface that can move money, that containment is the entire safety argument.

Form follows data, not keywords: a bounded ratio becomes a gauge, a ranked categorical becomes a leaderboard. The composer reads the shape of what came back, not words in the prompt.

When it acts, it acts under a gate it cannot talk its way past

Atlas autonomous loop: Substreams tick to policy gate to signed transaction

Polling a subgraph means an autonomous guard is up to five minutes late. For an app that displays, that's a preference. For one that spends, it's a correctness bug — so triggers run off a real Substreams gRPC subscription, one tick per block, each carrying the cursor the stream resumes from.

Four properties the design turns on:

  • A reorg is not an event. blockUndoSignal is journalled and rewinds; it never becomes a trigger signal. The right answer to "that block didn't happen" is to stop, not to act.
  • A replayed block does not trade twice. Substreams replays from a cursor by design after a disconnect, so ticks are keyed <block>:<hash> and the ledger dedupes.
  • The cursor is committed after the action, not before. At-least-once with an idempotent key beats at-most-once when the effect is a trade.
  • The metric is re-read server-side, per block, from the app's own health-checked sources. A client that could post healthFactor could make an autonomous app trade on demand.

The whole chain runs to a real transaction:

Arbitrum block 487540654 (real, off arb-one.streamingfast.io)
  → Substreams tick → trigger "healthFactor < 1.15" fired
  → POLICY OK  approve $25 → 0x036CbD53…  (allowlisted, under the $50/tx cap)
  → session key signed → tx 0x5a44e9d5…9d78
  → status success, block 44604106, 1 Approval event
  → allowance 25 USDC, read back off chain

V It is an approve, and it is described as one — granting the router an allowance is genuinely the first step of a swap, but it is not a swap, so approve is its own action kind rather than a swap the manifest lies about.

A name you can verify before you fund it

Atlas identity: ENS subname, Agentic ID and registry verifying each other

atlas-apps.eth is registered and wrapped on Sepolia and issues a subname per mini app. The ENS record asserts the Agentic ID; the registry on 0G Chain asserts the name; the ERC-7857 token stores the name it was minted against. mutuallyVerified is true only when all three agree, and it is read off the chain on every resolve, never assumed.

Eight subnames are issued; three mutually verify today, and the table in the README says exactly why each of the other five does not — four were published under a previous parent name and their tokens still assert it, and the name↔token binding is deliberately immutable, so renaming a parent cannot drag old tokens along. One (aave-guard-fork) is left broken on purpose as the artifact that exposed a swallowed revert in the fork-registration path.

The contenthash resolves without us: read contenthash off Sepolia, fetch the CID from ipfs.io, parse the manifest. Five of the eight issued names do that today. Three point at bytes that no longer exist anywhere, because they were published while the content store was a gitignored local directory — a CID is the hash of its bytes, so that is unrecoverable rather than pending, and it's stated as such rather than quietly dropped.

Also shipped

  • An MCP server at /api/mcp — the same URL every published app writes into its ENS agent-endpoint[mcp] record. Five read-only tools: list_schemas, plan_mini_app, query_graph, build_mini_app, resolve_mini_app. Nothing there signs or spends, because an MCP endpoint is a URL strangers point agents at.
  • A SKILL.md at the repo root so an agent can decide whether The Graph covers this at all before trying.
  • Policy enforced at the signer, not in the prompt. Four signer modes behind one interface; in smart-session mode (ERC-7579 + Rhinestone Smart Sessions) the allowlist, expiry and lifetime cap are enforced by the account itself. onchainEnforced is the return value of a live isSessionEnabled() call, never a constant, and the running session-eoa mode says plainly that every row is server-enforced.
  • 159 tests, 123 of them on the agency layer, including every policy rejection path.

What is deliberately not built

Named explicitly, because a vague scope claim is worse than a small one: a real swap from a stream trigger (the transaction is real, the swap is not built); per-account positions (the standardized fan-out reads protocol-level scalars, so healthFactor conditions evaluate false and are marked inert rather than shown as armed); per-app wallets (one session key currently signs for every app, and the autonomous panel says so); a shared browsable registry (the board is per-browser localStorage plus the measured seed snapshot); x402 receipts (implemented, never exercised — no key set); and Subgraph MCP consumption (the env var exists, nothing calls it, so it is a gap, not an integration).

The README marks every claim that was verified by reading it back from a chain or a gateway, and strikes through two claims that an audit found overstated rather than deleting them.

How it's Made

Stack. Next.js 16 (App Router) + React 19 + TypeScript, Tailwind v4 with shadcn/ui and Base UI, deployed on Vercel. Contracts are Solidity + Foundry on 0G Galileo (chain 16602). Chain access is viem end to end; Privy handles the human wallet; permissionless + @rhinestone/module-sdk handle the smart-account signer modes. Zod validates every boundary. No database — state is localStorage, IPFS via Pinata, a generated JSON snapshot, and the chains themselves.

The Graph is the load-bearing data source, three products deep.

Standardized Subgraphs. is a 96-entry registry — 86 verified deployment IDs across 11 schema families and 4 networks (Arbitrum One, Optimism, Base, Mainnet). resolves a question to families, not ids; fires one query shape at every healthy deployment and merges. Health checks live in with a 90s TTL and a 2s timeout, because a quarter of standardized deployments are dead at any moment and a demo that hides that is lying.

Substreams. is a real gRPC subscription (@substreams/core over Connect/HTTP-2) against arb-one.streamingfast.io, with a cursor store, <block>:<hash> dedupe keys, and blockUndoSignal handling. verifies it both directions — a breach run that fires and a control run over healthy blocks that fires nothing, because a harness that can only print ✅ proves nothing:

breach:  blocks 487508073 → 487508075 in 1.5s → TRIGGER fired → POLICY OK → journaled
control: blocks 487509578 → 487509580, healthy throughout → 0 firings

x402. implements keyless per-query payment by hand rather than via the client package: POST unpaid, parse the real 402 challenge, answer it with an EIP-3009 signature at the published ~$0.01/query. This is the mechanism that makes forking coherent — a forked app pays with its own wallet instead of inheriting a shared API key. X402_PRIVATE_KEY is unset, so no query has actually been paid this way, and the submission says so rather than implying a receipt exists.

0G does the inference and the identity. Planning and UI composition run on 0G Private Computer (router-api.0g.ai/v1, model 0gm-1.0-35b-a3b) pinned to verified routing in — the router's default Standard mode spans unverified community channels, where verify_tee can return no attestation at all. Proof of inference is a chain, not a screenshot:

TEE run (tee_verified: true) → request id
  → manifest.provenance.attestationRef   0g://f1ade7e8-e9ca-4fb2-b5c9-79cd400e3195
  → keccak256                            0xa651240d…52f1
  → MiniAppRegistry.attestationHash      ← same value, onchain, bound to token and name

implements the ERC-7857 V2 eip-7857-draft interface properly rather than shaped-alike: AES-256-GCM metadata encryption with dataHash = keccak256(ciphertext), ECDSA proofs the onchain verifier actually accepts, receiver binding, stale-metadata rejection, single-use 48-byte nonces. The reference verifier ships two stubs (verifyPreimage returns true for any blob; the TEE check is a // TODO) and neither was copied.

ENS is the identity layer, not a label. writes addr, contenthash, ENSIP-26 agent-context and agent-endpoint[web|mcp], and the ENSIP-25 agent-registration[<erc7930>][<agentId>] binding, with the ERC-7930 chain-address encoding verified against ENSIP-25's own worked example. reads the 0G side of the binding back and mutuallyVerified is the conjunction of all three assertions.

The hacky parts worth naming

  • ENS on Sepolia does not work the documented way. ETHRegistrarController is not an authorised controller there, so register() reverts while available() still returns true — a silent dead end. The path that works is TestnetV1PremigrationRegistrar (0xdf60C561Ca35AD3C89D24BbA854654b1c3477078), free, one transaction, data: []. Then the name comes back unwrapped, so NameWrapper.setSubnodeRecord reverts Unauthorised until you setApprovalForAll + wrapETH2LD. That sequence is the single largest time sink of the build and it is written down in so the next team doesn't lose the same day.
  • viem's getEnsResolver returns a non-resolver on Sepolia. For one of our names it answers 0x422484c2…, where every addr/text/contenthash call reverts; the registry's own resolver(node) answers 0xE99638b4…, which holds the records. We ask the registry first and keep the UniversalResolver path second for wildcard/CCIP names.
  • Reading ENS was gated on a write credential. resolveRegistrarMode() degrades to mock without a registrar private key, and readRecords skipped the resolver entirely in mock mode — so the public deployment resolved nothing while working perfectly on the machine holding the key. Reading needs an RPC and nothing else. Same shape as the IPFS bug below: "it resolves on my machine" is not a property of a decentralized record.
  • Pinata returns a different CID for the same bytes. pinJSONToIPFS wraps a document as UnixFS and returns a dag-pb CID (bafybei…) while local mode addresses the raw block (bafkrei…). Enabling Pinata naively would have minted new addresses and orphaned every contenthash already written to Sepolia. pinFileToIPFS with cidVersion: 1 reproduces the local CID exactly, and now asserts it per pin: if Pinata ever returns a CID we did not derive, the publish fails rather than writing an unverifiable address onchain.
  • A swallowed revert made every published fork unverifiable. MiniAppRegistry.registerFork reverts ParentUnknown when the parent was never published — correct, so lineage can't be faked. But the publish path passed a parent key whenever a manifest carried forkedFrom, and every bundled app is unpublished. So: ENS written, token minted, registerFork reverts, caught as a generic "registry write failed" — and the 0G half of the mutual proof silently missing. Fixed by asking the contract's own get(parentKey) first and registering without the lineage link when the parent is genuinely absent, surfacing lineageSkipped in the publish warnings. Losing attribution is recoverable from the manifest; losing the registration costs the safety primitive a stranger uses before funding a name.
  • Live data broke four things fixtures had hidden. Placeholder GraphQL in seed manifests was being treated as planner intent and degraded every single-schema app (3 rows where the real query returns 36). [email protected] denominates in ETH — cumulativeTradeVolumeUSD does not exist on Marketplace, so asking for it is a hard error that zeroed the whole family. A broken SushiSwap price feed put $72384163253T at the top of a leaderboard because the shape detector re-sorted and undid the suspect ranking. And isActive: true — the normal state of every healthy market — rendered "Is Active has fired" in an alert banner on a wallet-holding app. pins all four.
  • A fabricated ticker used to invent money, and it is gone. A client-side interval made up a block number, a tx hash and a dollar amount every 4.2 seconds and wrote them into stats the board presented as value moved. Deleted. spentUsd now comes from the same server-side number the lifetime cap is metered against, so $0.00 is a measurement. valueTransactedUsd had no writer once the ticker went, so it renders nowhere — a figure with no writer is worse than a missing one, because it looks like a measurement.
  • Seed triggers that could never fire were being drawn as armed. The condition grammar was extracted into (it imports nothing) and gained isConditionEvaluable — the same grammar the evaluator uses, asked a different question. Applying it to the seed apps found zero of ten evaluable: four grammatically valid on impossible paths, five unparseable prose, one null. All ten were rewritten to protocol-level premises the fan-out genuinely answers, and one that would have alerted on the first block it saw was re-armed with measured headroom.
  • Deploying to 0G Galileo needs --priority-gas-price 2gwei. The chain enforces a 2 gwei floor and rejects forge's default estimate outright.
  • pnpm test runs the typecheck first, on purpose. tsx strips types and runs; it never type-checks, so the suite alone goes green on code that does not compile.
background image mobile

Join the mailing list

Get the latest news and updates

atlas | ETHGlobal